水印相机前端
This commit is contained in:
@@ -0,0 +1,475 @@
|
||||
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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user