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:
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import '../../../../api/marianumcloud/talk/get_participants/get_participants_cac
|
||||
import '../../../../api/marianumcloud/talk/get_participants/get_participants_response.dart';
|
||||
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.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/loading_spinner.dart';
|
||||
@@ -25,6 +27,8 @@ class ChatInfo extends StatefulWidget {
|
||||
class _ChatInfoState extends State<ChatInfo> {
|
||||
GetParticipantsResponse? participants;
|
||||
late bool _isFavorite;
|
||||
int _avatarVersion = 0;
|
||||
bool _avatarBusy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -66,28 +70,101 @@ class _ChatInfoState extends State<ChatInfo> {
|
||||
if (closed == true && mounted) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
// Spreed's POST /room/{token}/avatar requires moderator rights and rejects
|
||||
// 1:1, changelog and note-to-self rooms server-side. Mirror that here so
|
||||
// the edit affordance only shows when the upload would actually succeed.
|
||||
bool _canEditAvatar() {
|
||||
final room = widget.room;
|
||||
const editableTypes = {
|
||||
GetRoomResponseObjectConversationType.group,
|
||||
GetRoomResponseObjectConversationType.public,
|
||||
};
|
||||
if (!editableTypes.contains(room.type)) return false;
|
||||
// Owner=1, Moderator=2, GuestModerator=6.
|
||||
return room.participantType == 1 ||
|
||||
room.participantType == 2 ||
|
||||
room.participantType == 6;
|
||||
}
|
||||
|
||||
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: 'Gruppenbild entfernen',
|
||||
content: 'Möchtest du das Gruppenbild 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 SetRoomAvatar(widget.room.token, result.bytes).run();
|
||||
} else {
|
||||
await DeleteRoomAvatar(widget.room.token).run();
|
||||
}
|
||||
});
|
||||
if (!mounted) return;
|
||||
setState(() => _avatarBusy = false);
|
||||
if (!ok) return;
|
||||
|
||||
invalidateAvatarCache(id: widget.room.token, isGroup: true);
|
||||
setState(() => _avatarVersion++);
|
||||
_refreshList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var isGroup =
|
||||
widget.room.type != GetRoomResponseObjectConversationType.oneToOne;
|
||||
final canEdit = _canEditAvatar();
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.room.displayName)),
|
||||
body: ListView(
|
||||
children: [
|
||||
const SizedBox(height: 30),
|
||||
Center(
|
||||
child: GestureDetector(
|
||||
child: UserAvatar(
|
||||
id: isGroup ? widget.room.token : widget.room.name,
|
||||
isGroup: isGroup,
|
||||
size: 80,
|
||||
),
|
||||
onTap: () => TalkNavigator.pushSplitView(
|
||||
context,
|
||||
LargeProfilePictureView(
|
||||
id: isGroup ? widget.room.token : widget.room.name,
|
||||
isGroup: isGroup,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 180,
|
||||
height: 180,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Center(
|
||||
child: GestureDetector(
|
||||
child: UserAvatar(
|
||||
key: ValueKey(_avatarVersion),
|
||||
id: isGroup ? widget.room.token : widget.room.name,
|
||||
isGroup: isGroup,
|
||||
size: 80,
|
||||
),
|
||||
onTap: () => TalkNavigator.pushSplitView(
|
||||
context,
|
||||
LargeProfilePictureView(
|
||||
id: isGroup ? widget.room.token : widget.room.name,
|
||||
isGroup: isGroup,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (canEdit)
|
||||
Positioned(
|
||||
right: 18,
|
||||
bottom: 18,
|
||||
child: _AvatarEditBadge(
|
||||
busy: _avatarBusy,
|
||||
onTap: _avatarBusy ? null : _editAvatar,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -153,3 +230,39 @@ class _ChatInfoState extends State<ChatInfo> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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: 30,
|
||||
height: 30,
|
||||
child: busy
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(7),
|
||||
child: AppProgressIndicator.small(
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.edit,
|
||||
size: 15,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user