diff --git a/lib/api/geo_api.dart b/lib/api/geo_api.dart index 02d1225..ca7c866 100644 --- a/lib/api/geo_api.dart +++ b/lib/api/geo_api.dart @@ -14,8 +14,21 @@ import '../core/network/api_exception.dart'; /// 硬编码在客户端)。风险只有一个:AK 被刷爆后逆地理会失效,水印仍能正常合成。 /// 百度地图开放平台 AK(服务端类型,需勾选逆地理编码权限) +/// +/// ⚠️ 有每日配额,用完当天就不可用 —— 所以下面还有 [nominatimUrl] 兜底。 const String _baiduAk = 'I3IYR0glCNfmfYzMoBDr7lLTKdZ8Y0Xs'; const String _baiduReverseUrl = 'https://api.map.baidu.com/reverse_geocoding/v3/'; + +/// OpenStreetMap Nominatim:**完全免费、不需要 key、没有配额**。 +/// +/// 用它兜底是因为百度的免费配额会耗尽(用户实际撞上了)。 +/// 代价两点,都写在明面上: +/// 1. 它是海外服务,国内访问偶尔会慢(实测约 1 秒) +/// 2. 使用政策要求「每秒不超过 1 次请求」+ 必须带 User-Agent。 +/// 我们只在百度失败时才走它,量很小,符合政策。 +const String _nominatimUrl = 'https://nominatim.openstreetmap.org/reverse'; +const String _nominatimUserAgent = 'QiangbaoAssistant/1.0 (personal use)'; + const String _openMeteoUrl = 'https://api.open-meteo.com/v1/forecast'; final geoApiProvider = Provider((ref) => GeoApi()); @@ -29,6 +42,7 @@ class GeoSnapshot { this.weather = '', this.temperature = '', this.error, + this.addressError, }); final double? latitude; @@ -40,6 +54,12 @@ class GeoSnapshot { /// 定位失败时的原因(地址/天气会留空,页面据此提示用户) final String? error; + /// 定位成功了、但地址没解析出来时的原因。 + /// + /// 和 [error] 分开是为了不混淆:定位成功但地址失败时,坐标还在, + /// 用户该看到「有位置、但地址没解析出来 + 为什么」,而不是一句笼统的失败。 + final String? addressError; + bool get hasPosition => latitude != null && longitude != null; } @@ -47,8 +67,10 @@ class GeoApi { GeoApi({Dio? dio}) : _dio = dio ?? Dio(BaseOptions( - connectTimeout: const Duration(seconds: 8), - receiveTimeout: const Duration(seconds: 10), + // 比默认宽松一点:移动网络下 TLS 握手 + DNS 偶尔要好几秒, + // 国内直连百度和海外 Nominatim 的耗时也不一样 + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 15), )); final Dio _dio; @@ -86,10 +108,31 @@ class GeoApi { } /// 经纬度 → 文字地址。 + /// + /// 走「**百度优先、失败降级 Nominatim**」:百度国内数据最准(有街道门牌), + /// 但它有每日配额;配额用完时自动换 OSM,至少还能拿到街道级地址。 + /// 用户实际撞上过配额耗尽 —— 那时候不降级就只剩一串坐标了。 Future reverseAddress({ required double latitude, required double longitude, }) async { + String? baiduError; + try { + return await _baiduReverseAddress(latitude, longitude); + } on ApiException catch (e) { + baiduError = e.message; + } + + try { + return await _nominatimReverseAddress(latitude, longitude); + } catch (_) { + // 两个都不行。把百度的原因带出来 —— 那多半是「配额用完」,用户看得懂 + throw ApiException(-1, baiduError); + } + } + + /// 百度逆地理(主用) + Future _baiduReverseAddress(double latitude, double longitude) async { final Response res; try { res = await _dio.get( @@ -105,21 +148,22 @@ class GeoApi { }, ); } on DioException catch (e) { - throw ApiException(-1, '地址解析失败:${e.message ?? '网络异常'}'); + throw ApiException(-1, '百度地址解析失败:${e.message ?? '网络异常'}'); } final data = res.data; if (data is! Map) { - throw ApiException(-1, '地址解析失败:返回格式异常'); + throw ApiException(-1, '百度地址解析失败:返回格式异常'); } final status = data['status']; if (status != 0) { - throw ApiException(-1, '地址解析失败:${data['msg'] ?? '百度返回 $status'}'); + final msg = data['msg']?.toString() ?? ''; + throw ApiException(-1, '百度地址解析失败:${msg.isEmpty ? '返回 $status' : msg}'); } final result = data['result']; if (result is! Map) { - throw ApiException(-1, '地址解析失败:没有结果'); + throw ApiException(-1, '百度地址解析失败:没有结果'); } final formatted = result['formatted_address']?.toString() ?? ''; @@ -132,6 +176,71 @@ class GeoApi { '${ac['street'] ?? ''}${ac['street_number'] ?? ''}'; if (joined.isNotEmpty) return joined; } + throw ApiException(-1, '百度地址解析失败:结果为空'); + } + + /// OSM Nominatim 逆地理(兜底,免费且没有配额) + Future _nominatimReverseAddress(double latitude, double longitude) async { + final Response res; + try { + res = await _dio.get( + _nominatimUrl, + queryParameters: { + 'format': 'json', + 'lat': latitude, + 'lon': longitude, + 'accept-language': 'zh-CN', + 'zoom': 18, // 建筑/门牌级,尽量贴近百度的粒度 + }, + // Nominatim 的使用政策要求带 User-Agent,不带会被拒 + options: Options(headers: {'User-Agent': _nominatimUserAgent}), + ); + } on DioException catch (e) { + throw ApiException(-1, '地址解析失败:${e.message ?? '网络异常'}'); + } + + final data = res.data; + if (data is! Map) { + throw ApiException(-1, '地址解析失败:返回格式异常'); + } + + // Nominatim 的 address 各字段是「小 → 大」,拼成中文习惯的「大 → 小」: + // 云南省 + 昆明市 + 五华区 + 护国街道 + 庆云街 + final address = data['address']; + if (address is Map) { + final parts = []; + for (final key in const [ + 'state', + 'city', + 'district', + 'county', + 'suburb', + 'city_district', + 'road', + 'pedestrian', + 'house_number', + ]) { + final value = address[key]?.toString().trim() ?? ''; + // 去重:有些地方 city 和 district 会填同一个值 + if (value.isEmpty || parts.contains(value)) continue; + parts.add(value); + } + if (parts.isNotEmpty) return parts.join(); + } + + // 再退一步:display_name 是逗号分隔的「小 → 大」,反过来拼,顺手滤掉邮编和国家 + final display = data['display_name']?.toString() ?? ''; + if (display.isNotEmpty) { + final segments = display + .split(',') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty && s != '中国') + .where((s) => int.tryParse(s) == null) + .toList() + .reversed; + if (segments.isNotEmpty) return segments.join(); + } + throw ApiException(-1, '地址解析失败:结果为空'); } @@ -181,16 +290,21 @@ class GeoApi { return GeoSnapshot(error: e.message); } - // 地址拿不到就退回「纬度, 经度」,至少信息量不为零(原项目也这么做) + // 地址拿不到就退回「纬度, 经度」,至少信息量不为零(原项目也这么做)。 + // 同时把**原因**记下来给页面显示 —— 只给一串坐标、不说为什么, + // 上次就是这样让人以为是功能坏了。 var address = '${position.latitude.toStringAsFixed(4)}, ' '${position.longitude.toStringAsFixed(4)}'; + String? addressError; try { address = await reverseAddress( latitude: position.latitude, longitude: position.longitude, ); + } on ApiException catch (e) { + addressError = e.message; } catch (_) { - // 保持坐标兜底 + addressError = '地址解析失败'; } final weather = await this.weather( @@ -205,6 +319,7 @@ class GeoApi { address: address, weather: weather.weather, temperature: weather.temperature, + addressError: addressError, ); } diff --git a/lib/api/photo_api.dart b/lib/api/photo_api.dart index 3ff6309..ce6f0b1 100644 --- a/lib/api/photo_api.dart +++ b/lib/api/photo_api.dart @@ -1,8 +1,11 @@ import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; +import '../core/network/api_env.dart'; import '../core/network/api_exception.dart'; import '../core/network/dio_client.dart'; +import '../models/photo.dart'; final photoApiProvider = Provider((ref) => PhotoApi(ref.watch(dioProvider))); @@ -59,6 +62,51 @@ class PhotoApi { } } + /// 分页列表,最新的在前 + Future list({int page = 1, int size = 30}) async { + try { + final res = await _dio.get( + '/photos', + queryParameters: {'page': page, 'size': size}, + ); + final data = res.data; + if (data is Map) return PhotoPage.fromJson(data); + if (data is Map) return PhotoPage.fromJson(Map.from(data)); + return PhotoPage.empty; + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '照片列表加载失败'); + } + } + + /// 把原图下载到临时文件,返回本地路径。 + /// + /// 「重新打水印」必须先有本地文件:编辑页要读它拿尺寸、显示预览、合成成品。 + /// 原图可能好几 MB,所以留了进度回调让界面能显示进度。 + /// + /// [url] 是后端给的相对地址(`/uploads/...`),这里用 [resolveAssetUrl] + /// 补成绝对地址 —— `/uploads` 不在 `/api` 前缀下,不能直接丢给 dio。 + Future downloadOriginal( + String url, { + void Function(int received, int total)? onProgress, + }) async { + try { + final dir = await getTemporaryDirectory(); + final path = '${dir.path}/photo_${DateTime.now().millisecondsSinceEpoch}.jpg'; + await _dio.download( + resolveAssetUrl(url), + path, + onReceiveProgress: onProgress, + // 原图几 MB,移动网络下给宽一点 + options: Options(receiveTimeout: const Duration(seconds: 120)), + ); + return path; + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '原图下载失败'); + } + } + /// 从路径里取个带扩展名的文件名,后端靠它判断文件类型 static String _fileName(String path) { final normalized = path.replaceAll('\\', '/'); diff --git a/lib/app.dart b/lib/app.dart index cf70aeb..054d089 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'core/navigation/navigator_key.dart'; @@ -15,6 +16,15 @@ class QiangbaoApp extends ConsumerWidget { return MaterialApp( title: '强宝小助手', navigatorKey: navigatorKey, + // 让系统组件说中文。项目里已经有 3 处 showDatePicker(记账页、账单编辑、 + // 月份切换),之前没配这个 —— 弹出来的日历一直是英文的。 + // 水印的「编辑时间」也用日期/时间选择器,正好一起受益。 + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('zh', 'CN'), Locale('en')], theme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: const Color(0xFFC8956E), diff --git a/lib/core/module/app_module.dart b/lib/core/module/app_module.dart index 37d717c..b9a6c48 100644 --- a/lib/core/module/app_module.dart +++ b/lib/core/module/app_module.dart @@ -5,6 +5,7 @@ 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'; +import '../../pages/watermark/widgets/watermark_module_summary.dart'; /// 一个功能模块要对外暴露的东西:它是谁、怎么打开自己。 /// @@ -72,5 +73,6 @@ final List appModules = [ blurb: '拍照自动加时间地点水印', icon: 'camera', open: (_) => const WatermarkCameraPage(), + summary: (_) => const WatermarkModuleSummary(), ), ]; diff --git a/lib/core/storage/local_storage.dart b/lib/core/storage/local_storage.dart index 90cce02..4c6342e 100644 --- a/lib/core/storage/local_storage.dart +++ b/lib/core/storage/local_storage.dart @@ -90,4 +90,21 @@ class LocalStorage { return null; } } + + // ---- 水印常用地址 ---- + // 编辑水印内容时用的快捷地址,一键替换「地点」那一行。 + // 存成字符串列表就够了,不需要 JSON。 + static const _keyWatermarkAddresses = 'watermark_quick_addresses'; + + /// 最多存几条 —— 太多会把弹层撑得很难看 + static const int watermarkAddressLimit = 8; + + Future saveWatermarkAddresses(List addresses) async { + final prefs = await _getPrefs(); + await prefs.setStringList(_keyWatermarkAddresses, addresses); + } + + List getWatermarkAddresses() { + return _prefs?.getStringList(_keyWatermarkAddresses) ?? const []; + } } diff --git a/lib/models/json_util.dart b/lib/models/json_util.dart index 3fb7f81..a9a8c9f 100644 --- a/lib/models/json_util.dart +++ b/lib/models/json_util.dart @@ -30,6 +30,17 @@ double asDouble(Object? value, {double fallback = 0}) { return fallback; } +/// 取可空小数:null / 无法解析都返回 null +/// +/// 用于照片的经纬度这类"没有值"本身有含义的字段 +/// (定位没拿到就没有,不能退化成 0 —— 0 是几内亚湾) +double? asNullableDouble(Object? value) { + if (value == null) return null; + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value); + return null; +} + String asString(Object? value, {String fallback = ''}) { if (value == null) return fallback; return value.toString(); diff --git a/lib/models/photo.dart b/lib/models/photo.dart new file mode 100644 index 0000000..bf63c89 --- /dev/null +++ b/lib/models/photo.dart @@ -0,0 +1,124 @@ +import 'json_util.dart'; +import 'watermark.dart'; + +/// 一张已上传的照片。 +/// +/// ⚠️ [url] 指向的是**未加水印的原图** —— 服务器只存原图,水印一直是 +/// App 本地合成的。所以「重新打水印」(换颜色、换位置、补备注)随时可行。 +class PhotoItem { + const PhotoItem({ + required this.id, + required this.url, + required this.thumbUrl, + required this.takenAt, + required this.address, + required this.weather, + required this.remark, + required this.latitude, + required this.longitude, + }); + + final int id; + + /// 原图地址(相对路径,如 `/uploads/photo/2026/09/28/xxx.jpg`) + final String url; + + /// 缩略图地址。为 null 表示后端没生成出来,客户端只能回退用原图。 + final String? thumbUrl; + + /// 拍摄时间,已格式化;后端两个时间字段取到哪个用哪个 + final String takenAt; + + final String address; + + /// 「晴 25℃」这种拼好的天气文本 + final String weather; + + final String remark; + + final double? latitude; + final double? longitude; + + factory PhotoItem.fromJson(Map json) { + // 拍摄时间是客户端传的,可能为空(格式没对上时后端会存 null), + // 那就退回记录创建时间 —— 至少能显示个日期 + final taken = formatDateTime(json['takenTime']); + final created = formatDateTime(json['createTime']); + + return PhotoItem( + id: asInt(json['id']), + url: asString(json['url']), + thumbUrl: asString(json['thumbUrl']).isEmpty + ? null + : asString(json['thumbUrl']), + takenAt: taken.isNotEmpty ? taken : created, + address: asString(json['address']), + weather: asString(json['weather']), + remark: asString(json['remark']), + latitude: asNullableDouble(json['latitude']), + longitude: asNullableDouble(json['longitude']), + ); + } + + /// 还原成水印数据,「重新打水印」时用。 + /// + /// 关键点是**复用这张照片当时的地址和天气**,而不是拿当前位置 —— + /// 不然给三年前的照片重新加个水印,地点会变成「你现在站的地方」。 + WatermarkData toWatermarkData() { + final split = splitWeatherText(weather); + return WatermarkData( + time: parsePhotoTime(takenAt), + address: address, + weather: split.weather, + temperature: split.temperature, + remark: remark, + ); + } +} + +/// 把后端存的 `yyyy-MM-dd HH:mm` 解析回 DateTime;解析不了就用当前时间 +DateTime parsePhotoTime(String value) { + final normalized = value.trim().replaceFirst(' ', 'T'); + return DateTime.tryParse(normalized) ?? DateTime.now(); +} + +/// 拆开后端存的天气文本(「晴 25℃」→ weather=晴, temperature=25℃)。 +/// +/// 存的时候是一个字段(上传时客户端就是这么拼的),所以这里拆回来。 +/// 两种情况都要兜住:只有天气没有温度(「晴」)、只有温度没有天气(「25℃」)。 +({String weather, String temperature}) splitWeatherText(String combined) { + final text = combined.trim(); + if (text.isEmpty) return (weather: '', temperature: ''); + + final parts = text.split(' '); + if (parts.last.endsWith('℃')) { + return ( + weather: parts.sublist(0, parts.length - 1).join(' '), + temperature: parts.last, + ); + } + return (weather: text, temperature: ''); +} + +/// 照片分页 +class PhotoPage { + const PhotoPage({ + required this.items, + required this.total, + required this.hasMore, + }); + + final List items; + final int total; + final bool hasMore; + + static const empty = PhotoPage(items: [], total: 0, hasMore: false); + + factory PhotoPage.fromJson(Map json) { + return PhotoPage( + items: asMapList(json['list']).map(PhotoItem.fromJson).toList(), + total: asInt(json['total']), + hasMore: json['hasMore'] == true, + ); + } +} diff --git a/lib/pages/add/add_page.dart b/lib/pages/add/add_page.dart index a17c93d..b84be73 100644 --- a/lib/pages/add/add_page.dart +++ b/lib/pages/add/add_page.dart @@ -2,9 +2,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'widgets/manual_entry_tab.dart'; -import 'widgets/ocr_tab.dart'; /// 记账页内部 Tab:0=手动记账 1=OCR识别 +/// +/// ⚠️ **OCR 已从前端隐藏**(2026-09-28,用户不再需要)。这个 provider 先留着, +/// 因为 `widgets/ocr_result_card.dart` 还在引用它 —— OCR 相关的那几个文件 +/// (`ocr_tab.dart` / `ocr_result_card.dart` / `ocr_provider.dart` / +/// `ocr_api.dart` / `ocr_result.dart`)都保留着,没有删。 +/// +/// 要恢复 OCR:把下面 body 换回「Tab 栏 + `tabIndex == 0 ? ManualEntryTab : OcrTab`」 +/// 就行,后端 `/api/ocr/**` 也一直没停。 class AddTabNotifier extends Notifier { @override int build() => 0; @@ -14,13 +21,12 @@ class AddTabNotifier extends Notifier { final addTabProvider = NotifierProvider(AddTabNotifier.new); -/// 记账页:手动记账 / OCR识别 双 Tab(复刻 uniApp pages/add/add.vue) +/// 记账页(原本是「手动记账 / OCR识别」双 Tab,现在只剩手动记账) class AddPage extends ConsumerWidget { const AddPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final tabIndex = ref.watch(addTabProvider); return Scaffold( appBar: AppBar(title: const Text('添加账单')), body: Container( @@ -31,63 +37,8 @@ class AddPage extends ConsumerWidget { colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)], ), ), - child: Column( - children: [ - const SizedBox(height: 12), - _buildTabs(tabIndex, ref), - Expanded( - child: tabIndex == 0 ? const ManualEntryTab() : const OcrTab(), - ), - ], - ), - ), - ); - } - - Widget _buildTabs(int tabIndex, WidgetRef ref) { - void setTab(int index) => ref.read(addTabProvider.notifier).set(index); - return Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFFC8956E)), - ), - child: Row( - children: [ - _tab('手动记账', 0, tabIndex, setTab), - _tab('OCR识别', 1, tabIndex, setTab), - ], - ), - ); - } - - Widget _tab(String text, int index, int tabIndex, void Function(int) setTab) { - final active = tabIndex == index; - return Expanded( - child: GestureDetector( - onTap: () => setTab(index), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - gradient: active - ? const LinearGradient(colors: [Color(0xFFC8956E), Color(0xFFF5D59E)]) - : null, - ), - child: Center( - child: Text( - text, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: active ? const Color(0xFF5D4037) : const Color(0xFF8D6E63), - ), - ), - ), - ), + // ManualEntryTab 自带 ListView,直接铺满就行 + child: const ManualEntryTab(), ), ); } diff --git a/lib/pages/bill/bill_home_page.dart b/lib/pages/bill/bill_home_page.dart index 3b9f2f3..d271a9f 100644 --- a/lib/pages/bill/bill_home_page.dart +++ b/lib/pages/bill/bill_home_page.dart @@ -212,7 +212,7 @@ class BillHomePage extends ConsumerWidget { if (bills.isEmpty) { return const EmptyView( title: '本月暂无账单记录', - tip: '点击底部「记录」添加,或用 OCR 识别小票', + tip: '点击底部「记录」添加', ); } return Column( diff --git a/lib/pages/watermark/watermark_camera_page.dart b/lib/pages/watermark/watermark_camera_page.dart index 7e88720..a248d71 100644 --- a/lib/pages/watermark/watermark_camera_page.dart +++ b/lib/pages/watermark/watermark_camera_page.dart @@ -2,21 +2,179 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; +import '../../../api/photo_api.dart'; +import '../../../core/network/api_env.dart'; +import '../../../core/network/api_exception.dart'; +import '../../../models/photo.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 { +class WatermarkCameraPage extends ConsumerStatefulWidget { const WatermarkCameraPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _WatermarkCameraPageState(); +} + +class _WatermarkCameraPageState extends ConsumerState { + static const _pageSize = 21; // 3 列,正好 7 行 + + final List _photos = []; + int _total = 0; + + bool _loading = true; + bool _loadingMore = false; + bool _hasMore = false; + int _page = 0; + String? _error; + + /// 正在下载哪一张的原图(下载完才进编辑页) + int? _openingId; + double? _openProgress; + + @override + void initState() { + super.initState(); + _loadFirstPage(); + } + + // ---------------- 列表 ---------------- + + Future _loadFirstPage() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final page = await ref.read(photoApiProvider).list(page: 1, size: _pageSize); + if (!mounted) return; + setState(() { + _photos + ..clear() + ..addAll(page.items); + _total = page.total; + _page = 1; + _hasMore = page.hasMore; + _loading = false; + }); + } on ApiException catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _error = e.message; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _loading = false; + _error = '照片加载失败'; + }); + } + } + + Future _loadMore() async { + if (_loadingMore || !_hasMore) return; + setState(() => _loadingMore = true); + try { + final next = _page + 1; + final page = await ref.read(photoApiProvider).list(page: next, size: _pageSize); + if (!mounted) return; + setState(() { + // 按 id 去重:翻页期间如果有新照片传上来,可能两页里都有 + final existing = _photos.map((p) => p.id).toSet(); + _photos.addAll(page.items.where((p) => !existing.contains(p.id))); + _total = page.total; + _page = next; + _hasMore = page.hasMore; + _loadingMore = false; + }); + } catch (_) { + if (!mounted) return; + setState(() => _loadingMore = false); + } + } + + // ---------------- 选照片 ---------------- + + /// 选图。**不传 imageQuality / maxWidth** —— 要的就是原图, + /// 编辑页会在合成时按最长边 1920 缩放,原始文件则原样备份到服务器。 + Future _pick(ImageSource source) async { + try { + final file = await ImagePicker().pickImage(source: source); + if (file == null || !mounted) return; + + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => WatermarkEditPage(imagePath: file.path)), + ); + // 回来刷新一下:刚才那张已经传上去了,列表该多一张 + if (mounted) await _loadFirstPage(); + } catch (e) { + if (mounted) showToast(context, '打开相机/相册失败:$e', error: true); + } + } + + /// 点一张老照片 → 下载原图 → 进编辑页重新打水印 + Future _openPhoto(PhotoItem photo) async { + if (_openingId != null) return; + setState(() { + _openingId = photo.id; + _openProgress = null; + }); + + try { + final path = await ref.read(photoApiProvider).downloadOriginal( + photo.url, + onProgress: (received, total) { + if (!mounted || total <= 0) return; + setState(() => _openProgress = received / total); + }, + ); + if (!mounted) return; + setState(() { + _openingId = null; + _openProgress = null; + }); + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => WatermarkEditPage( + imagePath: path, + // 服务器上已经有这张原图了,别再传一遍 + uploadOriginal: false, + // 用拍摄当时的地点天气,而不是你现在站的地方 + initialData: photo.toWatermarkData(), + ), + ), + ); + } on ApiException catch (e) { + if (!mounted) return; + setState(() { + _openingId = null; + _openProgress = null; + }); + showToast(context, e.message, error: true); + } catch (_) { + if (!mounted) return; + setState(() { + _openingId = null; + _openProgress = null; + }); + showToast(context, '原图下载失败,请重试', error: true); + } + } + + // ---------------- 构建 ---------------- + + @override + Widget build(BuildContext context) { final settings = ref.watch(watermarkSettingsProvider); return Scaffold( @@ -30,26 +188,33 @@ class WatermarkCameraPage extends ConsumerWidget { ), ], ), - body: ListView( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + body: Stack( children: [ - _introCard(), - const SizedBox(height: 20), - _captureButton( - icon: Icons.camera_alt, - label: '拍照', - primary: true, - onTap: () => _pick(context, ImageSource.camera), + 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(ImageSource.camera), + ), + const SizedBox(height: 12), + _captureButton( + icon: Icons.photo_library_outlined, + label: '从相册选', + primary: false, + onTap: () => _pick(ImageSource.gallery), + ), + const SizedBox(height: 24), + _settingsCard(context, settings), + const SizedBox(height: 24), + _recentSection(), + ], ), - 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), + if (_openingId != null) _downloadOverlay(), ], ), ); @@ -57,7 +222,7 @@ class WatermarkCameraPage extends ConsumerWidget { Widget _introCard() { return Container( - padding: const EdgeInsets.fromLTRB(18, 18, 18, 18), + padding: const EdgeInsets.all(18), decoration: BoxDecoration( gradient: const LinearGradient( begin: Alignment.topLeft, @@ -94,22 +259,18 @@ class WatermarkCameraPage extends ConsumerWidget { 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, + 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: 0, + ), icon: Icon(icon, size: 20), label: Text( label, @@ -192,20 +353,198 @@ class WatermarkCameraPage extends ConsumerWidget { ); } - /// 选图。**不传 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)), + /// 已上传照片的网格 —— 点一张就能重新打水印(换颜色/位置/补备注) + Widget _recentSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + '最近拍摄', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + const Spacer(), + if (_total > 0) + Text( + '共 $_total 张', + style: const TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)), + ), + ], + ), + const SizedBox(height: 4), + const Text( + '点一张可以重新加水印(原图没带水印,随便改)', + style: TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)), + ), + const SizedBox(height: 12), + _recentBody(), + ], + ); + } + + Widget _recentBody() { + if (_loading) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 36), + child: Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), ); - } catch (e) { - if (context.mounted) { - showToast(context, '打开相机/相册失败:$e', error: true); - } } + + if (_error != null) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 22, horizontal: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + children: [ + Text( + _error!, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 12.5, color: Color(0xFFE53935)), + ), + const SizedBox(height: 10), + TextButton( + onPressed: _loadFirstPage, + child: const Text( + '重试', + style: TextStyle(fontSize: 13, color: Color(0xFFC8956E)), + ), + ), + ], + ), + ); + } + + if (_photos.isEmpty) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + ), + child: const Text( + '还没拍过。拍一张试试,原图会自动存到服务器', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12.5, color: Color(0xFFBCAAA4)), + ), + ); + } + + return Column( + children: [ + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: _photos.length, + itemBuilder: (context, i) => _photoTile(_photos[i]), + ), + if (_hasMore) + Padding( + padding: const EdgeInsets.only(top: 14), + child: TextButton( + onPressed: _loadingMore ? null : _loadMore, + child: _loadingMore + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text( + '加载更多', + style: TextStyle(fontSize: 13, color: Color(0xFFC8956E)), + ), + ), + ), + ], + ); + } + + Widget _photoTile(PhotoItem photo) { + // 缩略图没生成出来时退回原图(慢,但总比空着好) + final url = resolveAssetUrl(photo.thumbUrl ?? photo.url); + + return GestureDetector( + onTap: () => _openPhoto(photo), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Container( + color: const Color(0xFFF0EAE4), + child: Image.network( + url, + fit: BoxFit.cover, + // 缩略图本来就只有 400 宽,这里再限一次解码尺寸,省内存 + cacheWidth: 400, + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return const Center( + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + }, + errorBuilder: (context, _, _) => const Center( + child: Icon(Icons.broken_image_outlined, size: 20, color: Color(0xFFBCAAA4)), + ), + ), + ), + ), + ); + } + + /// 下载原图时的遮罩。原图好几 MB,不提示的话用户会以为卡死了 + Widget _downloadOverlay() { + final percent = _openProgress == null ? null : (_openProgress! * 100).round(); + + return Positioned.fill( + child: ColoredBox( + color: const Color(0x99000000), + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 22), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 26, + height: 26, + child: CircularProgressIndicator(strokeWidth: 2.5), + ), + const SizedBox(height: 14), + Text( + percent == null ? '正在下载原图…' : '正在下载原图… $percent%', + style: const TextStyle(fontSize: 13, color: Color(0xFF5D4037)), + ), + ], + ), + ), + ), + ), + ); } } diff --git a/lib/pages/watermark/watermark_edit_page.dart b/lib/pages/watermark/watermark_edit_page.dart index d9aefed..9f30013 100644 --- a/lib/pages/watermark/watermark_edit_page.dart +++ b/lib/pages/watermark/watermark_edit_page.dart @@ -1,10 +1,13 @@ import 'dart:io'; import 'dart:math' as math; +import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:gal/gal.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; import '../../../api/geo_api.dart'; import '../../../api/photo_api.dart'; @@ -21,10 +24,25 @@ import 'widgets/watermark_settings_sheet.dart'; /// /// 水印是**本地合成**的 —— 服务器那份只是原图备份。 class WatermarkEditPage extends ConsumerStatefulWidget { - const WatermarkEditPage({super.key, required this.imagePath}); + const WatermarkEditPage({ + super.key, + required this.imagePath, + this.uploadOriginal = true, + this.initialData, + }); final String imagePath; + /// 是否把原图上传存档。 + /// + /// 新拍的要传;从「最近拍摄」里挑出来的老照片**不能传** —— + /// 服务器上本来就有那一张,再传一遍就是重复文件。 + final bool uploadOriginal; + + /// 已有的水印内容。传了就不自动定位 —— + /// 老照片要用**拍摄当时**的地点,而不是你现在站的地方。 + final WatermarkData? initialData; + @override ConsumerState createState() => _WatermarkEditPageState(); } @@ -40,6 +58,9 @@ class _WatermarkEditPageState extends ConsumerState { bool _locating = false; String? _locateError; + /// 定位成功但地址没解析出来时的原因(配额用完、两个服务都连不上…) + String? _addressError; + /// 定位到的坐标(上传原图时一并存到服务器) double? _latitude; double? _longitude; @@ -47,14 +68,26 @@ class _WatermarkEditPageState extends ConsumerState { _UploadStatus _upload = _UploadStatus.waiting; String? _uploadError; bool _saving = false; + bool _sharing = false; @override void initState() { super.initState(); - _data = WatermarkData(time: DateTime.now()); + _data = widget.initialData ?? WatermarkData(time: DateTime.now()); _loadImageSize(); - // 先定位、再上传 —— 这样服务器那份记录能带上地址和天气 - _locateThenUpload(); + _bootstrap(); + } + + /// 启动流程分两种: + /// · 新拍的:先定位(拿地址/天气)、再把原图上传存档 + /// · 从「最近拍摄」挑的老照片:地址天气从服务器记录里还原,既不用定位 + /// 也不用重传 —— 那张原图服务器上本来就有 + Future _bootstrap() async { + if (widget.initialData != null || !widget.uploadOriginal) { + return; + } + await _locate(); + await _uploadOriginal(); } // ---------------- 图片尺寸 ---------------- @@ -79,11 +112,6 @@ class _WatermarkEditPageState extends ConsumerState { // ---------------- 定位 ---------------- - Future _locateThenUpload() async { - await _locate(); - await _uploadOriginal(); - } - Future _locate() async { if (!mounted) return; setState(() { @@ -97,6 +125,7 @@ class _WatermarkEditPageState extends ConsumerState { setState(() { _locating = false; _locateError = snapshot.error; + _addressError = snapshot.addressError; _latitude = snapshot.latitude; _longitude = snapshot.longitude; _data = _data.copyWith( @@ -148,23 +177,25 @@ class _WatermarkEditPageState extends ConsumerState { return joined; } - // ---------------- 保存 ---------------- + // ---------------- 合成 / 保存 / 分享 ---------------- + + /// 合成一次带水印的 JPEG。 + /// 保存和分享共用这一份 —— 两边都要同一张图,逻辑不该写两遍。 + Future _composeJpeg() async { + final bytes = await File(widget.imagePath).readAsBytes(); + return composeWatermarkedJpeg( + originalBytes: bytes, + settings: ref.read(watermarkSettingsProvider), + data: _data, + brandIcon: ref.read(brandIconProvider).value, + ); + } 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, - ); - + final jpeg = await _composeJpeg(); // gal 的约定:name 不带扩展名 await Gal.putImageBytes(jpeg, name: 'qiangbao_${_timestamp()}'); @@ -173,7 +204,7 @@ class _WatermarkEditPageState extends ConsumerState { } on GalException catch (e) { if (!mounted) return; showToast(context, e.type.message, error: true); - } catch (e) { + } catch (_) { if (!mounted) return; showToast(context, '保存失败,请重试', error: true); } finally { @@ -181,6 +212,31 @@ class _WatermarkEditPageState extends ConsumerState { } } + /// 调系统分享面板(Android 走 ACTION_SEND,会列出微信/QQ/邮件等) + Future _share() async { + if (_sharing) return; + setState(() => _sharing = true); + try { + final jpeg = await _composeJpeg(); + + // 先落一个临时文件再分享。 + // 不用 XFile.fromData:cross_file 在 Android 上会忽略它的 name, + // 对方收到会是个随机文件名。 + final dir = await getTemporaryDirectory(); + final file = File('${dir.path}/qiangbao_${_timestamp()}.jpg'); + await file.writeAsBytes(jpeg, flush: true); + + await SharePlus.instance.share( + ShareParams(files: [XFile(file.path, mimeType: 'image/jpeg')]), + ); + } catch (_) { + if (!mounted) return; + showToast(context, '分享失败,请重试', error: true); + } finally { + if (mounted) setState(() => _sharing = false); + } + } + String _timestamp() { final t = DateTime.now(); String two(int n) => n.toString().padLeft(2, '0'); @@ -320,6 +376,27 @@ class _WatermarkEditPageState extends ConsumerState { /// 原图上传状态条 Widget _uploadBar() { + // 老照片本来就在服务器上,没有「上传」这回事,别显示「准备上传原图」误导人 + if (!widget.uploadOriginal) { + return Container( + width: double.infinity, + color: const Color(0xFFF7F4F1), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: const Row( + children: [ + Icon(Icons.circle, size: 8, color: Color(0xFF19BE6B)), + SizedBox(width: 8), + Expanded( + child: Text( + '原图已在服务器上', + style: TextStyle(fontSize: 12, color: Color(0xFF19BE6B)), + ), + ), + ], + ), + ); + } + final (String text, Color color) = switch (_upload) { _UploadStatus.waiting => ('准备上传原图', const Color(0xFFBCAAA4)), _UploadStatus.uploading => ('正在上传原图…', const Color(0xFF8D6E63)), @@ -365,16 +442,13 @@ class _WatermarkEditPageState extends ConsumerState { /// 定位/地址条 Widget _locationBar() { - final String text; - if (_locating) { - text = '正在定位…'; - } else if (_locateError != null) { - text = _locateError!; - } else if (_data.address.isEmpty) { - text = '未获取到位置'; - } else { - text = _data.address; - } + final (String text, Color color) = _locating + ? ('正在定位…', const Color(0xFF8D6E63)) + : _locateError != null + ? (_locateError!, const Color(0xFFE53935)) + : _data.address.isEmpty + ? ('未获取到位置', const Color(0xFFE53935)) + : (_data.address, const Color(0xFF5D4037)); return Container( width: double.infinity, @@ -385,17 +459,33 @@ class _WatermarkEditPageState extends ConsumerState { 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), - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + text, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 13, height: 1.4, color: color), + ), + // 定位成功但地址没解析出来时,把原因摆出来 —— + // 只给一串坐标、不说为什么,上次就让人以为是功能坏了 + if (_addressError != null && _locateError == null) + Padding( + padding: const EdgeInsets.only(top: 3), + child: Text( + '地址没解析出来:$_addressError\n可点下面「编辑内容」手动填', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 11, + height: 1.35, + color: Color(0xFFE53935), + ), + ), + ), + ], ), ), TextButton( @@ -434,6 +524,12 @@ class _WatermarkEditPageState extends ConsumerState { label: '水印设置', onTap: () => showWatermarkSettingsSheet(context), ), + _toolButton( + icon: Icons.share_outlined, + label: '分享', + busy: _sharing, + onTap: _share, + ), ], ), ), @@ -450,17 +546,24 @@ class _WatermarkEditPageState extends ConsumerState { required IconData icon, required String label, required VoidCallback onTap, + bool busy = false, }) { return Expanded( child: InkWell( - onTap: onTap, + onTap: busy ? null : 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)), + SizedBox( + height: 21, + width: 21, + child: busy + ? const CircularProgressIndicator(strokeWidth: 2) + : Icon(icon, size: 21, color: const Color(0xFF8D6E63)), + ), const SizedBox(height: 4), Text( label, diff --git a/lib/pages/watermark/widgets/watermark_content_sheet.dart b/lib/pages/watermark/widgets/watermark_content_sheet.dart index 40bef02..16b216b 100644 --- a/lib/pages/watermark/widgets/watermark_content_sheet.dart +++ b/lib/pages/watermark/widgets/watermark_content_sheet.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; +import '../../../core/storage/local_storage.dart'; import '../../../models/watermark.dart'; -/// 编辑这一张照片的水印内容(地址 / 天气 / 温度 / 备注) +/// 编辑这一张照片的水印内容(时间 / 地址 / 天气 / 温度 / 备注) /// -/// 返回 null 表示用户取消了。时间不在这里改 —— 它总是「现在」, -/// 让用户手改反而容易把水印弄成假的时间。 +/// 返回 null 表示用户取消了。 Future showWatermarkContentSheet( BuildContext context, { required WatermarkData initial, @@ -36,9 +36,18 @@ class _WatermarkContentSheetState extends State<_WatermarkContentSheet> { late final TextEditingController _temperature; late final TextEditingController _remark; + /// 时间可以改 —— 补加水印、或者当时手机时间不对时要靠它 + late DateTime _time; + + /// 常用地址,从 LocalStorage 读(同步可读) + late List _addresses; + bool _managing = false; + @override void initState() { super.initState(); + _time = widget.initial.time; + _addresses = List.from(LocalStorage.instance.getWatermarkAddresses()); _address = TextEditingController(text: widget.initial.address); _weather = TextEditingController(text: widget.initial.weather); _temperature = TextEditingController(text: widget.initial.temperature); @@ -57,6 +66,7 @@ class _WatermarkContentSheetState extends State<_WatermarkContentSheet> { void _submit() { Navigator.of(context).pop( widget.initial.copyWith( + time: _time, address: _address.text.trim(), weather: _weather.text.trim(), temperature: _temperature.text.trim(), @@ -65,6 +75,88 @@ class _WatermarkContentSheetState extends State<_WatermarkContentSheet> { ); } + // ---------------- 时间 ---------------- + + Future _pickTime() async { + final date = await showDatePicker( + context: context, + initialDate: _time, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (date == null || !mounted) return; + + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(_time), + ); + if (time == null || !mounted) return; + + setState(() { + _time = DateTime(date.year, date.month, date.day, time.hour, time.minute); + }); + } + + // ---------------- 常用地址 ---------------- + + Future _addAddress() async { + if (_addresses.length >= LocalStorage.watermarkAddressLimit) return; + // 预填当前地址:常见的用法是「改一改然后存成常用」,省得再敲一遍 + final text = await _promptAddress(initial: _address.text.trim()); + if (text == null || !mounted) return; + + final next = [..._addresses, text]; + await LocalStorage.instance.saveWatermarkAddresses(next); + if (!mounted) return; + setState(() => _addresses = next); + } + + Future _removeAddress(int index) async { + final next = [..._addresses]..removeAt(index); + await LocalStorage.instance.saveWatermarkAddresses(next); + if (!mounted) return; + setState(() { + _addresses = next; + if (_addresses.isEmpty) _managing = false; + }); + } + + Future _promptAddress({String initial = ''}) { + final controller = TextEditingController(text: initial); + return showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('添加常用地址', style: TextStyle(fontSize: 16)), + content: TextField( + controller: controller, + autofocus: true, + maxLines: 2, + minLines: 1, + decoration: const InputDecoration( + hintText: '例如:云南省昆明市五华区…', + hintStyle: TextStyle(fontSize: 13, color: Color(0xFFD7CCC8)), + ), + style: const TextStyle(fontSize: 14), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('取消', style: TextStyle(color: Color(0xFF8D6E63))), + ), + TextButton( + onPressed: () { + final text = controller.text.trim(); + Navigator.of(dialogContext).pop(text.isEmpty ? null : text); + }, + child: const Text('添加', style: TextStyle(color: Color(0xFFC8956E))), + ), + ], + ), + ).whenComplete(controller.dispose); + } + + // ---------------- 构建 ---------------- + @override Widget build(BuildContext context) { return Padding( @@ -89,12 +181,17 @@ class _WatermarkContentSheetState extends State<_WatermarkContentSheet> { ), const SizedBox(height: 18), + _timeRow(), + const SizedBox(height: 16), + _quickAddressSection(), + const SizedBox(height: 16), + _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), + const SizedBox(height: 12), Row( children: [ Expanded( @@ -139,6 +236,150 @@ class _WatermarkContentSheetState extends State<_WatermarkContentSheet> { ); } + /// 时间行:点一下弹日期 + 时间选择器 + Widget _timeRow() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('时间', style: TextStyle(fontSize: 13, color: Color(0xFF8D6E63))), + const SizedBox(height: 6), + InkWell( + onTap: _pickTime, + borderRadius: BorderRadius.circular(10), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + decoration: BoxDecoration( + color: const Color(0xFFF7F4F1), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Expanded( + child: Text( + formatWatermarkTime(_time), + style: const TextStyle(fontSize: 14, color: Color(0xFF5D4037)), + ), + ), + const Icon(Icons.event, size: 17, color: Color(0xFFBCAAA4)), + ], + ), + ), + ), + ], + ); + } + + Widget _quickAddressSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + '常用地址', + style: TextStyle(fontSize: 13, color: Color(0xFF8D6E63)), + ), + const Spacer(), + if (_addresses.isNotEmpty) + GestureDetector( + onTap: () => setState(() => _managing = !_managing), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Text( + _managing ? '完成' : '管理', + style: const TextStyle(fontSize: 12.5, color: Color(0xFFC8956E)), + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (var i = 0; i < _addresses.length; i++) + _addressChip(i, _addresses[i]), + if (!_managing && _addresses.length < LocalStorage.watermarkAddressLimit) + _addChip(), + ], + ), + if (_addresses.isEmpty && !_managing) + const Padding( + padding: EdgeInsets.only(top: 2), + child: Text( + '把常去的地点存下来,以后一键替换', + style: TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)), + ), + ), + ], + ); + } + + Widget _addressChip(int index, String address) { + return GestureDetector( + // 管理模式下点整体是删除,正常模式点一下填进地址框 + onTap: _managing + ? () => _removeAddress(index) + : () => setState(() => _address.text = address), + child: Container( + constraints: const BoxConstraints(maxWidth: 210), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: _managing ? const Color(0xFFFDECEA) : const Color(0xFFFFF3E6), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + address, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.5, + color: _managing + ? const Color(0xFFE53935) + : const Color(0xFF8D6E63), + ), + ), + ), + if (_managing) ...[ + const SizedBox(width: 6), + const Icon(Icons.close, size: 14, color: Color(0xFFE53935)), + ], + ], + ), + ), + ); + } + + Widget _addChip() { + return GestureDetector( + onTap: _addAddress, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE0C9A6)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add, size: 14, color: Color(0xFFC8956E)), + SizedBox(width: 4), + Text( + '添加', + style: TextStyle(fontSize: 12.5, color: Color(0xFFC8956E)), + ), + ], + ), + ), + ); + } + Widget _field({ required String label, required TextEditingController controller, diff --git a/lib/pages/watermark/widgets/watermark_module_summary.dart b/lib/pages/watermark/widgets/watermark_module_summary.dart new file mode 100644 index 0000000..ff9dcdd --- /dev/null +++ b/lib/pages/watermark/widgets/watermark_module_summary.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/watermark_provider.dart'; + +/// 首页仪表盘上「水印相机」卡片的摘要:已上传张数 + 最近一张的日期。 +/// +/// 卡片高度由 [AppModule.summary] 那段固定高度占位决定(见 dashboard_page), +/// 所以这里只负责填内容,布局和「笔记」卡片保持一致 —— +/// 每格都是「标签 + 加粗数值」两行,少一个卡片就会比别的矮一截。 +/// +/// 数据来自 [photoOverviewProvider](只取 1 条),不碰列表页那套状态。 +class WatermarkModuleSummary extends ConsumerWidget { + const WatermarkModuleSummary({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final page = ref.watch(photoOverviewProvider).value; + final latest = (page == null || page.items.isEmpty) + ? null + : page.items.first.takenAt; + + return Row( + children: [ + Expanded(child: _item('已存', page == null ? null : '${page.total} 张')), + Expanded(child: _item('最近拍摄', latest == null ? null : _shortDate(latest))), + ], + ); + } + + /// 后端给的是 `yyyy-MM-dd HH:mm`,卡片上只要 `MM-dd` + String _shortDate(String value) => + value.length >= 10 ? value.substring(5, 10) : value; + + Widget _item(String label, String? value) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontSize: 11, color: Color(0xFF8D6E63))), + const SizedBox(height: 3), + Text( + value ?? '--', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14.5, + fontWeight: FontWeight.bold, + color: Color(0xFF5D4037), + ), + ), + ], + ); + } +} diff --git a/lib/providers/watermark_provider.dart b/lib/providers/watermark_provider.dart index 9d420c7..6930ad7 100644 --- a/lib/providers/watermark_provider.dart +++ b/lib/providers/watermark_provider.dart @@ -3,7 +3,9 @@ import 'dart:ui' as ui; import 'package:flutter/services.dart' show rootBundle; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../api/photo_api.dart'; import '../core/storage/local_storage.dart'; +import '../models/photo.dart'; import '../models/watermark.dart'; /// 水印设置(全局共享,改动即落盘) @@ -64,3 +66,11 @@ final brandIconProvider = FutureProvider((ref) async { codec.dispose(); return frame.image; }); + +/// 首页水印卡片用:只想知道「一共几张、最近一张什么时候」。 +/// +/// 和 `noteOverviewProvider` 一个思路 —— 只取 1 条拿 total, +/// 不为了一个数字把列表页那套分页状态挂到首页上。 +final photoOverviewProvider = FutureProvider.autoDispose((ref) { + return ref.watch(photoApiProvider).list(page: 1, size: 1); +}); diff --git a/pubspec.lock b/pubspec.lock index 070722c..1fdc742 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -65,6 +65,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.1.3" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "828110d598123b5ea96c00c9f3c72105bf79f8ee36c20a39b26209ade421ec57" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.0" collection: dependency: transitive description: @@ -230,6 +238,11 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_markdown_plus: dependency: "direct main" description: @@ -352,6 +365,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.2.8" + hooks: + dependency: transitive + description: + name: hooks + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" http: dependency: transitive description: @@ -448,6 +469,30 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.20.3" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.3" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" json_annotation: dependency: transitive description: @@ -496,6 +541,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" markdown: dependency: transitive description: @@ -536,6 +589,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "2f9a00fff06cc27846da6e8bcda5e64c41a1ea656dcc4dc355ac717f8a272413" + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.6.1" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" package_info_plus: dependency: transitive description: @@ -568,6 +637,30 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.1.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -624,6 +717,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.1" + record_use: + dependency: transitive + description: + name: record_use + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" riverpod: dependency: transitive description: @@ -632,6 +741,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.4.3" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "13.3.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.2.0" shared_preferences: dependency: "direct main" description: @@ -757,6 +882,38 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.4.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.3" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.6" uuid: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4a5a6e8..8d8eff1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,10 @@ dependencies: geolocator: ^14.1.1 gal: ^2.3.3 image: ^4.10.1 + share_plus: ^13.3.0 + path_provider: ^2.1.6 + flutter_localizations: + sdk: flutter dev_dependencies: flutter_test: diff --git a/test/geo_api_test.dart b/test/geo_api_test.dart new file mode 100644 index 0000000..d335a6b --- /dev/null +++ b/test/geo_api_test.dart @@ -0,0 +1,159 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:qiangbao_accounting/api/geo_api.dart'; +import 'package:qiangbao_accounting/core/network/api_exception.dart'; + +/// 用一个假的 HTTP 适配器接管请求,按 URL 分发不同的响应。 +/// 这样不用真的联网,也能把「百度失败 → Nominatim 兜底」整条链路跑通。 +class _FakeAdapter implements HttpClientAdapter { + _FakeAdapter(this.handler); + + final Future Function(RequestOptions options) handler; + + /// 记录每次请求,用来断言请求头和参数 + final List requests = []; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) { + requests.add(options); + return handler(options); + } + + @override + void close({bool force = false}) {} +} + +ResponseBody _json(String body) => ResponseBody.fromString( + body, + 200, + headers: { + Headers.contentTypeHeader: ['application/json'], + }, + ); + +bool _isBaidu(RequestOptions o) => o.uri.host.contains('baidu'); +bool _isNominatim(RequestOptions o) => o.uri.host.contains('nominatim'); + +void main() { + GeoApi apiWith(_FakeAdapter adapter) => GeoApi(dio: Dio()..httpClientAdapter = adapter); + + group('地址解析:百度优先、Nominatim 兜底', () { + test('百度正常时用百度的结果(国内数据最准)', () async { + final adapter = _FakeAdapter((o) async { + if (_isBaidu(o)) { + return _json( + '{"status":0,"result":{"formatted_address":"云南省昆明市五华区护国街道南屏街112"}}', + ); + } + fail('百度成功了就不该再去打 Nominatim'); + }); + + final address = await apiWith(adapter) + .reverseAddress(latitude: 25.041234, longitude: 102.712345); + + expect(address, '云南省昆明市五华区护国街道南屏街112'); + expect(adapter.requests.length, 1); + }); + + test('★ 百度配额用完时自动降级到 Nominatim(用户实际撞到的情况)', () async { + final adapter = _FakeAdapter((o) async { + if (_isBaidu(o)) { + return _json('{"status":4,"msg":"AK 配额校验失败"}'); + } + return _json( + '{"display_name":"庆云街, 护国街道, 五华区, 昆明市, 云南省, 650011, 中国",' + '"address":{"road":"庆云街","suburb":"护国街道","district":"五华区",' + '"city":"昆明市","state":"云南省","postcode":"650011","country":"中国"}}', + ); + }); + + final address = await apiWith(adapter) + .reverseAddress(latitude: 25.041234, longitude: 102.712345); + + // 拼成中文习惯的「大 → 小」,而不是 Nominatim 原生的「小 → 大」 + expect(address, '云南省昆明市五华区护国街道庆云街'); + expect(adapter.requests.length, 2); + }); + + test('百度网络异常也会降级(不只是配额问题)', () async { + final adapter = _FakeAdapter((o) async { + if (_isBaidu(o)) { + throw DioException(requestOptions: o, message: 'connection timeout'); + } + return _json('{"address":{"state":"云南省","city":"昆明市","district":"五华区"}}'); + }); + + final address = await apiWith(adapter) + .reverseAddress(latitude: 25.0, longitude: 102.7); + + expect(address, '云南省昆明市五华区'); + }); + + test('两个都挂时,把百度的原因带出来(而不是一句笼统的失败)', () async { + final adapter = _FakeAdapter((o) async { + if (_isBaidu(o)) return _json('{"status":4,"msg":"AK 配额校验失败"}'); + throw DioException(requestOptions: o, message: 'timeout'); + }); + + expect( + () => apiWith(adapter).reverseAddress(latitude: 25.0, longitude: 102.7), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('配额'), + ), + ), + ); + }); + + test('Nominatim 只给 display_name 时也能拼出地址,且滤掉邮编和国家', () async { + final adapter = _FakeAdapter((o) async { + if (_isBaidu(o)) return _json('{"status":1,"msg":"失败"}'); + return _json( + '{"display_name":"庆云街, 护国街道, 五华区, 昆明市, 云南省, 650011, 中国"}', + ); + }); + + final address = await apiWith(adapter) + .reverseAddress(latitude: 25.0, longitude: 102.7); + + expect(address, '云南省昆明市五华区护国街道庆云街'); + }); + + test('Nominatim 请求必须带 User-Agent(它的使用政策要求,不带会被拒)', () async { + final adapter = _FakeAdapter((o) async { + if (_isBaidu(o)) return _json('{"status":1,"msg":"失败"}'); + return _json('{"address":{"state":"云南省"}}'); + }); + + await apiWith(adapter).reverseAddress(latitude: 25.0, longitude: 102.7); + + final nominatim = adapter.requests.firstWhere(_isNominatim); + expect(nominatim.headers['User-Agent'], isNotNull); + expect(nominatim.headers['User-Agent'], contains('Qiangbao')); + }); + + test('百度请求带 coordtype=wgs84ll(geolocator 给的是 WGS84)', () async { + final adapter = _FakeAdapter((o) async { + if (_isBaidu(o)) { + return _json('{"status":0,"result":{"formatted_address":"昆明"}}'); + } + return _json('{}'); + }); + + await apiWith(adapter).reverseAddress(latitude: 25.0, longitude: 102.7); + + final baidu = adapter.requests.firstWhere(_isBaidu); + expect(baidu.uri.queryParameters['coordtype'], 'wgs84ll'); + expect(baidu.uri.queryParameters['location'], '25.0,102.7'); + }); + }); +} diff --git a/test/watermark_layout_test.dart b/test/watermark_layout_test.dart index 20aa2e2..481a1a9 100644 --- a/test/watermark_layout_test.dart +++ b/test/watermark_layout_test.dart @@ -5,9 +5,12 @@ 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:shared_preferences/shared_preferences.dart'; import 'package:qiangbao_accounting/api/geo_api.dart'; import 'package:qiangbao_accounting/api/photo_api.dart'; +import 'package:qiangbao_accounting/core/storage/local_storage.dart'; +import 'package:qiangbao_accounting/models/photo.dart'; import 'package:qiangbao_accounting/pages/watermark/watermark_camera_page.dart'; import 'package:qiangbao_accounting/pages/watermark/watermark_edit_page.dart'; @@ -41,9 +44,12 @@ class _FailingGeoApi extends GeoApi { Future resolve() async => const GeoSnapshot(error: '没有定位权限'); } -/// 假上传:立刻成功,避免测试里真的打网络 +/// 假照片接口:不真打网络。默认返回空列表 —— 入口页会在 initState 里拉列表, +/// 不给它替身的话测试会真的去连 localhost,每个用例白等好几秒。 class _FakePhotoApi extends PhotoApi { - _FakePhotoApi() : super(Dio()); + _FakePhotoApi({this.photos = const []}) : super(Dio()); + + final List photos; @override Future upload({ @@ -56,6 +62,17 @@ class _FakePhotoApi extends PhotoApi { DateTime? takenTime, }) async => 1; + + @override + Future list({int page = 1, int size = 30}) async => + PhotoPage(items: photos, total: photos.length, hasMore: false); + + @override + Future downloadOriginal( + String url, { + void Function(int received, int total)? onProgress, + }) async => + ''; } void main() { @@ -93,11 +110,22 @@ void main() { await tester.pump(); } + /// 弹出带输入框的弹层后不能用 pumpAndSettle: + /// TextField 的光标闪烁是无限动画,永远等不到静止。手动推两帧就够。 + Future openSheet(WidgetTester tester, String buttonText) async { + await tester.tap(find.text(buttonText)); + await tester.pump(); // 开始入场动画 + await tester.pump(const Duration(milliseconds: 400)); // 动画走完 + } + for (final (label, size) in sizes) { testWidgets('入口页 $label 不溢出', (tester) async { useSize(tester, size); await tester.pumpWidget( - const ProviderScope(child: MaterialApp(home: WatermarkCameraPage())), + ProviderScope( + overrides: [photoApiProvider.overrideWithValue(_FakePhotoApi())], + child: const MaterialApp(home: WatermarkCameraPage()), + ), ); await tester.pumpAndSettle(); @@ -133,7 +161,10 @@ void main() { testWidgets('设置弹层在窄屏也不溢出', (tester) async { useSize(tester, const Size(320, 568)); await tester.pumpWidget( - const ProviderScope(child: MaterialApp(home: WatermarkCameraPage())), + ProviderScope( + overrides: [photoApiProvider.overrideWithValue(_FakePhotoApi())], + child: const MaterialApp(home: WatermarkCameraPage()), + ), ); await tester.pumpAndSettle(); @@ -146,6 +177,108 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('已上传的照片在入口页以网格显示', (tester) async { + useSize(tester, const Size(393, 873)); + + const photos = [ + PhotoItem( + id: 1, + url: '/uploads/photo/a.jpg', + thumbUrl: '/uploads/photo/a.thumb.jpg', + takenAt: '2026-09-28 14:30', + address: '昆明市五华区', + weather: '晴 25℃', + remark: '', + latitude: 25.0, + longitude: 102.7, + ), + PhotoItem( + id: 2, + url: '/uploads/photo/b.jpg', + // 缩略图没生成出来的情况:应该退回用原图,而不是空一块 + thumbUrl: null, + takenAt: '2026-09-27 09:00', + address: '', + weather: '', + remark: '', + latitude: null, + longitude: null, + ), + ]; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + photoApiProvider.overrideWithValue(_FakePhotoApi(photos: photos)), + ], + child: const MaterialApp(home: WatermarkCameraPage()), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('最近拍摄'), findsOneWidget); + expect(find.text('共 2 张'), findsOneWidget); + expect(find.byType(GridView), findsOneWidget); + // 测试环境加载不到网络图,会落到 errorBuilder 的占位图标;数量仍然对 + expect(find.byType(Image), findsNWidgets(2)); + expect(tester.takeException(), isNull); + }); + + testWidgets('编辑内容弹层在窄屏也不溢出(时间 + 常用地址 + 四个输入框)', (tester) async { + useSize(tester, const Size(320, 568)); + 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); + await openSheet(tester, '编辑内容'); + + expect(find.text('时间'), findsOneWidget); + expect(find.text('常用地址'), findsOneWidget); + expect(find.text('备注'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + // 放在最后:它会初始化 LocalStorage 单例(单例的 _prefs 只取第一次) + testWidgets('常用地址:点一下填进地址框', (tester) async { + useSize(tester, const Size(393, 873)); + + SharedPreferences.setMockInitialValues({ + 'watermark_quick_addresses': ['昆明市公安局', '昆明南站'], + }); + await LocalStorage.instance.init(); + + 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); + await openSheet(tester, '编辑内容'); + + expect(find.text('昆明市公安局'), findsOneWidget); + + await tester.tap(find.text('昆明市公安局')); + await tester.pump(); + + // 弹层里第一个输入框是「地址」,点快捷地址应该把它填上 + final addressField = tester.widget(find.byType(TextField).at(0)); + expect(addressField.controller?.text, '昆明市公安局'); + expect(tester.takeException(), isNull); + }); + testWidgets('定位失败时编辑页仍可用(只是少一行地点)', (tester) async { useSize(tester, const Size(393, 873)); final photo = makePhoto(tester); diff --git a/test/watermark_test.dart b/test/watermark_test.dart index c8bb217..6f16c44 100644 --- a/test/watermark_test.dart +++ b/test/watermark_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:image/image.dart' as img; +import 'package:qiangbao_accounting/models/photo.dart'; import 'package:qiangbao_accounting/models/watermark.dart'; import 'package:qiangbao_accounting/pages/watermark/watermark_painter.dart'; @@ -211,6 +212,57 @@ void main() { }); }); + group('照片记录还原成水印数据', () { + test('天气和温度能从拼在一起的那个字段拆回来', () { + expect(splitWeatherText('晴 25℃'), (weather: '晴', temperature: '25℃')); + expect(splitWeatherText('多云 18℃'), (weather: '多云', temperature: '18℃')); + }); + + test('只有天气 / 只有温度 / 空,三种都要兜住', () { + expect(splitWeatherText('晴'), (weather: '晴', temperature: '')); + expect(splitWeatherText('25℃'), (weather: '', temperature: '25℃')); + expect(splitWeatherText(''), (weather: '', temperature: '')); + expect(splitWeatherText(' '), (weather: '', temperature: '')); + }); + + test('★ 重新打水印时复用当时的地点和天气(不是你现在站的地方)', () { + const photo = PhotoItem( + id: 7, + url: '/uploads/photo/a.jpg', + thumbUrl: null, + takenAt: '2026-09-28 14:30', + address: '云南省昆明市五华区', + weather: '晴 25℃', + remark: '和宝一起', + latitude: 25.0, + longitude: 102.7, + ); + + final data = photo.toWatermarkData(); + + expect(data.address, '云南省昆明市五华区'); + expect(data.weather, '晴'); + expect(data.temperature, '25℃'); + expect(data.remark, '和宝一起'); + expect(data.time, DateTime(2026, 9, 28, 14, 30)); + }); + + test('拍摄时间解析不出来时退回当前时间,不崩', () { + const photo = PhotoItem( + id: 1, + url: '', + thumbUrl: null, + takenAt: '', + address: '', + weather: '', + remark: '', + latitude: null, + longitude: null, + ); + expect(photo.toWatermarkData().time, isNotNull); + }); + }); + group('绘制', () { test('paintWatermark 在真实画布上不抛异常', () { final recorder = ui.PictureRecorder();