优化:相册选择优化

This commit is contained in:
ivan 2026-03-29 19:54:56 +08:00
parent a8c9a59167
commit e86b001a27
8 changed files with 651 additions and 170 deletions

View File

@ -1,5 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="com.android.vending.BILLING" />
<application

View File

@ -48,6 +48,12 @@
@import in_app_purchase_storekit;
#endif
#if __has_include(<photo_manager/PhotoManagerPlugin.h>)
#import <photo_manager/PhotoManagerPlugin.h>
#else
@import photo_manager;
#endif
#if __has_include(<screen_secure/ScreenSecurePlugin.h>)
#import <screen_secure/ScreenSecurePlugin.h>
#else
@ -100,6 +106,7 @@
[GalPlugin registerWithRegistrar:[registry registrarForPlugin:@"GalPlugin"]];
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
[InAppPurchasePlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppPurchasePlugin"]];
[PhotoManagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PhotoManagerPlugin"]];
[ScreenSecurePlugin registerWithRegistrar:[registry registrarForPlugin:@"ScreenSecurePlugin"]];
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];

View File

@ -1,7 +1,7 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:visibility_detector/visibility_detector.dart';
import '../../core/api/api_config.dart';
import '../../core/api/services/image_api.dart';
@ -9,6 +9,7 @@ import '../../core/auth/auth_service.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_spacing.dart';
import '../../shared/widgets/top_nav_bar.dart';
import '../home/widgets/video_card.dart';
import 'models/gallery_task_item.dart';
import 'video_thumbnail_cache.dart';
@ -34,6 +35,36 @@ class _GalleryScreenState extends State<GalleryScreen> {
final ScrollController _scrollController = ScrollController();
static const int _pageSize = 20;
///
static const double _videoVisibilityThreshold = 0.15;
Set<int> _visibleVideoIndices = {};
final Set<int> _userPausedVideoIndices = {};
final Map<int, double> _cardVisibleFraction = {};
bool _visibilityReconcileScheduled = false;
/// [IndexedStack] Tab 0 [notifyNow]
/// Grid loading
/// + [HomeScreen._scheduleVisibilityRefresh]
void _scheduleGalleryVisibilityRefresh() {
if (!mounted || !widget.isActive) return;
void notify() {
if (!mounted || !widget.isActive) return;
VisibilityDetectorController.instance.notifyNow();
}
WidgetsBinding.instance.addPostFrameCallback((_) {
notify();
WidgetsBinding.instance.addPostFrameCallback((_) {
notify();
WidgetsBinding.instance.addPostFrameCallback((_) {
notify();
});
});
});
Future<void>.delayed(const Duration(milliseconds: 80), notify);
Future<void>.delayed(const Duration(milliseconds: 220), notify);
}
@override
void initState() {
super.initState();
@ -44,10 +75,13 @@ class _GalleryScreenState extends State<GalleryScreen> {
@override
void didUpdateWidget(covariant GalleryScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isActive && !oldWidget.isActive && !_hasLoadedOnce) {
if (widget.isActive && !oldWidget.isActive) {
_scheduleGalleryVisibilityRefresh();
if (!_hasLoadedOnce) {
_loadTasks(refresh: true);
}
}
}
@override
void dispose() {
@ -57,12 +91,54 @@ class _GalleryScreenState extends State<GalleryScreen> {
void _onScroll() {
if (_loadingMore || !_hasNext || _tasks.isEmpty) return;
if (!_scrollController.hasClients) return;
final pos = _scrollController.position;
if (pos.pixels >= pos.maxScrollExtent - 200) {
_loadTasks(refresh: false);
}
}
void _onGridCardVisibilityChanged(int index, VisibilityInfo info) {
if (!mounted) return;
if (info.visibleFraction >= _videoVisibilityThreshold) {
_cardVisibleFraction[index] = info.visibleFraction;
} else {
_cardVisibleFraction.remove(index);
}
if (_visibilityReconcileScheduled) return;
_visibilityReconcileScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_visibilityReconcileScheduled = false;
if (mounted) _reconcileVisibleVideoIndicesFromDetector();
});
}
void _reconcileVisibleVideoIndicesFromDetector() {
final items = _gridItems;
final next = <int>{};
for (final e in _cardVisibleFraction.entries) {
final i = e.key;
if (i < 0 || i >= items.length) continue;
if (e.value < _videoVisibilityThreshold) continue;
final url = items[i].videoUrl;
if (url != null && url.isNotEmpty) {
next.add(i);
}
}
_userPausedVideoIndices.removeWhere((i) => !next.contains(i));
if (!_setsEqual(next, _visibleVideoIndices)) {
setState(() => _visibleVideoIndices = next);
}
}
bool _setsEqual(Set<int> a, Set<int> b) {
if (a.length != b.length) return false;
for (final e in a) {
if (!b.contains(e)) return false;
}
return true;
}
Future<void> _loadTasks({bool refresh = true}) async {
if (refresh) {
setState(() {
@ -97,6 +173,9 @@ class _GalleryScreenState extends State<GalleryScreen> {
setState(() {
if (refresh) {
_tasks = list;
_visibleVideoIndices = {};
_userPausedVideoIndices.clear();
_cardVisibleFraction.clear();
} else {
_tasks = [..._tasks, ...list];
}
@ -106,6 +185,9 @@ class _GalleryScreenState extends State<GalleryScreen> {
_loadingMore = false;
_hasLoadedOnce = true;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _scheduleGalleryVisibilityRefresh();
});
} else {
setState(() {
if (refresh) _tasks = [];
@ -222,73 +304,58 @@ class _GalleryScreenState extends State<GalleryScreen> {
),
);
}
return _GalleryCard(
mediaItem: _gridItems[index],
onTap: () {
final media = _gridItems[index];
final videoUrl = media.videoUrl;
final hasVideo =
videoUrl != null && videoUrl.isNotEmpty;
final detectorKey =
'gallery_${index}_${videoUrl ?? media.imageUrl ?? ''}';
void openResult() {
Navigator.of(context).pushNamed(
'/result',
arguments: _gridItems[index],
);
},
);
},
),
),
),
);
},
),
arguments: media,
);
}
}
class _GalleryCard extends StatelessWidget {
const _GalleryCard({
required this.mediaItem,
required this.onTap,
});
final GalleryMediaItem mediaItem;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: LayoutBuilder(
builder: (context, constraints) {
return Container(
width: constraints.maxWidth,
height: constraints.maxHeight,
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: AppColors.border, width: 1),
boxShadow: const [
BoxShadow(
color: AppColors.shadowMedium,
blurRadius: 12,
offset: Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: mediaItem.isVideo
? _VideoThumbnailCover(videoUrl: mediaItem.videoUrl!)
: (mediaItem.imageUrl != null &&
mediaItem.imageUrl!.isNotEmpty)
? CachedNetworkImage(
imageUrl: mediaItem.imageUrl!,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
color: AppColors.surfaceAlt,
),
errorWidget: (_, __, ___) => Container(
color: AppColors.surfaceAlt,
),
return VisibilityDetector(
key: ValueKey(detectorKey),
onVisibilityChanged: hasVideo
? (info) =>
_onGridCardVisibilityChanged(
index, info)
: (_) {},
child: VideoCard(
key: ValueKey(detectorKey),
imageUrl: media.imageUrl ??
videoUrl ??
'',
cover: hasVideo
? _VideoThumbnailCover(
videoUrl: media.videoUrl!,
)
: Container(color: AppColors.surfaceAlt),
: null,
videoUrl: hasVideo ? videoUrl : null,
credits: '50',
showCreditsBadge: false,
showBottomGenerateButton: false,
isActive: widget.isActive &&
hasVideo &&
_visibleVideoIndices
.contains(index) &&
!_userPausedVideoIndices
.contains(index),
onPlayRequested: () => setState(() =>
_userPausedVideoIndices
.remove(index)),
onStopRequested: () => setState(() =>
_userPausedVideoIndices
.add(index)),
onGenerateSimilar: openResult,
),
);
},
),
),
),
);
},

