added support for multiple accounts, guardian login bugfixes, ui changes

This commit is contained in:
2026-09-23 20:50:12 +02:00
parent 630497abdd
commit 84098af7e2
37 changed files with 1759 additions and 376 deletions
@@ -6,24 +6,25 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/marianumcloud/cloud_users/cloud_users_actions.dart';
import '../../../../push/push_registration.dart';
import '../../../../routing/app_routes.dart';
import '../../../../session/account_codec.dart';
import '../../../../session/session.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/capabilities/bloc/capabilities_cubit.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';
import '../../../../widget/centered_leading.dart';
import '../../../../widget/child_switcher.dart';
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 StatelessWidget {
const AccountSection({super.key});
@@ -44,20 +45,45 @@ class _GuardianAccount extends StatelessWidget {
@override
Widget build(BuildContext context) {
final children = context.watch<CapabilitiesCubit>().state.children;
final colors = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
contentPadding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
leading: const CenteredLeading(Icon(Icons.family_restroom_outlined)),
title: const Text('Elternkonto'),
subtitle: Text(email),
trailing: TextButton.icon(
icon: const Icon(Icons.logout_outlined, size: 18),
label: const Text('Abmelden'),
onPressed: () => _confirmLogout(context),
Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 8, 16),
child: Row(
children: [
CircleAvatar(
radius: 36,
backgroundColor: colors.secondaryContainer,
foregroundColor: colors.onSecondaryContainer,
child: const Icon(Icons.family_restroom_outlined, size: 34),
),
const SizedBox(width: 12),
Expanded(
child: ValueListenableBuilder<AccountIndex>(
valueListenable: SessionManager().accounts,
builder: (context, index, _) => _AccountName(
name: index.active?.displayName ?? 'Elternkonto',
identity: email,
),
),
),
const SizedBox(width: 4),
const _AccountActions(),
],
),
),
if (children.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(
children.length == 1 ? 'Dein Kind' : 'Deine Kinder',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
),
for (final child in children) ChildTile(child: child),
],
);
@@ -74,17 +100,20 @@ class _SchoolAccount extends StatefulWidget {
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();
_cachedDisplayNameFor = username;
_cachedDisplayName = info.displayName.isEmpty ? null : info.displayName;
if (!mounted) return;
setState(() => _displayName = _cachedDisplayName);
@@ -143,7 +172,7 @@ class _SchoolAccountState extends State<_SchoolAccount> {
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(
@@ -177,7 +206,7 @@ class _SchoolAccountState extends State<_SchoolAccount> {
],
),
),
const SizedBox(width: 16),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -207,12 +236,8 @@ class _SchoolAccountState extends State<_SchoolAccount> {
],
),
),
const SizedBox(width: 8),
TextButton.icon(
icon: const Icon(Icons.logout_outlined, size: 18),
label: const Text('Abmelden'),
onPressed: () => _confirmLogout(context),
),
const SizedBox(width: 4),
const _AccountActions(),
],
),
),
@@ -245,26 +270,77 @@ class _SchoolAccountState extends State<_SchoolAccount> {
}
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 listener's popUntil(isFirst) and could leave the
// navigator in an inconsistent state.
// pop(true) against the navigator teardown of the account switch.
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => ConfirmDialog(
title: 'Abmelden?',
content: 'Möchtest du dich wirklich 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: _performLogout,
onConfirmAsync: () async =>
nextAccountId = await SessionLifecycle.signOut(),
),
);
if (confirmed != true || !context.mounted) return;
context.read<AccountBloc>().setStatus(AccountStatus.loggedOut);
if (confirmed != true) return;
accountBloc.activated(nextAccountId);
}
Future<void> _performLogout() async {
await SessionLifecycle.signOut();
_cachedDisplayName = null;
class _AccountName extends StatelessWidget {
final String name;
final String identity;
const _AccountName({required this.name, required this.identity});
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
name,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
identity,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
);
}
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),
),
const AccountSwitchButton(),
],
);
}
class _AvatarEditBadge extends StatelessWidget {