水印优化+ORC优化

This commit is contained in:
2026-09-28 11:39:19 +08:00
parent c5b3d81d07
commit 6176c49964
19 changed files with 1701 additions and 171 deletions
+148 -45
View File
@@ -1,10 +1,13 @@
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';
@@ -21,10 +24,25 @@ import 'widgets/watermark_settings_sheet.dart';
///
/// 水印是**本地合成**的 —— 服务器那份只是原图备份。
class WatermarkEditPage extends ConsumerStatefulWidget {
const WatermarkEditPage({super.key, required this.imagePath});
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();
}
@@ -40,6 +58,9 @@ class _WatermarkEditPageState extends ConsumerState<WatermarkEditPage> {
bool _locating = false;
String? _locateError;
/// 定位成功但地址没解析出来时的原因(配额用完、两个服务都连不上…)
String? _addressError;
/// 定位到的坐标(上传原图时一并存到服务器)
double? _latitude;
double? _longitude;
@@ -47,14 +68,26 @@ class _WatermarkEditPageState extends ConsumerState<WatermarkEditPage> {
_UploadStatus _upload = _UploadStatus.waiting;
String? _uploadError;
bool _saving = false;
bool _sharing = false;
@override
void initState() {
super.initState();
_data = WatermarkData(time: DateTime.now());
_data = widget.initialData ?? WatermarkData(time: DateTime.now());
_loadImageSize();
// 先定位、再上传 —— 这样服务器那份记录能带上地址和天气
_locateThenUpload();
_bootstrap();
}
/// 启动流程分两种:
/// · 新拍的:先定位(拿地址/天气)、再把原图上传存档
/// · 从「最近拍摄」挑的老照片:地址天气从服务器记录里还原,既不用定位
/// 也不用重传 —— 那张原图服务器上本来就有
Future<void> _bootstrap() async {
if (widget.initialData != null || !widget.uploadOriginal) {
return;
}
await _locate();
await _uploadOriginal();
}
// ---------------- 图片尺寸 ----------------
@@ -79,11 +112,6 @@ class _WatermarkEditPageState extends ConsumerState<WatermarkEditPage> {
// ---------------- 定位 ----------------
Future<void> _locateThenUpload() async {
await _locate();
await _uploadOriginal();
}
Future<void> _locate() async {
if (!mounted) return;
setState(() {
@@ -97,6 +125,7 @@ class _WatermarkEditPageState extends ConsumerState<WatermarkEditPage> {
setState(() {
_locating = false;
_locateError = snapshot.error;
_addressError = snapshot.addressError;
_latitude = snapshot.latitude;
_longitude = snapshot.longitude;
_data = _data.copyWith(
@@ -148,23 +177,25 @@ class _WatermarkEditPageState extends ConsumerState<WatermarkEditPage> {
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;
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,
);
final jpeg = await _composeJpeg();
// gal 的约定:name 不带扩展名
await Gal.putImageBytes(jpeg, name: 'qiangbao_${_timestamp()}');
@@ -173,7 +204,7 @@ class _WatermarkEditPageState extends ConsumerState<WatermarkEditPage> {
} on GalException catch (e) {
if (!mounted) return;
showToast(context, e.type.message, error: true);
} catch (e) {
} catch (_) {
if (!mounted) return;
showToast(context, '保存失败,请重试', error: true);
} finally {
@@ -181,6 +212,31 @@ class _WatermarkEditPageState extends ConsumerState<WatermarkEditPage> {
}
}
/// 调系统分享面板(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');
@@ -320,6 +376,27 @@ class _WatermarkEditPageState extends ConsumerState<WatermarkEditPage> {
/// 原图上传状态条
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)),
@@ -365,16 +442,13 @@ class _WatermarkEditPageState extends ConsumerState<WatermarkEditPage> {
/// 定位/地址条
Widget _locationBar() {
final String text;
if (_locating) {
text = '正在定位…';
} else if (_locateError != null) {
text = _locateError!;
} else if (_data.address.isEmpty) {
text = '未获取到位置';
} else {
text = _data.address;
}
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,
@@ -385,17 +459,33 @@ class _WatermarkEditPageState extends ConsumerState<WatermarkEditPage> {
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),
),
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(
@@ -434,6 +524,12 @@ class _WatermarkEditPageState extends ConsumerState<WatermarkEditPage> {
label: '水印设置',
onTap: () => showWatermarkSettingsSheet(context),
),
_toolButton(
icon: Icons.share_outlined,
label: '分享',
busy: _sharing,
onTap: _share,
),
],
),
),
@@ -450,17 +546,24 @@ class _WatermarkEditPageState extends ConsumerState<WatermarkEditPage> {
required IconData icon,
required String label,
required VoidCallback onTap,
bool busy = false,
}) {
return Expanded(
child: InkWell(
onTap: onTap,
onTap: busy ? null : 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)),
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,