水印相机前端

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
+208
View File
@@ -0,0 +1,208 @@
/// 水印相机模块的数据模型
///
/// 拆成两个类是为了区分「**怎么显示**」和「**显示什么**」:
/// · [WatermarkSettings] —— 全局设置,持久化,很久才改一次(开关/颜色/位置)
/// · [WatermarkData] —— 每张照片都不一样(拍摄时间/地址/天气/备注)
///
/// 绘制入口见 `pages/watermark/watermark_painter.dart`。
library;
/// 水印设置(全局共享,存 LocalStorage)
class WatermarkSettings {
const WatermarkSettings({
this.showTime = true,
this.showAddress = true,
this.showWeather = false,
this.showRemark = false,
this.showBrand = true,
this.colorValue = 0xFFFFFFFF,
this.posX,
this.posY,
});
/// 默认:时间 + 地点 + 品牌角标(和原「水印相机」的默认观感一致)
static const defaults = WatermarkSettings();
final bool showTime;
final bool showAddress;
final bool showWeather;
final bool showRemark;
/// 右下角「相机图标 + 水印相机」角标
final bool showBrand;
/// 文字颜色(ARGB)。默认白色 —— 深色照片上最通用。
final int colorValue;
/// 水印块的**归一化左上角坐标**(0~1,相对于画布宽高)。
///
/// 为 null 表示用默认位置(左下角)。存归一化而不是像素,是为了让预览和导出
/// 天然一致,也让同一份设置在不同宽高比的照片上都能用。
final double? posX;
final double? posY;
/// 有没有任何一行正文要显示(不算品牌角标)
bool get hasTextContent => showTime || showAddress || showWeather || showRemark;
/// 是否已经手动拖过位置
bool get hasCustomPosition => posX != null && posY != null;
static const _sentinel = Object();
WatermarkSettings copyWith({
bool? showTime,
bool? showAddress,
bool? showWeather,
bool? showRemark,
bool? showBrand,
int? colorValue,
// 用 sentinel 兜住「显式置回 null」(重置位置要用)
Object? posX = _sentinel,
Object? posY = _sentinel,
}) {
return WatermarkSettings(
showTime: showTime ?? this.showTime,
showAddress: showAddress ?? this.showAddress,
showWeather: showWeather ?? this.showWeather,
showRemark: showRemark ?? this.showRemark,
showBrand: showBrand ?? this.showBrand,
colorValue: colorValue ?? this.colorValue,
posX: posX == _sentinel ? this.posX : posX as double?,
posY: posY == _sentinel ? this.posY : posY as double?,
);
}
Map<String, dynamic> toJson() => {
'showTime': showTime,
'showAddress': showAddress,
'showWeather': showWeather,
'showRemark': showRemark,
'showBrand': showBrand,
'colorValue': colorValue,
'posX': posX,
'posY': posY,
};
factory WatermarkSettings.fromJson(Map<String, dynamic> json) {
return WatermarkSettings(
showTime: _asBool(json['showTime'], true),
showAddress: _asBool(json['showAddress'], true),
showWeather: _asBool(json['showWeather'], false),
showRemark: _asBool(json['showRemark'], false),
showBrand: _asBool(json['showBrand'], true),
colorValue: _asInt(json['colorValue'], 0xFFFFFFFF),
posX: _asDoubleOrNull(json['posX']),
posY: _asDoubleOrNull(json['posY']),
);
}
static bool _asBool(Object? v, bool fallback) => v is bool ? v : fallback;
static int _asInt(Object? v, int fallback) {
if (v is int) return v;
if (v is num) return v.toInt();
return fallback;
}
static double? _asDoubleOrNull(Object? v) {
if (v is num) return v.toDouble();
return null;
}
}
/// 这一张照片要显示的内容
class WatermarkData {
const WatermarkData({
required this.time,
this.address = '',
this.weather = '',
this.temperature = '',
this.remark = '',
});
/// 拍摄时间(水印上只用到「分」)
final DateTime time;
final String address;
/// 天气描述,如「晴」
final String weather;
/// 温度,如「25℃」
final String temperature;
final String remark;
/// 天气行是否可显示(天气和温度都拿不到时不显示这一行)
bool get hasWeather => weather.isNotEmpty || temperature.isNotEmpty;
WatermarkData copyWith({
DateTime? time,
String? address,
String? weather,
String? temperature,
String? remark,
}) {
return WatermarkData(
time: time ?? this.time,
address: address ?? this.address,
weather: weather ?? this.weather,
temperature: temperature ?? this.temperature,
remark: remark ?? this.remark,
);
}
}
/// 时间格式化成水印上的样子:`2026.09.28 14:30`
///
/// 纯函数,方便单测。用两位补零,月/日/时/分都对齐。
String formatWatermarkTime(DateTime time) {
String two(int n) => n.toString().padLeft(2, '0');
return '${time.year}.${two(time.month)}.${two(time.day)} '
'${two(time.hour)}:${two(time.minute)}';
}
/// 水印正文的一行
class WatermarkLine {
const WatermarkLine(this.text, {this.wrap = true});
final String text;
/// 是否允许折行。
///
/// 时间行**不折**:它是定长字符串(「时间:2026.09.28 14:30」正好 19 个字),
/// 按字数硬折会把最后一个数字甩到单独一行。
/// 这不是猜的 —— 是在导出预览图上真看到了一行只有一个「0」才发现的。
final bool wrap;
}
/// 把设置和数据拼成水印正文的每一行
///
/// 顺序固定为 时间 → 天气 → 地点 → 备注(和原项目一致)。
/// 关掉的项目直接不产生行;天气/备注没内容时也不占一行。
List<WatermarkLine> buildWatermarkLines(WatermarkSettings settings, WatermarkData data) {
final lines = <WatermarkLine>[];
if (settings.showTime) {
lines.add(WatermarkLine(
'时间:${formatWatermarkTime(data.time)}',
wrap: false,
));
}
if (settings.showWeather && data.hasWeather) {
lines.add(WatermarkLine('天气:${'${data.weather} ${data.temperature}'.trim()}'));
}
if (settings.showAddress) {
// 地址拿不到时给个占位,而不是留个「地点:」的空壳
final address = data.address.isEmpty ? '未知地点' : data.address;
lines.add(WatermarkLine('地点:$address'));
}
if (settings.showRemark && data.remark.trim().isNotEmpty) {
lines.add(WatermarkLine('备注:${data.remark.trim()}'));
}
return lines;
}