added support for multiple accounts with an account switcher in settings

This commit is contained in:
2026-09-27 14:54:10 +02:00
parent 5e65c4671a
commit 27563a6dc1
33 changed files with 1801 additions and 462 deletions
+218 -199
View File
@@ -11,7 +11,6 @@ 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';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:jiffy/jiffy.dart';
import 'package:loader_overlay/loader_overlay.dart';
import 'package:path_provider/path_provider.dart';
@@ -33,19 +32,20 @@ import 'push/push_registration.dart';
import 'push/push_registration_store.dart';
import 'push/push_renderer.dart';
import 'routing/app_routes.dart';
import 'session/account_codec.dart';
import 'session/session_lifecycle.dart';
import 'session/session_manager.dart';
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/breaker/bloc/breaker_bloc.dart';
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import 'state/app/modules/chat/bloc/chat_bloc.dart';
import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
import 'state/app/modules/settings/bloc/settings_cubit.dart';
import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
import 'storage/hydrated_storage_bootstrap.dart';
import 'storage/account_storage.dart';
import 'storage/settings.dart';
import 'theming/dark_app_theme.dart';
import 'theming/light_app_theme.dart';
@@ -59,6 +59,7 @@ 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';
import 'widget_data/widget_sync.dart';
/// Runs one startup step with a time limit. Anything that throws or hangs
@@ -146,8 +147,7 @@ Future<void> main() async {
]),
),
_startupStep('hydrated storage', () async {
final path = (await getTemporaryDirectory()).path;
HydratedBloc.storage = await buildHydratedStorageWithFallback(path);
await AccountStorage.init((await getTemporaryDirectory()).path);
}, timeout: null),
_startupStep('documents dir', () async {
AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path;
@@ -167,6 +167,16 @@ Future<void> main() async {
log('starting app initialisation...');
await Future.wait(initialisationTasks);
// Snapshot: the session may still finish loading later (see _MainState),
// the bloc and the storage must agree on the account they start with.
final initialAccount = SessionManager().isLoaded
? SessionManager().activeAccount
: null;
await _startupStep(
'account storage',
() => AccountStorage.activate(initialAccount),
timeout: null,
);
log('app initialisation done!');
// Independent of the notification setup below, so it runs alongside it.
@@ -237,34 +247,48 @@ Future<void> main() async {
// placeholder flash.
AvatarDiskCache.instance.warmUp();
// Created eagerly so the endpoint is configured before anything below can
// issue a request (the timetable bloc loads on creation).
final settingsCubit = SettingsCubit(storage: AccountStorage.global);
_syncMarianumConnectEndpoint(settingsCubit.state);
settingsCubit.stream.listen(_syncMarianumConnectEndpoint);
log('running app...');
runApp(
MultiBlocProvider(
providers: [
BlocProvider<SettingsCubit>(create: (_) => SettingsCubit()),
BlocProvider<SettingsCubit>.value(value: settingsCubit),
BlocProvider<AccountBloc>(
create: (_) => AccountBloc(initialStatus: _initialAccountStatus()),
create: (_) => AccountBloc(
initialStatus: _initialAccountStatus(initialAccount),
accountId: initialAccount?.id,
),
),
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
BlocProvider<CapabilitiesCubit>(create: (_) => CapabilitiesCubit()),
BlocProvider<NextcloudCapabilitiesCubit>(
create: (_) => NextcloudCapabilitiesCubit(),
),
BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()),
BlocProvider<ChatBloc>(
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
),
BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()),
],
child: const Main(),
),
);
}
AccountStatus _initialAccountStatus() {
final session = SessionManager();
if (session.isSignedIn) return AccountStatus.loggedIn;
return session.isLoaded ? AccountStatus.loggedOut : AccountStatus.undefined;
String? _syncedMcBaseUrl;
/// Keeps the MC dio singleton aligned with the selected endpoint (live /
/// beta / custom), mirrored into WidgetSync so the background isolate
/// refreshes against the same endpoint. Settings emit on every toggle; only
/// an actual URL change is applied.
void _syncMarianumConnectEndpoint(Settings settings) {
final url = settings.devToolsSettings.resolveMarianumConnectBaseUrl();
if (url == _syncedMcBaseUrl) return;
_syncedMcBaseUrl = url;
MarianumConnectEndpoint.update(url);
unawaited(WidgetSync.setMarianumConnectBaseUrl(url));
}
AccountStatus _initialAccountStatus(AccountEntry? account) {
if (account != null) return AccountStatus.loggedIn;
return SessionManager().isLoaded
? AccountStatus.loggedOut
: AccountStatus.undefined;
}
class Main extends StatefulWidget {
@@ -293,31 +317,52 @@ class _MainState extends State<Main> {
Jiffy.setLocale('de');
_lastStatus = context.read<AccountBloc>().state.status;
SessionManager().waitForLoad().then((session) {
SessionManager().waitForLoad().then((session) async {
if (!mounted) return;
final accountBloc = context.read<AccountBloc>();
accountBloc.setStatus(
session != null ? AccountStatus.loggedIn : AccountStatus.loggedOut,
);
if (session != null) {
_scheduleSessionValidation(accountBloc);
// Cold start while already logged in: the account status doesn't
// change, so the loggedIn listener below never fires — refresh
// capabilities here, then self-heal the push registration.
final settingsCubit = context.read<SettingsCubit>();
unawaited(
context.read<CapabilitiesCubit>().load().then((_) {
if (!mounted) return;
final capabilities = context.read<CapabilitiesCubit>();
_syncPush(settingsCubit, capabilities);
_applyRoleDefaults(settingsCubit, capabilities);
}),
);
unawaited(context.read<NextcloudCapabilitiesCubit>().load());
if (session == null) {
accountBloc.setStatus(AccountStatus.loggedOut);
return;
}
// Covers a session that finished loading after the startup snapshot;
// otherwise the id is unchanged and nothing remounts.
final account = SessionManager().activeAccount;
await AccountStorage.activate(account);
if (!mounted) return;
accountBloc.activated(account?.id);
_scheduleSessionValidation(accountBloc);
});
}
/// Runs whenever the account-scoped tree mounts for a signed-in account:
/// pulls the capability flags, then registers push for this account.
void _onSessionActive(BuildContext scopeContext) {
final settingsCubit = scopeContext.read<SettingsCubit>();
final capabilitiesCubit = scopeContext.read<CapabilitiesCubit>();
unawaited(
capabilitiesCubit.load().then((_) {
// Closed: the account was switched away from while loading.
if (!mounted || capabilitiesCubit.isClosed) return;
_syncPush(settingsCubit, capabilitiesCubit);
_applyRoleDefaults(settingsCubit, capabilitiesCubit);
}),
);
unawaited(scopeContext.read<NextcloudCapabilitiesCubit>().load());
unawaited(SessionLifecycle.refreshDisplayName());
_prefetchBaseData(scopeContext);
WidgetsBinding.instance.addPostFrameCallback((_) => _showTakeoverNotice());
}
/// A forced sign-out that handed over to another account never reaches the
/// login screen, which normally explains it.
void _showTakeoverNotice() {
final notice = SessionLifecycle.signOutNotice.value;
final overlayContext = AppRoutes.overlayContext;
if (notice == null || overlayContext == null) return;
SessionLifecycle.signOutNotice.value = null;
InfoDialog.show(overlayContext, notice, title: 'Abgemeldet');
}
/// Warms the core caches (timetable, chat list, files root) in the
/// background so the first screen render hits populated data.
void _prefetchBaseData(BuildContext context) {
@@ -362,19 +407,48 @@ class _MainState extends State<Main> {
}
/// Background credential check: a 401 means the password was rotated
/// server-side, so the validator wipes the local session and flips the
/// account bloc to `loggedOut` (sending the user to the login screen).
/// server-side, so the validator signs the account out and the app moves
/// on to another stored account or the login screen.
void _scheduleSessionValidation(AccountBloc accountBloc) {
unawaited(
SessionValidator.probeStored(
onInvalidated: () async {
onInvalidated: (nextAccountId) async {
if (!mounted) return;
accountBloc.setStatus(AccountStatus.loggedOut);
accountBloc.activated(nextAccountId);
},
),
);
}
void _onAccountChanged(BuildContext context, AccountState accountState) {
if (accountState.status == AccountStatus.loggedIn &&
accountState.freshLogin) {
_showPostLoginSplash = true;
_appMounted = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _appMounted = true);
});
}
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(SessionWipe.deviceLeft()),
);
}
/// 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,
@@ -384,7 +458,6 @@ class _MainState extends State<Main> {
child: BlocSelector<SettingsCubit, Settings, _RootSettings>(
selector: (settings) => (
appTheme: settings.appTheme,
mcBaseUrl: settings.devToolsSettings.resolveMarianumConnectBaseUrl(),
notificationsEnabled: settings.notificationSettings.enabled,
showPerformanceOverlay:
settings.devToolsSettings.showPerformanceOverlay,
@@ -395,12 +468,6 @@ class _MainState extends State<Main> {
textScaleOverride: settings.devToolsSettings.textScaleOverride,
),
builder: (context, root) {
// Keep the MC dio singleton aligned with the currently selected
// endpoint (live / beta / custom). Idempotent when the URL is
// unchanged. Mirrored into WidgetSync so the background isolate
// refreshes against the same endpoint.
MarianumConnectEndpoint.update(root.mcBaseUrl);
unawaited(WidgetSync.setMarianumConnectBaseUrl(root.mcBaseUrl));
// Mirror the notification toggle into group-scoped storage so the FCM
// background isolate and the iOS NSE can suppress rendering when off.
unawaited(
@@ -409,164 +476,116 @@ class _MainState extends State<Main> {
),
);
timeDilation = root.slowAnimations ? 5.0 : 1.0;
return 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).
child: DownloadTrayHost(child: child ?? const SizedBox.shrink()),
);
final scale = root.textScaleOverride;
if (scale > 0) {
app = MediaQuery.withClampedTextScaling(
minScaleFactor: scale,
maxScaleFactor: scale,
child: app,
);
return BlocConsumer<AccountBloc, AccountState>(
listenWhen: (previous, current) =>
previous.status != current.status ||
previous.accountId != current.accountId,
listener: _onAccountChanged,
buildWhen: (previous, current) =>
_scopeOf(previous) != _scopeOf(current),
builder: (context, account) {
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 DebugStatsOverlay(
showFrameStats: root.showFrameStats,
showMemoryStats: root.showMemoryStats,
child: app,
return AccountScope(
key: ValueKey(scope),
onActive: _onSessionActive,
child: _buildApp(root),
);
},
home: EmergencyNoticeGate(
child: LoaderOverlay(
child: Breaker(
breaker: BreakerArea.global,
child: BlocConsumer<AccountBloc, AccountState>(
listenWhen: (previous, current) =>
previous.status != current.status,
listener: (context, accountState) {
final wasLoggedIn = _lastStatus == AccountStatus.loggedIn;
_lastStatus = accountState.status;
// Fresh login (loggedOut -> loggedIn): pull capability flags
// for the newly authenticated user, then register push right
// away instead of deferring it to the next app start.
if (accountState.status == AccountStatus.loggedIn) {
final settingsCubit = context.read<SettingsCubit>();
final capabilitiesCubit = context
.read<CapabilitiesCubit>();
unawaited(
capabilitiesCubit.load().then((_) {
if (!mounted) return;
_syncPush(settingsCubit, capabilitiesCubit);
_applyRoleDefaults(settingsCubit, capabilitiesCubit);
}),
);
unawaited(
context.read<NextcloudCapabilitiesCubit>().load(),
);
_showPostLoginSplash = true;
_appMounted = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _appMounted = true);
});
_prefetchBaseData(context);
}
if (accountState.status != AccountStatus.loggedOut) return;
// A pending share would otherwise survive logout and be
// re-applied to the next account. A cold-start share that
// is merely waiting for the stored session to resolve as
// signed out stays, to be sent after login.
if (wasLoggedIn) SessionWipe.clearImmediate();
// Routes pushed via AppRoutes (e.g. Settings) live on the
// root navigator and survive the home swap below, so they
// would still cover the Login screen after logout. Pop
// them here so the user immediately sees Login.
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst);
}
// Capture bloc references before the post-frame callback
// — by the time it runs the dialog/Settings context is
// gone but this listener context is still valid.
final timetableBloc = context.read<TimetableBloc>();
final chatListBloc = context.read<ChatListBloc>();
final chatBloc = context.read<ChatBloc>();
final breakerBloc = context.read<BreakerBloc>();
final capabilitiesCubit = context.read<CapabilitiesCubit>();
final nextcloudCapabilitiesCubit = context
.read<NextcloudCapabilitiesCubit>();
// Defer the actual wipe until after this frame so the
// App tree (TimetableBloc/ChatListBloc watchers etc.)
// is already torn down. Resetting blocs while App is
// still in front caused a black-frame race.
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(
SessionWipe.run(
timetableBloc: timetableBloc,
chatListBloc: chatListBloc,
chatBloc: chatBloc,
breakerBloc: breakerBloc,
capabilitiesCubit: capabilitiesCubit,
nextcloudCapabilitiesCubit:
nextcloudCapabilitiesCubit,
),
);
});
},
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,
),
),
],
);
case AccountStatus.loggedOut:
return const Login();
case AccountStatus.undefined:
return const AccountLoadingScreen();
}
},
),
),
),
),
);
},
),
);
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).
child: DownloadTrayHost(child: child ?? const SizedBox.shrink()),
);
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),
),
],
);
case AccountStatus.loggedOut:
return const Login();
case AccountStatus.addingAccount:
return const Login(addingAccount: true);
case AccountStatus.undefined:
return const AccountLoadingScreen();
}
},
),
),
),
),
);
}
typedef _RootSettings = ({
ThemeMode appTheme,
String mcBaseUrl,
bool notificationsEnabled,
bool showPerformanceOverlay,
bool showSemanticsDebugger,