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 bd5018546e..5e3a865886 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 @@ -2,7 +2,6 @@ package chat.simplex.app.views.chatlist import androidx.activity.compose.BackHandler import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -15,7 +14,6 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.platform.LocalUriHandler @@ -31,17 +29,25 @@ import chat.simplex.app.views.helpers.* import chat.simplex.app.views.newchat.NewChatSheet import chat.simplex.app.views.usersettings.SettingsView import chat.simplex.app.views.usersettings.simplexTeamUri +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @Composable fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: Boolean) { - val scaffoldState = rememberScaffoldState() - var showNewChatDialog by rememberSaveable { mutableStateOf(false) } + val newChatSheetState by rememberSaveable(stateSaver = NewChatSheetState.saver()) { mutableStateOf(MutableStateFlow(NewChatSheetState.GONE)) } + val showNewChatSheet = { + newChatSheetState.value = NewChatSheetState.VISIBLE + } + val hideNewChatSheet: (animated: Boolean) -> Unit = { animated -> + if (animated) newChatSheetState.value = NewChatSheetState.HIDING + else newChatSheetState.value = NewChatSheetState.GONE + } LaunchedEffect(chatModel.clearOverlays.value) { - if (chatModel.clearOverlays.value && showNewChatDialog) showNewChatDialog = false + if (chatModel.clearOverlays.value && newChatSheetState.value.isVisible()) hideNewChatSheet(false) } var searchInList by rememberSaveable { mutableStateOf("") } - Scaffold ( + val scaffoldState = rememberScaffoldState() + Scaffold( topBar = { ChatListToolbar(chatModel, scaffoldState.drawerState, stopped) { searchInList = it.trim() } }, scaffoldState = scaffoldState, drawerContent = { SettingsView(chatModel, setPerformLA) }, @@ -50,7 +56,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: FloatingActionButton( onClick = { if (!stopped) { - showNewChatDialog = !showNewChatDialog + if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet() } }, elevation = FloatingActionButtonDefaults.elevation( @@ -62,7 +68,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: backgroundColor = if (!stopped) MaterialTheme.colors.primary else HighOrLowlight, contentColor = Color.White ) { - Icon(if (!showNewChatDialog) Icons.Default.Edit else Icons.Default.Close, stringResource(R.string.add_contact_or_create_group)) + Icon(if (!newChatSheetState.collectAsState().value.isVisible()) Icons.Default.Edit else Icons.Default.Close, stringResource(R.string.add_contact_or_create_group)) } } } @@ -77,8 +83,8 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: ChatList(chatModel, search = searchInList) } else { Box(Modifier.fillMaxSize()) { - if (!stopped && !showNewChatDialog) { - OnboardingButtons { showNewChatDialog = true } + if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) { + OnboardingButtons(showNewChatSheet) } Text(stringResource(R.string.you_have_no_chats), Modifier.align(Alignment.Center), color = HighOrLowlight) } @@ -86,48 +92,20 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: } } } - if (showNewChatDialog && searchInList.isEmpty()) { - Surface( - Modifier - .fillMaxSize() - .clickable(remember { MutableInteractionSource() }, indication = null) { showNewChatDialog = false }, - color = if (isInDarkTheme()) Color.Black.copy(alpha = 0.64f) else DrawerDefaults.scrimColor, - ) { - Column( - verticalArrangement = Arrangement.Bottom, - horizontalAlignment = Alignment.End - ) { - NewChatSheet(chatModel, showNewChatDialog) { showNewChatDialog = false } - FloatingActionButton( - onClick = { - if (!stopped) { showNewChatDialog = !showNewChatDialog } - }, - Modifier.padding(end = 16.dp, bottom = 16.dp), - elevation = FloatingActionButtonDefaults.elevation( - defaultElevation = 0.dp, - pressedElevation = 0.dp, - hoveredElevation = 0.dp, - focusedElevation = 0.dp, - ), - backgroundColor = if (!stopped) MaterialTheme.colors.primary else HighOrLowlight, - contentColor = Color.White - ) { - Icon(if (!showNewChatDialog) Icons.Default.Edit else Icons.Default.Close, stringResource(R.string.add_contact_or_create_group)) - } - } - } + if (searchInList.isEmpty()) { + NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) } } @Composable -private fun OnboardingButtons(openNewChatDialog: () -> Unit) { +private fun OnboardingButtons(openNewChatSheet: () -> Unit) { Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Bottom) { val uriHandler = LocalUriHandler.current ConnectButton(generalGetString(R.string.chat_with_developers)) { uriHandler.openUri(simplexTeamUri) } Spacer(Modifier.height(DEFAULT_PADDING)) - ConnectButton(generalGetString(R.string.tap_to_start_new_chat), openNewChatDialog) + ConnectButton(generalGetString(R.string.tap_to_start_new_chat), openNewChatSheet) val color = MaterialTheme.colors.primary Canvas(modifier = Modifier.width(40.dp).height(10.dp), onDraw = { val trianglePath = Path().apply { @@ -147,7 +125,8 @@ private fun OnboardingButtons(openNewChatDialog: () -> Unit) { @Composable private fun ConnectButton(text: String, onClick: () -> Unit) { - Button(onClick, + Button( + onClick, shape = RoundedCornerShape(21.dp), colors = ButtonDefaults.textButtonColors( backgroundColor = MaterialTheme.colors.primary @@ -191,13 +170,14 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stop } } } - val scope = rememberCoroutineScope() DefaultTopAppBar( - navigationButton = { if (showSearch) - NavigationButtonBack(hideSearchOnBack) - else - NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } } }, + navigationButton = { + if (showSearch) + NavigationButtonBack(hideSearchOnBack) + else + NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } } + }, title = { Row(verticalAlignment = Alignment.CenterVertically) { Text( @@ -220,7 +200,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stop onSearchValueChanged = onSearchValueChanged, buttons = barButtons ) - Divider() + Divider(Modifier.padding(top = AppBarHeight)) } @Composable diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AnimationUtils.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AnimationUtils.kt new file mode 100644 index 0000000000..68f18faede --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AnimationUtils.kt @@ -0,0 +1,5 @@ +package chat.simplex.app.views.helpers + +import androidx.compose.animation.core.* + +fun newChatSheetAnimSpec() = tween(256, 0, LinearEasing) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt index d12ecd84bd..b908e66e3f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt @@ -1,9 +1,32 @@ package chat.simplex.app.views.helpers import android.net.Uri +import androidx.compose.runtime.saveable.Saver +import kotlinx.coroutines.flow.MutableStateFlow sealed class SharedContent { data class Text(val text: String): SharedContent() data class Image(val uri: Uri): SharedContent() data class File(val uri: Uri): SharedContent() } + +enum class NewChatSheetState { + VISIBLE, HIDING, GONE; + fun isVisible(): Boolean { + return this == VISIBLE + } + fun isHiding(): Boolean { + return this == HIDING + } + fun isGone(): Boolean { + return this == GONE + } + companion object { + fun saver(): Saver, *> = Saver( + save = { it.value.toString() }, + restore = { + MutableStateFlow(valueOf(it)) + } + ) + } +} 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 80478e7878..a1a262a933 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,88 +1,169 @@ package chat.simplex.app.views.newchat import androidx.activity.compose.BackHandler +import androidx.compose.animation.* +import androidx.compose.animation.core.* import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.* import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.core.graphics.ColorUtils import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.helpers.ModalManager +import chat.simplex.app.views.helpers.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlin.math.roundToInt @Composable -fun NewChatSheet(chatModel: ChatModel, newChatDialogOpen: Boolean, closeNewChatDialog: () -> Unit) { - if (newChatDialogOpen) BackHandler { closeNewChatDialog() } +fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) { + if (newChatSheetState.collectAsState().value.isVisible()) BackHandler { closeNewChatSheet(true) } NewChatSheetLayout( + newChatSheetState, + stopped, addContact = { - closeNewChatDialog() + closeNewChatSheet(false) ModalManager.shared.showModal { CreateLinkView(chatModel, CreateLinkTab.ONE_TIME) } }, connectViaLink = { - closeNewChatDialog() + closeNewChatSheet(false) ModalManager.shared.showModalCloseable { close -> ConnectViaLinkView(chatModel, close) } }, createGroup = { - closeNewChatDialog() + closeNewChatSheet(false) ModalManager.shared.showCustomModal { close -> AddGroupView(chatModel, close) } - } + }, + closeNewChatSheet, ) } +private val titles = listOf(R.string.share_one_time_link, R.string.connect_via_link_or_qr, R.string.create_group) +private val icons = listOf(Icons.Outlined.AddLink, Icons.Outlined.QrCode, Icons.Outlined.Group) + @Composable -fun NewChatSheetLayout( +private fun NewChatSheetLayout( + newChatSheetState: StateFlow, + stopped: Boolean, addContact: () -> Unit, connectViaLink: () -> Unit, - createGroup: () -> Unit + createGroup: () -> Unit, + closeNewChatSheet: (animated: Boolean) -> Unit, ) { - val actions = remember { listOf(addContact, connectViaLink, createGroup) } - val titles = remember { listOf(R.string.share_one_time_link, R.string.connect_via_link_or_qr, R.string.create_group) } - val icons = remember { listOf(Icons.Outlined.AddLink, Icons.Outlined.QrCode, Icons.Outlined.Group) } - LazyColumn { - items(3) { index -> - Row { - Spacer(Modifier.weight(1f)) - Box(contentAlignment = Alignment.CenterEnd) { - Button( - actions[index], - shape = RoundedCornerShape(21.dp), - colors = ButtonDefaults.textButtonColors( - backgroundColor = if (isInDarkTheme()) DarkGray else MaterialTheme.colors.background - ), - elevation = null, - contentPadding = PaddingValues(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF), - modifier = Modifier.height(42.dp) - ) { - Text( - stringResource(titles[index]), - Modifier.padding(start = DEFAULT_PADDING_HALF), - color = if (isInDarkTheme()) MaterialTheme.colors.primary else MaterialTheme.colors.primary, - fontWeight = FontWeight.Medium, - ) - Icon( - icons[index], - stringResource(titles[index]), - Modifier.size(42.dp), - tint = if (isInDarkTheme()) MaterialTheme.colors.primary else MaterialTheme.colors.primary - ) - } + var newChat by remember { mutableStateOf(newChatSheetState.value) } + val resultingColor = if (isInDarkTheme()) Color.Black.copy(0.64f) else DrawerDefaults.scrimColor + val animatedColor = remember { + Animatable( + if (newChat.isVisible()) Color.Transparent else resultingColor, + Color.VectorConverter(resultingColor.colorSpace) + ) + } + val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) } + LaunchedEffect(Unit) { + launch { + newChatSheetState.collect { + newChat = it + launch { + animatedColor.animateTo(if (newChat.isVisible()) resultingColor else Color.Transparent, newChatSheetAnimSpec()) + } + launch { + animatedFloat.animateTo(if (newChat.isVisible()) 1f else 0f, newChatSheetAnimSpec()) + if (newChat.isHiding()) closeNewChatSheet(false) } - Spacer(Modifier.width(DEFAULT_PADDING)) } - Spacer(Modifier.height(DEFAULT_PADDING)) + } + } + val maxWidth = with(LocalDensity.current) { LocalConfiguration.current.screenWidthDp * density } + Column( + Modifier + .fillMaxSize() + .offset { IntOffset(if (newChat.isGone()) -maxWidth.roundToInt() else 0, 0) } + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { closeNewChatSheet(true) } + .drawBehind { drawRect(animatedColor.value) }, + verticalArrangement = Arrangement.Bottom, + horizontalAlignment = Alignment.End + ) { + val actions = remember { listOf(addContact, connectViaLink, createGroup) } + val backgroundColor = if (isInDarkTheme()) + Color(ColorUtils.blendARGB(MaterialTheme.colors.primary.toArgb(), Color.Black.toArgb(), 0.7F)) + else + MaterialTheme.colors.background + LazyColumn(Modifier + .graphicsLayer { + alpha = animatedFloat.value + translationY = (1 - animatedFloat.value) * 20.dp.toPx() + }) { + items(actions.size) { index -> + Row { + Spacer(Modifier.weight(1f)) + Box(contentAlignment = Alignment.CenterEnd) { + Button( + actions[index], + shape = RoundedCornerShape(21.dp), + colors = ButtonDefaults.textButtonColors(backgroundColor = backgroundColor), + elevation = null, + contentPadding = PaddingValues(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF), + modifier = Modifier.height(42.dp) + ) { + Text( + stringResource(titles[index]), + Modifier.padding(start = DEFAULT_PADDING_HALF), + color = if (isInDarkTheme()) MaterialTheme.colors.primary else MaterialTheme.colors.primary, + fontWeight = FontWeight.Medium, + ) + Icon( + icons[index], + stringResource(titles[index]), + Modifier.size(42.dp), + tint = if (isInDarkTheme()) MaterialTheme.colors.primary else MaterialTheme.colors.primary + ) + } + } + Spacer(Modifier.width(DEFAULT_PADDING)) + } + Spacer(Modifier.height(DEFAULT_PADDING)) + } + } + FloatingActionButton( + onClick = { if (!stopped) closeNewChatSheet(true) }, + Modifier.padding(end = 16.dp, bottom = 16.dp), + elevation = FloatingActionButtonDefaults.elevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp, + hoveredElevation = 0.dp, + focusedElevation = 0.dp, + ), + backgroundColor = if (!stopped) MaterialTheme.colors.primary else HighOrLowlight, + contentColor = Color.White + ) { + Icon( + Icons.Default.Edit, stringResource(R.string.add_contact_or_create_group), + Modifier.graphicsLayer { alpha = 1 - animatedFloat.value } + ) + Icon( + Icons.Default.Close, stringResource(R.string.add_contact_or_create_group), + Modifier.graphicsLayer { alpha = animatedFloat.value } + ) } } } @@ -103,11 +184,13 @@ fun ActionButton( horizontalAlignment = Alignment.CenterHorizontally ) { val tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary - Icon(icon, text, + Icon( + icon, text, tint = tint, modifier = Modifier .size(40.dp) - .padding(bottom = 8.dp)) + .padding(bottom = 8.dp) + ) if (text != null) { Text( text, @@ -130,12 +213,15 @@ fun ActionButton( @Preview @Composable -fun PreviewNewChatSheet() { +private fun PreviewNewChatSheet() { SimpleXTheme { NewChatSheetLayout( + MutableStateFlow(NewChatSheetState.VISIBLE), + stopped = false, addContact = {}, connectViaLink = {}, - createGroup = {} + createGroup = {}, + closeNewChatSheet = {}, ) } }