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:
@@ -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<http.Response> _send(
|
||||||
|
Future<http.Response> Function(Uri uri, Map<String, String> 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<void> 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<void> 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<CloudUserInfo> 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<String, dynamic>;
|
||||||
|
final data =
|
||||||
|
(root['ocs'] as Map<String, dynamic>)['data']
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
return CloudUserInfo(
|
||||||
|
userId: data['id'] as String,
|
||||||
|
displayName: (data['displayname'] as String?) ?? '',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
throw ParseException(
|
||||||
|
technicalDetails: 'Cloud $uri user info parse: $e',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../../api_params.dart';
|
import '../../../api_params.dart';
|
||||||
@@ -62,3 +64,44 @@ class DeleteMessage extends TalkApi {
|
|||||||
Map<String, String>? headers,
|
Map<String, String>? headers,
|
||||||
) => http.delete(uri, headers: 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<http.Response> request(
|
||||||
|
Uri uri,
|
||||||
|
ApiParams? body,
|
||||||
|
Map<String, String>? 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<http.Response> request(
|
||||||
|
Uri uri,
|
||||||
|
ApiParams? body,
|
||||||
|
Map<String, String>? headers,
|
||||||
|
) => http.delete(uri, headers: headers);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,23 +1,176 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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 '../../../../api/marianumconnect/queries/auth_logout/auth_logout.dart';
|
||||||
import '../../../../model/account_data.dart';
|
import '../../../../model/account_data.dart';
|
||||||
import '../../../../state/app/modules/account/bloc/account_bloc.dart';
|
import '../../../../state/app/modules/account/bloc/account_bloc.dart';
|
||||||
import '../../../../state/app/modules/account/bloc/account_state.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/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});
|
const AccountSection({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => ListTile(
|
State<AccountSection> createState() => _AccountSectionState();
|
||||||
leading: const CenteredLeading(Icon(Icons.logout_outlined)),
|
}
|
||||||
title: const Text('Konto abmelden'),
|
|
||||||
subtitle: Text('Angemeldet als ${AccountData().getUsername()}'),
|
class _AccountSectionState extends State<AccountSection> {
|
||||||
onTap: () => _showLogoutDialog(context),
|
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 {
|
Future<void> _showLogoutDialog(BuildContext context) async {
|
||||||
// Sequential logout flow: dialog wipes secure storage, dialog closes
|
// Sequential logout flow: dialog wipes secure storage, dialog closes
|
||||||
@@ -45,5 +198,42 @@ class AccountSection extends StatelessWidget {
|
|||||||
Future<void> _performLogout() async {
|
Future<void> _performLogout() async {
|
||||||
await AuthLogout().run();
|
await AuthLogout().run();
|
||||||
await AccountData().removeData();
|
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/get_participants/get_participants_response.dart';
|
||||||
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||||
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.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/async_action_button.dart';
|
||||||
|
import '../../../../widget/avatar_actions_sheet.dart';
|
||||||
import '../../../../widget/confirm_dialog.dart';
|
import '../../../../widget/confirm_dialog.dart';
|
||||||
import '../../../../widget/large_profile_picture_view.dart';
|
import '../../../../widget/large_profile_picture_view.dart';
|
||||||
import '../../../../widget/loading_spinner.dart';
|
import '../../../../widget/loading_spinner.dart';
|
||||||
@@ -25,6 +27,8 @@ class ChatInfo extends StatefulWidget {
|
|||||||
class _ChatInfoState extends State<ChatInfo> {
|
class _ChatInfoState extends State<ChatInfo> {
|
||||||
GetParticipantsResponse? participants;
|
GetParticipantsResponse? participants;
|
||||||
late bool _isFavorite;
|
late bool _isFavorite;
|
||||||
|
int _avatarVersion = 0;
|
||||||
|
bool _avatarBusy = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -66,28 +70,101 @@ class _ChatInfoState extends State<ChatInfo> {
|
|||||||
if (closed == true && mounted) Navigator.of(context).pop();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var isGroup =
|
var isGroup =
|
||||||
widget.room.type != GetRoomResponseObjectConversationType.oneToOne;
|
widget.room.type != GetRoomResponseObjectConversationType.oneToOne;
|
||||||
|
final canEdit = _canEditAvatar();
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(widget.room.displayName)),
|
appBar: AppBar(title: Text(widget.room.displayName)),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 30),
|
const SizedBox(height: 30),
|
||||||
Center(
|
Center(
|
||||||
child: GestureDetector(
|
child: SizedBox(
|
||||||
child: UserAvatar(
|
width: 180,
|
||||||
id: isGroup ? widget.room.token : widget.room.name,
|
height: 180,
|
||||||
isGroup: isGroup,
|
child: Stack(
|
||||||
size: 80,
|
clipBehavior: Clip.none,
|
||||||
),
|
children: [
|
||||||
onTap: () => TalkNavigator.pushSplitView(
|
Center(
|
||||||
context,
|
child: GestureDetector(
|
||||||
LargeProfilePictureView(
|
child: UserAvatar(
|
||||||
id: isGroup ? widget.room.token : widget.room.name,
|
key: ValueKey(_avatarVersion),
|
||||||
isGroup: isGroup,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<AvatarSheetResult?> showAvatarActionsSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
required bool allowRemove,
|
||||||
|
}) async {
|
||||||
|
AvatarSheetResult? result;
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
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<Uint8List?> _pickAndCrop(
|
||||||
|
BuildContext context,
|
||||||
|
Future<XFile?> 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<Uint8List>(
|
||||||
|
MaterialPageRoute(
|
||||||
|
fullscreenDialog: true,
|
||||||
|
builder: (_) => AvatarCropPage(imageBytes: bytes),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<AvatarCropPage> createState() => _AvatarCropPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AvatarCropPageState extends State<AvatarCropPage> {
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:photo_view/photo_view.dart';
|
import 'package:photo_view/photo_view.dart';
|
||||||
|
|
||||||
import '../model/account_data.dart';
|
import '../model/account_data.dart';
|
||||||
import '../model/endpoint_data.dart';
|
import 'user_avatar.dart';
|
||||||
|
|
||||||
class LargeProfilePictureView extends StatelessWidget {
|
class LargeProfilePictureView extends StatelessWidget {
|
||||||
final String id;
|
final String id;
|
||||||
@@ -14,14 +14,6 @@ class LargeProfilePictureView extends StatelessWidget {
|
|||||||
super.key,
|
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
|
@override
|
||||||
Widget build(BuildContext context) => Scaffold(
|
Widget build(BuildContext context) => Scaffold(
|
||||||
appBar: AppBar(title: Text(isGroup ? 'Gruppenbild' : 'Profilbild')),
|
appBar: AppBar(title: Text(isGroup ? 'Gruppenbild' : 'Profilbild')),
|
||||||
@@ -29,7 +21,7 @@ class LargeProfilePictureView extends StatelessWidget {
|
|||||||
minScale: 0.5,
|
minScale: 0.5,
|
||||||
maxScale: 3.0,
|
maxScale: 3.0,
|
||||||
imageProvider: Image.network(
|
imageProvider: Image.network(
|
||||||
_imageUrl(),
|
avatarUrl(id: id, isGroup: isGroup, size: 1024),
|
||||||
headers: {'Authorization': AccountData().getBasicAuthHeader()},
|
headers: {'Authorization': AccountData().getBasicAuthHeader()},
|
||||||
).image,
|
).image,
|
||||||
backgroundDecoration: BoxDecoration(
|
backgroundDecoration: BoxDecoration(
|
||||||
|
|||||||
@@ -13,10 +13,18 @@ class UserAvatar extends StatefulWidget {
|
|||||||
final String id;
|
final String id;
|
||||||
final bool isGroup;
|
final bool isGroup;
|
||||||
final int size;
|
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({
|
const UserAvatar({
|
||||||
required this.id,
|
required this.id,
|
||||||
this.isGroup = false,
|
this.isGroup = false,
|
||||||
this.size = 20,
|
this.size = 20,
|
||||||
|
this.requestSize,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,6 +54,48 @@ final LinkedHashMap<String, _AvatarCacheEntry> _resolvedAvatars =
|
|||||||
LinkedHashMap<String, _AvatarCacheEntry>();
|
LinkedHashMap<String, _AvatarCacheEntry>();
|
||||||
final Map<String, Future<_AvatarPayload?>> _pendingAvatars = {};
|
final Map<String, Future<_AvatarPayload?>> _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<int> _avatarCacheGeneration = ValueNotifier<int>(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) {
|
_AvatarCacheEntry? _readAvatarCache(String url) {
|
||||||
final entry = _resolvedAvatars.remove(url);
|
final entry = _resolvedAvatars.remove(url);
|
||||||
if (entry == null) return null;
|
if (entry == null) return null;
|
||||||
@@ -72,6 +122,20 @@ class _UserAvatarState extends State<UserAvatar> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_attach();
|
_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
|
@override
|
||||||
@@ -79,18 +143,20 @@ class _UserAvatarState extends State<UserAvatar> {
|
|||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.id != widget.id ||
|
if (oldWidget.id != widget.id ||
|
||||||
oldWidget.isGroup != widget.isGroup ||
|
oldWidget.isGroup != widget.isGroup ||
|
||||||
oldWidget.size != widget.size) {
|
oldWidget.size != widget.size ||
|
||||||
|
oldWidget.requestSize != widget.requestSize) {
|
||||||
_attach();
|
_attach();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _url() {
|
int _resolvedRequestSize() =>
|
||||||
final host = EndpointData().nextcloud().full();
|
widget.requestSize ?? (widget.size * 4).clamp(64, 1024);
|
||||||
if (widget.isGroup) {
|
|
||||||
return 'https://$host/ocs/v2.php/apps/spreed/api/v1/room/${widget.id}/avatar';
|
String _url() => avatarUrl(
|
||||||
}
|
id: widget.id,
|
||||||
return 'https://$host/avatar/${widget.id}/${widget.size}';
|
isGroup: widget.isGroup,
|
||||||
}
|
size: _resolvedRequestSize(),
|
||||||
|
);
|
||||||
|
|
||||||
void _attach() {
|
void _attach() {
|
||||||
final url = _url();
|
final url = _url();
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ dependencies:
|
|||||||
http: ^1.3.0
|
http: ^1.3.0
|
||||||
hydrated_bloc: ^11.0.0
|
hydrated_bloc: ^11.0.0
|
||||||
image_picker: ^1.1.2
|
image_picker: ^1.1.2
|
||||||
|
crop_your_image: ^2.0.0
|
||||||
in_app_review: ^2.0.10
|
in_app_review: ^2.0.10
|
||||||
jiffy: ^6.2.1
|
jiffy: ^6.2.1
|
||||||
json_annotation: ^4.9.0
|
json_annotation: ^4.9.0
|
||||||
|
|||||||
Reference in New Issue
Block a user