343 lines
11 KiB
Dart
343 lines
11 KiB
Dart
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 'package:url_launcher/url_launcher.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();
|
||
unawaited(_logFacebookInstallState('first_frame'));
|
||
_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)');
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Detect whether a local app can handle Facebook's URL scheme.
|
||
///
|
||
/// iOS requires `LSApplicationQueriesSchemes` to include `fb`; Android 11+
|
||
/// requires a matching `<queries>` entry in AndroidManifest.
|
||
Future<void> _logFacebookInstallState(String reason) async {
|
||
const scheme = 'fb://';
|
||
try {
|
||
final installed = await canLaunchUrl(Uri.parse(scheme));
|
||
_fbLog.d(
|
||
'Facebook app install check ($reason): installed=$installed scheme=$scheme',
|
||
);
|
||
} catch (e, st) {
|
||
_fbLog.e('Facebook app install check failed ($reason)', e, st);
|
||
}
|
||
}
|
||
|
||
/// 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 Wi‑Fi 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 couldn’t 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,
|
||
),
|
||
);
|
||
}
|
||
}
|