优化:屏幕适配,数据对接

This commit is contained in:
ivan 2026-03-09 16:39:54 +08:00
parent 0cff1e509d
commit e47e0800e5
17 changed files with 701 additions and 207 deletions

View File

@ -18,11 +18,18 @@
@import sqflite_darwin; @import sqflite_darwin;
#endif #endif
#if __has_include(<video_player_avfoundation/FVPVideoPlayerPlugin.h>)
#import <video_player_avfoundation/FVPVideoPlayerPlugin.h>
#else
@import video_player_avfoundation;
#endif
@implementation GeneratedPluginRegistrant @implementation GeneratedPluginRegistrant
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry { + (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
[FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]]; [FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]];
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
[FVPVideoPlayerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FVPVideoPlayerPlugin"]];
} }
@end @end

View File

@ -1,10 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'core/theme/app_theme.dart'; import 'core/theme/app_theme.dart';
import 'core/user/user_state.dart';
import 'features/gallery/gallery_screen.dart'; import 'features/gallery/gallery_screen.dart';
import 'features/generate_video/generate_progress_screen.dart'; import 'features/generate_video/generate_progress_screen.dart';
import 'features/generate_video/generate_video_screen.dart'; import 'features/generate_video/generate_video_screen.dart';
import 'features/generate_video/generation_result_screen.dart'; import 'features/generate_video/generation_result_screen.dart';
import 'features/home/home_screen.dart'; import 'features/home/home_screen.dart';
import 'features/home/models/task_item.dart';
import 'features/profile/profile_screen.dart'; import 'features/profile/profile_screen.dart';
import 'features/recharge/recharge_screen.dart'; import 'features/recharge/recharge_screen.dart';
import 'shared/widgets/bottom_nav_bar.dart'; import 'shared/widgets/bottom_nav_bar.dart';
@ -22,21 +25,35 @@ class _AppState extends State<App> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return UserCreditsScope(
child: MaterialApp(
title: 'AI Video App', title: 'AI Video App',
theme: AppTheme.light, theme: AppTheme.light,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
initialRoute: '/', initialRoute: '/',
builder: (context, child) {
return SafeArea(
top: true,
left: false,
right: false,
bottom: false,
child: child ?? const SizedBox.shrink(),
);
},
routes: { routes: {
'/': (_) => _MainScaffold( '/': (_) => _MainScaffold(
currentTab: _currentTab, currentTab: _currentTab,
onTabSelected: (tab) => setState(() => _currentTab = tab), onTabSelected: (tab) => setState(() => _currentTab = tab),
), ),
'/recharge': (_) => const RechargeScreen(), '/recharge': (_) => const RechargeScreen(),
'/generate': (_) => const GenerateVideoScreen(), '/generate': (ctx) {
final task = ModalRoute.of(ctx)?.settings.arguments as TaskItem?;
return GenerateVideoScreen(task: task);
},
'/progress': (_) => const GenerateProgressScreen(), '/progress': (_) => const GenerateProgressScreen(),
'/result': (_) => const GenerationResultScreen(), '/result': (_) => const GenerationResultScreen(),
}, },
),
); );
} }
} }

View File

