💄 Better posting with attachments
This commit is contained in:
parent
d4538a9ef6
commit
bdb602c8c6
@ -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({
|
||||||
|
@ -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 {
|
||||||
|
@ -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,14 +81,22 @@ class PostComposeScreen extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (atk == null) throw ArgumentError('Access token is null');
|
if (atk == null) throw ArgumentError('Access token is null');
|
||||||
|
try {
|
||||||
attachmentProgress.value = {...attachmentProgress.value, index: 0};
|
attachmentProgress.value = {...attachmentProgress.value, index: 0};
|
||||||
final cloudFile =
|
final cloudFile =
|
||||||
await putMediaToCloud(
|
await putMediaToCloud(
|
||||||
fileData: attachment,
|
fileData: attachment,
|
||||||
atk: atk,
|
atk: atk,
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
filename: attachment.name ?? 'Post media',
|
filename: attachment.data.name ?? 'Post media',
|
||||||
mimetype: attachment.mimeType ?? 'image/jpeg',
|
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) {
|
onProgress: (progress, estimate) {
|
||||||
attachmentProgress.value = {
|
attachmentProgress.value = {
|
||||||
...attachmentProgress.value,
|
...attachmentProgress.value,
|
||||||
@ -83,16 +108,20 @@ class PostComposeScreen extends HookConsumerWidget {
|
|||||||
throw ArgumentError('Failed to upload the file...');
|
throw ArgumentError('Failed to upload the file...');
|
||||||
}
|
}
|
||||||
final clone = List.of(attachments.value);
|
final clone = List.of(attachments.value);
|
||||||
clone[index] = cloudFile;
|
clone[index] = UniversalFile(data: cloudFile, type: attachment.type);
|
||||||
attachments.value = clone;
|
attachments.value = clone;
|
||||||
|
} catch (err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
} finally {
|
||||||
attachmentProgress.value = attachmentProgress.value..remove(index);
|
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)),
|
||||||
],
|
],
|
||||||
|
Loading…
x
Reference in New Issue
Block a user