From 1e587df3d48427aaac79e74e2ee0d6b57877c868 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 31 Aug 2022 23:49:19 +0300 Subject: [PATCH] State preserving for some UI elements which otherwise would be lost on orientation change (#994) - restore message text as well as reply state - restore search view - don't display blank view on orientation change for a moment - better saving of local user name while typing. Prevent loosing state on orientation change and hard killing the app - don't display same messages in MainActivity from old intents on orientation change (no double processing of intent) --- .../java/chat/simplex/app/MainActivity.kt | 9 ++++-- .../simplex/app/views/chat/ChatInfoView.kt | 18 +++++++++-- .../chat/simplex/app/views/chat/ChatView.kt | 12 ++++--- .../simplex/app/views/chat/ComposeView.kt | 32 +++++++++++++------ .../app/views/helpers/SearchTextField.kt | 14 ++++---- 5 files changed, 59 insertions(+), 26 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt index 163d09a8af..19dbaec96d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -15,6 +15,7 @@ import androidx.compose.material.Surface import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Replay 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 @@ -51,7 +52,11 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver { ProcessLifecycleOwner.get().lifecycle.addObserver(this) // testJson() val m = vm.chatModel - processNotificationIntent(intent, m) + // When call ended and orientation changes, it re-process old intent, it's unneeded. + // Only needed to be processed on first creation of activity + if (savedInstanceState == null) { + processNotificationIntent(intent, m) + } setContent { SimpleXTheme { Surface( @@ -223,7 +228,7 @@ fun MainPage( showLANotice: () -> Unit ) { // this with LaunchedEffect(userAuthorized.value) fixes bottom sheet visibly collapsing after authentication - var chatsAccessAuthorized by remember { mutableStateOf(false) } + var chatsAccessAuthorized by rememberSaveable { mutableStateOf(false) } LaunchedEffect(userAuthorized.value) { if (chatModel.controller.appPrefs.performLA.get()) { delay(500L) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt index 130fbaf864..cd84fb6348 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt @@ -16,6 +16,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -34,6 +35,8 @@ import chat.simplex.app.SimplexApp import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* @Composable fun ChatInfoView( @@ -250,10 +253,10 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) { @Composable private fun LocalAliasEditor(initialValue: String, updateValue: (String) -> Unit) { - var value by remember { mutableStateOf(initialValue) } + var value by rememberSaveable { mutableStateOf(initialValue) } DefaultBasicTextField( Modifier.fillMaxWidth().padding(horizontal = 10.dp), - initialValue, + value, { Text( generalGetString(R.string.text_field_set_contact_placeholder), @@ -268,8 +271,17 @@ private fun LocalAliasEditor(initialValue: String, updateValue: (String) -> Unit ) { value = it } + LaunchedEffect(Unit) { + snapshotFlow { value } + .onEach { delay(500) } // wait a little after every new character, don't emit until user stops typing + .conflate() // get the latest value + .filter { it == value } // don't process old ones + .collect { + updateValue(value) + } + } DisposableEffect(Unit) { - onDispose { updateValue(value) } + onDispose { updateValue(value) } // just in case snapshotFlow will be canceled when user presses Back too fast } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index d95afec8ed..c7c8c06d03 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -47,11 +47,13 @@ import kotlinx.datetime.Clock @Composable fun ChatView(chatModel: ChatModel) { var activeChat by remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }) } - val searchText = remember { mutableStateOf("") } + val searchText = rememberSaveable { mutableStateOf("") } val user = chatModel.currentUser.value val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() - val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews)) } - val attachmentOption = remember { mutableStateOf(null) } + val composeState = rememberSaveable(saver = ComposeState.saver()) { + mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews)) + } + val attachmentOption = rememberSaveable { mutableStateOf(null) } val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val scope = rememberCoroutineScope() @@ -304,8 +306,8 @@ fun ChatInfoToolbar( addMembers: (GroupInfo) -> Unit, onSearchValueChanged: (String) -> Unit, ) { - var showMenu by remember { mutableStateOf(false) } - var showSearch by remember { mutableStateOf(false) } + var showMenu by rememberSaveable { mutableStateOf(false) } + var showSearch by rememberSaveable { mutableStateOf(false) } val onBackClicked = { if (!showSearch) { back() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index 6c254935a9..0c18eb38aa 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -19,13 +19,13 @@ import androidx.annotation.CallSuper import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme +import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.outlined.Reply import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.Saver import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -42,21 +42,26 @@ import chat.simplex.app.views.chat.item.* import chat.simplex.app.views.helpers.* import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString import java.io.File +@Serializable sealed class ComposePreview { - object NoPreview: ComposePreview() - class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview() - class ImagePreview(val image: String): ComposePreview() - class FilePreview(val fileName: String): ComposePreview() + @Serializable object NoPreview: ComposePreview() + @Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview() + @Serializable class ImagePreview(val image: String): ComposePreview() + @Serializable class FilePreview(val fileName: String): ComposePreview() } +@Serializable sealed class ComposeContextItem { - object NoContextItem: ComposeContextItem() - class QuotedItem(val chatItem: ChatItem): ComposeContextItem() - class EditingItem(val chatItem: ChatItem): ComposeContextItem() + @Serializable object NoContextItem: ComposeContextItem() + @Serializable class QuotedItem(val chatItem: ChatItem): ComposeContextItem() + @Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem() } +@Serializable data class ComposeState( val message: String = "", val preview: ComposePreview = ComposePreview.NoPreview, @@ -99,6 +104,15 @@ data class ComposeState( is ComposePreview.CLinkPreview -> preview.linkPreview else -> null } + + companion object { + fun saver(): Saver, *> = Saver( + save = { json.encodeToString(serializer(), it.value) }, + restore = { + mutableStateOf(json.decodeFromString(it)) + } + ) + } } fun chatItemPreview(chatItem: ChatItem): ComposePreview { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SearchTextField.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SearchTextField.kt index b2fc5d6da0..09967ca0b6 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SearchTextField.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SearchTextField.kt @@ -11,6 +11,7 @@ import androidx.compose.material.TextFieldDefaults.textFieldWithLabelPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -20,8 +21,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.input.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.R @@ -30,7 +30,7 @@ import kotlinx.coroutines.delay @OptIn(ExperimentalComposeUiApi::class) @Composable fun SearchTextField(modifier: Modifier, placeholder: String, onValueChange: (String) -> Unit) { - var searchText by remember { mutableStateOf("") } + var searchText by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } val focusRequester = remember { FocusRequester() } val keyboard = LocalSoftwareKeyboardController.current @@ -61,7 +61,7 @@ fun SearchTextField(modifier: Modifier, placeholder: String, onValueChange: (Str ), onValueChange = { searchText = it - onValueChange(it) + onValueChange(it.text) }, cursorBrush = SolidColor(colors.cursorColor(false).value), visualTransformation = VisualTransformation.None, @@ -75,13 +75,13 @@ fun SearchTextField(modifier: Modifier, placeholder: String, onValueChange: (Str interactionSource = interactionSource, decorationBox = @Composable { innerTextField -> TextFieldDefaults.TextFieldDecorationBox( - value = searchText, + value = searchText.text, innerTextField = innerTextField, placeholder = { Text(placeholder) }, - trailingIcon = if (searchText.isNotEmpty()) {{ - IconButton({ searchText = ""; onValueChange("") }) { + trailingIcon = if (searchText.text.isNotEmpty()) {{ + IconButton({ searchText = TextFieldValue(""); onValueChange("") }) { Icon(Icons.Default.Close, stringResource(R.string.icon_descr_close_button), tint = MaterialTheme.colors.primary,) } }} else null,