Files
Client/lib/app.dart
T

281 lines
10 KiB
Dart

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<App> createState() => _AppState();
}
class _AppState extends State<App> with WidgetsBindingObserver {
late Timer _updateTimings;
StreamSubscription<dynamic>? _timetableWidgetSync;
StreamSubscription<RemoteMessage>? _onMessageSub;
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
StreamSubscription<String>? _fcmTokenRefreshSub;
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<ChatListBloc>();
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<void> _handlePendingWidgetNavigation() async {
final pending = await WidgetNavigation.consumePendingTimetableTap();
if (!pending || !mounted) return;
// `withNavBar: false` routes sit on the root navigator above the
// bottom-nav; pop them so jumpToTab is actually visible. Stop at
// popups so open dialogs/sheets stay alive.
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 would otherwise leave the previous share-flow page
// on top with stale (already-cleared) file paths.
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<BreakerBloc>().refresh();
context.read<ChatListBloc>().refresh();
// Re-mounts on every login, so this also covers post-logout state reset.
final timetable = context.read<TimetableBloc>();
timetable.refresh();
// Mirror BLoC updates into the home-screen widget without waiting
// for the periodic background refresh.
final settingsCubit = context.read<SettingsCubit>();
_timetableWidgetSync?.cancel();
_timetableWidgetSync = timetable.stream.listen((state) {
final data = state.data;
if (data is TimetableState && !state.isLoading) {
unawaited(
WidgetPublisher.publishFromBlocState(
data,
settings: settingsCubit.val(),
),
);
}
});
// Initial publish in case hydrated storage already has data.
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<SettingsCubit>().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<SettingsCubit, model.Settings>(
builder: (context, _) {
final bottomBarModules = AppModule.getBottomBarModules(context);
final totalTabs = bottomBarModules.length + 1;
final currentIndex = Main.bottomNavigator.index;
// PersistentTabView caches per-tab navigators by index and only
// appends/trims at the end, so reordering/hiding leaves stale
// route stacks under the wrong tabs. Re-key on layout to remount.
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;
}
// Replace the controller atomically: a stale index past the new
// tab list crashes Style6BottomNavBar's initState.
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(
// Animation controllers are built once in initState and never
// grown — re-key on item count to avoid RangeError on growth.
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,
),
),
);
},
);
}