From c5b3d81d07a72799a603bb3770f247b0a64ed139 Mon Sep 17 00:00:00 2001 From: handsomeAq Date: Mon, 28 Sep 2026 09:59:43 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B0=B4=E5=8D=B0=E7=9B=B8=E6=9C=BA=E5=89=8D?= =?UTF-8?q?=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 13 + assets/images/watermark_camera.png | Bin 0 -> 5560 bytes ios/Runner/Info.plist | 8 +- lib/api/geo_api.dart | 247 +++++++++ lib/api/photo_api.dart | 74 +++ lib/core/module/app_module.dart | 8 + lib/core/storage/local_storage.dart | 20 + lib/models/watermark.dart | 208 ++++++++ .../watermark/watermark_camera_page.dart | 211 ++++++++ lib/pages/watermark/watermark_edit_page.dart | 475 ++++++++++++++++++ lib/pages/watermark/watermark_painter.dart | 370 ++++++++++++++ .../widgets/watermark_content_sheet.dart | 179 +++++++ .../widgets/watermark_settings_sheet.dart | 221 ++++++++ lib/providers/watermark_provider.dart | 66 +++ pubspec.lock | 122 ++++- pubspec.yaml | 3 + test/watermark_layout_test.dart | 167 ++++++ test/watermark_preview_test.dart | 79 +++ test/watermark_test.dart | 293 +++++++++++ 19 files changed, 2761 insertions(+), 3 deletions(-) create mode 100644 assets/images/watermark_camera.png create mode 100644 lib/api/geo_api.dart create mode 100644 lib/api/photo_api.dart create mode 100644 lib/models/watermark.dart create mode 100644 lib/pages/watermark/watermark_camera_page.dart create mode 100644 lib/pages/watermark/watermark_edit_page.dart create mode 100644 lib/pages/watermark/watermark_painter.dart create mode 100644 lib/pages/watermark/widgets/watermark_content_sheet.dart create mode 100644 lib/pages/watermark/widgets/watermark_settings_sheet.dart create mode 100644 lib/providers/watermark_provider.dart create mode 100644 test/watermark_layout_test.dart create mode 100644 test/watermark_preview_test.dart create mode 100644 test/watermark_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6a18d88..6676dad 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,18 @@ + + + + + + + + |jTPoN&q*FOHn!tfQxiMZv*+Ih zn?o}zg433pK<>*;N_g2K(-AgxH$#a)IVFuIT z)MvlFFZe(p`t6j*fZpvw#!<*06)+G$2Le#^ArmxoC=^8-A{%;e#Xx)bbZk$sa3gMx z7zqFY-qZh*^?r$L2!2kF<53$#L4iU1D{8(nESa;;FTv}Q05bH~6y%u$*)SLI3NQ|? zzRN;3%kljl7}h|Se9CPkm8E^Cl`OH8XbASk+D0 zSNlO8c)hLzJfHuSx){SvfS{nw(2tPAA^IKr9pkw=PTrqtE9ZNe2|Q~6=Nh(il9z=| z-UC@O?mW)PPvG2ZhTov~-o89|Nr%F?q>d*!@u=Z8*$Sp?%`#UnPu5#QZBC=03G?J% zhM-XA4Ye&en)hUzr8EoGyzkN@WxL8v!cttrS`3{@)BDyg%AXu@h*JPT@Z9O~<9%mIZNR>_OL7Ce5v9Me7B0Hu$xovr3=HN6= zKi3Erw$g*K>k-*%b|M3d9MbLzc-ao^dWFVD^CrWG9=}=O*eO zU>uE(;@_<^JB4pdq51knWTRYyhr^?F@e7~#93#0Pzh&KZ4ISV0kk?A}A3C_Y*Z>xAD2h;(?d7_3-O?#UU|#dTaFoHjzWcF{pa({8O^Vl&iz7TZH1$Gf};9NvHSMtIRi>%tIK+4BI zJ^^|cQ)N{wr8RfHYC*!UEqv$D1{I`bH3n|w`xu{7;Ftxfz-X9+?g$#M-No|GISzmY z9}?iKx^i%$2TwPLPHT{KH8pyZ#G8KUH;~Fd@~f4qKAul)4 z)JU1b_S%@1!%oxuGKl5au(&eX;+H&G)p?|~`Xuf>HBl0>`i=lcGdlv|92g-AU^qFy zP}Ok_Hc8QK?oKNazaI)miEGQ@iz53vSu@{;YIfGwB%>>Xbu*wy9wlNFB9bm9B^!YJ z+-7gZIb?7G0gWH&+X(2qVGbC8CY~2V#kg30`vZY+dc;3>J!`zNnetWwfZ|zKq^-ed z?axd$99?%tg>;$|;)c{VeFP~(?`l$DSSk*#9}t>+D?t6dGXO;lXq#kNsUdIl8#vD* z|GqZ#XUGa(D4Ve$tM-*P16k|S?45dZ!pLFWpG<5?-07<^Vm=mbXCV9CS{qOWw_E`O zvcPJ!(!!uL(b`B$x?&CNeAD+@DahVc2{vn-oEXN6&o9wq@I| z#&I1owT9(lI-bx^@7hVfy&hlS+qi1Wfy%}++y^@^s1JHCv?O%f!1rF3kZHj|O|IVT z<*;PD0zj%pUK8(LN|?Oqb}0X2@!i?Qw`)R_FGbcxB4jqod3;@TVYwZT1AXYW1B4`C zq?p{{-_&*XNNs-ofo+s&C>DTkDMD1ys%@bfI1H4x(@rVjv0;4iI*_N0EVXo(! zyZNUACa%5$;PWOar|Cnjlo_-BxzpwKrLJwe-hDResO;o!D)<^{CUSSI3EH2!kRQt?PxSVt1sgEg5{^;4~x<~ndyjcVtdsRgFwLWUQU;IlCB93KVSkHq8P^FAd5Nosyj;PCpPOon~4skPOt zg(yiN(F$98FKG?@G7bu_Eesb7=+WGQfFIJ5+c2#={D2=+@Q8A7G>R^06tkM}yN1Dj zh?RKB67I=KuNXc|WH}VEiUR7;rmC|}AA8Dl4ckR1>h*Un*e(0PfV}Oi0y=V3k<=?5 zUoXhG50tB(1G~Y}dAxx_Y^LZ!Z8D8YoFSHMPQFi^OGrL(E-W#JYIqZXzgy_P*VxR> zfq5ksa}s1{)HYDNG6{K&EA=&9I|<_0pQJ>lUZGHO!2E~;|0F|F?iVvKP0w#EOyHB+ z6ZvB>8x2%5&b#jl-hTDnRyofp0g+Ztw`*P zEWjey_WRDS{79;G9KG@V{bIT7Sa&yf(jZ5qsng+Sf#9ciz94xovh+XOw=N(L`+$>f zCZf}-l(>yEg^agT-2;Cebx{>%7N4`PB?OgNz7`8$m^GvcA4?FCZ^g`yASzovG;uoD zN8QwjP@x+#-{|zGd~~_G48S9Sr>~X17U3*(VxVo5yMiCYh=;SQ&`O8GRYHfvD9`!_ zA`3Y6zjHu@FCRB>(Rm{ZB$JnND|~J;lEe*O8uIYGZ@x|HANhSM)u@Myf1bCc5=hTB zjSLuKMcTddc=s7}l~AxCCwk(Mct7b1aCCg~xm>R({+Cm{Y`LoxkXrpfEHK?{w%#=a z#EA{Lu;F3R4Dwdv7R6?Zb^1rlah((FV4#EAc8g0Q2FKWl!-Yc#S4qi>>%mBZOgF50fH!Eq}XzX3miBc5+~-HIQMyB znMo8_<9|Qa7l*tZmkR?q8D_L~7&tz50CBvAsF1%rAG#}FN=(J;KmJgWe(`L%)9;UO zQqw_IihmI}Z-0cPjP1%he!=O!GU0C(!QPwV$xN8a(>e?A3t!n+Jm;X@*&=yIr&6uQ zK1Eq}?qNOJ%vE3AxYgAA{Q@fJT#*E>H{vrb%tC?BDZ8rAqv8!xTiia(*Jo$4k0|Je_id7jiDcJE+whQjaOWsd{6} z$y82-jFB17X@9=Zs3=H@+pki$@Qr~s1o!NOvx%R-Ty_zh z@hnbtK~*M0>|N{>mj6`s^B5Lb5hk;w`B;AYZ{!TgGF4T`dF}rRMuyQMdeS`&S~8Z| zVycZ|vzVjfW?uuG7tt;i1=RW5msNr;lY5|q`EB_14LebV5%V)zKh_YXIdg+q2k!xa zk=8TEK8ga{d`fXI=9)6arYFqfTpVSAE`P4h)v84LJE#2iJJ+j{S>c`wg_j^eO~0No zG4eY%yn3jH6@A3CVqpNf5BtrQfAUoWQJ;p;HAtIP$H9SXZYQqbOp;K1I1TLI*1nQ` z9z+ZNmrS!Mcvcq`)NJ}8X*w;}JwAuaxNAn}pL{NKf8*AkLr#Kf=Pd^UNgzlaIHM*0 zAz+USwGtkDH2r-mbFM_IAJ%NNwdXBt(3RJ&DUK9{lxK*S?RTSSzqHo6erUN2TzTlm zWz5t%?RyMW)AlSD?B$QfTt31%Tt~DVMUFE@?pel4DVgp*+JvEKQJZ(`|7kf}4M)fy zW$$(sj(B{gbXE+M1%tg$zEHSI@^wy|+c?|~ET8nsF2AYTTIc_X3P820tT3!w^QfD5 zA`e4U=pKmDj_rlq*Z^QDEs*eyvMv7KPmr!e!fn5O+~x?1wjq=gm6_&>sk8eG7D&^rDS7E%*7f(D20a#b9s;PXEx}LgYs6)pD9o zgiuh5b&FE<+Eyk`VD$Vd|BtQHT>1=2KxNvmXJX1|x~I@~l-&cLrQe49z}M5u(!&va zr#fyOts1RjVh{`7pcEgsK2Nk!gohxV2I zf|}|#m;Fd>CMe!Kyc}l(tOD~N_pSsQBTWoPnWD7Vy%DJ^+c2GcPtjiC(~lmB<*Y&@ z3aIvHnz**Xl$*Mua%Lkj?3J5mkEPFm?@sg<_yLV$G)20mAnGr7yLPOS#+KvT8bk@N z(|TgYiB+!_$9*W*^K5vV9eQ&Wzhy03lCRr3S+r5)>&BwlzeZJug1eUP#%RQ;)&DSl zWf3>ssVt2gEtqrAByvz3e7n8HK0bp8)nDKEbFk@Ss!jxV5G7FPMX$F^Y z@Cp|)QaYxJD-!rbN~&+2;r`F!&bWnJ&(lBrt5%m+claV7F1v#wv>Fl`ZV$(L$4MZ;ZV#x+h6-p!i_=v>~f`btz}yWm0>PGq13G4~}$z^wd%3 z09N@#(FkUHgP8gIDP0SZ)yz7LVV^8bMJGXf*r5TM6p89>9#ZYL_zS9>M()R3XMdlcA^lI;bTRXIV?FxNL?AhHr}yB((# z={myE#z6nA5=Y$o@*{r}MKhirfb{<>Dm+{tWE(_8$Kq6t4d!69XZ1FUz#v+@l4RE< z@ib1)40?9ai-n1;ImQH7=F8wTYp)*}wwlW+ujZ$H5O=?6rt|t=fM-0tFsx1#T0hy` zt*Yy-)mvgy{PN}xXivqRtGPSv$F*)TT;#)r38tQ#p{9}GS#b6`SK5h9qCjnk0x9>& zkMBRxJuUJ@=BEINNYEsJDLXztFyuOTRj=m{tVnJNMN`kXMw>yZo$dn@x~ACv(H4UbG2uX@dzXS^d+7*R{!!VzF`14 za!4vJ{7n)j1Um-2V*46_0`uk{FhHdh^lE7q<+;VRC%)#MX4ac&OJ2jIi%o|a6;W6u z7%fCR|J1~M!*U%;rxFuW1rd690kTtHT|mo7M79ILT`g=i_rC+Q6_Wa#319d-?wl#KAv4<=aq% z0j@DrS~;>whqV;WjTwx|^;?X?%3IlslUw0}!}b1J>(Cwt)7`yZZ2!Fv2gtgQpTG#MP~Gb{6+W*p+u}Zm^d378q`SO`%xh{+h}ZTCd~;(*exA{QqIrGkPtH|Y zLB@Z9)>w$&%g2r=eEvQ`B?8-2t~dUzJITIUTP6`(p7lJ1zZs+pcfKHPUxx(j_$~bY zGbzl$s&p~R^42Q2zg?`YUa&x0dt6_v#ey5u2z*&QG#Vo)wPmbv8ku*woki2vhP5eM zjR$%&eYsd#D^h7$c1;BTKt2_&iF*^b==V;F>Z;!p|pp)YCqAtnNw8 zjpSXzHT`dOjB$XVvBYDcJa5B6{YSillkHr#=a>~Q1k+N&lm8C-VsNFmmfJ1CjdtC@ zw*}l-Ht$wic7$kKa^0EzUko zQIjwTZ_3*CFn_S-ASNIy$RrAe&`o`QM>7UpQmxN^K0fI7%b(2G;;VXiS1FmI$I zwah5vq_Ur7ND@PH#bTqBRQJM@g~LG#P;U4_@tPGCqz;)UG)-TYr^&uwp0B#3t{yYG zoK>je)ne|(>!^)ck@?cKcSo2^HL4bKB`2(T0c<8-=Zo{8K|C^L%H mc0D>uX(*9o4Y>X#TTV9YZ4vFcM-FHf!iGg#nUooMkpBn&mOMoO literal 0 HcmV?d00001 diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 4dba7fd..a33a606 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -3,9 +3,13 @@ NSCameraUsageDescription - 用于拍摄账单照片进行OCR识别 + 用于拍摄账单照片进行OCR识别,以及水印相机拍照 NSPhotoLibraryUsageDescription - 用于选择账单截图进行OCR识别 + 用于选择账单截图进行OCR识别,以及水印相机选图 + NSPhotoLibraryAddUsageDescription + 用于把加好水印的照片保存到相册 + NSLocationWhenInUseUsageDescription + 用于在水印上显示拍摄地点 CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion diff --git a/lib/api/geo_api.dart b/lib/api/geo_api.dart new file mode 100644 index 0000000..02d1225 --- /dev/null +++ b/lib/api/geo_api.dart @@ -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((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 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 reverseAddress({ + required double latitude, + required double longitude, + }) async { + final Response res; + try { + res = await _dio.get( + _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( + _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 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 _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: '强雷阵雨伴大冰雹', +}; diff --git a/lib/api/photo_api.dart b/lib/api/photo_api.dart new file mode 100644 index 0000000..3ff6309 --- /dev/null +++ b/lib/api/photo_api.dart @@ -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((ref) => PhotoApi(ref.watch(dioProvider))); + +/// 照片存档接口。 +/// +/// App 拍完照把**原图**传上来;水印是本地合成的,和这里无关。 +/// 上传是「锦上添花」—— 失败了不该影响用户继续编辑和保存照片, +/// 所以调用方要自己吞掉异常并给个「重试」入口。 +class PhotoApi { + PhotoApi(this._dio); + + final Dio _dio; + + /// 上传一张照片,返回后端记录 id + Future 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( + '/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)}'; + } +} diff --git a/lib/core/module/app_module.dart b/lib/core/module/app_module.dart index e2dc4f4..37d717c 100644 --- a/lib/core/module/app_module.dart +++ b/lib/core/module/app_module.dart @@ -4,6 +4,7 @@ import '../../pages/bill/bill_home_page.dart'; import '../../pages/bill/widgets/bill_module_summary.dart'; import '../../pages/note/note_list_page.dart'; import '../../pages/note/widgets/note_module_summary.dart'; +import '../../pages/watermark/watermark_camera_page.dart'; /// 一个功能模块要对外暴露的东西:它是谁、怎么打开自己。 /// @@ -65,4 +66,11 @@ final List appModules = [ open: (_) => const NoteListPage(), summary: (_) => const NoteModuleSummary(), ), + AppModule( + id: 'watermark', + name: '水印相机', + blurb: '拍照自动加时间地点水印', + icon: 'camera', + open: (_) => const WatermarkCameraPage(), + ), ]; diff --git a/lib/core/storage/local_storage.dart b/lib/core/storage/local_storage.dart index cf254a5..90cce02 100644 --- a/lib/core/storage/local_storage.dart +++ b/lib/core/storage/local_storage.dart @@ -70,4 +70,24 @@ class LocalStorage { if (username == null || password == null) return null; return (username: username, password: password); } + + // ---- 水印设置 ---- + // 存的是 WatermarkSettings.toJson() 的结果。读取失败/没存过都返回 null, + // 由调用方回落到默认设置(见 models/watermark.dart 的 WatermarkSettings.defaults)。 + static const _keyWatermarkSettings = 'watermark_settings'; + + Future saveWatermarkSettings(Map json) async { + final prefs = await _getPrefs(); + await prefs.setString(_keyWatermarkSettings, jsonEncode(json)); + } + + Map? getWatermarkSettings() { + final raw = _prefs?.getString(_keyWatermarkSettings); + if (raw == null) return null; + try { + return jsonDecode(raw) as Map; + } catch (_) { + return null; + } + } } diff --git a/lib/models/watermark.dart b/lib/models/watermark.dart new file mode 100644 index 0000000..2b0bc07 --- /dev/null +++ b/lib/models/watermark.dart @@ -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 toJson() => { + 'showTime': showTime, + 'showAddress': showAddress, + 'showWeather': showWeather, + 'showRemark': showRemark, + 'showBrand': showBrand, + 'colorValue': colorValue, + 'posX': posX, + 'posY': posY, + }; + + factory WatermarkSettings.fromJson(Map json) { + return WatermarkSettings( + showTime: _asBool(json['showTime'], true), + showAddress: _asBool(json['showAddress'], true), + showWeather: _asBool(json['showWeather'], false), + showRemark: _asBool(json['showRemark'], false), + showBrand: _asBool(json['showBrand'], true), + colorValue: _asInt(json['colorValue'], 0xFFFFFFFF), + posX: _asDoubleOrNull(json['posX']), + posY: _asDoubleOrNull(json['posY']), + ); + } + + static bool _asBool(Object? v, bool fallback) => v is bool ? v : fallback; + + static int _asInt(Object? v, int fallback) { + if (v is int) return v; + if (v is num) return v.toInt(); + return fallback; + } + + static double? _asDoubleOrNull(Object? v) { + if (v is num) return v.toDouble(); + return null; + } +} + +/// 这一张照片要显示的内容 +class WatermarkData { + const WatermarkData({ + required this.time, + this.address = '', + this.weather = '', + this.temperature = '', + this.remark = '', + }); + + /// 拍摄时间(水印上只用到「分」) + final DateTime time; + + final String address; + + /// 天气描述,如「晴」 + final String weather; + + /// 温度,如「25℃」 + final String temperature; + + final String remark; + + /// 天气行是否可显示(天气和温度都拿不到时不显示这一行) + bool get hasWeather => weather.isNotEmpty || temperature.isNotEmpty; + + WatermarkData copyWith({ + DateTime? time, + String? address, + String? weather, + String? temperature, + String? remark, + }) { + return WatermarkData( + time: time ?? this.time, + address: address ?? this.address, + weather: weather ?? this.weather, + temperature: temperature ?? this.temperature, + remark: remark ?? this.remark, + ); + } +} + +/// 时间格式化成水印上的样子:`2026.09.28 14:30` +/// +/// 纯函数,方便单测。用两位补零,月/日/时/分都对齐。 +String formatWatermarkTime(DateTime time) { + String two(int n) => n.toString().padLeft(2, '0'); + return '${time.year}.${two(time.month)}.${two(time.day)} ' + '${two(time.hour)}:${two(time.minute)}'; +} + +/// 水印正文的一行 +class WatermarkLine { + const WatermarkLine(this.text, {this.wrap = true}); + + final String text; + + /// 是否允许折行。 + /// + /// 时间行**不折**:它是定长字符串(「时间:2026.09.28 14:30」正好 19 个字), + /// 按字数硬折会把最后一个数字甩到单独一行。 + /// 这不是猜的 —— 是在导出预览图上真看到了一行只有一个「0」才发现的。 + final bool wrap; +} + +/// 把设置和数据拼成水印正文的每一行 +/// +/// 顺序固定为 时间 → 天气 → 地点 → 备注(和原项目一致)。 +/// 关掉的项目直接不产生行;天气/备注没内容时也不占一行。 +List buildWatermarkLines(WatermarkSettings settings, WatermarkData data) { + final lines = []; + + if (settings.showTime) { + lines.add(WatermarkLine( + '时间:${formatWatermarkTime(data.time)}', + wrap: false, + )); + } + + if (settings.showWeather && data.hasWeather) { + lines.add(WatermarkLine('天气:${'${data.weather} ${data.temperature}'.trim()}')); + } + + if (settings.showAddress) { + // 地址拿不到时给个占位,而不是留个「地点:」的空壳 + final address = data.address.isEmpty ? '未知地点' : data.address; + lines.add(WatermarkLine('地点:$address')); + } + + if (settings.showRemark && data.remark.trim().isNotEmpty) { + lines.add(WatermarkLine('备注:${data.remark.trim()}')); + } + + return lines; +} diff --git a/lib/pages/watermark/watermark_camera_page.dart b/lib/pages/watermark/watermark_camera_page.dart new file mode 100644 index 0000000..7e88720 --- /dev/null +++ b/lib/pages/watermark/watermark_camera_page.dart @@ -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 = [ + 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 _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); + } + } + } +} diff --git a/lib/pages/watermark/watermark_edit_page.dart b/lib/pages/watermark/watermark_edit_page.dart new file mode 100644 index 0000000..d9aefed --- /dev/null +++ b/lib/pages/watermark/watermark_edit_page.dart @@ -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 createState() => _WatermarkEditPageState(); +} + +enum _UploadStatus { waiting, uploading, done, failed } + +class _WatermarkEditPageState extends ConsumerState { + 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 _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 _locateThenUpload() async { + await _locate(); + await _uploadOriginal(); + } + + Future _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 _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 _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 _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)), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/watermark/watermark_painter.dart b/lib/pages/watermark/watermark_painter.dart new file mode 100644 index 0000000..47046da --- /dev/null +++ b/lib/pages/watermark/watermark_painter.dart @@ -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 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 = [ + 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 _wrapByCharCount(String text, int maxChars) { + if (text.length <= maxChars) return [text]; + final result = []; + 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 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); +} diff --git a/lib/pages/watermark/widgets/watermark_content_sheet.dart b/lib/pages/watermark/widgets/watermark_content_sheet.dart new file mode 100644 index 0000000..40bef02 --- /dev/null +++ b/lib/pages/watermark/widgets/watermark_content_sheet.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; + +import '../../../models/watermark.dart'; + +/// 编辑这一张照片的水印内容(地址 / 天气 / 温度 / 备注) +/// +/// 返回 null 表示用户取消了。时间不在这里改 —— 它总是「现在」, +/// 让用户手改反而容易把水印弄成假的时间。 +Future showWatermarkContentSheet( + BuildContext context, { + required WatermarkData initial, +}) { + return showModalBottomSheet( + 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)), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/watermark/widgets/watermark_settings_sheet.dart b/lib/pages/watermark/widgets/watermark_settings_sheet.dart new file mode 100644 index 0000000..f4beaf6 --- /dev/null +++ b/lib/pages/watermark/widgets/watermark_settings_sheet.dart @@ -0,0 +1,221 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/watermark_provider.dart'; + +/// 水印设置(开关 + 文字颜色) +/// +/// 用底部弹层而不是独立页面:编辑页里改完能立刻在预览上看到效果, +/// 入口页也能从同一个地方进。 +Future showWatermarkSettingsSheet(BuildContext context) { + return showModalBottomSheet( + 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 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; + } +} diff --git a/lib/providers/watermark_provider.dart b/lib/providers/watermark_provider.dart new file mode 100644 index 0000000..9d420c7 --- /dev/null +++ b/lib/providers/watermark_provider.dart @@ -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 { + @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.new, +); + +/// 品牌角标用的相机图标(原「水印相机」项目那张,白色描边 + 透明底)。 +/// +/// 画布要的是 `ui.Image`,不是 widget 用的 `AssetImage`,所以这里自己解码一次。 +/// 不加 autoDispose:就一张小图,加载一次全程复用。 +final brandIconProvider = FutureProvider((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; +}); diff --git a/pubspec.lock b/pubspec.lock index 5ba21bf..070722c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -97,6 +97,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted 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: dependency: "direct main" description: @@ -137,6 +145,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted 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: dependency: transitive description: @@ -256,6 +272,86 @@ packages: description: flutter source: sdk 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: dependency: transitive description: @@ -273,7 +369,7 @@ packages: source: hosted version: "4.1.2" image: - dependency: transitive + dependency: "direct main" description: name: image sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89 @@ -440,6 +536,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted 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: dependency: transitive description: @@ -701,6 +813,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c9353d8..4a5a6e8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,9 @@ dependencies: fl_chart: ^1.2.0 flutter_markdown_plus: ^1.0.12 flutter_svg: ^2.3.0 + geolocator: ^14.1.1 + gal: ^2.3.3 + image: ^4.10.1 dev_dependencies: flutter_test: diff --git a/test/watermark_layout_test.dart b/test/watermark_layout_test.dart new file mode 100644 index 0000000..20aa2e2 --- /dev/null +++ b/test/watermark_layout_test.dart @@ -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 resolve() async => const GeoSnapshot( + latitude: 25.041234, + longitude: 102.712345, + address: '云南省昆明市五华区莲花池正街某小区某栋某单元', + weather: '晴', + temperature: '25℃', + ); +} + +/// 定位失败:页面仍应可用,只是少了地点那一行 +class _FailingGeoApi extends GeoApi { + @override + Future resolve() async => const GeoSnapshot(error: '没有定位权限'); +} + +/// 假上传:立刻成功,避免测试里真的打网络 +class _FakePhotoApi extends PhotoApi { + _FakePhotoApi() : super(Dio()); + + @override + Future 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 settleIo(WidgetTester tester) async { + await tester.runAsync(() async { + await Future.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); + }); +} diff --git a/test/watermark_preview_test.dart b/test/watermark_preview_test.dart new file mode 100644 index 0000000..eb57f94 --- /dev/null +++ b/test/watermark_preview_test.dart @@ -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}'); + }); +} diff --git a/test/watermark_test.dart b/test/watermark_test.dart new file mode 100644 index 0000000..c8bb217 --- /dev/null +++ b/test/watermark_test.dart @@ -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 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); + }); + }); +}