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

476 lines
14 KiB
Dart

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<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;
/// 定位到的坐标(上传原图时一并存到服务器)
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<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> _locateThenUpload() async {
await _locate();
await _uploadOriginal();
}
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;
_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;
}
// ---------------- 保存 ----------------
Future<void> _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<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,
}) {
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)),
),
],
),
),
),
);
}
}