petsHero-AI/lib/app.dart
2026-04-23 10:57:46 +08:00

325 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'package:facebook_app_events/facebook_app_events.dart';
import 'package:flutter/material.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'core/auth/auth_service.dart';
import 'core/nav/app_route_observer.dart';
import 'core/config/facebook_config.dart';
import 'core/log/app_logger.dart';
import 'core/theme/app_colors.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/gallery/models/gallery_task_item.dart';
import 'features/generate_video/generation_result_screen.dart';
import 'features/home/home_playback_resume.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';
import 'shared/tab_selector_scope.dart';
/// Root app widget with navigation
class App extends StatefulWidget {
const App({super.key});
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> with WidgetsBindingObserver {
NavTab _currentTab = NavTab.home;
static final _fbLog = AppLogger('FB');
final FacebookAppEvents _fbAppEvents = FacebookAppEvents();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// Cold start is often already resumed, so lifecycle may not emit resumed—fire once after first frame.
WidgetsBinding.instance.addPostFrameCallback((_) {
// Remove native splash; Flutter login overlay takes over if startup is still running.
FlutterNativeSplash.remove();
_reportFacebookActivateApp('first_frame');
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
/// With `AutoLogAppEventsEnabled=false`, manually report Facebook install + activateApp.
void _reportFacebookActivateApp(String reason) {
_fbAppEvents.activateApp().then((_) {
if (FacebookConfig.debugLogs) {
_fbLog.d('activateApp (manual: $reason)');
}
});
}
/// Foreground resume; cold start also uses addPostFrameCallback in initState.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_reportFacebookActivateApp('lifecycle_resumed');
}
}
@override
Widget build(BuildContext context) {
return UserCreditsScope(
child: TabSelectorScope(
selectTab: (tab) => setState(() => _currentTab = tab),
child: MaterialApp(
title: 'AI Video App',
theme: AppTheme.light,
debugShowCheckedModeBanner: false,
navigatorObservers: [appRouteObserver],
initialRoute: '/',
builder: (context, child) {
return Stack(
fit: StackFit.expand,
children: [
SafeArea(
top: true,
left: false,
right: false,
bottom: false,
child: child ?? const SizedBox.shrink(),
),
ListenableBuilder(
listenable: AuthService.isLoginComplete,
builder: (context, _) {
if (AuthService.isLoginComplete.value) {
return const SizedBox.shrink();
}
return const _StartupLoginOverlay();
},
),
],
);
},
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': (ctx) {
final args = ModalRoute.of(ctx)?.settings.arguments;
final taskId = args is Map ? args['taskId'] : args;
final imagePath = args is Map ? args['imagePath'] as String? : null;
return GenerateProgressScreen(
taskId: taskId,
imagePath: imagePath,
);
},
'/result': (ctx) {
final mediaItem =
ModalRoute.of(ctx)?.settings.arguments as GalleryMediaItem?;
return GenerationResultScreen(mediaItem: mediaItem);
},
},
),
),
);
}
}
/// Full-screen wait until [AuthService.loginComplete]; after a delay, show a gentle network hint.
class _StartupLoginOverlay extends StatefulWidget {
const _StartupLoginOverlay();
@override
State<_StartupLoginOverlay> createState() => _StartupLoginOverlayState();
}
class _StartupLoginOverlayState extends State<_StartupLoginOverlay> {
static const _longWaitAfter = Duration(seconds: 22);
Timer? _longWaitTimer;
bool _showNetworkHint = false;
@override
void initState() {
super.initState();
_longWaitTimer = Timer(_longWaitAfter, () {
if (mounted) setState(() => _showNetworkHint = true);
});
}
@override
void dispose() {
_longWaitTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: AbsorbPointer(
child: Container(
color: Colors.black.withValues(alpha: 0.22),
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(color: AppColors.primary),
if (_showNetworkHint) ...[
const SizedBox(height: 22),
Text(
'Still getting things ready',
textAlign: TextAlign.center,
style: TextStyle(
color: AppColors.surface.withValues(alpha: 0.96),
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 10),
Text(
'This is slower than usual—your connection may be weak. Check WiFi or mobile data; loading will continue automatically. You can also fully close the app and open it again.',
textAlign: TextAlign.center,
style: TextStyle(
color: AppColors.surface.withValues(alpha: 0.78),
fontSize: 14,
height: 1.4,
),
),
],
],
),
),
),
),
),
);
}
}
class _MainScaffold extends StatefulWidget {
const _MainScaffold({
required this.currentTab,
required this.onTabSelected,
});
final NavTab currentTab;
final ValueChanged<NavTab> onTabSelected;
@override
State<_MainScaffold> createState() => _MainScaffoldState();
}
class _MainScaffoldState extends State<_MainScaffold> with RouteAware {
bool _routeObserverSubscribed = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_routeObserverSubscribed) {
final route = ModalRoute.of(context);
if (route is PageRoute<dynamic>) {
appRouteObserver.subscribe(this, route);
_routeObserverSubscribed = true;
}
}
}
@override
void dispose() {
if (_routeObserverSubscribed) {
appRouteObserver.unsubscribe(this);
}
super.dispose();
}
/// Subscribed on `/` [PageRoute]: fires when a pushed route is popped and home is visible again.
@override
void didPopNext() {
if (widget.currentTab == NavTab.home) {
homePlaybackResumeNonce.value++;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListenableBuilder(
listenable: AuthService.startupUiListenable,
builder: (context, _) {
if (!AuthService.shouldShowStartupFailure) {
return const SizedBox.shrink();
}
final msg = AuthService.startupFailureMessage.value ??
'We couldnt finish loading. Check your network and tap Retry.';
return Material(
color: const Color(0xFFFFF3E0),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 12, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.wifi_off_rounded,
color: AppColors.primary.withValues(alpha: 0.85),
size: 22,
),
const SizedBox(width: 12),
Expanded(
child: Text(
msg,
style: TextStyle(
color: Colors.brown.shade800,
fontSize: 14,
height: 1.35,
),
),
),
TextButton(
onPressed: () async {
await AuthService.retryStartup();
},
child: const Text('Retry'),
),
],
),
),
);
},
),
Expanded(
child: IndexedStack(
index: widget.currentTab.index,
children: [
HomeScreen(isActive: widget.currentTab == NavTab.home),
GalleryScreen(isActive: widget.currentTab == NavTab.gallery),
ProfileScreen(isActive: widget.currentTab == NavTab.profile),
],
),
),
],
),
bottomNavigationBar: BottomNavBar(
currentTab: widget.currentTab,
onTabSelected: widget.onTabSelected,
),
);
}
}