Files
Client/lib/app.dart
T

367 lines
14 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/marianumconnect/queries/get_breakers/get_breakers_response.dart';
import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart';
import 'main.dart';
import 'model/data_cleaner.dart';
import 'notification/notification_controller.dart';
import 'notification/notification_service.dart';
import 'notification/notification_tasks.dart';
import 'push/push_registration.dart';
import 'push/push_tap_router.dart';
import 'routing/app_routes.dart';
import 'session/session_manager.dart';
import 'share_intent/share_intent_listener.dart';
import 'state/app/infrastructure/loadable_state/loadable_state.dart';
import 'state/app/modules/app_modules.dart';
import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
import 'state/app/modules/capabilities/bloc/capabilities_cubit.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 'state/app/modules/timetable/policy/timetable_policy.dart';
import 'storage/settings.dart' as model;
import 'utils/debouncer.dart';
import 'utils/haptics.dart';
import 'view/pages/overhang.dart';
import 'widget/breaker/breaker.dart';
import 'widget/info_dialog.dart';
import 'widget_data/widget_navigation.dart';
import 'widget_data/widget_publisher.dart';
import 'widget_data/widget_sync.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<RemoteMessage>? _onMessageSub;
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
StreamSubscription<String>? _fcmTokenRefreshSub;
int _knownTotalTabs = 1;
int _lastTabIndex = 0;
bool _userOnLastTab = false;
DateTime? _lastTelemetryAt;
static const Duration _chatListActiveInterval = Duration(seconds: 15);
static const Duration _chatListIdleInterval = Duration(seconds: 60);
static const Duration _telemetryInterval = Duration(minutes: 15);
void _onTabControllerChanged() {
final newIndex = Main.bottomNavigator.index;
if (newIndex != _lastTabIndex) {
Haptics.selection();
_lastTabIndex = newIndex;
}
_userOnLastTab = newIndex == _knownTotalTabs - 1;
_syncChatListPolling();
}
void _syncChatListPolling({bool refresh = true}) {
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 && refresh) bloc.refresh();
}
// Wall-clock throttle rather than Debouncer.throttle: a Timer does not tick
// reliably while the app is suspended, so the window would still be open on
// the resume it is supposed to let through.
void _reportTelemetry() {
if (!mounted) return;
final now = DateTime.now();
final last = _lastTelemetryAt;
if (last != null && now.difference(last) < _telemetryInterval) return;
_lastTelemetryAt = now;
TelemetryHeartbeat.report(
notificationsEnabled: context
.read<SettingsCubit>()
.val()
.notificationSettings
.enabled,
);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
log('AppLifecycle: $state');
if (state == AppLifecycleState.resumed) {
_reportTelemetry();
Debouncer.throttle('appLifecycleState', const Duration(seconds: 10), () {
if (!mounted) return;
log('Refreshing due to LifecycleChange');
NotificationTasks.updateProviders(context);
});
// updateProviders already refreshes the chat list; only re-arm the poll.
_syncChatListPolling(refresh: false);
_handlePendingWidgetNavigation();
} else if (mounted) {
// Stop polling while backgrounded: a silent refresh failing in the
// background would otherwise leave an error that flashes on the next
// resume before the foreground refetch replaces it.
context.read<ChatListBloc>().setAutoRefreshInterval(null);
}
}
void _onPushTargetPending() {
final target = PushTapRouter.pendingTarget.value;
if (target == null || !mounted) return;
PushTapRouter.pendingTarget.value = null;
NotificationTasks.openPushTarget(context, target);
}
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);
}
/// Mirrors the primary plan into the home-screen widget without waiting
/// for the periodic background refresh.
void _publishWidget(TimetableBloc bloc) {
final data = bloc.state.data;
if (!mounted || data is! TimetableState) return;
if (WidgetSync.encodeSubject(bloc.subject) == null) return;
unawaited(
WidgetPublisher.publishFromBlocState(
data,
subject: bloc.subject,
settings: context.read<SettingsCubit>().val(),
showClassInsteadOfTeacher: TimetablePolicy.resolve(
subject: bloc.subject,
capabilities: context.read<CapabilitiesCubit>().state,
).showClassInsteadOfTeacher,
),
);
}
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.
// Sharing targets Talk chats and Files folders only.
final session = SessionManager().current;
if (!AppModule.isAvailableFor(Modules.talk, session) &&
!AppModule.isAvailableFor(Modules.files, session)) {
ShareIntentListener.instance.clear();
InfoDialog.show(
context,
'Mit diesem Konto können keine Inhalte in die App geteilt werden.',
title: 'Teilen nicht möglich',
);
return;
}
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();
Haptics.bind(context.read<SettingsCubit>());
Main.bottomNavigator = PersistentTabController(initialIndex: 0);
_lastTabIndex = Main.bottomNavigator.index;
Main.bottomNavigator.addListener(_onTabControllerChanged);
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.read<BreakerBloc>().refresh();
context.read<ChatListBloc>().refresh();
// Initial publish in case hydrated storage already has data. No refresh
// needed: PrimaryTimetableScope hands out a freshly loading bloc.
_publishWidget(context.read<TimetableBloc>());
unawaited(_handlePendingWidgetNavigation());
ShareIntentListener.instance.attach();
ShareIntentListener.pending.addListener(_handlePendingShare);
_handlePendingShare();
_syncChatListPolling();
});
_updateTimings = Timer.periodic(const Duration(seconds: 30), (_) {
if (mounted) setState(() {});
});
_reportTelemetry();
// A refreshed FCM token invalidates the existing push subscription — the
// NC device identifier stays stable, so we simply re-register (NC first,
// then the proxy). Debounced so a burst of refreshes triggers one call.
// Not gated on the notification toggle: registration is kept alive even
// when notifications are off so silent sync pushes keep flowing.
_fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen((_) {
Debouncer.debounce(
'pushTokenRefresh',
const Duration(seconds: 3),
() => unawaited(PushRegistration().onTokenRefresh()),
);
});
// Android renders pushes locally, so a tap arrives via the local
// notifications callback (PushTapRouter) rather than onMessageOpenedApp.
PushTapRouter.pendingTarget.addListener(_onPushTargetPending);
unawaited(
PushTapRouter.handleAppLaunch(
NotificationService().flutterLocalNotificationsPlugin,
),
);
_onMessageSub = FirebaseMessaging.onMessage.listen((message) {
if (!mounted) return;
NotificationController.onForegroundMessageHandler(message, context);
});
// iOS delivers alert pushes (Connect direct pushes, and NC pushes rendered
// by the NSE) natively; a tap surfaces here.
_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();
_onMessageSub?.cancel();
_onMessageOpenedAppSub?.cancel();
_fcmTokenRefreshSub?.cancel();
PushTapRouter.pendingTarget.removeListener(_onPushTargetPending);
ShareIntentListener.pending.removeListener(_handlePendingShare);
ShareIntentListener.instance.detach();
Main.bottomNavigator.removeListener(_onTabControllerChanged);
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
Widget build(BuildContext context) =>
BlocListener<TimetableBloc, LoadableState<TimetableState>>(
// Also follows a swapped instance (child switch), unlike a manual
// stream subscription.
listenWhen: (_, state) => !state.isLoading,
// A week change emits several times (week, prefetched neighbours);
// one widget reload for the burst is enough.
listener: (_, _) => Debouncer.debounce(
'widgetPublish',
const Duration(milliseconds: 500),
() {
if (mounted) _publishWidget(context.read<TimetableBloc>());
},
),
child: _buildShell(context),
);
Widget _buildShell(
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 || 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,
);
_lastTabIndex = 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,
),
),
);
},
);
}