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 a36afc3d65..0518ac819e 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 @@ -266,8 +266,9 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a if (chatModel.chatRunning.value == true) return apiSetNetworkConfig(getNetCfg()) val justStarted = apiStartChat() + val users = listUsers() chatModel.users.clear() - chatModel.users.addAll(listUsers()) + chatModel.users.addAll(users) if (justStarted) { chatModel.currentUser.value = user chatModel.userCreated.value = true @@ -293,8 +294,9 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a suspend fun changeActiveUser(toUserId: Long) { try { chatModel.currentUser.value = apiSetActiveUser(toUserId) + val users = listUsers() chatModel.users.clear() - chatModel.users.addAll(listUsers()) + chatModel.users.addAll(users) getUserChatData() } catch (e: Exception) { Log.e(TAG, "Unable to set active user: ${e.stackTraceToString()}") 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 2131c63f93..b2bb7733e3 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 @@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons @@ -13,16 +14,15 @@ 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 -import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.* import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.intl.Locale -import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.* +import chat.simplex.app.* import chat.simplex.app.R -import chat.simplex.app.connectIfOpenedViaUri import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* @@ -37,13 +37,14 @@ import kotlinx.coroutines.launch @Composable fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: Boolean) { - val newChatSheetState by rememberSaveable(stateSaver = NewChatSheetState.saver()) { mutableStateOf(MutableStateFlow(NewChatSheetState.GONE)) } + val newChatSheetState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) } + val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) } val showNewChatSheet = { - newChatSheetState.value = NewChatSheetState.VISIBLE + newChatSheetState.value = AnimatedViewState.VISIBLE } val hideNewChatSheet: (animated: Boolean) -> Unit = { animated -> - if (animated) newChatSheetState.value = NewChatSheetState.HIDING - else newChatSheetState.value = NewChatSheetState.GONE + if (animated) newChatSheetState.value = AnimatedViewState.HIDING + else newChatSheetState.value = AnimatedViewState.GONE } LaunchedEffect(Unit) { if (shouldShowWhatsNew(chatModel)) { @@ -63,8 +64,9 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: } var searchInList by rememberSaveable { mutableStateOf("") } val scaffoldState = rememberScaffoldState() + val scope = rememberCoroutineScope() Scaffold( - topBar = { ChatListToolbar(chatModel, scaffoldState.drawerState, stopped) { searchInList = it.trim() } }, + topBar = { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } }, scaffoldState = scaffoldState, drawerContent = { SettingsView(chatModel, setPerformLA) }, floatingActionButton = { @@ -111,6 +113,9 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: if (searchInList.isEmpty()) { NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) } + UserPicker(chatModel, userPickerState) { + scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } + } } @Composable @@ -156,7 +161,7 @@ private fun ConnectButton(text: String, onClick: () -> Unit) { } @Composable -private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stopped: Boolean, onSearchValueChanged: (String) -> Unit) { +private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, userPickerState: MutableStateFlow, stopped: Boolean, onSearchValueChanged: (String) -> Unit) { var showSearch by rememberSaveable { mutableStateOf(false) } val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false } if (showSearch) { @@ -189,10 +194,23 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stop val scope = rememberCoroutineScope() DefaultTopAppBar( navigationButton = { - if (showSearch) + if (showSearch) { NavigationButtonBack(hideSearchOnBack) - else + } else if (chatModel.users.isEmpty()) { NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } } + } else { + val users by remember { derivedStateOf { chatModel.users.toList() } } + val allRead = users + .filter { !it.user.activeUser } + .all { u -> u.unreadCount == 0 } + UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) { + if (users.size == 1) { + scope.launch { drawerState.open() } + } else { + userPickerState.value = AnimatedViewState.VISIBLE + } + } + } }, title = { Row(verticalAlignment = Alignment.CenterVertically) { @@ -219,6 +237,36 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stop Divider(Modifier.padding(top = AppBarHeight)) } +@Composable +private fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) { + IconButton(onClick = onButtonClicked) { + Box { + ProfileImage( + image = image, + size = 36.dp + ) + if (!allRead) { + unreadBadge() + } + } + } +} + +@Composable +private fun BoxScope.unreadBadge(text: String? = "") { + Text( + text ?: "", + color = MaterialTheme.colors.onPrimary, + fontSize = 6.sp, + modifier = Modifier + .background(MaterialTheme.colors.primary, shape = CircleShape) + .badgeLayout() + .padding(horizontal = 3.dp) + .padding(vertical = 1.dp) + .align(Alignment.TopEnd) + ) +} + private var lazyListState = 0 to 0 @Composable diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/UserPicker.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/UserPicker.kt new file mode 100644 index 0000000000..f90a47bc21 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/UserPicker.kt @@ -0,0 +1,170 @@ +package chat.simplex.app.views.chatlist + +import SectionItemViewSpaceBetween +import android.util.Log +import androidx.compose.animation.core.* +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.runtime.* +import androidx.compose.ui.* +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.capitalize +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.* +import chat.simplex.app.R +import chat.simplex.app.TAG +import chat.simplex.app.model.ChatModel +import chat.simplex.app.model.UserInfo +import chat.simplex.app.ui.theme.DEFAULT_PADDING +import chat.simplex.app.views.helpers.* +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@Composable +fun UserPicker(chatModel: ChatModel, userPickerState: MutableStateFlow, openSettings: () -> Unit) { + val scope = rememberCoroutineScope() + var newChat by remember { mutableStateOf(userPickerState.value) } + val users by remember { derivedStateOf { chatModel.users.sortedByDescending { it.user.activeUser } } } + val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) } + LaunchedEffect(Unit) { + launch { + userPickerState.collect { + newChat = it + launch { + animatedFloat.animateTo(if (newChat.isVisible()) 1f else 0f, newChatSheetAnimSpec()) + if (newChat.isHiding()) userPickerState.value = AnimatedViewState.GONE + } + } + } + } + LaunchedEffect(Unit) { + snapshotFlow { newChat.isVisible() } + .distinctUntilChanged() + .filter { it } + .collect { + try { + val updatedUsers = chatModel.controller.listUsers().sortedByDescending { it.user.activeUser } + var same = users.size == updatedUsers.size + if (same) { + for (i in 0 until minOf(users.size, updatedUsers.size)) { + val prev = updatedUsers[i].user + val next = users[i].user + if (prev.userId != next.userId || prev.activeUser != next.activeUser || prev.chatViewName != next.chatViewName || prev.image != next.image) { + same = false + break + } + } + } + if (!same) { + chatModel.users.clear() + chatModel.users.addAll(updatedUsers) + } + } catch (e: Exception) { + Log.e(TAG, "Error updating users ${e.stackTraceToString()}") + } + } + } + val xOffset = with(LocalDensity.current) { 10.dp.roundToPx() } + val maxWidth = with(LocalDensity.current) { LocalConfiguration.current.screenWidthDp * density } + Box(Modifier + .fillMaxSize() + .offset { IntOffset(if (newChat.isGone()) -maxWidth.roundToInt() else xOffset, 0) } + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { userPickerState.value = AnimatedViewState.HIDING }) + .padding(bottom = 10.dp, top = 10.dp) + .graphicsLayer { + alpha = animatedFloat.value + translationY = (animatedFloat.value - 1) * xOffset + } + ) { + Column( + Modifier + .widthIn(min = 220.dp) + .width(IntrinsicSize.Min) + .height(IntrinsicSize.Min) + .shadow(8.dp, MaterialTheme.shapes.medium, clip = false) + .background(MaterialTheme.colors.background, MaterialTheme.shapes.medium) + ) { + Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { + users.forEachIndexed { i, u -> + UserProfilePickerItem(u) { + userPickerState.value = AnimatedViewState.HIDING + scope.launch { + if (!u.user.activeUser) { + chatModel.controller.changeActiveUser(u.user.userId) + } + } + } + if (i != users.lastIndex) { + Divider(Modifier.requiredHeight(1.dp)) + } + } + } + Divider() + SettingsPickerItem { + openSettings() + userPickerState.value = AnimatedViewState.GONE + } + } + } +} + +@Composable +private fun UserProfilePickerItem(u: UserInfo, onClick: () -> Unit) { + SectionItemViewSpaceBetween(onClick, padding = PaddingValues(start = 8.dp, end = 8.dp)) { + Row( + Modifier + .widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.7f) + .padding(top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ProfileImage( + image = u.user.image, + size = 60.dp + ) + Text( + u.user.chatViewName, + modifier = Modifier + .padding(start = 8.dp, end = 8.dp) + ) + } + if (u.user.activeUser) { + Icon(Icons.Filled.Done, null, Modifier.size(20.dp), tint = MaterialTheme.colors.primary) + } else if (u.unreadCount > 0) { + Text( + unreadCountStr(u.unreadCount), + color = MaterialTheme.colors.onPrimary, + fontSize = 11.sp, + modifier = Modifier + .background(MaterialTheme.colors.primary, shape = CircleShape) + .sizeIn(minWidth = 20.dp, minHeight = 20.dp) + .padding(horizontal = 3.dp) + .padding(vertical = 1.dp), + textAlign = TextAlign.Center, + maxLines = 1 + ) + } + } +} + +@Composable +private fun SettingsPickerItem(onClick: () -> Unit) { + SectionItemViewSpaceBetween(onClick, minHeight = 60.dp) { + val text = generalGetString(R.string.settings_section_title_settings).lowercase().capitalize(Locale.current) + Text( + text, + color = MaterialTheme.colors.onBackground, + ) + Icon(Icons.Outlined.Settings, text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + } +} \ No newline at end of file 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 a7ed87d761..d6729ac68d 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,6 +1,5 @@ package chat.simplex.app.views.helpers -import android.graphics.Bitmap import android.net.Uri import androidx.compose.runtime.saveable.Saver import kotlinx.coroutines.flow.MutableStateFlow @@ -11,7 +10,7 @@ sealed class SharedContent { data class File(val text: String, val uri: Uri): SharedContent() } -enum class NewChatSheetState { +enum class AnimatedViewState { VISIBLE, HIDING, GONE; fun isVisible(): Boolean { return this == VISIBLE @@ -23,7 +22,7 @@ enum class NewChatSheetState { return this == GONE } companion object { - fun saver(): Saver, *> = Saver( + 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 a1a262a933..02ebdf6b89 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 @@ -37,7 +37,7 @@ import kotlinx.coroutines.launch import kotlin.math.roundToInt @Composable -fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) { +fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) { if (newChatSheetState.collectAsState().value.isVisible()) BackHandler { closeNewChatSheet(true) } NewChatSheetLayout( newChatSheetState, @@ -63,7 +63,7 @@ private val icons = listOf(Icons.Outlined.AddLink, Icons.Outlined.QrCode, Icons. @Composable private fun NewChatSheetLayout( - newChatSheetState: StateFlow, + newChatSheetState: StateFlow, stopped: Boolean, addContact: () -> Unit, connectViaLink: () -> Unit, @@ -216,7 +216,7 @@ fun ActionButton( private fun PreviewNewChatSheet() { SimpleXTheme { NewChatSheetLayout( - MutableStateFlow(NewChatSheetState.VISIBLE), + MutableStateFlow(AnimatedViewState.VISIBLE), stopped = false, addContact = {}, connectViaLink = {},