水印相机前端

This commit is contained in:
2026-09-28 09:59:43 +08:00
parent 187a7e0911
commit c5b3d81d07
19 changed files with 2761 additions and 3 deletions
@@ -0,0 +1,211 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import '../../../models/watermark.dart';
import '../../../providers/watermark_provider.dart';
import '../../../utils/toast.dart';
import 'watermark_edit_page.dart';
import 'widgets/watermark_settings_sheet.dart';
/// 水印相机模块入口:拍照 / 相册选图 → 进编辑页加水印
///
/// 刻意不做自绘相机预览(原项目也是调系统相机):省掉一整套相机权限、
/// 预览流、方向处理的复杂度,代价只是没法实时看到水印。
class WatermarkCameraPage extends ConsumerWidget {
const WatermarkCameraPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(watermarkSettingsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('水印相机'),
actions: [
IconButton(
tooltip: '水印设置',
icon: const Icon(Icons.tune),
onPressed: () => showWatermarkSettingsSheet(context),
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
_introCard(),
const SizedBox(height: 20),
_captureButton(
icon: Icons.camera_alt,
label: '拍照',
primary: true,
onTap: () => _pick(context, ImageSource.camera),
),
const SizedBox(height: 12),
_captureButton(
icon: Icons.photo_library_outlined,
label: '从相册选',
primary: false,
onTap: () => _pick(context, ImageSource.gallery),
),
const SizedBox(height: 24),
_settingsCard(context, settings),
],
),
);
}
Widget _introCard() {
return Container(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 18),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8)],
),
borderRadius: BorderRadius.circular(16),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'拍一张,自动加水印',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
SizedBox(height: 6),
Text(
'时间、地点、天气自动填好,可以拖动位置、换颜色。\n'
'加好水印的照片直接存进手机相册;原图会同时备份到服务器。',
style: TextStyle(fontSize: 12.5, height: 1.6, color: Color(0xFF8D6E63)),
),
],
),
);
}
Widget _captureButton({
required IconData icon,
required String label,
required bool primary,
required VoidCallback onTap,
}) {
final style = FilledButton.styleFrom(
backgroundColor: primary ? const Color(0xFFC8956E) : Colors.white,
foregroundColor: primary ? Colors.white : const Color(0xFFC8956E),
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(26),
side: primary
? BorderSide.none
: const BorderSide(color: Color(0xFFC8956E)),
),
elevation: primary ? 0 : 0,
);
return FilledButton.icon(
onPressed: onTap,
style: style,
icon: Icon(icon, size: 20),
label: Text(
label,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
);
}
Widget _settingsCard(BuildContext context, WatermarkSettings settings) {
final items = <String>[
if (settings.showTime) '时间',
if (settings.showAddress) '地点',
if (settings.showWeather) '天气',
if (settings.showRemark) '备注',
if (settings.showBrand) '品牌角标',
];
return InkWell(
onTap: () => showWatermarkSettingsSheet(context),
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.fromLTRB(18, 16, 14, 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'当前水印内容',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 8),
if (items.isEmpty)
const Text(
'全部关闭(照片不会加任何水印)',
style: TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)),
)
else
Wrap(
spacing: 6,
runSpacing: 6,
children: items
.map(
(t) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFFFFF3E6),
borderRadius: BorderRadius.circular(10),
),
child: Text(
t,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF8D6E63),
),
),
),
)
.toList(),
),
],
),
),
const Icon(Icons.chevron_right, color: Color(0xFFBCAAA4)),
],
),
),
);
}
/// 选图。**不传 imageQuality / maxWidth** —— 要的就是原图,
/// 编辑页会在合成时按最长边 1920 缩放,原始文件则原样备份到服务器。
Future<void> _pick(BuildContext context, ImageSource source) async {
try {
final file = await ImagePicker().pickImage(source: source);
if (file == null) return;
if (!context.mounted) return;
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => WatermarkEditPage(imagePath: file.path)),
);
} catch (e) {
if (context.mounted) {
showToast(context, '打开相机/相册失败:$e', error: true);
}
}
}
}
@@ -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)),
),
],
),
),
),
);
}
}
+370
View File
@@ -0,0 +1,370 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:image/image.dart' as img;
import '../../../models/watermark.dart';
/// 水印绘制核心 —— **预览、拖动、导出三处共用同一套几何**。
///
/// 关键约定:所有尺寸都按 `scale = 画布宽度 / [kWatermarkDesignWidth]` 等比缩放。
/// 于是同一个 [paintWatermark],在预览时传屏幕显示尺寸、在导出时传图片像素尺寸,
/// 出来的**视觉完全一致**——这就是「所见即所得」的实现方式。
///
/// 改样式(字号/间距/颜色/位置)只改这里,三处自动同步。
/// 设计稿基准宽度(沿用原水印相机项目的 1080)
const double kWatermarkDesignWidth = 1080;
// ---- 设计稿尺寸(单位:设计稿像素,实际绘制时乘 scale)----
const double _margin = 28; // 水印块到画布边缘的默认距离
const double _lineWidth = 4; // 左侧黄色竖线宽
const double _lineToText = 14; // 竖线到文字的水平距离
const double _cardPaddingY = 16; // 水印块上下内边距
const double _fontSize = 37;
const double _rowHeight = 54;
const double _rowGap = 8;
const int _maxCharsPerLine = 18; // 每行最多几个字,超了折行
// 右下角品牌角标
const double _brandMargin = 8;
const double _brandIconSize = 48;
const double _brandTextSize = 32;
const double _brandGap = 10;
const double _brandPaddingX = 16;
const double _brandPaddingY = 12;
const double _brandOpacity = 0.8;
const String _brandText = '水印相机';
/// 黄色竖线的颜色(品牌装饰,不跟随文字颜色变)
const Color _accentColor = Color(0xFFFFB800);
/// 水印正文块的几何信息。
///
/// 抽出来是为了让「拖动」能用和「绘制」完全一样的公式算位置 ——
/// 两边各算一遍的话,迟早会漂移。
class WatermarkLayout {
const WatermarkLayout({
required this.scale,
required this.lines,
required this.textHeight,
required this.cardHeight,
required this.cardWidth,
required this.origin,
});
final double scale;
/// 折行后的实际文本行(已按 [_maxCharsPerLine] 拆好)
final List<String> lines;
/// 所有文本行的总高度
final double textHeight;
/// 块总高(含上下内边距);没有内容时为 0
final double cardHeight;
/// 块总宽(最宽一行 + 竖线 + 间距)
final double cardWidth;
/// 块左上角在画布上的位置
final Offset origin;
bool get isEmpty => lines.isEmpty;
}
/// 按 [size] 算出水印块的位置和尺寸
WatermarkLayout layoutWatermark(
Size size,
WatermarkSettings settings,
WatermarkData data,
) {
final scale = size.width / kWatermarkDesignWidth;
final lines = <String>[
for (final line in buildWatermarkLines(settings, data))
// 标了「不折行」的(时间)原样保留,其余按字数折
...(line.wrap ? _wrapByCharCount(line.text, _maxCharsPerLine) : [line.text]),
];
if (lines.isEmpty) {
return WatermarkLayout(
scale: scale,
lines: const [],
textHeight: 0,
cardHeight: 0,
cardWidth: 0,
origin: Offset(
settings.posX != null ? settings.posX! * size.width : _margin * scale,
settings.posY != null ? settings.posY! * size.height : size.height - _margin * scale,
),
);
}
final rowH = _rowHeight * scale;
final gap = _rowGap * scale;
final padding = _cardPaddingY * scale;
final textHeight = lines.length * rowH + (lines.length - 1) * gap;
final cardHeight = textHeight + padding * 2;
// 最宽一行决定块的宽度(拖动时用它做边界夹紧)
var maxLineWidth = 0.0;
for (final line in lines) {
final painter = _layoutText(
line,
fontSize: _fontSize * scale,
color: Colors.white,
scale: scale,
);
if (painter.width > maxLineWidth) maxLineWidth = painter.width;
}
final cardWidth = _lineWidth * scale + _lineToText * scale + maxLineWidth;
final origin = Offset(
settings.posX != null ? settings.posX! * size.width : _margin * scale,
settings.posY != null
? settings.posY! * size.height
: size.height - _margin * scale - cardHeight,
);
return WatermarkLayout(
scale: scale,
lines: lines,
textHeight: textHeight,
cardHeight: cardHeight,
cardWidth: cardWidth,
origin: origin,
);
}
/// 画一笔水印。
///
/// [size] 既可以是屏幕显示尺寸(预览),也可以是图片像素尺寸(导出)。
void paintWatermark(
Canvas canvas,
Size size,
WatermarkSettings settings,
WatermarkData data, {
ui.Image? brandIcon,
}) {
if (size.width <= 0 || size.height <= 0) return;
final layout = layoutWatermark(size, settings, data);
final scale = layout.scale;
final textColor = Color(settings.colorValue);
// ---- 正文块 ----
if (!layout.isEmpty) {
final origin = layout.origin;
final padding = _cardPaddingY * scale;
// 左侧黄色竖线
canvas.drawRect(
Rect.fromLTWH(origin.dx, origin.dy + padding, _lineWidth * scale, layout.textHeight),
Paint()..color = _accentColor,
);
// 多行文字
final textX = origin.dx + _lineWidth * scale + _lineToText * scale;
var y = origin.dy + padding;
for (final line in layout.lines) {
final painter = _layoutText(
line,
fontSize: _fontSize * scale,
color: textColor,
scale: scale,
);
painter.paint(canvas, Offset(textX, y));
y += _rowHeight * scale + _rowGap * scale;
}
}
// ---- 右下角品牌角标 ----
if (settings.showBrand) {
_paintBrand(canvas, size, scale, brandIcon);
}
}
/// 右下角「相机图标 + 水印相机」,整体 80% 透明
void _paintBrand(Canvas canvas, Size size, double scale, ui.Image? brandIcon) {
final margin = _brandMargin * scale;
final iconSize = _brandIconSize * scale;
final gap = _brandGap * scale;
final padX = _brandPaddingX * scale;
final padY = _brandPaddingY * scale;
final textPainter = _layoutText(
_brandText,
fontSize: _brandTextSize * scale,
color: Colors.white,
bold: true,
scale: scale,
);
final hasIcon = brandIcon != null;
final brandW = (hasIcon ? iconSize + gap : 0) + textPainter.width + padX * 2;
final brandH = (hasIcon ? iconSize : textPainter.height) + padY * 2;
final brandX = size.width - margin - brandW;
final brandY = size.height - margin - brandH;
// saveLayer 的 paint alpha 就是整组元素的透明度(Flutter 的 Opacity 内部也这么做)
canvas.saveLayer(
Rect.fromLTWH(brandX, brandY, brandW, brandH),
Paint()..color = const Color(0xFF000000).withValues(alpha: _brandOpacity),
);
double contentX = brandX + padX;
if (hasIcon) {
canvas.drawImageRect(
brandIcon,
Rect.fromLTWH(0, 0, brandIcon.width.toDouble(), brandIcon.height.toDouble()),
Rect.fromLTWH(contentX, brandY + padY, iconSize, iconSize),
Paint()..filterQuality = FilterQuality.medium,
);
contentX += iconSize + gap;
}
// 文字在内容区里垂直居中
final contentCenterY = brandY + brandH / 2;
textPainter.paint(canvas, Offset(contentX, contentCenterY - textPainter.height / 2));
canvas.restore();
}
/// 排一段文字。加了淡阴影 —— 白字直接压在浅色照片上会糊,老项目没做这一步。
TextPainter _layoutText(
String text, {
required double fontSize,
required Color color,
required double scale,
bool bold = false,
}) {
final painter = TextPainter(
text: TextSpan(
text: text,
style: TextStyle(
fontSize: fontSize,
color: color,
fontWeight: bold ? FontWeight.bold : FontWeight.normal,
height: 1.2,
shadows: [
Shadow(
color: const Color(0x99000000),
blurRadius: 5 * scale,
offset: Offset(0, 1 * scale),
),
],
),
),
textDirection: TextDirection.ltr,
maxLines: 1,
);
painter.layout();
return painter;
}
/// 按字符数折行(中文按字数折最直观,和原项目一致)
List<String> _wrapByCharCount(String text, int maxChars) {
if (text.length <= maxChars) return [text];
final result = <String>[];
for (var i = 0; i < text.length; i += maxChars) {
final end = (i + maxChars < text.length) ? i + maxChars : text.length;
result.add(text.substring(i, end));
}
return result;
}
/// 预览用:把水印盖在照片上
class WatermarkPainter extends CustomPainter {
const WatermarkPainter({
required this.settings,
required this.data,
this.brandIcon,
});
final WatermarkSettings settings;
final WatermarkData data;
final ui.Image? brandIcon;
@override
void paint(Canvas canvas, Size size) {
paintWatermark(canvas, size, settings, data, brandIcon: brandIcon);
}
@override
bool shouldRepaint(WatermarkPainter old) {
return old.settings != settings ||
old.data != data ||
old.brandIcon != brandIcon;
}
}
/// 把原图和水印合成为一张 JPEG。
///
/// 走离屏绘制而不是「截图 Widget」:这样能按**图片原始像素**出图,
/// 不受屏幕分辨率限制,也不会因为页面滚动/遮挡出问题。
///
/// [maxWidth] 限制最长边(原项目也是 1920)—— 一是省内存,二是纯 Dart 的
/// JPEG 编码在大图上会明显变慢。
Future<Uint8List> composeWatermarkedJpeg({
required Uint8List originalBytes,
required WatermarkSettings settings,
required WatermarkData data,
ui.Image? brandIcon,
double maxWidth = 1920,
int quality = 92,
}) async {
// 先只读文件头拿原始尺寸,再决定按多大解码。
// 直接全尺寸解码一张 12MP 照片要 48MB 内存,没必要。
final buffer = await ui.ImmutableBuffer.fromUint8List(originalBytes);
final descriptor = await ui.ImageDescriptor.encoded(buffer);
final ratio = descriptor.width > maxWidth ? maxWidth / descriptor.width : 1.0;
final outW = (descriptor.width * ratio).round();
final outH = (descriptor.height * ratio).round();
final codec = await descriptor.instantiateCodec(targetWidth: outW, targetHeight: outH);
final frame = await codec.getNextFrame();
final source = frame.image;
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawImage(source, Offset.zero, Paint());
paintWatermark(
canvas,
Size(outW.toDouble(), outH.toDouble()),
settings,
data,
brandIcon: brandIcon,
);
final picture = recorder.endRecording();
final rendered = await picture.toImage(outW, outH);
// 到这里内容已经栅格化,可以把中间对象都放掉
source.dispose();
picture.dispose();
codec.dispose();
descriptor.dispose();
buffer.dispose();
final byteData = await rendered.toByteData(format: ui.ImageByteFormat.rawRgba);
rendered.dispose();
if (byteData == null) {
throw StateError('水印合成失败:拿不到位图数据');
}
// toByteData 只有 PNG / rawRgba,出不了 JPEG,所以自己用 image 包编码。
// 之所以非要 JPEG:1920×1440 的照片 PNG 要 3~6MB,JPEG 只要 ~500KB。
// 画面不透明(底图铺满),预乘与非预乘 RGBA 等价,直接当 RGBA 用。
final raster = img.Image.fromBytes(
width: outW,
height: outH,
bytes: byteData.buffer,
bytesOffset: byteData.offsetInBytes,
numChannels: 4,
);
return img.encodeJpg(raster, quality: quality);
}
@@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import '../../../models/watermark.dart';
/// 编辑这一张照片的水印内容(地址 / 天气 / 温度 / 备注)
///
/// 返回 null 表示用户取消了。时间不在这里改 —— 它总是「现在」,
/// 让用户手改反而容易把水印弄成假的时间。
Future<WatermarkData?> showWatermarkContentSheet(
BuildContext context, {
required WatermarkData initial,
}) {
return showModalBottomSheet<WatermarkData>(
context: context,
backgroundColor: Colors.white,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => _WatermarkContentSheet(initial: initial),
);
}
class _WatermarkContentSheet extends StatefulWidget {
const _WatermarkContentSheet({required this.initial});
final WatermarkData initial;
@override
State<_WatermarkContentSheet> createState() => _WatermarkContentSheetState();
}
class _WatermarkContentSheetState extends State<_WatermarkContentSheet> {
late final TextEditingController _address;
late final TextEditingController _weather;
late final TextEditingController _temperature;
late final TextEditingController _remark;
@override
void initState() {
super.initState();
_address = TextEditingController(text: widget.initial.address);
_weather = TextEditingController(text: widget.initial.weather);
_temperature = TextEditingController(text: widget.initial.temperature);
_remark = TextEditingController(text: widget.initial.remark);
}
@override
void dispose() {
_address.dispose();
_weather.dispose();
_temperature.dispose();
_remark.dispose();
super.dispose();
}
void _submit() {
Navigator.of(context).pop(
widget.initial.copyWith(
address: _address.text.trim(),
weather: _weather.text.trim(),
temperature: _temperature.text.trim(),
remark: _remark.text.trim(),
),
);
}
@override
Widget build(BuildContext context) {
return Padding(
// 键盘弹出时把内容顶上去,否则输入框会被挡住
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Center(
child: Text(
'编辑水印内容',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
),
const SizedBox(height: 18),
_field(label: '地址', controller: _address, hint: '自动定位,也可手改'),
_field(label: '天气', controller: _weather, hint: '如:晴'),
_field(label: '温度', controller: _temperature, hint: '如:25℃'),
_field(label: '备注', controller: _remark, hint: '想写点什么'),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: SizedBox(
height: 46,
child: OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFC8956E),
side: const BorderSide(color: Color(0xFFC8956E)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(23),
),
),
child: const Text('取消'),
),
),
),
const SizedBox(width: 12),
Expanded(
child: SizedBox(
height: 46,
child: FilledButton(
onPressed: _submit,
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFC8956E),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(23),
),
),
child: const Text('确定'),
),
),
),
],
),
],
),
),
),
);
}
Widget _field({
required String label,
required TextEditingController controller,
required String hint,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(fontSize: 13, color: Color(0xFF8D6E63)),
),
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: const Color(0xFFF7F4F1),
borderRadius: BorderRadius.circular(10),
),
child: TextField(
controller: controller,
style: const TextStyle(fontSize: 14, color: Color(0xFF5D4037)),
decoration: InputDecoration(
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 13),
hintText: hint,
hintStyle: const TextStyle(fontSize: 14, color: Color(0xFFD7CCC8)),
),
),
),
],
),
);
}
}
@@ -0,0 +1,221 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/watermark_provider.dart';
/// 水印设置(开关 + 文字颜色)
///
/// 用底部弹层而不是独立页面:编辑页里改完能立刻在预览上看到效果,
/// 入口页也能从同一个地方进。
Future<void> showWatermarkSettingsSheet(BuildContext context) {
return showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.white,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => const _WatermarkSettingsSheet(),
);
}
/// 可选文字颜色。白色是默认 —— 深色照片上最通用;浅色照片可以换黑色或暖黄。
const List<({String label, int value})> _textColors = [
(label: '白', value: 0xFFFFFFFF),
(label: '黑', value: 0xFF000000),
(label: '暖黄', value: 0xFFFFD54F),
(label: '品牌黄', value: 0xFFFFB800),
(label: '品牌棕', value: 0xFFC8956E),
(label: '浅蓝', value: 0xFF4FC3F7),
];
class _WatermarkSettingsSheet extends ConsumerWidget {
const _WatermarkSettingsSheet();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(watermarkSettingsProvider);
final notifier = ref.read(watermarkSettingsProvider.notifier);
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Center(
child: Text(
'水印设置',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
),
const SizedBox(height: 18),
_sectionTitle('显示内容'),
_switchRow(
'时间',
'如 2026.09.28 14:30',
settings.showTime,
(v) => notifier.update(settings.copyWith(showTime: v)),
),
_switchRow(
'地点',
'自动定位,也可在编辑页手填',
settings.showAddress,
(v) => notifier.update(settings.copyWith(showAddress: v)),
),
_switchRow(
'天气温度',
'如 晴 25℃(拿不到时这一行不显示)',
settings.showWeather,
(v) => notifier.update(settings.copyWith(showWeather: v)),
),
_switchRow(
'备注',
'每张照片单独填',
settings.showRemark,
(v) => notifier.update(settings.copyWith(showRemark: v)),
),
_switchRow(
'右下角品牌角标',
'相机图标 + 水印相机',
settings.showBrand,
(v) => notifier.update(settings.copyWith(showBrand: v)),
),
const SizedBox(height: 18),
_sectionTitle('文字颜色'),
const SizedBox(height: 10),
Wrap(
spacing: 12,
runSpacing: 12,
children: _textColors.map((c) {
final selected = settings.colorValue == c.value;
return GestureDetector(
onTap: () => notifier.update(settings.copyWith(colorValue: c.value)),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: Color(c.value),
shape: BoxShape.circle,
border: Border.all(
color: selected
? const Color(0xFFC8956E)
: const Color(0xFFE0E0E0),
width: selected ? 3 : 1,
),
),
child: selected
? Icon(
Icons.check,
size: 18,
color: _isLight(c.value)
? const Color(0xFF5D4037)
: Colors.white,
)
: null,
),
const SizedBox(height: 4),
Text(
c.label,
style: const TextStyle(fontSize: 11, color: Color(0xFF8D6E63)),
),
],
),
);
}).toList(),
),
const SizedBox(height: 18),
_sectionTitle('水印位置'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
decoration: BoxDecoration(
color: const Color(0xFFFFF3E6),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Expanded(
child: Text(
'位置在编辑页直接拖动水印调整,会自动记住',
style: TextStyle(fontSize: 13, color: Color(0xFF6D4C41), height: 1.5),
),
),
if (settings.hasCustomPosition)
TextButton(
onPressed: notifier.resetPosition,
child: const Text(
'恢复默认',
style: TextStyle(fontSize: 13, color: Color(0xFFC8956E)),
),
),
],
),
),
],
),
),
);
}
Widget _sectionTitle(String text) {
return Text(
text,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF8D6E63),
),
);
}
Widget _switchRow(String label, String hint, bool value, ValueChanged<bool> onChanged) {
return InkWell(
onTap: () => onChanged(!value),
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037)),
),
const SizedBox(height: 2),
Text(
hint,
style: const TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)),
),
],
),
),
Switch(value: value, onChanged: onChanged),
],
),
),
);
}
/// 浅色底上的对勾要用深色,不然看不见
static bool _isLight(int argb) {
final r = (argb >> 16) & 0xFF;
final g = (argb >> 8) & 0xFF;
final b = argb & 0xFF;
return (r * 299 + g * 587 + b * 114) / 1000 > 160;
}
}