merged develop
This commit is contained in:
+162
-117
@@ -7,6 +7,7 @@ import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart' show timeDilation;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
@@ -14,8 +15,8 @@ import 'package:jiffy/jiffy.dart';
|
||||
import 'package:loader_overlay/loader_overlay.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'access/user_role.dart';
|
||||
import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart';
|
||||
import 'api/marianumconnect/auth/session_validator.dart';
|
||||
import 'api/marianumconnect/marianumconnect_endpoint.dart';
|
||||
@@ -26,6 +27,7 @@ import 'app.dart';
|
||||
import 'auth_link/guardian_link_listener.dart';
|
||||
import 'background/widget_background_task.dart';
|
||||
import 'firebase_options.dart';
|
||||
import 'model/session_wipe.dart';
|
||||
import 'notification/notification_service.dart';
|
||||
import 'push/notification_permission_prompt.dart';
|
||||
import 'push/push_message_handler.dart';
|
||||
@@ -40,6 +42,7 @@ import 'share_intent/share_intent_listener.dart';
|
||||
import 'state/app/modules/account/account_scope.dart';
|
||||
import 'state/app/modules/account/bloc/account_bloc.dart';
|
||||
import 'state/app/modules/account/bloc/account_state.dart';
|
||||
import 'state/app/modules/app_modules.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/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
|
||||
@@ -57,6 +60,7 @@ import 'view/login/login.dart';
|
||||
import 'view/login/post_login_splash.dart';
|
||||
import 'widget/avatar_disk_cache.dart';
|
||||
import 'widget/breaker/breaker.dart';
|
||||
import 'widget/debug/debug_stats_overlay.dart';
|
||||
import 'widget/downloads/download_tray.dart';
|
||||
import 'widget/emergency/emergency_notice_gate.dart';
|
||||
import 'widget/info_dialog.dart';
|
||||
@@ -120,6 +124,7 @@ void _installErrorHandlers() {
|
||||
Future<void> main() async {
|
||||
log('MarianumMobile started');
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
SessionManager().markUiEngine();
|
||||
// Before any initialisation so startup failures reach the backend too.
|
||||
_installErrorHandlers();
|
||||
|
||||
@@ -179,6 +184,12 @@ Future<void> main() async {
|
||||
);
|
||||
log('app initialisation done!');
|
||||
|
||||
// Independent of the notification setup below, so it runs alongside it.
|
||||
final widgetSyncInit = _startupStep(
|
||||
'widget sync',
|
||||
WidgetSync.ensureInitialized,
|
||||
);
|
||||
|
||||
// Local notifications: init the plugin (with tap/action callbacks) and the
|
||||
// Android channels, then register the FCM background isolate handler that
|
||||
// decrypts and renders Nextcloud pushes while the app is not in foreground.
|
||||
@@ -198,9 +209,9 @@ Future<void> main() async {
|
||||
),
|
||||
);
|
||||
|
||||
// Wire up the home-screen widget bridge before runApp so any widget render
|
||||
// triggered during startup hits initialised native storage.
|
||||
await _startupStep('widget sync', WidgetSync.ensureInitialized);
|
||||
// The home-screen widget bridge must be ready before runApp so any widget
|
||||
// render triggered during startup hits initialised native storage.
|
||||
await widgetSyncInit;
|
||||
unawaited(
|
||||
WidgetBackgroundTask.initialize().onError(
|
||||
(e, _) => log('Workmanager init failed: $e'),
|
||||
@@ -297,13 +308,19 @@ class Main extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MainState extends State<Main> {
|
||||
final List<NavigatorObserver> _navigatorObservers = [
|
||||
AppRoutes.chatRouteObserver,
|
||||
DownloadRouteObserver(),
|
||||
];
|
||||
bool _showPostLoginSplash = false;
|
||||
bool _appMounted = true;
|
||||
late AccountStatus _lastStatus;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Jiffy.setLocale('de');
|
||||
_lastStatus = context.read<AccountBloc>().state.status;
|
||||
|
||||
SessionManager().unauthorizedSignal.addListener(_onUnauthorized);
|
||||
GuardianLinkListener.pending.addListener(_onGuardianLink);
|
||||
@@ -332,8 +349,10 @@ class _MainState extends State<Main> {
|
||||
final capabilitiesCubit = scopeContext.read<CapabilitiesCubit>();
|
||||
unawaited(
|
||||
capabilitiesCubit.load().then((_) {
|
||||
if (!mounted) return;
|
||||
// Closed: the account was switched away from while loading.
|
||||
if (!mounted || capabilitiesCubit.isClosed) return;
|
||||
_syncPush(settingsCubit, capabilitiesCubit);
|
||||
_applyRoleDefaults(settingsCubit, capabilitiesCubit);
|
||||
_promptGuardianNotifications();
|
||||
}),
|
||||
);
|
||||
@@ -393,6 +412,19 @@ class _MainState extends State<Main> {
|
||||
);
|
||||
}
|
||||
|
||||
/// userType is only known once capabilities are loaded.
|
||||
void _applyRoleDefaults(
|
||||
SettingsCubit settings,
|
||||
CapabilitiesCubit capabilities,
|
||||
) {
|
||||
if (AppModule.applyTeacherDefaults(
|
||||
settings.val().modulesSettings,
|
||||
isTeacher: capabilities.state.role == UserRole.teacher,
|
||||
)) {
|
||||
settings.val(write: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
SessionManager().unauthorizedSignal.removeListener(_onUnauthorized);
|
||||
@@ -444,55 +476,72 @@ class _MainState extends State<Main> {
|
||||
if (mounted) setState(() => _appMounted = true);
|
||||
});
|
||||
}
|
||||
if (accountState.status == AccountStatus.addingAccount) {
|
||||
// Routes pushed on the root navigator (Settings) would otherwise keep
|
||||
// covering the login screen that replaces the home route.
|
||||
AppRoutes.rootNavigatorKey.currentState?.popUntil(
|
||||
(route) => route.isFirst,
|
||||
);
|
||||
}
|
||||
if (accountState.status != AccountStatus.loggedOut) return;
|
||||
// A pending share would otherwise survive logout and be re-applied after
|
||||
// re-login with file paths the OS may already have evicted from the cache.
|
||||
ShareIntentListener.instance.clear();
|
||||
final wasLoggedIn = _lastStatus == AccountStatus.loggedIn;
|
||||
_lastStatus = accountState.status;
|
||||
// A cold start that merely resolves as signed out has nothing to wipe,
|
||||
// and a share waiting for the login must survive it.
|
||||
if (accountState.status != AccountStatus.loggedOut || !wasLoggedIn) return;
|
||||
// Deferred until the account-scoped tree is torn down.
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => unawaited(_wipeDeviceState()),
|
||||
(_) => unawaited(SessionWipe.deviceLeft()),
|
||||
);
|
||||
}
|
||||
|
||||
String? _scopeAccountId;
|
||||
/// The account-scoped tree only lives while its account is the one in use:
|
||||
/// adding another account parks it, so its blocs go as well.
|
||||
static Object _scopeOf(AccountState state) => (
|
||||
state.accountId,
|
||||
state.status == AccountStatus.addingAccount,
|
||||
);
|
||||
|
||||
Object? _scope;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: BlocBuilder<SettingsCubit, Settings>(
|
||||
builder: (context, settings) {
|
||||
// Selects only what the root uses: Settings is mutated in place and
|
||||
// re-emitted as a fresh instance on every write, so a plain BlocBuilder
|
||||
// would rebuild MaterialApp (and every route) for unrelated settings.
|
||||
child: BlocSelector<SettingsCubit, Settings, _RootSettings>(
|
||||
selector: (settings) => (
|
||||
appTheme: settings.appTheme,
|
||||
notificationsEnabled: settings.notificationSettings.enabled,
|
||||
showPerformanceOverlay:
|
||||
settings.devToolsSettings.showPerformanceOverlay,
|
||||
showSemanticsDebugger: settings.devToolsSettings.showSemanticsDebugger,
|
||||
showFrameStats: settings.devToolsSettings.showFrameStats,
|
||||
showMemoryStats: settings.devToolsSettings.showMemoryStats,
|
||||
slowAnimations: settings.devToolsSettings.slowAnimations,
|
||||
textScaleOverride: settings.devToolsSettings.textScaleOverride,
|
||||
),
|
||||
builder: (context, root) {
|
||||
// Mirror the notification toggle into group-scoped storage so the FCM
|
||||
// background isolate and the iOS NSE can suppress rendering when off.
|
||||
unawaited(
|
||||
const PushRegistrationStore().setNotificationsEnabled(
|
||||
settings.notificationSettings.enabled,
|
||||
root.notificationsEnabled,
|
||||
),
|
||||
);
|
||||
timeDilation = root.slowAnimations ? 5.0 : 1.0;
|
||||
return BlocConsumer<AccountBloc, AccountState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.status != current.status ||
|
||||
previous.accountId != current.accountId,
|
||||
listener: _onAccountChanged,
|
||||
buildWhen: (previous, current) =>
|
||||
previous.accountId != current.accountId,
|
||||
_scopeOf(previous) != _scopeOf(current),
|
||||
builder: (context, account) {
|
||||
if (account.accountId != _scopeAccountId) {
|
||||
_scopeAccountId = account.accountId;
|
||||
final scope = _scopeOf(account);
|
||||
if (scope != _scope) {
|
||||
_scope = scope;
|
||||
// The old navigator (and every route on it) goes with the old
|
||||
// account; a shared GlobalKey would carry it over instead.
|
||||
AppRoutes.rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
}
|
||||
return AccountScope(
|
||||
key: ValueKey(account.accountId),
|
||||
key: ValueKey(scope),
|
||||
onActive: _onSessionActive,
|
||||
child: _buildApp(settings),
|
||||
child: _buildApp(root),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -500,101 +549,97 @@ class _MainState extends State<Main> {
|
||||
),
|
||||
);
|
||||
|
||||
Widget _buildApp(Settings settings) {
|
||||
final devToolsSettings = settings.devToolsSettings;
|
||||
return MaterialApp(
|
||||
showPerformanceOverlay: devToolsSettings.showPerformanceOverlay,
|
||||
checkerboardOffscreenLayers: devToolsSettings.checkerboardOffscreenLayers,
|
||||
checkerboardRasterCacheImages:
|
||||
devToolsSettings.checkerboardRasterCacheImages,
|
||||
debugShowCheckedModeBanner: false,
|
||||
navigatorKey: AppRoutes.rootNavigatorKey,
|
||||
// Used by ChatView.didPopNext to reclaim the global ChatBloc.
|
||||
// DownloadRouteObserver tracks full-page navigations so the downloads
|
||||
// chip only surfaces once the user leaves the screen they started on.
|
||||
navigatorObservers: [
|
||||
AppRoutes.chatRouteObserver,
|
||||
DownloadRouteObserver(),
|
||||
],
|
||||
localizationsDelegates: const [
|
||||
...GlobalMaterialLocalizations.delegates,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [Locale('de'), Locale('en')],
|
||||
locale: const Locale('de'),
|
||||
title: 'Marianum Fulda',
|
||||
themeMode: settings.appTheme,
|
||||
theme: LightAppTheme.theme,
|
||||
darkTheme: DarkAppTheme.theme,
|
||||
// Brand-colored backdrop behind every route. During the logout
|
||||
// home-swap and route pop animations the framework can briefly
|
||||
// expose the layer below the topmost Scaffold; without this
|
||||
// the dark Material default shows through and the user sees a
|
||||
// black flash.
|
||||
builder: (context, child) => ColoredBox(
|
||||
Widget _buildApp(_RootSettings root) => MaterialApp(
|
||||
showPerformanceOverlay: root.showPerformanceOverlay,
|
||||
showSemanticsDebugger: root.showSemanticsDebugger,
|
||||
debugShowCheckedModeBanner: false,
|
||||
navigatorKey: AppRoutes.rootNavigatorKey,
|
||||
// Used by ChatView.didPopNext to reclaim the global ChatBloc.
|
||||
// DownloadRouteObserver tracks full-page navigations so the downloads
|
||||
// chip only surfaces once the user leaves the screen they started on.
|
||||
navigatorObservers: _navigatorObservers,
|
||||
localizationsDelegates: const [
|
||||
...GlobalMaterialLocalizations.delegates,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [Locale('de'), Locale('en')],
|
||||
locale: const Locale('de'),
|
||||
title: 'Marianum Fulda',
|
||||
themeMode: root.appTheme,
|
||||
theme: LightAppTheme.theme,
|
||||
darkTheme: DarkAppTheme.theme,
|
||||
// Brand-colored backdrop behind every route. During the logout
|
||||
// home-swap and route pop animations the framework can briefly
|
||||
// expose the layer below the topmost Scaffold; without this
|
||||
// the dark Material default shows through and the user sees a
|
||||
// black flash.
|
||||
builder: (context, child) {
|
||||
Widget app = ColoredBox(
|
||||
color: LightAppTheme.marianumRed,
|
||||
// Downloads tray mounted ABOVE the navigator so its chip floats over
|
||||
// every route (folder views, chat, viewer are full-page pushes that
|
||||
// would otherwise cover it).
|
||||
// Downloads tray mounted ABOVE the navigator so its chip floats
|
||||
// over every route (folder views, chat, viewer are full-page
|
||||
// pushes that would otherwise cover it).
|
||||
child: DownloadTrayHost(child: child ?? const SizedBox.shrink()),
|
||||
),
|
||||
home: EmergencyNoticeGate(
|
||||
child: LoaderOverlay(
|
||||
child: Breaker(
|
||||
breaker: BreakerArea.global,
|
||||
child: BlocBuilder<AccountBloc, AccountState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.status != current.status,
|
||||
builder: (context, accountState) {
|
||||
switch (accountState.status) {
|
||||
case AccountStatus.loggedIn:
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (_appMounted) const App(key: ValueKey('app-shell')),
|
||||
if (_showPostLoginSplash)
|
||||
PostLoginSplash(
|
||||
key: const ValueKey('post-login-splash'),
|
||||
onComplete: () {
|
||||
setState(() => _showPostLoginSplash = false);
|
||||
_promptGuardianNotifications();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
case AccountStatus.loggedOut:
|
||||
return const Login();
|
||||
case AccountStatus.addingAccount:
|
||||
return const Login(addingAccount: true);
|
||||
case AccountStatus.undefined:
|
||||
return const AccountLoadingScreen();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
final scale = root.textScaleOverride;
|
||||
if (scale > 0) {
|
||||
app = MediaQuery.withClampedTextScaling(
|
||||
minScaleFactor: scale,
|
||||
maxScaleFactor: scale,
|
||||
child: app,
|
||||
);
|
||||
}
|
||||
return DebugStatsOverlay(
|
||||
showFrameStats: root.showFrameStats,
|
||||
showMemoryStats: root.showMemoryStats,
|
||||
child: app,
|
||||
);
|
||||
},
|
||||
home: EmergencyNoticeGate(
|
||||
child: LoaderOverlay(
|
||||
child: Breaker(
|
||||
breaker: BreakerArea.global,
|
||||
child: BlocBuilder<AccountBloc, AccountState>(
|
||||
buildWhen: (previous, current) => previous.status != current.status,
|
||||
builder: (context, accountState) {
|
||||
switch (accountState.status) {
|
||||
case AccountStatus.loggedIn:
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (_appMounted) const App(key: ValueKey('app-shell')),
|
||||
if (_showPostLoginSplash)
|
||||
PostLoginSplash(
|
||||
key: const ValueKey('post-login-splash'),
|
||||
onComplete: () {
|
||||
setState(() => _showPostLoginSplash = false);
|
||||
_promptGuardianNotifications();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
case AccountStatus.loggedOut:
|
||||
return const Login();
|
||||
case AccountStatus.addingAccount:
|
||||
return const Login(addingAccount: true);
|
||||
case AccountStatus.undefined:
|
||||
return const AccountLoadingScreen();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Wipes what outlives accounts once the last one signed out. Account data
|
||||
/// itself is removed by [SessionLifecycle.signOut].
|
||||
Future<void> _wipeDeviceState() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.clear();
|
||||
// The chat background image lives outside HydratedStorage, so clear it too
|
||||
// (best-effort) to avoid orphaning the previous user's wallpaper.
|
||||
final backgroundImage = File(AppPaths.chatBackgroundImage);
|
||||
if (backgroundImage.existsSync()) backgroundImage.deleteSync();
|
||||
// Stop the periodic widget refresh job so the background isolate doesn't
|
||||
// wake up every 30 minutes only to write `loggedIn=false`. Re-registers
|
||||
// on the next successful login.
|
||||
await WidgetBackgroundTask.cancelAll();
|
||||
await WidgetSync.clear();
|
||||
await WidgetSync.triggerUpdate();
|
||||
} catch (e, s) {
|
||||
log('Device state wipe failed: $e', stackTrace: s);
|
||||
}
|
||||
}
|
||||
typedef _RootSettings = ({
|
||||
ThemeMode appTheme,
|
||||
bool notificationsEnabled,
|
||||
bool showPerformanceOverlay,
|
||||
bool showSemanticsDebugger,
|
||||
bool showFrameStats,
|
||||
bool showMemoryStats,
|
||||
bool slowAnimations,
|
||||
double textScaleOverride,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user