优化:相册选择优化
This commit is contained in:
parent
a8c9a59167
commit
e86b001a27
@ -1,5 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<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="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||||
<uses-permission android:name="com.android.vending.BILLING" />
|
<uses-permission android:name="com.android.vending.BILLING" />
|
||||||
<application
|
<application
|
||||||
|
|||||||
@ -48,6 +48,12 @@
|
|||||||
@import in_app_purchase_storekit;
|
@import in_app_purchase_storekit;
|
||||||
#endif
|
#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>)
|
#if __has_include(<screen_secure/ScreenSecurePlugin.h>)
|
||||||
#import <screen_secure/ScreenSecurePlugin.h>
|
#import <screen_secure/ScreenSecurePlugin.h>
|
||||||
#else
|
#else
|
||||||
@ -100,6 +106,7 @@
|
|||||||
[GalPlugin registerWithRegistrar:[registry registrarForPlugin:@"GalPlugin"]];
|
[GalPlugin registerWithRegistrar:[registry registrarForPlugin:@"GalPlugin"]];
|
||||||
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
||||||
[InAppPurchasePlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppPurchasePlugin"]];
|
[InAppPurchasePlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppPurchasePlugin"]];
|
||||||
|
[PhotoManagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PhotoManagerPlugin"]];
|
||||||
[ScreenSecurePlugin registerWithRegistrar:[registry registrarForPlugin:@"ScreenSecurePlugin"]];
|
[ScreenSecurePlugin registerWithRegistrar:[registry registrarForPlugin:@"ScreenSecurePlugin"]];
|
||||||
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
|
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
|
||||||
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
|
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
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/api_config.dart';
|
||||||
import '../../core/api/services/image_api.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_colors.dart';
|
||||||
import '../../core/theme/app_spacing.dart';
|
import '../../core/theme/app_spacing.dart';
|
||||||
import '../../shared/widgets/top_nav_bar.dart';
|
import '../../shared/widgets/top_nav_bar.dart';
|
||||||
|
import '../home/widgets/video_card.dart';
|
||||||
|
|
||||||
import 'models/gallery_task_item.dart';
|
import 'models/gallery_task_item.dart';
|
||||||
import 'video_thumbnail_cache.dart';
|
import 'video_thumbnail_cache.dart';
|
||||||
@ -34,6 +35,36 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
|||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
static const int _pageSize = 20;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@ -44,8 +75,11 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
|||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant GalleryScreen oldWidget) {
|
void didUpdateWidget(covariant GalleryScreen oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (widget.isActive && !oldWidget.isActive && !_hasLoadedOnce) {
|
if (widget.isActive && !oldWidget.isActive) {
|
||||||
_loadTasks(refresh: true);
|
_scheduleGalleryVisibilityRefresh();
|
||||||
|
if (!_hasLoadedOnce) {
|
||||||
|
_loadTasks(refresh: true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,12 +91,54 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
|||||||
|
|
||||||
void _onScroll() {
|
void _onScroll() {
|
||||||
if (_loadingMore || !_hasNext || _tasks.isEmpty) return;
|
if (_loadingMore || !_hasNext || _tasks.isEmpty) return;
|
||||||
|
if (!_scrollController.hasClients) return;
|
||||||
final pos = _scrollController.position;
|
final pos = _scrollController.position;
|
||||||
if (pos.pixels >= pos.maxScrollExtent - 200) {
|
if (pos.pixels >= pos.maxScrollExtent - 200) {
|
||||||
_loadTasks(refresh: false);
|
_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 {
|
Future<void> _loadTasks({bool refresh = true}) async {
|
||||||
if (refresh) {
|
if (refresh) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@ -97,6 +173,9 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
if (refresh) {
|
if (refresh) {
|
||||||
_tasks = list;
|
_tasks = list;
|
||||||
|
_visibleVideoIndices = {};
|
||||||
|
_userPausedVideoIndices.clear();
|
||||||
|
_cardVisibleFraction.clear();
|
||||||
} else {
|
} else {
|
||||||
_tasks = [..._tasks, ...list];
|
_tasks = [..._tasks, ...list];
|
||||||
}
|
}
|
||||||
@ -106,6 +185,9 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
|||||||
_loadingMore = false;
|
_loadingMore = false;
|
||||||
_hasLoadedOnce = true;
|
_hasLoadedOnce = true;
|
||||||
});
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) _scheduleGalleryVisibilityRefresh();
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setState(() {
|
setState(() {
|
||||||
if (refresh) _tasks = [];
|
if (refresh) _tasks = [];
|
||||||
@ -222,14 +304,54 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return _GalleryCard(
|
final media = _gridItems[index];
|
||||||
mediaItem: _gridItems[index],
|
final videoUrl = media.videoUrl;
|
||||||
onTap: () {
|
final hasVideo =
|
||||||
Navigator.of(context).pushNamed(
|
videoUrl != null && videoUrl.isNotEmpty;
|
||||||
'/result',
|
final detectorKey =
|
||||||
arguments: _gridItems[index],
|
'gallery_${index}_${videoUrl ?? media.imageUrl ?? ''}';
|
||||||
);
|
void openResult() {
|
||||||
},
|
Navigator.of(context).pushNamed(
|
||||||
|
'/result',
|
||||||
|
arguments: media,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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!,
|
||||||
|
)
|
||||||
|
: 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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -242,61 +364,6 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Container(color: AppColors.surfaceAlt),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _VideoThumbnailCover extends StatelessWidget {
|
class _VideoThumbnailCover extends StatelessWidget {
|
||||||
const _VideoThumbnailCover({required this.videoUrl});
|
const _VideoThumbnailCover({required this.videoUrl});
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:image_picker/image_picker.dart';
|
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
import '../../core/auth/auth_service.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 '../../shared/widgets/top_nav_bar.dart';
|
||||||
|
|
||||||
import '../../core/api/services/image_api.dart';
|
import '../../core/api/services/image_api.dart';
|
||||||
|
import 'widgets/album_picker_sheet.dart';
|
||||||
|
|
||||||
/// Generate Video screen - matches Pencil mmLB5
|
/// Generate Video screen - matches Pencil mmLB5
|
||||||
class GenerateVideoScreen extends StatefulWidget {
|
class GenerateVideoScreen extends StatefulWidget {
|
||||||
@ -79,36 +79,18 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final source = await showModalBottomSheet<ImageSource>(
|
final path = await showModalBottomSheet<String>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => SafeArea(
|
isScrollControlled: true,
|
||||||
child: Column(
|
backgroundColor: Colors.transparent,
|
||||||
mainAxisSize: MainAxisSize.min,
|
builder: (ctx) => SizedBox(
|
||||||
children: [
|
height: MediaQuery.sizeOf(ctx).height * 0.92,
|
||||||
ListTile(
|
child: const AlbumPickerSheet(),
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (source == null || !mounted) return;
|
if (path == null || path.isEmpty || !mounted) return;
|
||||||
|
|
||||||
final picker = ImagePicker();
|
final file = File(path);
|
||||||
final picked = await picker.pickImage(
|
|
||||||
source: source,
|
|
||||||
imageQuality: 85,
|
|
||||||
);
|
|
||||||
if (picked == null || !mounted) return;
|
|
||||||
|
|
||||||
final file = File(picked.path);
|
|
||||||
await _runGenerationApi(file);
|
await _runGenerationApi(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -52,6 +52,9 @@ class _GenerationResultScreenState extends State<GenerationResultScreen> {
|
|||||||
final controller = VideoPlayerController.file(file);
|
final controller = VideoPlayerController.file(file);
|
||||||
await controller.initialize();
|
await controller.initialize();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
await controller.setLooping(true);
|
||||||
|
await controller.play();
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_videoController = controller;
|
_videoController = controller;
|
||||||
_videoLoading = false;
|
_videoLoading = false;
|
||||||
|
|||||||
401
lib/features/generate_video/widgets/album_picker_sheet.dart
Normal file
401
lib/features/generate_video/widgets/album_picker_sheet.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,7 +17,10 @@ class VideoCard extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.imageUrl,
|
required this.imageUrl,
|
||||||
this.videoUrl,
|
this.videoUrl,
|
||||||
|
this.cover,
|
||||||
this.credits = '50',
|
this.credits = '50',
|
||||||
|
this.showCreditsBadge = true,
|
||||||
|
this.showBottomGenerateButton = true,
|
||||||
required this.isActive,
|
required this.isActive,
|
||||||
required this.onPlayRequested,
|
required this.onPlayRequested,
|
||||||
required this.onStopRequested,
|
required this.onStopRequested,
|
||||||
@ -26,7 +29,11 @@ class VideoCard extends StatefulWidget {
|
|||||||
|
|
||||||
final String imageUrl;
|
final String imageUrl;
|
||||||
final String? videoUrl;
|
final String? videoUrl;
|
||||||
|
/// 非空时用自定义封面(如相册视频缩略图),替代 [CachedNetworkImage](imageUrl)
|
||||||
|
final Widget? cover;
|
||||||
final String credits;
|
final String credits;
|
||||||
|
final bool showCreditsBadge;
|
||||||
|
final bool showBottomGenerateButton;
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
final VoidCallback onPlayRequested;
|
final VoidCallback onPlayRequested;
|
||||||
final VoidCallback onStopRequested;
|
final VoidCallback onStopRequested;
|
||||||
@ -203,7 +210,13 @@ class _VideoCardState extends State<VideoCard> {
|
|||||||
if (videoPath == null || videoPath.isEmpty) {
|
if (videoPath == null || videoPath.isEmpty) {
|
||||||
throw StateError('Video file stream ended without FileInfo');
|
throw StateError('Video file stream ended without FileInfo');
|
||||||
}
|
}
|
||||||
setState(_clearBottomProgress);
|
// 下载结束后到解码完成前:底部 2px 不确定进度(缓存直出时下载阶段可能无 DownloadProgress)
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_showBottomProgress = true;
|
||||||
|
_bottomProgressFraction = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
_controller = VideoPlayerController.file(
|
_controller = VideoPlayerController.file(
|
||||||
File(videoPath),
|
File(videoPath),
|
||||||
@ -211,9 +224,11 @@ class _VideoCardState extends State<VideoCard> {
|
|||||||
);
|
);
|
||||||
await _controller!.initialize();
|
await _controller!.initialize();
|
||||||
if (!mounted || gen != _loadGen || !widget.isActive) {
|
if (!mounted || gen != _loadGen || !widget.isActive) {
|
||||||
|
if (mounted) setState(_clearBottomProgress);
|
||||||
_disposeController();
|
_disposeController();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (mounted) setState(_clearBottomProgress);
|
||||||
_controller!.setVolume(0);
|
_controller!.setVolume(0);
|
||||||
_controller!.setLooping(true);
|
_controller!.setLooping(true);
|
||||||
_controller!.addListener(_onVideoUpdate);
|
_controller!.addListener(_onVideoUpdate);
|
||||||
@ -274,16 +289,17 @@ class _VideoCardState extends State<VideoCard> {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: widget.onGenerateSimilar,
|
onTap: widget.onGenerateSimilar,
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
child: CachedNetworkImage(
|
child: widget.cover ??
|
||||||
imageUrl: widget.imageUrl,
|
CachedNetworkImage(
|
||||||
fit: BoxFit.cover,
|
imageUrl: widget.imageUrl,
|
||||||
placeholder: (_, __) => Container(
|
fit: BoxFit.cover,
|
||||||
color: AppColors.surfaceAlt,
|
placeholder: (_, __) => Container(
|
||||||
),
|
color: AppColors.surfaceAlt,
|
||||||
errorWidget: (_, __, ___) => Container(
|
),
|
||||||
color: AppColors.surfaceAlt,
|
errorWidget: (_, __, ___) => Container(
|
||||||
),
|
color: AppColors.surfaceAlt,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (showVideoLayer)
|
if (showVideoLayer)
|
||||||
@ -313,83 +329,85 @@ class _VideoCardState extends State<VideoCard> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
if (widget.showCreditsBadge)
|
||||||
top: 12,
|
Positioned(
|
||||||
right: 12,
|
top: 12,
|
||||||
child: Container(
|
right: 12,
|
||||||
height: 24,
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
height: 24,
|
||||||
horizontal: 8,
|
padding: const EdgeInsets.symmetric(
|
||||||
vertical: 4,
|
horizontal: 8,
|
||||||
),
|
vertical: 4,
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
color: AppColors.overlayDark,
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(14),
|
color: AppColors.overlayDark,
|
||||||
),
|
borderRadius: BorderRadius.circular(14),
|
||||||
child: Row(
|
),
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
const Icon(
|
children: [
|
||||||
LucideIcons.sparkles,
|
const Icon(
|
||||||
size: 12,
|
LucideIcons.sparkles,
|
||||||
color: AppColors.surface,
|
size: 12,
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
widget.credits,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: AppColors.surface,
|
color: AppColors.surface,
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontFamily: 'Inter',
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 4),
|
||||||
],
|
Text(
|
||||||
),
|
widget.credits,
|
||||||
),
|
style: const TextStyle(
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
bottom: 16,
|
|
||||||
left: 12,
|
|
||||||
right: 12,
|
|
||||||
child: Center(
|
|
||||||
child: IntrinsicWidth(
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: widget.onGenerateSimilar,
|
|
||||||
child: Container(
|
|
||||||
height: 24,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.primaryButtonFill,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(
|
|
||||||
color: AppColors.primary,
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
boxShadow: const [
|
|
||||||
BoxShadow(
|
|
||||||
color: AppColors.primaryButtonShadow,
|
|
||||||
blurRadius: 6,
|
|
||||||
offset: Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: const Text(
|
|
||||||
'Generate Similar',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppColors.surface,
|
color: AppColors.surface,
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
fontFamily: 'Inter',
|
fontFamily: 'Inter',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.showBottomGenerateButton)
|
||||||
|
Positioned(
|
||||||
|
bottom: 16,
|
||||||
|
left: 12,
|
||||||
|
right: 12,
|
||||||
|
child: Center(
|
||||||
|
child: IntrinsicWidth(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: widget.onGenerateSimilar,
|
||||||
|
child: Container(
|
||||||
|
height: 24,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primaryButtonFill,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.primary,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: AppColors.primaryButtonShadow,
|
||||||
|
blurRadius: 6,
|
||||||
|
offset: Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: const Text(
|
||||||
|
'Generate Similar',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.surface,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if (_showBottomProgress)
|
if (_showBottomProgress)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
|
|||||||
@ -36,6 +36,7 @@ dependencies:
|
|||||||
flutter_native_splash: ^2.4.7
|
flutter_native_splash: ^2.4.7
|
||||||
android_id: ^0.5.1
|
android_id: ^0.5.1
|
||||||
visibility_detector: ^0.4.0+2
|
visibility_detector: ^0.4.0+2
|
||||||
|
photo_manager: ^3.9.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user