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
+92 -45
View File
@@ -1,12 +1,13 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../background/widget_background_task.dart';
import '../../session/session_lifecycle.dart';
import '../../session/session_manager.dart';
import '../../state/app/modules/account/bloc/account_bloc.dart';
import '../../state/app/modules/account/bloc/account_state.dart';
import '../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../storage/dev_tools_settings.dart';
import '../../storage/settings.dart' as model;
@@ -20,7 +21,10 @@ import 'widgets/login_branding.dart';
import 'widgets/login_card.dart';
class Login extends StatefulWidget {
const Login({super.key});
/// Signs in another account while one is active; offers to go back.
final bool addingAccount;
const Login({super.key, this.addingAccount = false});
@override
State<Login> createState() => _LoginState();
@@ -65,59 +69,100 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
super.dispose();
}
void _onLoginSuccess() {
Future<void> _onLoginSuccess() async {
Haptics.heavyAccent();
final accountBloc = context.read<AccountBloc>();
// Fade the login content out before handing over to the post-login splash.
// Both share the red backdrop, so this reads as one continuous transition
// instead of an abrupt swap.
final finished = SessionLifecycle.finishLogin();
await _fade.reverse().orCancel.onError<TickerCanceled>((_, _) {});
String? accountId;
try {
accountId = await finished;
} on Object catch (e) {
log('Login: finishing failed: $e');
accountId = SessionManager().activeAccount?.id;
}
// Re-register the periodic refresh (cancelAll runs on logout) and kick
// off an immediate one-off so the widget populates within seconds
// instead of waiting up to 30 minutes for the next periodic slot.
unawaited(WidgetBackgroundTask.initialize());
unawaited(WidgetBackgroundTask.requestImmediateRefresh());
// Fade the login content out before handing over to the post-login splash.
// Both share the red backdrop, so this reads as one continuous transition
// instead of an abrupt swap.
_fade.reverse().whenComplete(() {
if (!mounted) return;
context.read<AccountBloc>().setStatus(AccountStatus.loggedIn);
});
accountBloc.activated(accountId, freshLogin: true);
}
Future<void> _cancelAddAccount() async {
final accountBloc = context.read<AccountBloc>();
await SessionLifecycle.cancelAddAccount();
accountBloc.activated(SessionManager().activeAccount?.id);
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: _marianumRed,
body: FadeTransition(
opacity: _fade,
child: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
maxWidth: 420,
Widget build(BuildContext context) => PopScope(
canPop: !widget.addingAccount,
onPopInvokedWithResult: (didPop, _) {
if (!didPop && !_busy) unawaited(_cancelAddAccount());
},
child: Scaffold(
backgroundColor: _marianumRed,
appBar: widget.addingAccount
? AppBar(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
elevation: 0,
leading: ListenableBuilder(
listenable: _controller,
builder: (context, _) => IconButton(
icon: const Icon(Icons.close),
tooltip: 'Abbrechen',
onPressed: _busy ? null : _cancelAddAccount,
),
// spaceBetween statt Spacer-in-IntrinsicHeight: Letzteres würde
// die Column bei Inhaltsänderungen im unteren Block auf die
// intrinsic-Höhe pinnen und ein paar Pixel Overflow erzeugen.
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
children: [
const LoginHeader(),
const SizedBox(height: 28),
LoginCard(
controller: _controller,
onSuccess: _onLoginSuccess,
),
const SizedBox(height: 12),
],
),
const Column(
mainAxisSize: MainAxisSize.min,
children: [_EndpointLink(), LoginFooter()],
),
],
),
title: const Text('Konto hinzufügen'),
)
: null,
body: FadeTransition(
opacity: _fade,
child: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
maxWidth: 420,
),
// spaceBetween statt Spacer-in-IntrinsicHeight: Letzteres würde
// die Column bei Inhaltsänderungen im unteren Block auf die
// intrinsic-Höhe pinnen und ein paar Pixel Overflow erzeugen.
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
children: [
LoginHeader(addingAccount: widget.addingAccount),
const SizedBox(height: 28),
LoginCard(
controller: _controller,
onSuccess: _onLoginSuccess,
addingAccount: widget.addingAccount,
),
const SizedBox(height: 12),
],
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
// The new account must live on the server the app
// already talks to.
if (!widget.addingAccount) const _EndpointLink(),
const LoginFooter(),
],
),
],
),
),
),
),
@@ -126,6 +171,8 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
),
),
);
bool get _busy => _controller.loading;
}
/// Subtle text link above the footer that surfaces the currently selected
+8 -2
View File
@@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
class LoginHeader extends StatelessWidget {
const LoginHeader({super.key});
/// Signs in an additional account instead of the first one.
final bool addingAccount;
const LoginHeader({this.addingAccount = false, super.key});
@override
Widget build(BuildContext context) => Column(
@@ -29,7 +32,10 @@ class LoginHeader extends StatelessWidget {
),
const SizedBox(height: 6),
Text(
'Stundenplan, Talk, Dateien und mehr - alles an einem Ort für deinen Schulalltag am Marianum Fulda.',
addingAccount
? 'Melde ein weiteres Konto an. In den Einstellungen kannst du '
'danach jederzeit zwischen deinen Konten wechseln.'
: 'Stundenplan, Talk, Dateien und mehr - alles an einem Ort für deinen Schulalltag am Marianum Fulda.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.85),
+10 -3
View File
@@ -12,9 +12,13 @@ class LoginCard extends StatefulWidget {
final LoginController controller;
final VoidCallback onSuccess;
/// Signs in an additional account instead of the first one.
final bool addingAccount;
const LoginCard({
required this.controller,
required this.onSuccess,
this.addingAccount = false,
super.key,
});
@@ -83,8 +87,11 @@ class _LoginCardState extends State<LoginCard> {
return Form(
key: _formKey,
child: LoginCardFrame(
title: 'Anmelden',
hint: 'Melde dich mit deinen Marianum-Zugangsdaten an.',
title: widget.addingAccount ? 'Konto hinzufügen' : 'Anmelden',
hint: widget.addingAccount
? 'Melde dich mit den Marianum-Zugangsdaten des Kontos an, das '
'du hinzufügen möchtest.'
: 'Melde dich mit deinen Marianum-Zugangsdaten an.',
children: [
TextFormField(
key: const Key('login-username-field'),
@@ -127,7 +134,7 @@ class _LoginCardState extends State<LoginCard> {
const SizedBox(height: 20),
LoginSubmitButton(
key: const Key('login-submit-button'),
label: 'Anmelden',
label: widget.addingAccount ? 'Hinzufügen' : 'Anmelden',
loading: loading,
onPressed: _submit,
),
@@ -16,7 +16,7 @@ import '../../../extensions/date_time.dart';
import '../../../model/endpoint_data.dart';
import '../../../push/push_registration_store.dart';
import '../../../push/push_registration_type.dart';
import '../../../session/session.dart';
import '../../../session/account_codec.dart';
import '../../../session/session_manager.dart';
import '../../../state/app/modules/account/bloc/account_bloc.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
@@ -103,10 +103,9 @@ class _DiagnosticsPageState extends State<DiagnosticsPage> {
title: 'Sitzung',
rows: await rows({
'Status': () async => accountStatus.name,
'Benutzer': () async => switch (session) {
CredentialSession(:final username) => username,
null => null,
},
'Benutzer': () async =>
session == null ? null : identityOf(session).$2,
'Konten': () async => SessionManager().accounts.value.accounts.length,
'Demo-Modus': () async => session?.isDemo ?? false,
'Login Flow v2': () async => nextcloud?.usesLoginFlow ?? false,
'App-Passwort (Dateien)': () async =>
@@ -9,7 +9,7 @@ import '../../../../routing/app_routes.dart';
import '../../../../session/session_lifecycle.dart';
import '../../../../session/session_manager.dart';
import '../../../../state/app/modules/account/bloc/account_bloc.dart';
import '../../../../state/app/modules/account/bloc/account_state.dart';
import '../../../../widget/account_switcher_sheet.dart';
import '../../../../widget/app_progress_indicator.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/avatar_actions_sheet.dart';
@@ -17,34 +17,43 @@ import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/demo_restricted.dart';
import '../../../../widget/user_avatar.dart';
// Display-name is process-wide stable until the user logs out; cache it so
// every Settings rebuild doesn't re-issue the OCS request.
// Display-name is stable per account; cache it so every Settings rebuild
// doesn't re-issue the OCS request.
String? _cachedDisplayName;
String? _cachedDisplayNameFor;
class AccountSection extends StatefulWidget {
class AccountSection extends StatelessWidget {
const AccountSection({super.key});
@override
State<AccountSection> createState() => _AccountSectionState();
Widget build(BuildContext context) => const _SchoolAccount();
}
class _AccountSectionState extends State<AccountSection> {
class _SchoolAccount extends StatefulWidget {
const _SchoolAccount();
@override
State<_SchoolAccount> createState() => _SchoolAccountState();
}
class _SchoolAccountState extends State<_SchoolAccount> {
int _avatarVersion = 0;
bool _avatarBusy = false;
String? _displayName = _cachedDisplayName;
String? _displayName;
@override
void initState() {
super.initState();
if (_displayName == null) _loadDisplayName();
final username = SessionManager().requireNextcloud().username;
if (_cachedDisplayNameFor == username) _displayName = _cachedDisplayName;
if (_displayName == null) _loadDisplayName(username);
}
Future<void> _loadDisplayName() async {
Future<void> _loadDisplayName(String username) async {
try {
final info = await GetUserInfo().run();
_cachedDisplayName = info.displayName.isEmpty
? null
: info.displayName;
_cachedDisplayNameFor = username;
_cachedDisplayName = info.displayName.isEmpty ? null : info.displayName;
if (!mounted) return;
setState(() => _displayName = _cachedDisplayName);
} catch (_) {
@@ -102,7 +111,7 @@ class _AccountSectionState extends State<AccountSection> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 16),
padding: const EdgeInsets.fromLTRB(16, 20, 8, 16),
child: Row(
children: [
SizedBox(
@@ -113,8 +122,10 @@ class _AccountSectionState extends State<AccountSection> {
children: [
Center(
child: GestureDetector(
onTap: () =>
AppRoutes.openLargeProfilePicture(context, username),
onTap: () => AppRoutes.openLargeProfilePicture(
context,
username,
),
child: UserAvatar(
key: ValueKey(_avatarVersion),
id: username,
@@ -134,7 +145,7 @@ class _AccountSectionState extends State<AccountSection> {
],
),
),
const SizedBox(width: 16),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -167,12 +178,8 @@ class _AccountSectionState extends State<AccountSection> {
],
),
),
const SizedBox(width: 8),
TextButton.icon(
icon: const Icon(Icons.logout_outlined, size: 18),
label: const Text('Abmelden'),
onPressed: () => _showLogoutDialog(context),
),
const SizedBox(width: 4),
const _AccountActions(),
],
),
),
@@ -183,9 +190,7 @@ class _AccountSectionState extends State<AccountSection> {
AsyncListTile(
leading: const Icon(Icons.cloud_sync_outlined),
title: const Text('Nextcloud neu verbinden'),
subtitle: const Text(
'Bei Anmeldeproblemen in Talk oder Dateien',
),
subtitle: const Text('Bei Anmeldeproblemen in Talk oder Dateien'),
closeOnSuccess: false,
onPressed: _reconnectNextcloud,
),
@@ -204,29 +209,49 @@ class _AccountSectionState extends State<AccountSection> {
const SnackBar(content: Text('Nextcloud-Verbindung erneuert.')),
);
}
}
Future<void> _showLogoutDialog(BuildContext context) async {
// Flip AccountBloc state only after the dialog fully closes: doing it from
// inside the sign-out (the previous approach) raced AsyncDialogAction's
// pop(true) against the listener's popUntil(isFirst) and could leave the
// navigator in an inconsistent state.
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => ConfirmDialog(
title: 'Abmelden?',
content: 'Möchtest du dich wirklich abmelden?',
confirmButton: 'Abmelden',
onConfirmAsync: _performLogout,
Future<void> _confirmLogout(BuildContext context) async {
final accountBloc = context.read<AccountBloc>();
final others = SessionManager().accounts.value.accounts.length - 1;
String? nextAccountId;
// Flip AccountBloc state only after the dialog fully closes: doing it from
// inside the sign-out (the previous approach) raced AsyncDialogAction's
// pop(true) against the navigator teardown of the account switch.
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => ConfirmDialog(
title: 'Abmelden?',
content: others > 0
? 'Möchtest du dich wirklich abmelden? Die App wechselt danach zu '
'einem deiner anderen Konten.'
: 'Möchtest du dich wirklich abmelden?',
confirmButton: 'Abmelden',
onConfirmAsync: () async =>
nextAccountId = await SessionLifecycle.signOut(),
),
);
if (confirmed != true) return;
accountBloc.activated(nextAccountId);
}
class _AccountActions extends StatelessWidget {
const _AccountActions();
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextButton.icon(
style: compactAccountButtonStyle,
icon: const Icon(Icons.logout_outlined, size: 18),
label: const Text('Abmelden'),
onPressed: () => _confirmLogout(context),
),
);
if (confirmed != true || !context.mounted) return;
context.read<AccountBloc>().setStatus(AccountStatus.loggedOut);
}
Future<void> _performLogout() async {
await SessionLifecycle.signOut();
_cachedDisplayName = null;
}
const AccountSwitchButton(),
],
);
}
class _AvatarEditBadge extends StatelessWidget {
@@ -254,11 +279,7 @@ class _AvatarEditBadge extends StatelessWidget {
color: theme.colorScheme.onPrimary,
),
)
: Icon(
Icons.edit,
size: 14,
color: theme.colorScheme.onPrimary,
),
: Icon(Icons.edit, size: 14, color: theme.colorScheme.onPrimary),
),
),
);