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