android: edit group profile (#862)

This commit is contained in:
JRoberts
2022-08-01 16:32:42 +04:00
committed by GitHub
parent cc0a74fae4
commit 95757ed562
14 changed files with 273 additions and 17 deletions

View File

@@ -524,6 +524,9 @@ class GroupInfo (
override val fullName get() = groupProfile.fullName
override val image get() = groupProfile.image
val canEdit: Boolean
get() = membership.memberRole == GroupMemberRole.Owner && membership.memberCurrent
val canDelete: Boolean
get() = membership.memberRole == GroupMemberRole.Owner || !membership.memberCurrent
@@ -1364,6 +1367,7 @@ sealed class RcvGroupEvent() {
@Serializable @SerialName("memberDeleted") class MemberDeleted(val groupMemberId: Long, val profile: Profile): RcvGroupEvent()
@Serializable @SerialName("userDeleted") class UserDeleted(): RcvGroupEvent()
@Serializable @SerialName("groupDeleted") class GroupDeleted(): RcvGroupEvent()
@Serializable @SerialName("groupUpdated") class GroupUpdated(val groupProfile: GroupProfile): RcvGroupEvent()
val text: String get() = when (this) {
is MemberAdded -> String.format(generalGetString(R.string.rcv_group_event_member_added), profile.profileViewName)
@@ -1372,6 +1376,7 @@ sealed class RcvGroupEvent() {
is MemberDeleted -> String.format(generalGetString(R.string.rcv_group_event_member_deleted), profile.profileViewName)
is UserDeleted -> generalGetString(R.string.rcv_group_event_user_deleted)
is GroupDeleted -> generalGetString(R.string.rcv_group_event_group_deleted)
is GroupUpdated -> generalGetString(R.string.rcv_group_event_updated_group_profile)
}
}
@@ -1379,9 +1384,11 @@ sealed class RcvGroupEvent() {
sealed class SndGroupEvent() {
@Serializable @SerialName("memberDeleted") class MemberDeleted(val groupMemberId: Long, val profile: Profile): SndGroupEvent()
@Serializable @SerialName("userLeft") class UserLeft(): SndGroupEvent()
@Serializable @SerialName("groupUpdated") class GroupUpdated(val groupProfile: GroupProfile): SndGroupEvent()
val text: String get() = when (this) {
is MemberDeleted -> String.format(generalGetString(R.string.snd_group_event_member_deleted), profile.profileViewName)
is UserLeft -> generalGetString(R.string.snd_group_event_user_left)
is GroupUpdated -> generalGetString(R.string.snd_group_event_group_profile_updated)
}
}

View File

@@ -620,6 +620,24 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
return emptyList()
}
suspend fun apiUpdateGroup(groupId: Long, groupProfile: GroupProfile): GroupInfo? {
return when (val r = sendCmd(CC.ApiUpdateGroupProfile(groupId, groupProfile))) {
is CR.GroupUpdated -> r.toGroup
is CR.ChatCmdError -> {
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_saving_group_profile), "$r.chatError")
null
}
else -> {
Log.e(TAG, "apiUpdateGroup bad response: ${r.responseType} ${r.details}")
AlertManager.shared.showAlertMsg(
generalGetString(R.string.error_saving_group_profile),
"${r.responseType}: ${r.details}"
)
null
}
}
}
fun apiErrorAlert(method: String, title: String, r: CR) {
val errMsg = "${r.responseType}: ${r.details}"
Log.e(TAG, "$method bad response: $errMsg")
@@ -716,6 +734,8 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
chatModel.updateGroup(r.groupInfo)
is CR.DeletedMemberUser ->
chatModel.updateGroup(r.groupInfo)
is CR.GroupUpdated ->
chatModel.updateGroup(r.toGroup)
is CR.RcvFileStart ->
chatItemSimpleUpdate(r.chatItem)
is CR.RcvFileComplete ->
@@ -1028,6 +1048,7 @@ sealed class CC {
class ApiRemoveMember(val groupId: Long, val memberId: Long): CC()
class ApiLeaveGroup(val groupId: Long): CC()
class ApiListMembers(val groupId: Long): CC()
class ApiUpdateGroupProfile(val groupId: Long, val groupProfile: GroupProfile): CC()
class GetUserSMPServers: CC()
class SetUserSMPServers(val smpServers: List<String>): CC()
class APISetNetworkConfig(val networkConfig: NetCfg): CC()
@@ -1077,6 +1098,7 @@ sealed class CC {
is ApiRemoveMember -> "/_remove #$groupId $memberId"
is ApiLeaveGroup -> "/_leave #$groupId"
is ApiListMembers -> "/_members #$groupId"
is ApiUpdateGroupProfile -> "/_group_profile #$groupId ${json.encodeToString(groupProfile)}"
is GetUserSMPServers -> "/smp_servers"
is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}"
is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
@@ -1127,6 +1149,7 @@ sealed class CC {
is ApiRemoveMember -> "apiRemoveMember"
is ApiLeaveGroup -> "apiLeaveGroup"
is ApiListMembers -> "apiListMembers"
is ApiUpdateGroupProfile -> "apiUpdateGroupProfile"
is GetUserSMPServers -> "getUserSMPServers"
is SetUserSMPServers -> "setUserSMPServers"
is APISetNetworkConfig -> "/apiSetNetworkConfig"
@@ -1265,6 +1288,7 @@ sealed class CR {
@Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("groupRemoved") class GroupRemoved(val groupInfo: GroupInfo): CR()
@Serializable @SerialName("groupUpdated") class GroupUpdated(val toGroup: GroupInfo): CR()
// receiving file events
@Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val chatItem: AChatItem): CR()
@Serializable @SerialName("rcvFileStart") class RcvFileStart(val chatItem: AChatItem): CR()
@@ -1349,6 +1373,7 @@ sealed class CR {
is JoinedGroupMember -> "joinedGroupMember"
is ConnectedToGroupMember -> "connectedToGroupMember"
is GroupRemoved -> "groupRemoved"
is GroupUpdated -> "groupUpdated"
is RcvFileAccepted -> "rcvFileAccepted"
is RcvFileStart -> "rcvFileStart"
is RcvFileComplete -> "rcvFileComplete"
@@ -1432,6 +1457,7 @@ sealed class CR {
is JoinedGroupMember -> "groupInfo: $groupInfo\nmember: $member"
is ConnectedToGroupMember -> "groupInfo: $groupInfo\nmember: $member"
is GroupRemoved -> json.encodeToString(groupInfo)
is GroupUpdated -> json.encodeToString(toGroup)
is RcvFileAccepted -> json.encodeToString(chatItem)
is RcvFileStart -> json.encodeToString(chatItem)
is RcvFileComplete -> json.encodeToString(chatItem)

View File

@@ -18,6 +18,7 @@ val MessagePreviewLight = Color(49, 45, 44, 255)
val ToolbarLight = Color(220, 220, 220, 20)
val ToolbarDark = Color(80, 80, 80, 20)
val SettingsBackgroundLight = Color(220, 216, 215, 90)
val SettingsSecondaryLight = Color(200, 196, 195, 90)
val GroupDark = Color(80, 80, 80, 60)
val IncomingCallLight = Color(239, 237, 236, 255)
val IncomingCallDark = Color(34, 30, 29, 255)

View File

@@ -103,7 +103,6 @@ fun ChatInfoLayout(
SectionItemView {
NetworkStatusRow(chat.serverInfo.networkStatus)
}
val rcvServers = connStats.rcvServers
if (rcvServers != null && rcvServers.isNotEmpty()) {
SectionDivider()
@@ -144,7 +143,7 @@ fun ChatInfoHeader(cInfo: ChatInfo) {
Modifier.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
ChatInfoImage(cInfo, size = 192.dp, iconColor = HighOrLowlight)
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isSystemInDarkTheme()) GroupDark else SettingsSecondaryLight)
Text(
cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,

View File

@@ -112,7 +112,7 @@ fun ChatView(chatModel: ChatModel) {
close = close, modifier = Modifier,
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
GroupChatInfoView(cInfo.groupInfo, chatModel, close)
GroupChatInfoView(chatModel, close)
}
}
}

View File

@@ -87,7 +87,11 @@ fun AddGroupMembersLayout(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
ChatInfoToolbarTitle(ChatInfo.Group(groupInfo), imageSize = 60.dp, iconColor = HighOrLowlight) // TODO tertiary color
ChatInfoToolbarTitle(
ChatInfo.Group(groupInfo),
imageSize = 60.dp,
iconColor = if (isSystemInDarkTheme()) GroupDark else SettingsSecondaryLight
)
}
SectionSpacer()

View File

@@ -28,10 +28,11 @@ import chat.simplex.app.views.chatlist.populateGroupMembers
import chat.simplex.app.views.helpers.*
@Composable
fun GroupChatInfoView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
if (chat != null) {
if (chat != null && chat.chatInfo is ChatInfo.Group) {
val groupInfo = chat.chatInfo.groupInfo
GroupChatInfoLayout(
chat,
groupInfo,
@@ -62,6 +63,9 @@ fun GroupChatInfoView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> U
}
}
},
editGroupProfile = {
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
},
deleteGroup = { deleteGroupDialog(chat.chatInfo, chatModel, close) },
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) }
@@ -109,6 +113,7 @@ fun GroupChatInfoLayout(
members: List<GroupMember>,
addMembers: () -> Unit,
showMemberInfo: (GroupMember) -> Unit,
editGroupProfile: () -> Unit,
deleteGroup: () -> Unit,
clearChat: () -> Unit,
leaveGroup: () -> Unit,
@@ -143,6 +148,12 @@ fun GroupChatInfoLayout(
SectionSpacer()
SectionView {
if (groupInfo.canEdit) {
SectionItemView {
EditGroupProfileButton(editGroupProfile)
}
SectionDivider()
}
SectionItemView {
ClearChatButton(clearChat)
}
@@ -235,6 +246,24 @@ fun MemberRow(member: GroupMember, showMemberInfo: ((GroupMember) -> Unit)? = nu
}
}
@Composable
fun EditGroupProfileButton(editGroupProfile: () -> Unit) {
Row(
Modifier
.fillMaxSize()
.clickable { editGroupProfile() },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.Edit,
stringResource(R.string.button_edit_group_profile),
tint = MaterialTheme.colors.primary
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.button_edit_group_profile), color = MaterialTheme.colors.primary)
}
}
@Composable
fun LeaveGroupButton(leaveGroup: () -> Unit) {
Row(
@@ -283,7 +312,7 @@ fun PreviewGroupChatInfoLayout() {
),
groupInfo = GroupInfo.sampleData,
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
addMembers = {}, showMemberInfo = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}
)
}
}

View File

@@ -131,7 +131,7 @@ fun GroupMemberInfoHeader(member: GroupMember) {
Modifier.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
ProfileImage(size = 192.dp, member.image, color = HighOrLowlight)
ProfileImage(size = 192.dp, member.image, color = if (isSystemInDarkTheme()) GroupDark else SettingsSecondaryLight)
Text(
member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,

View File

@@ -0,0 +1,173 @@
package chat.simplex.app.views.chat.group
import android.content.res.Configuration
import android.graphics.Bitmap
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.ProfileNameField
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.isValidDisplayName
import chat.simplex.app.views.usersettings.*
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.launch
@Composable
fun GroupProfileView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
GroupProfileLayout(
close = close,
groupProfile = groupInfo.groupProfile,
saveProfile = { p ->
withApi {
val gInfo = chatModel.controller.apiUpdateGroup(groupInfo.groupId, p)
if (gInfo != null) {
chatModel.updateGroup(gInfo)
close.invoke()
}
}
}
)
}
@Composable
fun GroupProfileLayout(
close: () -> Unit,
groupProfile: GroupProfile,
saveProfile: (GroupProfile) -> Unit,
) {
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val displayName = remember { mutableStateOf(groupProfile.displayName) }
val fullName = remember { mutableStateOf(groupProfile.fullName) }
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
val profileImage = remember { mutableStateOf(groupProfile.image) }
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val focusRequester = remember { FocusRequester() }
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
ModalBottomSheetLayout(
scrimColor = Color.Black.copy(alpha = 0.12F),
modifier = Modifier.navigationBarsWithImePadding(),
sheetContent = {
GetImageBottomSheet(
chosenImage,
onImageChange = { bitmap -> profileImage.value = resizeImageToStrSize(cropToSquare(bitmap), maxDataSize = 12500) },
hideBottomSheet = {
scope.launch { bottomSheetModalState.hide() }
})
},
sheetState = bottomSheetModalState,
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
ModalView(close = close) {
Column(
Modifier
.verticalScroll(scrollState)
.padding(bottom = 16.dp),
horizontalAlignment = Alignment.Start
) {
Text(
stringResource(R.string.group_profile_is_stored_on_members_devices),
Modifier.padding(bottom = 24.dp),
color = MaterialTheme.colors.onBackground,
lineHeight = 22.sp
)
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
Box(
Modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
contentAlignment = Alignment.Center
) {
Box(contentAlignment = Alignment.TopEnd) {
Box(contentAlignment = Alignment.Center) {
ProfileImage(192.dp, profileImage.value)
EditImageButton { scope.launch { bottomSheetModalState.show() } }
}
if (profileImage.value != null) {
DeleteImageButton { profileImage.value = null }
}
}
}
Text(
stringResource(R.string.group_display_name_field),
Modifier.padding(bottom = 3.dp)
)
ProfileNameField(displayName, focusRequester)
val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
Text(
errorText,
fontSize = 15.sp,
color = MaterialTheme.colors.error
)
Spacer(Modifier.height(3.dp))
Text(
stringResource(R.string.group_full_name_field),
Modifier.padding(bottom = 5.dp)
)
ProfileNameField(fullName)
Spacer(Modifier.height(16.dp))
Row {
TextButton(stringResource(R.string.cancel_verb)) {
close.invoke()
}
Spacer(Modifier.padding(horizontal = 8.dp))
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
if (enabled) {
Text(
stringResource(R.string.save_group_profile),
modifier = Modifier.clickable { saveProfile(GroupProfile(displayName.value, fullName.value, profileImage.value)) },
color = MaterialTheme.colors.primary
)
} else {
Text(
stringResource(R.string.save_group_profile),
color = HighOrLowlight
)
}
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewGroupProfileLayout() {
SimpleXTheme {
GroupProfileLayout(
close = {},
groupProfile = GroupProfile.sampleData,
saveProfile = { _ -> }
)
}
}

View File

@@ -115,8 +115,7 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) {
}
Text(
stringResource(R.string.group_display_name_field),
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(bottom = 3.dp)
Modifier.padding(bottom = 3.dp)
)
ProfileNameField(displayName, focusRequester)
val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
@@ -128,16 +127,16 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) {
Spacer(Modifier.height(3.dp))
Text(
stringResource(R.string.group_full_name_field),
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(bottom = 5.dp)
Modifier.padding(bottom = 5.dp)
)
ProfileNameField(fullName)
Spacer(Modifier.height(8.dp))
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
if (enabled) {
val groupProfile = GroupProfile(displayName.value, fullName.value, profileImage.value)
CreateGroupButton(MaterialTheme.colors.primary, Modifier.clickable { createGroup(groupProfile) }.padding(8.dp))
CreateGroupButton(MaterialTheme.colors.primary, Modifier
.clickable { createGroup(GroupProfile(displayName.value, fullName.value, profileImage.value)) }
.padding(8.dp))
} else {
CreateGroupButton(HighOrLowlight, Modifier.padding(8.dp))
}

View File

@@ -200,7 +200,7 @@ fun UserProfileLayout(
}
@Composable
private fun ProfileNameTextField(name: MutableState<String>) {
fun ProfileNameTextField(name: MutableState<String>) {
BasicTextField(
value = name.value,
onValueChange = { name.value = it },
@@ -218,7 +218,7 @@ private fun ProfileNameTextField(name: MutableState<String>) {
}
@Composable
private fun ProfileNameRow(label: String, text: String) {
fun ProfileNameRow(label: String, text: String) {
Row(Modifier.padding(bottom = 24.dp)) {
Text(
label,
@@ -234,7 +234,7 @@ private fun ProfileNameRow(label: String, text: String) {
}
@Composable
private fun TextButton(text: String, click: () -> Unit) {
fun TextButton(text: String, click: () -> Unit) {
Text(
text,
color = MaterialTheme.colors.primary,

View File

@@ -532,8 +532,10 @@
<string name="rcv_group_event_member_deleted">удалил(а) <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_user_deleted">удалил(а) вас из группы</string>
<string name="rcv_group_event_group_deleted">удалил(а) группу</string>
<string name="rcv_group_event_updated_group_profile">обновил(а) профиль группы</string>
<string name="snd_group_event_member_deleted">вы удалили <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="snd_group_event_user_left">вы покинули группу</string>
<string name="snd_group_event_group_profile_updated">профиль группы обновлен</string>
<!-- GroupMemberRole -->
<string name="group_member_role_member">участник</string>
@@ -572,6 +574,7 @@
<string name="delete_group_question">Удалить группу?</string>
<string name="delete_group_for_all_members_cannot_undo_warning">Группа будет удалена для всех участников - это действие нельзя отменить!</string>
<string name="button_leave_group">Выйти из группы</string>
<string name="button_edit_group_profile">Редактировать профиль группы</string>
<!-- For Console chat info section -->
<string name="section_title_for_console">ДЛЯ КОНСОЛИ</string>
@@ -598,4 +601,9 @@
<string name="group_is_decentralized">Группа полностью децентрализована — она видна только участникам.</string>
<string name="group_display_name_field">Имя группы:</string>
<string name="group_full_name_field">Полное имя:</string>
<!-- GroupProfileView.kt -->
<string name="group_profile_is_stored_on_members_devices">Профиль группы хранится на устройствах участников, а не на серверах.</string>
<string name="save_group_profile">Сохранить профиль группы</string>
<string name="error_saving_group_profile">Ошибка при сохранении профиля группы</string>
</resources>

View File

@@ -534,8 +534,10 @@
<string name="rcv_group_event_member_deleted">removed <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_user_deleted">removed you</string>
<string name="rcv_group_event_group_deleted">deleted group</string>
<string name="rcv_group_event_updated_group_profile">updated group profile</string>
<string name="snd_group_event_member_deleted">you removed <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="snd_group_event_user_left">you left</string>
<string name="snd_group_event_group_profile_updated">group profile updated</string>
<!-- GroupMemberRole -->
<string name="group_member_role_member">member</string>
@@ -574,6 +576,7 @@
<string name="delete_group_question">Delete group?</string>
<string name="delete_group_for_all_members_cannot_undo_warning">Group will be deleted for all members - this cannot be undone!</string>
<string name="button_leave_group">Leave group</string>
<string name="button_edit_group_profile">Edit group profile</string>
<!-- For Console chat info section -->
<string name="section_title_for_console">FOR CONSOLE</string>
@@ -600,4 +603,9 @@
<string name="group_is_decentralized">The group is fully decentralized it is visible only to the members.</string>
<string name="group_display_name_field">Group display name:</string>
<string name="group_full_name_field">Group full name:</string>
<!-- GroupProfileView.kt -->
<string name="group_profile_is_stored_on_members_devices">Group profile is stored on members\' devices, not on the servers.</string>
<string name="save_group_profile">Save group profile</string>
<string name="error_saving_group_profile">Error saving group profile</string>
</resources>