209 lines
6.4 KiB
Dart
209 lines
6.4 KiB
Dart
/// 水印相机模块的数据模型
|
||
///
|
||
/// 拆成两个类是为了区分「**怎么显示**」和「**显示什么**」:
|
||
/// · [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;
|
||
}
|