From aeb7ebd6426aa397fb4a056518373b8d498e751c Mon Sep 17 00:00:00 2001 From: jingyun <> Date: Tue, 23 Jun 2026 10:42:14 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E5=88=86=E4=BA=AB=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ios/Podfile.lock | 6 ++ lib/common/util/share_util.dart | 74 ++++++++++++++++ .../news/presentation/news_detail_page.dart | 30 +++++++ .../presentation/profile_action_pages.dart | 86 +++++++++++++++---- pubspec.lock | 16 ++++ pubspec.yaml | 1 + 6 files changed, 196 insertions(+), 17 deletions(-) create mode 100644 lib/common/util/share_util.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 165f08a..70330da 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -28,6 +28,8 @@ PODS: - FlutterMacOS - platform_metadata (0.0.1): - Flutter + - share_plus (0.0.1): + - Flutter - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -54,6 +56,7 @@ DEPENDENCIES: - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - photo_manager (from `.symlinks/plugins/photo_manager/darwin`) - platform_metadata (from `.symlinks/plugins/platform_metadata/ios`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) @@ -86,6 +89,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/photo_manager/darwin" platform_metadata: :path: ".symlinks/plugins/platform_metadata/ios" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" sqflite_darwin: @@ -109,6 +114,7 @@ SPEC CHECKSUMS: permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e photo_manager: 25fd77df14f4f0ba5ef99e2c61814dde77e2bceb platform_metadata: a4d1e9569219bb2dcbb54510554611df032ea023 + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b diff --git a/lib/common/util/share_util.dart b/lib/common/util/share_util.dart new file mode 100644 index 0000000..5cef978 --- /dev/null +++ b/lib/common/util/share_util.dart @@ -0,0 +1,74 @@ +// ignore_for_file: use_build_context_synchronously + +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:share_plus/share_plus.dart'; + +ui.Rect? shareOriginForContext(BuildContext context) { + final renderObject = context.findRenderObject(); + if (renderObject is! RenderBox || !renderObject.hasSize) { + return null; + } + return renderObject.localToGlobal(Offset.zero) & renderObject.size; +} + +Future captureWidgetPng( + GlobalKey key, { + double pixelRatio = 3.0, +}) async { + await WidgetsBinding.instance.endOfFrame; + + final context = key.currentContext; + if (context == null) { + throw StateError('share target is not mounted'); + } + + final renderObject = context.findRenderObject(); + if (renderObject is! RenderRepaintBoundary) { + throw StateError('share target is not a repaint boundary'); + } + + final image = await renderObject.toImage(pixelRatio: pixelRatio); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) { + throw StateError('failed to convert share image'); + } + return byteData.buffer.asUint8List(); +} + +Future sharePngFromBoundary( + GlobalKey key, { + required String fileName, + String? text, + String? subject, + ui.Rect? sharePositionOrigin, + double pixelRatio = 3.0, +}) async { + final bytes = await captureWidgetPng(key, pixelRatio: pixelRatio); + final xFile = XFile.fromData(bytes, mimeType: 'image/png', name: fileName); + await SharePlus.instance.share( + ShareParams( + files: [xFile], + text: text, + subject: subject, + sharePositionOrigin: sharePositionOrigin, + ), + ); +} + +Future sharePlainText({ + required String text, + String? subject, + ui.Rect? sharePositionOrigin, +}) async { + await SharePlus.instance.share( + ShareParams( + text: text, + subject: subject, + sharePositionOrigin: sharePositionOrigin, + ), + ); +} diff --git a/lib/features/news/presentation/news_detail_page.dart b/lib/features/news/presentation/news_detail_page.dart index 1b5e68d..cec1edf 100644 --- a/lib/features/news/presentation/news_detail_page.dart +++ b/lib/features/news/presentation/news_detail_page.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import '../../../common/config/app_h5_urls.dart'; +import '../../../common/util/share_util.dart'; +import '../../../common/widgets/app_toast.dart'; import '../../../common/widgets/prototype_ui.dart'; import '../application/news_controller.dart'; @@ -35,6 +38,33 @@ class NewsDetailPage extends ConsumerWidget { item.metal.label, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), ), + actions: [ + Builder( + builder: (buttonContext) => IconButton( + onPressed: () async { + final shareText = [ + item.title, + '${item.source} · ${_timeLabel(item.publishedAt)} · ${item.metal.label}', + item.summary, + '打开金值查看:${AppH5Urls.baseUrl}/news/detail?id=${item.id}', + ].join('\n'); + try { + await sharePlainText( + text: shareText, + subject: item.title, + sharePositionOrigin: shareOriginForContext(buttonContext), + ); + } catch (e) { + debugPrint('share news failed: $e'); + if (!context.mounted) return; + await toastInfo(msg: '分享失败'); + } + }, + icon: const Icon(Icons.ios_share_outlined, size: 20), + color: const Color(0xFF8A8780), + ), + ), + ], ), body: SafeArea( child: ListView( diff --git a/lib/features/profile/presentation/profile_action_pages.dart b/lib/features/profile/presentation/profile_action_pages.dart index 2a9d363..e80b75d 100644 --- a/lib/features/profile/presentation/profile_action_pages.dart +++ b/lib/features/profile/presentation/profile_action_pages.dart @@ -4,10 +4,13 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'dart:ui' as ui; import '../../../common/domain/metal_type.dart'; import '../../../common/domain/money.dart'; +import '../../../common/widgets/app_toast.dart'; import '../../../common/router/app_router.dart'; +import '../../../common/util/share_util.dart'; import '../../../common/widgets/adaptive.dart'; import '../../../common/widgets/circle_back_button.dart'; import '../../../common/widgets/prototype_ui.dart'; @@ -44,7 +47,7 @@ class InviteRewardPage extends StatelessWidget { ), const SizedBox(height: 14), FilledButton( - onPressed: () => _toast(context, '演示:拉起微信 / 系统分享面板'), + onPressed: () => toastInfo(msg: '演示:拉起微信 / 系统分享面板'), child: const Text('分享给好友'), ), const SizedBox(height: 14), @@ -62,11 +65,42 @@ class InviteRewardPage extends StatelessWidget { } } -class ShareCardPage extends ConsumerWidget { +class ShareCardPage extends ConsumerStatefulWidget { const ShareCardPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _ShareCardPageState(); +} + +class _ShareCardPageState extends ConsumerState { + final GlobalKey _previewKey = GlobalKey(); + bool _sharing = false; + + Future _shareCard(String owner, {ui.Rect? origin}) async { + if (_sharing) return; + setState(() => _sharing = true); + try { + await sharePngFromBoundary( + _previewKey, + fileName: 'jinzhi_share_card.png', + text: '$owner 的贵金属估值卡,已通过金值生成。', + subject: '金值贵金属估值卡', + sharePositionOrigin: origin ?? shareOriginForContext(context), + ); + } catch (e) { + debugPrint('share card failed: $e'); + await Clipboard.setData(ClipboardData(text: '$owner 的贵金属估值卡,已通过金值生成。')); + if (!mounted) return; + toastInfo(msg: '图片分享失败,已复制分享文案'); + } finally { + if (mounted) { + setState(() => _sharing = false); + } + } + } + + @override + Widget build(BuildContext context) { final auth = ref.watch(authProvider); final currentUser = auth.valueOrNull is LoggedInAuthState ? (auth.valueOrNull as LoggedInAuthState).user @@ -79,7 +113,10 @@ class ShareCardPage extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _SharePreviewCard(owner: owner), + RepaintBoundary( + key: _previewKey, + child: _SharePreviewCard(owner: owner), + ), const SizedBox(height: 14), PrototypeCard( padding: EdgeInsets.zero, @@ -100,13 +137,32 @@ class ShareCardPage extends ConsumerWidget { ), ), const SizedBox(height: 14), - FilledButton( + Builder( + builder: (buttonContext) => FilledButton.icon( + onPressed: _sharing + ? null + : () { + final origin = shareOriginForContext(buttonContext); + _shareCard(owner, origin: origin); + }, + icon: _sharing + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.ios_share_outlined), + label: Text(_sharing ? '分享中' : '系统分享图片'), + ), + ), + const SizedBox(height: 12), + OutlinedButton( onPressed: () async { await Clipboard.setData( ClipboardData(text: '$owner 的贵金属估值卡,已通过金值生成。'), ); if (!context.mounted) return; - _toast(context, '分享文案已复制'); + toastInfo(msg: '分享文案已复制'); }, child: const Text('复制分享文案'), ), @@ -152,7 +208,7 @@ class LocalBackupPage extends StatelessWidget { ), const SizedBox(height: 14), FilledButton( - onPressed: () => _toast(context, '已生成本地备份文件'), + onPressed: () => toastInfo(msg: '已生成本地备份文件'), child: const Text('生成备份'), ), const SizedBox(height: 12), @@ -213,7 +269,7 @@ class CloudBackupPage extends ConsumerWidget { if (!loggedIn) { context.push(AppRoutes.login); } else { - _toast(context, '云同步入口已打开'); + toastInfo(msg: '云同步入口已打开'); } }, child: Text(loggedIn ? '开启云备份' : '去登录'), @@ -256,7 +312,7 @@ class WidgetSettingsPage extends StatelessWidget { ), const SizedBox(height: 14), FilledButton( - onPressed: () => _toast(context, '演示:打开系统小组件设置'), + onPressed: () => toastInfo(msg: '演示:打开系统小组件设置'), child: const Text('添加到桌面'), ), ], @@ -302,7 +358,7 @@ class PuritySettingsPage extends ConsumerWidget { title: purity, value: (settings.purityPresets[purity] ?? 1) .toStringAsFixed(4), - onTap: () => _toast(context, '演示:自定义「$purity」纯度系数'), + onTap: () => toastInfo(msg: '演示:自定义「$purity」纯度系数'), ), if (purity != group.$2.last) const PrototypeDivider(margin: EdgeInsets.zero), @@ -599,12 +655,12 @@ class AboutPage extends StatelessWidget { const SizedBox(height: 16), _GoldTextAction( label: '提交意见反馈', - onTap: () => _toast(context, '演示:打开反馈表单'), + onTap: () => toastInfo(msg: '演示:打开反馈表单'), ), const SizedBox(height: 10), _GoldTextAction( label: '检查更新', - onTap: () => _toast(context, '当前已是最新版本'), + onTap: () => toastInfo(msg: '当前已是最新版本'), ), ], ), @@ -718,7 +774,7 @@ class _InviteCodeCard extends StatelessWidget { const ClipboardData(text: 'JINZHI-8K3F'), ); if (!context.mounted) return; - _toast(context, '已复制邀请码'); + toastInfo(msg: '已复制邀请码'); }, child: const Text( '复制', @@ -1419,7 +1475,3 @@ class _GoldDot extends StatelessWidget { ); } } - -void _toast(BuildContext context, String message) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); -} diff --git a/pubspec.lock b/pubspec.lock index 1fb2477..072c562 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1267,6 +1267,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.53.6" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa" + url: "https://pub.dev" + source: hosted + version: "12.0.2" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" + url: "https://pub.dev" + source: hosted + version: "6.1.0" shared_preferences: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 0d312c4..88ec0ca 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: url_launcher: ^6.3.1 gal: ^2.3.2 image_picker: ^1.1.2 + share_plus: ^12.0.1 wechat_assets_picker: ^10.1.2 flutter_cache_manager: ^3.4.1 photo_view: ^0.15.0