View File

@ -5,7 +5,6 @@ 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';
@ -19,6 +18,7 @@ import '../../features/home/models/task_item.dart';
import '../../shared/widgets/top_nav_bar.dart';
import '../../core/api/services/image_api.dart';
import 'widgets/album_picker_sheet.dart';
/// Generate Video screen - matches Pencil mmLB5
class GenerateVideoScreen extends StatefulWidget {
@ -79,36 +79,18 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
return;
}
final source = await showModalBottomSheet<ImageSource>(
final path = await showModalBottomSheet<String>(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(LucideIcons.image),
title: const Text('Choose from gallery'),
onTap: () => Navigator.pop(context, ImageSource.gallery),
),
ListTile(
leading: const Icon(LucideIcons.camera),
title: const Text('Take photo'),
onTap: () => Navigator.pop(context, ImageSource.camera),
),
],
),
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) => SizedBox(
height: MediaQuery.sizeOf(ctx).height * 0.92,
child: const AlbumPickerSheet(),
),
);
if (source == null || !mounted) return;
if (path == null || path.isEmpty || !mounted) return;
final picker = ImagePicker();
final picked = await picker.pickImage(
source: source,
imageQuality: 85,
);
if (picked == null || !mounted) return;
final file = File(picked.path);
final file = File(path);
await _runGenerationApi(file);
}