@ -5,6 +5,24 @@ import '../proxy_client.dart';
abstract final class ImageApi { abstract final class ImageApi {
static final _client = ApiClient.instance.proxy; static final _client = ApiClient.instance.proxy;
///
static Future<ApiResponse> getCategoryList() async {
return _client.request(
path: '/v1/image/img2video/categories',
method: 'GET',
);
}
///
/// insignia: categoryId
static Future<ApiResponse> getImg2VideoTasks({int? insignia}) async {
return _client.request(
path: '/v1/image/img2video/tasks',
method: 'GET',
queryParams: insignia != null ? {'insignia': insignia.toString()} : null,
);
}
/// ///
static Future<ApiResponse> getPromptRecommends({ static Future<ApiResponse> getPromptRecommends({
required String sentinel, required String sentinel,

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
@ -7,6 +8,7 @@ import 'package:flutter/foundation.dart';
import '../api/api_client.dart'; import '../api/api_client.dart';
import '../api/proxy_client.dart'; import '../api/proxy_client.dart';
import '../api/services/user_api.dart'; import '../api/services/user_api.dart';
import '../user/user_state.dart';
/// APP /// APP
class AuthService { class AuthService {
@ -14,6 +16,12 @@ class AuthService {
static const _tag = '[AuthService]'; static const _tag = '[AuthService]';
static Future<void>? _loginFuture;
/// Future await Future
static Future<void> get loginComplete =>
_loginFuture ?? Future<void>.value();
static void _log(String msg) { static void _log(String msg) {
debugPrint('$_tag $msg'); debugPrint('$_tag $msg');
} }
@ -43,6 +51,10 @@ class AuthService {
/// APP /// APP
/// ///
static Future<void> init() async { static Future<void> init() async {
if (_loginFuture != null) return _loginFuture!;
final completer = Completer<void>();
_loginFuture = completer.future;
_log('init: 开始快速登录'); _log('init: 开始快速登录');
const maxRetries = 3; const maxRetries = 3;
const retryDelay = Duration(seconds: 2); const retryDelay = Duration(seconds: 2);
@ -89,12 +101,19 @@ class AuthService {
} else { } else {
_log('init: 响应中无 reevaluate (userToken)'); _log('init: 响应中无 reevaluate (userToken)');
} }
final credits = data?['reveal'] as int?;
if (credits != null) {
UserState.setCredits(credits);
_log('init: 已同步积分 $credits');
}
} else { } else {
_log('init: 登录失败'); _log('init: 登录失败');
} }
} catch (e, st) { } catch (e, st) {
_log('init: 异常 $e'); _log('init: 异常 $e');
_log('init: 堆栈 $st'); _log('init: 堆栈 $st');
} finally {
if (!completer.isCompleted) completer.complete();
} }
} }
} }

View File

@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
///
class UserState {
UserState._();
static final ValueNotifier<int?> credits = ValueNotifier<int?>(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<UserCreditsData>();
}
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<UserCreditsScope> createState() => _UserCreditsScopeState();
}
class _UserCreditsScopeState extends State<UserCreditsScope> {
@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,
);
}
}

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/user/user_state.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';
@ -23,7 +25,7 @@ class GalleryScreen extends StatelessWidget {
preferredSize: const Size.fromHeight(56), preferredSize: const Size.fromHeight(56),
child: TopNavBar( child: TopNavBar(
title: 'Gallery', title: 'Gallery',
credits: '1,280', credits: UserCreditsData.of(context)?.creditsDisplay ?? '--',
onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'), onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'),
), ),
), ),

View File

@ -1,13 +1,37 @@
import 'dart:developer' as developer;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:flutter_lucide/flutter_lucide.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/user/user_state.dart';
import '../../core/theme/app_spacing.dart'; import '../../core/theme/app_spacing.dart';
import '../../core/theme/app_typography.dart'; import '../../core/theme/app_typography.dart';
import '../../features/home/models/task_item.dart';
import '../../shared/widgets/top_nav_bar.dart'; import '../../shared/widgets/top_nav_bar.dart';
/// Generate Video screen - matches Pencil mmLB5 /// Generate Video screen - matches Pencil mmLB5
class GenerateVideoScreen extends StatelessWidget { class GenerateVideoScreen extends StatefulWidget {
const GenerateVideoScreen({super.key}); const GenerateVideoScreen({super.key, this.task});
final TaskItem? task;
@override
State<GenerateVideoScreen> createState() => _GenerateVideoScreenState();
}
class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
developer.log(
'GenerateVideoScreen opened with task: ${widget.task}',
name: 'GenerateVideoScreen',
);
debugPrint('[GenerateVideoScreen] task: ${widget.task}');
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -17,8 +41,10 @@ class GenerateVideoScreen extends StatelessWidget {
preferredSize: const Size.fromHeight(56), preferredSize: const Size.fromHeight(56),
child: TopNavBar( child: TopNavBar(
title: 'Generate Video', title: 'Generate Video',
credits: UserCreditsData.of(context)?.creditsDisplay ?? '--',
showBackButton: true, showBackButton: true,
onBack: () => Navigator.of(context).pop(), onBack: () => Navigator.of(context).pop(),
onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'),
), ),
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
@ -26,7 +52,9 @@ class GenerateVideoScreen extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_CreditsCard(credits: '1,280'), _CreditsCard(
credits: UserCreditsData.of(context)?.creditsDisplay ?? '--',
),
const SizedBox(height: AppSpacing.xxl), const SizedBox(height: AppSpacing.xxl),
_UploadArea(onUpload: () {}), _UploadArea(onUpload: () {}),
const SizedBox(height: AppSpacing.xxl), const SizedBox(height: AppSpacing.xxl),

View File

@ -1,10 +1,16 @@
import 'package:flutter/material.dart'; 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 '../../core/theme/app_spacing.dart';
import '../../shared/widgets/top_nav_bar.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/home_tab_row.dart';
import 'widgets/video_card.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 { class HomeScreen extends StatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@ -13,16 +19,67 @@ class HomeScreen extends StatefulWidget {
} }
class _HomeScreenState extends State<HomeScreen> { class _HomeScreenState extends State<HomeScreen> {
HomeTab _selectedTab = HomeTab.all; List<CategoryItem> _categories = [];
CategoryItem? _selectedCategory;
List<TaskItem> _tasks = [];
bool _categoriesLoading = true;
bool _tasksLoading = false;
int? _activeCardIndex;
static const _placeholderImages = [ @override
'https://images.unsplash.com/photo-1763929272543-0df093e4f659?w=400', void initState() {
'https://images.unsplash.com/photo-1703592819695-ea63799b7315?w=400', super.initState();
'https://images.unsplash.com/photo-1764787435677-1321e12559e3?w=400', _loadCategories();
'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', Future<void> _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<String, dynamic>))
.toList();
setState(() {
_categories = list;
_selectedCategory = list.isNotEmpty ? list.first : null;
if (_selectedCategory != null) _loadTasks(_selectedCategory!.id);
});
} else {
setState(() => _categories = []);
}
setState(() => _categoriesLoading = false);
}
}
Future<void> _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<String, dynamic>))
.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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -32,7 +89,7 @@ class _HomeScreenState extends State<HomeScreen> {
preferredSize: const Size.fromHeight(56), preferredSize: const Size.fromHeight(56),
child: TopNavBar( child: TopNavBar(
title: 'AI Video', title: 'AI Video',
credits: '1,280', credits: UserCreditsData.of(context)?.creditsDisplay ?? '--',
onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'), onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'),
), ),
), ),
@ -43,19 +100,22 @@ class _HomeScreenState extends State<HomeScreen> {
horizontal: AppSpacing.screenPadding, horizontal: AppSpacing.screenPadding,
vertical: AppSpacing.xs, vertical: AppSpacing.xs,
), ),
child: HomeTabRow( child: _categoriesLoading
selectedTab: _selectedTab, ? const SizedBox(height: 40, child: Center(child: CircularProgressIndicator()))
onTabChanged: (tab) => setState(() => _selectedTab = tab), : HomeTabRow(
categories: _categories,
selectedId: _selectedCategory?.id ?? -1,
onTabChanged: _onTabChanged,
), ),
), ),
Expanded( Expanded(
child: LayoutBuilder( child: _tasksLoading
? const Center(child: CircularProgressIndicator())
: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
return Center( return Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints( constraints: const BoxConstraints(maxWidth: 390),
maxWidth: 390,
),
child: GridView.builder( child: GridView.builder(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
AppSpacing.screenPadding, AppSpacing.screenPadding,
@ -70,12 +130,24 @@ class _HomeScreenState extends State<HomeScreen> {
mainAxisSpacing: AppSpacing.xl, mainAxisSpacing: AppSpacing.xl,
crossAxisSpacing: AppSpacing.xl, crossAxisSpacing: AppSpacing.xl,
), ),
itemCount: _placeholderImages.length, itemCount: _tasks.length,
itemBuilder: (context, index) => VideoCard( itemBuilder: (context, index) {
imageUrl: _placeholderImages[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: () => onGenerateSimilar: () =>
Navigator.of(context).pushNamed('/generate'), Navigator.of(context).pushNamed(
'/generate',
arguments: task,
), ),
);
},
), ),
), ),
); );

View File

@ -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<String, dynamic> json) {
return CategoryItem(
id: json['federation'] as int? ?? 0,
name: json['brigade'] as String? ?? '',
icon: json['greylist'] as String?,
);
}
}

View File

@ -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<String, dynamic> json) {
final extract = json['extract'] as Map<String, dynamic>?;
final preempt = json['preempt'] as Map<String, dynamic>?;
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,
);
}
}

View File

