💄 Better posting with attachments

This commit is contained in:
LittleSheep 2025-04-26 16:11:09 +08:00
parent d4538a9ef6
commit bdb602c8c6
3 changed files with 263 additions and 50 deletions

View File

@ -3,6 +3,21 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'file.freezed.dart'; part 'file.freezed.dart';
part 'file.g.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 @freezed
abstract class SnCloudFile with _$SnCloudFile { abstract class SnCloudFile with _$SnCloudFile {
const factory SnCloudFile({ const factory SnCloudFile({

View File

@ -12,6 +12,136 @@ part of 'file.dart';
// dart format off // dart format off
T _$identity<T>(T value) => value; T _$identity<T>(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<UniversalFile> get copyWith => _$UniversalFileCopyWithImpl<UniversalFile>(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 /// @nodoc
mixin _$SnCloudFile { mixin _$SnCloudFile {

View File

@ -37,16 +37,33 @@ class PostComposeScreen extends HookConsumerWidget {
}, [publishers]); }, [publishers]);
// Contains the XFile, ByteData, or SnCloudFile // Contains the XFile, ByteData, or SnCloudFile
final attachments = useState<List<dynamic>>([]); final attachments = useState<List<UniversalFile>>([]);
final contentController = useTextEditingController(); final contentController = useTextEditingController();
final titleController = useTextEditingController();
final descriptionController = useTextEditingController();
final submitting = useState(false); final submitting = useState(false);
Future<void> pickAttachment() async { Future<void> pickPhotoMedia() async {
final result = await ref final result = await ref
.watch(imagePickerProvider) .watch(imagePickerProvider)
.pickMultipleMedia(requestFullMetadata: true); .pickMultiImage(requestFullMetadata: true);
attachments.value = [...attachments.value, ...result]; attachments.value = [
...attachments.value,
...result.map(
(e) => UniversalFile(data: e, type: UniversalFileType.image),
),
];
}
Future<void> 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<Map<int, double>>({}); final attachmentProgress = useState<Map<int, double>>({});
@ -64,35 +81,47 @@ class PostComposeScreen extends HookConsumerWidget {
}, },
); );
if (atk == null) throw ArgumentError('Access token is null'); if (atk == null) throw ArgumentError('Access token is null');
attachmentProgress.value = {...attachmentProgress.value, index: 0}; try {
final cloudFile = attachmentProgress.value = {...attachmentProgress.value, index: 0};
await putMediaToCloud( final cloudFile =
fileData: attachment, await putMediaToCloud(
atk: atk, fileData: attachment,
baseUrl: baseUrl, atk: atk,
filename: attachment.name ?? 'Post media', baseUrl: baseUrl,
mimetype: attachment.mimeType ?? 'image/jpeg', filename: attachment.data.name ?? 'Post media',
onProgress: (progress, estimate) { mimetype:
attachmentProgress.value = { attachment.data.mimeType ??
...attachmentProgress.value, switch (attachment.type) {
index: progress, UniversalFileType.image => 'image/unknown',
}; UniversalFileType.video => 'video/unknown',
}, UniversalFileType.audio => 'audio/unknown',
).future; UniversalFileType.file => 'application/octet-stream',
if (cloudFile == null) { },
throw ArgumentError('Failed to upload the file...'); 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<void> deleteAttachment(int index) async { Future<void> deleteAttachment(int index) async {
final attachment = attachments.value[index]; final attachment = attachments.value[index];
if (attachment is SnCloudFile) { if (attachment.isOnCloud) {
final client = ref.watch(apiClientProvider); final client = ref.watch(apiClientProvider);
await client.delete('/files/${attachment.id}'); await client.delete('/files/${attachment.data.id}');
} }
final clone = List.of(attachments.value); final clone = List.of(attachments.value);
clone.removeAt(index); clone.removeAt(index);
@ -100,17 +129,13 @@ class PostComposeScreen extends HookConsumerWidget {
} }
Future<void> performAction() async { Future<void> performAction() async {
if (!contentController.text.isNotEmpty) {
return;
}
try { try {
submitting.value = true; submitting.value = true;
await Future.wait( await Future.wait(
attachments.value attachments.value
.where((e) => e is! SnCloudFile) .where((e) => e.isOnDevice)
.map((e) => uploadAttachment(e)), .map((e) => uploadAttachment(e.data)),
); );
final client = ref.watch(apiClientProvider); final client = ref.watch(apiClientProvider);
@ -120,8 +145,8 @@ class PostComposeScreen extends HookConsumerWidget {
'content': contentController.text, 'content': contentController.text,
'attachments': 'attachments':
attachments.value attachments.value
.whereType<SnCloudFile>() .where((e) => e.isOnCloud)
.map((e) => e.id) .map((e) => e.data.id)
.toList(), .toList(),
}, },
); );
@ -141,7 +166,17 @@ class PostComposeScreen extends HookConsumerWidget {
actions: [ actions: [
IconButton( IconButton(
onPressed: submitting.value ? null : performAction, 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), const Gap(8),
], ],
@ -163,6 +198,27 @@ class PostComposeScreen extends HookConsumerWidget {
padding: EdgeInsets.symmetric(vertical: 16), padding: EdgeInsets.symmetric(vertical: 16),
child: Column( child: Column(
children: [ 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( TextField(
controller: contentController, controller: contentController,
decoration: InputDecoration.collapsed( decoration: InputDecoration.collapsed(
@ -215,10 +271,15 @@ class PostComposeScreen extends HookConsumerWidget {
child: Row( child: Row(
children: [ children: [
IconButton( IconButton(
onPressed: pickAttachment, onPressed: pickPhotoMedia,
icon: const Icon(LucideIcons.imagePlus), icon: const Icon(LucideIcons.imagePlus),
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
IconButton(
onPressed: pickVideoMedia,
icon: const Icon(LucideIcons.fileVideo2),
color: Theme.of(context).colorScheme.primary,
),
], ],
).padding( ).padding(
bottom: MediaQuery.of(context).padding.bottom, bottom: MediaQuery.of(context).padding.bottom,
@ -233,13 +294,13 @@ class PostComposeScreen extends HookConsumerWidget {
} }
class _AttachmentPreview extends StatelessWidget { class _AttachmentPreview extends StatelessWidget {
final dynamic item; final UniversalFile item;
final double? progress; final double? progress;
final Function(int)? onMove; final Function(int)? onMove;
final Function? onDelete; final Function? onDelete;
final Function? onRequestUpload; final Function? onRequestUpload;
const _AttachmentPreview({ const _AttachmentPreview({
this.item, required this.item,
this.progress, this.progress,
this.onRequestUpload, this.onRequestUpload,
this.onMove, this.onMove,
@ -259,20 +320,30 @@ class _AttachmentPreview extends StatelessWidget {
color: Theme.of(context).colorScheme.surfaceContainerHigh, color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Builder( child: Builder(
builder: (context) { builder: (context) {
if (item is SnCloudFile) { if (item.isOnCloud) {
return CloudFileWidget(item: item); return CloudFileWidget(item: item.data);
} else if (item is XFile) { } else if (item.data is XFile) {
if (item.mimeType?.startsWith('image') ?? false) { if (item.type == UniversalFileType.image) {
return Image.file(File(item.path)); return Image.file(File(item.data.path));
} else { } else {
return Center( return Center(
child: Text( child: Text(
'Preview is not supported for ${item.mimeType}', 'Preview is not supported for ${item.type}',
textAlign: TextAlign.center,
), ),
); );
} }
} else if (item is List<int> || item is Uint8List) { } else if (item is List<int> || 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(); return Placeholder();
}, },
@ -287,10 +358,7 @@ class _AttachmentPreview extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Text( Text('Uploading', style: TextStyle(color: Colors.white)),
'Uploading...',
style: TextStyle(color: Colors.white),
),
Gap(4), Gap(4),
Center(child: LinearProgressIndicator(value: progress)), Center(child: LinearProgressIndicator(value: progress)),
], ],