水印优化+ORC优化
This commit is contained in:
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user