@ -1,47 +1,41 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.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 '../../../core/theme/app_typography.dart'; import '../../../core/theme/app_typography.dart';
import '../models/category_item.dart';
enum HomeTab { all, trending, newTab } /// Tab row for home screen - 使
/// Tab row for home screen - matches Pencil tabRow
class HomeTabRow extends StatelessWidget { class HomeTabRow extends StatelessWidget {
const HomeTabRow({ const HomeTabRow({
super.key, super.key,
required this.selectedTab, required this.categories,
required this.selectedId,
required this.onTabChanged, required this.onTabChanged,
}); });
final HomeTab selectedTab; final List<CategoryItem> categories;
final ValueChanged<HomeTab> onTabChanged; final int selectedId;
final ValueChanged<CategoryItem> onTabChanged;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return SizedBox(
height: 40, height: 40,
child: Padding( child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
child: Row( child: Row(
children: [ children: [
for (var i = 0; i < categories.length; i++) ...[
if (i > 0) const SizedBox(width: AppSpacing.md),
_TabChip( _TabChip(
label: 'All', label: categories[i].name,
isSelected: selectedTab == HomeTab.all, isSelected: selectedId == categories[i].id,
onTap: () => onTabChanged(HomeTab.all), onTap: () => onTabChanged(categories[i]),
),
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),
), ),
], ],
],
), ),
), ),
); );

View File

