From ef51948faddecefc10075406494ebcc96c16da29 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 10 Nov 2024 16:41:11 +0800 Subject: [PATCH] :sparkles: Post with attachment --- assets/translations/en-US.json | 1 + assets/translations/zh-CN.json | 1 + lib/screens/explore.dart | 7 +- lib/screens/post/post_editor.dart | 56 +++++++++++++-- lib/types/attachment.dart | 2 +- lib/types/attachment.freezed.dart | 68 +++++++++---------- lib/types/attachment.g.dart | 4 +- lib/widgets/attachment/attachment_item.dart | 6 +- lib/widgets/attachment/attachment_list.dart | 20 ++++-- lib/widgets/post/post_media_pending_list.dart | 67 ++++++++++++++++++ pubspec.lock | 8 +++ pubspec.yaml | 1 + 12 files changed, 191 insertions(+), 50 deletions(-) create mode 100644 lib/widgets/post/post_media_pending_list.dart diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 953f7c7..5a069ab 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -30,6 +30,7 @@ "create": "Create", "preview": "Preview", "loading": "Loading...", + "delete": "Delete", "fieldUsername": "Username", "fieldNickname": "Nickname", "fieldEmail": "Email address", diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index b1bf6a6..162865f 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -30,6 +30,7 @@ "apply": "应用", "create": "创建", "preview": "预览", + "delete": "删除", "fieldUsername": "用户名", "fieldNickname": "显示名", "fieldEmail": "电子邮箱地址", diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index cd63698..4f8bb2f 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -84,7 +84,12 @@ class _ExploreScreenState extends State { distance: 75, type: ExpandableFabType.up, childrenAnimation: ExpandableFabAnimation.none, - overlayStyle: ExpandableFabOverlayStyle(blur: 10), + overlayStyle: ExpandableFabOverlayStyle( + color: Theme.of(context) + .colorScheme + .surface + .withAlpha((255 * 0.5).round()), + ), openButtonBuilder: RotateFloatingActionButtonBuilder( child: const Icon(Symbols.add, size: 28), fabSize: ExpandableFabSize.regular, diff --git a/lib/screens/post/post_editor.dart b/lib/screens/post/post_editor.dart index dcade82..3e6a0c4 100644 --- a/lib/screens/post/post_editor.dart +++ b/lib/screens/post/post_editor.dart @@ -4,15 +4,19 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:material_symbols_icons/symbols.dart'; -import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; +import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_network.dart'; +import 'package:surface/types/attachment.dart'; import 'package:surface/types/post.dart'; import 'package:surface/widgets/account/account_image.dart'; -import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/navigation/app_scaffold.dart'; +import 'package:surface/widgets/post/post_media_pending_list.dart'; import 'package:surface/widgets/post/post_meta_editor.dart'; +import 'package:surface/widgets/dialog.dart'; +import 'package:provider/provider.dart'; class PostEditorScreen extends StatefulWidget { final String mode; @@ -33,6 +37,9 @@ class _PostEditorScreenState extends State { SnPublisher? _publisher; List? _publishers; + final List _selectedMedia = List.empty(growable: true); + final List _attachments = List.empty(growable: true); + void _fetchPublishers() async { final sn = context.read(); final resp = await sn.client.get('/cgi/co/publishers'); @@ -49,19 +56,41 @@ class _PostEditorScreenState extends State { final TextEditingController _contentController = TextEditingController(); + double? _progress; + void _performAction() async { if (_isBusy || _publisher == null) return; final sn = context.read(); + final attach = context.read(); setState(() => _isBusy = true); + // Uploading attachments + try { + for (final media in _selectedMedia) { + final place = await attach.chunkedUploadInitialize( + await media.length(), + media.name, + 'interactive', + null, + ); + final item = await attach.chunkedUploadParts(media, place.$1, place.$2); + _attachments.add(item); + } + } catch (err) { + context.showErrorDialog(err); + return; + } + + // Finishing up try { await sn.client.post('/cgi/co/${widget.mode}', data: { 'publisher': _publisher!.id, 'content': _contentController.value.text, 'title': _title, 'description': _description, + 'attachments': _attachments.map((e) => e.rid).toList(), }); Navigator.pop(context, true); } catch (err) { @@ -88,6 +117,15 @@ class _PostEditorScreenState extends State { }); } + final _imagePicker = ImagePicker(); + + void _selectMedia() async { + final result = await _imagePicker.pickMultipleMedia(); + if (result.isEmpty) return; + _selectedMedia.addAll(result); + setState(() {}); + } + @override void dispose() { _contentController.dispose(); @@ -248,13 +286,23 @@ class _PostEditorScreenState extends State { ), ), ), + if (_selectedMedia.isNotEmpty) + PostMediaPendingList( + data: _selectedMedia, + onRemove: (idx) { + setState(() { + _selectedMedia.removeAt(idx); + }); + }, + ).padding(bottom: 8), Material( elevation: 2, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_isBusy) - const LinearProgressIndicator( + LinearProgressIndicator( + value: _progress, minHeight: 2, ), Row( @@ -268,7 +316,7 @@ class _PostEditorScreenState extends State { child: Row( children: [ IconButton( - onPressed: () {}, + onPressed: _isBusy ? null : _selectMedia, icon: Icon( Symbols.add_photo_alternate, color: Theme.of(context).colorScheme.primary, diff --git a/lib/types/attachment.dart b/lib/types/attachment.dart index 66d934b..8fbb263 100644 --- a/lib/types/attachment.dart +++ b/lib/types/attachment.dart @@ -21,7 +21,6 @@ class SnAttachment with _$SnAttachment { required int refCount, required dynamic fileChunks, required dynamic cleanedAt, - required Map metadata, required bool isMature, required bool isAnalyzed, required bool isUploaded, @@ -31,6 +30,7 @@ class SnAttachment with _$SnAttachment { required SnAttachmentPool? pool, required int poolId, required int accountId, + @Default({}) Map metadata, }) = _SnAttachment; factory SnAttachment.fromJson(Map json) => diff --git a/lib/types/attachment.freezed.dart b/lib/types/attachment.freezed.dart index 708e04a..84ef1ec 100644 --- a/lib/types/attachment.freezed.dart +++ b/lib/types/attachment.freezed.dart @@ -35,7 +35,6 @@ mixin _$SnAttachment { int get refCount => throw _privateConstructorUsedError; dynamic get fileChunks => throw _privateConstructorUsedError; dynamic get cleanedAt => throw _privateConstructorUsedError; - Map get metadata => throw _privateConstructorUsedError; bool get isMature => throw _privateConstructorUsedError; bool get isAnalyzed => throw _privateConstructorUsedError; bool get isUploaded => throw _privateConstructorUsedError; @@ -45,6 +44,7 @@ mixin _$SnAttachment { SnAttachmentPool? get pool => throw _privateConstructorUsedError; int get poolId => throw _privateConstructorUsedError; int get accountId => throw _privateConstructorUsedError; + Map get metadata => throw _privateConstructorUsedError; /// Serializes this SnAttachment to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -78,7 +78,6 @@ abstract class $SnAttachmentCopyWith<$Res> { int refCount, dynamic fileChunks, dynamic cleanedAt, - Map metadata, bool isMature, bool isAnalyzed, bool isUploaded, @@ -87,7 +86,8 @@ abstract class $SnAttachmentCopyWith<$Res> { dynamic refId, SnAttachmentPool? pool, int poolId, - int accountId}); + int accountId, + Map metadata}); $SnAttachmentPoolCopyWith<$Res>? get pool; } @@ -122,7 +122,6 @@ class _$SnAttachmentCopyWithImpl<$Res, $Val extends SnAttachment> Object? refCount = null, Object? fileChunks = freezed, Object? cleanedAt = freezed, - Object? metadata = null, Object? isMature = null, Object? isAnalyzed = null, Object? isUploaded = null, @@ -132,6 +131,7 @@ class _$SnAttachmentCopyWithImpl<$Res, $Val extends SnAttachment> Object? pool = freezed, Object? poolId = null, Object? accountId = null, + Object? metadata = null, }) { return _then(_value.copyWith( id: null == id @@ -194,10 +194,6 @@ class _$SnAttachmentCopyWithImpl<$Res, $Val extends SnAttachment> ? _value.cleanedAt : cleanedAt // ignore: cast_nullable_to_non_nullable as dynamic, - metadata: null == metadata - ? _value.metadata - : metadata // ignore: cast_nullable_to_non_nullable - as Map, isMature: null == isMature ? _value.isMature : isMature // ignore: cast_nullable_to_non_nullable @@ -234,6 +230,10 @@ class _$SnAttachmentCopyWithImpl<$Res, $Val extends SnAttachment> ? _value.accountId : accountId // ignore: cast_nullable_to_non_nullable as int, + metadata: null == metadata + ? _value.metadata + : metadata // ignore: cast_nullable_to_non_nullable + as Map, ) as $Val); } @@ -276,7 +276,6 @@ abstract class _$$SnAttachmentImplCopyWith<$Res> int refCount, dynamic fileChunks, dynamic cleanedAt, - Map metadata, bool isMature, bool isAnalyzed, bool isUploaded, @@ -285,7 +284,8 @@ abstract class _$$SnAttachmentImplCopyWith<$Res> dynamic refId, SnAttachmentPool? pool, int poolId, - int accountId}); + int accountId, + Map metadata}); @override $SnAttachmentPoolCopyWith<$Res>? get pool; @@ -319,7 +319,6 @@ class __$$SnAttachmentImplCopyWithImpl<$Res> Object? refCount = null, Object? fileChunks = freezed, Object? cleanedAt = freezed, - Object? metadata = null, Object? isMature = null, Object? isAnalyzed = null, Object? isUploaded = null, @@ -329,6 +328,7 @@ class __$$SnAttachmentImplCopyWithImpl<$Res> Object? pool = freezed, Object? poolId = null, Object? accountId = null, + Object? metadata = null, }) { return _then(_$SnAttachmentImpl( id: null == id @@ -391,10 +391,6 @@ class __$$SnAttachmentImplCopyWithImpl<$Res> ? _value.cleanedAt : cleanedAt // ignore: cast_nullable_to_non_nullable as dynamic, - metadata: null == metadata - ? _value._metadata - : metadata // ignore: cast_nullable_to_non_nullable - as Map, isMature: null == isMature ? _value.isMature : isMature // ignore: cast_nullable_to_non_nullable @@ -431,6 +427,10 @@ class __$$SnAttachmentImplCopyWithImpl<$Res> ? _value.accountId : accountId // ignore: cast_nullable_to_non_nullable as int, + metadata: null == metadata + ? _value._metadata + : metadata // ignore: cast_nullable_to_non_nullable + as Map, )); } } @@ -454,7 +454,6 @@ class _$SnAttachmentImpl implements _SnAttachment { required this.refCount, required this.fileChunks, required this.cleanedAt, - required final Map metadata, required this.isMature, required this.isAnalyzed, required this.isUploaded, @@ -463,7 +462,8 @@ class _$SnAttachmentImpl implements _SnAttachment { required this.refId, required this.pool, required this.poolId, - required this.accountId}) + required this.accountId, + final Map metadata = const {}}) : _metadata = metadata; factory _$SnAttachmentImpl.fromJson(Map json) => @@ -499,14 +499,6 @@ class _$SnAttachmentImpl implements _SnAttachment { final dynamic fileChunks; @override final dynamic cleanedAt; - final Map _metadata; - @override - Map get metadata { - if (_metadata is EqualUnmodifiableMapView) return _metadata; - // ignore: implicit_dynamic_type - return EqualUnmodifiableMapView(_metadata); - } - @override final bool isMature; @override @@ -525,10 +517,18 @@ class _$SnAttachmentImpl implements _SnAttachment { final int poolId; @override final int accountId; + final Map _metadata; + @override + @JsonKey() + Map get metadata { + if (_metadata is EqualUnmodifiableMapView) return _metadata; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_metadata); + } @override String toString() { - return 'SnAttachment(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, rid: $rid, uuid: $uuid, size: $size, name: $name, alt: $alt, mimetype: $mimetype, hash: $hash, destination: $destination, refCount: $refCount, fileChunks: $fileChunks, cleanedAt: $cleanedAt, metadata: $metadata, isMature: $isMature, isAnalyzed: $isAnalyzed, isUploaded: $isUploaded, isSelfRef: $isSelfRef, ref: $ref, refId: $refId, pool: $pool, poolId: $poolId, accountId: $accountId)'; + return 'SnAttachment(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, rid: $rid, uuid: $uuid, size: $size, name: $name, alt: $alt, mimetype: $mimetype, hash: $hash, destination: $destination, refCount: $refCount, fileChunks: $fileChunks, cleanedAt: $cleanedAt, isMature: $isMature, isAnalyzed: $isAnalyzed, isUploaded: $isUploaded, isSelfRef: $isSelfRef, ref: $ref, refId: $refId, pool: $pool, poolId: $poolId, accountId: $accountId, metadata: $metadata)'; } @override @@ -557,7 +557,6 @@ class _$SnAttachmentImpl implements _SnAttachment { const DeepCollectionEquality() .equals(other.fileChunks, fileChunks) && const DeepCollectionEquality().equals(other.cleanedAt, cleanedAt) && - const DeepCollectionEquality().equals(other._metadata, _metadata) && (identical(other.isMature, isMature) || other.isMature == isMature) && (identical(other.isAnalyzed, isAnalyzed) || @@ -571,7 +570,8 @@ class _$SnAttachmentImpl implements _SnAttachment { (identical(other.pool, pool) || other.pool == pool) && (identical(other.poolId, poolId) || other.poolId == poolId) && (identical(other.accountId, accountId) || - other.accountId == accountId)); + other.accountId == accountId) && + const DeepCollectionEquality().equals(other._metadata, _metadata)); } @JsonKey(includeFromJson: false, includeToJson: false) @@ -593,7 +593,6 @@ class _$SnAttachmentImpl implements _SnAttachment { refCount, const DeepCollectionEquality().hash(fileChunks), const DeepCollectionEquality().hash(cleanedAt), - const DeepCollectionEquality().hash(_metadata), isMature, isAnalyzed, isUploaded, @@ -602,7 +601,8 @@ class _$SnAttachmentImpl implements _SnAttachment { const DeepCollectionEquality().hash(refId), pool, poolId, - accountId + accountId, + const DeepCollectionEquality().hash(_metadata) ]); /// Create a copy of SnAttachment @@ -638,7 +638,6 @@ abstract class _SnAttachment implements SnAttachment { required final int refCount, required final dynamic fileChunks, required final dynamic cleanedAt, - required final Map metadata, required final bool isMature, required final bool isAnalyzed, required final bool isUploaded, @@ -647,7 +646,8 @@ abstract class _SnAttachment implements SnAttachment { required final dynamic refId, required final SnAttachmentPool? pool, required final int poolId, - required final int accountId}) = _$SnAttachmentImpl; + required final int accountId, + final Map metadata}) = _$SnAttachmentImpl; factory _SnAttachment.fromJson(Map json) = _$SnAttachmentImpl.fromJson; @@ -683,8 +683,6 @@ abstract class _SnAttachment implements SnAttachment { @override dynamic get cleanedAt; @override - Map get metadata; - @override bool get isMature; @override bool get isAnalyzed; @@ -702,6 +700,8 @@ abstract class _SnAttachment implements SnAttachment { int get poolId; @override int get accountId; + @override + Map get metadata; /// Create a copy of SnAttachment /// with the given fields replaced by the non-null parameter values. diff --git a/lib/types/attachment.g.dart b/lib/types/attachment.g.dart index b38362c..fa2cb92 100644 --- a/lib/types/attachment.g.dart +++ b/lib/types/attachment.g.dart @@ -23,7 +23,6 @@ _$SnAttachmentImpl _$$SnAttachmentImplFromJson(Map json) => refCount: (json['ref_count'] as num).toInt(), fileChunks: json['file_chunks'], cleanedAt: json['cleaned_at'], - metadata: json['metadata'] as Map, isMature: json['is_mature'] as bool, isAnalyzed: json['is_analyzed'] as bool, isUploaded: json['is_uploaded'] as bool, @@ -35,6 +34,7 @@ _$SnAttachmentImpl _$$SnAttachmentImplFromJson(Map json) => : SnAttachmentPool.fromJson(json['pool'] as Map), poolId: (json['pool_id'] as num).toInt(), accountId: (json['account_id'] as num).toInt(), + metadata: json['metadata'] as Map? ?? const {}, ); Map _$$SnAttachmentImplToJson(_$SnAttachmentImpl instance) => @@ -54,7 +54,6 @@ Map _$$SnAttachmentImplToJson(_$SnAttachmentImpl instance) => 'ref_count': instance.refCount, 'file_chunks': instance.fileChunks, 'cleaned_at': instance.cleanedAt, - 'metadata': instance.metadata, 'is_mature': instance.isMature, 'is_analyzed': instance.isAnalyzed, 'is_uploaded': instance.isUploaded, @@ -64,6 +63,7 @@ Map _$$SnAttachmentImplToJson(_$SnAttachmentImpl instance) => 'pool': instance.pool?.toJson(), 'pool_id': instance.poolId, 'account_id': instance.accountId, + 'metadata': instance.metadata, }; _$SnAttachmentPoolImpl _$$SnAttachmentPoolImplFromJson( diff --git a/lib/widgets/attachment/attachment_item.dart b/lib/widgets/attachment/attachment_item.dart index b895e0f..2696472 100644 --- a/lib/widgets/attachment/attachment_item.dart +++ b/lib/widgets/attachment/attachment_item.dart @@ -14,9 +14,9 @@ class AttachmentItem extends StatelessWidget { final sn = context.read(); switch (tp) { case 'image': - return AspectRatio( - aspectRatio: data.metadata['ratio']?.toDouble(), - child: UniversalImage(sn.getAttachmentUrl(data.rid)), + return UniversalImage( + sn.getAttachmentUrl(data.rid), + fit: BoxFit.cover, ); default: return const Placeholder(); diff --git a/lib/widgets/attachment/attachment_list.dart b/lib/widgets/attachment/attachment_list.dart index ebcc026..271d931 100644 --- a/lib/widgets/attachment/attachment_list.dart +++ b/lib/widgets/attachment/attachment_list.dart @@ -8,8 +8,12 @@ class AttachmentList extends StatelessWidget { final List data; final bool? bordered; final double? maxListHeight; - const AttachmentList( - {super.key, required this.data, this.bordered, this.maxListHeight}); + const AttachmentList({ + super.key, + required this.data, + this.bordered, + this.maxListHeight, + }); @override Widget build(BuildContext context) { @@ -37,13 +41,19 @@ class AttachmentList extends StatelessWidget { itemBuilder: (context, idx) { const radius = BorderRadius.all(Radius.circular(8)); return Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width - 20, + ), decoration: BoxDecoration( border: Border(top: borderSide, bottom: borderSide), borderRadius: radius, ), - child: ClipRRect( - borderRadius: radius, - child: AttachmentItem(data: data[idx]), + child: AspectRatio( + aspectRatio: data[idx].metadata['ratio']?.toDouble() ?? 1, + child: ClipRRect( + borderRadius: radius, + child: AttachmentItem(data: data[idx]), + ), ), ); }, diff --git a/lib/widgets/post/post_media_pending_list.dart b/lib/widgets/post/post_media_pending_list.dart new file mode 100644 index 0000000..cf0d8b2 --- /dev/null +++ b/lib/widgets/post/post_media_pending_list.dart @@ -0,0 +1,67 @@ +import 'dart:io'; + +import 'package:cross_file/cross_file.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_context_menu/flutter_context_menu.dart'; +import 'package:gap/gap.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class PostMediaPendingList extends StatelessWidget { + final List data; + final Function(int idx)? onRemove; + const PostMediaPendingList({ + super.key, + required this.data, + this.onRemove, + }); + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(maxHeight: 120), + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8), + separatorBuilder: (context, index) => const Gap(8), + itemCount: data.length, + itemBuilder: (context, idx) { + final file = data[idx]; + return ContextMenuRegion( + contextMenu: ContextMenu( + entries: [ + if (onRemove != null) + MenuItem( + label: 'delete'.tr(), + icon: Symbols.delete, + onSelected: () { + onRemove!(idx); + }, + ), + ], + ), + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).dividerColor, + width: 1, + ), + borderRadius: BorderRadius.circular(8), + ), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: AspectRatio( + aspectRatio: 1, + child: kIsWeb + ? Image.network(file.path, fit: BoxFit.cover) + : Image.file(File(file.path), fit: BoxFit.cover), + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 5e8fed8..1ecf56b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -467,6 +467,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.1" + flutter_context_menu: + dependency: "direct main" + description: + name: flutter_context_menu + sha256: "4bc1dc30ae5aa705ed99ebbeb875898c6341a6d092397a566fecd5184b392380" + url: "https://pub.dev" + source: hosted + version: "0.2.0" flutter_expandable_fab: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 08d7111..b2ba9ef 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -66,6 +66,7 @@ dependencies: croppy: ^1.3.1 flutter_expandable_fab: ^2.3.0 dropdown_button2: ^2.3.9 + flutter_context_menu: ^0.2.0 dev_dependencies: flutter_test: