/// 水印相机模块的数据模型 /// /// 拆成两个类是为了区分「**怎么显示**」和「**显示什么**」: /// · [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 toJson() => { 'showTime': showTime, 'showAddress': showAddress, 'showWeather': showWeather, 'showRemark': showRemark, 'showBrand': showBrand, 'colorValue': colorValue, 'posX': posX, 'posY': posY, }; factory WatermarkSettings.fromJson(Map 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 buildWatermarkLines(WatermarkSettings settings, WatermarkData data) { final lines = []; 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; }