diff --git a/apps/mobile/lib/l10n/app_en.arb b/apps/mobile/lib/l10n/app_en.arb index 93c0ee5..f3e4425 100644 --- a/apps/mobile/lib/l10n/app_en.arb +++ b/apps/mobile/lib/l10n/app_en.arb @@ -429,5 +429,10 @@ "posterSaveFailed": "Failed to save poster", "close": "Close", "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" } diff --git a/apps/mobile/lib/l10n/app_localizations.dart b/apps/mobile/lib/l10n/app_localizations.dart index 39ce691..c13f689 100644 --- a/apps/mobile/lib/l10n/app_localizations.dart +++ b/apps/mobile/lib/l10n/app_localizations.dart @@ -2029,6 +2029,36 @@ abstract class AppLocalizations { /// In zh, this message translates to: /// **'当前无网络连接,照片将在恢复后自动上传'** 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 diff --git a/apps/mobile/lib/l10n/app_localizations_en.dart b/apps/mobile/lib/l10n/app_localizations_en.dart index 04ca0c0..fba570f 100644 --- a/apps/mobile/lib/l10n/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/app_localizations_en.dart @@ -1038,4 +1038,20 @@ class AppLocalizationsEn extends AppLocalizations { @override String get networkOfflineBanner => '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'; } diff --git a/apps/mobile/lib/l10n/app_localizations_zh.dart b/apps/mobile/lib/l10n/app_localizations_zh.dart index 7c954f8..e73ec4a 100644 --- a/apps/mobile/lib/l10n/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/app_localizations_zh.dart @@ -1004,4 +1004,19 @@ class AppLocalizationsZh extends AppLocalizations { @override 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'; } diff --git a/apps/mobile/lib/l10n/app_zh.arb b/apps/mobile/lib/l10n/app_zh.arb index 0975e64..7ebb132 100644 --- a/apps/mobile/lib/l10n/app_zh.arb +++ b/apps/mobile/lib/l10n/app_zh.arb @@ -429,5 +429,10 @@ "posterSaveFailed": "海报保存失败", "close": "关闭", "albumUploadDisabled": "该相册已关闭上传,无法添加照片", - "networkOfflineBanner": "当前无网络连接,照片将在恢复后自动上传" + "networkOfflineBanner": "当前无网络连接,照片将在恢复后自动上传", + "cropTitle": "裁剪图片", + "cropConfirm": "完成", + "cropCancel": "取消", + "cropProcessing": "处理中…", + "cropUnsupportedFormat": "暂不支持该图片格式,请选择 JPG/PNG" } diff --git a/apps/mobile/lib/pages/album_form_page.dart b/apps/mobile/lib/pages/album_form_page.dart index 8d10bd7..a4abefd 100644 --- a/apps/mobile/lib/pages/album_form_page.dart +++ b/apps/mobile/lib/pages/album_form_page.dart @@ -31,6 +31,7 @@ import '../features/uploads/enqueue.dart'; import '../l10n/app_localizations.dart'; import '../ui/components/section_header.dart'; import '../ui/tokens.dart'; +import 'image_crop_page.dart'; /// 创建([existing] 为 null)/ 编辑相册表单。 class AlbumFormPage extends ConsumerStatefulWidget { @@ -497,17 +498,43 @@ class _AlbumFormPageState extends ConsumerState ], ); + /// 选图后必经裁剪:封面 16:9(表单预览区比例),输出宽 ≤1920。 Future _pickCoverImage() async { final path = await ref.read(singleImagePickerProvider)(); - if (path != null) { - setState(() => _coverFilePath = path); + if (path == null || !mounted) { + return; + } + final cropped = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ImageCropPage( + sourcePath: path, + aspectRatio: 16 / 9, + maxOutputWidth: 1920, + ), + ), + ); + if (cropped != null && mounted) { + setState(() => _coverFilePath = cropped); } } + /// 选图后必经裁剪:宣传图 3:1(观众页横幅),输出宽 ≤2400。 Future _pickBannerImage() async { final path = await ref.read(singleImagePickerProvider)(); - if (path != null) { - setState(() => _bannerFilePath = path); + if (path == null || !mounted) { + return; + } + final cropped = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ImageCropPage( + sourcePath: path, + aspectRatio: 3 / 1, + maxOutputWidth: 2400, + ), + ), + ); + if (cropped != null && mounted) { + setState(() => _bannerFilePath = cropped); } } diff --git a/apps/mobile/lib/pages/image_crop_page.dart b/apps/mobile/lib/pages/image_crop_page.dart new file mode 100644 index 0000000..f868572 --- /dev/null +++ b/apps/mobile/lib/pages/image_crop_page.dart @@ -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 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), + ); + }, + ); + } +} diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 00cf77f..4808615 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -217,6 +217,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted 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: dependency: transitive description: @@ -568,7 +576,7 @@ packages: source: hosted version: "4.1.2" image: - dependency: transitive + dependency: "direct main" description: name: image sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89 diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index b5cf839..8b84d49 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -42,6 +42,10 @@ dependencies: gal: ^2.3.3 fluwx: ^6.0.2 connectivity_plus: ^7.3.1 + # 封面/宣传图裁剪(纯 Flutter) + crop_your_image: ^2.0.0 + # 像素编码输出(裁剪页直接 import) + image: ^4.3.0 flutter_launcher_icons: diff --git a/apps/mobile/test/pages/image_crop_page_test.dart b/apps/mobile/test/pages/image_crop_page_test.dart new file mode 100644 index 0000000..889d9c3 --- /dev/null +++ b/apps/mobile/test/pages/image_crop_page_test.dart @@ -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 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( + 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 = []; + 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( + find.ancestor(of: done, matching: find.byType(FilledButton)), + ) + .enabled, + isFalse, + ); + + bogus.deleteSync(); + }); + + testWidgets('GIF 源 → 预检判不支持(防包内 png 兜底解码挂起)且不 pop', + (tester) async { + final popped = []; + 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( + find.ancestor(of: done, matching: find.byType(FilledButton)), + ) + .enabled, + isFalse, + ); + }); +}