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