水印优化+ORC优化

This commit is contained in:
2026-09-28 11:39:19 +08:00
parent c5b3d81d07
commit 6176c49964
19 changed files with 1701 additions and 171 deletions
+123 -8
View File
@@ -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<GeoApi>((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<String> 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<String> _baiduReverseAddress(double latitude, double longitude) async {
final Response<dynamic> res;
try {
res = await _dio.get<dynamic>(
@@ -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<String> _nominatimReverseAddress(double latitude, double longitude) async {
final Response<dynamic> res;
try {
res = await _dio.get<dynamic>(
_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 = <String>[];
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,
);
}
+48
View File
@@ -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<PhotoApi>((ref) => PhotoApi(ref.watch(dioProvider)));
@@ -59,6 +62,51 @@ class PhotoApi {
}
}
/// 分页列表,最新的在前
Future<PhotoPage> list({int page = 1, int size = 30}) async {
try {
final res = await _dio.get<dynamic>(
'/photos',
queryParameters: {'page': page, 'size': size},
);
final data = res.data;
if (data is Map<String, dynamic>) return PhotoPage.fromJson(data);
if (data is Map) return PhotoPage.fromJson(Map<String, dynamic>.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<String> 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('\\', '/');
+10
View File
@@ -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),
+2
View File
@@ -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<AppModule> appModules = [
blurb: '拍照自动加时间地点水印',
icon: 'camera',
open: (_) => const WatermarkCameraPage(),
summary: (_) => const WatermarkModuleSummary(),
),
];
+17
View File
@@ -90,4 +90,21 @@ class LocalStorage {
return null;
}
}
// ---- 水印常用地址 ----
// 编辑水印内容时用的快捷地址,一键替换「地点」那一行。
// 存成字符串列表就够了,不需要 JSON。
static const _keyWatermarkAddresses = 'watermark_quick_addresses';
/// 最多存几条 —— 太多会把弹层撑得很难看
static const int watermarkAddressLimit = 8;
Future<void> saveWatermarkAddresses(List<String> addresses) async {
final prefs = await _getPrefs();
await prefs.setStringList(_keyWatermarkAddresses, addresses);
}
List<String> getWatermarkAddresses() {
return _prefs?.getStringList(_keyWatermarkAddresses) ?? const [];
}
}
+11
View File
@@ -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();
+124
View File
@@ -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<String, dynamic> 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<PhotoItem> items;
final int total;
final bool hasMore;
static const empty = PhotoPage(items: [], total: 0, hasMore: false);
factory PhotoPage.fromJson(Map<String, dynamic> json) {
return PhotoPage(
items: asMapList(json['list']).map(PhotoItem.fromJson).toList(),
total: asInt(json['total']),
hasMore: json['hasMore'] == true,
);
}
}
+11 -60
View File
@@ -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<int> {
@override
int build() => 0;
@@ -14,13 +21,12 @@ class AddTabNotifier extends Notifier<int> {
final addTabProvider = NotifierProvider<AddTabNotifier, int>(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(),
),
);
}
+1 -1
View File
@@ -212,7 +212,7 @@ class BillHomePage extends ConsumerWidget {
if (bills.isEmpty) {
return const EmptyView(
title: '本月暂无账单记录',
tip: '点击底部「记录」添加,或用 OCR 识别小票',
tip: '点击底部「记录」添加',
);
}
return Column(
+368 -29
View File
@@ -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<WatermarkCameraPage> createState() => _WatermarkCameraPageState();
}
class _WatermarkCameraPageState extends ConsumerState<WatermarkCameraPage> {
static const _pageSize = 21; // 3 列,正好 7 行
final List<PhotoItem> _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<void> _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<void> _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<void> _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<void> _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,7 +188,9 @@ class WatermarkCameraPage extends ConsumerWidget {
),
],
),
body: ListView(
body: Stack(
children: [
ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
_introCard(),
@@ -39,17 +199,22 @@ class WatermarkCameraPage extends ConsumerWidget {
icon: Icons.camera_alt,
label: '拍照',
primary: true,
onTap: () => _pick(context, ImageSource.camera),
onTap: () => _pick(ImageSource.camera),
),
const SizedBox(height: 12),
_captureButton(
icon: Icons.photo_library_outlined,
label: '从相册选',
primary: false,
onTap: () => _pick(context, ImageSource.gallery),
onTap: () => _pick(ImageSource.gallery),
),
const SizedBox(height: 24),
_settingsCard(context, settings),
const SizedBox(height: 24),
_recentSection(),
],
),
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(
return FilledButton.icon(
onPressed: onTap,
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)),
side: primary ? BorderSide.none : const BorderSide(color: Color(0xFFC8956E)),
),
elevation: 0,
),
elevation: primary ? 0 : 0,
);
return FilledButton.icon(
onPressed: onTap,
style: style,
icon: Icon(icon, size: 20),
label: Text(
label,
@@ -192,20 +353,198 @@ class WatermarkCameraPage extends ConsumerWidget {
);
}
/// 选图。**不传 imageQuality / maxWidth** —— 要的就是原图,
/// 编辑页会在合成时按最长边 1920 缩放,原始文件则原样备份到服务器。
Future<void> _pick(BuildContext context, ImageSource source) async {
try {
final file = await ImagePicker().pickImage(source: source);
if (file == null) return;
if (!context.mounted) return;
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => WatermarkEditPage(imagePath: file.path)),
/// 已上传照片的网格 —— 点一张就能重新打水印(换颜色/位置/补备注)
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(),
],
);
} catch (e) {
if (context.mounted) {
showToast(context, '打开相机/相册失败:$e', error: true);
}
Widget _recentBody() {
if (_loading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 36),
child: Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
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)),
),
],
),
),
),
),
);
}
}
+144 -41
View File
@@ -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<WatermarkEditPage> createState() => _WatermarkEditPageState();
}
@@ -40,6 +58,9 @@ class _WatermarkEditPageState extends ConsumerState<WatermarkEditPage> {
bool _locating = false;
String? _locateError;
/// 定位成功但地址没解析出来时的原因(配额用完、两个服务都连不上…)
String? _addressError;
/// 定位到的坐标(上传原图时一并存到服务器)
double? _latitude;
double? _longitude;
@@ -47,14 +68,26 @@ class _WatermarkEditPageState extends ConsumerState<WatermarkEditPage> {
_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<void> _bootstrap() async {
if (widget.initialData != null || !widget.uploadOriginal) {
return;
}
await _locate();
await _uploadOriginal();
}
// ---------------- 图片尺寸 ----------------
@@ -79,11 +112,6 @@ class _WatermarkEditPageState extends ConsumerState<WatermarkEditPage> {
// ---------------- 定位 ----------------
Future<void> _locateThenUpload() async {
await _locate();
await _uploadOriginal();
}
Future<void> _locate() async {
if (!mounted) return;
setState(() {
@@ -97,6 +125,7 @@ class _WatermarkEditPageState extends ConsumerState<WatermarkEditPage> {
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<WatermarkEditPage> {
return joined;
}
// ---------------- 保存 ----------------
// ---------------- 合成 / 保存 / 分享 ----------------
/// 合成一次带水印的 JPEG。
/// 保存和分享共用这一份 —— 两边都要同一张图,逻辑不该写两遍。
Future<Uint8List> _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<void> _save() async {
if (_saving) return;
final settings = ref.read(watermarkSettingsProvider);
final brandIcon = ref.read(brandIconProvider).value;
setState(() => _saving = true);
try {
final bytes = await File(widget.imagePath).readAsBytes();
final jpeg = await composeWatermarkedJpeg(
originalBytes: bytes,
settings: settings,
data: _data,
brandIcon: brandIcon,
);
final jpeg = await _composeJpeg();
// gal 的约定:name 不带扩展名
await Gal.putImageBytes(jpeg, name: 'qiangbao_${_timestamp()}');
@@ -173,7 +204,7 @@ class _WatermarkEditPageState extends ConsumerState<WatermarkEditPage> {
} 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<WatermarkEditPage> {
}
}
/// 调系统分享面板(Android 走 ACTION_SEND,会列出微信/QQ/邮件等)
Future<void> _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<WatermarkEditPage> {
/// 原图上传状态条
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<WatermarkEditPage> {
/// 定位/地址条
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<WatermarkEditPage> {
const Icon(Icons.location_on_outlined, size: 16, color: Color(0xFFC8956E)),
const SizedBox(width: 6),
Expanded(
child: Text(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
height: 1.4,
color: _locateError != null
? const Color(0xFFE53935)
: const Color(0xFF5D4037),
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<WatermarkEditPage> {
label: '水印设置',
onTap: () => showWatermarkSettingsSheet(context),
),
_toolButton(
icon: Icons.share_outlined,
label: '分享',
busy: _sharing,
onTap: _share,
),
],
),
),
@@ -450,17 +546,24 @@ class _WatermarkEditPageState extends ConsumerState<WatermarkEditPage> {
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,
@@ -1,11 +1,11 @@
import 'package:flutter/material.dart';
import '../../../core/storage/local_storage.dart';
import '../../../models/watermark.dart';
/// 编辑这一张照片的水印内容(地址 / 天气 / 温度 / 备注)
/// 编辑这一张照片的水印内容(时间 / 地址 / 天气 / 温度 / 备注)
///
/// 返回 null 表示用户取消了。时间不在这里改 —— 它总是「现在」,
/// 让用户手改反而容易把水印弄成假的时间。
/// 返回 null 表示用户取消了。
Future<WatermarkData?> 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<String> _addresses;
bool _managing = false;
@override
void initState() {
super.initState();
_time = widget.initial.time;
_addresses = List<String>.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<void> _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<void> _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<void> _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<String?> _promptAddress({String initial = ''}) {
final controller = TextEditingController(text: initial);
return showDialog<String>(
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,
@@ -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),
),
),
],
);
}
}
+10
View File
@@ -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<ui.Image>((ref) async {
codec.dispose();
return frame.image;
});
/// 首页水印卡片用:只想知道「一共几张、最近一张什么时候」。
///
/// 和 `noteOverviewProvider` 一个思路 —— 只取 1 条拿 total,
/// 不为了一个数字把列表页那套分页状态挂到首页上。
final photoOverviewProvider = FutureProvider.autoDispose<PhotoPage>((ref) {
return ref.watch(photoApiProvider).list(page: 1, size: 1);
});
+157
View File
@@ -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:
+4
View File
@@ -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:
+159
View File
@@ -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<ResponseBody> Function(RequestOptions options) handler;
/// 记录每次请求,用来断言请求头和参数
final List<RequestOptions> requests = [];
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? 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<ApiException>().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');
});
});
}
+137 -4
View File
@@ -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<GeoSnapshot> resolve() async => const GeoSnapshot(error: '没有定位权限');
}
/// 假上传:立刻成功,避免测试里真的打网络
/// 假照片接口:不真打网络。默认返回空列表 —— 入口页会在 initState 里拉列表,
/// 不给它替身的话测试会真的去连 localhost,每个用例白等好几秒。
class _FakePhotoApi extends PhotoApi {
_FakePhotoApi() : super(Dio());
_FakePhotoApi({this.photos = const []}) : super(Dio());
final List<PhotoItem> photos;
@override
Future<int?> upload({
@@ -56,6 +62,17 @@ class _FakePhotoApi extends PhotoApi {
DateTime? takenTime,
}) async =>
1;
@override
Future<PhotoPage> list({int page = 1, int size = 30}) async =>
PhotoPage(items: photos, total: photos.length, hasMore: false);
@override
Future<String> 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<void> 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': <String>['昆明市公安局', '昆明南站'],
});
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<TextField>(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);
+52
View File
@@ -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();