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'; /// [showModalBottomSheet] 返回此值时由**外层页面**调系统相机,避免 BottomSheet 与相机 Activity 叠放导致返回后黑屏/卡死。 const String kAlbumPickerRequestCamera = '__album_picker_camera__'; /// 底部弹层:首格为拍照,其余为相册图片(与常见 App 一致) class AlbumPickerSheet extends StatefulWidget { 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; return Material( color: AppColors.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), clipBehavior: Clip.antiAlias, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB( AppSpacing.sm, AppSpacing.sm, AppSpacing.sm, AppSpacing.md, ), child: Row( children: [ IconButton( onPressed: () => Navigator.of(context).pop(), icon: const Icon(LucideIcons.x, color: AppColors.textPrimary), ), const Expanded( child: Text( 'Choose photo', textAlign: TextAlign.center, style: TextStyle( fontSize: 17, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), ), const SizedBox(width: 48), ], ), ), 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), ); }, ), ), ], ), ); } } /// 权限为「部分照片」或相册对本应用不可见时:引导补充选图或去设置,避免只显示相机无说明 class _NoPhotosInLibraryPanel extends StatelessWidget { const _NoPhotosInLibraryPanel({ required this.isLimitedAccess, required this.onManageAccess, required this.onOpenSettings, required this.onRetry, required this.onUseCamera, }); 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 VoidCallback onTap; @override Widget build(BuildContext context) { return Material( color: AppColors.surfaceAlt, 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), ), ), ], ), ), ); } } 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, ), ), ), ); } }