551 lines
17 KiB
Dart
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)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|