377 lines
12 KiB
Dart
377 lines
12 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
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/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/child_switcher.dart';
|
|
import '../../../../widget/confirm_dialog.dart';
|
|
import '../../../../widget/demo_restricted.dart';
|
|
import '../../../../widget/user_avatar.dart';
|
|
|
|
// 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});
|
|
|
|
@override
|
|
Widget build(BuildContext context) => switch (SessionManager().current) {
|
|
GuardianSession(:final email) => _GuardianAccount(email: email),
|
|
_ => const _SchoolAccount(),
|
|
};
|
|
}
|
|
|
|
/// Guardians have no Nextcloud profile: show the e-mail and linked children.
|
|
class _GuardianAccount extends StatelessWidget {
|
|
final String email;
|
|
|
|
const _GuardianAccount({required this.email});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final children = context.watch<CapabilitiesCubit>().state.children;
|
|
final colors = Theme.of(context).colorScheme;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
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),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SchoolAccount extends StatefulWidget {
|
|
const _SchoolAccount();
|
|
|
|
@override
|
|
State<_SchoolAccount> createState() => _SchoolAccountState();
|
|
}
|
|
|
|
class _SchoolAccountState extends State<_SchoolAccount> {
|
|
int _avatarVersion = 0;
|
|
bool _avatarBusy = false;
|
|
String? _displayName;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final username = SessionManager().requireNextcloud().username;
|
|
if (_cachedDisplayNameFor == username) _displayName = _cachedDisplayName;
|
|
if (_displayName == null) _loadDisplayName(username);
|
|
}
|
|
|
|
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);
|
|
} catch (_) {
|
|
// Silent fallback to username — surfacing an error dialog over the
|
|
// settings screen on every open would be noisier than helpful.
|
|
}
|
|
}
|
|
|
|
Future<void> _editAvatar() async {
|
|
if (guardDemoAction(context)) return;
|
|
final result = await showAvatarActionsSheet(context, allowRemove: true);
|
|
if (result == null || !mounted) return;
|
|
|
|
if (result is AvatarRemoveResult) {
|
|
var confirmed = false;
|
|
await showDialog<void>(
|
|
context: context,
|
|
builder: (_) => ConfirmDialog(
|
|
title: 'Profilbild entfernen',
|
|
content: 'Möchtest du dein Profilbild wirklich entfernen?',
|
|
confirmButton: 'Entfernen',
|
|
onConfirm: () => confirmed = true,
|
|
),
|
|
);
|
|
if (!confirmed || !mounted) return;
|
|
}
|
|
|
|
setState(() => _avatarBusy = true);
|
|
final ok = await runWithErrorDialog(context, () async {
|
|
if (result is AvatarUploadResult) {
|
|
await SetUserAvatar(result.bytes).run();
|
|
} else {
|
|
await DeleteUserAvatar().run();
|
|
}
|
|
});
|
|
if (!mounted) return;
|
|
setState(() => _avatarBusy = false);
|
|
if (!ok) return;
|
|
|
|
invalidateAvatarCache(
|
|
id: SessionManager().requireNextcloud().username,
|
|
isGroup: false,
|
|
);
|
|
setState(() => _avatarVersion++);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final nextcloud = SessionManager().requireNextcloud();
|
|
final username = nextcloud.username;
|
|
final displayName = _displayName;
|
|
final theme = Theme.of(context);
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 20, 8, 16),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 84,
|
|
height: 84,
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Center(
|
|
child: GestureDetector(
|
|
onTap: () => AppRoutes.openLargeProfilePicture(
|
|
context,
|
|
username,
|
|
),
|
|
child: UserAvatar(
|
|
key: ValueKey(_avatarVersion),
|
|
id: username,
|
|
size: 36,
|
|
requestSize: 256,
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
right: 0,
|
|
bottom: 0,
|
|
child: _AvatarEditBadge(
|
|
busy: _avatarBusy,
|
|
onTap: _avatarBusy ? null : _editAvatar,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
displayName ?? username,
|
|
style: const TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (displayName != null) ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
username,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
const _AccountActions(),
|
|
],
|
|
),
|
|
),
|
|
// Nur für Login-Flow-Konten (2FA) sichtbar — Passwort-Konten heilen
|
|
// sich still über das App-Passwort-Minting und sollen von dem ganzen
|
|
// Flow-Mechanismus nichts mitbekommen.
|
|
if (!SessionManager().isDemo && nextcloud.usesLoginFlow)
|
|
AsyncListTile(
|
|
leading: const Icon(Icons.cloud_sync_outlined),
|
|
title: const Text('Nextcloud neu verbinden'),
|
|
subtitle: const Text('Bei Anmeldeproblemen in Talk oder Dateien'),
|
|
closeOnSuccess: false,
|
|
onPressed: _reconnectNextcloud,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Erneuert die Nextcloud-Zugangsdaten über den Login Flow v2 (inkl. des
|
|
/// zweiten Talk-Durchlaufs) und bindet die Push-Subscription neu.
|
|
Future<void> _reconnectNextcloud() async {
|
|
final ok = await AppRoutes.openNextcloudLoginFlow(context);
|
|
if (!ok || !mounted) return;
|
|
// Neues App-Passwort = neue NC-Session: die Push-Subscription neu binden.
|
|
unawaited(PushRegistration().register());
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Nextcloud-Verbindung erneuert.')),
|
|
);
|
|
}
|
|
}
|
|
|
|
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 _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 {
|
|
final bool busy;
|
|
final VoidCallback? onTap;
|
|
const _AvatarEditBadge({required this.busy, required this.onTap});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Material(
|
|
color: theme.colorScheme.primary,
|
|
shape: const CircleBorder(),
|
|
elevation: 2,
|
|
child: InkWell(
|
|
customBorder: const CircleBorder(),
|
|
onTap: onTap,
|
|
child: SizedBox(
|
|
width: 27,
|
|
height: 27,
|
|
child: busy
|
|
? Padding(
|
|
padding: const EdgeInsets.all(6),
|
|
child: AppProgressIndicator.small(
|
|
color: theme.colorScheme.onPrimary,
|
|
),
|
|
)
|
|
: Icon(Icons.edit, size: 14, color: theme.colorScheme.onPrimary),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|