feat:分享功能

This commit is contained in:
jingyun
2026-06-23 10:42:14 +08:00
parent d59c3212b8
commit aeb7ebd642
6 changed files with 196 additions and 17 deletions
+6
View File
@@ -28,6 +28,8 @@ PODS:
- FlutterMacOS - FlutterMacOS
- platform_metadata (0.0.1): - platform_metadata (0.0.1):
- Flutter - Flutter
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1): - shared_preferences_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
@@ -54,6 +56,7 @@ DEPENDENCIES:
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- photo_manager (from `.symlinks/plugins/photo_manager/darwin`) - photo_manager (from `.symlinks/plugins/photo_manager/darwin`)
- platform_metadata (from `.symlinks/plugins/platform_metadata/ios`) - 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`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
@@ -86,6 +89,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/photo_manager/darwin" :path: ".symlinks/plugins/photo_manager/darwin"
platform_metadata: platform_metadata:
:path: ".symlinks/plugins/platform_metadata/ios" :path: ".symlinks/plugins/platform_metadata/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation: shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin" :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin: sqflite_darwin:
@@ -109,6 +114,7 @@ SPEC CHECKSUMS:
permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e
photo_manager: 25fd77df14f4f0ba5ef99e2c61814dde77e2bceb photo_manager: 25fd77df14f4f0ba5ef99e2c61814dde77e2bceb
platform_metadata: a4d1e9569219bb2dcbb54510554611df032ea023 platform_metadata: a4d1e9569219bb2dcbb54510554611df032ea023
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
+74
View File
@@ -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<Uint8List> 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<void> 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<void> sharePlainText({
required String text,
String? subject,
ui.Rect? sharePositionOrigin,
}) async {
await SharePlus.instance.share(
ShareParams(
text: text,
subject: subject,
sharePositionOrigin: sharePositionOrigin,
),
);
}
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.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 '../../../common/widgets/prototype_ui.dart';
import '../application/news_controller.dart'; import '../application/news_controller.dart';
@@ -35,6 +38,33 @@ class NewsDetailPage extends ConsumerWidget {
item.metal.label, item.metal.label,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), 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( body: SafeArea(
child: ListView( child: ListView(
@@ -4,10 +4,13 @@ import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:shadcn_ui/shadcn_ui.dart';
import 'dart:ui' as ui;
import '../../../common/domain/metal_type.dart'; import '../../../common/domain/metal_type.dart';
import '../../../common/domain/money.dart'; import '../../../common/domain/money.dart';
import '../../../common/widgets/app_toast.dart';
import '../../../common/router/app_router.dart'; import '../../../common/router/app_router.dart';
import '../../../common/util/share_util.dart';
import '../../../common/widgets/adaptive.dart'; import '../../../common/widgets/adaptive.dart';
import '../../../common/widgets/circle_back_button.dart'; import '../../../common/widgets/circle_back_button.dart';
import '../../../common/widgets/prototype_ui.dart'; import '../../../common/widgets/prototype_ui.dart';
@@ -44,7 +47,7 @@ class InviteRewardPage extends StatelessWidget {
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
FilledButton( FilledButton(
onPressed: () => _toast(context, '演示:拉起微信 / 系统分享面板'), onPressed: () => toastInfo(msg: '演示:拉起微信 / 系统分享面板'),
child: const Text('分享给好友'), child: const Text('分享给好友'),
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
@@ -62,11 +65,42 @@ class InviteRewardPage extends StatelessWidget {
} }
} }
class ShareCardPage extends ConsumerWidget { class ShareCardPage extends ConsumerStatefulWidget {
const ShareCardPage({super.key}); const ShareCardPage({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<ShareCardPage> createState() => _ShareCardPageState();
}
class _ShareCardPageState extends ConsumerState<ShareCardPage> {
final GlobalKey _previewKey = GlobalKey();
bool _sharing = false;
Future<void> _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 auth = ref.watch(authProvider);
final currentUser = auth.valueOrNull is LoggedInAuthState final currentUser = auth.valueOrNull is LoggedInAuthState
? (auth.valueOrNull as LoggedInAuthState).user ? (auth.valueOrNull as LoggedInAuthState).user
@@ -79,7 +113,10 @@ class ShareCardPage extends ConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_SharePreviewCard(owner: owner), RepaintBoundary(
key: _previewKey,
child: _SharePreviewCard(owner: owner),
),
const SizedBox(height: 14), const SizedBox(height: 14),
PrototypeCard( PrototypeCard(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
@@ -100,13 +137,32 @@ class ShareCardPage extends ConsumerWidget {
), ),
), ),
const SizedBox(height: 14), 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 { onPressed: () async {
await Clipboard.setData( await Clipboard.setData(
ClipboardData(text: '$owner 的贵金属估值卡,已通过金值生成。'), ClipboardData(text: '$owner 的贵金属估值卡,已通过金值生成。'),
); );
if (!context.mounted) return; if (!context.mounted) return;
_toast(context, '分享文案已复制'); toastInfo(msg: '分享文案已复制');
}, },
child: const Text('复制分享文案'), child: const Text('复制分享文案'),
), ),
@@ -152,7 +208,7 @@ class LocalBackupPage extends StatelessWidget {
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
FilledButton( FilledButton(
onPressed: () => _toast(context, '已生成本地备份文件'), onPressed: () => toastInfo(msg: '已生成本地备份文件'),
child: const Text('生成备份'), child: const Text('生成备份'),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -213,7 +269,7 @@ class CloudBackupPage extends ConsumerWidget {
if (!loggedIn) { if (!loggedIn) {
context.push(AppRoutes.login); context.push(AppRoutes.login);
} else { } else {
_toast(context, '云同步入口已打开'); toastInfo(msg: '云同步入口已打开');
} }
}, },
child: Text(loggedIn ? '开启云备份' : '去登录'), child: Text(loggedIn ? '开启云备份' : '去登录'),
@@ -256,7 +312,7 @@ class WidgetSettingsPage extends StatelessWidget {
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
FilledButton( FilledButton(
onPressed: () => _toast(context, '演示:打开系统小组件设置'), onPressed: () => toastInfo(msg: '演示:打开系统小组件设置'),
child: const Text('添加到桌面'), child: const Text('添加到桌面'),
), ),
], ],
@@ -302,7 +358,7 @@ class PuritySettingsPage extends ConsumerWidget {
title: purity, title: purity,
value: (settings.purityPresets[purity] ?? 1) value: (settings.purityPresets[purity] ?? 1)
.toStringAsFixed(4), .toStringAsFixed(4),
onTap: () => _toast(context, '演示:自定义「$purity」纯度系数'), onTap: () => toastInfo(msg: '演示:自定义「$purity」纯度系数'),
), ),
if (purity != group.$2.last) if (purity != group.$2.last)
const PrototypeDivider(margin: EdgeInsets.zero), const PrototypeDivider(margin: EdgeInsets.zero),
@@ -599,12 +655,12 @@ class AboutPage extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
_GoldTextAction( _GoldTextAction(
label: '提交意见反馈', label: '提交意见反馈',
onTap: () => _toast(context, '演示:打开反馈表单'), onTap: () => toastInfo(msg: '演示:打开反馈表单'),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
_GoldTextAction( _GoldTextAction(
label: '检查更新', label: '检查更新',
onTap: () => _toast(context, '当前已是最新版本'), onTap: () => toastInfo(msg: '当前已是最新版本'),
), ),
], ],
), ),
@@ -718,7 +774,7 @@ class _InviteCodeCard extends StatelessWidget {
const ClipboardData(text: 'JINZHI-8K3F'), const ClipboardData(text: 'JINZHI-8K3F'),
); );
if (!context.mounted) return; if (!context.mounted) return;
_toast(context, '已复制邀请码'); toastInfo(msg: '已复制邀请码');
}, },
child: const Text( 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)));
}
+16
View File
@@ -1267,6 +1267,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.53.6" 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: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
+1
View File
@@ -42,6 +42,7 @@ dependencies:
url_launcher: ^6.3.1 url_launcher: ^6.3.1
gal: ^2.3.2 gal: ^2.3.2
image_picker: ^1.1.2 image_picker: ^1.1.2
share_plus: ^12.0.1
wechat_assets_picker: ^10.1.2 wechat_assets_picker: ^10.1.2
flutter_cache_manager: ^3.4.1 flutter_cache_manager: ^3.4.1
photo_view: ^0.15.0 photo_view: ^0.15.0