View File

@ -52,6 +52,9 @@ class _GenerationResultScreenState extends State<GenerationResultScreen> {
final controller = VideoPlayerController.file(file);
await controller.initialize();
if (!mounted) return;
await controller.setLooping(true);
await controller.play();
if (!mounted) return;
setState(() {
_videoController = controller;
_videoLoading = false;

View File

@ -0,0 +1,401 @@
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<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;
@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;
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<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 {
final picker = ImagePicker();
final x = await picker.pickImage(
source: ImageSource.camera,
imageQuality: 85,
);
if (!mounted) return;
if (x != null) {
Navigator.of(context).pop<String>(x.path);
}
}
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) {
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<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'),
),
],
),
),
)
: 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<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,
),
),
),
);
}
}

View File

@ -17,7 +17,10 @@ class VideoCard extends StatefulWidget {
super.key,
required this.imageUrl,
this.videoUrl,
this.cover,
this.credits = '50',
this.showCreditsBadge = true,
this.showBottomGenerateButton = true,
required this.isActive,
required this.onPlayRequested,
required this.onStopRequested,
@ -26,7 +29,11 @@ class VideoCard extends StatefulWidget {
final String imageUrl;
final String? videoUrl;
/// [CachedNetworkImage](imageUrl)
final Widget? cover;
final String credits;
final bool showCreditsBadge;
final bool showBottomGenerateButton;
final bool isActive;
final VoidCallback onPlayRequested;
final VoidCallback onStopRequested;
@ -203,7 +210,13 @@ class _VideoCardState extends State<VideoCard> {
if (videoPath == null || videoPath.isEmpty) {
throw StateError('Video file stream ended without FileInfo');
}
setState(_clearBottomProgress);
// 2px DownloadProgress
if (mounted) {
setState(() {
_showBottomProgress = true;
_bottomProgressFraction = null;
});
}
_controller = VideoPlayerController.file(
File(videoPath),
@ -211,9 +224,11 @@ class _VideoCardState extends State<VideoCard> {
);
await _controller!.initialize();
if (!mounted || gen != _loadGen || !widget.isActive) {
if (mounted) setState(_clearBottomProgress);
_disposeController();
return;
}
if (mounted) setState(_clearBottomProgress);
_controller!.setVolume(0);
_controller!.setLooping(true);
_controller!.addListener(_onVideoUpdate);
@ -274,7 +289,8 @@ class _VideoCardState extends State<VideoCard> {
child: GestureDetector(
onTap: widget.onGenerateSimilar,
behavior: HitTestBehavior.opaque,
child: CachedNetworkImage(
child: widget.cover ??
CachedNetworkImage(
imageUrl: widget.imageUrl,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
@ -313,6 +329,7 @@ class _VideoCardState extends State<VideoCard> {
),
),
),
if (widget.showCreditsBadge)
Positioned(
top: 12,
right: 12,
@ -349,6 +366,7 @@ class _VideoCardState extends State<VideoCard> {
),
),
),
if (widget.showBottomGenerateButton)
Positioned(
bottom: 16,
left: 12,

View File

@ -36,6 +36,7 @@ dependencies:
flutter_native_splash: ^2.4.7
android_id: ^0.5.1
visibility_detector: ^0.4.0+2
photo_manager: ^3.9.0
dev_dependencies:
flutter_test: