水印相机前端
This commit is contained in:
@@ -1,5 +1,18 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
|
||||||
|
<!-- 水印相机:定位取地址(逆地理编码) -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||||
|
|
||||||
|
<!-- 水印相机:保存成品图到相册。
|
||||||
|
Android 10(API 29) 起走 MediaStore 不需要权限,所以限到 29 为止。
|
||||||
|
注意**不要**加 CAMERA 权限 —— image_picker 走的是系统相机 Intent,
|
||||||
|
一旦声明了 CAMERA,调用前就必须先申请,反而多一道坎。 -->
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="29"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="强宝小助手"
|
android:label="强宝小助手"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
@@ -3,9 +3,13 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>用于拍摄账单照片进行OCR识别</string>
|
<string>用于拍摄账单照片进行OCR识别,以及水印相机拍照</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>用于选择账单截图进行OCR识别</string>
|
<string>用于选择账单截图进行OCR识别,以及水印相机选图</string>
|
||||||
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
|
<string>用于把加好水印的照片保存到相册</string>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>用于在水印上显示拍摄地点</string>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
|||||||
@@ -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/bill/widgets/bill_module_summary.dart';
|
||||||
import '../../pages/note/note_list_page.dart';
|
import '../../pages/note/note_list_page.dart';
|
||||||
import '../../pages/note/widgets/note_module_summary.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(),
|
open: (_) => const NoteListPage(),
|
||||||
summary: (_) => const NoteModuleSummary(),
|
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;
|
if (username == null || password == null) return null;
|
||||||
return (username: username, password: password);
|
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;
|
||||||
|
});
|
||||||
+121
-1
@@ -97,6 +97,14 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.9"
|
version: "1.0.9"
|
||||||
|
dbus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dbus
|
||||||
|
sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.15"
|
||||||
dio:
|
dio:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -137,6 +145,14 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
ffi_leak_tracker:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ffi_leak_tracker
|
||||||
|
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.2"
|
||||||
file:
|
file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -256,6 +272,86 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
gal:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: gal
|
||||||
|
sha256: f71e79840fe023a21f2f949771375444b6efcd34b9e625d5f4f5504971380a77
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.3"
|
||||||
|
geoclue:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geoclue
|
||||||
|
sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.1"
|
||||||
|
geolocator:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: geolocator
|
||||||
|
sha256: "4e41ad6bc072364ff01822531dac47936c065e5bf8f928f240a25bb51256ada0"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "14.1.1"
|
||||||
|
geolocator_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geolocator_android
|
||||||
|
sha256: b449e3bd4ebdc7fcc1d8377848dd1486a07bfccd8eeff9c1bf66b1c6d8fde7d1
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.1"
|
||||||
|
geolocator_apple:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geolocator_apple
|
||||||
|
sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.14"
|
||||||
|
geolocator_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geolocator_linux
|
||||||
|
sha256: "3da7420f11c3496511a5bd3c18fd67b88e5659f12e46b7ce00a788f6996e850a"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.6"
|
||||||
|
geolocator_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geolocator_platform_interface
|
||||||
|
sha256: b640b6b687b14c66f51fe1130b1c2e8babbd408e0158052b92006b6675ca77fc
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "4.4.0"
|
||||||
|
geolocator_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geolocator_web
|
||||||
|
sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.4"
|
||||||
|
geolocator_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geolocator_windows
|
||||||
|
sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.5"
|
||||||
|
gsettings:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: gsettings
|
||||||
|
sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.8"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -273,7 +369,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
image:
|
image:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89
|
sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89
|
||||||
@@ -440,6 +536,22 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
version: "2.1.0"
|
||||||
|
package_info_plus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_info_plus
|
||||||
|
sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "10.2.1"
|
||||||
|
package_info_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_info_plus_platform_interface
|
||||||
|
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -701,6 +813,14 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
win32:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32
|
||||||
|
sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "6.4.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ dependencies:
|
|||||||
fl_chart: ^1.2.0
|
fl_chart: ^1.2.0
|
||||||
flutter_markdown_plus: ^1.0.12
|
flutter_markdown_plus: ^1.0.12
|
||||||
flutter_svg: ^2.3.0
|
flutter_svg: ^2.3.0
|
||||||
|
geolocator: ^14.1.1
|
||||||
|
gal: ^2.3.3
|
||||||
|
image: ^4.10.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
|
||||||
|
import 'package:qiangbao_accounting/api/geo_api.dart';
|
||||||
|
import 'package:qiangbao_accounting/api/photo_api.dart';
|
||||||
|
import 'package:qiangbao_accounting/pages/watermark/watermark_camera_page.dart';
|
||||||
|
import 'package:qiangbao_accounting/pages/watermark/watermark_edit_page.dart';
|
||||||
|
|
||||||
|
/// 水印相机的页面布局回归测试。
|
||||||
|
///
|
||||||
|
/// 价值在之前抓过真 bug:记账首页顶部一行在 393 宽的常规手机上溢出 32px,
|
||||||
|
/// 光看代码看不出来。
|
||||||
|
///
|
||||||
|
/// 这里只断言「不溢出、不抛异常」,具体样式靠人眼验收。
|
||||||
|
///
|
||||||
|
/// 两个测试上的坑(踩过):
|
||||||
|
/// 1. 页面上的加载圈是**无限动画**,`pumpAndSettle` 永远等不到静止 → 超时。
|
||||||
|
/// 所以编辑页只能手动 pump。
|
||||||
|
/// 2. 读文件/解码图片是**真实异步 I/O**,得用 `tester.runAsync` 才跑得完。
|
||||||
|
|
||||||
|
/// 假定位:不发网络请求,直接返回一份完整快照
|
||||||
|
class _FakeGeoApi extends GeoApi {
|
||||||
|
@override
|
||||||
|
Future<GeoSnapshot> resolve() async => const GeoSnapshot(
|
||||||
|
latitude: 25.041234,
|
||||||
|
longitude: 102.712345,
|
||||||
|
address: '云南省昆明市五华区莲花池正街某小区某栋某单元',
|
||||||
|
weather: '晴',
|
||||||
|
temperature: '25℃',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 定位失败:页面仍应可用,只是少了地点那一行
|
||||||
|
class _FailingGeoApi extends GeoApi {
|
||||||
|
@override
|
||||||
|
Future<GeoSnapshot> resolve() async => const GeoSnapshot(error: '没有定位权限');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 假上传:立刻成功,避免测试里真的打网络
|
||||||
|
class _FakePhotoApi extends PhotoApi {
|
||||||
|
_FakePhotoApi() : super(Dio());
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int?> upload({
|
||||||
|
required String filePath,
|
||||||
|
double? latitude,
|
||||||
|
double? longitude,
|
||||||
|
String? address,
|
||||||
|
String? weather,
|
||||||
|
String? remark,
|
||||||
|
DateTime? takenTime,
|
||||||
|
}) async =>
|
||||||
|
1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const sizes = [
|
||||||
|
('iPhone SE 窄屏', Size(320, 568)),
|
||||||
|
('常见安卓机', Size(393, 873)),
|
||||||
|
('大屏', Size(480, 1000)),
|
||||||
|
];
|
||||||
|
|
||||||
|
void useSize(WidgetTester tester, Size size) {
|
||||||
|
tester.view.physicalSize = size * 3;
|
||||||
|
tester.view.devicePixelRatio = 3.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 造一张真实的小图(编辑页要读它的文件头拿尺寸)
|
||||||
|
File makePhoto(WidgetTester tester) {
|
||||||
|
final dir = Directory.systemTemp.createTempSync('wm_layout_test');
|
||||||
|
addTearDown(() {
|
||||||
|
// Image.file 可能还占着文件句柄,删不掉就算了,别让清理把测试搞挂
|
||||||
|
try {
|
||||||
|
dir.deleteSync(recursive: true);
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
return File('${dir.path}/photo.png')
|
||||||
|
..writeAsBytesSync(img.encodePng(img.Image(width: 120, height: 90)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 等真实 I/O 跑完再渲染一帧
|
||||||
|
Future<void> settleIo(WidgetTester tester) async {
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||||
|
});
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final (label, size) in sizes) {
|
||||||
|
testWidgets('入口页 $label 不溢出', (tester) async {
|
||||||
|
useSize(tester, size);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const ProviderScope(child: MaterialApp(home: WatermarkCameraPage())),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('水印相机'), findsOneWidget);
|
||||||
|
expect(find.text('拍照'), findsOneWidget);
|
||||||
|
expect(find.text('从相册选'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('编辑页 $label 不溢出', (tester) async {
|
||||||
|
useSize(tester, size);
|
||||||
|
final photo = makePhoto(tester);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
geoApiProvider.overrideWithValue(_FakeGeoApi()),
|
||||||
|
photoApiProvider.overrideWithValue(_FakePhotoApi()),
|
||||||
|
],
|
||||||
|
child: MaterialApp(home: WatermarkEditPage(imagePath: photo.path)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await settleIo(tester);
|
||||||
|
|
||||||
|
expect(find.text('加水印'), findsOneWidget);
|
||||||
|
expect(find.text('保存'), findsOneWidget);
|
||||||
|
// 假定位的地址应该显示出来了(顺带验证异步链路是通的)
|
||||||
|
expect(find.textContaining('昆明市'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('设置弹层在窄屏也不溢出', (tester) async {
|
||||||
|
useSize(tester, const Size(320, 568));
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const ProviderScope(child: MaterialApp(home: WatermarkCameraPage())),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 点右上角进设置
|
||||||
|
await tester.tap(find.byIcon(Icons.tune));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('水印设置'), findsOneWidget);
|
||||||
|
expect(find.text('天气温度'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('定位失败时编辑页仍可用(只是少一行地点)', (tester) async {
|
||||||
|
useSize(tester, const Size(393, 873));
|
||||||
|
final photo = makePhoto(tester);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
geoApiProvider.overrideWithValue(_FailingGeoApi()),
|
||||||
|
photoApiProvider.overrideWithValue(_FakePhotoApi()),
|
||||||
|
],
|
||||||
|
child: MaterialApp(home: WatermarkEditPage(imagePath: photo.path)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await settleIo(tester);
|
||||||
|
|
||||||
|
expect(find.textContaining('没有定位权限'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
|
||||||
|
import 'package:qiangbao_accounting/models/watermark.dart';
|
||||||
|
import 'package:qiangbao_accounting/pages/watermark/watermark_painter.dart';
|
||||||
|
|
||||||
|
/// 把水印渲染到一张模拟背景上并**写出图片文件**,用来用眼睛验收排版。
|
||||||
|
///
|
||||||
|
/// 为什么需要它:单元测试只能断言「不报错、尺寸对」,断言不了「好不好看」。
|
||||||
|
/// 之前正是靠看图才发现「时间:2026.09.28 14:30」是 19 个字、被折行阈值切成
|
||||||
|
/// 两行、最后一个「0」孤零零占了一行。
|
||||||
|
///
|
||||||
|
/// 跑法:
|
||||||
|
/// ```
|
||||||
|
/// flutter test test/watermark_preview_test.dart
|
||||||
|
/// ```
|
||||||
|
/// 输出图片的路径会打印在结果里,直接用图片查看器打开即可。
|
||||||
|
///
|
||||||
|
/// 注意:测试环境没有中文字体,汉字会渲染成方块(Ahem 测试字体)——
|
||||||
|
/// **这是正常的**,看的是排版/位置/颜色,不是字形。真机上才是真汉字。
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
/// 造一张「有明有暗」的底图,用来检查白字在浅色区域会不会糊
|
||||||
|
img.Image makeBackground(int w, int h) {
|
||||||
|
final bg = img.Image(width: w, height: h);
|
||||||
|
for (var y = 0; y < h; y++) {
|
||||||
|
for (var x = 0; x < w; x++) {
|
||||||
|
final t = x / w;
|
||||||
|
var v = (215 - 150 * t).round(); // 左亮右暗
|
||||||
|
if (y > h * 0.55 && y < h * 0.62) v = 235; // 一条浅色横带,模拟高光
|
||||||
|
bg.setPixelRgb(x, y, v, (v * 0.96).round(), (v * 0.9).round());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('渲染一张水印预览图供人工核对', () async {
|
||||||
|
final iconData = await rootBundle.load('assets/images/watermark_camera.png');
|
||||||
|
final codec = await ui.instantiateImageCodec(iconData.buffer.asUint8List());
|
||||||
|
final brandIcon = (await codec.getNextFrame()).image;
|
||||||
|
|
||||||
|
// 全项打开,把四种行和品牌角标都覆盖到
|
||||||
|
final settings = WatermarkSettings.defaults.copyWith(
|
||||||
|
showWeather: true,
|
||||||
|
showRemark: true,
|
||||||
|
);
|
||||||
|
final data = WatermarkData(
|
||||||
|
time: DateTime(2026, 9, 28, 14, 30),
|
||||||
|
address: '云南省昆明市五华区莲花池正街某小区 3 栋',
|
||||||
|
weather: '晴',
|
||||||
|
temperature: '25℃',
|
||||||
|
remark: '和宝一起吃的那家店',
|
||||||
|
);
|
||||||
|
|
||||||
|
final jpg = await composeWatermarkedJpeg(
|
||||||
|
originalBytes: img.encodePng(makeBackground(1080, 1440)),
|
||||||
|
settings: settings,
|
||||||
|
data: data,
|
||||||
|
brandIcon: brandIcon,
|
||||||
|
maxWidth: 1080,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 顺带当一次端到端冒烟:产物必须是能解回来的合法 JPEG
|
||||||
|
final decoded = img.decodeJpg(jpg);
|
||||||
|
expect(decoded, isNotNull);
|
||||||
|
expect(decoded!.width, 1080);
|
||||||
|
expect(decoded.height, 1440);
|
||||||
|
|
||||||
|
final out = File('${Directory.systemTemp.path}/wm_preview.jpg')
|
||||||
|
..writeAsBytesSync(jpg);
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('水印预览图已写出(人工核对用): ${out.path}');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
|
||||||
|
import 'package:qiangbao_accounting/models/watermark.dart';
|
||||||
|
import 'package:qiangbao_accounting/pages/watermark/watermark_painter.dart';
|
||||||
|
|
||||||
|
/// 水印模块的核心逻辑测试。
|
||||||
|
///
|
||||||
|
/// 这里最值钱的一条是「不同画布尺寸下布局等比缩放」——
|
||||||
|
/// **预览和导出共用同一个 [paintWatermark],正是靠这个性质才做到所见即所得。**
|
||||||
|
/// 它一旦被破坏,用户就会遇到「预览挺好,导出来位置/大小不对」。
|
||||||
|
void main() {
|
||||||
|
// TextPainter 需要绑定初始化才能量文字
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
final sampleData = WatermarkData(
|
||||||
|
time: DateTime(2026, 9, 28, 14, 30),
|
||||||
|
address: '云南省昆明市五华区莲花池正街',
|
||||||
|
weather: '晴',
|
||||||
|
temperature: '25℃',
|
||||||
|
remark: '测试备注',
|
||||||
|
);
|
||||||
|
|
||||||
|
group('时间格式化', () {
|
||||||
|
test('补零到 2026.09.28 14:30 这种样子', () {
|
||||||
|
expect(formatWatermarkTime(DateTime(2026, 9, 28, 14, 30)), '2026.09.28 14:30');
|
||||||
|
expect(formatWatermarkTime(DateTime(2026, 1, 5, 9, 7)), '2026.01.05 09:07');
|
||||||
|
expect(formatWatermarkTime(DateTime(2026, 12, 31, 23, 59)), '2026.12.31 23:59');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('水印正文拼装', () {
|
||||||
|
/// 取文本,忽略折行标记
|
||||||
|
List<String> texts(WatermarkSettings s, WatermarkData d) =>
|
||||||
|
buildWatermarkLines(s, d).map((l) => l.text).toList();
|
||||||
|
|
||||||
|
test('默认设置只出时间和地点', () {
|
||||||
|
expect(texts(WatermarkSettings.defaults, sampleData),
|
||||||
|
['时间:2026.09.28 14:30', '地点:云南省昆明市五华区莲花池正街']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('关掉的项不产生行', () {
|
||||||
|
final settings = WatermarkSettings.defaults.copyWith(
|
||||||
|
showTime: false,
|
||||||
|
showAddress: false,
|
||||||
|
);
|
||||||
|
expect(texts(settings, sampleData), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('要求显示天气但拿不到天气时,不留下空行', () {
|
||||||
|
final settings = WatermarkSettings.defaults.copyWith(showWeather: true);
|
||||||
|
final noWeather = sampleData.copyWith(weather: '', temperature: '');
|
||||||
|
expect(
|
||||||
|
texts(settings, noWeather).any((l) => l.startsWith('天气:')),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('有天气时拼成「天气:晴 25℃」', () {
|
||||||
|
final settings = WatermarkSettings.defaults.copyWith(showWeather: true);
|
||||||
|
expect(texts(settings, sampleData), contains('天气:晴 25℃'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('备注为空时不出行,有内容时去掉首尾空白', () {
|
||||||
|
final settings = WatermarkSettings.defaults.copyWith(showRemark: true);
|
||||||
|
expect(texts(settings, sampleData), contains('备注:测试备注'));
|
||||||
|
|
||||||
|
final blank = sampleData.copyWith(remark: ' ');
|
||||||
|
expect(
|
||||||
|
texts(settings, blank).any((l) => l.startsWith('备注:')),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('地址拿不到时给「未知地点」占位,而不是留个空壳', () {
|
||||||
|
final lines = texts(WatermarkSettings.defaults, sampleData.copyWith(address: ''));
|
||||||
|
expect(lines, contains('地点:未知地点'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('时间行标记为「不折行」,其余行允许折', () {
|
||||||
|
final settings = WatermarkSettings.defaults.copyWith(showRemark: true);
|
||||||
|
final lines = buildWatermarkLines(settings, sampleData);
|
||||||
|
|
||||||
|
expect(lines.first.wrap, isFalse, reason: '时间行不能折');
|
||||||
|
expect(
|
||||||
|
lines.first.text.length,
|
||||||
|
greaterThan(18),
|
||||||
|
reason: '它正好 19 个字,超过折行阈值 —— 所以才必须豁免,否则会甩出一个孤字',
|
||||||
|
);
|
||||||
|
expect(lines.skip(1).every((l) => l.wrap), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('设置序列化', () {
|
||||||
|
test('存活一圈 JSON 还原后内容不变', () {
|
||||||
|
const original = WatermarkSettings(
|
||||||
|
showTime: false,
|
||||||
|
showAddress: true,
|
||||||
|
showWeather: true,
|
||||||
|
showRemark: true,
|
||||||
|
showBrand: false,
|
||||||
|
colorValue: 0xFF000000,
|
||||||
|
posX: 0.25,
|
||||||
|
posY: 0.75,
|
||||||
|
);
|
||||||
|
final restored = WatermarkSettings.fromJson(original.toJson());
|
||||||
|
|
||||||
|
expect(restored.showTime, isFalse);
|
||||||
|
expect(restored.showAddress, isTrue);
|
||||||
|
expect(restored.showWeather, isTrue);
|
||||||
|
expect(restored.showRemark, isTrue);
|
||||||
|
expect(restored.showBrand, isFalse);
|
||||||
|
expect(restored.colorValue, 0xFF000000);
|
||||||
|
expect(restored.posX, 0.25);
|
||||||
|
expect(restored.posY, 0.75);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('脏数据不会把设置读崩,缺字段走默认值', () {
|
||||||
|
final restored = WatermarkSettings.fromJson(const {});
|
||||||
|
expect(restored.showTime, WatermarkSettings.defaults.showTime);
|
||||||
|
expect(restored.showAddress, WatermarkSettings.defaults.showAddress);
|
||||||
|
expect(restored.colorValue, WatermarkSettings.defaults.colorValue);
|
||||||
|
expect(restored.posX, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith 能把位置显式置回 null(重置位置要用)', () {
|
||||||
|
const moved = WatermarkSettings(posX: 0.3, posY: 0.4);
|
||||||
|
expect(moved.copyWith().posX, 0.3, reason: '不传参数时保持不变');
|
||||||
|
expect(moved.copyWith(posX: null).posX, isNull, reason: '显式置 null 要生效');
|
||||||
|
expect(moved.copyWith(posX: null).posY, 0.4, reason: '另一个轴不受影响');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('布局', () {
|
||||||
|
test('★ 时间行不会被折成两行(曾经把最后一个数字甩出去)', () {
|
||||||
|
// 「时间:2026.09.28 14:30」是 19 个字,而折行阈值是 18。
|
||||||
|
// 统一折行的话最后一个「0」会单独占一行 —— 这是导出预览图上真看到的。
|
||||||
|
final layout = layoutWatermark(
|
||||||
|
const Size(1080, 1440),
|
||||||
|
WatermarkSettings.defaults,
|
||||||
|
sampleData.copyWith(address: '昆明'),
|
||||||
|
);
|
||||||
|
expect(layout.lines.length, 2, reason: '时间 + 地点,正好两行');
|
||||||
|
expect(layout.lines.first, '时间:2026.09.28 14:30');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('没拖过时默认贴左下角', () {
|
||||||
|
final layout = layoutWatermark(
|
||||||
|
const Size(1080, 1440),
|
||||||
|
WatermarkSettings.defaults,
|
||||||
|
sampleData,
|
||||||
|
);
|
||||||
|
// 左边距 28(设计稿单位),1080 宽时 scale = 1
|
||||||
|
expect(layout.origin.dx, closeTo(28, 0.01));
|
||||||
|
// 底边也留 28
|
||||||
|
expect(
|
||||||
|
layout.origin.dy + layout.cardHeight,
|
||||||
|
closeTo(1440 - 28, 0.5),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('拖过之后用归一化坐标', () {
|
||||||
|
const settings = WatermarkSettings(posX: 0.5, posY: 0.25);
|
||||||
|
final layout = layoutWatermark(const Size(1080, 1440), settings, sampleData);
|
||||||
|
expect(layout.origin.dx, closeTo(540, 0.01));
|
||||||
|
expect(layout.origin.dy, closeTo(360, 0.01));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('★ 等比缩放的画布,布局等比放大(预览=导出的前提)', () {
|
||||||
|
const settings = WatermarkSettings();
|
||||||
|
final small = layoutWatermark(const Size(540, 720), settings, sampleData);
|
||||||
|
final big = layoutWatermark(const Size(1080, 1440), settings, sampleData);
|
||||||
|
|
||||||
|
expect(big.scale, closeTo(small.scale * 2, 1e-9));
|
||||||
|
expect(big.textHeight, closeTo(small.textHeight * 2, 0.01));
|
||||||
|
expect(big.cardHeight, closeTo(small.cardHeight * 2, 0.01));
|
||||||
|
expect(big.cardWidth, closeTo(small.cardWidth * 2, 0.01));
|
||||||
|
expect(big.origin.dx, closeTo(small.origin.dx * 2, 0.01));
|
||||||
|
expect(big.origin.dy, closeTo(small.origin.dy * 2, 0.01));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('长地址会折行,块高随之增加', () {
|
||||||
|
final short = layoutWatermark(
|
||||||
|
const Size(1080, 1440),
|
||||||
|
WatermarkSettings.defaults,
|
||||||
|
sampleData.copyWith(address: '昆明'),
|
||||||
|
);
|
||||||
|
final long = layoutWatermark(
|
||||||
|
const Size(1080, 1440),
|
||||||
|
WatermarkSettings.defaults,
|
||||||
|
sampleData.copyWith(address: '云南省昆明市五华区莲花池正街某小区某栋某单元某室'),
|
||||||
|
);
|
||||||
|
expect(long.lines.length, greaterThan(short.lines.length));
|
||||||
|
expect(long.cardHeight, greaterThan(short.cardHeight));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('内容全关时布局为空(拖也没得拖)', () {
|
||||||
|
final layout = layoutWatermark(
|
||||||
|
const Size(1080, 1440),
|
||||||
|
WatermarkSettings.defaults.copyWith(
|
||||||
|
showTime: false,
|
||||||
|
showAddress: false,
|
||||||
|
),
|
||||||
|
sampleData,
|
||||||
|
);
|
||||||
|
expect(layout.isEmpty, isTrue);
|
||||||
|
expect(layout.cardHeight, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('绘制', () {
|
||||||
|
test('paintWatermark 在真实画布上不抛异常', () {
|
||||||
|
final recorder = ui.PictureRecorder();
|
||||||
|
final canvas = Canvas(recorder);
|
||||||
|
paintWatermark(canvas, const Size(1080, 1440), WatermarkSettings.defaults, sampleData);
|
||||||
|
recorder.endRecording().dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('内容全关 + 关掉品牌角标时也不会崩', () {
|
||||||
|
final settings = WatermarkSettings.defaults.copyWith(
|
||||||
|
showTime: false,
|
||||||
|
showAddress: false,
|
||||||
|
showBrand: false,
|
||||||
|
);
|
||||||
|
final recorder = ui.PictureRecorder();
|
||||||
|
paintWatermark(Canvas(recorder), const Size(1080, 1440), settings, sampleData);
|
||||||
|
recorder.endRecording().dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('尺寸为 0 时直接返回,不做无意义绘制', () {
|
||||||
|
final recorder = ui.PictureRecorder();
|
||||||
|
paintWatermark(Canvas(recorder), Size.zero, WatermarkSettings.defaults, sampleData);
|
||||||
|
recorder.endRecording().dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('导出(整条离屏管线)', () {
|
||||||
|
// 这是风险最高的一段:解码 → 离屏绘制 → 栅格化 → JPEG 编码,
|
||||||
|
// 中间任何一环 API 用错都会到真机上才炸,所以这里端到端跑一遍。
|
||||||
|
test('合成出来的字节是合法 JPEG,尺寸不变', () async {
|
||||||
|
final source = img.encodePng(img.Image(width: 200, height: 150));
|
||||||
|
|
||||||
|
final bytes = await composeWatermarkedJpeg(
|
||||||
|
originalBytes: source,
|
||||||
|
settings: WatermarkSettings.defaults,
|
||||||
|
data: sampleData,
|
||||||
|
);
|
||||||
|
|
||||||
|
// JPEG 魔数 FF D8
|
||||||
|
expect(bytes.length, greaterThan(200));
|
||||||
|
expect(bytes[0], 0xFF);
|
||||||
|
expect(bytes[1], 0xD8);
|
||||||
|
|
||||||
|
final decoded = img.decodeJpg(bytes);
|
||||||
|
expect(decoded, isNotNull);
|
||||||
|
expect(decoded!.width, 200);
|
||||||
|
expect(decoded.height, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('超过 maxWidth 的图会被等比缩下来(省内存 + 让编码别太慢)', () async {
|
||||||
|
final source = img.encodePng(img.Image(width: 800, height: 600));
|
||||||
|
|
||||||
|
final bytes = await composeWatermarkedJpeg(
|
||||||
|
originalBytes: source,
|
||||||
|
settings: WatermarkSettings.defaults,
|
||||||
|
data: sampleData,
|
||||||
|
maxWidth: 400,
|
||||||
|
);
|
||||||
|
|
||||||
|
final decoded = img.decodeJpg(bytes);
|
||||||
|
expect(decoded!.width, 400);
|
||||||
|
expect(decoded.height, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('原始图比 maxWidth 小的时候不会被放大', () async {
|
||||||
|
final source = img.encodePng(img.Image(width: 120, height: 90));
|
||||||
|
|
||||||
|
final bytes = await composeWatermarkedJpeg(
|
||||||
|
originalBytes: source,
|
||||||
|
settings: WatermarkSettings.defaults,
|
||||||
|
data: sampleData,
|
||||||
|
maxWidth: 1920,
|
||||||
|
);
|
||||||
|
|
||||||
|
final decoded = img.decodeJpg(bytes);
|
||||||
|
expect(decoded!.width, 120);
|
||||||
|
expect(decoded.height, 90);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user