diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index b0da426..3346c9b 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -403,6 +403,7 @@ "accountStatusOffline": "Offline", "accountStatusLastSeen": "Last seen at {}", "postArticle": "Article on the Solar Network", + "postStory": "Story on the Solar Network", "articleWrittenAt": "Written at {}", "articleEditedAt": "Edited at {}", "attachmentSaved": "Saved to album", @@ -436,5 +437,7 @@ "publisherBlockHint": "Block {}", "publisherBlockHintDescription": "You are going to block this publisher's maintainer, this will also block publishers that run by the same user.", "userUnblocked": "{} has been unblocked.", - "userBlocked": "{} has been blocked." + "userBlocked": "{} has been blocked.", + "postSharingViaPicture": "Capturing post as picture, please stand by...", + "postImageShareAds": "Explore posts on the Solar Network" } diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index 413cd48..cc6d318 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -401,6 +401,7 @@ "accountStatusOffline": "离线", "accountStatusLastSeen": "最后一次在 {} 上线", "postArticle": "Solar Network 上的文章", + "postStory": "Solar Network 上的故事", "articleWrittenAt": "发表于 {}", "articleEditedAt": "编辑于 {}", "attachmentSaved": "已保存到相册", @@ -434,5 +435,7 @@ "publisherBlockHint": "屏蔽 {}", "publisherBlockHintDescription": "你正要屏蔽此发布者的运营者,该操作也将屏蔽由同一用户运营的发布者。", "userUnblocked": "已解除屏蔽用户 {}", - "userBlocked": "已屏蔽用户 {}" + "userBlocked": "已屏蔽用户 {}", + "postSharingViaPicture": "正在生成帖子截图,请稍等片刻……", + "postImageShareAds": "来 Solar Network 探索更多有趣帖子" } diff --git a/lib/providers/post.dart b/lib/providers/post.dart index ba98350..74466bb 100644 --- a/lib/providers/post.dart +++ b/lib/providers/post.dart @@ -53,6 +53,11 @@ class SnPostContentProvider { if (out.body['thumbnail'] != null) { rids.add(out.body['thumbnail']); } + if (out.repostId != null) { + out = out.copyWith( + repostTo: await _preloadRelatedDataSingle(out.repostTo!), + ); + } final attachments = await _attach.getMultiple(rids.toList()); out = out.copyWith( diff --git a/lib/widgets/attachment/attachment_list.dart b/lib/widgets/attachment/attachment_list.dart index c366a9b..db0d6ca 100644 --- a/lib/widgets/attachment/attachment_list.dart +++ b/lib/widgets/attachment/attachment_list.dart @@ -1,5 +1,6 @@ import 'dart:math' as math; +import 'package:collection/collection.dart'; import 'package:dismissible_page/dismissible_page.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -14,19 +15,21 @@ class AttachmentList extends StatefulWidget { final List data; final bool bordered; final bool noGrow; + final bool isFlatted; final double? maxHeight; final EdgeInsets? listPadding; + const AttachmentList({ super.key, required this.data, this.bordered = false, this.noGrow = false, + this.isFlatted = false, this.maxHeight, this.listPadding, }); - static const BorderRadius kDefaultRadius = - BorderRadius.all(Radius.circular(8)); + static const BorderRadius kDefaultRadius = BorderRadius.all(Radius.circular(8)); @override State createState() => _AttachmentListState(); @@ -44,9 +47,8 @@ class _AttachmentListState extends State { Widget build(BuildContext context) { return LayoutBuilder( builder: (context, layoutConstraints) { - final borderSide = widget.bordered - ? BorderSide(width: 1, color: Theme.of(context).dividerColor) - : BorderSide.none; + final borderSide = + widget.bordered ? BorderSide(width: 1, color: Theme.of(context).dividerColor) : BorderSide.none; final backgroundColor = Theme.of(context).colorScheme.surfaceContainer; final constraints = BoxConstraints( minWidth: 80, @@ -56,14 +58,13 @@ class _AttachmentListState extends State { if (widget.data.isEmpty) return const SizedBox.shrink(); if (widget.data.length == 1) { - final singleAspectRatio = - widget.data[0]?.metadata['ratio']?.toDouble() ?? - switch (widget.data[0]?.mimetype.split('/').firstOrNull) { - 'audio' => 16 / 9, - 'video' => 16 / 9, - _ => 1, - } - .toDouble(); + final singleAspectRatio = widget.data[0]?.metadata['ratio']?.toDouble() ?? + switch (widget.data[0]?.mimetype.split('/').firstOrNull) { + 'audio' => 16 / 9, + 'video' => 16 / 9, + _ => 1, + } + .toDouble(); return Container( constraints: ResponsiveBreakpoints.of(context).largerThan(MOBILE) @@ -79,8 +80,7 @@ class _AttachmentListState extends State { child: GestureDetector( child: Builder( builder: (context) { - if (ResponsiveBreakpoints.of(context).largerThan(MOBILE) || - widget.noGrow) { + if (ResponsiveBreakpoints.of(context).largerThan(MOBILE) || widget.noGrow) { return Padding( // Single child list-like displaying padding: widget.listPadding ?? EdgeInsets.zero, @@ -129,6 +129,37 @@ class _AttachmentListState extends State { ); } + if (widget.isFlatted) { + return Wrap( + spacing: 4, + runSpacing: 4, + children: widget.data + .mapIndexed( + (idx, ele) => AspectRatio( + aspectRatio: (ele?.metadata['ratio'] ?? 1).toDouble(), + child: Container( + decoration: BoxDecoration( + color: backgroundColor, + border: Border( + top: borderSide, + bottom: borderSide, + ), + borderRadius: AttachmentList.kDefaultRadius, + ), + child: ClipRRect( + borderRadius: AttachmentList.kDefaultRadius, + child: AttachmentItem( + data: ele, + heroTag: heroTags[idx], + ), + ), + ), + ), + ) + .toList(), + ); + } + return AspectRatio( aspectRatio: (widget.data.firstOrNull?.metadata['ratio'] ?? 1).toDouble(), child: Container( @@ -147,9 +178,7 @@ class _AttachmentListState extends State { onTap: () { context.pushTransparentRoute( AttachmentZoomView( - data: widget.data - .where((ele) => ele != null) - .cast(), + data: widget.data.where((ele) => ele != null).cast(), initialIndex: idx, heroTags: heroTags, ), diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index 86ea46f..942e7f0 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -5,10 +5,15 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.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:path_provider/path_provider.dart'; import 'package:popover/popover.dart'; import 'package:provider/provider.dart'; +import 'package:qr_flutter/qr_flutter.dart'; import 'package:relative_time/relative_time.dart'; +import 'package:responsive_framework/responsive_framework.dart'; +import 'package:screenshot/screenshot.dart'; import 'package:share_plus/share_plus.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:surface/providers/sn_network.dart'; @@ -56,6 +61,9 @@ class PostItem extends StatelessWidget { Widget build(BuildContext context) { final sn = context.read(); + final ua = context.read(); + final isAuthor = ua.isAuthorized && data.publisher.accountId == ua.user!.id; + // Article headline preview if (!showFullPost && data.type == 'article') { return Container( @@ -65,6 +73,7 @@ class PostItem extends StatelessWidget { children: [ _PostContentHeader( data: data, + isAuthor: isAuthor, onDeleted: () { if (onDeleted != null) {} }, @@ -191,6 +200,118 @@ class PostItem extends StatelessWidget { } } +class PostShareImage extends StatelessWidget { + const PostShareImage({ + super.key, + required this.data, + }); + + final SnPost data; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 480, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _PostContentHeader( + data: data, + onDeleted: () {}, + showMenu: false, + isRelativeDate: false, + ).padding(horizontal: 16, bottom: 8), + _PostHeadline( + data: data, + isEnlarge: data.type == 'article', + ).padding(horizontal: 16, bottom: 8), + _PostContentBody( + data: data, + isEnlarge: data.type == 'article', + ).padding(horizontal: 16, bottom: 8), + if (data.repostTo != null) + _PostQuoteContent( + child: data.repostTo!, + isRelativeDate: false, + isFlatted: true, + ).padding(horizontal: 16, bottom: 8), + if (data.type != 'article' && (data.preload?.attachments?.isNotEmpty ?? false)) + AttachmentList( + data: data.preload!.attachments!, + isFlatted: true, + ).padding(horizontal: 16, bottom: 8), + _PostBottomAction( + data: data, + showComments: true, + showReactions: true, + onChanged: (SnPost data) {}, + ).padding(left: 8, right: 14), + const Divider(height: 1), + const Gap(12), + SizedBox( + height: 100, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${data.aliasPrefix} / ${data.alias ?? '#${data.id}'}', + style: GoogleFonts.robotoMono(fontSize: 17), + ), + const Gap(2), + Text( + switch (data.type) { + 'article' => 'postArticle'.tr(), + _ => 'postStory'.tr(), + }, + style: GoogleFonts.robotoMono(fontSize: 12), + ), + ], + ), + ), + Text( + 'postImageShareAds', + style: GoogleFonts.robotoMono(fontSize: 13), + ).tr(), + ], + ), + ), + QrImageView( + padding: EdgeInsets.zero, + data: 'https://solsynth.dev/posts/${data.id}', + version: QrVersions.auto, + size: 100, + gapless: true, + embeddedImage: AssetImage('assets/icon/icon-light-radius.png'), + embeddedImageStyle: QrEmbeddedImageStyle( + size: Size(32, 32), + ), + eyeStyle: QrEyeStyle( + eyeShape: QrEyeShape.circle, + color: Theme.of(context).colorScheme.onSurface, + ), + dataModuleStyle: QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: Theme.of(context).colorScheme.onSurface, + ), + ) + ], + ), + ).padding(left: 16, right: 32, vertical: 8), + ], + ).padding(vertical: 16), + ); + } +} + class _PostBottomAction extends StatelessWidget { final SnPost data; final bool showComments; @@ -204,17 +325,57 @@ class _PostBottomAction extends StatelessWidget { required this.onChanged, }); - void _doShare() { + void _doShare(BuildContext context) { + final box = context.findRenderObject() as RenderBox?; final url = 'https://solsynth.dev/posts/${data.id}'; if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { - Share.shareUri(Uri.parse(url)); + Share.shareUri(Uri.parse(url), sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size); } else { - Share.share(url); + Share.share(url, sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size); } } - void _doShareViaPicture() { + void _doShareViaPicture(BuildContext context) async { + final box = context.findRenderObject() as RenderBox?; + context.showSnackbar('postSharingViaPicture'.tr()); + final controller = ScreenshotController(); + final capturedImage = await controller.captureFromLongWidget( + InheritedTheme.captureAll( + context, + MediaQuery( + data: MediaQuery.of(context), + child: Material( + child: MultiProvider( + providers: [ + Provider(create: (_) => context.read()), + ], + child: ResponsiveBreakpoints.builder( + breakpoints: ResponsiveBreakpoints.of(context).breakpoints, + child: PostShareImage(data: data), + ), + ), + ), + ), + ), + pixelRatio: 3, + context: context, + ); + + if (kIsWeb) return; + + final directory = await getTemporaryDirectory(); + final imagePath = await File( + '${directory.path}/sn-share-via-image-${DateTime.now().millisecondsSinceEpoch}.png', + ).create(); + await imagePath.writeAsBytes(capturedImage); + + await Share.shareXFiles( + [XFile(imagePath.path)], + sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, + ); + + await imagePath.delete(); } @override @@ -301,8 +462,8 @@ class _PostBottomAction extends StatelessWidget { ..removeLast(), ), InkWell( - onTap: _doShare, - onLongPress: _doShareViaPicture, + onTap: () => _doShare(context), + onLongPress: () => _doShareViaPicture(context), child: Icon( Symbols.share, size: 20, @@ -410,13 +571,17 @@ class _PostHeadline extends StatelessWidget { class _PostContentHeader extends StatelessWidget { final SnPost data; + final bool isAuthor; final bool isCompact; + final bool isRelativeDate; final bool showMenu; final Function onDeleted; const _PostContentHeader({ required this.data, + this.isAuthor = false, this.isCompact = false, + this.isRelativeDate = true, this.showMenu = true, required this.onDeleted, }); @@ -446,9 +611,6 @@ class _PostContentHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final ua = context.read(); - final isAuthor = ua.isAuthorized && data.publisher.accountId == ua.user!.id; - return Row( children: [ GestureDetector( @@ -484,9 +646,11 @@ class _PostContentHeader extends StatelessWidget { children: [ Text('@${data.publisher.name}').fontSize(13), const Gap(4), - Text(RelativeTime(context).format( - data.publishedAt ?? data.createdAt, - )).fontSize(13), + Text( + isRelativeDate + ? RelativeTime(context).format(data.publishedAt ?? data.createdAt) + : DateFormat('y/M/d HH:mm').format(data.publishedAt ?? data.createdAt), + ).fontSize(13), ], ).opacity(0.8), ], @@ -501,9 +665,11 @@ class _PostContentHeader extends StatelessWidget { children: [ Text('@${data.publisher.name}').fontSize(13), const Gap(4), - Text(RelativeTime(context).format( - data.publishedAt ?? data.createdAt, - )).fontSize(13), + Text( + isRelativeDate + ? RelativeTime(context).format(data.publishedAt ?? data.createdAt) + : DateFormat('y/M/d HH:mm').format(data.publishedAt ?? data.createdAt), + ).fontSize(13), ], ).opacity(0.8), ], @@ -628,8 +794,15 @@ class _PostContentBody extends StatelessWidget { class _PostQuoteContent extends StatelessWidget { final SnPost child; + final bool isRelativeDate; + final bool isFlatted; - const _PostQuoteContent({super.key, required this.child}); + const _PostQuoteContent({ + super.key, + this.isRelativeDate = true, + this.isFlatted = false, + required this.child, + }); @override Widget build(BuildContext context) { @@ -650,6 +823,7 @@ class _PostQuoteContent extends StatelessWidget { _PostContentHeader( data: child, isCompact: true, + isRelativeDate: isRelativeDate, showMenu: false, onDeleted: () {}, ).padding(bottom: 4), @@ -665,12 +839,15 @@ class _PostQuoteContent extends StatelessWidget { ), child: AttachmentList( data: child.preload!.attachments!, + isFlatted: isFlatted, listPadding: const EdgeInsets.symmetric(horizontal: 12), ), ).padding( top: 8, bottom: (child.preload?.attachments?.length ?? 0) > 1 ? 12 : 0, - ), + ) + else + const Gap(8), ], ), ), diff --git a/pubspec.lock b/pubspec.lock index 8c7587b..1911a9e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -266,10 +266,10 @@ packages: dependency: transitive description: name: connectivity_plus - sha256: "876849631b0c7dc20f8b471a2a03142841b482438e3b707955464f5ffca3e4c3" + sha256: e0817759ec6d2d8e57eb234e6e57d2173931367a865850c7acea40d4b4f9c27d url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "6.1.1" connectivity_plus_platform_interface: dependency: transitive description: @@ -362,18 +362,18 @@ packages: dependency: transitive description: name: device_info_plus - sha256: f545ffbadee826f26f2e1a0f0cbd667ae9a6011cc0f77c0f8f00a969655e6e95 + sha256: "4fa68e53e26ab17b70ca39f072c285562cfc1589df5bb1e9295db90f6645f431" url: "https://pub.dev" source: hosted - version: "11.1.1" + version: "11.2.0" device_info_plus_platform_interface: dependency: transitive description: name: device_info_plus_platform_interface - sha256: "282d3cf731045a2feb66abfe61bbc40870ae50a3ed10a4d3d217556c35c8c2ba" + sha256: "0b04e02b30791224b31969eb1b50d723498f402971bff3630bca2ba839bd1ed2" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.2" dio: dependency: "direct main" description: @@ -1190,18 +1190,18 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: da8d9ac8c4b1df253d1a328b7bf01ae77ef132833479ab40763334db13b91cce + sha256: "70c421fe9d9cc1a9a7f3b05ae56befd469fe4f8daa3b484823141a55442d858d" url: "https://pub.dev" source: hosted - version: "8.1.1" + version: "8.1.2" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: ac1f4a4847f1ade8e6a87d1f39f5d7c67490738642e2542f559ec38c37489a66 + sha256: a5ef9986efc7bf772f2696183a3992615baa76c1ffb1189318dd8803778fb05b url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" pasteboard: dependency: "direct main" description: @@ -1402,6 +1402,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" relative_time: dependency: "direct main" description: @@ -1502,18 +1518,18 @@ packages: dependency: "direct main" description: name: share_plus - sha256: "9c9bafd4060728d7cdb2464c341743adbd79d327cb067ec7afb64583540b47c8" + sha256: "6327c3f233729374d0abaafd61f6846115b2a481b4feddd8534211dc10659400" url: "https://pub.dev" source: hosted - version: "10.1.2" + version: "10.1.3" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: c57c0bbfec7142e3a0f55633be504b796af72e60e3c791b44d5a017b985f7a48 + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b url: "https://pub.dev" source: hosted - version: "5.0.1" + version: "5.0.2" shared_preferences: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index d4d61af..2b3f88e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -99,6 +99,7 @@ dependencies: package_info_plus: ^8.1.1 intl: ^0.19.0 screenshot: ^3.0.0 + qr_flutter: ^4.1.0 dev_dependencies: flutter_test: