From 30c345933b42f01b2a3d5c32facd58b241c96a7e Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Sun, 31 Jul 2022 19:49:32 +0400 Subject: [PATCH] android: create group view (#855) * android: create group view wip * wip * android: add group view image wip (#856) * new chat sheet layout * alternative layout for new chat sheet * simpler layout for new chat sheet * fix add image sheet * fix creating group * add members when creating a group * update text Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../java/chat/simplex/app/model/SimpleXAPI.kt | 9 +- .../app/views/chatlist/ChatListView.kt | 2 +- .../simplex/app/views/newchat/AddGroupView.kt | 178 ++++++++++++++++++ .../simplex/app/views/newchat/NewChatSheet.kt | 135 ++++++++----- .../app/src/main/res/values-ru/strings.xml | 10 +- .../app/src/main/res/values/strings.xml | 10 +- 6 files changed, 294 insertions(+), 50 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index a577882a3..64c8ab8bb 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -559,6 +559,13 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager return null } + suspend fun apiNewGroup(p: GroupProfile): GroupInfo? { + val r = sendCmd(CC.NewGroup(p)) + if (r is CR.GroupCreated) return r.groupInfo + Log.e(TAG, "apiNewGroup bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun apiAddMember(groupId: Long, contactId: Long, memberRole: GroupMemberRole) { val r = sendCmd(CC.ApiAddMember(groupId, contactId, memberRole)) if (r is CR.SentGroupInvitation) return @@ -1047,7 +1054,7 @@ sealed class CC { is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}" is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" - is NewGroup -> "/group ${groupProfile.displayName} ${groupProfile.fullName}" + is NewGroup -> "/_group ${json.encodeToString(groupProfile)}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" is ApiRemoveMember -> "/_remove #$groupId $memberId" diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt index 1413b3317..aeff3cc29 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt @@ -135,7 +135,7 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController, stopped: Boolean) { Icons.Outlined.AddCircle, stringResource(R.string.add_contact), tint = MaterialTheme.colors.primary, - modifier = Modifier.padding(10.dp) + modifier = Modifier.padding(10.dp).size(26.dp) ) } } else { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt new file mode 100644 index 000000000..cc887ccb6 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt @@ -0,0 +1,178 @@ +package chat.simplex.app.views.newchat + +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.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowForwardIos +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.* +import chat.simplex.app.views.ProfileNameField +import chat.simplex.app.views.chat.group.AddGroupMembersView +import chat.simplex.app.views.chatlist.populateGroupMembers +import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.isValidDisplayName +import chat.simplex.app.views.onboarding.ReadableText +import chat.simplex.app.views.usersettings.DeleteImageButton +import chat.simplex.app.views.usersettings.EditImageButton +import com.google.accompanist.insets.ProvideWindowInsets +import com.google.accompanist.insets.navigationBarsWithImePadding +import kotlinx.coroutines.launch + +@Composable +fun AddGroupView(chatModel: ChatModel, close: () -> Unit) { + AddGroupLayout( + createGroup = { groupProfile -> + withApi { + val groupInfo = chatModel.controller.apiNewGroup(groupProfile) + if (groupInfo != null) { + chatModel.addChat(Chat(chatInfo = ChatInfo.Group(groupInfo), chatItems = listOf())) + chatModel.chatItems.clear() + chatModel.chatId.value = groupInfo.id + populateGroupMembers(groupInfo, chatModel) + close.invoke() + ModalManager.shared.showCustomModal { close -> + ModalView( + close = close, modifier = Modifier, + background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight + ) { + AddGroupMembersView(groupInfo, chatModel, close) + } + } + } + } + }, + close + ) +} + +@Composable +fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { + val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) + val scope = rememberCoroutineScope() + val displayName = remember { mutableStateOf("") } + val fullName = remember { mutableStateOf("") } + val profileImage = remember { mutableStateOf(null) } + val chosenImage = remember { mutableStateOf(null) } + 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) { + Surface(Modifier.background(MaterialTheme.colors.onBackground).fillMaxSize()) { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .padding(bottom = 16.dp), + ) { + Text( + stringResource(R.string.create_secret_group_title), + style = MaterialTheme.typography.h4, + modifier = Modifier.padding(vertical = 5.dp) + ) + ReadableText(R.string.group_is_decentralized) + Spacer(Modifier.height(10.dp)) + Box( + Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + contentAlignment = Alignment.Center + ) { + Box(contentAlignment = Alignment.TopEnd) { + Box(contentAlignment = Alignment.Center) { + ProfileImage(size = 192.dp, image = profileImage.value) + EditImageButton { scope.launch { bottomSheetModalState.show() } } + } + if (profileImage.value != null) { + DeleteImageButton { profileImage.value = null } + } + } + } + Text( + stringResource(R.string.group_display_name_field), + style = MaterialTheme.typography.h6, + modifier = 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), + style = MaterialTheme.typography.h6, + modifier = 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)) + } else { + CreateGroupButton(HighOrLowlight, Modifier.padding(8.dp)) + } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } + } + } + } + } +} + +@Composable +fun CreateGroupButton(color: Color, modifier: Modifier) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + Surface(shape = RoundedCornerShape(20.dp)) { + Row(modifier, verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = color) + Icon(Icons.Outlined.ArrowForwardIos, stringResource(R.string.create_profile_button), tint = color) + } + } + } +} + +@Preview +@Composable +fun PreviewAddGroupLayout() { + SimpleXTheme { + AddGroupLayout( + createGroup = {}, + close = {} + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt index d7687a3a5..8186fb25e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt @@ -1,7 +1,7 @@ package chat.simplex.app.views.newchat import android.Manifest -import androidx.compose.foundation.clickable +import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* @@ -48,66 +48,108 @@ fun NewChatSheet(chatModel: ChatModel, newChatCtrl: ScaffoldController) { pasteLink = { newChatCtrl.collapse() ModalManager.shared.showCustomModal { close -> PasteToConnectView(chatModel, close) } + }, + createGroup = { + newChatCtrl.collapse() + ModalManager.shared.showCustomModal { close -> AddGroupView(chatModel, close) } } ) } @Composable -fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit, pasteLink: () -> Unit) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { +fun NewChatSheetLayout( + addContact: () -> Unit, + scanCode: () -> Unit, + pasteLink: () -> Unit, + createGroup: () -> Unit +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - stringResource(R.string.add_contact_to_start_new_chat), - modifier = Modifier.padding(horizontal = 8.dp).padding(top = 32.dp) + stringResource(R.string.add_contact_or_create_group), + modifier = Modifier.padding(horizontal = 4.dp).padding(top = 20.dp, bottom = 20.dp), + style = MaterialTheme.typography.body2 ) + val boxModifier = Modifier.fillMaxWidth().height(80.dp).padding(horizontal = 0.dp) + Divider(Modifier.padding(horizontal = 8.dp)) + Box(boxModifier) { + ActionRowButton( + stringResource(R.string.create_one_time_link), + stringResource(R.string.to_share_with_your_contact), + Icons.Outlined.AddLink, + click = addContact + ) + } + Divider(Modifier.padding(horizontal = 8.dp)) + Box(boxModifier) { + ActionRowButton( + stringResource(R.string.paste_received_link), + stringResource(R.string.paste_received_link_from_clipboard), + Icons.Outlined.Article, + click = pasteLink + ) + } + Divider(Modifier.padding(horizontal = 8.dp)) + Box(boxModifier) { + ActionRowButton( + stringResource(R.string.scan_QR_code), + stringResource(R.string.in_person_or_in_video_call__bracketed), + Icons.Outlined.QrCode, + click = scanCode + ) + } + Divider(Modifier.padding(horizontal = 8.dp)) + Box(boxModifier) { + ActionRowButton( + stringResource(R.string.create_group), + stringResource(R.string.only_stored_on_members_devices), + icon = Icons.Outlined.Group, + click = createGroup + ) + } + } +} + +@Composable +fun ActionRowButton( + text: String, comment: String? = null, icon: ImageVector, disabled: Boolean = false, + click: () -> Unit = {} +) { + Surface(Modifier.fillMaxSize()) { Row( - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp) - .padding(top = 24.dp, bottom = 40.dp), - horizontalArrangement = Arrangement.SpaceEvenly + Modifier.clickable(onClick = click).size(48.dp).padding(8.dp), + verticalAlignment = Alignment.CenterVertically ) { - Box( - Modifier - .weight(1F) - .fillMaxWidth()) { - ActionButton( - stringResource(R.string.create_one_time_link), - stringResource(R.string.to_share_with_your_contact), - Icons.Outlined.AddLink, - click = addContact - ) - } - Box( - Modifier - .weight(1F) - .fillMaxWidth()) { - ActionButton( - stringResource(R.string.paste_received_link), - stringResource(R.string.paste_received_link_from_clipboard), - Icons.Outlined.Article, - click = pasteLink - ) - } - Box( - Modifier - .weight(1F) - .fillMaxWidth()) { - ActionButton( - stringResource(R.string.scan_QR_code), - stringResource(R.string.in_person_or_in_video_call__bracketed), - Icons.Outlined.QrCode, - click = scanCode + val tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary + Icon(icon, text, tint = tint, modifier = Modifier.size(48.dp).padding(start = 4.dp, end = 16.dp)) + + Column { + Text( + text, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + color = tint ) + + if (comment != null) { + Text( + comment, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.body2 + ) + } } } } } @Composable -fun ActionButton(text: String?, comment: String?, icon: ImageVector, disabled: Boolean = false, - click: () -> Unit = {}) { +fun ActionButton( + text: String?, + comment: String?, + icon: ImageVector, + disabled: Boolean = false, + click: () -> Unit = {} +) { Surface(shape = RoundedCornerShape(18.dp)) { Column( Modifier @@ -148,7 +190,8 @@ fun PreviewNewChatSheet() { NewChatSheetLayout( addContact = {}, scanCode = {}, - pasteLink = {} + pasteLink = {}, + createGroup = {} ) } } diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 56e7062db..189bb8e17 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -161,12 +161,15 @@ Добавить контакт + Начать новый разговор Создать одноразовую ссылку Вставить полученную ссылку Сканировать QR код + Создать секретную группу (чтобы отправить вашему контакту) (при встрече или через видеозвонок) (вставить ссылку из буфера обмена) + (хранится только у членов группы) Разрешение не получено! @@ -320,7 +323,6 @@ Соединиться через ссылку Эта строка не является ссылкой-приглашением! Вы также можете соединиться, открыв ссылку там, где вы её получили. Если ссылка откроется в браузере, нажмите кнопку Open in mobile app. - Добавьте контакт, чтобы начать разговор: входящий звонок… @@ -585,4 +587,10 @@ Получение через Отправка через Состояние сети + + + Создать секретную группу + Группа полностью децентрализована — она видна только участникам. + Имя группы: + Полное имя: diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index cd294488e..98d7e9efa 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -161,13 +161,15 @@ Add contact - Add contact to start a new chat: + Start new chat Create link / QR code Connect via received link Scan QR code + Create secret group (to share with your contact) (in person or in video call) (paste link from clipboard) + (only stored by group members) Permission Denied! @@ -587,4 +589,10 @@ Receiving via Sending via Network status + + + Create secret group + The group is fully decentralized – it is visible only to the members. + Group display name: + Group full name: