import 'dart:io'; import 'dart:math' as math; import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:gal/gal.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import '../../../api/geo_api.dart'; import '../../../api/photo_api.dart'; import '../../../core/network/api_exception.dart'; import '../../../models/watermark.dart'; import '../../../providers/watermark_provider.dart'; import '../../../utils/toast.dart'; import '../../../widgets/state_views.dart'; import 'watermark_painter.dart'; import 'widgets/watermark_content_sheet.dart'; import 'widgets/watermark_settings_sheet.dart'; /// 水印编辑页:预览(照片 + 水印)+ 拖动定位 + 保存到相册 /// /// 水印是**本地合成**的 —— 服务器那份只是原图备份。 class WatermarkEditPage extends ConsumerStatefulWidget { const WatermarkEditPage({ super.key, required this.imagePath, this.uploadOriginal = true, this.initialData, }); final String imagePath; /// 是否把原图上传存档。 /// /// 新拍的要传;从「最近拍摄」里挑出来的老照片**不能传** —— /// 服务器上本来就有那一张,再传一遍就是重复文件。 final bool uploadOriginal; /// 已有的水印内容。传了就不自动定位 —— /// 老照片要用**拍摄当时**的地点,而不是你现在站的地方。 final WatermarkData? initialData; @override ConsumerState createState() => _WatermarkEditPageState(); } enum _UploadStatus { waiting, uploading, done, failed } class _WatermarkEditPageState extends ConsumerState { Size? _imageSize; String? _imageError; late WatermarkData _data; bool _locating = false; String? _locateError; /// 定位成功但地址没解析出来时的原因(配额用完、两个服务都连不上…) String? _addressError; /// 定位到的坐标(上传原图时一并存到服务器) double? _latitude; double? _longitude; _UploadStatus _upload = _UploadStatus.waiting; String? _uploadError; bool _saving = false; bool _sharing = false; @override void initState() { super.initState(); _data = widget.initialData ?? WatermarkData(time: DateTime.now()); _loadImageSize(); _bootstrap(); } /// 启动流程分两种: /// · 新拍的:先定位(拿地址/天气)、再把原图上传存档 /// · 从「最近拍摄」挑的老照片:地址天气从服务器记录里还原,既不用定位 /// 也不用重传 —— 那张原图服务器上本来就有 Future _bootstrap() async { if (widget.initialData != null || !widget.uploadOriginal) { return; } await _locate(); await _uploadOriginal(); } // ---------------- 图片尺寸 ---------------- Future _loadImageSize() async { setState(() => _imageError = null); try { // 只读文件头拿尺寸,不整张解码(12MP 全解码要 48MB 内存) final bytes = await File(widget.imagePath).readAsBytes(); final buffer = await ui.ImmutableBuffer.fromUint8List(bytes); final descriptor = await ui.ImageDescriptor.encoded(buffer); final size = Size(descriptor.width.toDouble(), descriptor.height.toDouble()); descriptor.dispose(); buffer.dispose(); if (!mounted) return; setState(() => _imageSize = size); } catch (_) { if (!mounted) return; setState(() => _imageError = '这张照片读不出来,换一张试试'); } } // ---------------- 定位 ---------------- Future _locate() async { if (!mounted) return; setState(() { _locating = true; _locateError = null; }); final snapshot = await ref.read(geoApiProvider).resolve(); if (!mounted) return; setState(() { _locating = false; _locateError = snapshot.error; _addressError = snapshot.addressError; _latitude = snapshot.latitude; _longitude = snapshot.longitude; _data = _data.copyWith( address: snapshot.address, weather: snapshot.weather, temperature: snapshot.temperature, ); }); } // ---------------- 上传原图 ---------------- Future _uploadOriginal() async { if (!mounted) return; setState(() { _upload = _UploadStatus.uploading; _uploadError = null; }); try { await ref.read(photoApiProvider).upload( filePath: widget.imagePath, latitude: _latitude, longitude: _longitude, address: _data.address, weather: _weatherText(), remark: _data.remark, takenTime: _data.time, ); if (!mounted) return; setState(() => _upload = _UploadStatus.done); } on ApiException catch (e) { if (!mounted) return; setState(() { _upload = _UploadStatus.failed; _uploadError = e.message; }); } catch (_) { if (!mounted) return; setState(() { _upload = _UploadStatus.failed; _uploadError = '网络异常'; }); } } String _weatherText() { final joined = '${_data.weather} ${_data.temperature}'.trim(); return joined; } // ---------------- 合成 / 保存 / 分享 ---------------- /// 合成一次带水印的 JPEG。 /// 保存和分享共用这一份 —— 两边都要同一张图,逻辑不该写两遍。 Future _composeJpeg() async { final bytes = await File(widget.imagePath).readAsBytes(); return composeWatermarkedJpeg( originalBytes: bytes, settings: ref.read(watermarkSettingsProvider), data: _data, brandIcon: ref.read(brandIconProvider).value, ); } Future _save() async { if (_saving) return; setState(() => _saving = true); try { final jpeg = await _composeJpeg(); // gal 的约定:name 不带扩展名 await Gal.putImageBytes(jpeg, name: 'qiangbao_${_timestamp()}'); if (!mounted) return; showToast(context, '已保存到相册', icon: 'check'); } on GalException catch (e) { if (!mounted) return; showToast(context, e.type.message, error: true); } catch (_) { if (!mounted) return; showToast(context, '保存失败,请重试', error: true); } finally { if (mounted) setState(() => _saving = false); } } /// 调系统分享面板(Android 走 ACTION_SEND,会列出微信/QQ/邮件等) Future _share() async { if (_sharing) return; setState(() => _sharing = true); try { final jpeg = await _composeJpeg(); // 先落一个临时文件再分享。 // 不用 XFile.fromData:cross_file 在 Android 上会忽略它的 name, // 对方收到会是个随机文件名。 final dir = await getTemporaryDirectory(); final file = File('${dir.path}/qiangbao_${_timestamp()}.jpg'); await file.writeAsBytes(jpeg, flush: true); await SharePlus.instance.share( ShareParams(files: [XFile(file.path, mimeType: 'image/jpeg')]), ); } catch (_) { if (!mounted) return; showToast(context, '分享失败,请重试', error: true); } finally { if (mounted) setState(() => _sharing = false); } } String _timestamp() { final t = DateTime.now(); String two(int n) => n.toString().padLeft(2, '0'); return '${t.year}${two(t.month)}${two(t.day)}_${two(t.hour)}${two(t.minute)}${two(t.second)}'; } // ---------------- 拖动 ---------------- Size _fitSize(Size source, Size box) { if (source.width <= 0 || source.height <= 0 || box.width <= 0 || box.height <= 0) { return Size.zero; } final scale = math.min(box.width / source.width, box.height / source.height); return Size(source.width * scale, source.height * scale); } void _onDrag(DragUpdateDetails details, Size displaySize, WatermarkSettings settings) { final layout = layoutWatermark(displaySize, settings, _data); if (layout.isEmpty) return; // 正文都关掉了,没什么可拖 // 位置公式和绘制共用 layoutWatermark,所以拖到哪就画到哪 final nextX = layout.origin.dx + details.delta.dx; final nextY = layout.origin.dy + details.delta.dy; final maxX = math.max(0.0, displaySize.width - layout.cardWidth); final maxY = math.max(0.0, displaySize.height - layout.cardHeight); ref.read(watermarkSettingsProvider.notifier).updatePosition( nextX.clamp(0.0, maxX) / displaySize.width, nextY.clamp(0.0, maxY) / displaySize.height, ); } // ---------------- 构建 ---------------- @override Widget build(BuildContext context) { return PopScope( canPop: !_saving, child: Scaffold( appBar: AppBar( title: const Text('加水印'), actions: [ TextButton( onPressed: _saving ? null : _save, child: _saving ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text( '保存', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFFC8956E), ), ), ), const SizedBox(width: 8), ], ), body: Column( children: [ _uploadBar(), _locationBar(), Expanded(child: _preview()), const Padding( padding: EdgeInsets.symmetric(vertical: 6), child: Text( '按住水印可以拖动位置', style: TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)), ), ), _toolbar(), ], ), ), ); } Widget _preview() { if (_imageError != null) { return Center( child: ErrorView(message: _imageError!, onRetry: _loadImageSize), ); } final imageSize = _imageSize; if (imageSize == null) { return const Center(child: LoadingView(tip: '正在读取照片...')); } final settings = ref.watch(watermarkSettingsProvider); final brandIcon = ref.watch(brandIconProvider).value; return LayoutBuilder( builder: (context, constraints) { final displaySize = _fitSize(imageSize, constraints.biggest); if (displaySize.width <= 0 || displaySize.height <= 0) { return const SizedBox.shrink(); } return Center( child: GestureDetector( onPanUpdate: (d) => _onDrag(d, displaySize, settings), // 松手才落盘:拖动过程每帧都写 SharedPreferences 太浪费 onPanEnd: (_) => ref.read(watermarkSettingsProvider.notifier).persistPosition(), onPanCancel: () => ref.read(watermarkSettingsProvider.notifier).persistPosition(), child: SizedBox( width: displaySize.width, height: displaySize.height, child: Stack( fit: StackFit.expand, children: [ Image.file( File(widget.imagePath), fit: BoxFit.fill, // 尺寸已按 contain 算好,这里填满即可 gaplessPlayback: true, ), CustomPaint( painter: WatermarkPainter( settings: settings, data: _data, brandIcon: brandIcon, ), ), ], ), ), ), ); }, ); } /// 原图上传状态条 Widget _uploadBar() { // 老照片本来就在服务器上,没有「上传」这回事,别显示「准备上传原图」误导人 if (!widget.uploadOriginal) { return Container( width: double.infinity, color: const Color(0xFFF7F4F1), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: const Row( children: [ Icon(Icons.circle, size: 8, color: Color(0xFF19BE6B)), SizedBox(width: 8), Expanded( child: Text( '原图已在服务器上', style: TextStyle(fontSize: 12, color: Color(0xFF19BE6B)), ), ), ], ), ); } final (String text, Color color) = switch (_upload) { _UploadStatus.waiting => ('准备上传原图', const Color(0xFFBCAAA4)), _UploadStatus.uploading => ('正在上传原图…', const Color(0xFF8D6E63)), _UploadStatus.done => ('原图已备份到服务器', const Color(0xFF19BE6B)), _UploadStatus.failed => ( _uploadError == null ? '原图上传失败' : '原图上传失败:$_uploadError', const Color(0xFFE53935), ), }; return Container( width: double.infinity, color: const Color(0xFFF7F4F1), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ Icon(Icons.circle, size: 8, color: color), const SizedBox(width: 8), Expanded( child: Text( text, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: color), ), ), if (_upload == _UploadStatus.failed) GestureDetector( onTap: _uploadOriginal, child: const Text( '重试', style: TextStyle( fontSize: 12, color: Color(0xFFC8956E), fontWeight: FontWeight.w600, ), ), ), ], ), ); } /// 定位/地址条 Widget _locationBar() { final (String text, Color color) = _locating ? ('正在定位…', const Color(0xFF8D6E63)) : _locateError != null ? (_locateError!, const Color(0xFFE53935)) : _data.address.isEmpty ? ('未获取到位置', const Color(0xFFE53935)) : (_data.address, const Color(0xFF5D4037)); return Container( width: double.infinity, color: Colors.white, padding: const EdgeInsets.fromLTRB(16, 10, 12, 10), child: Row( children: [ const Icon(Icons.location_on_outlined, size: 16, color: Color(0xFFC8956E)), const SizedBox(width: 6), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( text, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 13, height: 1.4, color: color), ), // 定位成功但地址没解析出来时,把原因摆出来 —— // 只给一串坐标、不说为什么,上次就让人以为是功能坏了 if (_addressError != null && _locateError == null) Padding( padding: const EdgeInsets.only(top: 3), child: Text( '地址没解析出来:$_addressError\n可点下面「编辑内容」手动填', maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 11, height: 1.35, color: Color(0xFFE53935), ), ), ), ], ), ), TextButton( onPressed: _locating ? null : _locate, child: const Text( '刷新', style: TextStyle(fontSize: 13, color: Color(0xFFC8956E)), ), ), ], ), ); } Widget _toolbar() { return Container( color: Colors.white, padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), child: SafeArea( top: false, child: Row( children: [ _toolButton( icon: Icons.camera_alt_outlined, label: '重拍', // 回到入口页重新拍 —— 入口页本身就是拍照页,比在这里再塞一个相机逻辑干净 onTap: () => Navigator.of(context).maybePop(), ), _toolButton( icon: Icons.edit_outlined, label: '编辑内容', onTap: _editContent, ), _toolButton( icon: Icons.tune, label: '水印设置', onTap: () => showWatermarkSettingsSheet(context), ), _toolButton( icon: Icons.share_outlined, label: '分享', busy: _sharing, onTap: _share, ), ], ), ), ); } Future _editContent() async { final updated = await showWatermarkContentSheet(context, initial: _data); if (updated == null || !mounted) return; setState(() => _data = updated); } Widget _toolButton({ required IconData icon, required String label, required VoidCallback onTap, bool busy = false, }) { return Expanded( child: InkWell( onTap: busy ? null : onTap, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 21, width: 21, child: busy ? const CircularProgressIndicator(strokeWidth: 2) : Icon(icon, size: 21, color: const Color(0xFF8D6E63)), ), const SizedBox(height: 4), Text( label, style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)), ), ], ), ), ), ); } }