diff --git a/assets/translations/en.json b/assets/translations/en.json index 5c1155a..04f1009 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -257,8 +257,13 @@ "addAttachmentFromAlbum": "Add from album", "addAttachmentFromClipboard": "Paste file", "attachmentPastedImage": "Pasted Image", - "notificationUnread": "未读", - "notificationRead": "已读", + "attachmentInsertLink": "Insert Link", + "attachmentSetAsPostThumbnail": "Set as post thumbnail", + "attachmentUnsetAsPostThumbnail": "Unset as post thumbnail", + "attachmentSetThumbnail": "Set thumbnail", + "attachmentUpload": "Upload", + "notificationUnread": "Unread", + "notificationRead": "Read", "notificationMarkAllRead": "Mark all notifications as read", "notificationMarkAllReadDescription": "Are you sure you want to mark all notifications as read? This operation is irreversible.", "notificationMarkAllReadPrompt": { @@ -377,5 +382,6 @@ "accountStatus": "Status", "accountStatusOnline": "Online", "accountStatusOffline": "Offline", - "accountStatusLastSeen": "Last seen at {}" + "accountStatusLastSeen": "Last seen at {}", + "postArticle": "Article on the Solar Network" } diff --git a/assets/translations/zh.json b/assets/translations/zh.json index 3114711..c6d8a87 100644 --- a/assets/translations/zh.json +++ b/assets/translations/zh.json @@ -257,6 +257,11 @@ "addAttachmentFromAlbum": "从相册中添加附件", "addAttachmentFromClipboard": "粘贴附件", "attachmentPastedImage": "粘贴的图片", + "attachmentInsertLink": "插入连接", + "attachmentSetAsPostThumbnail": "设置为帖子缩略图", + "attachmentUnsetAsPostThumbnail": "取消设置为帖子缩略图", + "attachmentSetThumbnail": "设置缩略图", + "attachmentUpload": "上传", "notificationUnread": "未读", "notificationRead": "已读", "notificationMarkAllRead": "已读所有通知", @@ -377,5 +382,6 @@ "accountStatus": "状态", "accountStatusOnline": "在线", "accountStatusOffline": "离线", - "accountStatusLastSeen": "最后一次在 {} 上线" + "accountStatusLastSeen": "最后一次在 {} 上线", + "postArticle": "Solar Network 上的文章" } diff --git a/lib/controllers/post_write_controller.dart b/lib/controllers/post_write_controller.dart index 4afee1e..8e84456 100644 --- a/lib/controllers/post_write_controller.dart +++ b/lib/controllers/post_write_controller.dart @@ -28,6 +28,8 @@ class PostWriteMedia { final XFile? file; final Uint8List? raw; + PostWriteMedia? thumbnail; + PostWriteMedia(this.attachment, {this.file, this.raw}) { name = attachment!.name; @@ -67,8 +69,7 @@ class PostWriteMedia { } } - PostWriteMedia.fromBytes(this.raw, this.name, this.type, - {this.attachment, this.file}); + PostWriteMedia.fromBytes(this.raw, this.name, this.type, {this.attachment, this.file}); bool get isEmpty => attachment == null && file == null && raw == null; @@ -102,8 +103,7 @@ class PostWriteMedia { }) { if (attachment != null) { final sn = context.read(); - final ImageProvider provider = - UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid)); + final ImageProvider provider = UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid)); if (width != null && height != null) { return ResizeImage( provider, @@ -114,8 +114,7 @@ class PostWriteMedia { } return provider; } else if (file != null) { - final ImageProvider provider = - kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path)); + final ImageProvider provider = kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path)); if (width != null && height != null) { return ResizeImage( provider, @@ -162,9 +161,10 @@ class PostWriteController extends ChangeNotifier { String mode = kTitleMap.keys.first; String get title => titleController.text; + String get description => descriptionController.text; - bool get isRelatedNull => - ![editingPost, repostingPost, replyingPost].any((ele) => ele != null); + + bool get isRelatedNull => ![editingPost, repostingPost, replyingPost].any((ele) => ele != null); bool isLoading = false, isBusy = false; double? progress; @@ -176,6 +176,7 @@ class PostWriteController extends ChangeNotifier { List visibleUsers = List.empty(); List invisibleUsers = List.empty(); List tags = List.empty(); + PostWriteMedia? thumbnail; List attachments = List.empty(growable: true); DateTime? publishedAt, publishedUntil; @@ -203,9 +204,11 @@ class PostWriteController extends ChangeNotifier { invisibleUsers = List.from(post.invisibleUsersList ?? []); visibility = post.visibility; tags = List.from(post.tags.map((ele) => ele.alias)); - attachments.addAll( - post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? [], - ); + attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []); + + if (post.preload?.thumbnail != null) { + thumbnail = PostWriteMedia(post.preload!.thumbnail); + } editingPost = post; } @@ -228,6 +231,43 @@ class PostWriteController extends ChangeNotifier { } } + Future _uploadAttachment(BuildContext context, PostWriteMedia media) async { + final attach = context.read(); + + final place = await attach.chunkedUploadInitialize( + (await media.length())!, + media.name, + 'interactive', + null, + mimetype: media.raw != null && media.type == PostWriteMediaType.image ? 'image/png' : null, + ); + + final item = await attach.chunkedUploadParts( + media.toFile()!, + place.$1, + place.$2, + onProgress: (progress) { + progress = progress; + notifyListeners(); + }, + ); + + return item; + } + + Future uploadSingleAttachment(BuildContext context, int idx) async { + if (isBusy) return; + + final media = idx == -1 ? thumbnail! : attachments[idx]; + isBusy = true; + notifyListeners(); + + final item = await _uploadAttachment(context, media); + attachments[idx] = PostWriteMedia(item); + + notifyListeners(); + } + Future post(BuildContext context) async { if (isBusy || publisher == null) return; @@ -240,6 +280,11 @@ class PostWriteController extends ChangeNotifier { // Uploading attachments try { + if (thumbnail != null && thumbnail!.attachment == null) { + final thumb = await _uploadAttachment(context, thumbnail!); + thumbnail = PostWriteMedia(thumb); + } + for (int i = 0; i < attachments.length; i++) { final media = attachments[i]; if (media.attachment != null) continue; // Already uploaded, skip @@ -250,9 +295,7 @@ class PostWriteController extends ChangeNotifier { media.name, 'interactive', null, - mimetype: media.raw != null && media.type == PostWriteMediaType.image - ? 'image/png' - : null, + mimetype: media.raw != null && media.type == PostWriteMediaType.image ? 'image/png' : null, ); final item = await attach.chunkedUploadParts( @@ -261,8 +304,7 @@ class PostWriteController extends ChangeNotifier { place.$2, onProgress: (progress) { // Calculate overall progress for attachments - progress = ((i + progress) / attachments.length) * - kAttachmentProgressWeight; + progress = ((i + progress) / attachments.length) * kAttachmentProgressWeight; notifyListeners(); }, ); @@ -292,32 +334,24 @@ class PostWriteController extends ChangeNotifier { 'publisher': publisher!.id, 'content': contentController.text, if (titleController.text.isNotEmpty) 'title': titleController.text, - if (descriptionController.text.isNotEmpty) - 'description': descriptionController.text, - 'attachments': attachments - .where((e) => e.attachment != null) - .map((e) => e.attachment!.rid) - .toList(), + if (descriptionController.text.isNotEmpty) 'description': descriptionController.text, + if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid, + 'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(), 'tags': tags.map((ele) => {'alias': ele}).toList(), 'visibility': visibility, 'visible_users_list': visibleUsers, 'invisible_users_list': invisibleUsers, - if (publishedAt != null) - 'published_at': publishedAt!.toUtc().toIso8601String(), - if (publishedUntil != null) - 'published_until': publishedAt!.toUtc().toIso8601String(), + if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(), + if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(), if (replyingPost != null) 'reply_to': replyingPost!.id, if (repostingPost != null) 'repost_to': repostingPost!.id, }, onSendProgress: (count, total) { - progress = - baseProgressVal + (count / total) * (kPostingProgressWeight / 2); + progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2); notifyListeners(); }, onReceiveProgress: (count, total) { - progress = baseProgressVal + - (kPostingProgressWeight / 2) + - (count / total) * (kPostingProgressWeight / 2); + progress = baseProgressVal + (kPostingProgressWeight / 2) + (count / total) * (kPostingProgressWeight / 2); notifyListeners(); }, options: Options( @@ -339,12 +373,31 @@ class PostWriteController extends ChangeNotifier { } void setAttachmentAt(int idx, PostWriteMedia item) { - attachments[idx] = item; + if (idx == -1) { + thumbnail = item; + } else { + attachments[idx] = item; + } notifyListeners(); } void removeAttachmentAt(int idx) { - attachments.removeAt(idx); + if (idx == -1) { + thumbnail = null; + } else { + attachments.removeAt(idx); + } + notifyListeners(); + } + + void setThumbnail(int? idx) { + if (idx == null) { + attachments.add(thumbnail!); + thumbnail = null; + } else { + thumbnail = attachments[idx]; + attachments.removeAt(idx); + } notifyListeners(); } @@ -383,11 +436,21 @@ class PostWriteController extends ChangeNotifier { notifyListeners(); } + void setProgress(double? value) { + progress = value; + notifyListeners(); + } + void setIsBusy(bool value) { isBusy = value; notifyListeners(); } + void setMode(String value) { + mode = value; + notifyListeners(); + } + void reset() { publishedAt = null; publishedUntil = null; diff --git a/lib/main.dart b/lib/main.dart index 061a4c0..06ae709 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -45,10 +45,6 @@ void main() async { options: DefaultFirebaseOptions.currentPlatform, ); - if (!kReleaseMode) { - // debugInvertOversizedImages = true; - } - GoRouter.optionURLReflectsImperativeAPIs = true; usePathUrlStrategy(); diff --git a/lib/screens/post/post_detail.dart b/lib/screens/post/post_detail.dart index d890b8d..bc7ac4d 100644 --- a/lib/screens/post/post_detail.dart +++ b/lib/screens/post/post_detail.dart @@ -110,6 +110,7 @@ class _PostDetailScreenState extends State { data: _data!, maxWidth: 640, showComments: false, + showFullPost: true, onChanged: (data) { setState(() => _data = data); }, diff --git a/lib/screens/post/post_editor.dart b/lib/screens/post/post_editor.dart index e73cf99..6474628 100644 --- a/lib/screens/post/post_editor.dart +++ b/lib/screens/post/post_editor.dart @@ -25,6 +25,7 @@ class PostEditorScreen extends StatefulWidget { final int? postEditId; final int? postReplyId; final int? postRepostId; + const PostEditorScreen({ super.key, required this.mode, @@ -41,6 +42,7 @@ class _PostEditorScreenState extends State { final PostWriteController _writeController = PostWriteController(); bool _isFetching = false; + bool get _isLoading => _isFetching || _writeController.isLoading; List? _publishers; @@ -105,6 +107,8 @@ class _PostEditorScreenState extends State { if (!PostWriteController.kTitleMap.keys.contains(widget.mode)) { context.showErrorDialog('Unknown post type'); Navigator.pop(context); + } else { + _writeController.setMode(widget.mode); } _fetchPublishers(); _writeController.fetchRelatedPost( @@ -131,21 +135,13 @@ class _PostEditorScreenState extends State { textAlign: TextAlign.center, text: TextSpan(children: [ TextSpan( - text: _writeController.title.isNotEmpty - ? _writeController.title - : 'untitled'.tr(), - style: Theme.of(context) - .textTheme - .titleLarge! - .copyWith(color: Colors.white), + text: _writeController.title.isNotEmpty ? _writeController.title : 'untitled'.tr(), + style: Theme.of(context).textTheme.titleLarge!.copyWith(color: Colors.white), ), const TextSpan(text: '\n'), TextSpan( text: PostWriteController.kTitleMap[widget.mode]!.tr(), - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(color: Colors.white), + style: Theme.of(context).textTheme.bodySmall!.copyWith(color: Colors.white), ), ]), ), @@ -181,17 +177,11 @@ class _PostEditorScreenState extends State { Expanded( child: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: - CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(item.nick).textStyle( - Theme.of(context) - .textTheme - .bodyMedium!), + Text(item.nick).textStyle(Theme.of(context).textTheme.bodyMedium!), Text('@${item.name}') - .textStyle(Theme.of(context) - .textTheme - .bodySmall!) + .textStyle(Theme.of(context).textTheme.bodySmall!) .fontSize(12), ], ), @@ -208,8 +198,7 @@ class _PostEditorScreenState extends State { CircleAvatar( radius: 16, backgroundColor: Colors.transparent, - foregroundColor: - Theme.of(context).colorScheme.onSurface, + foregroundColor: Theme.of(context).colorScheme.onSurface, child: const Icon(Symbols.add), ), const Gap(8), @@ -218,8 +207,7 @@ class _PostEditorScreenState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('publishersNew').tr().textStyle( - Theme.of(context).textTheme.bodyMedium!), + Text('publishersNew').tr().textStyle(Theme.of(context).textTheme.bodyMedium!), ], ), ), @@ -230,9 +218,7 @@ class _PostEditorScreenState extends State { value: _writeController.publisher, onChanged: (SnPublisher? value) { if (value == null) { - GoRouter.of(context) - .pushNamed('accountPublisherNew') - .then((value) { + GoRouter.of(context).pushNamed('accountPublisherNew').then((value) { if (value == true) { _publishers = null; _fetchPublishers(); @@ -267,16 +253,11 @@ class _PostEditorScreenState extends State { ), child: ExpansionTile( minTileHeight: 48, - leading: - const Icon(Symbols.reply).padding(left: 4), + leading: const Icon(Symbols.reply).padding(left: 4), title: Text('postReplyingNotice') .fontSize(15) - .tr(args: [ - '@${_writeController.replyingPost!.publisher.name}' - ]), - children: [ - PostItem(data: _writeController.replyingPost!) - ], + .tr(args: ['@${_writeController.replyingPost!.publisher.name}']), + children: [PostItem(data: _writeController.replyingPost!)], ), ), const Divider(height: 1), @@ -292,13 +273,10 @@ class _PostEditorScreenState extends State { ), child: ExpansionTile( minTileHeight: 48, - leading: const Icon(Symbols.forward) - .padding(left: 4), + leading: const Icon(Symbols.forward).padding(left: 4), title: Text('postRepostingNotice') .fontSize(15) - .tr(args: [ - '@${_writeController.repostingPost!.publisher.name}' - ]), + .tr(args: ['@${_writeController.repostingPost!.publisher.name}']), children: [ PostItem( data: _writeController.repostingPost!, @@ -319,16 +297,11 @@ class _PostEditorScreenState extends State { ), child: ExpansionTile( minTileHeight: 48, - leading: const Icon(Symbols.edit_note) - .padding(left: 4), + leading: const Icon(Symbols.edit_note).padding(left: 4), title: Text('postEditingNotice') .fontSize(15) - .tr(args: [ - '@${_writeController.editingPost!.publisher.name}' - ]), - children: [ - PostItem(data: _writeController.editingPost!) - ], + .tr(args: ['@${_writeController.editingPost!.publisher.name}']), + children: [PostItem(data: _writeController.editingPost!)], ), ), const Divider(height: 1), @@ -347,14 +320,12 @@ class _PostEditorScreenState extends State { ), border: InputBorder.none, ), - onTapOutside: (_) => - FocusManager.instance.primaryFocus?.unfocus(), + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), ), ] .expandIndexed( (idx, ele) => [ - if (idx != 0 || _writeController.isRelatedNull) - const Gap(8), + if (idx != 0 || _writeController.isRelatedNull) const Gap(8), ele, ], ) @@ -362,10 +333,21 @@ class _PostEditorScreenState extends State { ), ), ), - if (_writeController.attachments.isNotEmpty) + if (_writeController.attachments.isNotEmpty || _writeController.thumbnail != null) PostMediaPendingList( + thumbnail: _writeController.thumbnail, attachments: _writeController.attachments, isBusy: _writeController.isBusy, + onUpload: (int idx) async { + await _writeController.uploadSingleAttachment(context, idx); + }, + onPostSetThumbnail: (int? idx) { + _writeController.setThumbnail(idx); + }, + onInsertLink: (int idx) async { + _writeController.contentController.text += + '\n![](solink://attachments/${_writeController.attachments[idx].attachment!.rid})'; + }, onUpdate: (int idx, PostWriteMedia updatedMedia) async { _writeController.setIsBusy(true); try { @@ -390,13 +372,11 @@ class _PostEditorScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ LoadingIndicator(isActive: _isLoading), - if (_writeController.isBusy && - _writeController.progress != null) + if (_writeController.isBusy && _writeController.progress != null) TweenAnimationBuilder( tween: Tween(begin: 0, end: _writeController.progress), duration: Duration(milliseconds: 300), - builder: (context, value, _) => - LinearProgressIndicator(value: value, minHeight: 2), + builder: (context, value, _) => LinearProgressIndicator(value: value, minHeight: 2), ) else if (_writeController.isBusy) const LinearProgressIndicator(value: null, minHeight: 2), @@ -413,8 +393,7 @@ class _PostEditorScreenState extends State { PopupMenuButton( icon: Icon( Symbols.add_photo_alternate, - color: - Theme.of(context).colorScheme.primary, + color: Theme.of(context).colorScheme.primary, ), itemBuilder: (context) => [ PopupMenuItem( @@ -434,8 +413,7 @@ class _PostEditorScreenState extends State { children: [ const Icon(Symbols.content_paste), const Gap(16), - Text('addAttachmentFromClipboard') - .tr(), + Text('addAttachmentFromClipboard').tr(), ], ), onTap: () { @@ -450,8 +428,7 @@ class _PostEditorScreenState extends State { ), ), TextButton.icon( - onPressed: (_writeController.isBusy || - _writeController.publisher == null) + onPressed: (_writeController.isBusy || _writeController.publisher == null) ? null : () { _writeController.post(context).then((_) { diff --git a/lib/widgets/navigation/app_drawer_navigation.dart b/lib/widgets/navigation/app_drawer_navigation.dart index 3688d29..c5eebbb 100644 --- a/lib/widgets/navigation/app_drawer_navigation.dart +++ b/lib/widgets/navigation/app_drawer_navigation.dart @@ -56,7 +56,7 @@ class _AppNavigationDrawerState extends State { ], ).padding( horizontal: 32, - top: MediaQuery.of(context).padding.top > 16 ? 8 : 24, + top: MediaQuery.of(context).padding.top > 32 ? 8 : 32, bottom: 8, ), ...destinations.where((ele) => ele.isPinned).map((ele) { diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index d095d67..933ffe8 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -21,21 +21,25 @@ import 'package:surface/widgets/post/post_comment_list.dart'; import 'package:surface/widgets/post/post_meta_editor.dart'; import 'package:surface/widgets/post/post_reaction.dart'; import 'package:surface/widgets/post/publisher_popover.dart'; +import 'package:surface/widgets/universal_image.dart'; class PostItem extends StatelessWidget { final SnPost data; final bool showReactions; final bool showComments; final bool showMenu; + final bool showFullPost; final double? maxWidth; final Function(SnPost data)? onChanged; final Function()? onDeleted; + const PostItem({ super.key, required this.data, this.showReactions = true, this.showComments = true, this.showMenu = true, + this.showFullPost = false, this.maxWidth, this.onChanged, this.onDeleted, @@ -47,6 +51,75 @@ class PostItem extends StatelessWidget { @override Widget build(BuildContext context) { + final sn = context.read(); + + // Article headline preview + if (!showFullPost && data.type == 'article') { + return Container( + constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _PostContentHeader( + data: data, + onDeleted: () { + if (onDeleted != null) {} + }, + ).padding(horizontal: 12, top: 8, bottom: 4), + Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 4, left: 12, right: 12), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8)), + border: Border.all( + color: Theme.of(context).dividerColor, + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (data.preload?.thumbnail != null) + AspectRatio( + aspectRatio: 16 / 9, + child: ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(8), + topRight: Radius.circular(8), + ), + child: AutoResizeUniversalImage( + sn.getAttachmentUrl(data.preload!.thumbnail!.rid), + fit: BoxFit.cover, + ), + ), + ), + const Gap(8), + _PostHeadline(data: data).padding(horizontal: 14), + const Gap(4), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (data.visibility > 0) _PostVisibilityHint(data: data), + _PostTruncatedHint(data: data), + ], + ).padding(horizontal: 12), + const Gap(8), + ], + ), + ), + Text('postArticle').tr().fontSize(13).opacity(0.75).padding(horizontal: 24, bottom: 8), + if (data.tags.isNotEmpty) _PostTagsList(data: data).padding(horizontal: 16, bottom: 6), + _PostBottomAction( + data: data, + showComments: showComments, + showReactions: showReactions, + onChanged: _onChanged, + ).padding(left: 8, right: 14), + ], + ), + ); + } + return Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -62,11 +135,9 @@ class PostItem extends StatelessWidget { if (onDeleted != null) onDeleted!(); }, ).padding(horizontal: 12, vertical: 8), - if (data.body['title'] != null || - data.body['description'] != null) + if (data.body['title'] != null || data.body['description'] != null) _PostHeadline(data: data).padding(horizontal: 16, bottom: 8), - _PostContentBody(data: data.body) - .padding(horizontal: 16, bottom: 6), + _PostContentBody(data: data.body).padding(horizontal: 16, bottom: 6), if (data.repostTo != null) _PostQuoteContent(child: data.repostTo!).padding( horizontal: 12, @@ -81,8 +152,7 @@ class PostItem extends StatelessWidget { horizontal: 16, vertical: 4, ), - if (data.tags.isNotEmpty) - _PostTagsList(data: data).padding(horizontal: 16, bottom: 6), + if (data.tags.isNotEmpty) _PostTagsList(data: data).padding(horizontal: 16, bottom: 6), ], ), ), @@ -116,6 +186,7 @@ class _PostBottomAction extends StatelessWidget { final bool showComments; final bool showReactions; final Function(SnPost data) onChanged; + const _PostBottomAction({ required this.data, required this.showComments, @@ -130,9 +201,7 @@ class _PostBottomAction extends StatelessWidget { ); final String? mostTypicalReaction = data.metric.reactionList.isNotEmpty - ? data.metric.reactionList.entries - .reduce((a, b) => a.value > b.value ? a : b) - .key + ? data.metric.reactionList.entries.reduce((a, b) => a.value > b.value ? a : b).key : null; return Row( @@ -145,8 +214,7 @@ class _PostBottomAction extends StatelessWidget { InkWell( child: Row( children: [ - if (mostTypicalReaction == null || - kTemplateReactions[mostTypicalReaction] == null) + if (mostTypicalReaction == null || kTemplateReactions[mostTypicalReaction] == null) Icon(Symbols.add_reaction, size: 20, color: iconColor) else Text( @@ -158,8 +226,7 @@ class _PostBottomAction extends StatelessWidget { ), ), const Gap(8), - if (data.totalUpvote > 0 && - data.totalUpvote >= data.totalDownvote) + if (data.totalUpvote > 0 && data.totalUpvote >= data.totalDownvote) Text('postReactionUpvote').plural( data.totalUpvote, ) @@ -178,12 +245,8 @@ class _PostBottomAction extends StatelessWidget { data: data, onChanged: (value, attr, delta) { onChanged(data.copyWith( - totalUpvote: attr == 1 - ? data.totalUpvote + delta - : data.totalUpvote, - totalDownvote: attr == 2 - ? data.totalDownvote + delta - : data.totalDownvote, + totalUpvote: attr == 1 ? data.totalUpvote + delta : data.totalUpvote, + totalDownvote: attr == 2 ? data.totalDownvote + delta : data.totalDownvote, metric: data.metric.copyWith(reactionList: value), )); }, @@ -229,6 +292,7 @@ class _PostBottomAction extends StatelessWidget { class _PostHeadline extends StatelessWidget { final SnPost data; + const _PostHeadline({super.key, required this.data}); @override @@ -256,6 +320,7 @@ class _PostContentHeader extends StatelessWidget { final bool isCompact; final bool showMenu; final Function onDeleted; + const _PostContentHeader({ required this.data, this.isCompact = false, @@ -438,6 +503,7 @@ class _PostContentHeader extends StatelessWidget { class _PostContentBody extends StatelessWidget { final dynamic data; + const _PostContentBody({this.data}); @override @@ -449,6 +515,7 @@ class _PostContentBody extends StatelessWidget { class _PostQuoteContent extends StatelessWidget { final SnPost child; + const _PostQuoteContent({super.key, required this.child}); @override @@ -479,6 +546,7 @@ class _PostQuoteContent extends StatelessWidget { class _PostTagsList extends StatelessWidget { final SnPost data; + const _PostTagsList({super.key, required this.data}); @override @@ -505,6 +573,7 @@ class _PostTagsList extends StatelessWidget { class _PostVisibilityHint extends StatelessWidget { final SnPost data; + const _PostVisibilityHint({super.key, required this.data}); static const List kVisibilityIcons = [ @@ -529,6 +598,7 @@ class _PostVisibilityHint extends StatelessWidget { class _PostTruncatedHint extends StatelessWidget { final SnPost data; + const _PostTruncatedHint({super.key, required this.data}); static const int kHumanReadSpeed = 238; @@ -544,13 +614,11 @@ class _PostTruncatedHint extends StatelessWidget { const Gap(4), Text('postReadEstimate').tr(args: [ '${Duration( - seconds: (data.body['content_length'] as num).toDouble() * - 60 ~/ - kHumanReadSpeed, + seconds: (data.body['content_length'] as num).toDouble() * 60 ~/ kHumanReadSpeed, ).inSeconds}s', ]), ], - ).padding(right: 12), + ).padding(right: 8), if (data.body['content_length'] != null) Row( children: [ diff --git a/lib/widgets/post/post_media_pending_list.dart b/lib/widgets/post/post_media_pending_list.dart index 3ff77df..1a37756 100644 --- a/lib/widgets/post/post_media_pending_list.dart +++ b/lib/widgets/post/post_media_pending_list.dart @@ -17,18 +17,26 @@ import 'package:surface/widgets/attachment/attachment_detail.dart'; import 'package:surface/widgets/dialog.dart'; class PostMediaPendingList extends StatelessWidget { + final PostWriteMedia? thumbnail; final List attachments; final bool isBusy; final Future Function(int idx, PostWriteMedia updatedMedia)? onUpdate; final Future Function(int idx)? onRemove; + final Future Function(int idx)? onUpload; + final void Function(int? idx)? onPostSetThumbnail; + final void Function(int idx)? onInsertLink; final void Function(bool state)? onUpdateBusy; const PostMediaPendingList({ super.key, + this.thumbnail, required this.attachments, required this.isBusy, this.onUpdate, this.onRemove, + this.onUpload, + this.onPostSetThumbnail, + this.onInsertLink, this.onUpdateBusy, }); @@ -50,10 +58,7 @@ class PostMediaPendingList extends StatelessWidget { if (result == null) return; - final rawBytes = - (await result.uiImage.toByteData(format: ImageByteFormat.png))! - .buffer - .asUint8List(); + final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List(); if (onUpdate != null) { final updatedMedia = PostWriteMedia.fromBytes( @@ -66,7 +71,7 @@ class PostMediaPendingList extends StatelessWidget { } Future _deleteAttachment(BuildContext context, int idx) async { - final media = attachments[idx]; + final media = idx == -1 ? thumbnail! : attachments[idx]; if (media.attachment == null) return; try { @@ -82,10 +87,40 @@ class PostMediaPendingList extends StatelessWidget { } } - ContextMenu _buildContextMenu( - BuildContext context, int idx, PostWriteMedia media) { + ContextMenu _buildContextMenu(BuildContext context, int idx, PostWriteMedia media) { return ContextMenu( entries: [ + if (media.attachment == null && onUpload != null) + MenuItem( + label: 'attachmentUpload'.tr(), + icon: Symbols.upload, + onSelected: () { + onUpload!(idx); + }), + if (media.attachment != null && onPostSetThumbnail != null && idx != -1) + MenuItem( + label: 'attachmentSetAsPostThumbnail'.tr(), + icon: Symbols.gallery_thumbnail, + onSelected: () { + onPostSetThumbnail!(idx); + }, + ) + else if (media.attachment != null && onPostSetThumbnail != null) + MenuItem( + label: 'attachmentUnsetAsPostThumbnail'.tr(), + icon: Symbols.cancel, + onSelected: () { + onPostSetThumbnail!(null); + }, + ), + if (media.attachment != null && onInsertLink != null) + MenuItem( + label: 'attachmentInsertLink'.tr(), + icon: Symbols.add_link, + onSelected: () { + onInsertLink!(idx); + }, + ), if (media.type == PostWriteMediaType.image && media.attachment != null) MenuItem( label: 'preview'.tr(), @@ -135,51 +170,91 @@ class PostMediaPendingList extends StatelessWidget { 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: attachments.length, - itemBuilder: (context, idx) { - final media = attachments[idx]; - return ContextMenuRegion( - contextMenu: _buildContextMenu(context, idx, media), - child: Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).dividerColor, - width: 1, + child: Row( + children: [ + const Gap(8), + if (thumbnail != null) + ContextMenuRegion( + contextMenu: _buildContextMenu(context, -1, thumbnail!), + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).dividerColor, + width: 1, + ), + borderRadius: BorderRadius.circular(8), ), - borderRadius: BorderRadius.circular(8), - ), - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(8)), - child: AspectRatio( - aspectRatio: 1, - child: switch (media.type) { - PostWriteMediaType.image => - LayoutBuilder(builder: (context, constraints) { - return Image( - image: media.getImageProvider( - context, - width: (constraints.maxWidth * devicePixelRatio) - .round(), - height: (constraints.maxHeight * devicePixelRatio) - .round(), - )!, - fit: BoxFit.cover, - ); - }), - _ => Container( - color: Theme.of(context).colorScheme.surface, - child: const Icon(Symbols.docs).center(), - ), - }, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: AspectRatio( + aspectRatio: 1, + child: switch (thumbnail!.type) { + PostWriteMediaType.image => LayoutBuilder(builder: (context, constraints) { + return Image( + image: thumbnail!.getImageProvider( + context, + width: (constraints.maxWidth * devicePixelRatio).round(), + height: (constraints.maxHeight * devicePixelRatio).round(), + )!, + fit: BoxFit.cover, + ); + }), + _ => Container( + color: Theme.of(context).colorScheme.surface, + child: const Icon(Symbols.docs).center(), + ), + }, + ), ), ), ), - ); - }, + if (thumbnail != null) const VerticalDivider(width: 1).padding(horizontal: 8), + Expanded( + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only(right: 8), + separatorBuilder: (context, index) => const Gap(8), + itemCount: attachments.length, + itemBuilder: (context, idx) { + final media = attachments[idx]; + return ContextMenuRegion( + contextMenu: _buildContextMenu(context, idx, media), + 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: switch (media.type) { + PostWriteMediaType.image => LayoutBuilder(builder: (context, constraints) { + return Image( + image: media.getImageProvider( + context, + width: (constraints.maxWidth * devicePixelRatio).round(), + height: (constraints.maxHeight * devicePixelRatio).round(), + )!, + fit: BoxFit.cover, + ); + }), + _ => Container( + color: Theme.of(context).colorScheme.surface, + child: const Icon(Symbols.docs).center(), + ), + }, + ), + ), + ), + ); + }, + ), + ), + ], ), ); } diff --git a/lib/widgets/post/post_meta_editor.dart b/lib/widgets/post/post_meta_editor.dart index 3a469f7..afdfbec 100644 --- a/lib/widgets/post/post_meta_editor.dart +++ b/lib/widgets/post/post_meta_editor.dart @@ -94,8 +94,8 @@ class PostMetaEditor extends StatelessWidget { onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), ).padding(horizontal: 24), - if (controller.mode == 'article') const Gap(4), - if (controller.mode == 'article') + if (controller.mode == 'articles') const Gap(4), + if (controller.mode == 'articles') TextField( controller: controller.descriptionController, maxLines: null,