Files
2026-09-28 11:39:19 +08:00

551 lines
17 KiB
Dart

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 ConsumerStatefulWidget {
const WatermarkCameraPage({super.key});
@override
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(
appBar: AppBar(
title: const Text('水印相机'),
actions: [
IconButton(
tooltip: '水印设置',
icon: const Icon(Icons.tune),
onPressed: () => showWatermarkSettingsSheet(context),
),
],
),
body: Stack(
children: [
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(),
],
),
if (_openingId != null) _downloadOverlay(),
],
),
);
}
Widget _introCard() {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8)],
),
borderRadius: BorderRadius.circular(16),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'拍一张,自动加水印',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
SizedBox(height: 6),
Text(
'时间、地点、天气自动填好,可以拖动位置、换颜色。\n'
'加好水印的照片直接存进手机相册;原图会同时备份到服务器。',
style: TextStyle(fontSize: 12.5, height: 1.6, color: Color(0xFF8D6E63)),
),
],
),
);
}
Widget _captureButton({
required IconData icon,
required String label,
required bool primary,
required VoidCallback onTap,
}) {
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)),
),
elevation: 0,
),
icon: Icon(icon, size: 20),
label: Text(
label,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
);
}
Widget _settingsCard(BuildContext context, WatermarkSettings settings) {
final items = <String>[
if (settings.showTime) '时间',
if (settings.showAddress) '地点',
if (settings.showWeather) '天气',
if (settings.showRemark) '备注',
if (settings.showBrand) '品牌角标',
];
return InkWell(
onTap: () => showWatermarkSettingsSheet(context),
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.fromLTRB(18, 16, 14, 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'当前水印内容',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 8),
if (items.isEmpty)
const Text(
'全部关闭(照片不会加任何水印)',
style: TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)),
)
else
Wrap(
spacing: 6,
runSpacing: 6,
children: items
.map(
(t) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFFFFF3E6),
borderRadius: BorderRadius.circular(10),
),
child: Text(
t,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF8D6E63),
),
),
),
)
.toList(),
),
],
),
),
const Icon(Icons.chevron_right, color: Color(0xFFBCAAA4)),
],
),
),
);
}
/// 已上传照片的网格 —— 点一张就能重新打水印(换颜色/位置/补备注)
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),
),
),
);
}
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)),
),
],
),
),
),
),
);
}
}