水印优化+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
+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(
+388 -49
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,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)),
),
],
),
),
),
),
);
}
}
+148 -45
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(
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),
),
),
],
);
}
}