petsHero-AI/lib/features/generate_video/widgets/album_picker_sheet.dart
2026-03-29 23:53:24 +08:00

554 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<AlbumPickerSheet> createState() => _AlbumPickerSheetState();
}
class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
/// 首屏少一点,减轻同时解码缩略图的压力
static const int _pageSize = 36;
final ScrollController _scrollController = ScrollController();
bool _busy = true;
String? _error;
AssetPathEntity? _recentAlbum;
final List<AssetEntity> _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<void> _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<void> _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<void> _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<void> _onCamera() async {
if (!mounted) return;
// 不在弹层内调 pickImageBottomSheet 与相机 Activity 叠加时,返回后 Surface 有概率无法恢复(全黑)。
Navigator.of(context).pop<String>(kAlbumPickerRequestCamera);
}
Future<void> _onAsset(AssetEntity e) async {
final f = await e.file;
if (!mounted) return;
if (f != null) {
Navigator.of(context).pop<String>(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<String?>(),
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<void> Function() onManageAccess;
final Future<void> Function() onOpenSettings;
final Future<void> Function() onRetry;
final Future<void> 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<void> _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,
),
),
),
);
}
}