import 'dart:io'; import 'dart:math' as math; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:gal/gal.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}); final String imagePath; @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; /// 定位到的坐标(上传原图时一并存到服务器) double? _latitude; double? _longitude; _UploadStatus _upload = _UploadStatus.waiting; String? _uploadError; bool _saving = false; @override void initState() { super.initState(); _data = WatermarkData(time: DateTime.now()); _loadImageSize(); // 先定位、再上传 —— 这样服务器那份记录能带上地址和天气 _locateThenUpload(); } // ---------------- 图片尺寸 ---------------- 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 _locateThenUpload() async { await _locate(); await _uploadOriginal(); } 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; _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; } // ---------------- 保存 ---------------- Future _save() async { if (_saving) return; final settings = ref.read(watermarkSettingsProvider); final brandIcon = ref.read(brandIconProvider).value; setState(() => _saving = true); try { final bytes = await File(widget.imagePath).readAsBytes(); final jpeg = await composeWatermarkedJpeg( originalBytes: bytes, settings: settings, data: _data, brandIcon: brandIcon, ); // 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 (e) { if (!mounted) return; showToast(context, '保存失败,请重试', error: true); } finally { if (mounted) setState(() => _saving = 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() { 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; if (_locating) { text = '正在定位…'; } else if (_locateError != null) { text = _locateError!; } else if (_data.address.isEmpty) { text = '未获取到位置'; } else { text = _data.address; } 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: Text( text, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13, height: 1.4, color: _locateError != null ? const Color(0xFFE53935) : const Color(0xFF5D4037), ), ), ), 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), ), ], ), ), ); } 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, }) { return Expanded( child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 21, color: const Color(0xFF8D6E63)), const SizedBox(height: 4), Text( label, style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)), ), ], ), ), ), ); } }