水印相机前端
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
import '../core/network/api_exception.dart';
|
||||
|
||||
/// 定位 + 逆地理编码 + 天气。
|
||||
///
|
||||
/// **注意这里直连外部服务,不走 [dioProvider]** —— 那个 Dio 带着项目的 baseUrl
|
||||
/// 和 Authorization 拦截器,会把我们的 JWT 一起发到百度/Open-Meteo 去。
|
||||
/// 所以本类自己持有一个干净的 Dio。
|
||||
///
|
||||
/// AK 说明:沿用原「水印相机」项目的百度服务端 AK(用户已确认个人项目可接受
|
||||
/// 硬编码在客户端)。风险只有一个:AK 被刷爆后逆地理会失效,水印仍能正常合成。
|
||||
|
||||
/// 百度地图开放平台 AK(服务端类型,需勾选逆地理编码权限)
|
||||
const String _baiduAk = 'I3IYR0glCNfmfYzMoBDr7lLTKdZ8Y0Xs';
|
||||
const String _baiduReverseUrl = 'https://api.map.baidu.com/reverse_geocoding/v3/';
|
||||
const String _openMeteoUrl = 'https://api.open-meteo.com/v1/forecast';
|
||||
|
||||
final geoApiProvider = Provider<GeoApi>((ref) => GeoApi());
|
||||
|
||||
/// 一次定位拿到的全部信息
|
||||
class GeoSnapshot {
|
||||
const GeoSnapshot({
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.address = '',
|
||||
this.weather = '',
|
||||
this.temperature = '',
|
||||
this.error,
|
||||
});
|
||||
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final String address;
|
||||
final String weather;
|
||||
final String temperature;
|
||||
|
||||
/// 定位失败时的原因(地址/天气会留空,页面据此提示用户)
|
||||
final String? error;
|
||||
|
||||
bool get hasPosition => latitude != null && longitude != null;
|
||||
}
|
||||
|
||||
class GeoApi {
|
||||
GeoApi({Dio? dio})
|
||||
: _dio = dio ??
|
||||
Dio(BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 8),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
));
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
/// 取当前位置(WGS84)。
|
||||
///
|
||||
/// 位置本身失败会抛 [ApiException](权限没给 / 定位服务没开),
|
||||
/// 由调用方决定怎么提示。
|
||||
Future<Position> currentPosition() async {
|
||||
if (!await Geolocator.isLocationServiceEnabled()) {
|
||||
throw ApiException(-1, '定位服务未开启,请到系统设置里打开');
|
||||
}
|
||||
|
||||
var permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
}
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
throw ApiException(-1, '定位权限已被永久拒绝,请到系统设置里开启');
|
||||
}
|
||||
if (permission == LocationPermission.denied) {
|
||||
throw ApiException(-1, '没有定位权限');
|
||||
}
|
||||
|
||||
try {
|
||||
return await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
timeLimit: Duration(seconds: 15),
|
||||
),
|
||||
);
|
||||
} on Exception catch (e) {
|
||||
throw ApiException(-1, '定位失败:${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// 经纬度 → 文字地址。
|
||||
Future<String> reverseAddress({
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
}) async {
|
||||
final Response<dynamic> res;
|
||||
try {
|
||||
res = await _dio.get<dynamic>(
|
||||
_baiduReverseUrl,
|
||||
queryParameters: {
|
||||
'ak': _baiduAk,
|
||||
'output': 'json',
|
||||
// geolocator 给的是 WGS84 原始 GPS 坐标,必须显式告诉百度,
|
||||
// 否则地址会有几百米偏移
|
||||
'coordtype': 'wgs84ll',
|
||||
'location': '$latitude,$longitude',
|
||||
'extensions_poi': 0,
|
||||
},
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw ApiException(-1, '地址解析失败:${e.message ?? '网络异常'}');
|
||||
}
|
||||
|
||||
final data = res.data;
|
||||
if (data is! Map) {
|
||||
throw ApiException(-1, '地址解析失败:返回格式异常');
|
||||
}
|
||||
final status = data['status'];
|
||||
if (status != 0) {
|
||||
throw ApiException(-1, '地址解析失败:${data['msg'] ?? '百度返回 $status'}');
|
||||
}
|
||||
|
||||
final result = data['result'];
|
||||
if (result is! Map) {
|
||||
throw ApiException(-1, '地址解析失败:没有结果');
|
||||
}
|
||||
|
||||
final formatted = result['formatted_address']?.toString() ?? '';
|
||||
if (formatted.isNotEmpty) return formatted;
|
||||
|
||||
// 兜底:拼 addressComponent
|
||||
final ac = result['addressComponent'];
|
||||
if (ac is Map) {
|
||||
final joined = '${ac['city'] ?? ''}${ac['district'] ?? ''}'
|
||||
'${ac['street'] ?? ''}${ac['street_number'] ?? ''}';
|
||||
if (joined.isNotEmpty) return joined;
|
||||
}
|
||||
throw ApiException(-1, '地址解析失败:结果为空');
|
||||
}
|
||||
|
||||
/// 经纬度 → 实时天气。
|
||||
///
|
||||
/// 拿不到就返回 null —— 天气是锦上添花,**不该因为它失败就让整张照片没法出**。
|
||||
Future<({String weather, String temperature})?> weather({
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
}) async {
|
||||
try {
|
||||
final res = await _dio.get<dynamic>(
|
||||
_openMeteoUrl,
|
||||
queryParameters: {
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'current_weather': true,
|
||||
'timezone': 'auto',
|
||||
},
|
||||
);
|
||||
final data = res.data;
|
||||
if (data is! Map) return null;
|
||||
if (data['error'] == true) return null;
|
||||
|
||||
final current = data['current_weather'];
|
||||
if (current is! Map) return null;
|
||||
|
||||
final temp = current['temperature'];
|
||||
return (
|
||||
weather: _weatherText(current['weathercode']),
|
||||
temperature: temp is num ? '${temp.round()}℃' : '',
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 一把梭:定位 → 地址 → 天气。
|
||||
///
|
||||
/// 定位失败不抛异常,而是返回带 [GeoSnapshot.error] 的结果 —— 用户可能只是
|
||||
/// 不想给定位权限,那也该能继续拍照,水印少一行地点而已。
|
||||
Future<GeoSnapshot> resolve() async {
|
||||
final Position position;
|
||||
try {
|
||||
position = await currentPosition();
|
||||
} on ApiException catch (e) {
|
||||
return GeoSnapshot(error: e.message);
|
||||
}
|
||||
|
||||
// 地址拿不到就退回「纬度, 经度」,至少信息量不为零(原项目也这么做)
|
||||
var address = '${position.latitude.toStringAsFixed(4)}, '
|
||||
'${position.longitude.toStringAsFixed(4)}';
|
||||
try {
|
||||
address = await reverseAddress(
|
||||
latitude: position.latitude,
|
||||
longitude: position.longitude,
|
||||
);
|
||||
} catch (_) {
|
||||
// 保持坐标兜底
|
||||
}
|
||||
|
||||
final weather = await this.weather(
|
||||
latitude: position.latitude,
|
||||
longitude: position.longitude,
|
||||
) ??
|
||||
(weather: '', temperature: '');
|
||||
|
||||
return GeoSnapshot(
|
||||
latitude: position.latitude,
|
||||
longitude: position.longitude,
|
||||
address: address,
|
||||
weather: weather.weather,
|
||||
temperature: weather.temperature,
|
||||
);
|
||||
}
|
||||
|
||||
/// Open-Meteo 的 WMO 天气码 → 中文
|
||||
static String _weatherText(Object? code) {
|
||||
if (code is! num) return '';
|
||||
return _weatherCodes[code.toInt()] ?? '未知天气';
|
||||
}
|
||||
}
|
||||
|
||||
const Map<int, String> _weatherCodes = {
|
||||
0: '晴空',
|
||||
1: '大部晴朗',
|
||||
2: '多云',
|
||||
3: '阴天',
|
||||
45: '雾',
|
||||
48: '霜雾',
|
||||
51: '细雨',
|
||||
53: '中细雨',
|
||||
55: '密细雨',
|
||||
56: '冻细雨',
|
||||
57: '强冻细雨',
|
||||
61: '小雨',
|
||||
63: '中雨',
|
||||
65: '大雨',
|
||||
66: '轻冻雨',
|
||||
67: '强冻雨',
|
||||
71: '小雪',
|
||||
73: '中雪',
|
||||
75: '大雪',
|
||||
77: '雪粒',
|
||||
80: '小阵雨',
|
||||
81: '中阵雨',
|
||||
82: '强阵雨',
|
||||
85: '小阵雪',
|
||||
86: '大阵雪',
|
||||
95: '雷阵雨',
|
||||
96: '雷阵雨伴冰雹',
|
||||
99: '强雷阵雨伴大冰雹',
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/network/api_exception.dart';
|
||||
import '../core/network/dio_client.dart';
|
||||
|
||||
final photoApiProvider = Provider<PhotoApi>((ref) => PhotoApi(ref.watch(dioProvider)));
|
||||
|
||||
/// 照片存档接口。
|
||||
///
|
||||
/// App 拍完照把**原图**传上来;水印是本地合成的,和这里无关。
|
||||
/// 上传是「锦上添花」—— 失败了不该影响用户继续编辑和保存照片,
|
||||
/// 所以调用方要自己吞掉异常并给个「重试」入口。
|
||||
class PhotoApi {
|
||||
PhotoApi(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
/// 上传一张照片,返回后端记录 id
|
||||
Future<int?> upload({
|
||||
required String filePath,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? address,
|
||||
String? weather,
|
||||
String? remark,
|
||||
DateTime? takenTime,
|
||||
}) async {
|
||||
try {
|
||||
final form = FormData.fromMap({
|
||||
'file': await MultipartFile.fromFile(
|
||||
filePath,
|
||||
filename: _fileName(filePath),
|
||||
),
|
||||
'latitude': ?latitude,
|
||||
'longitude': ?longitude,
|
||||
if (address != null && address.isNotEmpty) 'address': address,
|
||||
if (weather != null && weather.isNotEmpty) 'weather': weather,
|
||||
if (remark != null && remark.isNotEmpty) 'remark': remark,
|
||||
if (takenTime != null) 'takenTime': _formatTime(takenTime),
|
||||
});
|
||||
|
||||
final res = await _dio.post<dynamic>(
|
||||
'/photos',
|
||||
data: form,
|
||||
// 原图可能有好几 MB,移动网络下给宽一点
|
||||
options: Options(receiveTimeout: const Duration(seconds: 60)),
|
||||
);
|
||||
|
||||
final data = res.data;
|
||||
if (data is Map) {
|
||||
final id = data['id'];
|
||||
if (id is int) return id;
|
||||
}
|
||||
return null;
|
||||
} on DioException catch (e) {
|
||||
final err = e.error;
|
||||
throw err is ApiException ? err : ApiException(-1, '照片上传失败');
|
||||
}
|
||||
}
|
||||
|
||||
/// 从路径里取个带扩展名的文件名,后端靠它判断文件类型
|
||||
static String _fileName(String path) {
|
||||
final normalized = path.replaceAll('\\', '/');
|
||||
final name = normalized.substring(normalized.lastIndexOf('/') + 1);
|
||||
return name.isEmpty ? 'photo.jpg' : name;
|
||||
}
|
||||
|
||||
static String _formatTime(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)}:${two(time.second)}';
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import '../../pages/bill/bill_home_page.dart';
|
||||
import '../../pages/bill/widgets/bill_module_summary.dart';
|
||||
import '../../pages/note/note_list_page.dart';
|
||||
import '../../pages/note/widgets/note_module_summary.dart';
|
||||
import '../../pages/watermark/watermark_camera_page.dart';
|
||||
|
||||
/// 一个功能模块要对外暴露的东西:它是谁、怎么打开自己。
|
||||
///
|
||||
@@ -65,4 +66,11 @@ final List<AppModule> appModules = [
|
||||
open: (_) => const NoteListPage(),
|
||||
summary: (_) => const NoteModuleSummary(),
|
||||
),
|
||||
AppModule(
|
||||
id: 'watermark',
|
||||
name: '水印相机',
|
||||
blurb: '拍照自动加时间地点水印',
|
||||
icon: 'camera',
|
||||
open: (_) => const WatermarkCameraPage(),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -70,4 +70,24 @@ class LocalStorage {
|
||||
if (username == null || password == null) return null;
|
||||
return (username: username, password: password);
|
||||
}
|
||||
|
||||
// ---- 水印设置 ----
|
||||
// 存的是 WatermarkSettings.toJson() 的结果。读取失败/没存过都返回 null,
|
||||
// 由调用方回落到默认设置(见 models/watermark.dart 的 WatermarkSettings.defaults)。
|
||||
static const _keyWatermarkSettings = 'watermark_settings';
|
||||
|
||||
Future<void> saveWatermarkSettings(Map<String, dynamic> json) async {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyWatermarkSettings, jsonEncode(json));
|
||||
}
|
||||
|
||||
Map<String, dynamic>? getWatermarkSettings() {
|
||||
final raw = _prefs?.getString(_keyWatermarkSettings);
|
||||
if (raw == null) return null;
|
||||
try {
|
||||
return jsonDecode(raw) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/storage/local_storage.dart';
|
||||
import '../models/watermark.dart';
|
||||
|
||||
/// 水印设置(全局共享,改动即落盘)
|
||||
///
|
||||
/// 跟项目里其他 provider 不同的一点:这个**不涉及网络**,纯粹是设备本地的偏好,
|
||||
/// 所以直接读写 [LocalStorage],没有 api / 没有 refresh 失效那一套。
|
||||
///
|
||||
/// 需要 `LocalStorage.instance.init()` 已经跑过(`main.dart` 启动时做了)。
|
||||
class WatermarkSettingsNotifier extends Notifier<WatermarkSettings> {
|
||||
@override
|
||||
WatermarkSettings build() {
|
||||
final raw = LocalStorage.instance.getWatermarkSettings();
|
||||
if (raw == null) return WatermarkSettings.defaults;
|
||||
// 存坏了就当没存过,用默认值兜住 —— 不能让一段脏 JSON 把页面打不开
|
||||
try {
|
||||
return WatermarkSettings.fromJson(raw);
|
||||
} catch (_) {
|
||||
return WatermarkSettings.defaults;
|
||||
}
|
||||
}
|
||||
|
||||
/// 整体替换并落盘。设置页里改开关/颜色走这个。
|
||||
void update(WatermarkSettings next) {
|
||||
state = next;
|
||||
LocalStorage.instance.saveWatermarkSettings(next.toJson());
|
||||
}
|
||||
|
||||
/// 拖动过程中更新位置 —— **只改内存,不落盘**。
|
||||
/// 拖一次会产生几十上百个事件,每个都写 SharedPreferences 太浪费。
|
||||
void updatePosition(double x, double y) {
|
||||
state = state.copyWith(posX: x, posY: y);
|
||||
}
|
||||
|
||||
/// 松手时落盘。
|
||||
void persistPosition() {
|
||||
LocalStorage.instance.saveWatermarkSettings(state.toJson());
|
||||
}
|
||||
|
||||
/// 回到默认位置(左下角)
|
||||
void resetPosition() {
|
||||
update(state.copyWith(posX: null, posY: null));
|
||||
}
|
||||
}
|
||||
|
||||
final watermarkSettingsProvider =
|
||||
NotifierProvider<WatermarkSettingsNotifier, WatermarkSettings>(
|
||||
WatermarkSettingsNotifier.new,
|
||||
);
|
||||
|
||||
/// 品牌角标用的相机图标(原「水印相机」项目那张,白色描边 + 透明底)。
|
||||
///
|
||||
/// 画布要的是 `ui.Image`,不是 widget 用的 `AssetImage`,所以这里自己解码一次。
|
||||
/// 不加 autoDispose:就一张小图,加载一次全程复用。
|
||||
final brandIconProvider = FutureProvider<ui.Image>((ref) async {
|
||||
final data = await rootBundle.load('assets/images/watermark_camera.png');
|
||||
final codec = await ui.instantiateImageCodec(data.buffer.asUint8List());
|
||||
final frame = await codec.getNextFrame();
|
||||
codec.dispose();
|
||||
return frame.image;
|
||||
});
|
||||
Reference in New Issue
Block a user