240 lines
8.0 KiB
Dart
240 lines
8.0 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
|
|
import '../../../../api/marianumcloud/cloud_users/cloud_users_actions.dart';
|
|
import '../../../../api/marianumconnect/queries/auth_logout/auth_logout.dart';
|
|
import '../../../../model/account_data.dart';
|
|
import '../../../../state/app/modules/account/bloc/account_bloc.dart';
|
|
import '../../../../state/app/modules/account/bloc/account_state.dart';
|
|
import '../../../../widget/app_progress_indicator.dart';
|
|
import '../../../../widget/async_action_button.dart';
|
|
import '../../../../widget/avatar_actions_sheet.dart';
|
|
import '../../../../widget/confirm_dialog.dart';
|
|
import '../../../../widget/large_profile_picture_view.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.
|
|
String? _cachedDisplayName;
|
|
|
|
class AccountSection extends StatefulWidget {
|
|
const AccountSection({super.key});
|
|
|
|
@override
|
|
State<AccountSection> createState() => _AccountSectionState();
|
|
}
|
|
|
|
class _AccountSectionState extends State<AccountSection> {
|
|
int _avatarVersion = 0;
|
|
bool _avatarBusy = false;
|
|
String? _displayName = _cachedDisplayName;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
if (_displayName == null) _loadDisplayName();
|
|
}
|
|
|
|
Future<void> _loadDisplayName() async {
|
|
try {
|
|
final info = await GetUserInfo().run();
|
|
_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 {
|
|
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: AccountData().getUsername(), isGroup: false);
|
|
setState(() => _avatarVersion++);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final username = AccountData().getUsername();
|
|
final displayName = _displayName;
|
|
final theme = Theme.of(context);
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 20, 16, 16),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 84,
|
|
height: 84,
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Center(
|
|
child: GestureDetector(
|
|
onTap: () => Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) =>
|
|
LargeProfilePictureView(id: 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: 16),
|
|
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: 8),
|
|
TextButton.icon(
|
|
icon: const Icon(Icons.logout_outlined, size: 18),
|
|
label: const Text('Abmelden'),
|
|
onPressed: () => _showLogoutDialog(context),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _showLogoutDialog(BuildContext context) async {
|
|
// Sequential logout flow: dialog wipes secure storage, dialog closes
|
|
// (single Navigator.pop), then we flip the AccountBloc state. The bloc
|
|
// listener in main.dart pops the Settings route and runs the in-memory
|
|
// wipe. Triggering setStatus from inside removeData (the previous
|
|
// approach) raced AsyncDialogAction's pop(true) against 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,
|
|
),
|
|
);
|
|
if (confirmed != true || !context.mounted) return;
|
|
context.read<AccountBloc>().setStatus(AccountStatus.loggedOut);
|
|
}
|
|
|
|
// Best-effort revoke of the MC bearer token before we wipe local credentials.
|
|
// The token storage itself is cleared inside AuthLogout regardless of network
|
|
// success, so an offline logout still gets us into a clean local state.
|
|
Future<void> _performLogout() async {
|
|
await AuthLogout().run();
|
|
await AccountData().removeData();
|
|
_cachedDisplayName = null;
|
|
}
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|