@ -1,26 +1,156 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:cached_network_image/cached_network_image.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_colors.dart';
import '../../../core/theme/app_spacing.dart'; import '../../../core/theme/app_spacing.dart';
/// Video card for home grid - matches Pencil card1 /// Video card for home grid -
class VideoCard extends StatelessWidget { ///
class VideoCard extends StatefulWidget {
const VideoCard({ const VideoCard({
super.key, super.key,
required this.imageUrl, required this.imageUrl,
this.videoUrl,
this.credits = '50', this.credits = '50',
this.onTap, required this.isActive,
required this.onPlayRequested,
required this.onStopRequested,
this.onGenerateSimilar, this.onGenerateSimilar,
}); });
final String imageUrl; final String imageUrl;
final String? videoUrl;
final String credits; final String credits;
final VoidCallback? onTap; final bool isActive;
final VoidCallback onPlayRequested;
final VoidCallback onStopRequested;
final VoidCallback? onGenerateSimilar; final VoidCallback? onGenerateSimilar;
@override
State<VideoCard> createState() => _VideoCardState();
}
class _VideoCardState extends State<VideoCard> {
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<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final showVideo = widget.isActive && _controller != null && _controller!.value.isInitialized;
final showLoading = widget.isActive && _isLoading;
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
return Container( return Container(
@ -43,8 +173,24 @@ class VideoCard extends StatelessWidget {
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ 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!),
),
),
)
else
CachedNetworkImage( CachedNetworkImage(
imageUrl: imageUrl, imageUrl: widget.imageUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (_, __) => Container( placeholder: (_, __) => Container(
color: AppColors.surfaceAlt, color: AppColors.surfaceAlt,
@ -75,7 +221,7 @@ class VideoCard extends StatelessWidget {
), ),
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),
Text( Text(
credits, widget.credits,
style: const TextStyle( style: const TextStyle(
color: AppColors.surface, color: AppColors.surface,
fontSize: 11, fontSize: 11,
@ -90,7 +236,9 @@ class VideoCard extends StatelessWidget {
Positioned.fill( Positioned.fill(
child: Center( child: Center(
child: GestureDetector( child: GestureDetector(
onTap: onTap, onTap: widget.videoUrl != null && widget.videoUrl!.isNotEmpty
? _onPlayButtonTap
: null,
child: Container( child: Container(
width: 48, width: 48,
height: 48, height: 48,
@ -105,8 +253,16 @@ class VideoCard extends StatelessWidget {
), ),
], ],
), ),
child: const Icon( child: showLoading
LucideIcons.play, ? const Padding(
padding: EdgeInsets.all(12),
child: CircularProgressIndicator(
color: AppColors.textPrimary,
strokeWidth: 2,
),
)
: Icon(
_isPlaying ? LucideIcons.pause : LucideIcons.play,
size: 24, size: 24,
color: AppColors.textPrimary, color: AppColors.textPrimary,
), ),
@ -114,12 +270,35 @@ class VideoCard extends StatelessWidget {
), ),
), ),
), ),
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( Positioned(
bottom: 12, bottom: 12,
left: 12, left: 12,
right: 12, right: 12,
child: GestureDetector( child: GestureDetector(
onTap: onGenerateSimilar, onTap: widget.onGenerateSimilar,
child: Container( child: Container(
height: 44, height: 44,
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),

View File

@ -1,9 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:flutter_lucide/flutter_lucide.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/user/user_state.dart';
import '../../core/theme/app_spacing.dart'; import '../../core/theme/app_spacing.dart';
import '../../core/theme/app_typography.dart'; import '../../core/theme/app_typography.dart';
import '../../shared/widgets/top_nav_bar.dart';
/// Profile screen - matches Pencil KXeow /// Profile screen - matches Pencil KXeow
class ProfileScreen extends StatelessWidget { class ProfileScreen extends StatelessWidget {
@ -13,14 +14,6 @@ class ProfileScreen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: AppColors.background, 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( body: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
AppSpacing.screenPadding, AppSpacing.screenPadding,
@ -36,7 +29,7 @@ class ProfileScreen extends StatelessWidget {
), ),
const SizedBox(height: AppSpacing.xl), const SizedBox(height: AppSpacing.xl),
_BalanceCard( _BalanceCard(
balance: '1,280', balance: UserCreditsData.of(context)?.creditsDisplay ?? '--',
onRecharge: () => Navigator.of(context).pushNamed('/recharge'), onRecharge: () => Navigator.of(context).pushNamed('/recharge'),
), ),
const SizedBox(height: AppSpacing.xxl), const SizedBox(height: AppSpacing.xxl),

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:flutter_lucide/flutter_lucide.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/user/user_state.dart';
import '../../core/theme/app_spacing.dart'; import '../../core/theme/app_spacing.dart';
import '../../core/theme/app_typography.dart'; import '../../core/theme/app_typography.dart';
import '../../shared/widgets/top_nav_bar.dart'; import '../../shared/widgets/top_nav_bar.dart';
@ -17,8 +19,10 @@ class RechargeScreen extends StatelessWidget {
preferredSize: const Size.fromHeight(56), preferredSize: const Size.fromHeight(56),
child: TopNavBar( child: TopNavBar(
title: 'Recharge', title: 'Recharge',
credits: UserCreditsData.of(context)?.creditsDisplay ?? '--',
showBackButton: true, showBackButton: true,
onBack: () => Navigator.of(context).pop(), onBack: () => Navigator.of(context).pop(),
onCreditsTap: null,
), ),
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
@ -26,7 +30,10 @@ class RechargeScreen extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_CreditsSection(currentCredits: '1,280'), _CreditsSection(
currentCredits:
UserCreditsData.of(context)?.creditsDisplay ?? '--',
),
Padding( Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.screenPaddingLarge, horizontal: AppSpacing.screenPaddingLarge,

View File

@ -1,10 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'app.dart'; import 'app.dart';
import 'core/auth/auth_service.dart'; import 'core/auth/auth_service.dart';
import 'core/theme/app_colors.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: AppColors.surface,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
),
);
runApp(const App()); runApp(const App());
// APP // APP
AuthService.init(); AuthService.init();

View File

@ -41,7 +41,6 @@ class TopNavBar extends StatelessWidget implements PreferredSizeWidget {
], ],
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
if (showBackButton) if (showBackButton)
GestureDetector( GestureDetector(
@ -58,13 +57,23 @@ class TopNavBar extends StatelessWidget implements PreferredSizeWidget {
), ),
), ),
), ),
) ),
else Expanded(
const SizedBox(width: 40), child: showBackButton
Text( ? Center(
child: Text(
title, title,
style: AppTypography.navTitle, style: AppTypography.navTitle,
), ),
)
: Align(
alignment: Alignment.centerLeft,
child: Text(
title,
style: AppTypography.navTitle,
),
),
),
if (credits != null) if (credits != null)
CreditsBadge(credits: credits!, onTap: onCreditsTap) CreditsBadge(credits: credits!, onTap: onCreditsTap)
else else

View File

@ -17,6 +17,7 @@ dependencies:
device_info_plus: ^11.1.0 device_info_plus: ^11.1.0
encrypt: ^5.0.3 encrypt: ^5.0.3
http: ^1.2.2 http: ^1.2.2
video_player: ^2.9.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: