579 lines
18 KiB
Dart
579 lines
18 KiB
Dart
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)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|