implemented avatar management for user profiles and chat rooms, including 1:1 cropping, integrated OCS and Spreed avatar APIs, added cache invalidation logic, and updated the account settings view to display user info and profile pictures.

This commit is contained in:
2026-05-31 18:42:30 +02:00
parent f966cf302b
commit 5ebf5bccdb
9 changed files with 777 additions and 38 deletions
@@ -1,23 +1,176 @@
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/centered_leading.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';
class AccountSection extends StatelessWidget {
// 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
Widget build(BuildContext context) => ListTile(
leading: const CenteredLeading(Icon(Icons.logout_outlined)),
title: const Text('Konto abmelden'),
subtitle: Text('Angemeldet als ${AccountData().getUsername()}'),
onTap: () => _showLogoutDialog(context),
);
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
@@ -45,5 +198,42 @@ class AccountSection extends StatelessWidget {
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,
),
),
),
);
}
}