281 lines
10 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|