diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 752a04a..f8033e3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,7 @@ + + ) +#import +#else +@import photo_manager; +#endif + #if __has_include() #import #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"]]; diff --git a/lib/features/gallery/gallery_screen.dart b/lib/features/gallery/gallery_screen.dart index 8d00a17..845b604 100644 --- a/lib/features/gallery/gallery_screen.dart +++ b/lib/features/gallery/gallery_screen.dart @@ -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 { final ScrollController _scrollController = ScrollController(); static const int _pageSize = 20; + /// 与首页一致:足够可见比例的格子才自动静音循环播视频(允许多个) + static const double _videoVisibilityThreshold = 0.15; + Set _visibleVideoIndices = {}; + final Set _userPausedVideoIndices = {}; + final Map _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.delayed(const Duration(milliseconds: 80), notify); + Future.delayed(const Duration(milliseconds: 220), notify); + } + @override void initState() { super.initState(); @@ -44,8 +75,11 @@ class _GalleryScreenState extends State { @override void didUpdateWidget(covariant GalleryScreen oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.isActive && !oldWidget.isActive && !_hasLoadedOnce) { - _loadTasks(refresh: true); + if (widget.isActive && !oldWidget.isActive) { + _scheduleGalleryVisibilityRefresh(); + if (!_hasLoadedOnce) { + _loadTasks(refresh: true); + } } } @@ -57,12 +91,54 @@ class _GalleryScreenState extends State { 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 = {}; + 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 a, Set b) { + if (a.length != b.length) return false; + for (final e in a) { + if (!b.contains(e)) return false; + } + return true; + } + Future _loadTasks({bool refresh = true}) async { if (refresh) { setState(() { @@ -97,6 +173,9 @@ class _GalleryScreenState extends State { setState(() { if (refresh) { _tasks = list; + _visibleVideoIndices = {}; + _userPausedVideoIndices.clear(); + _cardVisibleFraction.clear(); } else { _tasks = [..._tasks, ...list]; } @@ -106,6 +185,9 @@ class _GalleryScreenState extends State { _loadingMore = false; _hasLoadedOnce = true; }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _scheduleGalleryVisibilityRefresh(); + }); } else { setState(() { if (refresh) _tasks = []; @@ -222,14 +304,54 @@ class _GalleryScreenState extends State { ), ); } - return _GalleryCard( - mediaItem: _gridItems[index], - onTap: () { - Navigator.of(context).pushNamed( - '/result', - arguments: _gridItems[index], - ); - }, + 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: 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 { } } -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 { const _VideoThumbnailCover({required this.videoUrl}); diff --git a/lib/features/generate_video/generate_video_screen.dart b/lib/features/generate_video/generate_video_screen.dart index 17654e7..da71513 100644 --- a/lib/features/generate_video/generate_video_screen.dart +++ b/lib/features/generate_video/generate_video_screen.dart @@ -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 { return; } - final source = await showModalBottomSheet( + final path = await showModalBottomSheet( 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); } diff --git a/lib/features/generate_video/generation_result_screen.dart b/lib/features/generate_video/generation_result_screen.dart index c241e34..7bd5985 100644 --- a/lib/features/generate_video/generation_result_screen.dart +++ b/lib/features/generate_video/generation_result_screen.dart @@ -52,6 +52,9 @@ class _GenerationResultScreenState extends State { 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; diff --git a/lib/features/generate_video/widgets/album_picker_sheet.dart b/lib/features/generate_video/widgets/album_picker_sheet.dart new file mode 100644 index 0000000..fb9d114 --- /dev/null +++ b/lib/features/generate_video/widgets/album_picker_sheet.dart @@ -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 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, + ), + ), + ), + ); + } +} diff --git a/lib/features/home/widgets/video_card.dart b/lib/features/home/widgets/video_card.dart index 17666ab..6437452 100644 --- a/lib/features/home/widgets/video_card.dart +++ b/lib/features/home/widgets/video_card.dart @@ -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 { 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 { ); 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,16 +289,17 @@ class _VideoCardState extends State { child: GestureDetector( onTap: widget.onGenerateSimilar, behavior: HitTestBehavior.opaque, - child: CachedNetworkImage( - imageUrl: widget.imageUrl, - fit: BoxFit.cover, - placeholder: (_, __) => Container( - color: AppColors.surfaceAlt, - ), - errorWidget: (_, __, ___) => Container( - color: AppColors.surfaceAlt, - ), - ), + child: widget.cover ?? + CachedNetworkImage( + imageUrl: widget.imageUrl, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + color: AppColors.surfaceAlt, + ), + errorWidget: (_, __, ___) => Container( + color: AppColors.surfaceAlt, + ), + ), ), ), if (showVideoLayer) @@ -313,83 +329,85 @@ class _VideoCardState extends State { ), ), ), - Positioned( - top: 12, - right: 12, - child: Container( - height: 24, - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: AppColors.overlayDark, - borderRadius: BorderRadius.circular(14), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - LucideIcons.sparkles, - size: 12, - color: AppColors.surface, - ), - const SizedBox(width: 4), - Text( - widget.credits, - style: const TextStyle( + if (widget.showCreditsBadge) + Positioned( + top: 12, + right: 12, + child: Container( + height: 24, + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppColors.overlayDark, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + LucideIcons.sparkles, + size: 12, color: AppColors.surface, - fontSize: 11, - fontWeight: FontWeight.w600, - fontFamily: 'Inter', ), - ), - ], - ), - ), - ), - 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( + const SizedBox(width: 4), + Text( + widget.credits, + style: const TextStyle( color: AppColors.surface, - fontSize: 12, + fontSize: 11, fontWeight: FontWeight.w600, 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) Positioned( left: 0, diff --git a/pubspec.yaml b/pubspec.yaml index 3c9c195..f868828 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: