diff --git a/lib/models/file.dart b/lib/models/file.dart index 3b60fcf..75dc15b 100644 --- a/lib/models/file.dart +++ b/lib/models/file.dart @@ -3,6 +3,21 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'file.freezed.dart'; part 'file.g.dart'; +enum UniversalFileType { image, video, audio, file } + +@freezed +abstract class UniversalFile with _$UniversalFile { + const UniversalFile._(); + + const factory UniversalFile({ + required dynamic data, + required UniversalFileType type, + }) = _UniversalFile; + + bool get isOnCloud => data is SnCloudFile; + bool get isOnDevice => !isOnCloud; +} + @freezed abstract class SnCloudFile with _$SnCloudFile { const factory SnCloudFile({ diff --git a/lib/models/file.freezed.dart b/lib/models/file.freezed.dart index ef20055..db3aeba 100644 --- a/lib/models/file.freezed.dart +++ b/lib/models/file.freezed.dart @@ -12,6 +12,136 @@ part of 'file.dart'; // dart format off T _$identity(T value) => value; +/// @nodoc +mixin _$UniversalFile { + + dynamic get data; UniversalFileType get type; +/// Create a copy of UniversalFile +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$UniversalFileCopyWith get copyWith => _$UniversalFileCopyWithImpl(this as UniversalFile, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type); + +@override +String toString() { + return 'UniversalFile(data: $data, type: $type)'; +} + + +} + +/// @nodoc +abstract mixin class $UniversalFileCopyWith<$Res> { + factory $UniversalFileCopyWith(UniversalFile value, $Res Function(UniversalFile) _then) = _$UniversalFileCopyWithImpl; +@useResult +$Res call({ + dynamic data, UniversalFileType type +}); + + + + +} +/// @nodoc +class _$UniversalFileCopyWithImpl<$Res> + implements $UniversalFileCopyWith<$Res> { + _$UniversalFileCopyWithImpl(this._self, this._then); + + final UniversalFile _self; + final $Res Function(UniversalFile) _then; + +/// Create a copy of UniversalFile +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? data = freezed,Object? type = null,}) { + return _then(_self.copyWith( +data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable +as dynamic,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as UniversalFileType, + )); +} + +} + + +/// @nodoc + + +class _UniversalFile extends UniversalFile { + const _UniversalFile({required this.data, required this.type}): super._(); + + +@override final dynamic data; +@override final UniversalFileType type; + +/// Create a copy of UniversalFile +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$UniversalFileCopyWith<_UniversalFile> get copyWith => __$UniversalFileCopyWithImpl<_UniversalFile>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type); + +@override +String toString() { + return 'UniversalFile(data: $data, type: $type)'; +} + + +} + +/// @nodoc +abstract mixin class _$UniversalFileCopyWith<$Res> implements $UniversalFileCopyWith<$Res> { + factory _$UniversalFileCopyWith(_UniversalFile value, $Res Function(_UniversalFile) _then) = __$UniversalFileCopyWithImpl; +@override @useResult +$Res call({ + dynamic data, UniversalFileType type +}); + + + + +} +/// @nodoc +class __$UniversalFileCopyWithImpl<$Res> + implements _$UniversalFileCopyWith<$Res> { + __$UniversalFileCopyWithImpl(this._self, this._then); + + final _UniversalFile _self; + final $Res Function(_UniversalFile) _then; + +/// Create a copy of UniversalFile +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? data = freezed,Object? type = null,}) { + return _then(_UniversalFile( +data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable +as dynamic,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as UniversalFileType, + )); +} + + +} + /// @nodoc mixin _$SnCloudFile { diff --git a/lib/screens/posts/compose.dart b/lib/screens/posts/compose.dart index f67b7e2..c16841f 100644 --- a/lib/screens/posts/compose.dart +++ b/lib/screens/posts/compose.dart @@ -37,16 +37,33 @@ class PostComposeScreen extends HookConsumerWidget { }, [publishers]); // Contains the XFile, ByteData, or SnCloudFile - final attachments = useState>([]); + final attachments = useState>([]); final contentController = useTextEditingController(); + final titleController = useTextEditingController(); + final descriptionController = useTextEditingController(); final submitting = useState(false); - Future pickAttachment() async { + Future pickPhotoMedia() async { final result = await ref .watch(imagePickerProvider) - .pickMultipleMedia(requestFullMetadata: true); - attachments.value = [...attachments.value, ...result]; + .pickMultiImage(requestFullMetadata: true); + attachments.value = [ + ...attachments.value, + ...result.map( + (e) => UniversalFile(data: e, type: UniversalFileType.image), + ), + ]; + } + + Future pickVideoMedia() async { + final result = await ref + .watch(imagePickerProvider) + .pickVideo(source: ImageSource.gallery); + attachments.value = [ + ...attachments.value, + UniversalFile(data: result, type: UniversalFileType.video), + ]; } final attachmentProgress = useState>({}); @@ -64,35 +81,47 @@ class PostComposeScreen extends HookConsumerWidget { }, ); 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...'); + try { + attachmentProgress.value = {...attachmentProgress.value, index: 0}; + final cloudFile = + await putMediaToCloud( + fileData: attachment, + atk: atk, + baseUrl: baseUrl, + filename: attachment.data.name ?? 'Post media', + mimetype: + attachment.data.mimeType ?? + switch (attachment.type) { + UniversalFileType.image => 'image/unknown', + UniversalFileType.video => 'video/unknown', + UniversalFileType.audio => 'audio/unknown', + UniversalFileType.file => 'application/octet-stream', + }, + 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] = UniversalFile(data: cloudFile, type: attachment.type); + attachments.value = clone; + } catch (err) { + showErrorAlert(err); + } finally { + attachmentProgress.value = attachmentProgress.value..remove(index); } - 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) { + if (attachment.isOnCloud) { final client = ref.watch(apiClientProvider); - await client.delete('/files/${attachment.id}'); + await client.delete('/files/${attachment.data.id}'); } final clone = List.of(attachments.value); clone.removeAt(index); @@ -100,17 +129,13 @@ class PostComposeScreen extends HookConsumerWidget { } Future performAction() async { - if (!contentController.text.isNotEmpty) { - return; - } - try { submitting.value = true; await Future.wait( attachments.value - .where((e) => e is! SnCloudFile) - .map((e) => uploadAttachment(e)), + .where((e) => e.isOnDevice) + .map((e) => uploadAttachment(e.data)), ); final client = ref.watch(apiClientProvider); @@ -120,8 +145,8 @@ class PostComposeScreen extends HookConsumerWidget { 'content': contentController.text, 'attachments': attachments.value - .whereType() - .map((e) => e.id) + .where((e) => e.isOnCloud) + .map((e) => e.data.id) .toList(), }, ); @@ -141,7 +166,17 @@ class PostComposeScreen extends HookConsumerWidget { actions: [ IconButton( onPressed: submitting.value ? null : performAction, - icon: const Icon(LucideIcons.upload), + icon: + submitting.value + ? SizedBox( + width: 28, + height: 28, + child: const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2.5, + ), + ).center() + : const Icon(LucideIcons.upload), ), const Gap(8), ], @@ -163,6 +198,27 @@ class PostComposeScreen extends HookConsumerWidget { padding: EdgeInsets.symmetric(vertical: 16), child: Column( children: [ + TextField( + controller: titleController, + decoration: InputDecoration.collapsed( + hintText: 'Title', + ), + style: TextStyle(fontSize: 20), + onTapOutside: + (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ), + TextField( + controller: descriptionController, + decoration: InputDecoration.collapsed( + hintText: 'Description', + ), + style: TextStyle(fontSize: 18), + onTapOutside: + (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ), + const Gap(12), TextField( controller: contentController, decoration: InputDecoration.collapsed( @@ -215,10 +271,15 @@ class PostComposeScreen extends HookConsumerWidget { child: Row( children: [ IconButton( - onPressed: pickAttachment, + onPressed: pickPhotoMedia, icon: const Icon(LucideIcons.imagePlus), color: Theme.of(context).colorScheme.primary, ), + IconButton( + onPressed: pickVideoMedia, + icon: const Icon(LucideIcons.fileVideo2), + color: Theme.of(context).colorScheme.primary, + ), ], ).padding( bottom: MediaQuery.of(context).padding.bottom, @@ -233,13 +294,13 @@ class PostComposeScreen extends HookConsumerWidget { } class _AttachmentPreview extends StatelessWidget { - final dynamic item; + final UniversalFile item; final double? progress; final Function(int)? onMove; final Function? onDelete; final Function? onRequestUpload; const _AttachmentPreview({ - this.item, + required this.item, this.progress, this.onRequestUpload, this.onMove, @@ -259,20 +320,30 @@ class _AttachmentPreview extends StatelessWidget { 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)); + if (item.isOnCloud) { + return CloudFileWidget(item: item.data); + } else if (item.data is XFile) { + if (item.type == UniversalFileType.image) { + return Image.file(File(item.data.path)); } else { return Center( child: Text( - 'Preview is not supported for ${item.mimeType}', + 'Preview is not supported for ${item.type}', + textAlign: TextAlign.center, ), ); } } else if (item is List || item is Uint8List) { - return Image.memory(item); + if (item.type == UniversalFileType.image) { + return Image.memory(item.data); + } else { + return Center( + child: Text( + 'Preview is not supported for ${item.type}', + textAlign: TextAlign.center, + ), + ); + } } return Placeholder(); }, @@ -287,10 +358,7 @@ class _AttachmentPreview extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ - Text( - 'Uploading...', - style: TextStyle(color: Colors.white), - ), + Text('Uploading', style: TextStyle(color: Colors.white)), Gap(4), Center(child: LinearProgressIndicator(value: progress)), ],