水印优化+ORC优化
This commit is contained in:
+123
-8
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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('\\', '/');
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ class BillHomePage extends ConsumerWidget {
|
||||
if (bills.isEmpty) {
|
||||
return const EmptyView(
|
||||
title: '本月暂无账单记录',
|
||||
tip: '点击底部「记录」添加,或用 OCR 识别小票',
|
||||
tip: '点击底部「记录」添加',
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
|
||||
@@ -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,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<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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
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<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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user