Browse Source

android: add avatar editors and localized group properties dialog

master
evgeny 6 days ago
parent
commit
b9d41d179d
  1. 123
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarEditors.kt
  2. 13
      tools/chatgui-android/app/src/main/res/values-ru/strings.xml
  3. 13
      tools/chatgui-android/app/src/main/res/values/strings.xml

123
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
}
}
}

13
tools/chatgui-android/app/src/main/res/values-ru/strings.xml

@ -347,4 +347,17 @@
<string name="invite_join_failed">Не удалось подключиться к группе (код %1$d). Возможно, срок ссылки истёк; попросите отправителя создать новое приглашение.</string>
<string name="invite_core_unavailable">Чат-сервис недоступен.</string>
<string name="retry">Повторить</string>
<string name="group_properties">Свойства группы</string>
<string name="profile_avatar">Аватар профиля</string>
<string name="avatar_upload">Загрузить аватар</string>
<string name="avatar_remove">Удалить аватар</string>
<string name="avatar_retry">Повторить</string>
<string name="avatar_load_failed">Не удалось загрузить аватар группы.</string>
<string name="avatar_edit_failed">Не удалось обновить аватар. Подробности в логе Avatar.</string>
<string name="group_avatar_limits">Изображение будет обрезано до квадрата: не более 128 × 128 и 16 КиБ.</string>
<string name="group_avatar_key_required">Для изменения аватара нужен приватный ключ группы.</string>
<string name="profile_avatar_hint">Изображение будет обрезано до 256 × 256. Аватар публикуется в группах, где выбранный сервер имеет роль хранителя.</string>
<string name="avatar_no_storage">В ваших группах нет доступных серверов хранения.</string>
<string name="avatar_storage_server">Сервер хранения: %1$s</string>
<string name="profile_avatar_saved">Аватар сохранён. Публикация в группы продолжается; недоступные серверы будут опрошены повторно.</string>
</resources>

13
tools/chatgui-android/app/src/main/res/values/strings.xml

@ -345,4 +345,17 @@
<string name="invite_join_failed">Could not join the group (code %1$d). The link may have expired; ask the sender for a new invitation.</string>
<string name="invite_core_unavailable">The chat service is unavailable.</string>
<string name="retry">Retry</string>
<string name="group_properties">Group properties</string>
<string name="profile_avatar">Profile avatar</string>
<string name="avatar_upload">Upload avatar</string>
<string name="avatar_remove">Remove avatar</string>
<string name="avatar_retry">Retry</string>
<string name="avatar_load_failed">Could not load the group avatar.</string>
<string name="avatar_edit_failed">Could not update the avatar. See the Avatar log for details.</string>
<string name="group_avatar_limits">The image will be cropped to a square, up to 128 × 128 and 16 KiB.</string>
<string name="group_avatar_key_required">Changing the avatar requires the private group key.</string>
<string name="profile_avatar_hint">The image will be cropped to 256 × 256. It is published in groups where the selected server has the storage role.</string>
<string name="avatar_no_storage">No storage server is available in your groups.</string>
<string name="avatar_storage_server">Storage server: %1$s</string>
<string name="profile_avatar_saved">Avatar saved. Publishing to groups; unavailable servers will be retried.</string>
</resources>

Loading…
Cancel
Save