diff --git a/ios/Runner/GeneratedPluginRegistrant.m b/ios/Runner/GeneratedPluginRegistrant.m index ef90571..517026c 100644 --- a/ios/Runner/GeneratedPluginRegistrant.m +++ b/ios/Runner/GeneratedPluginRegistrant.m @@ -18,11 +18,18 @@ @import sqflite_darwin; #endif +#if __has_include() +#import +#else +@import video_player_avfoundation; +#endif + @implementation GeneratedPluginRegistrant + (void)registerWithRegistry:(NSObject*)registry { [FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]]; [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; + [FVPVideoPlayerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FVPVideoPlayerPlugin"]]; } @end diff --git a/lib/app.dart b/lib/app.dart index e5c3dad..280518d 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; + import 'core/theme/app_theme.dart'; +import 'core/user/user_state.dart'; import 'features/gallery/gallery_screen.dart'; import 'features/generate_video/generate_progress_screen.dart'; import 'features/generate_video/generate_video_screen.dart'; import 'features/generate_video/generation_result_screen.dart'; import 'features/home/home_screen.dart'; +import 'features/home/models/task_item.dart'; import 'features/profile/profile_screen.dart'; import 'features/recharge/recharge_screen.dart'; import 'shared/widgets/bottom_nav_bar.dart'; @@ -22,21 +25,35 @@ class _AppState extends State { @override Widget build(BuildContext context) { - return MaterialApp( - title: 'AI Video App', - theme: AppTheme.light, - debugShowCheckedModeBanner: false, - initialRoute: '/', - routes: { - '/': (_) => _MainScaffold( - currentTab: _currentTab, - onTabSelected: (tab) => setState(() => _currentTab = tab), - ), - '/recharge': (_) => const RechargeScreen(), - '/generate': (_) => const GenerateVideoScreen(), - '/progress': (_) => const GenerateProgressScreen(), - '/result': (_) => const GenerationResultScreen(), - }, + return UserCreditsScope( + child: MaterialApp( + title: 'AI Video App', + theme: AppTheme.light, + debugShowCheckedModeBanner: false, + initialRoute: '/', + builder: (context, child) { + return SafeArea( + top: true, + left: false, + right: false, + bottom: false, + child: child ?? const SizedBox.shrink(), + ); + }, + routes: { + '/': (_) => _MainScaffold( + currentTab: _currentTab, + onTabSelected: (tab) => setState(() => _currentTab = tab), + ), + '/recharge': (_) => const RechargeScreen(), + '/generate': (ctx) { + final task = ModalRoute.of(ctx)?.settings.arguments as TaskItem?; + return GenerateVideoScreen(task: task); + }, + '/progress': (_) => const GenerateProgressScreen(), + '/result': (_) => const GenerationResultScreen(), + }, + ), ); } } diff --git a/lib/core/api/services/image_api.dart b/lib/core/api/services/image_api.dart index f7ec9e0..29619c1 100644 --- a/lib/core/api/services/image_api.dart +++ b/lib/core/api/services/image_api.dart @@ -5,6 +5,24 @@ import '../proxy_client.dart'; abstract final class ImageApi { static final _client = ApiClient.instance.proxy; + /// 获取图转视频分类列表 + static Future getCategoryList() async { + return _client.request( + path: '/v1/image/img2video/categories', + method: 'GET', + ); + } + + /// 获取图转视频任务列表 + /// insignia: categoryId + static Future getImg2VideoTasks({int? insignia}) async { + return _client.request( + path: '/v1/image/img2video/tasks', + method: 'GET', + queryParams: insignia != null ? {'insignia': insignia.toString()} : null, + ); + } + /// 获取推荐提示词 static Future getPromptRecommends({ required String sentinel, diff --git a/lib/core/auth/auth_service.dart b/lib/core/auth/auth_service.dart index 0616c16..cbf8f01 100644 --- a/lib/core/auth/auth_service.dart +++ b/lib/core/auth/auth_service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:crypto/crypto.dart'; @@ -7,6 +8,7 @@ import 'package:flutter/foundation.dart'; import '../api/api_client.dart'; import '../api/proxy_client.dart'; import '../api/services/user_api.dart'; +import '../user/user_state.dart'; /// 认证服务:APP 启动时执行快速登录 class AuthService { @@ -14,6 +16,12 @@ class AuthService { static const _tag = '[AuthService]'; + static Future? _loginFuture; + + /// 登录完成后的 Future,需鉴权接口应 await 此 Future 再请求 + static Future get loginComplete => + _loginFuture ?? Future.value(); + static void _log(String msg) { debugPrint('$_tag $msg'); } @@ -43,6 +51,10 @@ class AuthService { /// APP 启动时调用快速登录 /// 启动时网络可能未就绪,会延迟后重试 static Future init() async { + if (_loginFuture != null) return _loginFuture!; + final completer = Completer(); + _loginFuture = completer.future; + _log('init: 开始快速登录'); const maxRetries = 3; const retryDelay = Duration(seconds: 2); @@ -89,12 +101,19 @@ class AuthService { } else { _log('init: 响应中无 reevaluate (userToken)'); } + final credits = data?['reveal'] as int?; + if (credits != null) { + UserState.setCredits(credits); + _log('init: 已同步积分 $credits'); + } } else { _log('init: 登录失败'); } } catch (e, st) { _log('init: 异常 $e'); _log('init: 堆栈 $st'); + } finally { + if (!completer.isCompleted) completer.complete(); } } } diff --git a/lib/core/user/user_state.dart b/lib/core/user/user_state.dart new file mode 100644 index 0000000..982c77f --- /dev/null +++ b/lib/core/user/user_state.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +/// 用户积分等全局状态 +class UserState { + UserState._(); + + static final ValueNotifier credits = ValueNotifier(null); + + static void setCredits(int? value) { + credits.value = value; + } + + static String formatCredits(int? value) { + if (value == null) return '--'; + return value.toString().replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (m) => '${m[1]},', + ); + } +} + +/// 提供积分数据的 InheritedWidget +class UserCreditsData extends InheritedWidget { + const UserCreditsData({ + super.key, + required this.credits, + required super.child, + }); + + final int? credits; + + static UserCreditsData? of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + String get creditsDisplay => UserState.formatCredits(credits); + + @override + bool updateShouldNotify(UserCreditsData oldWidget) { + return oldWidget.credits != credits; + } +} + +/// 监听 UserState.credits 并向下提供 UserCreditsData +class UserCreditsScope extends StatefulWidget { + const UserCreditsScope({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _UserCreditsScopeState(); +} + +class _UserCreditsScopeState extends State { + @override + void initState() { + super.initState(); + UserState.credits.addListener(_onCreditsChanged); + } + + @override + void dispose() { + UserState.credits.removeListener(_onCreditsChanged); + super.dispose(); + } + + void _onCreditsChanged() { + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return UserCreditsData( + credits: UserState.credits.value, + child: widget.child, + ); + } +} diff --git a/lib/features/gallery/gallery_screen.dart b/lib/features/gallery/gallery_screen.dart index 3b55003..72b33fe 100644 --- a/lib/features/gallery/gallery_screen.dart +++ b/lib/features/gallery/gallery_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; + import '../../core/theme/app_colors.dart'; +import '../../core/user/user_state.dart'; import '../../core/theme/app_spacing.dart'; import '../../shared/widgets/top_nav_bar.dart'; @@ -23,7 +25,7 @@ class GalleryScreen extends StatelessWidget { preferredSize: const Size.fromHeight(56), child: TopNavBar( title: 'Gallery', - credits: '1,280', + credits: UserCreditsData.of(context)?.creditsDisplay ?? '--', onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'), ), ), diff --git a/lib/features/generate_video/generate_video_screen.dart b/lib/features/generate_video/generate_video_screen.dart index 867a098..80781b7 100644 --- a/lib/features/generate_video/generate_video_screen.dart +++ b/lib/features/generate_video/generate_video_screen.dart @@ -1,13 +1,37 @@ +import 'dart:developer' as developer; + import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; + import '../../core/theme/app_colors.dart'; +import '../../core/user/user_state.dart'; import '../../core/theme/app_spacing.dart'; import '../../core/theme/app_typography.dart'; +import '../../features/home/models/task_item.dart'; import '../../shared/widgets/top_nav_bar.dart'; /// Generate Video screen - matches Pencil mmLB5 -class GenerateVideoScreen extends StatelessWidget { - const GenerateVideoScreen({super.key}); +class GenerateVideoScreen extends StatefulWidget { + const GenerateVideoScreen({super.key, this.task}); + + final TaskItem? task; + + @override + State createState() => _GenerateVideoScreenState(); +} + +class _GenerateVideoScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + developer.log( + 'GenerateVideoScreen opened with task: ${widget.task}', + name: 'GenerateVideoScreen', + ); + debugPrint('[GenerateVideoScreen] task: ${widget.task}'); + }); + } @override Widget build(BuildContext context) { @@ -17,8 +41,10 @@ class GenerateVideoScreen extends StatelessWidget { preferredSize: const Size.fromHeight(56), child: TopNavBar( title: 'Generate Video', + credits: UserCreditsData.of(context)?.creditsDisplay ?? '--', showBackButton: true, onBack: () => Navigator.of(context).pop(), + onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'), ), ), body: SingleChildScrollView( @@ -26,7 +52,9 @@ class GenerateVideoScreen extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _CreditsCard(credits: '1,280'), + _CreditsCard( + credits: UserCreditsData.of(context)?.creditsDisplay ?? '--', + ), const SizedBox(height: AppSpacing.xxl), _UploadArea(onUpload: () {}), const SizedBox(height: AppSpacing.xxl), diff --git a/lib/features/home/home_screen.dart b/lib/features/home/home_screen.dart index 028c8e4..65d85e7 100644 --- a/lib/features/home/home_screen.dart +++ b/lib/features/home/home_screen.dart @@ -1,10 +1,16 @@ import 'package:flutter/material.dart'; + +import '../../core/api/services/image_api.dart'; +import '../../core/user/user_state.dart'; +import '../../core/auth/auth_service.dart'; import '../../core/theme/app_spacing.dart'; import '../../shared/widgets/top_nav_bar.dart'; +import 'models/category_item.dart'; +import 'models/task_item.dart'; import 'widgets/home_tab_row.dart'; import 'widgets/video_card.dart'; -/// AI Video App home screen - matches Pencil bi8Au +/// AI Video App home screen - tab 来自分类接口,Grid 来自任务列表接口 class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -13,16 +19,67 @@ class HomeScreen extends StatefulWidget { } class _HomeScreenState extends State { - HomeTab _selectedTab = HomeTab.all; + List _categories = []; + CategoryItem? _selectedCategory; + List _tasks = []; + bool _categoriesLoading = true; + bool _tasksLoading = false; + int? _activeCardIndex; - static const _placeholderImages = [ - 'https://images.unsplash.com/photo-1763929272543-0df093e4f659?w=400', - 'https://images.unsplash.com/photo-1703592819695-ea63799b7315?w=400', - 'https://images.unsplash.com/photo-1764787435677-1321e12559e3?w=400', - 'https://images.unsplash.com/photo-1759264244741-7175af0b7e75?w=400', - 'https://images.unsplash.com/photo-1574717024653-61fd2cf4d44d?w=400', - 'https://images.unsplash.com/photo-1611162617474-5b21e879e113?w=400', - ]; + @override + void initState() { + super.initState(); + _loadCategories(); + } + + Future _loadCategories() async { + setState(() => _categoriesLoading = true); + await AuthService.loginComplete; + if (!mounted) return; + final res = await ImageApi.getCategoryList(); + if (mounted) { + if (res.isSuccess && res.data is List) { + final list = (res.data as List) + .map((e) => CategoryItem.fromJson(e as Map)) + .toList(); + setState(() { + _categories = list; + _selectedCategory = list.isNotEmpty ? list.first : null; + if (_selectedCategory != null) _loadTasks(_selectedCategory!.id); + }); + } else { + setState(() => _categories = []); + } + setState(() => _categoriesLoading = false); + } + } + + Future _loadTasks(int categoryId) async { + setState(() => _tasksLoading = true); + final res = await ImageApi.getImg2VideoTasks(insignia: categoryId); + if (mounted) { + if (res.isSuccess && res.data is List) { + final list = (res.data as List) + .map((e) => TaskItem.fromJson(e as Map)) + .toList(); + setState(() { + _tasks = list; + _activeCardIndex = null; + }); + } else { + setState(() => _tasks = []); + } + setState(() => _tasksLoading = false); + } + } + + void _onTabChanged(CategoryItem c) { + setState(() => _selectedCategory = c); + _loadTasks(c.id); + } + + static const _placeholderImage = + 'https://images.unsplash.com/photo-1763929272543-0df093e4f659?w=400'; @override Widget build(BuildContext context) { @@ -32,7 +89,7 @@ class _HomeScreenState extends State { preferredSize: const Size.fromHeight(56), child: TopNavBar( title: 'AI Video', - credits: '1,280', + credits: UserCreditsData.of(context)?.creditsDisplay ?? '--', onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'), ), ), @@ -43,44 +100,59 @@ class _HomeScreenState extends State { horizontal: AppSpacing.screenPadding, vertical: AppSpacing.xs, ), - child: HomeTabRow( - selectedTab: _selectedTab, - onTabChanged: (tab) => setState(() => _selectedTab = tab), - ), + child: _categoriesLoading + ? const SizedBox(height: 40, child: Center(child: CircularProgressIndicator())) + : HomeTabRow( + categories: _categories, + selectedId: _selectedCategory?.id ?? -1, + onTabChanged: _onTabChanged, + ), ), Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - return Center( - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: 390, - ), - child: GridView.builder( - padding: const EdgeInsets.fromLTRB( - AppSpacing.screenPadding, - AppSpacing.xl, - AppSpacing.screenPadding, - AppSpacing.screenPaddingLarge, - ), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 165 / 248, - mainAxisSpacing: AppSpacing.xl, - crossAxisSpacing: AppSpacing.xl, - ), - itemCount: _placeholderImages.length, - itemBuilder: (context, index) => VideoCard( - imageUrl: _placeholderImages[index], - onGenerateSimilar: () => - Navigator.of(context).pushNamed('/generate'), - ), - ), + child: _tasksLoading + ? const Center(child: CircularProgressIndicator()) + : LayoutBuilder( + builder: (context, constraints) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 390), + child: GridView.builder( + padding: const EdgeInsets.fromLTRB( + AppSpacing.screenPadding, + AppSpacing.xl, + AppSpacing.screenPadding, + AppSpacing.screenPaddingLarge, + ), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 165 / 248, + mainAxisSpacing: AppSpacing.xl, + crossAxisSpacing: AppSpacing.xl, + ), + itemCount: _tasks.length, + itemBuilder: (context, index) { + final task = _tasks[index]; + return VideoCard( + imageUrl: task.previewImageUrl ?? _placeholderImage, + videoUrl: task.previewVideoUrl, + isActive: _activeCardIndex == index, + onPlayRequested: () => + setState(() => _activeCardIndex = index), + onStopRequested: () => + setState(() => _activeCardIndex = null), + onGenerateSimilar: () => + Navigator.of(context).pushNamed( + '/generate', + arguments: task, + ), + ); + }, + ), + ), + ); + }, ), - ); - }, - ), ), ], ), diff --git a/lib/features/home/models/category_item.dart b/lib/features/home/models/category_item.dart new file mode 100644 index 0000000..69eb7a4 --- /dev/null +++ b/lib/features/home/models/category_item.dart @@ -0,0 +1,20 @@ +/// 分类项(V2 字段:federation=id, brigade=name, greylist=icon) +class CategoryItem { + const CategoryItem({ + required this.id, + required this.name, + this.icon, + }); + + final int id; + final String name; + final String? icon; + + factory CategoryItem.fromJson(Map json) { + return CategoryItem( + id: json['federation'] as int? ?? 0, + name: json['brigade'] as String? ?? '', + icon: json['greylist'] as String?, + ); + } +} diff --git a/lib/features/home/models/task_item.dart b/lib/features/home/models/task_item.dart new file mode 100644 index 0000000..ceded24 --- /dev/null +++ b/lib/features/home/models/task_item.dart @@ -0,0 +1,41 @@ +/// 图转视频任务项(V2 字段映射) +class TaskItem { + const TaskItem({ + required this.templateName, + required this.title, + this.previewImageUrl, + this.previewVideoUrl, + this.imageCount = 0, + this.taskType, + this.needopt = false, + }); + + final String templateName; + final String title; + final String? previewImageUrl; + final String? previewVideoUrl; + final int imageCount; + final String? taskType; + final bool needopt; + + @override + String toString() => + 'TaskItem(templateName: $templateName, title: $title, previewImageUrl: $previewImageUrl, ' + 'previewVideoUrl: $previewVideoUrl, imageCount: $imageCount, taskType: $taskType, needopt: $needopt)'; + + factory TaskItem.fromJson(Map json) { + final extract = json['extract'] as Map?; + final preempt = json['preempt'] as Map?; + final imgUrl = extract?['digitize'] as String?; + final videoUrl = preempt?['digitize'] as String?; + return TaskItem( + templateName: json['congregation'] as String? ?? '', + title: json['glossary'] as String? ?? '', + previewImageUrl: imgUrl, + previewVideoUrl: videoUrl, + imageCount: json['simplify'] as int? ?? 0, + taskType: json['cipher'] as String?, + needopt: json['allowance'] as bool? ?? false, + ); + } +} diff --git a/lib/features/home/widgets/home_tab_row.dart b/lib/features/home/widgets/home_tab_row.dart index ba22579..08bf1f2 100644 --- a/lib/features/home/widgets/home_tab_row.dart +++ b/lib/features/home/widgets/home_tab_row.dart @@ -1,47 +1,41 @@ import 'package:flutter/material.dart'; + import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_spacing.dart'; import '../../../core/theme/app_typography.dart'; +import '../models/category_item.dart'; -enum HomeTab { all, trending, newTab } - -/// Tab row for home screen - matches Pencil tabRow +/// Tab row for home screen - 使用分类列表接口数据 class HomeTabRow extends StatelessWidget { const HomeTabRow({ super.key, - required this.selectedTab, + required this.categories, + required this.selectedId, required this.onTabChanged, }); - final HomeTab selectedTab; - final ValueChanged onTabChanged; + final List categories; + final int selectedId; + final ValueChanged onTabChanged; @override Widget build(BuildContext context) { return SizedBox( height: 40, - child: Padding( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(vertical: 4), child: Row( children: [ - _TabChip( - label: 'All', - isSelected: selectedTab == HomeTab.all, - onTap: () => onTabChanged(HomeTab.all), - ), - const SizedBox(width: AppSpacing.md), - _TabChip( - label: 'Trending', - isSelected: selectedTab == HomeTab.trending, - onTap: () => onTabChanged(HomeTab.trending), - ), - const SizedBox(width: AppSpacing.md), - _TabChip( - label: 'New', - isSelected: selectedTab == HomeTab.newTab, - onTap: () => onTabChanged(HomeTab.newTab), - ), - ], + for (var i = 0; i < categories.length; i++) ...[ + if (i > 0) const SizedBox(width: AppSpacing.md), + _TabChip( + label: categories[i].name, + isSelected: selectedId == categories[i].id, + onTap: () => onTabChanged(categories[i]), + ), + ], + ], ), ), ); diff --git a/lib/features/home/widgets/video_card.dart b/lib/features/home/widgets/video_card.dart index d66a96c..5c67976 100644 --- a/lib/features/home/widgets/video_card.dart +++ b/lib/features/home/widgets/video_card.dart @@ -1,26 +1,156 @@ import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:video_player/video_player.dart'; + import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_spacing.dart'; -/// Video card for home grid - matches Pencil card1 -class VideoCard extends StatelessWidget { +/// Video card for home grid - 点击播放按钮可在卡片上播放视频 +/// 同时只能一个卡片处于播放状态 +class VideoCard extends StatefulWidget { const VideoCard({ super.key, required this.imageUrl, + this.videoUrl, this.credits = '50', - this.onTap, + required this.isActive, + required this.onPlayRequested, + required this.onStopRequested, this.onGenerateSimilar, }); final String imageUrl; + final String? videoUrl; final String credits; - final VoidCallback? onTap; + final bool isActive; + final VoidCallback onPlayRequested; + final VoidCallback onStopRequested; final VoidCallback? onGenerateSimilar; + @override + State createState() => _VideoCardState(); +} + +class _VideoCardState extends State { + VideoPlayerController? _controller; + bool _isLoading = false; + String? _loadError; + + @override + void initState() { + super.initState(); + if (widget.isActive) { + WidgetsBinding.instance.addPostFrameCallback((_) => _loadAndPlay()); + } + } + + @override + void dispose() { + _disposeController(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant VideoCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isActive && !widget.isActive) { + _stop(); + } else if (!oldWidget.isActive && widget.isActive) { + _loadAndPlay(); + } + } + + void _stop() { + _controller?.removeListener(_onVideoUpdate); + _controller?.pause(); + if (mounted) { + setState(() { + _isLoading = false; + _loadError = null; + }); + } + } + + void _disposeController() { + _controller?.removeListener(_onVideoUpdate); + _controller?.dispose(); + _controller = null; + } + + Future _loadAndPlay() async { + if (widget.videoUrl == null || widget.videoUrl!.isEmpty) { + widget.onStopRequested(); + return; + } + + if (_controller != null && _controller!.value.isInitialized) { + final needSeek = _controller!.value.position >= _controller!.value.duration && + _controller!.value.duration.inMilliseconds > 0; + if (needSeek) { + setState(() => _isLoading = true); + await _controller!.seekTo(Duration.zero); + } + _controller!.addListener(_onVideoUpdate); + await _controller!.play(); + if (mounted) setState(() => _isLoading = false); + return; + } + + setState(() { + _isLoading = true; + _loadError = null; + }); + + _controller = VideoPlayerController.networkUrl( + Uri.parse(widget.videoUrl!), + ); + try { + await _controller!.initialize(); + _controller!.addListener(_onVideoUpdate); + if (mounted && widget.isActive) { + await _controller!.play(); + setState(() => _isLoading = false); + } + } catch (e) { + if (mounted) { + _disposeController(); + setState(() { + _isLoading = false; + _loadError = e.toString(); + }); + widget.onStopRequested(); + } + } + } + + void _onVideoUpdate() { + if (_controller != null && + _controller!.value.position >= _controller!.value.duration && + _controller!.value.duration.inMilliseconds > 0) { + _controller!.removeListener(_onVideoUpdate); + _controller!.seekTo(Duration.zero); + if (mounted) widget.onStopRequested(); + } + } + + void _onPlayButtonTap() { + if (widget.isActive) { + widget.onStopRequested(); + _stop(); + } else { + widget.onPlayRequested(); + } + } + + bool get _isPlaying => + widget.isActive && _controller != null && _controller!.value.isPlaying; + @override Widget build(BuildContext context) { + final showVideo = widget.isActive && _controller != null && _controller!.value.isInitialized; + final showLoading = widget.isActive && _isLoading; + return LayoutBuilder( builder: (context, constraints) { return Container( @@ -38,117 +168,166 @@ class VideoCard extends StatelessWidget { ), ], ), - child: ClipRRect( + child: ClipRRect( borderRadius: BorderRadius.circular(24), child: Stack( - fit: StackFit.expand, - children: [ - CachedNetworkImage( - imageUrl: imageUrl, - fit: BoxFit.cover, - placeholder: (_, __) => Container( - color: AppColors.surfaceAlt, - ), - errorWidget: (_, __, ___) => Container( - color: AppColors.surfaceAlt, - ), - ), - Positioned( - top: 12, - right: 12, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.xs, - ), - decoration: BoxDecoration( - color: AppColors.overlayDark, - borderRadius: BorderRadius.circular(14), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - LucideIcons.sparkles, - size: 12, - color: AppColors.surface, - ), - const SizedBox(width: AppSpacing.sm), - Text( - credits, - style: const TextStyle( - color: AppColors.surface, - fontSize: 11, - fontWeight: FontWeight.w600, - fontFamily: 'Inter', + fit: StackFit.expand, + children: [ + if (showVideo) + Positioned.fill( + child: FittedBox( + fit: BoxFit.cover, + child: SizedBox( + width: _controller!.value.size.width > 0 + ? _controller!.value.size.width + : 16, + height: _controller!.value.size.height > 0 + ? _controller!.value.size.height + : 9, + child: VideoPlayer(_controller!), ), ), - ], - ), - ), - ), - Positioned.fill( - child: Center( - child: GestureDetector( - onTap: onTap, + ) + else + CachedNetworkImage( + imageUrl: widget.imageUrl, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + color: AppColors.surfaceAlt, + ), + errorWidget: (_, __, ___) => Container( + color: AppColors.surfaceAlt, + ), + ), + Positioned( + top: 12, + right: 12, child: Container( - width: 48, - height: 48, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.xs, + ), decoration: BoxDecoration( - color: AppColors.surface.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(24), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.13), - blurRadius: 8, - offset: const Offset(0, 2), + color: AppColors.overlayDark, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.sparkles, + size: 12, + color: AppColors.surface, + ), + const SizedBox(width: AppSpacing.sm), + Text( + widget.credits, + style: const TextStyle( + color: AppColors.surface, + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: 'Inter', + ), ), ], ), - child: const Icon( - LucideIcons.play, - size: 24, - color: AppColors.textPrimary, - ), ), ), - ), - ), - Positioned( - bottom: 12, - left: 12, - right: 12, - child: GestureDetector( - onTap: onGenerateSimilar, - child: Container( - height: 44, - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: AppColors.primary, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: AppColors.primaryShadow.withValues(alpha: 0.25), - blurRadius: 6, - offset: const Offset(0, 2), + Positioned.fill( + child: Center( + child: GestureDetector( + onTap: widget.videoUrl != null && widget.videoUrl!.isNotEmpty + ? _onPlayButtonTap + : null, + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.surface.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.13), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: showLoading + ? const Padding( + padding: EdgeInsets.all(12), + child: CircularProgressIndicator( + color: AppColors.textPrimary, + strokeWidth: 2, + ), + ) + : Icon( + _isPlaying ? LucideIcons.pause : LucideIcons.play, + size: 24, + color: AppColors.textPrimary, + ), ), - ], - ), - alignment: Alignment.center, - child: const Text( - 'Generate Similar', - style: TextStyle( - color: AppColors.surface, - fontSize: 12, - fontWeight: FontWeight.w600, - fontFamily: 'Inter', ), ), ), - ), + if (_isPlaying) + Positioned( + top: 8, + left: 8, + child: GestureDetector( + onTap: () { + widget.onStopRequested(); + _stop(); + }, + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppColors.overlayDark, + borderRadius: BorderRadius.circular(20), + ), + child: const Icon( + LucideIcons.x, + size: 16, + color: AppColors.surface, + ), + ), + ), + ), + Positioned( + bottom: 12, + left: 12, + right: 12, + child: GestureDetector( + onTap: widget.onGenerateSimilar, + child: Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: AppColors.primaryShadow.withValues(alpha: 0.25), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + alignment: Alignment.center, + child: const Text( + 'Generate Similar', + style: TextStyle( + color: AppColors.surface, + fontSize: 12, + fontWeight: FontWeight.w600, + fontFamily: 'Inter', + ), + ), + ), + ), + ), + ], ), - ], - ), ), ); }, diff --git a/lib/features/profile/profile_screen.dart b/lib/features/profile/profile_screen.dart index c024f9f..0e275b7 100644 --- a/lib/features/profile/profile_screen.dart +++ b/lib/features/profile/profile_screen.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; + import '../../core/theme/app_colors.dart'; +import '../../core/user/user_state.dart'; import '../../core/theme/app_spacing.dart'; import '../../core/theme/app_typography.dart'; -import '../../shared/widgets/top_nav_bar.dart'; /// Profile screen - matches Pencil KXeow class ProfileScreen extends StatelessWidget { @@ -13,14 +14,6 @@ class ProfileScreen extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.background, - appBar: PreferredSize( - preferredSize: const Size.fromHeight(56), - child: TopNavBar( - title: 'Profile', - credits: '1,280', - onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'), - ), - ), body: SingleChildScrollView( padding: const EdgeInsets.fromLTRB( AppSpacing.screenPadding, @@ -36,7 +29,7 @@ class ProfileScreen extends StatelessWidget { ), const SizedBox(height: AppSpacing.xl), _BalanceCard( - balance: '1,280', + balance: UserCreditsData.of(context)?.creditsDisplay ?? '--', onRecharge: () => Navigator.of(context).pushNamed('/recharge'), ), const SizedBox(height: AppSpacing.xxl), diff --git a/lib/features/recharge/recharge_screen.dart b/lib/features/recharge/recharge_screen.dart index 7eb4078..38914ab 100644 --- a/lib/features/recharge/recharge_screen.dart +++ b/lib/features/recharge/recharge_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; + import '../../core/theme/app_colors.dart'; +import '../../core/user/user_state.dart'; import '../../core/theme/app_spacing.dart'; import '../../core/theme/app_typography.dart'; import '../../shared/widgets/top_nav_bar.dart'; @@ -17,8 +19,10 @@ class RechargeScreen extends StatelessWidget { preferredSize: const Size.fromHeight(56), child: TopNavBar( title: 'Recharge', + credits: UserCreditsData.of(context)?.creditsDisplay ?? '--', showBackButton: true, onBack: () => Navigator.of(context).pop(), + onCreditsTap: null, ), ), body: SingleChildScrollView( @@ -26,7 +30,10 @@ class RechargeScreen extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _CreditsSection(currentCredits: '1,280'), + _CreditsSection( + currentCredits: + UserCreditsData.of(context)?.creditsDisplay ?? '--', + ), Padding( padding: const EdgeInsets.symmetric( horizontal: AppSpacing.screenPaddingLarge, diff --git a/lib/main.dart b/lib/main.dart index fe6810e..1b8e18a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,19 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'app.dart'; import 'core/auth/auth_service.dart'; +import 'core/theme/app_colors.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: AppColors.surface, + statusBarIconBrightness: Brightness.dark, + statusBarBrightness: Brightness.light, + ), + ); runApp(const App()); // APP 打开时后台执行快速登录 AuthService.init(); diff --git a/lib/shared/widgets/top_nav_bar.dart b/lib/shared/widgets/top_nav_bar.dart index 9071473..92ed1a7 100644 --- a/lib/shared/widgets/top_nav_bar.dart +++ b/lib/shared/widgets/top_nav_bar.dart @@ -41,7 +41,6 @@ class TopNavBar extends StatelessWidget implements PreferredSizeWidget { ], ), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ if (showBackButton) GestureDetector( @@ -58,12 +57,22 @@ class TopNavBar extends StatelessWidget implements PreferredSizeWidget { ), ), ), - ) - else - const SizedBox(width: 40), - Text( - title, - style: AppTypography.navTitle, + ), + Expanded( + child: showBackButton + ? Center( + child: Text( + title, + style: AppTypography.navTitle, + ), + ) + : Align( + alignment: Alignment.centerLeft, + child: Text( + title, + style: AppTypography.navTitle, + ), + ), ), if (credits != null) CreditsBadge(credits: credits!, onTap: onCreditsTap) diff --git a/pubspec.yaml b/pubspec.yaml index 12e5b06..d8d9dbe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: device_info_plus: ^11.1.0 encrypt: ^5.0.3 http: ^1.2.2 + video_player: ^2.9.2 dev_dependencies: flutter_test: