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/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/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 ab01dd90..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,14 +12,17 @@ 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.ui.utils.SnackbarStringsProvider +import tech.relaycorp.letro.messages.model.ExtendedConversation +import tech.relaycorp.letro.ui.utils.StringsProvider @Composable fun HomeScreen( homeViewModel: HomeViewModel, - snackbarStringsProvider: SnackbarStringsProvider, + stringsProvider: StringsProvider, + onConversationClick: (ExtendedConversation) -> Unit, onEditContactClick: (Contact) -> Unit, snackbarHostState: SnackbarHostState, conversationsViewModel: ConversationsViewModel = hiltViewModel(), @@ -38,13 +41,15 @@ fun HomeScreen( when (uiState.currentTab) { TAB_CHATS -> Column { ConversationsListScreen( + conversationsStringsProvider = stringsProvider.conversations, + onConversationClick = onConversationClick, viewModel = conversationsViewModel, ) } 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/home/HomeViewModel.kt b/app/src/main/java/tech/relaycorp/letro/home/HomeViewModel.kt index fc1c7ceb..316c499d 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 = when { + unreadConversationsCount > 9 -> "9+" + 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 new file mode 100644 index 00000000..97341ca9 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/home/tabs/LetroTabs.kt @@ -0,0 +1,134 @@ +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 +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.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 +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), + ) + val tabCounters = uiState.tabCounters + + 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 -> + BadgedTab( + selected = uiState.currentTab == index, + onClick = { + viewModel.onTabClick(index) + }, + text = title, + badge = tabCounters[index], + modifier = Modifier + .padding(horizontal = 0.dp) + .alpha(if (uiState.currentTab == index) 1f else 0.6f), + ) + } + } +} + +@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") +} 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) }, 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..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 @@ -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,33 @@ 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(), + 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 a534d436..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 @@ -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 @@ -26,12 +27,15 @@ 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, ) { - val conversations by viewModel.conversations.collectAsState(emptyList()) + val conversations by viewModel.conversations.collectAsState() Box(modifier = Modifier.fillMaxSize()) { if (conversations.isEmpty()) { @@ -41,8 +45,14 @@ fun ConversationsListScreen( } } else { LazyColumn { - items(conversations) { - Conversation(conversation = it) + items(conversations) { conversation -> + Conversation( + conversation = conversation, + noSubjectText = conversationsStringsProvider.noSubject, + onConversationClick = { + onConversationClick(conversation) + }, + ) } } } @@ -52,10 +62,13 @@ fun ConversationsListScreen( @Composable private fun Conversation( conversation: ExtendedConversation, + noSubjectText: String, + onConversationClick: () -> Unit, ) { Box( modifier = Modifier .fillMaxWidth() + .clickable { onConversationClick() } .padding( horizontal = 16.dp, vertical = 10.dp, @@ -66,34 +79,36 @@ private fun Conversation( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = conversation.recipientAlias ?: conversation.recipientVeraId, - style = MaterialTheme.typography.LargeProminent, + text = conversation.contactDisplayName, + 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, ) } Row { - if (conversation.subject != null) { - Text( - text = conversation.subject, - style = MaterialTheme.typography.MediumProminent, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = " - ", - style = MaterialTheme.typography.MediumProminent, - color = MaterialTheme.colorScheme.onSurface, - ) - } + 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 = 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/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/model/ExtendedConversation.kt b/app/src/main/java/tech/relaycorp/letro/messages/model/ExtendedConversation.kt index 885df026..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 @@ -5,10 +5,12 @@ 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 isRead: Boolean, 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/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 6de426f9..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 @@ -24,16 +24,20 @@ 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 { - val conversations: Flow> + val conversations: StateFlow> fun createNewConversation( ownerVeraId: String, recipient: Contact, messageText: String, subject: String? = null, ) + fun getConversation(id: String): ExtendedConversation? + fun markConversationAsRead(conversationId: String) + fun deleteConversation(conversationId: String) } class ConversationsRepositoryImpl @Inject constructor( @@ -48,9 +52,10 @@ class ConversationsRepositoryImpl @Inject constructor( private val scope = CoroutineScope(Dispatchers.IO) - private val _conversations = MutableStateFlow>(emptyList()) - override val conversations: Flow> - get() = _conversations + private val _conversations = MutableStateFlow>(emptyList()) + private val _extendedConversations = MutableStateFlow>(emptyList()) + override val conversations: StateFlow> + get() = _extendedConversations private val contacts: MutableStateFlow> = MutableStateFlow(emptyList()) @@ -68,13 +73,18 @@ class ConversationsRepositoryImpl @Inject constructor( conversationsCollectionJob = null contactsCollectionJob?.cancel() contactsCollectionJob = null - _conversations.emit(emptyList()) + _extendedConversations.emit(emptyList()) contacts.emit(emptyList()) } } } } + override fun getConversation(id: String): ExtendedConversation? { + val uuid = UUID.fromString(id) + return _extendedConversations.value.find { it.conversationId == uuid } + } + override fun createNewConversation( ownerVeraId: String, recipient: Contact, @@ -87,6 +97,7 @@ class ConversationsRepositoryImpl @Inject constructor( ownerVeraId = ownerVeraId, contactVeraId = recipient.contactVeraId, subject = if (subject.isNullOrEmpty()) null else subject, + isRead = true, ) val message = Message( text = messageText, @@ -111,6 +122,28 @@ 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, + ), + ) + } + } + + @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 { @@ -128,11 +161,13 @@ 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 }, contacts = contacts.filter { it.ownerVeraId == account.veraId }, + ownerVeraId = 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 132764b3..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,12 +1,15 @@ 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 +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 +18,13 @@ 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) + + @Delete + suspend fun delete(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/ConversationScreen.kt b/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationScreen.kt new file mode 100644 index 00000000..10ad66dc --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationScreen.kt @@ -0,0 +1,275 @@ +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 +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.rememberLazyListState +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 +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.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(), +) { + val scrollState = rememberLazyListState() + + 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) + } + Box( + modifier = Modifier + .fillMaxSize(), + ) { + if (deleteConversationDialogState.isShown) { + DeleteConversationDialog( + onDismissRequest = { viewModel.onDeleteConversationBottomSheetDismissed() }, + onConfirmClick = { + viewModel.onConfirmConversationDeletionClick() + onConversationDeleted() + }, + ) + } + Column { + ConversationToolbar( + onBackClicked = onBackClicked, + onDeleteClick = { viewModel.onDeleteConversationClick() }, + ) + 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, + ) { + 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, + ), + ) + } +} + +@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 new file mode 100644 index 00000000..5f17a0e9 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/messages/viewing/ConversationViewModel.kt @@ -0,0 +1,57 @@ +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 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 + +@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 + + 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/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/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 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..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 @@ -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 @@ -42,21 +41,23 @@ 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 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 import tech.relaycorp.letro.utils.navigation.navigateWithPoppingAllBackStack @Composable fun LetroNavHost( navController: NavHostController, - snackbarStringsProvider: SnackbarStringsProvider, + stringsProvider: StringsProvider, mainViewModel: MainViewModel = hiltViewModel(), homeViewModel: HomeViewModel = hiltViewModel(), ) { @@ -207,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, stringsProvider.snackbar.contactEdited) } else -> throw IllegalStateException("Unknown screen type: $type") } @@ -222,7 +219,14 @@ fun LetroNavHost( HomeScreen( homeViewModel = homeViewModel, snackbarHostState = snackbarHostState, - snackbarStringsProvider = snackbarStringsProvider, + stringsProvider = stringsProvider, + onConversationClick = { + navController.navigate( + Route.Conversation.getRouteName( + conversationId = it.conversationId.toString(), + ), + ) + }, onEditContactClick = { contact -> navController.navigate( Route.ManageContact.getRouteName( @@ -239,9 +243,27 @@ fun LetroNavHost( onBackClicked = { navController.popBackStack() }, onMessageSent = { navController.popBackStack() - scope.launch { - snackbarHostState.showSnackbar(snackbarStringsProvider.messageSent) - } + snackbarHostState.showSnackbar(scope, stringsProvider.snackbar.messageSent) + }, + ) + } + composable( + route = "${Route.Conversation.name}/{${Route.Conversation.KEY_CONVERSATION_ID}}", + arguments = listOf( + navArgument(Route.Conversation.KEY_CONVERSATION_ID) { + type = NavType.StringType + nullable = false + }, + ), + ) { + ConversationScreen( + conversationsStringsProvider = stringsProvider.conversations, + onConversationDeleted = { + navController.popBackStack() + snackbarHostState.showSnackbar(scope, stringsProvider.snackbar.conversationDeleted) + }, + onBackClicked = { + navController.popBackStack() }, ) } 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/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/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/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/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/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..3b78d481 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? @@ -78,9 +79,15 @@ Type your message… You’re not connected to this contact. Clear recipient + Reply + Delete conversation Nothing new for you. + Are you sure you want to permanently delete this conversation? + + (No subject) + james.bond@mi6.gov.uk James Bond https://letro.app/en/terms