diff --git a/lib/api/marianumcloud/cloud_users/cloud_users_actions.dart b/lib/api/marianumcloud/cloud_users/cloud_users_actions.dart new file mode 100644 index 0000000..6d0203d --- /dev/null +++ b/lib/api/marianumcloud/cloud_users/cloud_users_actions.dart @@ -0,0 +1,131 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; + +import '../../../model/account_data.dart'; +import '../../../model/endpoint_data.dart'; +import '../../errors/auth_exception.dart'; +import '../../errors/network_exception.dart'; +import '../../errors/not_found_exception.dart'; +import '../../errors/parse_exception.dart'; +import '../../errors/server_exception.dart'; +import '../nextcloud_ocs.dart'; + +/// Mix of two Nextcloud surfaces: +/// - User info comes from the OCS provisioning API +/// (`/ocs/v2.php/cloud/users/{userId}`). +/// - The own-avatar upload/delete uses the *core* AvatarController at +/// `/avatar/` — the OCS provisioning route has no POST (it answers 405). +/// This is the same controller the read path (`/avatar/{id}/{size}` in +/// [UserAvatar]) already talks to. CSRF is bypassed because we use Basic +/// Auth without a session cookie. + +/// Core AvatarController endpoint for the logged-in user (POST sets, DELETE +/// removes). Built against the bare Nextcloud base (domain + optional path), +/// not the OCS wrapper. +Uri _coreAvatarUri() { + final endpoint = EndpointData().nextcloud(); + return Uri.https(endpoint.domain, '${endpoint.path}/avatar/'); +} + +Uri _userInfoUri() => + NextcloudOcs.uri('cloud/users/${AccountData().getUsername()}'); + +Future _send( + Future Function(Uri uri, Map headers) + perform, + Uri uri, +) async { + final headers = NextcloudOcs.headers(); + + final http.Response response; + try { + response = await perform(uri, headers); + } on SocketException catch (e) { + throw NetworkException(technicalDetails: 'Cloud $uri: ${e.message}'); + } on TimeoutException catch (e) { + throw NetworkException.timeout(technicalDetails: 'Cloud $uri: $e'); + } on http.ClientException catch (e) { + throw NetworkException(technicalDetails: 'Cloud $uri: ${e.message}'); + } + + final status = response.statusCode; + if (status >= 200 && status < 300) return response; + + final body = response.body.replaceAll(RegExp(r'\s+'), ' ').trim(); + final preview = body.length > 500 ? '${body.substring(0, 500)}…' : body; + final detail = body.isEmpty + ? 'Cloud $uri -> HTTP $status' + : 'Cloud $uri -> HTTP $status body=$preview'; + log(detail); + if (status == 401) throw AuthException.unauthorized(technicalDetails: detail); + if (status == 403) throw AuthException.forbidden(technicalDetails: detail); + if (status == 404) throw NotFoundException(technicalDetails: detail); + throw ServerException(statusCode: status, technicalDetails: detail); +} + +class SetUserAvatar { + final Uint8List bytes; + final String filename; + SetUserAvatar(this.bytes, {this.filename = 'avatar.jpg'}); + + Future run() async { + await _send((uri, headers) async { + // Core AvatarController reads $_FILES['files']['error'][0] — the field + // must be `files[]` so PHP exposes it as an array, matching the web UI. + final req = http.MultipartRequest('POST', uri) + ..headers.addAll(headers) + ..files.add( + http.MultipartFile.fromBytes('files[]', bytes, filename: filename), + ); + final streamed = await req.send(); + return http.Response.fromStream(streamed); + }, _coreAvatarUri()); + } +} + +class DeleteUserAvatar { + Future run() async { + await _send( + (uri, headers) => http.delete(uri, headers: headers), + _coreAvatarUri(), + ); + } +} + +class CloudUserInfo { + final String userId; + final String displayName; + const CloudUserInfo({required this.userId, required this.displayName}); +} + +/// Reads the current user's provisioning record. The OCS wrapper looks like: +/// `{ "ocs": { "meta": {...}, "data": { "id": "...", "displayname": "...", ... } } }`. +/// We only need displayname; everything else is discarded. +class GetUserInfo { + Future run() async { + final uri = _userInfoUri(); + final response = await _send( + (u, headers) => http.get(u, headers: headers), + uri, + ); + try { + final root = jsonDecode(response.body) as Map; + final data = + (root['ocs'] as Map)['data'] + as Map; + return CloudUserInfo( + userId: data['id'] as String, + displayName: (data['displayname'] as String?) ?? '', + ); + } catch (e) { + throw ParseException( + technicalDetails: 'Cloud $uri user info parse: $e', + ); + } + } +} diff --git a/lib/api/marianumcloud/talk/actions/talk_actions.dart b/lib/api/marianumcloud/talk/actions/talk_actions.dart index 57a10d0..acf4d50 100644 --- a/lib/api/marianumcloud/talk/actions/talk_actions.dart +++ b/lib/api/marianumcloud/talk/actions/talk_actions.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:http/http.dart' as http; import '../../../api_params.dart'; @@ -62,3 +64,44 @@ class DeleteMessage extends TalkApi { Map? headers, ) => http.delete(uri, headers: headers); } + +class SetRoomAvatar extends TalkApi { + final String chatToken; + final Uint8List bytes; + final String filename; + + SetRoomAvatar(this.chatToken, this.bytes, {this.filename = 'avatar.jpg'}) + : super('v1/room/$chatToken/avatar', null); + + @override + ApiResponse? assemble(String raw) => null; + + @override + Future request( + Uri uri, + ApiParams? body, + Map? headers, + ) async { + final req = http.MultipartRequest('POST', uri) + ..headers.addAll(headers ?? const {}) + ..files.add(http.MultipartFile.fromBytes('file', bytes, filename: filename)); + final streamed = await req.send(); + return http.Response.fromStream(streamed); + } +} + +class DeleteRoomAvatar extends TalkApi { + final String chatToken; + + DeleteRoomAvatar(this.chatToken) : super('v1/room/$chatToken/avatar', null); + + @override + ApiResponse? assemble(String raw) => null; + + @override + Future request( + Uri uri, + ApiParams? body, + Map? headers, + ) => http.delete(uri, headers: headers); +} diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart index 69c4cdf..94bb806 100644 --- a/lib/view/pages/settings/sections/account_section.dart +++ b/lib/view/pages/settings/sections/account_section.dart @@ -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 createState() => _AccountSectionState(); +} + +class _AccountSectionState extends State { + int _avatarVersion = 0; + bool _avatarBusy = false; + String? _displayName = _cachedDisplayName; + + @override + void initState() { + super.initState(); + if (_displayName == null) _loadDisplayName(); + } + + Future _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 _editAvatar() async { + final result = await showAvatarActionsSheet(context, allowRemove: true); + if (result == null || !mounted) return; + + if (result is AvatarRemoveResult) { + var confirmed = false; + await showDialog( + 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( + 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 _showLogoutDialog(BuildContext context) async { // Sequential logout flow: dialog wipes secure storage, dialog closes @@ -45,5 +198,42 @@ class AccountSection extends StatelessWidget { Future _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, + ), + ), + ), + ); } } diff --git a/lib/view/pages/talk/details/chat_info.dart b/lib/view/pages/talk/details/chat_info.dart index 08a22ca..0578334 100644 --- a/lib/view/pages/talk/details/chat_info.dart +++ b/lib/view/pages/talk/details/chat_info.dart @@ -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 { GetParticipantsResponse? participants; late bool _isFavorite; + int _avatarVersion = 0; + bool _avatarBusy = false; @override void initState() { @@ -66,28 +70,101 @@ class _ChatInfoState extends State { 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 _editAvatar() async { + final result = await showAvatarActionsSheet(context, allowRemove: true); + if (result == null || !mounted) return; + + if (result is AvatarRemoveResult) { + var confirmed = false; + await showDialog( + 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 { ); } } + +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, + ), + ), + ), + ); + } +} diff --git a/lib/widget/avatar_actions_sheet.dart b/lib/widget/avatar_actions_sheet.dart new file mode 100644 index 0000000..f267109 --- /dev/null +++ b/lib/widget/avatar_actions_sheet.dart @@ -0,0 +1,116 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; + +import 'avatar_crop_page.dart'; +import 'file_pick.dart'; + +/// Result of the user's choice inside [showAvatarActionsSheet]. The sheet +/// only collects intent + the (cropped) image bytes — the actual upload / +/// delete and any loading state are the caller's responsibility, so failures +/// surface in the screen that owns the avatar, not in a transient sheet. +sealed class AvatarSheetResult { + const AvatarSheetResult(); +} + +class AvatarUploadResult extends AvatarSheetResult { + final Uint8List bytes; + const AvatarUploadResult(this.bytes); +} + +class AvatarRemoveResult extends AvatarSheetResult { + const AvatarRemoveResult(); +} + +/// Bottom sheet with "from gallery", "take photo" and optional "remove" +/// actions. The picker + 1:1 cropper run with the sheet still mounted, so a +/// cancelled pick simply returns the user to the sheet. The sheet only pops +/// once a concrete result exists (or never, if everything was cancelled). +Future showAvatarActionsSheet( + BuildContext context, { + required bool allowRemove, +}) async { + AvatarSheetResult? result; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + useSafeArea: true, + builder: (sheetContext) => SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.only( + bottom: 16 + MediaQuery.viewInsetsOf(sheetContext).bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListTile( + leading: const Icon(Icons.photo_library_outlined), + title: const Text('Aus Galerie wählen'), + onTap: () async { + final bytes = await _pickAndCrop( + sheetContext, + FilePick.singleGalleryPick, + ); + if (bytes == null || !sheetContext.mounted) return; + result = AvatarUploadResult(bytes); + Navigator.of(sheetContext).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.photo_camera_outlined), + title: const Text('Foto aufnehmen'), + onTap: () async { + final bytes = await _pickAndCrop( + sheetContext, + FilePick.cameraPick, + ); + if (bytes == null || !sheetContext.mounted) return; + result = AvatarUploadResult(bytes); + Navigator.of(sheetContext).pop(); + }, + ), + if (allowRemove) ...[ + const Divider(), + ListTile( + leading: Icon( + Icons.delete_outline, + color: Theme.of(sheetContext).colorScheme.error, + ), + title: Text( + 'Profilbild entfernen', + style: TextStyle( + color: Theme.of(sheetContext).colorScheme.error, + ), + ), + onTap: () { + result = const AvatarRemoveResult(); + Navigator.of(sheetContext).pop(); + }, + ), + ], + ], + ), + ), + ), + ); + return result; +} + +Future _pickAndCrop( + BuildContext context, + Future Function() pick, +) async { + final picked = await pick(); + if (picked == null) return null; + final bytes = await picked.readAsBytes(); + if (!context.mounted) return null; + return Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => AvatarCropPage(imageBytes: bytes), + ), + ); +} diff --git a/lib/widget/avatar_crop_page.dart b/lib/widget/avatar_crop_page.dart new file mode 100644 index 0000000..ae49362 --- /dev/null +++ b/lib/widget/avatar_crop_page.dart @@ -0,0 +1,87 @@ +import 'dart:typed_data'; + +import 'package:crop_your_image/crop_your_image.dart'; +import 'package:flutter/material.dart'; + +import 'app_progress_indicator.dart'; + +/// Full-screen 1:1 cropper. Pure-Flutter so it inherits the app theme and +/// MediaQuery insets (no UCrop / native Activity needed). Returns the +/// cropped JPEG/PNG bytes via Navigator pop, or `null` on cancel. +class AvatarCropPage extends StatefulWidget { + final Uint8List imageBytes; + const AvatarCropPage({required this.imageBytes, super.key}); + + @override + State createState() => _AvatarCropPageState(); +} + +class _AvatarCropPageState extends State { + final _controller = CropController(); + bool _busy = false; + + void _confirm() { + if (_busy) return; + setState(() => _busy = true); + _controller.crop(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + backgroundColor: theme.colorScheme.surface, + appBar: AppBar( + title: const Text('Zuschneiden'), + leading: IconButton( + icon: const Icon(Icons.close), + tooltip: 'Abbrechen', + onPressed: _busy ? null : () => Navigator.of(context).pop(), + ), + actions: [ + IconButton( + icon: _busy + ? Padding( + padding: const EdgeInsets.all(10), + child: AppProgressIndicator.small( + color: theme.colorScheme.onSurface, + ), + ) + : const Icon(Icons.check), + tooltip: 'Bestätigen', + onPressed: _busy ? null : _confirm, + ), + ], + ), + body: SafeArea( + // Pinch-Zoom (interactive: true) lässt zwei-Finger-Gesten direkt am + // Bildschirmrand starten und triggert dann die Android-Zurückgeste. + // Crop-Rahmen mit Eck-Dots reicht für Avatar-Auswahl völlig aus. + child: Crop( + image: widget.imageBytes, + controller: _controller, + aspectRatio: 1.0, + interactive: false, + baseColor: theme.colorScheme.surface, + maskColor: Colors.black.withValues(alpha: 0.6), + cornerDotBuilder: (size, _) => + DotControl(color: theme.colorScheme.primary), + onCropped: (result) { + if (!mounted) return; + switch (result) { + case CropSuccess(:final croppedImage): + Navigator.of(context).pop(croppedImage); + case CropFailure(): + setState(() => _busy = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Bild konnte nicht zugeschnitten werden'), + ), + ); + } + }, + ), + ), + ); + } +} diff --git a/lib/widget/large_profile_picture_view.dart b/lib/widget/large_profile_picture_view.dart index 5b20824..75194ea 100644 --- a/lib/widget/large_profile_picture_view.dart +++ b/lib/widget/large_profile_picture_view.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:photo_view/photo_view.dart'; import '../model/account_data.dart'; -import '../model/endpoint_data.dart'; +import 'user_avatar.dart'; class LargeProfilePictureView extends StatelessWidget { final String id; @@ -14,14 +14,6 @@ class LargeProfilePictureView extends StatelessWidget { super.key, }); - String _imageUrl() { - final host = EndpointData().nextcloud().full(); - if (isGroup) { - return 'https://$host/ocs/v2.php/apps/spreed/api/v1/room/$id/avatar'; - } - return 'https://$host/avatar/$id/1024'; - } - @override Widget build(BuildContext context) => Scaffold( appBar: AppBar(title: Text(isGroup ? 'Gruppenbild' : 'Profilbild')), @@ -29,7 +21,7 @@ class LargeProfilePictureView extends StatelessWidget { minScale: 0.5, maxScale: 3.0, imageProvider: Image.network( - _imageUrl(), + avatarUrl(id: id, isGroup: isGroup, size: 1024), headers: {'Authorization': AccountData().getBasicAuthHeader()}, ).image, backgroundDecoration: BoxDecoration( diff --git a/lib/widget/user_avatar.dart b/lib/widget/user_avatar.dart index 5ef5271..6116420 100644 --- a/lib/widget/user_avatar.dart +++ b/lib/widget/user_avatar.dart @@ -13,10 +13,18 @@ class UserAvatar extends StatefulWidget { final String id; final bool isGroup; final int size; + + /// Server-side pixel size requested for user avatars. `null` lets the + /// widget pick `(size * 4).clamp(64, 1024)` — enough headroom for typical + /// device pixel ratios. Group avatars ignore this (Spreed serves one + /// fixed-size image per token). + final int? requestSize; + const UserAvatar({ required this.id, this.isGroup = false, this.size = 20, + this.requestSize, super.key, }); @@ -46,6 +54,48 @@ final LinkedHashMap _resolvedAvatars = LinkedHashMap(); final Map> _pendingAvatars = {}; +// Bumped by invalidateAvatarCache so *already mounted* avatars re-resolve. +// Clearing the cache map alone only affects future mounts — a UserAvatar +// elsewhere on screen (chat list, chat header) holds its bytes in State and +// would keep showing the stale image until rebuilt. Each state listens here +// and re-attaches: invalidated urls miss the cache and re-fetch, the rest +// hit the cache and cost nothing. +final ValueNotifier _avatarCacheGeneration = ValueNotifier(0); + +String avatarUrl({required String id, required bool isGroup, int size = 512}) { + final host = EndpointData().nextcloud().full(); + if (isGroup) { + return 'https://$host/ocs/v2.php/apps/spreed/api/v1/room/$id/avatar'; + } + return 'https://$host/avatar/$id/$size'; +} + +/// Drops cached avatar bytes so the next mount re-fetches from the server. +/// Call after the app uploaded or removed an avatar — without this the +/// 30-min TTL would mask the change for the rest of the session. +/// +/// With [id]+[isGroup], invalidates every cached size for that subject +/// (user avatars cache per-size URL). Without arguments, clears everything. +/// Pending fetches are also discarded so a stale in-flight response can't +/// repopulate the cache. +void invalidateAvatarCache({String? id, bool? isGroup}) { + if (id == null) { + _resolvedAvatars.clear(); + _pendingAvatars.clear(); + } else if (isGroup == true) { + final url = avatarUrl(id: id, isGroup: true); + _resolvedAvatars.remove(url); + _pendingAvatars.remove(url); + } else { + // User avatars include the rendered size in the URL — drop every variant. + final host = EndpointData().nextcloud().full(); + final prefix = 'https://$host/avatar/$id/'; + _resolvedAvatars.removeWhere((url, _) => url.startsWith(prefix)); + _pendingAvatars.removeWhere((url, _) => url.startsWith(prefix)); + } + _avatarCacheGeneration.value++; +} + _AvatarCacheEntry? _readAvatarCache(String url) { final entry = _resolvedAvatars.remove(url); if (entry == null) return null; @@ -72,6 +122,20 @@ class _UserAvatarState extends State { void initState() { super.initState(); _attach(); + _avatarCacheGeneration.addListener(_onCacheInvalidated); + } + + @override + void dispose() { + _avatarCacheGeneration.removeListener(_onCacheInvalidated); + super.dispose(); + } + + // Re-resolve when the cache generation changes. Cache hit → no network and + // the visible bytes stay; cache miss (our url was invalidated) → re-fetch. + void _onCacheInvalidated() { + if (!mounted) return; + setState(_attach); } @override @@ -79,18 +143,20 @@ class _UserAvatarState extends State { super.didUpdateWidget(oldWidget); if (oldWidget.id != widget.id || oldWidget.isGroup != widget.isGroup || - oldWidget.size != widget.size) { + oldWidget.size != widget.size || + oldWidget.requestSize != widget.requestSize) { _attach(); } } - String _url() { - final host = EndpointData().nextcloud().full(); - if (widget.isGroup) { - return 'https://$host/ocs/v2.php/apps/spreed/api/v1/room/${widget.id}/avatar'; - } - return 'https://$host/avatar/${widget.id}/${widget.size}'; - } + int _resolvedRequestSize() => + widget.requestSize ?? (widget.size * 4).clamp(64, 1024); + + String _url() => avatarUrl( + id: widget.id, + isGroup: widget.isGroup, + size: _resolvedRequestSize(), + ); void _attach() { final url = _url(); diff --git a/pubspec.yaml b/pubspec.yaml index 8718a7e..31b92cb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -52,6 +52,7 @@ dependencies: http: ^1.3.0 hydrated_bloc: ^11.0.0 image_picker: ^1.1.2 + crop_your_image: ^2.0.0 in_app_review: ^2.0.10 jiffy: ^6.2.1 json_annotation: ^4.9.0