From b21f8159a72f66d3b4f073253d8dfe756e0f80b9 Mon Sep 17 00:00:00 2001 From: migulyaev Date: Tue, 19 Sep 2023 22:03:04 +0200 Subject: [PATCH 01/10] view conversation screen base --- .../tech/relaycorp/letro/home/HomeScreen.kt | 3 + .../ExtendedConversationConverter.kt | 43 ++-- .../messages/list/ConversationsListScreen.kt | 19 +- .../messages/model/ExtendedConversation.kt | 5 +- .../letro/messages/model/ExtendedMessage.kt | 3 + .../repository/ConversationsRepository.kt | 8 + .../messages/viewing/ConversationScreen.kt | 186 ++++++++++++++++++ .../messages/viewing/ConversationViewModel.kt | 23 +++ .../letro/ui/navigation/LetroNavHost.kt | 23 +++ .../relaycorp/letro/ui/navigation/Route.kt | 15 +- app/src/main/res/drawable/ic_reply.xml | 13 ++ app/src/main/res/drawable/ic_trash.xml | 13 ++ app/src/main/res/values/strings.xml | 2 + 13 files changed, 335 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationScreen.kt create mode 100644 app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationViewModel.kt create mode 100644 app/src/main/res/drawable/ic_reply.xml create mode 100644 app/src/main/res/drawable/ic_trash.xml diff --git a/app/src/main/java/tech/relaycorp/letro/home/HomeScreen.kt b/app/src/main/java/tech/relaycorp/letro/home/HomeScreen.kt index ab01dd90..15528ac0 100644 --- a/app/src/main/java/tech/relaycorp/letro/home/HomeScreen.kt +++ b/app/src/main/java/tech/relaycorp/letro/home/HomeScreen.kt @@ -14,12 +14,14 @@ import tech.relaycorp.letro.contacts.model.Contact import tech.relaycorp.letro.contacts.ui.ContactsScreen import tech.relaycorp.letro.messages.list.ConversationsListScreen import tech.relaycorp.letro.messages.list.ConversationsViewModel +import tech.relaycorp.letro.messages.model.ExtendedConversation import tech.relaycorp.letro.ui.utils.SnackbarStringsProvider @Composable fun HomeScreen( homeViewModel: HomeViewModel, snackbarStringsProvider: SnackbarStringsProvider, + onConversationClick: (ExtendedConversation) -> Unit, onEditContactClick: (Contact) -> Unit, snackbarHostState: SnackbarHostState, conversationsViewModel: ConversationsViewModel = hiltViewModel(), @@ -38,6 +40,7 @@ fun HomeScreen( when (uiState.currentTab) { TAB_CHATS -> Column { ConversationsListScreen( + onConversationClick = onConversationClick, viewModel = conversationsViewModel, ) } diff --git a/app/src/main/java/tech/relaycorp/letro/messages/converter/ExtendedConversationConverter.kt b/app/src/main/java/tech/relaycorp/letro/messages/converter/ExtendedConversationConverter.kt index 137b802f..03ac37a6 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/converter/ExtendedConversationConverter.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/converter/ExtendedConversationConverter.kt @@ -11,7 +11,12 @@ import java.util.UUID import javax.inject.Inject interface ExtendedConversationConverter { - fun convert(conversations: List, messages: List, contacts: List): List + fun convert( + conversations: List, + messages: List, + contacts: List, + ownerVeraId: String, + ): List } class ExtendedConversationConverterImpl @Inject constructor( @@ -22,11 +27,12 @@ class ExtendedConversationConverterImpl @Inject constructor( conversations: List, messages: List, contacts: List, + ownerVeraId: String, ): List { val conversationsMap = hashMapOf() conversations.forEach { conversationsMap[it.conversationId] = it } - val sortedMessages = messages.sortedByDescending { it.sentAt } + val sortedMessages = messages.sortedBy { it.sentAt } val messagesToConversation = hashMapOf>() sortedMessages.forEach { message -> @@ -42,25 +48,32 @@ class ExtendedConversationConverterImpl @Inject constructor( if (messagesToConversation[conversation.conversationId].isNullOrEmpty()) { return@mapNotNull null } - val lastMessage = messagesToConversation[conversation.conversationId]!!.first() + val contactDisplayName = contacts.find { it.contactVeraId == conversation.contactVeraId }?.alias ?: conversation.contactVeraId + val lastMessage = messagesToConversation[conversation.conversationId]!!.last() + val extendedMessagesList = sortedMessages + .filter { it.conversationId == conversation.conversationId } + .map { + val isOutgoing = ownerVeraId == it.senderVeraId + ExtendedMessage( + conversationId = conversation.conversationId, + senderVeraId = it.senderVeraId, + recipientVeraId = it.recipientVeraId, + isOutgoing = isOutgoing, + contactDisplayName = contactDisplayName, + text = it.text, + sentAtFormatted = messageTimestampConverter.convert(it.sentAt), + ) + } ExtendedConversation( conversationId = conversation.conversationId, ownerVeraId = conversation.ownerVeraId, - recipientVeraId = conversation.contactVeraId, - recipientAlias = contacts.find { it.contactVeraId == conversation.contactVeraId }?.alias, + contactVeraId = conversation.contactVeraId, + contactDisplayName = contactDisplayName, subject = conversation.subject, lastMessageTimestamp = Timestamp.from(lastMessage.sentAt.toInstant(ZoneOffset.UTC)).time, lastMessageFormattedTimestamp = messageTimestampConverter.convert(lastMessage.sentAt), - messages = sortedMessages - .filter { it.conversationId == conversation.conversationId } - .map { - ExtendedMessage( - conversationId = conversation.conversationId, - senderVeraId = it.senderVeraId, - recipientVeraId = it.recipientVeraId, - text = it.text, - ) - }, + messages = extendedMessagesList, + lastMessage = extendedMessagesList.last(), ) } .forEach { extendedConversations.add(it) } 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 a534d436..92195eaf 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,6 +1,7 @@ package tech.relaycorp.letro.messages.list import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -29,6 +30,7 @@ import tech.relaycorp.letro.ui.theme.SmallProminent @Composable fun ConversationsListScreen( + onConversationClick: (ExtendedConversation) -> Unit, viewModel: ConversationsViewModel, ) { val conversations by viewModel.conversations.collectAsState(emptyList()) @@ -41,8 +43,13 @@ fun ConversationsListScreen( } } else { LazyColumn { - items(conversations) { - Conversation(conversation = it) + items(conversations) { conversation -> + Conversation( + conversation = conversation, + onConversationClick = { + onConversationClick(conversation) + }, + ) } } } @@ -52,10 +59,12 @@ fun ConversationsListScreen( @Composable private fun Conversation( conversation: ExtendedConversation, + onConversationClick: () -> Unit, ) { Box( modifier = Modifier .fillMaxWidth() + .clickable { onConversationClick() } .padding( horizontal = 16.dp, vertical = 10.dp, @@ -66,15 +75,17 @@ private fun Conversation( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = conversation.recipientAlias ?: conversation.recipientVeraId, + text = conversation.contactDisplayName, style = MaterialTheme.typography.LargeProminent, color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, ) Spacer(modifier = Modifier.weight(1f)) Text( text = conversation.lastMessageFormattedTimestamp, style = MaterialTheme.typography.SmallProminent, color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, ) } Row { @@ -83,6 +94,7 @@ private fun Conversation( text = conversation.subject, style = MaterialTheme.typography.MediumProminent, color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, ) Text( text = " - ", @@ -94,6 +106,7 @@ private fun Conversation( text = conversation.messages.last().text, style = MaterialTheme.typography.MediumProminent, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, ) } } diff --git a/app/src/main/java/tech/relaycorp/letro/messages/model/ExtendedConversation.kt b/app/src/main/java/tech/relaycorp/letro/messages/model/ExtendedConversation.kt index 885df026..9ff1a0a4 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/model/ExtendedConversation.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/model/ExtendedConversation.kt @@ -5,10 +5,11 @@ import java.util.UUID data class ExtendedConversation( val conversationId: UUID, val ownerVeraId: String, - val recipientVeraId: String, - val recipientAlias: String?, + val contactVeraId: String, + val contactDisplayName: String, val subject: String?, val lastMessageTimestamp: Long, val lastMessageFormattedTimestamp: String, + val lastMessage: ExtendedMessage, val messages: List, ) diff --git a/app/src/main/java/tech/relaycorp/letro/messages/model/ExtendedMessage.kt b/app/src/main/java/tech/relaycorp/letro/messages/model/ExtendedMessage.kt index 5a9399bf..5e9571ec 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/model/ExtendedMessage.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/model/ExtendedMessage.kt @@ -6,5 +6,8 @@ data class ExtendedMessage( val conversationId: UUID, val senderVeraId: String, val recipientVeraId: String, + val isOutgoing: Boolean, + val contactDisplayName: String, val text: String, + val sentAtFormatted: String, ) diff --git a/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt b/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt index 6de426f9..1fec485e 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt @@ -24,6 +24,7 @@ import tech.relaycorp.letro.messages.storage.MessagesDao import tech.relaycorp.letro.messages.storage.entity.Conversation import tech.relaycorp.letro.messages.storage.entity.Message import java.time.LocalDateTime +import java.util.UUID import javax.inject.Inject interface ConversationsRepository { @@ -34,6 +35,7 @@ interface ConversationsRepository { messageText: String, subject: String? = null, ) + fun getConversation(id: String): ExtendedConversation? } class ConversationsRepositoryImpl @Inject constructor( @@ -75,6 +77,11 @@ class ConversationsRepositoryImpl @Inject constructor( } } + override fun getConversation(id: String): ExtendedConversation? { + val uuid = UUID.fromString(id) + return _conversations.value.find { it.conversationId == uuid } + } + override fun createNewConversation( ownerVeraId: String, recipient: Contact, @@ -133,6 +140,7 @@ class ConversationsRepositoryImpl @Inject constructor( conversations = conversations.filter { it.ownerVeraId == account.veraId }, messages = messages.filter { it.ownerVeraId == account.veraId }, contacts = contacts.filter { it.ownerVeraId == account.veraId }, + ownerVeraId = account.veraId, ), ) }.collect() diff --git a/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationScreen.kt b/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationScreen.kt new file mode 100644 index 00000000..dc6f56d2 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationScreen.kt @@ -0,0 +1,186 @@ +package tech.relaycorp.letro.messages.viewing + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import tech.relaycorp.letro.R +import tech.relaycorp.letro.messages.model.ExtendedMessage +import tech.relaycorp.letro.ui.common.LetroButton +import tech.relaycorp.letro.utils.ext.applyIf + +@Composable +fun ConversationScreen( + onBackClicked: () -> Unit, + viewModel: ConversationViewModel = hiltViewModel(), +) { + val conversationState by viewModel.conversation.collectAsState() + val conversation = conversationState + + if (conversation != null) { + Column( + modifier = Modifier + .fillMaxSize(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + vertical = 14.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = onBackClicked, + ) { + Icon( + painter = painterResource(id = R.drawable.arrow_back), + contentDescription = stringResource(id = R.string.general_navigate_back), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + Spacer( + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = { }, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_trash), + contentDescription = stringResource(id = R.string.content_description_delete_conversation), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + LetroButton( + text = stringResource(id = R.string.reply), + onClick = { /* TODO */ }, + leadingIconResId = R.drawable.ic_reply, + contentPadding = PaddingValues( + top = 8.dp, + bottom = 8.dp, + start = 16.dp, + end = 24.dp, + ), + ) + Spacer( + modifier = Modifier.width(16.dp), + ) + } + if (conversation.subject != null) { + Text( + text = conversation.subject, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding( + horizontal = 16.dp, + vertical = 10.dp, + ), + ) + } + LazyColumn { + items(conversation.messages.size) { position -> + val message = conversation.messages[position] + val isLastMessage = position == conversation.messages.size - 1 + Message( + message = message, + isCollapsable = conversation.messages.size > 1, + isLastMessage = isLastMessage, + ) + if (!isLastMessage) { + Divider( + modifier = Modifier + .background(MaterialTheme.colorScheme.outlineVariant) + .fillMaxWidth() + .height(1.dp), + ) + } + } + } + } + } +} + +@Composable +private fun Message( + message: ExtendedMessage, + isCollapsable: Boolean, + isLastMessage: Boolean, +) { + var isCollapsed: Boolean by remember { mutableStateOf(!isLastMessage) } + + Column( + modifier = Modifier + .fillMaxWidth() + .applyIf(isCollapsable && isCollapsed) { + clickable { isCollapsed = !isCollapsed } + } + .padding( + vertical = 10.dp, + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .applyIf(isCollapsable && !isCollapsed) { + clickable { isCollapsed = !isCollapsed } + } + .padding( + horizontal = 16.dp, + ), + ) { + Text( + text = if (message.isOutgoing) message.senderVeraId else message.contactDisplayName, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.weight(1F)) + Text( + text = message.sentAtFormatted, + style = if (isCollapsed) MaterialTheme.typography.labelSmall else MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + if (!isCollapsed) { + Spacer(modifier = Modifier.height(26.dp)) + } + Text( + text = message.text, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, + maxLines = if (isCollapsed) 1 else Int.MAX_VALUE, + modifier = Modifier + .padding( + vertical = if (isCollapsed) 0.dp else 10.dp, + horizontal = 16.dp, + ), + ) + } +} diff --git a/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationViewModel.kt b/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationViewModel.kt new file mode 100644 index 00000000..f35bfee5 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationViewModel.kt @@ -0,0 +1,23 @@ +package tech.relaycorp.letro.messages.viewing + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import tech.relaycorp.letro.messages.model.ExtendedConversation +import tech.relaycorp.letro.messages.repository.ConversationsRepository +import tech.relaycorp.letro.ui.navigation.Route +import javax.inject.Inject + +@HiltViewModel +class ConversationViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, + private val conversationsRepository: ConversationsRepository, +) : ViewModel() { + + private val conversationId: String = savedStateHandle[Route.Conversation.KEY_CONVERSATION_ID]!! + private val _conversation = MutableStateFlow(conversationsRepository.getConversation(conversationId)) + val conversation: StateFlow + get() = _conversation +} diff --git a/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt b/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt index 201fde3d..efae5f7b 100644 --- a/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt @@ -42,6 +42,7 @@ import tech.relaycorp.letro.home.HomeScreen import tech.relaycorp.letro.home.HomeViewModel import tech.relaycorp.letro.main.MainViewModel import tech.relaycorp.letro.messages.compose.CreateNewMessageScreen +import tech.relaycorp.letro.messages.viewing.ConversationScreen import tech.relaycorp.letro.onboarding.actionTaking.ActionTakingScreen import tech.relaycorp.letro.onboarding.actionTaking.ActionTakingScreenUIStateModel import tech.relaycorp.letro.onboarding.registration.ui.RegistrationScreen @@ -223,6 +224,13 @@ fun LetroNavHost( homeViewModel = homeViewModel, snackbarHostState = snackbarHostState, snackbarStringsProvider = snackbarStringsProvider, + onConversationClick = { + navController.navigate( + Route.Conversation.getRouteName( + conversationId = it.conversationId.toString(), + ), + ) + }, onEditContactClick = { contact -> navController.navigate( Route.ManageContact.getRouteName( @@ -245,6 +253,21 @@ fun LetroNavHost( }, ) } + composable( + route = "${Route.Conversation.name}/{${Route.Conversation.KEY_CONVERSATION_ID}}", + arguments = listOf( + navArgument(Route.Conversation.KEY_CONVERSATION_ID) { + type = NavType.StringType + nullable = false + }, + ), + ) { + ConversationScreen( + onBackClicked = { + navController.popBackStack() + }, + ) + } } } if (homeUiState.isAddContactFloatingMenuVisible && currentRoute == Route.Home) { diff --git a/app/src/main/java/tech/relaycorp/letro/ui/navigation/Route.kt b/app/src/main/java/tech/relaycorp/letro/ui/navigation/Route.kt index 24c483f7..678e3700 100644 --- a/app/src/main/java/tech/relaycorp/letro/ui/navigation/Route.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/navigation/Route.kt @@ -70,10 +70,22 @@ sealed class Route( ) object CreateNewMessage : Route( - name = "create_new_message", + name = "create_new_message_route", showTopBar = false, isStatusBarPrimaryColor = false, ) + + object Conversation : Route( + name = "conversation_route", + showTopBar = false, + isStatusBarPrimaryColor = false, + ) { + + const val KEY_CONVERSATION_ID = "conversation_id" + + fun getRouteName(conversationId: String) = + "${Conversation.name}/$conversationId" + } } fun String?.toRoute(): Route { @@ -88,6 +100,7 @@ fun String?.toRoute(): Route { it.startsWith(Route.ManageContact.name) -> Route.ManageContact it.startsWith(Route.Home.name) -> Route.Home it.startsWith(Route.CreateNewMessage.name) -> Route.CreateNewMessage + it.startsWith(Route.Conversation.name) -> Route.Conversation else -> throw IllegalArgumentException("Define the Route by the name of the Route $it") } } diff --git a/app/src/main/res/drawable/ic_reply.xml b/app/src/main/res/drawable/ic_reply.xml new file mode 100644 index 00000000..db172abe --- /dev/null +++ b/app/src/main/res/drawable/ic_reply.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_trash.xml b/app/src/main/res/drawable/ic_trash.xml new file mode 100644 index 00000000..0ff174b7 --- /dev/null +++ b/app/src/main/res/drawable/ic_trash.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 78187149..4c7777b6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -78,6 +78,8 @@ Type your message… You’re not connected to this contact. Clear recipient + Reply + Delete conversation Nothing new for you. From c760b0c9c131fef118c50972be0b3f944133bb01 Mon Sep 17 00:00:00 2001 From: migulyaev Date: Tue, 19 Sep 2023 23:24:00 +0200 Subject: [PATCH 02/10] fix bug with text in the chip updating --- .../letro/messages/compose/ComposeNewMessageScreen.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageScreen.kt b/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageScreen.kt index b17a9a71..16c14bf0 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageScreen.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageScreen.kt @@ -179,6 +179,9 @@ fun CreateNewMessageScreen( value = recipientTextFieldValueState, textStyle = MaterialTheme.typography.bodyLarge, onValueChange = { + if (uiState.showRecipientAsChip) { + return@LetroTextField + } recipientTextFieldValueState = it viewModel.onRecipientTextChanged(it.text) }, From 2a45f9d3fca8604a464f6b597c4cf45d6269d742 Mon Sep 17 00:00:00 2001 From: migulyaev Date: Wed, 20 Sep 2023 00:15:01 +0200 Subject: [PATCH 03/10] read state of conversations --- .../1.json | 18 ++++++++++--- .../ExtendedConversationConverter.kt | 1 + .../messages/list/ConversationsListScreen.kt | 14 +++++----- .../messages/model/ExtendedConversation.kt | 1 + .../parser/NewConversationMessageParser.kt | 1 + .../messages/processor/NewMessageProcessor.kt | 10 +++++++ .../repository/ConversationsRepository.kt | 27 +++++++++++++++---- .../messages/storage/ConversationsDao.kt | 8 ++++++ .../messages/storage/entity/Conversation.kt | 4 +++ .../messages/viewing/ConversationViewModel.kt | 4 +++ 10 files changed, 73 insertions(+), 15 deletions(-) diff --git a/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json b/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json index 94477dd7..c59af219 100644 --- a/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json +++ b/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "12466b7fa30a962804c156ee00283227", + "identityHash": "94e396e1f0c1805cb42e862839856f3f", "entities": [ { "tableName": "account", @@ -110,7 +110,7 @@ }, { "tableName": "conversations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`keyId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `conversationId` BLOB NOT NULL, `ownerVeraId` TEXT NOT NULL, `contactVeraId` TEXT NOT NULL, `subject` TEXT)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`keyId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `conversationId` BLOB NOT NULL, `ownerVeraId` TEXT NOT NULL, `contactVeraId` TEXT NOT NULL, `isRead` INTEGER NOT NULL, `subject` TEXT, `isArchived` INTEGER NOT NULL)", "fields": [ { "fieldPath": "keyId", @@ -136,11 +136,23 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "isRead", + "columnName": "isRead", + "affinity": "INTEGER", + "notNull": true + }, { "fieldPath": "subject", "columnName": "subject", "affinity": "TEXT", "notNull": false + }, + { + "fieldPath": "isArchived", + "columnName": "isArchived", + "affinity": "INTEGER", + "notNull": true } ], "primaryKey": { @@ -212,7 +224,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '12466b7fa30a962804c156ee00283227')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '94e396e1f0c1805cb42e862839856f3f')" ] } } \ No newline at end of file diff --git a/app/src/main/java/tech/relaycorp/letro/messages/converter/ExtendedConversationConverter.kt b/app/src/main/java/tech/relaycorp/letro/messages/converter/ExtendedConversationConverter.kt index 03ac37a6..49a2a96e 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/converter/ExtendedConversationConverter.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/converter/ExtendedConversationConverter.kt @@ -74,6 +74,7 @@ class ExtendedConversationConverterImpl @Inject constructor( lastMessageFormattedTimestamp = messageTimestampConverter.convert(lastMessage.sentAt), messages = extendedMessagesList, lastMessage = extendedMessagesList.last(), + isRead = conversation.isRead, ) } .forEach { extendedConversations.add(it) } 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 92195eaf..46aac438 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 @@ -76,14 +76,14 @@ private fun Conversation( ) { Text( text = conversation.contactDisplayName, - style = MaterialTheme.typography.LargeProminent, + style = if (!conversation.isRead) MaterialTheme.typography.LargeProminent else MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, ) Spacer(modifier = Modifier.weight(1f)) Text( text = conversation.lastMessageFormattedTimestamp, - style = MaterialTheme.typography.SmallProminent, + style = if (!conversation.isRead) MaterialTheme.typography.SmallProminent else MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, ) @@ -92,20 +92,20 @@ private fun Conversation( if (conversation.subject != null) { Text( text = conversation.subject, - style = MaterialTheme.typography.MediumProminent, + style = if (!conversation.isRead) MaterialTheme.typography.MediumProminent else MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, ) Text( text = " - ", - style = MaterialTheme.typography.MediumProminent, - color = MaterialTheme.colorScheme.onSurface, + style = if (!conversation.isRead) MaterialTheme.typography.MediumProminent else MaterialTheme.typography.bodyMedium, + color = if (!conversation.isRead) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, ) } Text( text = conversation.messages.last().text, - style = MaterialTheme.typography.MediumProminent, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = if (!conversation.isRead) MaterialTheme.typography.MediumProminent else MaterialTheme.typography.bodyMedium, + color = if (!conversation.isRead) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, ) } diff --git a/app/src/main/java/tech/relaycorp/letro/messages/model/ExtendedConversation.kt b/app/src/main/java/tech/relaycorp/letro/messages/model/ExtendedConversation.kt index 9ff1a0a4..642d5df7 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/model/ExtendedConversation.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/model/ExtendedConversation.kt @@ -11,5 +11,6 @@ data class ExtendedConversation( val lastMessageTimestamp: Long, val lastMessageFormattedTimestamp: String, val lastMessage: ExtendedMessage, + val isRead: Boolean, val messages: List, ) diff --git a/app/src/main/java/tech/relaycorp/letro/messages/parser/NewConversationMessageParser.kt b/app/src/main/java/tech/relaycorp/letro/messages/parser/NewConversationMessageParser.kt index bd5247bc..b29619c1 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/parser/NewConversationMessageParser.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/parser/NewConversationMessageParser.kt @@ -27,6 +27,7 @@ class NewConversationMessageParserImpl @Inject constructor() : NewConversationMe contactVeraId = "ff@applepie.rocks", subject = "Test subject", conversationId = MOCK_CONVERSATION_ID, + isRead = false, ) } diff --git a/app/src/main/java/tech/relaycorp/letro/messages/processor/NewMessageProcessor.kt b/app/src/main/java/tech/relaycorp/letro/messages/processor/NewMessageProcessor.kt index 222263fd..95d38b8b 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/processor/NewMessageProcessor.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/processor/NewMessageProcessor.kt @@ -5,6 +5,7 @@ import tech.relaycorp.letro.awala.AwalaManager import tech.relaycorp.letro.awala.processor.AwalaMessageProcessor import tech.relaycorp.letro.messages.dto.NewMessageIncomingMessage import tech.relaycorp.letro.messages.parser.NewMessageMessageParser +import tech.relaycorp.letro.messages.storage.ConversationsDao import tech.relaycorp.letro.messages.storage.MessagesDao import javax.inject.Inject @@ -12,10 +13,19 @@ interface NewMessageProcessor : AwalaMessageProcessor class NewMessageProcessorImpl @Inject constructor( private val parser: NewMessageMessageParser, + private val conversationsDao: ConversationsDao, private val messagesDao: MessagesDao, ) : NewMessageProcessor { + override suspend fun process(message: IncomingMessage, awalaManager: AwalaManager) { val parsedMessage = (parser.parse(message.content) as NewMessageIncomingMessage).content + conversationsDao.getConversationById(parsedMessage.conversationId)?.let { conversation -> + conversationsDao.update( + conversation.copy( + isRead = false, + ), + ) + } messagesDao.insert(parsedMessage) } } diff --git a/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt b/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt index 1fec485e..99179b9c 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt @@ -36,6 +36,7 @@ interface ConversationsRepository { subject: String? = null, ) fun getConversation(id: String): ExtendedConversation? + fun markConversationAsRead(conversationId: String) } class ConversationsRepositoryImpl @Inject constructor( @@ -50,9 +51,10 @@ class ConversationsRepositoryImpl @Inject constructor( private val scope = CoroutineScope(Dispatchers.IO) - private val _conversations = MutableStateFlow>(emptyList()) + private val _conversations = MutableStateFlow>(emptyList()) + private val _extendedConversations = MutableStateFlow>(emptyList()) override val conversations: Flow> - get() = _conversations + get() = _extendedConversations private val contacts: MutableStateFlow> = MutableStateFlow(emptyList()) @@ -70,7 +72,7 @@ class ConversationsRepositoryImpl @Inject constructor( conversationsCollectionJob = null contactsCollectionJob?.cancel() contactsCollectionJob = null - _conversations.emit(emptyList()) + _extendedConversations.emit(emptyList()) contacts.emit(emptyList()) } } @@ -79,7 +81,7 @@ class ConversationsRepositoryImpl @Inject constructor( override fun getConversation(id: String): ExtendedConversation? { val uuid = UUID.fromString(id) - return _conversations.value.find { it.conversationId == uuid } + return _extendedConversations.value.find { it.conversationId == uuid } } override fun createNewConversation( @@ -94,6 +96,7 @@ class ConversationsRepositoryImpl @Inject constructor( ownerVeraId = ownerVeraId, contactVeraId = recipient.contactVeraId, subject = if (subject.isNullOrEmpty()) null else subject, + isRead = true, ) val message = Message( text = messageText, @@ -118,6 +121,19 @@ class ConversationsRepositoryImpl @Inject constructor( } } + @Suppress("NAME_SHADOWING") + override fun markConversationAsRead(conversationId: String) { + scope.launch { + val conversationId = UUID.fromString(conversationId) + val conversation = _conversations.value.find { it.conversationId == conversationId } ?: return@launch + conversationsDao.update( + conversation.copy( + isRead = true, + ), + ) + } + } + private fun startCollectContacts(account: Account) { contactsCollectionJob = scope.launch { contactsRepository.getContacts(account.veraId).collect { @@ -135,7 +151,8 @@ class ConversationsRepositoryImpl @Inject constructor( messagesDao.getAll(), contacts, ) { conversations, messages, contacts -> - _conversations.emit( + _conversations.emit(conversations) + _extendedConversations.emit( conversationsConverter.convert( conversations = conversations.filter { it.ownerVeraId == account.veraId }, messages = messages.filter { it.ownerVeraId == account.veraId }, diff --git a/app/src/main/java/tech/relaycorp/letro/messages/storage/ConversationsDao.kt b/app/src/main/java/tech/relaycorp/letro/messages/storage/ConversationsDao.kt index 132764b3..a020c1ea 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/storage/ConversationsDao.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/storage/ConversationsDao.kt @@ -4,9 +4,11 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Update import kotlinx.coroutines.flow.Flow import tech.relaycorp.letro.messages.storage.entity.Conversation import tech.relaycorp.letro.messages.storage.entity.TABLE_NAME_CONVERSATIONS +import java.util.UUID @Dao interface ConversationsDao { @@ -15,4 +17,10 @@ interface ConversationsDao { @Query("SELECT * FROM $TABLE_NAME_CONVERSATIONS") fun getAll(): Flow> + + @Query("SELECT * FROM $TABLE_NAME_CONVERSATIONS WHERE conversationId = :conversationId") + suspend fun getConversationById(conversationId: UUID): Conversation? + + @Update + suspend fun update(conversation: Conversation) } diff --git a/app/src/main/java/tech/relaycorp/letro/messages/storage/entity/Conversation.kt b/app/src/main/java/tech/relaycorp/letro/messages/storage/entity/Conversation.kt index bda2033f..50dbf660 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/storage/entity/Conversation.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/storage/entity/Conversation.kt @@ -14,7 +14,9 @@ const val TABLE_NAME_CONVERSATIONS = "conversations" * @param conversationId - Unique ID of a conversation * @param ownerVeraId - ID of the account, which this conversation belongs (for display this conversation only for a particular account) * @param contactVeraId - ID of the contact + * @param isRead - whether this conversation was read or not * @param subject - the subject of the conversation + * @param isArchived - whether this conversation was archived or not */ data class Conversation( @PrimaryKey(autoGenerate = true) @@ -22,5 +24,7 @@ data class Conversation( val conversationId: UUID = UUID.randomUUID(), val ownerVeraId: String, val contactVeraId: String, + val isRead: Boolean, val subject: String? = null, + val isArchived: Boolean = false, ) diff --git a/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationViewModel.kt b/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationViewModel.kt index f35bfee5..62c87e14 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationViewModel.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationViewModel.kt @@ -20,4 +20,8 @@ class ConversationViewModel @Inject constructor( private val _conversation = MutableStateFlow(conversationsRepository.getConversation(conversationId)) val conversation: StateFlow get() = _conversation + + init { + conversationsRepository.markConversationAsRead(conversationId) + } } From dc35c37682e0657e11d4dc1dea931c1d241330b3 Mon Sep 17 00:00:00 2001 From: migulyaev Date: Wed, 20 Sep 2023 00:21:57 +0200 Subject: [PATCH 04/10] scroll to the bottom of a conversation on screen opening --- .../letro/messages/viewing/ConversationScreen.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationScreen.kt b/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationScreen.kt index dc6f56d2..675ebec3 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationScreen.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationScreen.kt @@ -13,12 +13,15 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.Divider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -40,10 +43,15 @@ fun ConversationScreen( onBackClicked: () -> Unit, viewModel: ConversationViewModel = hiltViewModel(), ) { + val scrollState = rememberLazyListState() + val conversationState by viewModel.conversation.collectAsState() val conversation = conversationState if (conversation != null) { + LaunchedEffect(Unit) { // Scroll to the top of a conversation on screen opening + scrollState.scrollToItem(conversation.messages.size - 1) + } Column( modifier = Modifier .fillMaxSize(), @@ -104,7 +112,9 @@ fun ConversationScreen( ), ) } - LazyColumn { + LazyColumn( + state = scrollState, + ) { items(conversation.messages.size) { position -> val message = conversation.messages[position] val isLastMessage = position == conversation.messages.size - 1 From 0acd970a6a5f52e1b576d93d36592b4ce71c0a87 Mon Sep 17 00:00:00 2001 From: migulyaev Date: Wed, 20 Sep 2023 11:46:30 +0200 Subject: [PATCH 05/10] contact deleting --- .../letro/contacts/ui/ContactsScreen.kt | 5 +- .../repository/ConversationsRepository.kt | 10 + .../messages/storage/ConversationsDao.kt | 4 + .../messages/viewing/ConversationScreen.kt | 223 ++++++++++++------ .../messages/viewing/ConversationViewModel.kt | 30 +++ .../letro/ui/navigation/LetroNavHost.kt | 16 +- .../letro/ui/utils/SnackbarStringsProvider.kt | 4 + .../letro/utils/ext/SnackbarHostStateExt.kt | 13 + app/src/main/res/values/strings.xml | 5 +- 9 files changed, 225 insertions(+), 85 deletions(-) create mode 100644 app/src/main/java/tech/relaycorp/letro/utils/ext/SnackbarHostStateExt.kt diff --git a/app/src/main/java/tech/relaycorp/letro/contacts/ui/ContactsScreen.kt b/app/src/main/java/tech/relaycorp/letro/contacts/ui/ContactsScreen.kt index 9149ff4f..23a173f6 100644 --- a/app/src/main/java/tech/relaycorp/letro/contacts/ui/ContactsScreen.kt +++ b/app/src/main/java/tech/relaycorp/letro/contacts/ui/ContactsScreen.kt @@ -39,6 +39,7 @@ import tech.relaycorp.letro.ui.theme.LargeProminent import tech.relaycorp.letro.ui.theme.LetroColor import tech.relaycorp.letro.ui.theme.SmallProminent import tech.relaycorp.letro.ui.utils.SnackbarStringsProvider +import tech.relaycorp.letro.utils.ext.showSnackbar @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -54,9 +55,7 @@ fun ContactsScreen( LaunchedEffect(Unit) { viewModel.showContactDeletedSnackbarSignal.collect { - snackbarHostState.showSnackbar( - message = snackbarStringsProvider.contactDeleted, - ) + snackbarHostState.showSnackbar(this, snackbarStringsProvider.contactDeleted) } } diff --git a/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt b/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt index 99179b9c..60e9be3a 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt @@ -37,6 +37,7 @@ interface ConversationsRepository { ) fun getConversation(id: String): ExtendedConversation? fun markConversationAsRead(conversationId: String) + fun deleteConversation(conversationId: String) } class ConversationsRepositoryImpl @Inject constructor( @@ -134,6 +135,15 @@ class ConversationsRepositoryImpl @Inject constructor( } } + @Suppress("NAME_SHADOWING") + override fun deleteConversation(conversationId: String) { + scope.launch { + val conversationId = UUID.fromString(conversationId) + val conversation = _conversations.value.find { it.conversationId == conversationId } ?: return@launch + conversationsDao.delete(conversation) + } + } + private fun startCollectContacts(account: Account) { contactsCollectionJob = scope.launch { contactsRepository.getContacts(account.veraId).collect { diff --git a/app/src/main/java/tech/relaycorp/letro/messages/storage/ConversationsDao.kt b/app/src/main/java/tech/relaycorp/letro/messages/storage/ConversationsDao.kt index a020c1ea..b486289c 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/storage/ConversationsDao.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/storage/ConversationsDao.kt @@ -1,6 +1,7 @@ package tech.relaycorp.letro.messages.storage import androidx.room.Dao +import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query @@ -23,4 +24,7 @@ interface ConversationsDao { @Update suspend fun update(conversation: Conversation) + + @Delete + suspend fun delete(conversation: Conversation) } diff --git a/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationScreen.kt b/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationScreen.kt index 675ebec3..ed95fe77 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationScreen.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationScreen.kt @@ -2,6 +2,7 @@ package tech.relaycorp.letro.messages.viewing import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -12,14 +13,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Divider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -36,10 +37,12 @@ import androidx.hilt.navigation.compose.hiltViewModel import tech.relaycorp.letro.R import tech.relaycorp.letro.messages.model.ExtendedMessage import tech.relaycorp.letro.ui.common.LetroButton +import tech.relaycorp.letro.ui.theme.LargeProminent import tech.relaycorp.letro.utils.ext.applyIf @Composable fun ConversationScreen( + onConversationDeleted: () -> Unit, onBackClicked: () -> Unit, viewModel: ConversationViewModel = hiltViewModel(), ) { @@ -48,88 +51,61 @@ fun ConversationScreen( val conversationState by viewModel.conversation.collectAsState() val conversation = conversationState + val deleteConversationDialogState by viewModel.deleteConversationDialogState.collectAsState() + if (conversation != null) { LaunchedEffect(Unit) { // Scroll to the top of a conversation on screen opening scrollState.scrollToItem(conversation.messages.size - 1) } - Column( + Box( modifier = Modifier .fillMaxSize(), ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding( - vertical = 14.dp, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - onClick = onBackClicked, - ) { - Icon( - painter = painterResource(id = R.drawable.arrow_back), - contentDescription = stringResource(id = R.string.general_navigate_back), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - Spacer( - modifier = Modifier.weight(1f), - ) - IconButton( - onClick = { }, - ) { - Icon( - painter = painterResource(id = R.drawable.ic_trash), - contentDescription = stringResource(id = R.string.content_description_delete_conversation), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - LetroButton( - text = stringResource(id = R.string.reply), - onClick = { /* TODO */ }, - leadingIconResId = R.drawable.ic_reply, - contentPadding = PaddingValues( - top = 8.dp, - bottom = 8.dp, - start = 16.dp, - end = 24.dp, - ), - ) - Spacer( - modifier = Modifier.width(16.dp), + if (deleteConversationDialogState.isShown) { + DeleteConversationDialog( + onDismissRequest = { viewModel.onDeleteConversationBottomSheetDismissed() }, + onConfirmClick = { + viewModel.onConfirmConversationDeletionClick() + onConversationDeleted() + }, ) } - if (conversation.subject != null) { - Text( - text = conversation.subject, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier - .padding( - horizontal = 16.dp, - vertical = 10.dp, - ), + Column { + ConversationToolbar( + onBackClicked = onBackClicked, + onDeleteClick = { viewModel.onDeleteConversationClick() }, ) - } - LazyColumn( - state = scrollState, - ) { - items(conversation.messages.size) { position -> - val message = conversation.messages[position] - val isLastMessage = position == conversation.messages.size - 1 - Message( - message = message, - isCollapsable = conversation.messages.size > 1, - isLastMessage = isLastMessage, + if (conversation.subject != null) { + Text( + text = conversation.subject, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding( + horizontal = 16.dp, + vertical = 10.dp, + ), ) - if (!isLastMessage) { - Divider( - modifier = Modifier - .background(MaterialTheme.colorScheme.outlineVariant) - .fillMaxWidth() - .height(1.dp), + } + LazyColumn( + state = scrollState, + ) { + items(conversation.messages.size) { position -> + val message = conversation.messages[position] + val isLastMessage = position == conversation.messages.size - 1 + Message( + message = message, + isCollapsable = conversation.messages.size > 1, + isLastMessage = isLastMessage, ) + if (!isLastMessage) { + Divider( + modifier = Modifier + .background(MaterialTheme.colorScheme.outlineVariant) + .fillMaxWidth() + .height(1.dp), + ) + } } } } @@ -194,3 +170,106 @@ private fun Message( ) } } + +@Composable +private fun ConversationToolbar( + onDeleteClick: () -> Unit, + onBackClicked: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + vertical = 14.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = onBackClicked, + ) { + Icon( + painter = painterResource(id = R.drawable.arrow_back), + contentDescription = stringResource(id = R.string.general_navigate_back), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + Spacer( + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = { onDeleteClick() }, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_trash), + contentDescription = stringResource(id = R.string.delete_conversation), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + LetroButton( + text = stringResource(id = R.string.reply), + onClick = { /* TODO */ }, + leadingIconResId = R.drawable.ic_reply, + contentPadding = PaddingValues( + top = 8.dp, + bottom = 8.dp, + start = 16.dp, + end = 24.dp, + ), + ) + Spacer( + modifier = Modifier.width(16.dp), + ) + } +} + +@Composable +private fun DeleteConversationDialog( + onDismissRequest: () -> Unit, + onConfirmClick: () -> Unit, +) { + AlertDialog( + onDismissRequest = { + onDismissRequest() + }, + title = { + Text( + text = stringResource(id = R.string.delete_conversation), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + }, + text = { + Text( + text = stringResource(id = R.string.delete_conversation_dialog_message), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton( + onClick = { + onConfirmClick() + }, + ) { + Text( + text = stringResource(id = R.string.delete), + style = MaterialTheme.typography.LargeProminent, + color = MaterialTheme.colorScheme.primary, + ) + } + }, + dismissButton = { + TextButton( + onClick = { + onDismissRequest() + }, + ) { + Text( + text = stringResource(id = R.string.cancel), + style = MaterialTheme.typography.LargeProminent, + color = MaterialTheme.colorScheme.primary, + ) + } + }, + ) +} diff --git a/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationViewModel.kt b/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationViewModel.kt index 62c87e14..5f17a0e9 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationViewModel.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationViewModel.kt @@ -5,8 +5,10 @@ import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import tech.relaycorp.letro.messages.model.ExtendedConversation import tech.relaycorp.letro.messages.repository.ConversationsRepository +import tech.relaycorp.letro.messages.storage.entity.Conversation import tech.relaycorp.letro.ui.navigation.Route import javax.inject.Inject @@ -21,7 +23,35 @@ class ConversationViewModel @Inject constructor( val conversation: StateFlow get() = _conversation + private val _deleteConversationDialogState = MutableStateFlow(DeleteConversationDialogState()) + val deleteConversationDialogState: StateFlow + get() = _deleteConversationDialogState + init { conversationsRepository.markConversationAsRead(conversationId) } + + fun onDeleteConversationClick() { + _deleteConversationDialogState.update { + it.copy( + isShown = true, + ) + } + } + + fun onConfirmConversationDeletionClick() { + conversationsRepository.deleteConversation(conversationId) + } + + fun onDeleteConversationBottomSheetDismissed() { + _deleteConversationDialogState.update { + it.copy( + isShown = false, + ) + } + } } + +data class DeleteConversationDialogState( + val isShown: Boolean = false, +) diff --git a/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt b/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt index efae5f7b..3fca4599 100644 --- a/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt @@ -32,7 +32,6 @@ import androidx.navigation.compose.composable import androidx.navigation.navArgument import com.google.accompanist.systemuicontroller.SystemUiController import com.google.accompanist.systemuicontroller.rememberSystemUiController -import kotlinx.coroutines.launch import tech.relaycorp.letro.R import tech.relaycorp.letro.awala.AwalaNotInstalledScreen import tech.relaycorp.letro.contacts.ManageContactViewModel @@ -52,6 +51,7 @@ import tech.relaycorp.letro.ui.theme.LetroColor import tech.relaycorp.letro.ui.utils.SnackbarStringsProvider import tech.relaycorp.letro.utils.compose.rememberLifecycleEvent import tech.relaycorp.letro.utils.ext.encodeToUTF +import tech.relaycorp.letro.utils.ext.showSnackbar import tech.relaycorp.letro.utils.navigation.navigateWithPoppingAllBackStack @Composable @@ -208,11 +208,7 @@ fun LetroNavHost( when (val type = entry.arguments?.getInt(Route.ManageContact.KEY_SCREEN_TYPE)) { ManageContactViewModel.Type.EDIT_CONTACT -> { navController.popBackStack() - scope.launch { - snackbarHostState.showSnackbar( - message = snackbarStringsProvider.contactEdited, - ) - } + snackbarHostState.showSnackbar(scope, snackbarStringsProvider.contactEdited) } else -> throw IllegalStateException("Unknown screen type: $type") } @@ -247,9 +243,7 @@ fun LetroNavHost( onBackClicked = { navController.popBackStack() }, onMessageSent = { navController.popBackStack() - scope.launch { - snackbarHostState.showSnackbar(snackbarStringsProvider.messageSent) - } + snackbarHostState.showSnackbar(scope, snackbarStringsProvider.messageSent) }, ) } @@ -263,6 +257,10 @@ fun LetroNavHost( ), ) { ConversationScreen( + onConversationDeleted = { + navController.popBackStack() + snackbarHostState.showSnackbar(scope, snackbarStringsProvider.conversationDeleted) + }, onBackClicked = { navController.popBackStack() }, diff --git a/app/src/main/java/tech/relaycorp/letro/ui/utils/SnackbarStringsProvider.kt b/app/src/main/java/tech/relaycorp/letro/ui/utils/SnackbarStringsProvider.kt index 258e91d9..1d9536cb 100644 --- a/app/src/main/java/tech/relaycorp/letro/ui/utils/SnackbarStringsProvider.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/utils/SnackbarStringsProvider.kt @@ -9,6 +9,7 @@ interface SnackbarStringsProvider { val contactDeleted: String val contactEdited: String val messageSent: String + val conversationDeleted: String } class SnackbarStringsProviderImpl @Inject constructor( @@ -22,4 +23,7 @@ class SnackbarStringsProviderImpl @Inject constructor( override val messageSent: String get() = activity.getString(R.string.snackbar_message_sent) + + override val conversationDeleted: String + get() = activity.getString(R.string.snackbar_conversation_deleted) } diff --git a/app/src/main/java/tech/relaycorp/letro/utils/ext/SnackbarHostStateExt.kt b/app/src/main/java/tech/relaycorp/letro/utils/ext/SnackbarHostStateExt.kt new file mode 100644 index 00000000..b1dafa42 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/utils/ext/SnackbarHostStateExt.kt @@ -0,0 +1,13 @@ +package tech.relaycorp.letro.utils.ext + +import androidx.compose.material3.SnackbarHostState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +fun SnackbarHostState.showSnackbar(scope: CoroutineScope, message: String) { + scope.launch { + showSnackbar( + message = message, + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4c7777b6..9c044232 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -62,6 +62,7 @@ Contact deleted. Changes saved! Message sent! + Conversation deleted. Delete contact Are you sure you want to delete %s from your contact list? @@ -79,10 +80,12 @@ You’re not connected to this contact. Clear recipient Reply - Delete conversation + Delete conversation Nothing new for you. + Are you sure you want to permanently delete this conversation? + james.bond@mi6.gov.uk James Bond https://letro.app/en/terms From 0c3b4b1f379028c278e1599b21aba8252cde98dd Mon Sep 17 00:00:00 2001 From: migulyaev Date: Wed, 20 Sep 2023 12:18:21 +0200 Subject: [PATCH 06/10] no subject displaying on the conversations list & inside a conversation --- .../tech/relaycorp/letro/di/MainModule.kt | 14 ++++++++++ .../tech/relaycorp/letro/home/HomeScreen.kt | 7 +++-- .../messages/list/ConversationsListScreen.kt | 28 ++++++++++--------- .../messages/viewing/ConversationScreen.kt | 24 ++++++++-------- .../tech/relaycorp/letro/ui/MainActivity.kt | 6 ++-- .../letro/ui/navigation/LetroNavHost.kt | 13 +++++---- .../ui/utils/ConversationsStringsProvider.kt | 18 ++++++++++++ .../letro/ui/utils/StringsProvider.kt | 13 +++++++++ app/src/main/res/values/strings.xml | 2 ++ 9 files changed, 88 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/tech/relaycorp/letro/ui/utils/ConversationsStringsProvider.kt create mode 100644 app/src/main/java/tech/relaycorp/letro/ui/utils/StringsProvider.kt diff --git a/app/src/main/java/tech/relaycorp/letro/di/MainModule.kt b/app/src/main/java/tech/relaycorp/letro/di/MainModule.kt index cd4e2a04..2c79e27b 100644 --- a/app/src/main/java/tech/relaycorp/letro/di/MainModule.kt +++ b/app/src/main/java/tech/relaycorp/letro/di/MainModule.kt @@ -4,15 +4,29 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.android.components.ActivityComponent +import tech.relaycorp.letro.ui.utils.ConversationsStringsProvider +import tech.relaycorp.letro.ui.utils.ConversationsStringsProviderImpl import tech.relaycorp.letro.ui.utils.SnackbarStringsProvider import tech.relaycorp.letro.ui.utils.SnackbarStringsProviderImpl +import tech.relaycorp.letro.ui.utils.StringsProvider +import tech.relaycorp.letro.ui.utils.StringsProviderImpl @Module @InstallIn(ActivityComponent::class) interface MainModule { + @Binds + fun bindStringsProvider( + impl: StringsProviderImpl, + ): StringsProvider + @Binds fun bindSnackbarStringsProvider( impl: SnackbarStringsProviderImpl, ): SnackbarStringsProvider + + @Binds + fun bindConversationsStringsProvider( + impl: ConversationsStringsProviderImpl, + ): ConversationsStringsProvider } diff --git a/app/src/main/java/tech/relaycorp/letro/home/HomeScreen.kt b/app/src/main/java/tech/relaycorp/letro/home/HomeScreen.kt index 15528ac0..4fc9b55a 100644 --- a/app/src/main/java/tech/relaycorp/letro/home/HomeScreen.kt +++ b/app/src/main/java/tech/relaycorp/letro/home/HomeScreen.kt @@ -15,12 +15,12 @@ import tech.relaycorp.letro.contacts.ui.ContactsScreen import tech.relaycorp.letro.messages.list.ConversationsListScreen import tech.relaycorp.letro.messages.list.ConversationsViewModel import tech.relaycorp.letro.messages.model.ExtendedConversation -import tech.relaycorp.letro.ui.utils.SnackbarStringsProvider +import tech.relaycorp.letro.ui.utils.StringsProvider @Composable fun HomeScreen( homeViewModel: HomeViewModel, - snackbarStringsProvider: SnackbarStringsProvider, + stringsProvider: StringsProvider, onConversationClick: (ExtendedConversation) -> Unit, onEditContactClick: (Contact) -> Unit, snackbarHostState: SnackbarHostState, @@ -40,6 +40,7 @@ fun HomeScreen( when (uiState.currentTab) { TAB_CHATS -> Column { ConversationsListScreen( + conversationsStringsProvider = stringsProvider.conversations, onConversationClick = onConversationClick, viewModel = conversationsViewModel, ) @@ -47,7 +48,7 @@ fun HomeScreen( TAB_CONTACTS -> ContactsScreen( viewModel = contactsViewModel, snackbarHostState = snackbarHostState, - snackbarStringsProvider = snackbarStringsProvider, + snackbarStringsProvider = stringsProvider.snackbar, onEditContactClick = { onEditContactClick(it) }, ) TAB_NOTIFICATIONS -> Column { 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 46aac438..42a9327d 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 @@ -27,9 +27,11 @@ import tech.relaycorp.letro.messages.model.ExtendedConversation import tech.relaycorp.letro.ui.theme.LargeProminent import tech.relaycorp.letro.ui.theme.MediumProminent import tech.relaycorp.letro.ui.theme.SmallProminent +import tech.relaycorp.letro.ui.utils.ConversationsStringsProvider @Composable fun ConversationsListScreen( + conversationsStringsProvider: ConversationsStringsProvider, onConversationClick: (ExtendedConversation) -> Unit, viewModel: ConversationsViewModel, ) { @@ -46,6 +48,7 @@ fun ConversationsListScreen( items(conversations) { conversation -> Conversation( conversation = conversation, + noSubjectText = conversationsStringsProvider.noSubject, onConversationClick = { onConversationClick(conversation) }, @@ -59,6 +62,7 @@ fun ConversationsListScreen( @Composable private fun Conversation( conversation: ExtendedConversation, + noSubjectText: String, onConversationClick: () -> Unit, ) { Box( @@ -89,19 +93,17 @@ private fun Conversation( ) } Row { - if (conversation.subject != null) { - Text( - text = conversation.subject, - style = if (!conversation.isRead) MaterialTheme.typography.MediumProminent else MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - ) - Text( - text = " - ", - style = if (!conversation.isRead) MaterialTheme.typography.MediumProminent else MaterialTheme.typography.bodyMedium, - color = if (!conversation.isRead) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Text( + text = conversation.subject ?: noSubjectText, + style = if (!conversation.isRead) MaterialTheme.typography.MediumProminent else MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + ) + Text( + text = " - ", + style = if (!conversation.isRead) MaterialTheme.typography.MediumProminent else MaterialTheme.typography.bodyMedium, + color = if (!conversation.isRead) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + ) Text( text = conversation.messages.last().text, style = if (!conversation.isRead) MaterialTheme.typography.MediumProminent else MaterialTheme.typography.bodyMedium, diff --git a/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationScreen.kt b/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationScreen.kt index ed95fe77..10ad66dc 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationScreen.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationScreen.kt @@ -38,10 +38,12 @@ import tech.relaycorp.letro.R import tech.relaycorp.letro.messages.model.ExtendedMessage import tech.relaycorp.letro.ui.common.LetroButton import tech.relaycorp.letro.ui.theme.LargeProminent +import tech.relaycorp.letro.ui.utils.ConversationsStringsProvider import tech.relaycorp.letro.utils.ext.applyIf @Composable fun ConversationScreen( + conversationsStringsProvider: ConversationsStringsProvider, onConversationDeleted: () -> Unit, onBackClicked: () -> Unit, viewModel: ConversationViewModel = hiltViewModel(), @@ -75,18 +77,16 @@ fun ConversationScreen( onBackClicked = onBackClicked, onDeleteClick = { viewModel.onDeleteConversationClick() }, ) - if (conversation.subject != null) { - Text( - text = conversation.subject, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier - .padding( - horizontal = 16.dp, - vertical = 10.dp, - ), - ) - } + Text( + text = conversation.subject ?: conversationsStringsProvider.noSubject, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding( + horizontal = 16.dp, + vertical = 10.dp, + ), + ) LazyColumn( state = scrollState, ) { diff --git a/app/src/main/java/tech/relaycorp/letro/ui/MainActivity.kt b/app/src/main/java/tech/relaycorp/letro/ui/MainActivity.kt index ad0ca447..b5fd16f8 100644 --- a/app/src/main/java/tech/relaycorp/letro/ui/MainActivity.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/MainActivity.kt @@ -21,7 +21,7 @@ import tech.relaycorp.letro.R import tech.relaycorp.letro.main.MainViewModel import tech.relaycorp.letro.ui.navigation.LetroNavHost import tech.relaycorp.letro.ui.theme.LetroTheme -import tech.relaycorp.letro.ui.utils.SnackbarStringsProvider +import tech.relaycorp.letro.ui.utils.StringsProvider import javax.inject.Inject @AndroidEntryPoint @@ -30,7 +30,7 @@ class MainActivity : ComponentActivity() { private val viewModel by viewModels() @Inject - lateinit var snackbarStringsProvider: SnackbarStringsProvider + lateinit var stringsProvider: StringsProvider override fun onCreate(savedInstanceState: Bundle?) { setTheme(R.style.Theme_Letro) @@ -47,7 +47,7 @@ class MainActivity : ComponentActivity() { ) { LetroNavHost( navController = navController, - snackbarStringsProvider = snackbarStringsProvider, + stringsProvider = stringsProvider, ) } } diff --git a/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt b/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt index 3fca4599..f1d78707 100644 --- a/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/navigation/LetroNavHost.kt @@ -48,7 +48,7 @@ import tech.relaycorp.letro.onboarding.registration.ui.RegistrationScreen import tech.relaycorp.letro.ui.common.LetroTopBar import tech.relaycorp.letro.ui.common.SplashScreen import tech.relaycorp.letro.ui.theme.LetroColor -import tech.relaycorp.letro.ui.utils.SnackbarStringsProvider +import tech.relaycorp.letro.ui.utils.StringsProvider import tech.relaycorp.letro.utils.compose.rememberLifecycleEvent import tech.relaycorp.letro.utils.ext.encodeToUTF import tech.relaycorp.letro.utils.ext.showSnackbar @@ -57,7 +57,7 @@ import tech.relaycorp.letro.utils.navigation.navigateWithPoppingAllBackStack @Composable fun LetroNavHost( navController: NavHostController, - snackbarStringsProvider: SnackbarStringsProvider, + stringsProvider: StringsProvider, mainViewModel: MainViewModel = hiltViewModel(), homeViewModel: HomeViewModel = hiltViewModel(), ) { @@ -208,7 +208,7 @@ fun LetroNavHost( when (val type = entry.arguments?.getInt(Route.ManageContact.KEY_SCREEN_TYPE)) { ManageContactViewModel.Type.EDIT_CONTACT -> { navController.popBackStack() - snackbarHostState.showSnackbar(scope, snackbarStringsProvider.contactEdited) + snackbarHostState.showSnackbar(scope, stringsProvider.snackbar.contactEdited) } else -> throw IllegalStateException("Unknown screen type: $type") } @@ -219,7 +219,7 @@ fun LetroNavHost( HomeScreen( homeViewModel = homeViewModel, snackbarHostState = snackbarHostState, - snackbarStringsProvider = snackbarStringsProvider, + stringsProvider = stringsProvider, onConversationClick = { navController.navigate( Route.Conversation.getRouteName( @@ -243,7 +243,7 @@ fun LetroNavHost( onBackClicked = { navController.popBackStack() }, onMessageSent = { navController.popBackStack() - snackbarHostState.showSnackbar(scope, snackbarStringsProvider.messageSent) + snackbarHostState.showSnackbar(scope, stringsProvider.snackbar.messageSent) }, ) } @@ -257,9 +257,10 @@ fun LetroNavHost( ), ) { ConversationScreen( + conversationsStringsProvider = stringsProvider.conversations, onConversationDeleted = { navController.popBackStack() - snackbarHostState.showSnackbar(scope, snackbarStringsProvider.conversationDeleted) + snackbarHostState.showSnackbar(scope, stringsProvider.snackbar.conversationDeleted) }, onBackClicked = { navController.popBackStack() diff --git a/app/src/main/java/tech/relaycorp/letro/ui/utils/ConversationsStringsProvider.kt b/app/src/main/java/tech/relaycorp/letro/ui/utils/ConversationsStringsProvider.kt new file mode 100644 index 00000000..76262a0c --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/ui/utils/ConversationsStringsProvider.kt @@ -0,0 +1,18 @@ +package tech.relaycorp.letro.ui.utils + +import android.content.Context +import dagger.hilt.android.qualifiers.ActivityContext +import tech.relaycorp.letro.R +import javax.inject.Inject + +interface ConversationsStringsProvider { + val noSubject: String +} + +class ConversationsStringsProviderImpl @Inject constructor( + @ActivityContext private val context: Context, +) : ConversationsStringsProvider { + + override val noSubject: String + get() = context.getString(R.string.no_subject) +} diff --git a/app/src/main/java/tech/relaycorp/letro/ui/utils/StringsProvider.kt b/app/src/main/java/tech/relaycorp/letro/ui/utils/StringsProvider.kt new file mode 100644 index 00000000..003545fd --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/ui/utils/StringsProvider.kt @@ -0,0 +1,13 @@ +package tech.relaycorp.letro.ui.utils + +import javax.inject.Inject + +interface StringsProvider { + val snackbar: SnackbarStringsProvider + val conversations: ConversationsStringsProvider +} + +class StringsProviderImpl @Inject constructor( + override val snackbar: SnackbarStringsProvider, + override val conversations: ConversationsStringsProvider, +) : StringsProvider diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9c044232..3b78d481 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -86,6 +86,8 @@ Are you sure you want to permanently delete this conversation? + (No subject) + james.bond@mi6.gov.uk James Bond https://letro.app/en/terms From bbd92eb9bf666db6e76f0c11282e129c6dd2cea5 Mon Sep 17 00:00:00 2001 From: migulyaev Date: Wed, 20 Sep 2023 12:28:56 +0200 Subject: [PATCH 07/10] move tab row from the file to separate package --- .../tech/relaycorp/letro/home/HomeScreen.kt | 1 + .../relaycorp/letro/home/tabs/LetroTabs.kt | 69 +++++++++++++++++++ .../common/ScrollableTabRow.kt} | 61 +--------------- 3 files changed, 71 insertions(+), 60 deletions(-) create mode 100644 app/src/main/java/tech/relaycorp/letro/home/tabs/LetroTabs.kt rename app/src/main/java/tech/relaycorp/letro/{home/LetroTabs.kt => ui/common/ScrollableTabRow.kt} (81%) diff --git a/app/src/main/java/tech/relaycorp/letro/home/HomeScreen.kt b/app/src/main/java/tech/relaycorp/letro/home/HomeScreen.kt index 4fc9b55a..7def1526 100644 --- a/app/src/main/java/tech/relaycorp/letro/home/HomeScreen.kt +++ b/app/src/main/java/tech/relaycorp/letro/home/HomeScreen.kt @@ -12,6 +12,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import tech.relaycorp.letro.contacts.ContactsViewModel import tech.relaycorp.letro.contacts.model.Contact import tech.relaycorp.letro.contacts.ui.ContactsScreen +import tech.relaycorp.letro.home.tabs.LetroTabs import tech.relaycorp.letro.messages.list.ConversationsListScreen import tech.relaycorp.letro.messages.list.ConversationsViewModel import tech.relaycorp.letro.messages.model.ExtendedConversation diff --git a/app/src/main/java/tech/relaycorp/letro/home/tabs/LetroTabs.kt b/app/src/main/java/tech/relaycorp/letro/home/tabs/LetroTabs.kt new file mode 100644 index 00000000..9558556b --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/home/tabs/LetroTabs.kt @@ -0,0 +1,69 @@ +package tech.relaycorp.letro.home.tabs + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRowDefaults +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import tech.relaycorp.letro.R +import tech.relaycorp.letro.home.HomeViewModel +import tech.relaycorp.letro.ui.theme.LetroColor + +@SuppressLint +@Composable +fun LetroTabs( + viewModel: HomeViewModel, + modifier: Modifier = Modifier, +) { + val uiState by viewModel.uiState.collectAsState() + + val tabTitles = listOf( + stringResource(id = R.string.top_bar_tab_conversations), + stringResource(id = R.string.top_bar_tab_contacts), + stringResource(id = R.string.top_bar_tab_notifications), + ) + ScrollableTabRow( + selectedTabIndex = uiState.currentTab, + containerColor = LetroColor.SurfaceContainerHigh, + contentColor = LetroColor.OnSurfaceContainerHigh, + edgePadding = 9.dp, + indicator = { + TabRowDefaults.Indicator( + color = LetroColor.OnSurfaceContainerHigh, + modifier = Modifier.tabIndicatorOffset(it[uiState.currentTab]), + ) + }, + modifier = modifier, + ) { + tabTitles.forEachIndexed { index, title -> + Tab( + selected = uiState.currentTab == index, + onClick = { + viewModel.onTabClick(index) + }, + text = { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = LetroColor.OnSurfaceContainerHigh, + maxLines = 1, + ) + }, + selectedContentColor = LetroColor.OnSurfaceContainerHigh, + unselectedContentColor = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 0.dp) + .alpha(if (uiState.currentTab == index) 1f else 0.6f) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/tech/relaycorp/letro/home/LetroTabs.kt b/app/src/main/java/tech/relaycorp/letro/ui/common/ScrollableTabRow.kt similarity index 81% rename from app/src/main/java/tech/relaycorp/letro/home/LetroTabs.kt rename to app/src/main/java/tech/relaycorp/letro/ui/common/ScrollableTabRow.kt index b72e87e7..b98cb95f 100644 --- a/app/src/main/java/tech/relaycorp/letro/home/LetroTabs.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/common/ScrollableTabRow.kt @@ -1,6 +1,5 @@ -package tech.relaycorp.letro.home +package tech.relaycorp.letro.ui.common -import android.annotation.SuppressLint import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateDpAsState @@ -9,89 +8,31 @@ import androidx.compose.foundation.ScrollState import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material3.Divider -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.Tab import androidx.compose.material3.TabRowDefaults -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.platform.debugInspectorInfo -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import tech.relaycorp.letro.R -import tech.relaycorp.letro.ui.theme.LetroColor - -@SuppressLint -@Composable -fun LetroTabs( - viewModel: HomeViewModel, - modifier: Modifier = Modifier, -) { - val uiState by viewModel.uiState.collectAsState() - - val tabTitles = listOf( - stringResource(id = R.string.top_bar_tab_conversations), - stringResource(id = R.string.top_bar_tab_contacts), - stringResource(id = R.string.top_bar_tab_notifications), - ) - ScrollableTabRow( - selectedTabIndex = uiState.currentTab, - containerColor = LetroColor.SurfaceContainerHigh, - contentColor = LetroColor.OnSurfaceContainerHigh, - edgePadding = 9.dp, - indicator = { - TabRowDefaults.Indicator( - color = LetroColor.OnSurfaceContainerHigh, - modifier = Modifier.tabIndicatorOffset(it[uiState.currentTab]), - ) - }, - modifier = modifier, - ) { - tabTitles.forEachIndexed { index, title -> - Tab( - selected = uiState.currentTab == index, - onClick = { - viewModel.onTabClick(index) - }, - text = { - Text( - text = title, - style = MaterialTheme.typography.labelLarge, - color = LetroColor.OnSurfaceContainerHigh, - maxLines = 1, - ) - }, - selectedContentColor = LetroColor.OnSurfaceContainerHigh, - unselectedContentColor = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(horizontal = 0.dp) - .alpha(if (uiState.currentTab == index) 1f else 0.6f), - ) - } - } -} // @Deprecated("Copy pasted from TabRow compose class, because they don't provide an option to add padding between items") @Composable From 1c0c0544ed29a7e34327b653546a78d5b22e09b0 Mon Sep 17 00:00:00 2001 From: migulyaev Date: Wed, 20 Sep 2023 13:22:58 +0200 Subject: [PATCH 08/10] counter of unread conversations --- .../relaycorp/letro/home/HomeViewModel.kt | 31 ++++++- .../relaycorp/letro/home/tabs/LetroTabs.kt | 93 ++++++++++++++++--- 2 files changed, 109 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/tech/relaycorp/letro/home/HomeViewModel.kt b/app/src/main/java/tech/relaycorp/letro/home/HomeViewModel.kt index fc1c7ceb..74b72368 100644 --- a/app/src/main/java/tech/relaycorp/letro/home/HomeViewModel.kt +++ b/app/src/main/java/tech/relaycorp/letro/home/HomeViewModel.kt @@ -9,10 +9,14 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import tech.relaycorp.letro.messages.model.ExtendedConversation +import tech.relaycorp.letro.messages.repository.ConversationsRepository import javax.inject.Inject @HiltViewModel -class HomeViewModel @Inject constructor() : ViewModel() { +class HomeViewModel @Inject constructor( + private val conversationsRepository: ConversationsRepository, +) : ViewModel() { private val _uiState = MutableStateFlow(HomeUiState()) val uiState: StateFlow @@ -22,6 +26,14 @@ class HomeViewModel @Inject constructor() : ViewModel() { val createNewMessageSignal: SharedFlow get() = _createNewMessageSignal + init { + viewModelScope.launch { + conversationsRepository.conversations.collect { + updateTabBadges(it) + } + } + } + fun onTabClick(index: Int) { viewModelScope.launch { _uiState.update { @@ -60,6 +72,22 @@ class HomeViewModel @Inject constructor() : ViewModel() { } } + private fun updateTabBadges(conversations: List) { + val unreadConversationsCount = conversations.count { !it.isRead } + val badge = if (unreadConversationsCount > 0) { + unreadConversationsCount.toString() + } else { + null + } + _uiState.update { + it.copy( + tabCounters = mapOf( + TAB_CHATS to badge, + ), + ) + } + } + private fun getFloatingActionButtonConfig(tabIndex: Int) = when (tabIndex) { TAB_CHATS -> HomeFloatingActionButtonConfig.ChatListFloatingActionButtonConfig TAB_CONTACTS -> HomeFloatingActionButtonConfig.ContactsFloatingActionButtonConfig @@ -70,6 +98,7 @@ class HomeViewModel @Inject constructor() : ViewModel() { data class HomeUiState( val currentTab: Int = TAB_CHATS, + val tabCounters: Map = emptyMap(), val floatingActionButtonConfig: HomeFloatingActionButtonConfig? = HomeFloatingActionButtonConfig.ChatListFloatingActionButtonConfig, val isAddContactFloatingMenuVisible: Boolean = false, ) diff --git a/app/src/main/java/tech/relaycorp/letro/home/tabs/LetroTabs.kt b/app/src/main/java/tech/relaycorp/letro/home/tabs/LetroTabs.kt index 9558556b..97341ca9 100644 --- a/app/src/main/java/tech/relaycorp/letro/home/tabs/LetroTabs.kt +++ b/app/src/main/java/tech/relaycorp/letro/home/tabs/LetroTabs.kt @@ -1,7 +1,14 @@ package tech.relaycorp.letro.home.tabs import android.annotation.SuppressLint +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Tab @@ -11,9 +18,11 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import tech.relaycorp.letro.R import tech.relaycorp.letro.home.HomeViewModel @@ -32,6 +41,8 @@ fun LetroTabs( stringResource(id = R.string.top_bar_tab_contacts), stringResource(id = R.string.top_bar_tab_notifications), ) + val tabCounters = uiState.tabCounters + ScrollableTabRow( selectedTabIndex = uiState.currentTab, containerColor = LetroColor.SurfaceContainerHigh, @@ -46,24 +57,78 @@ fun LetroTabs( modifier = modifier, ) { tabTitles.forEachIndexed { index, title -> - Tab( + BadgedTab( selected = uiState.currentTab == index, onClick = { viewModel.onTabClick(index) }, - text = { - Text( - text = title, - style = MaterialTheme.typography.labelLarge, - color = LetroColor.OnSurfaceContainerHigh, - maxLines = 1, - ) - }, - selectedContentColor = LetroColor.OnSurfaceContainerHigh, - unselectedContentColor = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(horizontal = 0.dp) - .alpha(if (uiState.currentTab == index) 1f else 0.6f) + text = title, + badge = tabCounters[index], + modifier = Modifier + .padding(horizontal = 0.dp) + .alpha(if (uiState.currentTab == index) 1f else 0.6f), ) } } -} \ No newline at end of file +} + +@Composable +private fun BadgedTab( + modifier: Modifier = Modifier, + selected: Boolean, + onClick: () -> Unit, + text: String, + badge: String? = null, +) { + Tab( + selected = selected, + onClick = { onClick() }, + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = LetroColor.OnSurfaceContainerHigh, + maxLines = 1, + ) + if (badge != null) { + Spacer(modifier = Modifier.width(4.dp)) + TabBadge(text = badge) + } + } + }, + selectedContentColor = LetroColor.OnSurfaceContainerHigh, + unselectedContentColor = MaterialTheme.colorScheme.error, + modifier = modifier, + ) +} + +@Composable +private fun TabBadge( + text: String, +) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .background( + color = LetroColor.OnSurfaceContainerHigh, + shape = CircleShape, + ) + .size(16.dp), + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = LetroColor.SurfaceContainerHigh, + maxLines = 1, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun PreviewTabBadge() { + TabBadge(text = "1") +} From f3bc506b04e4dfae15936b0f9ea5fe348e602fb1 Mon Sep 17 00:00:00 2001 From: migulyaev Date: Wed, 20 Sep 2023 13:34:12 +0200 Subject: [PATCH 09/10] fix emitting multiple values to flow which caused blinks on the conversation list screen --- .../letro/messages/list/ConversationsListScreen.kt | 2 +- .../relaycorp/letro/messages/list/ConversationsViewModel.kt | 4 ++-- .../letro/messages/repository/ConversationsRepository.kt | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) 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 42a9327d..4ef60c3e 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 @@ -35,7 +35,7 @@ fun ConversationsListScreen( onConversationClick: (ExtendedConversation) -> Unit, viewModel: ConversationsViewModel, ) { - val conversations by viewModel.conversations.collectAsState(emptyList()) + val conversations by viewModel.conversations.collectAsState() Box(modifier = Modifier.fillMaxSize()) { if (conversations.isEmpty()) { diff --git a/app/src/main/java/tech/relaycorp/letro/messages/list/ConversationsViewModel.kt b/app/src/main/java/tech/relaycorp/letro/messages/list/ConversationsViewModel.kt index 187c1e4a..62fbbb53 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/list/ConversationsViewModel.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/list/ConversationsViewModel.kt @@ -2,7 +2,7 @@ package tech.relaycorp.letro.messages.list import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow import tech.relaycorp.letro.messages.model.ExtendedConversation import tech.relaycorp.letro.messages.repository.ConversationsRepository import javax.inject.Inject @@ -12,6 +12,6 @@ class ConversationsViewModel @Inject constructor( private val conversationsRepository: ConversationsRepository, ) : ViewModel() { - val conversations: Flow> + val conversations: StateFlow> get() = conversationsRepository.conversations } diff --git a/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt b/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt index 60e9be3a..add3540f 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt @@ -3,8 +3,8 @@ package tech.relaycorp.letro.messages.repository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch @@ -28,7 +28,7 @@ import java.util.UUID import javax.inject.Inject interface ConversationsRepository { - val conversations: Flow> + val conversations: StateFlow> fun createNewConversation( ownerVeraId: String, recipient: Contact, @@ -54,7 +54,7 @@ class ConversationsRepositoryImpl @Inject constructor( private val _conversations = MutableStateFlow>(emptyList()) private val _extendedConversations = MutableStateFlow>(emptyList()) - override val conversations: Flow> + override val conversations: StateFlow> get() = _extendedConversations private val contacts: MutableStateFlow> = MutableStateFlow(emptyList()) From 13c8961551cbee5fa6a965980fc3f78823e11a03 Mon Sep 17 00:00:00 2001 From: migulyaev Date: Wed, 20 Sep 2023 13:42:20 +0200 Subject: [PATCH 10/10] display 9+ if there are more unread messages --- .../main/java/tech/relaycorp/letro/home/HomeViewModel.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/tech/relaycorp/letro/home/HomeViewModel.kt b/app/src/main/java/tech/relaycorp/letro/home/HomeViewModel.kt index 74b72368..316c499d 100644 --- a/app/src/main/java/tech/relaycorp/letro/home/HomeViewModel.kt +++ b/app/src/main/java/tech/relaycorp/letro/home/HomeViewModel.kt @@ -74,10 +74,10 @@ class HomeViewModel @Inject constructor( private fun updateTabBadges(conversations: List) { val unreadConversationsCount = conversations.count { !it.isRead } - val badge = if (unreadConversationsCount > 0) { - unreadConversationsCount.toString() - } else { - null + val badge = when { + unreadConversationsCount > 9 -> "9+" + unreadConversationsCount > 0 -> unreadConversationsCount.toString() + else -> null } _uiState.update { it.copy(