diff --git a/assets/translations/en.json b/assets/translations/en.json index 04f1009..b595150 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -383,5 +383,7 @@ "accountStatusOnline": "Online", "accountStatusOffline": "Offline", "accountStatusLastSeen": "Last seen at {}", - "postArticle": "Article on the Solar Network" + "postArticle": "Article on the Solar Network", + "articleWrittenAt": "Written at {}", + "articleEditedAt": "Edited at {}" } diff --git a/assets/translations/zh.json b/assets/translations/zh.json index c6d8a87..8f24075 100644 --- a/assets/translations/zh.json +++ b/assets/translations/zh.json @@ -383,5 +383,8 @@ "accountStatusOnline": "在线", "accountStatusOffline": "离线", "accountStatusLastSeen": "最后一次在 {} 上线", - "postArticle": "Solar Network 上的文章" + "postArticle": "Solar Network 上的文章", + "articleWrittenAt": "发表于 {}", + "articleEditedAt": "编辑于 {}" + } diff --git a/lib/controllers/post_write_controller.dart b/lib/controllers/post_write_controller.dart index 8e84456..86fe626 100644 --- a/lib/controllers/post_write_controller.dart +++ b/lib/controllers/post_write_controller.dart @@ -264,6 +264,7 @@ class PostWriteController extends ChangeNotifier { final item = await _uploadAttachment(context, media); attachments[idx] = PostWriteMedia(item); + isBusy = false; notifyListeners(); } @@ -395,6 +396,9 @@ class PostWriteController extends ChangeNotifier { attachments.add(thumbnail!); thumbnail = null; } else { + if (thumbnail != null) { + attachments.add(thumbnail!); + } thumbnail = attachments[idx]; attachments.removeAt(idx); } diff --git a/lib/widgets/markdown_content.dart b/lib/widgets/markdown_content.dart index 048a370..497b4ea 100644 --- a/lib/widgets/markdown_content.dart +++ b/lib/widgets/markdown_content.dart @@ -1,44 +1,48 @@ import 'dart:ui'; +import 'package:dismissible_page/dismissible_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:markdown/markdown.dart' as markdown; import 'package:styled_widget/styled_widget.dart'; +import 'package:surface/types/attachment.dart'; +import 'package:surface/widgets/attachment/attachment_item.dart'; import 'package:surface/widgets/universal_image.dart'; import 'package:syntax_highlight/syntax_highlight.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:path/path.dart'; +import 'package:uuid/uuid.dart'; -class MarkdownTextContent extends StatefulWidget { +import 'attachment/attachment_detail.dart'; + +class MarkdownTextContent extends StatelessWidget { final String content; final bool isSelectable; - final bool isLargeText; final bool isAutoWarp; + final TextScaler? textScaler; + final List? attachments; const MarkdownTextContent({ super.key, required this.content, this.isSelectable = false, - this.isLargeText = false, this.isAutoWarp = false, + this.textScaler, + this.attachments, }); - @override - State createState() => _MarkdownTextContentState(); -} - -class _MarkdownTextContentState extends State { Widget _buildContent(BuildContext context) { return Markdown( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - data: widget.content, + data: content, padding: EdgeInsets.zero, styleSheet: MarkdownStyleSheet.fromTheme( Theme.of(context), ).copyWith( - textScaler: TextScaler.linear(widget.isLargeText ? 1.1 : 1), + textScaler: textScaler, blockquote: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, ), @@ -73,7 +77,7 @@ class _MarkdownTextContentState extends State { ...markdown.ExtensionSet.gitHubFlavored.blockSyntaxes, ], [ - if (widget.isAutoWarp) markdown.LineBreakSyntax(), + if (isAutoWarp) markdown.LineBreakSyntax(), _UserNameCardInlineSyntax(), markdown.AutolinkSyntax(), markdown.AutolinkExtensionSyntax(), @@ -85,7 +89,12 @@ class _MarkdownTextContentState extends State { onTapLink: (text, href, title) async { if (href == null) return; if (href.startsWith('solink://')) { - // final segments = href.replaceFirst('solink://', '').split('/'); + final uri = href.replaceFirst('solink://', ''); + final segments = uri.split('/'); + switch (segments[0]) { + default: + GoRouter.of(context).push(uri); + } return; } @@ -99,7 +108,57 @@ class _MarkdownTextContentState extends State { double? width, height; BoxFit? fit; if (url.startsWith('solink://')) { - // final segments = url.replaceFirst('solink://', '').split('/'); + final segments = url.replaceFirst('solink://', '').split('/'); + switch (segments[0]) { + case 'attachments': + final attachment = attachments?.firstWhere( + (ele) => ele?.rid == segments[1], + orElse: () => null, + ); + if (attachment != null) { + const uuid = Uuid(); + final heroTag = uuid.v4(); + return GestureDetector( + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8)), + border: Border.all( + color: Theme.of(context).dividerColor, + width: 1, + ), + ), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: AspectRatio( + aspectRatio: attachment.metadata['ratio'] ?? + switch (attachment.mimetype.split('/').firstOrNull) { + 'audio' => 16 / 9, + 'video' => 16 / 9, + _ => 1, + } + .toDouble(), + child: AttachmentItem( + data: attachment, + heroTag: heroTag, + ), + ), + ), + ), + onTap: () { + context.pushTransparentRoute( + AttachmentZoomView( + data: [attachment], + initialIndex: 0, + heroTags: [heroTag], + ), + backgroundColor: Colors.black.withOpacity(0.7), + rootNavigator: true, + ); + }, + ); + } + break; + } return const SizedBox.shrink(); } return UniversalImage( @@ -114,7 +173,7 @@ class _MarkdownTextContentState extends State { @override Widget build(BuildContext context) { - if (widget.isSelectable) { + if (isSelectable) { return SelectionArea(child: _buildContent(context)); } return _buildContent(context); @@ -153,13 +212,11 @@ class _MarkdownTextCodeElement extends MarkdownElementBuilder { child: FutureBuilder( future: (() async { final docPath = '../../../'; - final highlightingPath = - join(docPath, 'assets/highlighting', language); + final highlightingPath = join(docPath, 'assets/highlighting', language); await Highlighter.initialize([highlightingPath]); return Highlighter( language: highlightingPath, - theme: PlatformDispatcher.instance.platformBrightness == - Brightness.light + theme: PlatformDispatcher.instance.platformBrightness == Brightness.light ? await HighlighterTheme.loadLightTheme() : await HighlighterTheme.loadDarkTheme(), ); diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index 933ffe8..3433638 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:popover/popover.dart'; import 'package:provider/provider.dart'; @@ -136,8 +137,14 @@ class PostItem extends StatelessWidget { }, ).padding(horizontal: 12, vertical: 8), 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), + _PostHeadline( + data: data, + isEnlarge: data.type == 'article' && showFullPost, + ).padding(horizontal: 16, bottom: 8), + _PostContentBody( + data: data, + isEnlarge: data.type == 'article' && showFullPost, + ).padding(horizontal: 16, bottom: 6), if (data.repostTo != null) _PostQuoteContent(child: data.repostTo!).padding( horizontal: 12, @@ -156,7 +163,7 @@ class PostItem extends StatelessWidget { ], ), ), - if (data.preload?.attachments?.isNotEmpty ?? false) + if ((data.preload?.attachments?.isNotEmpty ?? false) && data.type != 'article') AttachmentList( data: data.preload!.attachments!, bordered: true, @@ -292,11 +299,82 @@ class _PostBottomAction extends StatelessWidget { class _PostHeadline extends StatelessWidget { final SnPost data; + final bool isEnlarge; - const _PostHeadline({super.key, required this.data}); + const _PostHeadline({ + super.key, + required this.data, + this.isEnlarge = false, + }); @override Widget build(BuildContext context) { + if (isEnlarge) { + final sn = context.read(); + final textScaler = TextScaler.linear(1.5); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (data.preload?.thumbnail != null) + Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8)), + border: Border.all( + color: Theme.of(context).dividerColor, + width: 1, + ), + ), + child: AspectRatio( + aspectRatio: 16 / 9, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: AutoResizeUniversalImage( + sn.getAttachmentUrl(data.preload!.thumbnail!.rid), + fit: BoxFit.cover, + ), + ), + ), + ), + if (data.body['title'] != null) + Text( + data.body['title'], + style: Theme.of(context).textTheme.titleMedium, + textScaler: TextScaler.linear(1.4), + ), + if (data.body['description'] != null) + Text( + data.body['description'], + style: Theme.of(context).textTheme.bodyMedium, + textScaler: TextScaler.linear(1.1), + ), + if (data.body['description'] != null) const Gap(8) else const Gap(4), + Row( + children: [ + Text( + 'articleWrittenAt'.tr( + args: [DateFormat('y/M/d HH:mm').format(data.createdAt)], + ), + style: TextStyle(fontSize: 13), + ), + const Gap(8), + if (data.updatedAt != data.createdAt) + Text( + 'articleEditedAt'.tr( + args: [DateFormat('y/M/d HH:mm').format(data.updatedAt)], + ), + style: TextStyle(fontSize: 13), + ), + ], + ).opacity(0.75), + const Gap(8), + const Divider(height: 1, thickness: 1), + const Gap(8), + ], + ); + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -502,14 +580,22 @@ class _PostContentHeader extends StatelessWidget { } class _PostContentBody extends StatelessWidget { - final dynamic data; + final SnPost data; + final bool isEnlarge; - const _PostContentBody({this.data}); + const _PostContentBody({ + required this.data, + this.isEnlarge = false, + }); @override Widget build(BuildContext context) { - if (data['content'] == null) return const SizedBox.shrink(); - return MarkdownTextContent(content: data['content']); + if (data.body['content'] == null) return const SizedBox.shrink(); + return MarkdownTextContent( + textScaler: isEnlarge ? TextScaler.linear(1.1) : null, + content: data.body['content'], + attachments: data.preload?.attachments, + ); } } @@ -537,7 +623,7 @@ class _PostQuoteContent extends StatelessWidget { showMenu: false, onDeleted: () {}, ).padding(bottom: 4), - _PostContentBody(data: child.body), + _PostContentBody(data: child), ], ), ); diff --git a/lib/widgets/post/post_media_pending_list.dart b/lib/widgets/post/post_media_pending_list.dart index 1a37756..42e0abb 100644 --- a/lib/widgets/post/post_media_pending_list.dart +++ b/lib/widgets/post/post_media_pending_list.dart @@ -208,7 +208,11 @@ class PostMediaPendingList extends StatelessWidget { ), ), ), - if (thumbnail != null) const VerticalDivider(width: 1).padding(horizontal: 8), + if (thumbnail != null) + const VerticalDivider(width: 1, thickness: 1).padding( + horizontal: 12, + vertical: 16, + ), Expanded( child: ListView.separated( scrollDirection: Axis.horizontal,