feat(app): crop cover & banner before upload (crop_your_image, 16:9 / 3:1)
review fixes: drop GIF from pre-sniff (package maps gif→png decode → never-ready hang); PopScope blocks back during processing Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -429,5 +429,10 @@
|
|||||||
"posterSaveFailed": "Failed to save poster",
|
"posterSaveFailed": "Failed to save poster",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"albumUploadDisabled": "Upload is disabled for this album",
|
"albumUploadDisabled": "Upload is disabled for this album",
|
||||||
"networkOfflineBanner": "No network. Photos will upload automatically when reconnected."
|
"networkOfflineBanner": "No network. Photos will upload automatically when reconnected.",
|
||||||
|
"cropTitle": "Crop image",
|
||||||
|
"cropConfirm": "Done",
|
||||||
|
"cropCancel": "Cancel",
|
||||||
|
"cropProcessing": "Processing…",
|
||||||
|
"cropUnsupportedFormat": "This image format isn't supported yet — please pick JPG/PNG"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2029,6 +2029,36 @@ abstract class AppLocalizations {
|
|||||||
/// In zh, this message translates to:
|
/// In zh, this message translates to:
|
||||||
/// **'当前无网络连接,照片将在恢复后自动上传'**
|
/// **'当前无网络连接,照片将在恢复后自动上传'**
|
||||||
String get networkOfflineBanner;
|
String get networkOfflineBanner;
|
||||||
|
|
||||||
|
/// No description provided for @cropTitle.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'裁剪图片'**
|
||||||
|
String get cropTitle;
|
||||||
|
|
||||||
|
/// No description provided for @cropConfirm.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'完成'**
|
||||||
|
String get cropConfirm;
|
||||||
|
|
||||||
|
/// No description provided for @cropCancel.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'取消'**
|
||||||
|
String get cropCancel;
|
||||||
|
|
||||||
|
/// No description provided for @cropProcessing.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'处理中…'**
|
||||||
|
String get cropProcessing;
|
||||||
|
|
||||||
|
/// No description provided for @cropUnsupportedFormat.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'暂不支持该图片格式,请选择 JPG/PNG'**
|
||||||
|
String get cropUnsupportedFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -1038,4 +1038,20 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get networkOfflineBanner =>
|
String get networkOfflineBanner =>
|
||||||
'No network. Photos will upload automatically when reconnected.';
|
'No network. Photos will upload automatically when reconnected.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cropTitle => 'Crop image';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cropConfirm => 'Done';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cropCancel => 'Cancel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cropProcessing => 'Processing…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cropUnsupportedFormat =>
|
||||||
|
'This image format isn\'t supported yet — please pick JPG/PNG';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1004,4 +1004,19 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get networkOfflineBanner => '当前无网络连接,照片将在恢复后自动上传';
|
String get networkOfflineBanner => '当前无网络连接,照片将在恢复后自动上传';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cropTitle => '裁剪图片';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cropConfirm => '完成';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cropCancel => '取消';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cropProcessing => '处理中…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cropUnsupportedFormat => '暂不支持该图片格式,请选择 JPG/PNG';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -429,5 +429,10 @@
|
|||||||
"posterSaveFailed": "海报保存失败",
|
"posterSaveFailed": "海报保存失败",
|
||||||
"close": "关闭",
|
"close": "关闭",
|
||||||
"albumUploadDisabled": "该相册已关闭上传,无法添加照片",
|
"albumUploadDisabled": "该相册已关闭上传,无法添加照片",
|
||||||
"networkOfflineBanner": "当前无网络连接,照片将在恢复后自动上传"
|
"networkOfflineBanner": "当前无网络连接,照片将在恢复后自动上传",
|
||||||
|
"cropTitle": "裁剪图片",
|
||||||
|
"cropConfirm": "完成",
|
||||||
|
"cropCancel": "取消",
|
||||||
|
"cropProcessing": "处理中…",
|
||||||
|
"cropUnsupportedFormat": "暂不支持该图片格式,请选择 JPG/PNG"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import '../features/uploads/enqueue.dart';
|
|||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../ui/components/section_header.dart';
|
import '../ui/components/section_header.dart';
|
||||||
import '../ui/tokens.dart';
|
import '../ui/tokens.dart';
|
||||||
|
import 'image_crop_page.dart';
|
||||||
|
|
||||||
/// 创建([existing] 为 null)/ 编辑相册表单。
|
/// 创建([existing] 为 null)/ 编辑相册表单。
|
||||||
class AlbumFormPage extends ConsumerStatefulWidget {
|
class AlbumFormPage extends ConsumerStatefulWidget {
|
||||||
@@ -497,17 +498,43 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// 选图后必经裁剪:封面 16:9(表单预览区比例),输出宽 ≤1920。
|
||||||
Future<void> _pickCoverImage() async {
|
Future<void> _pickCoverImage() async {
|
||||||
final path = await ref.read(singleImagePickerProvider)();
|
final path = await ref.read(singleImagePickerProvider)();
|
||||||
if (path != null) {
|
if (path == null || !mounted) {
|
||||||
setState(() => _coverFilePath = path);
|
return;
|
||||||
|
}
|
||||||
|
final cropped = await Navigator.of(context).push<String>(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => ImageCropPage(
|
||||||
|
sourcePath: path,
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
maxOutputWidth: 1920,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (cropped != null && mounted) {
|
||||||
|
setState(() => _coverFilePath = cropped);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 选图后必经裁剪:宣传图 3:1(观众页横幅),输出宽 ≤2400。
|
||||||
Future<void> _pickBannerImage() async {
|
Future<void> _pickBannerImage() async {
|
||||||
final path = await ref.read(singleImagePickerProvider)();
|
final path = await ref.read(singleImagePickerProvider)();
|
||||||
if (path != null) {
|
if (path == null || !mounted) {
|
||||||
setState(() => _bannerFilePath = path);
|
return;
|
||||||
|
}
|
||||||
|
final cropped = await Navigator.of(context).push<String>(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => ImageCropPage(
|
||||||
|
sourcePath: path,
|
||||||
|
aspectRatio: 3 / 1,
|
||||||
|
maxOutputWidth: 2400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (cropped != null && mounted) {
|
||||||
|
setState(() => _bannerFilePath = cropped);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
/// 封面/宣传图裁剪页:固定纵横比交互裁剪 → 等比缩放上限 → JPEG 落盘。
|
||||||
|
///
|
||||||
|
/// 输入 image_picker 选中的本机路径;输出裁剪后的临时文件路径
|
||||||
|
/// (`{tmp}/album_assets/crop_{ms}.jpg`);取消/失败返回 null。
|
||||||
|
/// 封面 16:9(输出宽 ≤1920)、宣传图 3:1(输出宽 ≤2400)由调用方给定。
|
||||||
|
///
|
||||||
|
/// crop_your_image 2.0.0 为回调式 API:`CropController.crop()` 是 void,
|
||||||
|
/// 裁剪结果经 `onCropped` 回调(`CropSuccess.croppedImage` /
|
||||||
|
/// `CropFailure.cause`)。像素级 decode/缩放/编码统一放
|
||||||
|
/// `Isolate.run`,避免大图卡死主 isolate。
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:isolate';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:crop_your_image/crop_your_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../ui/tokens.dart';
|
||||||
|
|
||||||
|
/// 裁剪页输出目录(应用临时目录下的 album_assets;测试直接可用真实 FS)。
|
||||||
|
Directory _cropOutputDir() {
|
||||||
|
final dir = Directory(
|
||||||
|
'${Directory.systemTemp.path}${Platform.pathSeparator}album_assets',
|
||||||
|
);
|
||||||
|
dir.createSync(recursive: true);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 裁剪字节 → 可解码格式预检(魔数嗅探;HEIC/GIF 等走不支持提示)。
|
||||||
|
///
|
||||||
|
/// 包内解析失败(`_parseImageWith` 的 compute 抛错)不会回调页面——
|
||||||
|
/// 页面在交给 [Crop] 前先嗅探,拦下注定解析失败的源。
|
||||||
|
/// GIF 明确不支持:包内 formatDetector 的兜底分支按 png 解码
|
||||||
|
/// (`_ => ImageFormat.png`),decodePng(gif) 必然失败 → onStatusChanged
|
||||||
|
/// 永不 ready → 页面挂死在加载态。
|
||||||
|
bool _hasSupportedSignature(Uint8List bytes) {
|
||||||
|
if (bytes.length < 12) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final jpeg = bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
|
||||||
|
final png =
|
||||||
|
bytes[0] == 0x89 &&
|
||||||
|
bytes[1] == 0x50 &&
|
||||||
|
bytes[2] == 0x4E &&
|
||||||
|
bytes[3] == 0x47;
|
||||||
|
final bmp = bytes[0] == 0x42 && bytes[1] == 0x4D;
|
||||||
|
final webp =
|
||||||
|
bytes[0] == 0x52 &&
|
||||||
|
bytes[1] == 0x49 &&
|
||||||
|
bytes[2] == 0x46 &&
|
||||||
|
bytes[3] == 0x46 &&
|
||||||
|
bytes[8] == 0x57 &&
|
||||||
|
bytes[9] == 0x45 &&
|
||||||
|
bytes[10] == 0x42 &&
|
||||||
|
bytes[11] == 0x50;
|
||||||
|
return jpeg || png || bmp || webp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 裁剪输出编码 + 落盘(只在 isolate 内执行)。
|
||||||
|
String _encodeCropToJpeg(Uint8List cropped, int? maxOutputWidth) {
|
||||||
|
final decoded = img.decodeImage(cropped);
|
||||||
|
if (decoded == null) {
|
||||||
|
throw img.ImageException('crop output is not decodable');
|
||||||
|
}
|
||||||
|
var output = decoded;
|
||||||
|
if (maxOutputWidth != null && decoded.width > maxOutputWidth) {
|
||||||
|
output = img.copyResize(decoded, width: maxOutputWidth);
|
||||||
|
}
|
||||||
|
final file = File(
|
||||||
|
'${_cropOutputDir().path}${Platform.pathSeparator}'
|
||||||
|
'crop_${DateTime.now().millisecondsSinceEpoch}.jpg',
|
||||||
|
);
|
||||||
|
file.writeAsBytesSync(img.encodeJpg(output, quality: 90));
|
||||||
|
return file.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 封面/宣传图裁剪页。
|
||||||
|
class ImageCropPage extends StatefulWidget {
|
||||||
|
const ImageCropPage({
|
||||||
|
super.key,
|
||||||
|
required this.sourcePath,
|
||||||
|
required this.aspectRatio,
|
||||||
|
this.maxOutputWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 源图本机路径(image_picker 单选返回)。
|
||||||
|
final String sourcePath;
|
||||||
|
|
||||||
|
/// 目标宽高比(宽/高):封面 16/9、宣传图 3/1。
|
||||||
|
final double aspectRatio;
|
||||||
|
|
||||||
|
/// 输出宽度上限(等比缩放;null=保持裁剪原分辨率)。
|
||||||
|
final int? maxOutputWidth;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ImageCropPage> createState() => _ImageCropPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImageCropPageState extends State<ImageCropPage> {
|
||||||
|
final CropController _controller = CropController();
|
||||||
|
|
||||||
|
late final Future<Uint8List> _sourceBytes;
|
||||||
|
|
||||||
|
/// 源读取完成且魔数预检通过(可裁剪)。
|
||||||
|
bool _ready = false;
|
||||||
|
|
||||||
|
/// 包内解析完成(CropStatus.ready;完成按钮需等解析完再开放)。
|
||||||
|
bool _cropReady = false;
|
||||||
|
|
||||||
|
/// 源读取失败或预检不通过(HEIC 等不支持的格式)。
|
||||||
|
bool _unsupported = false;
|
||||||
|
|
||||||
|
/// 编码落盘中(遮罩 + 防重复点击)。
|
||||||
|
bool _processing = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_sourceBytes = File(widget.sourcePath).readAsBytes();
|
||||||
|
_sourceBytes
|
||||||
|
.then((bytes) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_ready = _hasSupportedSignature(bytes);
|
||||||
|
_unsupported = !_ready;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catchError((Object _) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _unsupported = true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancel() {
|
||||||
|
Navigator.of(context).pop(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirm() {
|
||||||
|
if (_processing || !_ready || !_cropReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _processing = true);
|
||||||
|
// crop() 为 void:结果经 onCropped 回调。
|
||||||
|
_controller.crop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 包内状态回调:解析完成(ready)才允许确认,防 crop() 在解析前触发的断言。
|
||||||
|
void _onCropStatus(CropStatus status) {
|
||||||
|
final ready = status == CropStatus.ready;
|
||||||
|
if (ready == _cropReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _cropReady = ready);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onCropped(CropResult result) async {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result is! CropSuccess) {
|
||||||
|
_fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 仅捕获可发送值(Uint8List/int),闭包跨 isolate 复制。
|
||||||
|
final cropped = result.croppedImage;
|
||||||
|
final maxWidth = widget.maxOutputWidth;
|
||||||
|
try {
|
||||||
|
final outputPath = await Isolate.run(
|
||||||
|
() => _encodeCropToJpeg(cropped, maxWidth),
|
||||||
|
);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(context).pop(outputPath);
|
||||||
|
} on Object {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_fail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 裁剪/编码失败:解除遮罩 + 提示,不 pop。
|
||||||
|
void _fail() {
|
||||||
|
setState(() => _processing = false);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context).cropUnsupportedFormat),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
// 编码落盘中拦截系统返回/ AppBar 返回(与底部取消禁用一致)。
|
||||||
|
return PopScope(
|
||||||
|
canPop: !_processing,
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(title: Text(l10n.cropTitle)),
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(child: _buildBody(l10n)),
|
||||||
|
if (_processing)
|
||||||
|
Positioned.fill(
|
||||||
|
child: AbsorbPointer(
|
||||||
|
child: ColoredBox(
|
||||||
|
color: Colors.black54,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
const SizedBox(height: kSpace12),
|
||||||
|
Text(
|
||||||
|
l10n.cropProcessing,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
bottomNavigationBar: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
kSpace16,
|
||||||
|
kSpace8,
|
||||||
|
kSpace16,
|
||||||
|
kSpace12,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: _processing ? null : _cancel,
|
||||||
|
child: Text(l10n.cropCancel),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: kSpace12),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: (_ready && _cropReady && !_processing)
|
||||||
|
? _confirm
|
||||||
|
: null,
|
||||||
|
child: Text(l10n.cropConfirm),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody(AppLocalizations l10n) {
|
||||||
|
if (_unsupported) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(kSpace16),
|
||||||
|
child: Text(
|
||||||
|
l10n.cropUnsupportedFormat,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return FutureBuilder<Uint8List>(
|
||||||
|
future: _sourceBytes,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState != ConnectionState.done ||
|
||||||
|
!snapshot.hasData) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
return Crop(
|
||||||
|
image: snapshot.data!,
|
||||||
|
controller: _controller,
|
||||||
|
onCropped: _onCropped,
|
||||||
|
onStatusChanged: _onCropStatus,
|
||||||
|
aspectRatio: widget.aspectRatio,
|
||||||
|
// 交互模式 + 锁定裁剪框:用户平移/缩放图片适配固定比例框。
|
||||||
|
interactive: true,
|
||||||
|
fixCropRect: true,
|
||||||
|
baseColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
progressIndicator: const CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -217,6 +217,14 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
version: "3.1.2"
|
||||||
|
crop_your_image:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: crop_your_image
|
||||||
|
sha256: "14c8977b11a009dc5e73e0f6522970f93363e38183f1b2ffefe1676dc9c3f49d"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
cross_file:
|
cross_file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -568,7 +576,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
image:
|
image:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89
|
sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ dependencies:
|
|||||||
gal: ^2.3.3
|
gal: ^2.3.3
|
||||||
fluwx: ^6.0.2
|
fluwx: ^6.0.2
|
||||||
connectivity_plus: ^7.3.1
|
connectivity_plus: ^7.3.1
|
||||||
|
# 封面/宣传图裁剪(纯 Flutter)
|
||||||
|
crop_your_image: ^2.0.0
|
||||||
|
# 像素编码输出(裁剪页直接 import)
|
||||||
|
image: ^4.3.0
|
||||||
|
|
||||||
|
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
/// 封面/宣传图裁剪页 widget 测试(crop_your_image 回调式 API)。
|
||||||
|
///
|
||||||
|
/// 真实异步边界(源文件读取 + 包内 parse/crop 的 compute + 编码落盘
|
||||||
|
/// Isolate.run)在 testWidgets 的 FakeAsync 下不会自行推进——参照
|
||||||
|
/// crop_your_image 自身测试的驱动方式:tap + 等待都包进 tester.runAsync,
|
||||||
|
/// 用真实 Future.delayed 等 isolate 完成。
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:photo_live/l10n/app_localizations.dart';
|
||||||
|
import 'package:photo_live/pages/image_crop_page.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late final Directory tmp;
|
||||||
|
late final String fixtureJpg;
|
||||||
|
late final String fixtureGif;
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
tmp = Directory.systemTemp.createTempSync('image_crop_page_test');
|
||||||
|
// 320x240(4:3)JPG 夹具:运行时生成,避免仓库二进制资产。
|
||||||
|
final source = img.Image(width: 320, height: 240, numChannels: 3);
|
||||||
|
img.fill(source, color: img.ColorRgb8(80, 120, 200));
|
||||||
|
fixtureJpg = '${tmp.path}${Platform.pathSeparator}fixture.jpg';
|
||||||
|
File(fixtureJpg).writeAsBytesSync(img.encodeJpg(source, quality: 90));
|
||||||
|
// GIF 夹具:包内 formatDetector 兜底按 png 解码必然失败挂起,
|
||||||
|
// 页面预检须直接判不支持(防 never-ready)。
|
||||||
|
fixtureGif = '${tmp.path}${Platform.pathSeparator}fixture.gif';
|
||||||
|
File(fixtureGif).writeAsBytesSync(img.encodeGif(source));
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDownAll(() {
|
||||||
|
tmp.deleteSync(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 打开裁剪页(zh 本地化);onPopped 接住 pop 结果。
|
||||||
|
Future<void> openCropPage(
|
||||||
|
WidgetTester tester,
|
||||||
|
String sourcePath, {
|
||||||
|
required double aspectRatio,
|
||||||
|
int? maxOutputWidth,
|
||||||
|
void Function(String? value)? onPopped,
|
||||||
|
}) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
locale: const Locale('zh'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: Builder(
|
||||||
|
builder: (context) => Center(
|
||||||
|
child: FloatingActionButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final value = await Navigator.of(context).push<String>(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => ImageCropPage(
|
||||||
|
sourcePath: sourcePath,
|
||||||
|
aspectRatio: aspectRatio,
|
||||||
|
maxOutputWidth: maxOutputWidth,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
onPopped?.call(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
await tester.tap(find.byType(FloatingActionButton));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
// 真实异步必须「等→pump→再等→再 pump」:读取完成后经 pump 才构建
|
||||||
|
// Crop(parse compute 此刻才启动),需再等真实时间让 isolate 回复落地。
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
await tester.pump();
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
await tester.pump();
|
||||||
|
});
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('取消 → pop(null)', (tester) async {
|
||||||
|
String? popped = 'sentinel';
|
||||||
|
await openCropPage(
|
||||||
|
tester,
|
||||||
|
fixtureJpg,
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
onPopped: (value) => popped = value,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
await tester.tap(find.text('取消'));
|
||||||
|
await tester.pump();
|
||||||
|
});
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(popped, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('确认 → pop 出存在的 .jpg 路径', (tester) async {
|
||||||
|
String? popped;
|
||||||
|
await openCropPage(
|
||||||
|
tester,
|
||||||
|
fixtureJpg,
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
maxOutputWidth: 1920,
|
||||||
|
onPopped: (value) => popped = value,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
await tester.tap(find.text('完成'));
|
||||||
|
await tester.pump();
|
||||||
|
// crop(compute)→ 编码落盘(Isolate.run)→ pop。
|
||||||
|
await Future.delayed(const Duration(seconds: 5));
|
||||||
|
await tester.pump();
|
||||||
|
});
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(popped, isNotNull);
|
||||||
|
expect(popped!.endsWith('.jpg'), isTrue);
|
||||||
|
expect(File(popped!).existsSync(), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('确认 → 输出尊重 maxOutputWidth 且保持纵横比', (tester) async {
|
||||||
|
String? popped;
|
||||||
|
await openCropPage(
|
||||||
|
tester,
|
||||||
|
fixtureJpg,
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
maxOutputWidth: 160,
|
||||||
|
onPopped: (value) => popped = value,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
await tester.tap(find.text('完成'));
|
||||||
|
await tester.pump();
|
||||||
|
await Future.delayed(const Duration(seconds: 5));
|
||||||
|
await tester.pump();
|
||||||
|
});
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(popped, isNotNull);
|
||||||
|
final decoded = img.decodeImage(File(popped!).readAsBytesSync());
|
||||||
|
expect(decoded, isNotNull);
|
||||||
|
expect(decoded!.width, 160);
|
||||||
|
expect(decoded.height, 90);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('非图片源 → 提示不支持且不 pop', (tester) async {
|
||||||
|
final bogus = File('${tmp.path}${Platform.pathSeparator}bogus.jpg');
|
||||||
|
bogus.writeAsStringSync('definitely not an image');
|
||||||
|
|
||||||
|
final popped = <String?>[];
|
||||||
|
await openCropPage(
|
||||||
|
tester,
|
||||||
|
bogus.path,
|
||||||
|
aspectRatio: 3 / 1,
|
||||||
|
onPopped: popped.add,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('暂不支持该图片格式,请选择 JPG/PNG'), findsOneWidget);
|
||||||
|
expect(popped, isEmpty);
|
||||||
|
// 完成不可用(无裁剪可做)。
|
||||||
|
final done = find.text('完成');
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<FilledButton>(
|
||||||
|
find.ancestor(of: done, matching: find.byType(FilledButton)),
|
||||||
|
)
|
||||||
|
.enabled,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
|
||||||
|
bogus.deleteSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('GIF 源 → 预检判不支持(防包内 png 兜底解码挂起)且不 pop',
|
||||||
|
(tester) async {
|
||||||
|
final popped = <String?>[];
|
||||||
|
await openCropPage(
|
||||||
|
tester,
|
||||||
|
fixtureGif,
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
onPopped: popped.add,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('暂不支持该图片格式,请选择 JPG/PNG'), findsOneWidget);
|
||||||
|
expect(popped, isEmpty);
|
||||||
|
// 完成不可用:包内若按 png 兜底解析 GIF,将永远到不了 ready。
|
||||||
|
final done = find.text('完成');
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<FilledButton>(
|
||||||
|
find.ancestor(of: done, matching: find.byType(FilledButton)),
|
||||||
|
)
|
||||||
|
.enabled,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user