From 42d9cf3fd23de9fa8ffced6a3c8406cf3640a0f0 Mon Sep 17 00:00:00 2001 From: ivan Date: Mon, 30 Mar 2026 12:44:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=88=E8=A7=84=EF=BC=9A=E5=8E=BB=E9=99=A4?= =?UTF-8?q?=E4=B8=8D=E6=BB=A1=E8=B6=B3=E8=A6=81=E6=B1=82=E7=9A=84READ=5FME?= =?UTF-8?q?DIA=5FIMAGES=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 2 +- ios/Runner/GeneratedPluginRegistrant.m | 7 - .../generate_video/generate_video_screen.dart | 20 +- .../widgets/album_picker_sheet.dart | 569 +++--------------- pubspec.yaml | 1 - 5 files changed, 109 insertions(+), 490 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index bdeef88..205d0d8 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,7 @@ xmlns:tools="http://schemas.android.com/tools"> - + ) -#import -#else -@import photo_manager; -#endif - #if __has_include() #import #else @@ -120,7 +114,6 @@ [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; [InAppPurchasePlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppPurchasePlugin"]]; [PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]]; - [PhotoManagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PhotoManagerPlugin"]]; [ScreenSecurePlugin registerWithRegistrar:[registry registrarForPlugin:@"ScreenSecurePlugin"]]; [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; diff --git a/lib/features/generate_video/generate_video_screen.dart b/lib/features/generate_video/generate_video_screen.dart index 1ec6f84..79de71c 100644 --- a/lib/features/generate_video/generate_video_screen.dart +++ b/lib/features/generate_video/generate_video_screen.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:http/http.dart' as http; +import 'package:image_picker/image_picker.dart'; import 'package:video_player/video_player.dart'; import '../../core/auth/auth_service.dart'; @@ -104,13 +105,28 @@ class _GenerateVideoScreenState extends State { context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (ctx) => SizedBox( - height: MediaQuery.sizeOf(ctx).height * 0.92, + builder: (ctx) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(ctx).bottom, + ), child: const AlbumPickerSheet(), ), ); if (!mounted) return; + if (path == kAlbumPickerRequestGallery) { + final picker = ImagePicker(); + final x = await picker.pickImage(source: ImageSource.gallery); + if (!mounted) return; + if (x != null && x.path.isNotEmpty) { + final f = File(x.path); + if (await f.exists()) { + await _runGenerationApi(f); + } + } + return; + } + if (path == kAlbumPickerRequestCamera) { // 应用内相机:推全屏页前暂停背景视频,返回后短暂延迟再恢复,降低 GPU 切换毛刺 setState(() => _suspendBackgroundVideo = true); diff --git a/lib/features/generate_video/widgets/album_picker_sheet.dart b/lib/features/generate_video/widgets/album_picker_sheet.dart index d03d84c..24e3d03 100644 --- a/lib/features/generate_video/widgets/album_picker_sheet.dart +++ b/lib/features/generate_video/widgets/album_picker_sheet.dart @@ -1,10 +1,5 @@ -import 'dart:typed_data'; - -import 'package:flutter/foundation.dart' - show defaultTargetPlatform, kIsWeb, TargetPlatform; import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:photo_manager/photo_manager.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_spacing.dart'; @@ -12,194 +7,13 @@ import '../../../core/theme/app_spacing.dart'; /// [showModalBottomSheet] 返回此值时由**外层页面**调系统相机,避免 BottomSheet 与相机 Activity 叠放导致返回后黑屏/卡死。 const String kAlbumPickerRequestCamera = '__album_picker_camera__'; -/// 底部弹层:首格为拍照,其余为相册图片(与常见 App 一致) -class AlbumPickerSheet extends StatefulWidget { +/// 由外层调用 [ImagePicker](系统照片选择器 / Photo Picker),避免在 Sheet 内嵌套系统选图界面。 +const String kAlbumPickerRequestGallery = '__album_picker_gallery__'; + +/// 底部弹层:拍照 与 从相册选择(Android 使用系统照片选择器,不申请 READ_MEDIA_*) +class AlbumPickerSheet extends StatelessWidget { const AlbumPickerSheet({super.key}); - @override - State createState() => _AlbumPickerSheetState(); -} - -class _AlbumPickerSheetState extends State { - /// 首屏少一点,减轻同时解码缩略图的压力 - static const int _pageSize = 36; - - final ScrollController _scrollController = ScrollController(); - - bool _busy = true; - String? _error; - AssetPathEntity? _recentAlbum; - final List _assets = []; - int _loadedPage = -1; - int _totalCount = 0; - bool _loadingMore = false; - /// 最近一次权限结果(用于「仅部分照片」等说明) - PermissionState _permissionState = PermissionState.notDetermined; - - @override - void initState() { - super.initState(); - _scrollController.addListener(_maybeLoadMore); - // 等弹层完成首帧布局后再跑权限/读库,避免与 ModalRoute 争主线程;也方便 ScrollController 晚一点 attach - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _init(); - }); - } - - @override - void dispose() { - _scrollController.removeListener(_maybeLoadMore); - _scrollController.dispose(); - super.dispose(); - } - - Future _init() async { - setState(() { - _busy = true; - _error = null; - }); - - final state = await PhotoManager.requestPermissionExtend(); - if (!mounted) return; - _permissionState = state; - - if (!state.hasAccess) { - setState(() { - _busy = false; - _error = state == PermissionState.denied - ? 'Photo library access is required. Tap below to open Settings.' - : 'Cannot access photo library.'; - }); - return; - } - - final paths = await PhotoManager.getAssetPathList( - type: RequestType.image, - onlyAll: true, - hasAll: true, - ); - if (!mounted) return; - - if (paths.isEmpty) { - setState(() { - _busy = false; - _recentAlbum = null; - _totalCount = 0; - _assets.clear(); - _loadedPage = -1; - }); - return; - } - - _recentAlbum = paths.first; - _totalCount = await _recentAlbum!.assetCountAsync; - if (!mounted) return; - - if (_totalCount == 0) { - setState(() { - _assets.clear(); - _loadedPage = -1; - _busy = false; - }); - return; - } - - final first = await _recentAlbum!.getAssetListPaged(page: 0, size: _pageSize); - if (!mounted) return; - - setState(() { - _assets - ..clear() - ..addAll(first); - _loadedPage = 0; - _busy = false; - }); - } - - /// iOS:系统「补充照片」面板;Android 等:打开系统设置改权限 - Future _openManagePhotoAccess() async { - if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) { - try { - await PhotoManager.presentLimited(); - } catch (_) { - await PhotoManager.openSetting(); - return; - } - } else { - await PhotoManager.openSetting(); - return; - } - if (mounted) await _init(); - } - - void _maybeLoadMore() { - if (_loadingMore || _busy || _recentAlbum == null) return; - if (_assets.length >= _totalCount) return; - // 未 attach 时访问 .position 会抛错,导致点击 Generate 后卡死/闪退 - if (!_scrollController.hasClients) return; - final pos = _scrollController.position; - final maxExtent = pos.maxScrollExtent; - if (!maxExtent.isFinite || maxExtent <= 0) return; - if (pos.pixels < maxExtent - 380) return; - _loadMore(); - } - - Future _loadMore() async { - final album = _recentAlbum; - if (album == null || _loadingMore) return; - if (_assets.length >= _totalCount) return; - - setState(() => _loadingMore = true); - try { - final nextPage = _loadedPage + 1; - final list = - await album.getAssetListPaged(page: nextPage, size: _pageSize); - if (!mounted) return; - if (list.isNotEmpty) { - setState(() { - _assets.addAll(list); - _loadedPage = nextPage; - }); - } - } finally { - if (mounted) setState(() => _loadingMore = false); - } - } - - Future _onCamera() async { - if (!mounted) return; - // 不在弹层内调 pickImage:BottomSheet 与相机 Activity 叠加时,返回后 Surface 有概率无法恢复(全黑)。 - Navigator.of(context).pop(kAlbumPickerRequestCamera); - } - - Future _onAsset(AssetEntity e) async { - final f = await e.file; - if (!mounted) return; - if (f != null) { - Navigator.of(context).pop(f.path); - } else if (mounted) { - final limited = _permissionState == PermissionState.limited; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - limited - ? 'Cannot read this photo. It may not be included in your ' - 'current selection. Use "Manage photo access" or choose ' - 'another image.' - : 'Could not open this photo. Try another.', - ), - behavior: SnackBarBehavior.floating, - ), - ); - } - } - - bool get _showNoPhotosHelp => - !_busy && _error == null && _totalCount == 0; - - static const int _crossCount = 3; - static const double _spacing = 3; - @override Widget build(BuildContext context) { final bottomPad = MediaQuery.paddingOf(context).bottom; @@ -210,6 +24,7 @@ class _AlbumPickerSheetState extends State { clipBehavior: Clip.antiAlias, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.fromLTRB( @@ -240,93 +55,32 @@ class _AlbumPickerSheetState extends State { ), ), const Divider(height: 1, color: AppColors.border), - Expanded( - child: _busy - ? const Center( - child: CircularProgressIndicator(color: AppColors.primary), - ) - : _error != null - ? Center( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.xl), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _error!, - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.textSecondary, - ), - ), - const SizedBox(height: AppSpacing.lg), - TextButton( - onPressed: () async { - await PhotoManager.openSetting(); - }, - child: const Text('Open Settings'), - ), - TextButton( - onPressed: _init, - child: const Text('Try again'), - ), - ], - ), - ), - ) - : _showNoPhotosHelp - ? _NoPhotosInLibraryPanel( - isLimitedAccess: - _permissionState == PermissionState.limited, - onManageAccess: _openManagePhotoAccess, - onOpenSettings: () async { - await PhotoManager.openSetting(); - }, - onRetry: _init, - onUseCamera: _onCamera, - ) - : GridView.builder( - controller: _scrollController, - cacheExtent: 220, - padding: EdgeInsets.fromLTRB( - _spacing, - _spacing, - _spacing, - _spacing + bottomPad, - ), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: _crossCount, - mainAxisSpacing: _spacing, - crossAxisSpacing: _spacing, - childAspectRatio: 1, - ), - itemCount: - 1 + _assets.length + (_loadingMore ? 1 : 0), - itemBuilder: (context, index) { - if (index == 0) { - return _CameraGridTile(onTap: _onCamera); - } - final ai = index - 1; - if (ai >= _assets.length) { - return const Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.primary, - ), - ), - ); - } - final asset = _assets[ai]; - return GestureDetector( - onTap: () => _onAsset(asset), - child: _AlbumThumbnail(asset: asset), - ); - }, - ), + Padding( + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.lg, + AppSpacing.lg, + AppSpacing.xl + bottomPad, + ), + child: Column( + children: [ + _ActionCard( + icon: LucideIcons.camera, + title: 'Take photo', + subtitle: 'Use camera', + onTap: () => Navigator.of(context) + .pop(kAlbumPickerRequestCamera), + ), + const SizedBox(height: AppSpacing.md), + _ActionCard( + icon: LucideIcons.images, + title: 'Choose from gallery', + subtitle: 'System photo picker', + onTap: () => Navigator.of(context) + .pop(kAlbumPickerRequestGallery), + ), + ], + ), ), ], ), @@ -334,220 +88,77 @@ class _AlbumPickerSheetState extends State { } } -/// 权限为「部分照片」或相册对本应用不可见时:引导补充选图或去设置,避免只显示相机无说明 -class _NoPhotosInLibraryPanel extends StatelessWidget { - const _NoPhotosInLibraryPanel({ - required this.isLimitedAccess, - required this.onManageAccess, - required this.onOpenSettings, - required this.onRetry, - required this.onUseCamera, +class _ActionCard extends StatelessWidget { + const _ActionCard({ + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, }); - final bool isLimitedAccess; - final Future Function() onManageAccess; - final Future Function() onOpenSettings; - final Future Function() onRetry; - final Future Function() onUseCamera; - - @override - Widget build(BuildContext context) { - final bottom = MediaQuery.paddingOf(context).bottom; - return SingleChildScrollView( - padding: EdgeInsets.fromLTRB( - AppSpacing.xl, - AppSpacing.lg, - AppSpacing.xl, - AppSpacing.xl + bottom, - ), - child: Column( - children: [ - Icon( - LucideIcons.folder_open, - size: 48, - color: AppColors.textSecondary.withValues(alpha: 0.9), - ), - const SizedBox(height: AppSpacing.lg), - Text( - isLimitedAccess - ? 'No photos shared with this app' - : 'No photos available', - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 17, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: AppSpacing.md), - Text( - isLimitedAccess - ? 'You allowed access to only some photos, or none are ' - 'selected for this app. Add photos or change permission, ' - 'then try again.' - : 'We could not load any images. Check photo access in ' - 'Settings or try again.', - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 14, - height: 1.4, - color: AppColors.textSecondary, - ), - ), - const SizedBox(height: AppSpacing.xl), - SizedBox( - width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: AppColors.surface, - minimumSize: const Size(double.infinity, 48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - onPressed: () => onManageAccess(), - child: Text( - !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS - ? 'Select photos' - : 'Manage photo access', - ), - ), - ), - const SizedBox(height: AppSpacing.sm), - TextButton( - onPressed: () => onOpenSettings(), - child: const Text('Open Settings'), - ), - TextButton( - onPressed: () => onRetry(), - child: const Text('Try again'), - ), - const SizedBox(height: AppSpacing.md), - TextButton.icon( - onPressed: () => onUseCamera(), - icon: const Icon(LucideIcons.camera, size: 20), - label: const Text('Take photo with camera'), - ), - ], - ), - ); - } -} - -class _CameraGridTile extends StatelessWidget { - const _CameraGridTile({required this.onTap}); - + final IconData icon; + final String title; + final String subtitle; final VoidCallback onTap; @override Widget build(BuildContext context) { return Material( color: AppColors.surfaceAlt, + borderRadius: BorderRadius.circular(14), child: InkWell( onTap: onTap, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - LucideIcons.camera, - size: 32, - color: AppColors.primary.withValues(alpha: 0.9), - ), - const SizedBox(height: AppSpacing.sm), - Text( - 'Camera', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: AppColors.textSecondary.withValues(alpha: 0.95), + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.lg, + ), + child: Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, size: 26, color: AppColors.primary), ), - ), - ], + const SizedBox(width: AppSpacing.lg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: TextStyle( + fontSize: 13, + height: 1.3, + color: AppColors.textSecondary.withValues(alpha: 0.95), + ), + ), + ], + ), + ), + Icon( + LucideIcons.chevron_right, + size: 20, + color: AppColors.textSecondary.withValues(alpha: 0.7), + ), + ], + ), ), ), ); } } - -class _AlbumThumbnail extends StatefulWidget { - const _AlbumThumbnail({required this.asset}); - - final AssetEntity asset; - - @override - State<_AlbumThumbnail> createState() => _AlbumThumbnailState(); -} - -class _AlbumThumbnailState extends State<_AlbumThumbnail> { - Uint8List? _bytes; - bool _loadFailed = false; - - @override - void initState() { - super.initState(); - _load(); - } - - @override - void didUpdateWidget(covariant _AlbumThumbnail oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.asset.id != widget.asset.id) { - _bytes = null; - _loadFailed = false; - _load(); - } - } - - Future _load() async { - try { - final data = await widget.asset.thumbnailDataWithSize( - const ThumbnailSize.square(200), - ); - if (mounted) { - setState(() { - _bytes = data; - _loadFailed = data == null || data.isEmpty; - }); - } - } catch (_) { - if (mounted) { - setState(() { - _bytes = null; - _loadFailed = true; - }); - } - } - } - - @override - Widget build(BuildContext context) { - final b = _bytes; - return ColoredBox( - color: AppColors.surfaceAlt, - child: b != null && b.isNotEmpty - ? Image.memory( - b, - fit: BoxFit.cover, - gaplessPlayback: true, - filterQuality: FilterQuality.low, - ) - : _loadFailed - ? const Icon( - LucideIcons.image_off, - size: 28, - color: AppColors.textSecondary, - ) - : const Center( - child: SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.primary, - ), - ), - ), - ); - } -} diff --git a/pubspec.yaml b/pubspec.yaml index 433f7d0..bffcd52 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,7 +36,6 @@ dependencies: flutter_native_splash: ^2.4.7 android_id: ^0.5.1 visibility_detector: ^0.4.0+2 - photo_manager: ^3.9.0 image: ^4.5.4 camera: ^0.12.0+1 permission_handler: ^12.0.1