import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:image_picker/image_picker.dart'; import 'package:photo_manager/photo_manager.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_spacing.dart'; /// 底部弹层:首格为拍照,其余为相册图片(与常见 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; @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; 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; }); return; } _recentAlbum = paths.first; _totalCount = await _recentAlbum!.assetCountAsync; final first = await _recentAlbum!.getAssetListPaged(page: 0, size: _pageSize); if (!mounted) return; setState(() { _assets ..clear() ..addAll(first); _loadedPage = 0; _busy = false; }); } 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 { final picker = ImagePicker(); final x = await picker.pickImage( source: ImageSource.camera, imageQuality: 85, ); if (!mounted) return; if (x != null) { Navigator.of(context).pop(x.path); } } 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) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Could not open this photo. Try another.'), behavior: SnackBarBehavior.floating, ), ); } } 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'), ), ], ), ), ) : 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 _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, ), ), ), ); } }