diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d7a8b1f..ce149b9 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -8,23 +8,22 @@ plugins { android { namespace = "dev.solsynth.solian" compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion + ndkVersion = "27.0.12077973" compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() + jvmTarget = JavaVersion.VERSION_17.toString() } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "dev.solsynth.solian" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + minSdk = 26 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 06a6d44..53c8ea2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,12 +1,29 @@ + + + + + + + + + + + + + + + + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> (T value) => value; /// @nodoc mixin _$SnCloudFile { - String get id; String get name; String? get description; Map? get fileMeta; Map? get userMeta; String? get mimeType; String? get hash; int get size; DateTime get uploadedAt; String? get uploadedTo; int get usedCount; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; + String get id; String get name; String? get description; Map? get fileMeta; Map? get userMeta; String? get mimeType; String? get hash; int get size; DateTime? get uploadedAt; String? get uploadedTo; int get usedCount; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; /// Create a copy of SnCloudFile /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -49,7 +49,7 @@ abstract mixin class $SnCloudFileCopyWith<$Res> { factory $SnCloudFileCopyWith(SnCloudFile value, $Res Function(SnCloudFile) _then) = _$SnCloudFileCopyWithImpl; @useResult $Res call({ - String id, String name, String? description, Map? fileMeta, Map? userMeta, String? mimeType, String? hash, int size, DateTime uploadedAt, String? uploadedTo, int usedCount, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt + String id, String name, String? description, Map? fileMeta, Map? userMeta, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, int usedCount, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt }); @@ -66,7 +66,7 @@ class _$SnCloudFileCopyWithImpl<$Res> /// Create a copy of SnCloudFile /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = null,Object? uploadedTo = freezed,Object? usedCount = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,Object? usedCount = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { return _then(_self.copyWith( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable @@ -76,8 +76,8 @@ as Map?,userMeta: freezed == userMeta ? _self.userMeta : userMe as Map?,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable as String?,hash: freezed == hash ? _self.hash : hash // ignore: cast_nullable_to_non_nullable as String?,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable -as int,uploadedAt: null == uploadedAt ? _self.uploadedAt : uploadedAt // ignore: cast_nullable_to_non_nullable -as DateTime,uploadedTo: freezed == uploadedTo ? _self.uploadedTo : uploadedTo // ignore: cast_nullable_to_non_nullable +as int,uploadedAt: freezed == uploadedAt ? _self.uploadedAt : uploadedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,uploadedTo: freezed == uploadedTo ? _self.uploadedTo : uploadedTo // ignore: cast_nullable_to_non_nullable as String?,usedCount: null == usedCount ? _self.usedCount : usedCount // ignore: cast_nullable_to_non_nullable as int,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable @@ -120,7 +120,7 @@ class _SnCloudFile implements SnCloudFile { @override final String? mimeType; @override final String? hash; @override final int size; -@override final DateTime uploadedAt; +@override final DateTime? uploadedAt; @override final String? uploadedTo; @override final int usedCount; @override final DateTime createdAt; @@ -160,7 +160,7 @@ abstract mixin class _$SnCloudFileCopyWith<$Res> implements $SnCloudFileCopyWith factory _$SnCloudFileCopyWith(_SnCloudFile value, $Res Function(_SnCloudFile) _then) = __$SnCloudFileCopyWithImpl; @override @useResult $Res call({ - String id, String name, String? description, Map? fileMeta, Map? userMeta, String? mimeType, String? hash, int size, DateTime uploadedAt, String? uploadedTo, int usedCount, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt + String id, String name, String? description, Map? fileMeta, Map? userMeta, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, int usedCount, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt }); @@ -177,7 +177,7 @@ class __$SnCloudFileCopyWithImpl<$Res> /// Create a copy of SnCloudFile /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = null,Object? uploadedTo = freezed,Object? usedCount = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,Object? usedCount = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { return _then(_SnCloudFile( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable @@ -187,8 +187,8 @@ as Map?,userMeta: freezed == userMeta ? _self._userMeta : userM as Map?,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable as String?,hash: freezed == hash ? _self.hash : hash // ignore: cast_nullable_to_non_nullable as String?,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable -as int,uploadedAt: null == uploadedAt ? _self.uploadedAt : uploadedAt // ignore: cast_nullable_to_non_nullable -as DateTime,uploadedTo: freezed == uploadedTo ? _self.uploadedTo : uploadedTo // ignore: cast_nullable_to_non_nullable +as int,uploadedAt: freezed == uploadedAt ? _self.uploadedAt : uploadedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,uploadedTo: freezed == uploadedTo ? _self.uploadedTo : uploadedTo // ignore: cast_nullable_to_non_nullable as String?,usedCount: null == usedCount ? _self.usedCount : usedCount // ignore: cast_nullable_to_non_nullable as int,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable diff --git a/lib/models/file.g.dart b/lib/models/file.g.dart index 41a0216..d5ce020 100644 --- a/lib/models/file.g.dart +++ b/lib/models/file.g.dart @@ -15,7 +15,10 @@ _SnCloudFile _$SnCloudFileFromJson(Map json) => _SnCloudFile( mimeType: json['mime_type'] as String?, hash: json['hash'] as String?, size: (json['size'] as num).toInt(), - uploadedAt: DateTime.parse(json['uploaded_at'] as String), + uploadedAt: + json['uploaded_at'] == null + ? null + : DateTime.parse(json['uploaded_at'] as String), uploadedTo: json['uploaded_to'] as String?, usedCount: (json['used_count'] as num).toInt(), createdAt: DateTime.parse(json['created_at'] as String), @@ -36,7 +39,7 @@ Map _$SnCloudFileToJson(_SnCloudFile instance) => 'mime_type': instance.mimeType, 'hash': instance.hash, 'size': instance.size, - 'uploaded_at': instance.uploadedAt.toIso8601String(), + 'uploaded_at': instance.uploadedAt?.toIso8601String(), 'uploaded_to': instance.uploadedTo, 'used_count': instance.usedCount, 'created_at': instance.createdAt.toIso8601String(), diff --git a/lib/pods/theme.dart b/lib/pods/theme.dart index c7b3a2c..0380245 100644 --- a/lib/pods/theme.dart +++ b/lib/pods/theme.dart @@ -121,8 +121,8 @@ Future createAppTheme( TargetPlatform.windows: ZoomPageTransitionsBuilder(), }, ), - progressIndicatorTheme: ProgressIndicatorThemeData(), - sliderTheme: SliderThemeData(), + progressIndicatorTheme: ProgressIndicatorThemeData(year2023: false), + sliderTheme: SliderThemeData(year2023: false), ); } diff --git a/lib/screens/posts/compose.dart b/lib/screens/posts/compose.dart index 9d72d0b..f67b7e2 100644 --- a/lib/screens/posts/compose.dart +++ b/lib/screens/posts/compose.dart @@ -1,12 +1,18 @@ -import 'package:auto_route/annotations.dart'; +import 'dart:io'; +import 'dart:typed_data'; + import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:island/models/file.dart'; import 'package:island/models/post.dart'; +import 'package:island/pods/config.dart'; import 'package:island/pods/network.dart'; import 'package:island/screens/account/me/publishers.dart'; +import 'package:island/services/file.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/content/cloud_files.dart'; @@ -30,10 +36,69 @@ class PostComposeScreen extends HookConsumerWidget { return null; }, [publishers]); + // Contains the XFile, ByteData, or SnCloudFile + final attachments = useState>([]); final contentController = useTextEditingController(); final submitting = useState(false); + Future pickAttachment() async { + final result = await ref + .watch(imagePickerProvider) + .pickMultipleMedia(requestFullMetadata: true); + attachments.value = [...attachments.value, ...result]; + } + + final attachmentProgress = useState>({}); + + Future uploadAttachment(int index) async { + final attachment = attachments.value[index]; + if (attachment is SnCloudFile) return; + final baseUrl = ref.watch(serverUrlProvider); + final atk = await getFreshAtk( + ref.watch(tokenPairProvider), + baseUrl, + onRefreshed: (atk, rtk) { + setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk); + ref.invalidate(tokenPairProvider); + }, + ); + if (atk == null) throw ArgumentError('Access token is null'); + attachmentProgress.value = {...attachmentProgress.value, index: 0}; + final cloudFile = + await putMediaToCloud( + fileData: attachment, + atk: atk, + baseUrl: baseUrl, + filename: attachment.name ?? 'Post media', + mimetype: attachment.mimeType ?? 'image/jpeg', + onProgress: (progress, estimate) { + attachmentProgress.value = { + ...attachmentProgress.value, + index: progress, + }; + }, + ).future; + if (cloudFile == null) { + throw ArgumentError('Failed to upload the file...'); + } + final clone = List.of(attachments.value); + clone[index] = cloudFile; + attachments.value = clone; + attachmentProgress.value = attachmentProgress.value..remove(index); + } + + Future deleteAttachment(int index) async { + final attachment = attachments.value[index]; + if (attachment is SnCloudFile) { + final client = ref.watch(apiClientProvider); + await client.delete('/files/${attachment.id}'); + } + final clone = List.of(attachments.value); + clone.removeAt(index); + attachments.value = clone; + } + Future performAction() async { if (!contentController.text.isNotEmpty) { return; @@ -41,8 +106,25 @@ class PostComposeScreen extends HookConsumerWidget { try { submitting.value = true; + + await Future.wait( + attachments.value + .where((e) => e is! SnCloudFile) + .map((e) => uploadAttachment(e)), + ); + final client = ref.watch(apiClientProvider); - await client.post('/posts', data: {'content': contentController.text}); + await client.post( + '/posts', + data: { + 'content': contentController.text, + 'attachments': + attachments.value + .whereType() + .map((e) => e.id) + .toList(), + }, + ); if (context.mounted) { context.maybePop(true); } @@ -75,23 +157,258 @@ class PostComposeScreen extends HookConsumerWidget { ProfilePictureWidget( item: currentPublisher.value?.picture, radius: 24, - ), + ).padding(top: 16), Expanded( - child: TextField( - controller: contentController, - decoration: InputDecoration.collapsed( - hintText: 'What\'s happened?!', + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(vertical: 16), + child: Column( + children: [ + TextField( + controller: contentController, + decoration: InputDecoration.collapsed( + hintText: 'What\'s happened?!', + ), + maxLines: null, + onTapOutside: + (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ), + const Gap(8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + for ( + var idx = 0; + idx < attachments.value.length; + idx++ + ) + _AttachmentPreview( + item: attachments.value[idx], + progress: attachmentProgress.value[idx], + onRequestUpload: () => uploadAttachment(idx), + onDelete: () => deleteAttachment(idx), + onMove: (delta) { + if (idx + delta < 0 || + idx + delta >= attachments.value.length) { + return; + } + final clone = List.of(attachments.value); + clone.insert( + idx + delta, + clone.removeAt(idx), + ); + attachments.value = clone; + }, + ), + ], + ), + ], ), - maxLines: null, - onTapOutside: - (_) => FocusManager.instance.primaryFocus?.unfocus(), ), ), ], - ).padding(all: 16), + ).padding(horizontal: 16), + ), + Material( + elevation: 2, + child: Row( + children: [ + IconButton( + onPressed: pickAttachment, + icon: const Icon(LucideIcons.imagePlus), + color: Theme.of(context).colorScheme.primary, + ), + ], + ).padding( + bottom: MediaQuery.of(context).padding.bottom, + horizontal: 16, + top: 8, + ), ), ], ), ); } } + +class _AttachmentPreview extends StatelessWidget { + final dynamic item; + final double? progress; + final Function(int)? onMove; + final Function? onDelete; + final Function? onRequestUpload; + const _AttachmentPreview({ + this.item, + this.progress, + this.onRequestUpload, + this.onMove, + this.onDelete, + }); + + @override + Widget build(BuildContext context) { + return AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Stack( + fit: StackFit.expand, + children: [ + Container( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + child: Builder( + builder: (context) { + if (item is SnCloudFile) { + return CloudFileWidget(item: item); + } else if (item is XFile) { + if (item.mimeType?.startsWith('image') ?? false) { + return Image.file(File(item.path)); + } else { + return Center( + child: Text( + 'Preview is not supported for ${item.mimeType}', + ), + ); + } + } else if (item is List || item is Uint8List) { + return Image.memory(item); + } + return Placeholder(); + }, + ), + ), + if (progress != null) + Positioned.fill( + child: Container( + color: Colors.black.withOpacity(0.3), + padding: EdgeInsets.symmetric(horizontal: 40, vertical: 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + 'Uploading...', + style: TextStyle(color: Colors.white), + ), + Gap(4), + Center(child: LinearProgressIndicator(value: progress)), + ], + ), + ), + ), + Positioned( + left: 8, + top: 8, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + color: Colors.black.withOpacity(0.5), + child: Material( + color: Colors.transparent, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + borderRadius: BorderRadius.circular(8), + child: const Icon( + LucideIcons.trash, + size: 14, + color: Colors.white, + ).padding(horizontal: 8, vertical: 6), + onTap: () { + onDelete?.call(); + }, + ), + SizedBox( + height: 26, + child: const VerticalDivider( + width: 0.3, + color: Colors.white, + thickness: 0.3, + ), + ).padding(horizontal: 2), + InkWell( + borderRadius: BorderRadius.circular(8), + child: const Icon( + LucideIcons.arrowUp, + size: 14, + color: Colors.white, + ).padding(horizontal: 8, vertical: 6), + onTap: () { + onMove?.call(-1); + }, + ), + InkWell( + borderRadius: BorderRadius.circular(8), + child: const Icon( + LucideIcons.arrowDown, + size: 14, + color: Colors.white, + ).padding(horizontal: 8, vertical: 6), + onTap: () { + onMove?.call(1); + }, + ), + ], + ), + ), + ), + ), + ), + Positioned( + top: 8, + right: 8, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => onRequestUpload?.call(), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + color: Colors.black.withOpacity(0.5), + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: + (item is SnCloudFile) + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.cloud, + size: 16, + color: Colors.white, + ), + const Gap(8), + Text( + 'On-cloud', + style: TextStyle(color: Colors.white), + ), + ], + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.cloudOff, + size: 16, + color: Colors.white, + ), + const Gap(8), + Text( + 'On-device', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/content/cloud_file_collection.dart b/lib/widgets/content/cloud_file_collection.dart index 36ed8f6..d07830b 100644 --- a/lib/widgets/content/cloud_file_collection.dart +++ b/lib/widgets/content/cloud_file_collection.dart @@ -44,7 +44,10 @@ class CloudFileList extends StatelessWidget { if (allImages) { return ConstrainedBox( - constraints: BoxConstraints(maxHeight: maxHeight), + constraints: BoxConstraints( + maxHeight: maxHeight, + minWidth: double.infinity, + ), child: AspectRatio( aspectRatio: calculateAspectRatio(), child: CarouselView( @@ -60,7 +63,10 @@ class CloudFileList extends StatelessWidget { } return ConstrainedBox( - constraints: BoxConstraints(maxHeight: maxHeight), + constraints: BoxConstraints( + maxHeight: maxHeight, + minWidth: double.infinity, + ), child: AspectRatio( aspectRatio: calculateAspectRatio(), child: ListView.separated( diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index dddb8a3..f8c8b25 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -6,6 +6,14 @@ com.apple.security.cs.allow-jit + com.apple.security.device.audio-input + + com.apple.security.files.downloads.read-write + + com.apple.security.files.user-selected.read-only + + com.apple.security.network.client + com.apple.security.network.server diff --git a/pubspec.lock b/pubspec.lock index 911df5b..20e1bb0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -774,7 +774,7 @@ packages: source: hosted version: "1.1.2" image_picker_android: - dependency: transitive + dependency: "direct main" description: name: image_picker_android sha256: "317a5d961cec5b34e777b9252393f2afbd23084aa6e60fcf601dcf6341b9ebeb" @@ -814,7 +814,7 @@ packages: source: hosted version: "0.2.1+2" image_picker_platform_interface: - dependency: transitive + dependency: "direct main" description: name: image_picker_platform_interface sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0" diff --git a/pubspec.yaml b/pubspec.yaml index 1e9bc00..51f52a1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -76,6 +76,8 @@ dependencies: image_picker: ^1.1.2 file_picker: ^10.1.2 riverpod_annotation: ^2.6.1 + image_picker_platform_interface: ^2.10.1 + image_picker_android: ^0.8.12+23 dev_dependencies: flutter_test: