From b9d41d179d1350f229ef25acb5cbcde58c39e0ec Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 16:24:25 +0200 Subject: [PATCH] android: add avatar editors and localized group properties dialog --- .../utun/chat/ui/components/AvatarEditors.kt | 123 ++++++++++++++++++ .../app/src/main/res/values-ru/strings.xml | 13 ++ .../app/src/main/res/values/strings.xml | 13 ++ 3 files changed, 149 insertions(+) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarEditors.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarEditors.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarEditors.kt new file mode 100644 index 00000000..dccfc5a2 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarEditors.kt @@ -0,0 +1,123 @@ +package com.utun.chat.ui.components + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.utun.chat.ChatApplication +import com.utun.chat.R +import com.utun.chat.data.* +import kotlinx.coroutines.launch + +/** Один диалог для меню списка и кнопки в шапке группового чата. */ +@Composable +fun GroupPropertiesDialog(channel: Channel, onDismiss: () -> Unit) { + val repository = ChatApplication.instance.avatars + val groups by repository.groups.collectAsState() + val edits by repository.edits.collectAsState() + val state = groups[channel.id] + val editState = edits["group:${channel.id}"] ?: AvatarEditState.IDLE + val working = editState == AvatarEditState.WORKING + var soundOn by rememberSaveable(channel.id) { mutableStateOf(!NotificationSound.isMuted(channel.id)) } + var pickedAccount by rememberSaveable { mutableStateOf(0L) } + var pickedGroup by rememberSaveable { mutableStateOf("") } + val scope = rememberCoroutineScope() + val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri != null) repository.editGroup(pickedGroup, uri, pickedAccount) + } + LaunchedEffect(channel.id) { repository.ensureGroup(channel.id, true) } + AlertDialog(onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.group_properties)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + GroupAvatar(channel.id, channel.name, size = 64.dp) + Column { Text(channel.name, style = MaterialTheme.typography.titleMedium); Text(channel.id) } + } + if (state?.loading == true || working) LinearProgressIndicator(Modifier.fillMaxWidth()) + if (state?.error == true) { + Text(stringResource(R.string.avatar_load_failed), color = MaterialTheme.colorScheme.error) + TextButton(onClick = { repository.ensureGroup(channel.id, true) }) { Text(stringResource(R.string.avatar_retry)) } + } + if (state?.canEdit == true) { + Text(stringResource(R.string.group_avatar_limits), style = MaterialTheme.typography.bodySmall) + Row { + TextButton(enabled = !working, onClick = { + pickedAccount = repository.accountId; pickedGroup = channel.id; picker.launch("image/*") + }) { Text(stringResource(R.string.avatar_upload)) } + TextButton(enabled = !working && state.hasImage, onClick = { + repository.editGroup(channel.id, null, repository.accountId) + }) { Text(stringResource(R.string.avatar_remove)) } + } + } else Text(stringResource(R.string.group_avatar_key_required), style = MaterialTheme.typography.bodySmall) + if (editState == AvatarEditState.ERROR) + Text(stringResource(R.string.avatar_edit_failed), color = MaterialTheme.colorScheme.error) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.sound), modifier = Modifier.weight(1f)) + Switch(checked = soundOn, onCheckedChange = { + soundOn = it; NotificationSound.setMuted(channel.id, !it) + scope.launch { ChatApplication.instance.configProvider.setChannelMuted(channel.id, !it) } + }) + } + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.close)) } }) +} + +@Composable +fun ProfileAvatarControls() { + val repository = ChatApplication.instance.avatars + val members by repository.members.collectAsState() + val edits by repository.edits.collectAsState() + val servers = remember(members) { repository.storageServers() } + var selectedServer by rememberSaveable { mutableStateOf(0L) } + var menu by remember { mutableStateOf(false) } + var pickedAccount by rememberSaveable { mutableStateOf(0L) } + var pickedServer by rememberSaveable { mutableStateOf(0L) } + val state = edits["profile"] ?: AvatarEditState.IDLE + LaunchedEffect(servers) { + if (servers.none { it.nodeId == selectedServer }) selectedServer = servers.firstOrNull()?.nodeId ?: 0L + } + val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri != null) repository.editProfile(pickedServer, uri, pickedAccount) + } + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(R.string.profile_avatar), style = MaterialTheme.typography.titleMedium) + UserAvatar(null, repository.accountId, "", size = 64.dp, profile = true) + Text(stringResource(R.string.profile_avatar_hint), style = MaterialTheme.typography.bodySmall) + if (servers.isEmpty()) Text(stringResource(R.string.avatar_no_storage), style = MaterialTheme.typography.bodySmall) + else Box { + TextButton(onClick = { menu = true }, enabled = state != AvatarEditState.WORKING) { + Text(stringResource(R.string.avatar_storage_server, servers.firstOrNull { it.nodeId == selectedServer }?.let { + "${it.name.ifBlank { AvatarProtocol.hex(it.nodeId) }} (${AvatarProtocol.hex(it.nodeId)})" + } ?: "…")) + } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + servers.forEach { server -> + DropdownMenuItem(text = { Text("${server.name} (${AvatarProtocol.hex(server.nodeId)})") }, + onClick = { selectedServer = server.nodeId; menu = false }) + } + } + } + Row { + TextButton(enabled = selectedServer != 0L && state != AvatarEditState.WORKING, onClick = { + pickedAccount = repository.accountId; pickedServer = selectedServer; picker.launch("image/*") + }) { Text(stringResource(R.string.avatar_upload)) } + TextButton(enabled = repository.accountId != 0L && state != AvatarEditState.WORKING, onClick = { + repository.editProfile(0, null, repository.accountId) + }) { Text(stringResource(R.string.avatar_remove)) } + } + when (state) { + AvatarEditState.WORKING -> LinearProgressIndicator(Modifier.fillMaxWidth()) + AvatarEditState.ERROR -> Text(stringResource(R.string.avatar_edit_failed), color = MaterialTheme.colorScheme.error) + AvatarEditState.SAVED -> Text(stringResource(R.string.profile_avatar_saved), style = MaterialTheme.typography.bodySmall) + else -> Unit + } + } +} diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml index bba08903..b1726367 100644 --- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml @@ -347,4 +347,17 @@ Не удалось подключиться к группе (код %1$d). Возможно, срок ссылки истёк; попросите отправителя создать новое приглашение. Чат-сервис недоступен. Повторить + Свойства группы + Аватар профиля + Загрузить аватар + Удалить аватар + Повторить + Не удалось загрузить аватар группы. + Не удалось обновить аватар. Подробности в логе Avatar. + Изображение будет обрезано до квадрата: не более 128 × 128 и 16 КиБ. + Для изменения аватара нужен приватный ключ группы. + Изображение будет обрезано до 256 × 256. Аватар публикуется в группах, где выбранный сервер имеет роль хранителя. + В ваших группах нет доступных серверов хранения. + Сервер хранения: %1$s + Аватар сохранён. Публикация в группы продолжается; недоступные серверы будут опрошены повторно. diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml index 033a2d9c..e5f6c65e 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -345,4 +345,17 @@ Could not join the group (code %1$d). The link may have expired; ask the sender for a new invitation. The chat service is unavailable. Retry + Group properties + Profile avatar + Upload avatar + Remove avatar + Retry + Could not load the group avatar. + Could not update the avatar. See the Avatar log for details. + The image will be cropped to a square, up to 128 × 128 and 16 KiB. + Changing the avatar requires the private group key. + The image will be cropped to 256 × 256. It is published in groups where the selected server has the storage role. + No storage server is available in your groups. + Storage server: %1$s + Avatar saved. Publishing to groups; unavailable servers will be retried.