优化:屏幕适配,数据对接
This commit is contained in:
parent
0cff1e509d
commit
e47e0800e5
@ -18,11 +18,18 @@
|
||||
@import sqflite_darwin;
|
||||
#endif
|
||||
|
||||
#if __has_include(<video_player_avfoundation/FVPVideoPlayerPlugin.h>)
|
||||
#import <video_player_avfoundation/FVPVideoPlayerPlugin.h>
|
||||
#else
|
||||
@import video_player_avfoundation;
|
||||
#endif
|
||||
|
||||
@implementation GeneratedPluginRegistrant
|
||||
|
||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
||||
[FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]];
|
||||
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
|
||||
[FVPVideoPlayerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FVPVideoPlayerPlugin"]];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
47
lib/app.dart
47
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<App> {
|
||||
|
||||
@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(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,24 @@ import '../proxy_client.dart';
|
||||
abstract final class ImageApi {
|
||||
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({
|
||||
required String sentinel,
|
||||
|
||||
@ -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<void>? _loginFuture;
|
||||
|
||||
/// 登录完成后的 Future,需鉴权接口应 await 此 Future 再请求
|
||||
static Future<void> get loginComplete =>
|
||||
_loginFuture ?? Future<void>.value();
|
||||
|
||||
static void _log(String msg) {
|
||||
debugPrint('$_tag $msg');
|
||||
}
|
||||
@ -43,6 +51,10 @@ class AuthService {
|
||||
/// APP 启动时调用快速登录
|
||||
/// 启动时网络可能未就绪,会延迟后重试
|
||||
static Future<void> init() async {
|
||||
if (_loginFuture != null) return _loginFuture!;
|
||||
final completer = Completer<void>();
|
||||
_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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
78
lib/core/user/user_state.dart
Normal file
78
lib/core/user/user_state.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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'),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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<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
|
||||
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),
|
||||
|
||||
@ -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<HomeScreen> {
|
||||
HomeTab _selectedTab = HomeTab.all;
|
||||
List<CategoryItem> _categories = [];
|
||||
CategoryItem? _selectedCategory;
|
||||
List<TaskItem> _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<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
|
||||
Widget build(BuildContext context) {
|
||||
@ -32,7 +89,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
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<HomeScreen> {
|
||||
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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
20
lib/features/home/models/category_item.dart
Normal file
20
lib/features/home/models/category_item.dart
Normal 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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
41
lib/features/home/models/task_item.dart
Normal file
41
lib/features/home/models/task_item.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<HomeTab> onTabChanged;
|
||||
final List<CategoryItem> categories;
|
||||
final int selectedId;
|
||||
final ValueChanged<CategoryItem> 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]),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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<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
|
||||
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',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user