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
+193
View File
@@ -0,0 +1,193 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../session/account_codec.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 '../utils/haptics.dart';
import 'app_progress_indicator.dart';
import 'async_action_button.dart';
import 'centered_leading.dart';
import 'confirm_dialog.dart';
import 'details_bottom_sheet.dart';
import 'user_avatar.dart';
/// Lists the signed-in accounts: tap one to switch, add another, or sign out
/// of an inactive one.
Future<void> showAccountSwitcherSheet(BuildContext context) {
final accountBloc = context.read<AccountBloc>();
return showDetailsBottomSheet(
context,
header: Builder(
builder: (headerContext) => ListTile(
title: const Text('Konten'),
trailing: TextButton.icon(
icon: const Icon(Icons.person_add_alt_outlined, size: 18),
label: const Text('Hinzufügen'),
onPressed: () {
Navigator.pop(headerContext);
unawaited(startAddAccount(accountBloc));
},
),
),
),
children: (sheetContext) => [
ValueListenableBuilder<AccountIndex>(
valueListenable: SessionManager().accounts,
builder: (context, index, _) => Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final account in index.accounts)
_AccountTile(
account: account,
active: account.id == index.activeId,
accountBloc: accountBloc,
),
],
),
),
],
);
}
/// Opens the login for another account; the active one is parked meanwhile.
Future<void> startAddAccount(AccountBloc accountBloc) async {
await SessionLifecycle.beginAddAccount();
accountBloc.setStatus(AccountStatus.addingAccount);
}
/// Stacked next to the account name, which needs the width more.
/// Only the horizontal padding is trimmed; height stays at a full tap target.
final ButtonStyle compactAccountButtonStyle = TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
);
/// Below the sign-out button: adds an account, or opens the switcher once
/// there is more than one.
class AccountSwitchButton extends StatelessWidget {
const AccountSwitchButton({super.key});
@override
Widget build(BuildContext context) => ValueListenableBuilder<AccountIndex>(
valueListenable: SessionManager().accounts,
builder: (context, index, _) => index.accounts.length > 1
? TextButton.icon(
style: compactAccountButtonStyle,
icon: const Icon(Icons.switch_account_outlined, size: 18),
label: const Text('Wechseln'),
onPressed: () => showAccountSwitcherSheet(context),
)
: TextButton.icon(
style: compactAccountButtonStyle,
icon: const Icon(Icons.person_add_alt_outlined, size: 18),
label: const Text('Hinzufügen'),
onPressed: () => startAddAccount(context.read<AccountBloc>()),
),
);
}
class _AccountTile extends StatefulWidget {
final AccountEntry account;
final bool active;
final AccountBloc accountBloc;
const _AccountTile({
required this.account,
required this.active,
required this.accountBloc,
});
@override
State<_AccountTile> createState() => _AccountTileState();
}
class _AccountTileState extends State<_AccountTile> {
bool _busy = false;
Future<void> _switch() async {
Haptics.selection();
setState(() => _busy = true);
final ok = await runWithErrorDialog(
context,
() => SessionLifecycle.switchTo(widget.account.id),
);
if (!mounted) return;
setState(() => _busy = false);
if (!ok) return;
Navigator.pop(context);
widget.accountBloc.activated(SessionManager().activeAccount?.id);
}
void _confirmRemove() => ConfirmDialog(
title: 'Abmelden?',
content:
'${widget.account.displayName ?? widget.account.label} wird von diesem Gerät abgemeldet und seine '
'lokal gespeicherten Daten werden gelöscht.',
confirmButton: 'Abmelden',
onConfirmAsync: () => SessionLifecycle.removeInactive(widget.account.id),
).asDialog(context);
@override
Widget build(BuildContext context) {
final account = widget.account;
return ListTile(
leading: CenteredLeading(
_busy
? const SizedBox.square(
dimension: 24,
child: AppProgressIndicator.small(),
)
: _AccountAvatar(account: account),
),
title: Text(
account.displayName ?? account.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
account.isDemo ? '${account.label} (Demo)' : account.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Sized like the IconButton so both line up.
trailing: widget.active
? const SizedBox.square(
dimension: kMinInteractiveDimension,
child: Icon(Icons.check),
)
: IconButton(
icon: const Icon(Icons.logout_outlined),
tooltip: 'Abmelden',
onPressed: _busy ? null : _confirmRemove,
),
onTap: widget.active || _busy ? null : _switch,
);
}
}
class _AccountAvatar extends StatelessWidget {
final AccountEntry account;
const _AccountAvatar({required this.account});
@override
Widget build(BuildContext context) {
// Demo accounts have no real Nextcloud user behind them.
if (!account.isGuardian && !account.isDemo) {
return UserAvatar(id: account.label, size: 16);
}
final colors = Theme.of(context).colorScheme;
return CircleAvatar(
radius: 16,
backgroundColor: colors.secondaryContainer,
foregroundColor: colors.onSecondaryContainer,
child: account.isGuardian
? const Icon(Icons.family_restroom_outlined, size: 18)
: Text(account.label.characters.first.toUpperCase()),
);
}
}
+34 -1
View File
@@ -10,6 +10,7 @@ import 'async_action_button.dart';
import 'centered_leading.dart';
import 'details_bottom_sheet.dart';
import 'placeholder_view.dart';
import 'user_avatar.dart';
/// AppBar action that shows the selected child and lets a guardian switch to
/// another one. Invisible unless there is a choice to make.
@@ -63,7 +64,7 @@ class ChildTile extends StatelessWidget {
@override
Widget build(BuildContext context) => ListTile(
leading: const CenteredLeading(Icon(Icons.face_outlined)),
leading: CenteredLeading(ChildAvatar(child: child)),
title: Text(child.displayName),
subtitle: child.className.isEmpty
? null
@@ -73,6 +74,38 @@ class ChildTile extends StatelessWidget {
);
}
/// Profile picture of a linked child, or its initials while the server does
/// not name the child's account.
class ChildAvatar extends StatelessWidget {
final GuardianChild child;
final int size;
const ChildAvatar({required this.child, this.size = 20, super.key});
@override
Widget build(BuildContext context) {
final username = child.username;
if (username != null && username.isNotEmpty) {
return UserAvatar(
id: username,
size: size,
semanticLabel: child.displayName,
);
}
final colors = Theme.of(context).colorScheme;
final initials = [child.firstName, child.lastName]
.where((part) => part.isNotEmpty)
.map((part) => part.characters.first.toUpperCase())
.join();
return CircleAvatar(
radius: size.toDouble(),
backgroundColor: colors.secondaryContainer,
foregroundColor: colors.onSecondaryContainer,
child: Text(initials, style: TextStyle(fontSize: size * 0.7)),
);
}
}
/// Shown by per-child modules while a guardian has no linked child. The
/// children come from `me/capabilities`, so a failed or pending load must not
/// be presented as "no child assigned" — that sends parents to the secretariat
+3 -1
View File
@@ -162,7 +162,9 @@ Future<AvatarPayload?> _fetchAvatarPayload(String url) async {
() => http.get(
Uri.parse(url),
headers: {
...SessionManager().requireNextcloud().authHeaders,
// User avatars are public in Nextcloud, so the account switcher can
// show them while a guardian (no Nextcloud identity) is active.
...?SessionManager().current?.nextcloud?.authHeaders,
'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml',
},
),