import 'dart:async'; import 'dart:developer'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import 'api/mhsl/breaker/get_breakers/get_breakers_response.dart'; import 'api/mhsl/server/user_index/update/update_userindex.dart'; import 'main.dart'; import 'model/data_cleaner.dart'; import 'notification/notification_controller.dart'; import 'notification/notification_tasks.dart'; import 'notification/notify_updater.dart'; import 'routing/app_routes.dart'; import 'share_intent/share_intent_listener.dart'; import 'state/app/modules/app_modules.dart'; import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; import 'state/app/modules/timetable/bloc/timetable_state.dart'; import 'storage/settings.dart' as model; import 'utils/debouncer.dart'; import 'view/pages/overhang.dart'; import 'widget/breaker/breaker.dart'; import 'widget_data/widget_navigation.dart'; import 'widget_data/widget_publisher.dart'; class App extends StatefulWidget { const App({super.key}); @override State createState() => _AppState(); } class _AppState extends State with WidgetsBindingObserver { late Timer _updateTimings; StreamSubscription? _timetableWidgetSync; StreamSubscription? _onMessageSub; StreamSubscription? _onMessageOpenedAppSub; StreamSubscription? _fcmTokenRefreshSub; // Tracked via the bottom-nav controller's listener so it always reflects the // user's actual position, even between rapid setting emits where the // controller hasn't caught up to a scheduled jump yet. int _knownTotalTabs = 1; bool _userOnLastTab = false; static const Duration _chatListActiveInterval = Duration(seconds: 15); static const Duration _chatListIdleInterval = Duration(seconds: 60); void _onTabControllerChanged() { _userOnLastTab = Main.bottomNavigator.index == _knownTotalTabs - 1; _syncChatListPolling(); } void _syncChatListPolling() { if (!mounted) return; final modules = AppModule.getBottomBarModules(context); final talkSlot = modules.indexWhere((m) => m.module == Modules.talk); final talkIsActive = talkSlot >= 0 && Main.bottomNavigator.index == talkSlot; final bloc = context.read(); bloc.setAutoRefreshInterval( talkIsActive ? _chatListActiveInterval : _chatListIdleInterval, ); if (talkIsActive) bloc.refresh(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { log('AppLifecycle: $state'); if (state == AppLifecycleState.resumed) { Debouncer.throttle('appLifecycleState', const Duration(seconds: 10), () { if (!mounted) return; log('Refreshing due to LifecycleChange'); NotificationTasks.updateProviders(context); }); _handlePendingWidgetNavigation(); } } Future _handlePendingWidgetNavigation() async { final pending = await WidgetNavigation.consumePendingTimetableTap(); if (!pending || !mounted) return; // Routes pushed with `withNavBar: false` (chat views, file viewers, …) // sit on the root navigator above the bottom-nav, so a bare jumpToTab // would swap the tab behind them and leave the user staring at the // previous screen. Reset to the tab root first — but stop at any open // popup so a confirmation dialog or bottom sheet that the user hasn't // dismissed yet doesn't get silently torn down. final navigator = Navigator.of(context); if (navigator.canPop()) { navigator.popUntil((route) => route.isFirst || route is PopupRoute); } AppRoutes.goToTab(context, Modules.timetable); } void _handlePendingShare() { if (!mounted) return; final share = ShareIntentListener.pending.value; if (share == null) return; // A second share arriving while a previous share-flow page is still on // the stack would otherwise leave the old page sitting on top with stale // (already-cleared) file paths. Reset to the tab root before pushing — // but stop at any open popup so dialogs/bottom-sheets remain intact. final navigator = Navigator.of(context); if (navigator.canPop()) { navigator.popUntil((route) => route.isFirst || route is PopupRoute); } AppRoutes.openShareTarget(context, share); } @override void initState() { super.initState(); Main.bottomNavigator = PersistentTabController(initialIndex: 0); Main.bottomNavigator.addListener(_onTabControllerChanged); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; context.read().refresh(); context.read().refresh(); // App is freshly mounted on every login (BlocConsumer in main.dart // swaps it in for Login), so this also covers the post-logout case // where the bloc was reset to an empty state and needs a fresh fetch. final timetable = context.read(); timetable.refresh(); // Push the freshest timetable state into the home-screen widget any // time the BLoC reports new data — without waiting for the periodic // background refresh. This is the "user just opened the app" path: // the widget gets the same data the user is looking at on screen. final settingsCubit = context.read(); _timetableWidgetSync?.cancel(); _timetableWidgetSync = timetable.stream.listen((state) { final data = state.data; if (data is TimetableState && !state.isLoading) { unawaited( WidgetPublisher.publishFromBlocState( data, settings: settingsCubit.val(), ), ); } }); // Also publish the current state once, in case data is already loaded // from hydrated storage before the listener attaches. final initialData = timetable.state.data; if (initialData is TimetableState) { unawaited( WidgetPublisher.publishFromBlocState( initialData, settings: settingsCubit.val(), ), ); } unawaited(_handlePendingWidgetNavigation()); ShareIntentListener.instance.attach(); ShareIntentListener.pending.addListener(_handlePendingShare); _handlePendingShare(); _syncChatListPolling(); }); _updateTimings = Timer.periodic(const Duration(seconds: 30), (_) { if (mounted) setState(() {}); }); UpdateUserIndex.index(); if (context.read().val().notificationSettings.enabled) { void update() => NotifyUpdater.registerToServer(); _fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen( (_) => update(), ); update(); } _onMessageSub = FirebaseMessaging.onMessage.listen((message) { if (!mounted) return; NotificationController.onForegroundMessageHandler(message, context); }); FirebaseMessaging.onBackgroundMessage( NotificationController.onBackgroundMessageHandler, ); _onMessageOpenedAppSub = FirebaseMessaging.onMessageOpenedApp.listen(( message, ) { if (!mounted) return; NotificationController.onAppOpenedByNotification(message, context); }); FirebaseMessaging.instance.getInitialMessage().then((message) { if (message == null || !mounted) return; NotificationController.onAppOpenedByNotification(message, context); }); DataCleaner.cleanOldCache(); } @override void dispose() { _updateTimings.cancel(); _timetableWidgetSync?.cancel(); _onMessageSub?.cancel(); _onMessageOpenedAppSub?.cancel(); _fcmTokenRefreshSub?.cancel(); ShareIntentListener.pending.removeListener(_handlePendingShare); ShareIntentListener.instance.detach(); Main.bottomNavigator.removeListener(_onTabControllerChanged); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override Widget build( BuildContext context, ) => BlocBuilder( builder: (context, _) { final bottomBarModules = AppModule.getBottomBarModules(context); final totalTabs = bottomBarModules.length + 1; final currentIndex = Main.bottomNavigator.index; // The bottom-bar layout is identified by the ordered list of module // names plus the trailing 'more' slot. Whenever this layout changes // — slot count, reordering, or hiding a module — we recreate the // entire PersistentTabView via the [layoutKey] below. The package // caches per-tab navigator state by index in `_navigatorKeys`, and // its internal `alignLength` only ever appends or trims at the end. // So when the module sitting at e.g. index 3 changes, the navigator // at that index still serves the old screen's route stack and the // user sees stale content. Re-mounting clears those stacks; the // trade-off (losing in-tab pushed routes on a settings change) is // acceptable since the user explicitly re-shaped the bar. final layoutKey = ValueKey( '${bottomBarModules.map((m) => m.module.name).join('|')}|more', ); if (totalTabs != _knownTotalTabs) { var targetIndex = currentIndex; if (_userOnLastTab) { targetIndex = totalTabs - 1; } else if (currentIndex >= totalTabs) { targetIndex = totalTabs - 1; } // Re-mounting PTV with a new key constructs fresh internals from // its controller's current index. If the controller still points // past the new tab list, Style6BottomNavBar (and others) crash on // out-of-range access during initState. Replace the controller // atomically with one initialised at the safe target index so the // new PTV mounts cleanly. if (targetIndex != currentIndex) { Main.bottomNavigator.removeListener(_onTabControllerChanged); Main.bottomNavigator = PersistentTabController( initialIndex: targetIndex, ); Main.bottomNavigator.addListener(_onTabControllerChanged); _userOnLastTab = targetIndex == totalTabs - 1; } } _knownTotalTabs = totalTabs; return PersistentTabView( key: layoutKey, controller: Main.bottomNavigator, navBarOverlap: const NavBarOverlap.none(), backgroundColor: Theme.of(context).colorScheme.primary, handleAndroidBackButtonPress: true, screenTransitionAnimation: const ScreenTransitionAnimation( curve: Curves.easeOutQuad, duration: Duration(milliseconds: 200), ), tabs: [ ...bottomBarModules.map((e) => e.toBottomTab(context)), PersistentTabConfig( screen: const Breaker(breaker: BreakerArea.more, child: Overhang()), item: ItemConfig( activeForegroundColor: Theme.of(context).primaryColor, inactiveForegroundColor: Theme.of(context).colorScheme.secondary, icon: const Icon(Icons.apps), title: 'Mehr', ), ), ], navBarBuilder: (config) => Style6BottomNavBar( // Style6BottomNavBar builds its internal animation controller list // in initState and never grows it on didUpdateWidget. Keying by the // item count forces a fresh State whenever the slot count changes, // which avoids a RangeError when more tabs slide in. key: ValueKey(config.items.length), navBarConfig: config, navBarDecoration: NavBarDecoration( border: Border( top: BorderSide( width: 1, color: Theme.of(context).colorScheme.outlineVariant, ), ), color: Theme.of(context).colorScheme.surface, ), ), ); }, ); }