diff --git a/app/src/main/java/tech/relaycorp/letro/messages/list/ConversationsListContent.kt b/app/src/main/java/tech/relaycorp/letro/messages/list/ConversationsListContent.kt new file mode 100644 index 00000000..26912094 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/messages/list/ConversationsListContent.kt @@ -0,0 +1,17 @@ +package tech.relaycorp.letro.messages.list + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import tech.relaycorp.letro.messages.model.ExtendedConversation + +sealed interface ConversationsListContent { + + data class Conversations( + val conversations: List, + ) : ConversationsListContent + + data class Empty( + @DrawableRes val image: Int, + @StringRes val text: Int, + ) : ConversationsListContent +} diff --git a/app/src/main/java/tech/relaycorp/letro/messages/list/ConversationsListScreen.kt b/app/src/main/java/tech/relaycorp/letro/messages/list/ConversationsListScreen.kt index f820e6d2..e686230f 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/list/ConversationsListScreen.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/list/ConversationsListScreen.kt @@ -1,5 +1,9 @@ +@file:Suppress("NAME_SHADOWING") + package tech.relaycorp.letro.messages.list +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -25,6 +29,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -32,6 +37,8 @@ import androidx.compose.ui.unit.dp import tech.relaycorp.letro.R import tech.relaycorp.letro.messages.model.ExtendedConversation import tech.relaycorp.letro.messages.model.ExtendedMessage +import tech.relaycorp.letro.ui.common.BottomSheetAction +import tech.relaycorp.letro.ui.common.LetroActionsBottomSheet import tech.relaycorp.letro.ui.theme.BodyLargeProminent import tech.relaycorp.letro.ui.theme.BodyMediumProminent import tech.relaycorp.letro.ui.theme.LabelSmallProminent @@ -46,32 +53,65 @@ fun ConversationsListScreen( ) { val conversations by viewModel.conversations.collectAsState() val isOnboardingVisible by viewModel.isOnboardingMessageVisible.collectAsState() + val sectionSelectorState by viewModel.conversationSectionState.collectAsState() - Column( - modifier = Modifier.fillMaxSize(), - ) { - if (isOnboardingVisible) { - ConversationsOnboardingView( - onCloseClick = { viewModel.onCloseOnboardingButtonClick() }, + Box(modifier = Modifier.fillMaxSize()) { + if (sectionSelectorState.sectionSelector.isOpened) { + LetroActionsBottomSheet( + actions = sectionSelectorState.sectionSelector.sections + .map { + BottomSheetAction( + icon = it.icon, + title = it.title, + action = { viewModel.onSectionChosen(it) }, + isChosen = it == sectionSelectorState.currentSection, + ) + }, + onDismissRequest = { viewModel.onConversationSectionDialogDismissed() }, ) } - Box { - if (conversations.isEmpty()) { - Column { - Spacer(modifier = Modifier.height(24.dp)) - EmptyConversationsView() - } - } else { + Column { + if (isOnboardingVisible) { + ConversationsOnboardingView( + onCloseClick = { viewModel.onCloseOnboardingButtonClick() }, + ) + } + Box { LazyColumn { - items(conversations) { conversation -> - Conversation( - conversation = conversation, - noSubjectText = conversationsStringsProvider.noSubject, - onConversationClick = { - onConversationClick(conversation) + items(1) { + ConversationsSectionSelector( + text = stringResource(id = sectionSelectorState.currentSection.title), + icon = painterResource(id = sectionSelectorState.currentSection.icon), + onClick = { + viewModel.onConversationSectionSelectorClick() }, ) } + + when (val conversations = conversations) { + is ConversationsListContent.Conversations -> { + items(conversations.conversations) { conversation -> + Conversation( + conversation = conversation, + noSubjectText = conversationsStringsProvider.noSubject, + onConversationClick = { + onConversationClick(conversation) + }, + ) + } + } + is ConversationsListContent.Empty -> { + items(1) { + Column { + Spacer(modifier = Modifier.height(24.dp)) + EmptyConversationsView( + image = conversations.image, + text = conversations.text, + ) + } + } + } + } } } } @@ -148,16 +188,59 @@ private fun Conversation( } @Composable -private fun EmptyConversationsView() { +private fun ConversationsSectionSelector( + text: String, + icon: Painter, + onClick: () -> Unit, +) { + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding( + horizontal = 16.dp, + vertical = 16.dp, + ) + .clickable { onClick() }, + ) { + Icon( + painter = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + ) + Spacer(modifier = Modifier.width(9.dp)) + Text( + text = text, + style = MaterialTheme.typography.BodyLargeProminent, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.width(2.dp)) + Icon( + painter = painterResource(id = R.drawable.arrow_down), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@Composable +private fun EmptyConversationsView( + @DrawableRes image: Int, + @StringRes text: Int, +) { Column( modifier = Modifier .fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { - Image(painter = painterResource(id = R.drawable.empty_inbox_image), contentDescription = null) + Image(painter = painterResource(id = image), contentDescription = null) Spacer(modifier = Modifier.height(24.dp)) Text( - text = stringResource(id = R.string.conversations_empty_stub), + text = stringResource(id = text), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, ) @@ -262,3 +345,10 @@ private fun Onboarding_Preview() { ConversationsOnboardingView { } } + +@Preview(showBackground = true) +@Composable +private fun SectionSelector_Preview() { + ConversationsSectionSelector(text = "inbox", icon = painterResource(id = R.drawable.inbox)) { + } +} diff --git a/app/src/main/java/tech/relaycorp/letro/messages/list/ConversationsListViewModel.kt b/app/src/main/java/tech/relaycorp/letro/messages/list/ConversationsListViewModel.kt index bf358168..015614e4 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/list/ConversationsListViewModel.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/list/ConversationsListViewModel.kt @@ -4,15 +4,21 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import tech.relaycorp.letro.R import tech.relaycorp.letro.account.model.Account import tech.relaycorp.letro.account.storage.AccountRepository -import tech.relaycorp.letro.messages.model.ExtendedConversation +import tech.relaycorp.letro.messages.list.section.ConversationSectionInfo import tech.relaycorp.letro.messages.onboarding.ConversationsOnboardingManager import tech.relaycorp.letro.messages.repository.ConversationsRepository import javax.inject.Inject +@Suppress("NAME_SHADOWING") @HiltViewModel class ConversationsListViewModel @Inject constructor( private val conversationsRepository: ConversationsRepository, @@ -20,8 +26,30 @@ class ConversationsListViewModel @Inject constructor( private val accountRepository: AccountRepository, ) : ViewModel() { - val conversations: StateFlow> - get() = conversationsRepository.conversations + private val _conversationSectionInfoState = MutableStateFlow(ConversationsSectionState()) + val conversationSectionState: StateFlow + get() = _conversationSectionInfoState + + val conversations: StateFlow + get() = combine( + conversationsRepository.conversations, + conversationSectionState, + ) { conversations, currentTab -> + val conversations = when (currentTab.currentSection) { + ConversationSectionInfo.Inbox -> { + conversations.filter { it.messages.any { !it.isOutgoing } } + } + ConversationSectionInfo.Sent -> { + conversations.filter { it.messages.any { it.isOutgoing } } + } + } + if (conversations.isNotEmpty()) { + ConversationsListContent.Conversations(conversations) + } else { + getEmptyConversationsStubInfo() + } + } + .stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = getEmptyConversationsStubInfo()) private val _isOnboardingMessageVisible = MutableStateFlow(false) val isOnboardingMessageVisible: StateFlow @@ -42,6 +70,37 @@ class ConversationsListViewModel @Inject constructor( } } + fun onSectionChosen(section: ConversationSectionInfo) { + _conversationSectionInfoState.update { + it.copy( + currentSection = section, + sectionSelector = it.sectionSelector.copy( + isOpened = false, + ), + ) + } + } + + fun onConversationSectionSelectorClick() { + _conversationSectionInfoState.update { + it.copy( + sectionSelector = it.sectionSelector.copy( + isOpened = true, + ), + ) + } + } + + fun onConversationSectionDialogDismissed() { + _conversationSectionInfoState.update { + it.copy( + sectionSelector = it.sectionSelector.copy( + isOpened = false, + ), + ) + } + } + fun onCloseOnboardingButtonClick() { viewModelScope.launch { currentAccount?.let { @@ -50,4 +109,25 @@ class ConversationsListViewModel @Inject constructor( } } } + + private fun getEmptyConversationsStubInfo() = ConversationsListContent.Empty( + image = when (_conversationSectionInfoState.value.currentSection) { + ConversationSectionInfo.Inbox -> R.drawable.empty_inbox_image + ConversationSectionInfo.Sent -> R.drawable.empty_inbox_image + }, + text = when (_conversationSectionInfoState.value.currentSection) { + ConversationSectionInfo.Inbox -> R.string.conversations_empty_inbox_stub + ConversationSectionInfo.Sent -> R.string.conversations_empty_sent_stub + }, + ) } + +data class ConversationsSectionState( + val currentSection: ConversationSectionInfo = ConversationSectionInfo.Inbox, + val sectionSelector: ConversationsSectionSelector = ConversationsSectionSelector(), +) + +data class ConversationsSectionSelector( + val isOpened: Boolean = false, + val sections: List = ConversationSectionInfo.allSections(), +) diff --git a/app/src/main/java/tech/relaycorp/letro/messages/list/section/ConversationSectionInfo.kt b/app/src/main/java/tech/relaycorp/letro/messages/list/section/ConversationSectionInfo.kt new file mode 100644 index 00000000..c384474c --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/messages/list/section/ConversationSectionInfo.kt @@ -0,0 +1,28 @@ +package tech.relaycorp.letro.messages.list.section + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import tech.relaycorp.letro.R + +sealed class ConversationSectionInfo( + @StringRes val title: Int, + @DrawableRes val icon: Int, +) { + + object Inbox : ConversationSectionInfo( + title = R.string.inbox, + icon = R.drawable.inbox, + ) + + object Sent : ConversationSectionInfo( + title = R.string.sent, + icon = R.drawable.sent, + ) + + companion object { + fun allSections() = listOf( + Inbox, + Sent, + ) + } +} diff --git a/app/src/main/java/tech/relaycorp/letro/ui/common/LetroActionsBottomSheet.kt b/app/src/main/java/tech/relaycorp/letro/ui/common/LetroActionsBottomSheet.kt index 7ae42a07..29edade3 100644 --- a/app/src/main/java/tech/relaycorp/letro/ui/common/LetroActionsBottomSheet.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/common/LetroActionsBottomSheet.kt @@ -2,6 +2,7 @@ package tech.relaycorp.letro.ui.common import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -26,20 +27,22 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import tech.relaycorp.letro.ui.theme.LetroColor import tech.relaycorp.letro.ui.theme.TitleSmallProminent +import tech.relaycorp.letro.utils.ext.applyIf import androidx.compose.ui.res.painterResource as painterResource1 data class BottomSheetAction( @DrawableRes val icon: Int, @StringRes val title: Int, val action: () -> Unit, + val isChosen: Boolean = false, ) @OptIn(ExperimentalMaterial3Api::class) @Composable fun LetroActionsBottomSheet( - title: String, actions: List, onDismissRequest: () -> Unit, + title: String? = null, ) { ModalBottomSheet( containerColor = MaterialTheme.colorScheme.surface, @@ -87,7 +90,7 @@ private fun BottomSheetContent( } LazyColumn { items(actions) { - BottomSheetActionView(it.icon, it.title, it.action) + BottomSheetActionView(it.icon, it.title, it.action, it.isChosen) } } } @@ -98,11 +101,15 @@ private fun BottomSheetActionView( @DrawableRes icon: Int, @StringRes title: Int, onClick: () -> Unit, + isChosen: Boolean, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() + .applyIf(isChosen) { + background(LetroColor.SurfaceContainer) + } .clickable { onClick() } .padding( vertical = 14.dp, diff --git a/app/src/main/res/drawable-v24/inbox.xml b/app/src/main/res/drawable-v24/inbox.xml new file mode 100644 index 00000000..05414ab1 --- /dev/null +++ b/app/src/main/res/drawable-v24/inbox.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable-v24/sent.xml b/app/src/main/res/drawable-v24/sent.xml new file mode 100644 index 00000000..22376b68 --- /dev/null +++ b/app/src/main/res/drawable-v24/sent.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/inbox.xml b/app/src/main/res/drawable/inbox.xml new file mode 100644 index 00000000..b3da61e7 --- /dev/null +++ b/app/src/main/res/drawable/inbox.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/sent.xml b/app/src/main/res/drawable/sent.xml new file mode 100644 index 00000000..e2e71080 --- /dev/null +++ b/app/src/main/res/drawable/sent.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3122d1b6..9bfbd4b0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -83,7 +83,8 @@ Reply Delete conversation - Nothing new for you. + Nothing new for you. + Nothing sent yet. Are you sure you want to permanently delete this conversation? @@ -104,6 +105,9 @@ All conversations are end-to-end encrypted, so only you and your contact can read them. Close onboarding + Inbox + Sent + james.bond@mi6.gov.uk James Bond https://letro.app/en/terms