683e19bdc1
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>
308 lines
9.3 KiB
Dart
308 lines
9.3 KiB
Dart
/// 封面/宣传图裁剪页:固定纵横比交互裁剪 → 等比缩放上限 → 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),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|