/// 封面/宣传图裁剪页:固定纵横比交互裁剪 → 等比缩放上限 → 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 createState() => _ImageCropPageState(); } class _ImageCropPageState extends State { final CropController _controller = CropController(); late final Future _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 _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( 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), ); }, ); } }