Files
Aq-Accounting-Flutter/lib/pages/watermark/watermark_edit_page.dart
T
2026-09-28 11:39:19 +08:00

579 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<WatermarkEditPage> createState() => _WatermarkEditPageState();
}
enum _UploadStatus { waiting, uploading, done, failed }
class _WatermarkEditPageState extends ConsumerState<WatermarkEditPage> {
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<void> _bootstrap() async {
if (widget.initialData != null || !widget.uploadOriginal) {
return;
}
await _locate();
await _uploadOriginal();
}
// ---------------- 图片尺寸 ----------------
Future<void> _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<void> _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<void> _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<Uint8List> _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<void> _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<void> _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<void> _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)),
),
],
),
),
),
);
}
}