diff --git a/app-old/src/main/java/tech/relaycorp/letro/ui/theme/Type.kt b/app-old/src/main/java/tech/relaycorp/letro/ui/theme/Type.kt index 539c2462..8562a1fa 100644 --- a/app-old/src/main/java/tech/relaycorp/letro/ui/theme/Type.kt +++ b/app-old/src/main/java/tech/relaycorp/letro/ui/theme/Type.kt @@ -65,10 +65,10 @@ val Typography = Typography( ), labelLarge = TextStyle( fontFamily = Inter, - fontWeight = FontWeight.Medium, + fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 20.sp, - letterSpacing = 0.25.sp, + letterSpacing = (-0.1).sp, ), labelMedium = TextStyle( fontFamily = Inter, diff --git a/app/lint.xml b/app/lint.xml index fcfe4e15..ffbeb626 100644 --- a/app/lint.xml +++ b/app/lint.xml @@ -6,6 +6,9 @@ + + + diff --git a/app/src/main/java/tech/relaycorp/letro/account/storage/AccountRepository.kt b/app/src/main/java/tech/relaycorp/letro/account/storage/AccountRepository.kt index ef6f443c..0bfa8da3 100644 --- a/app/src/main/java/tech/relaycorp/letro/account/storage/AccountRepository.kt +++ b/app/src/main/java/tech/relaycorp/letro/account/storage/AccountRepository.kt @@ -1,5 +1,6 @@ package tech.relaycorp.letro.account.storage +import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -7,6 +8,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import tech.relaycorp.letro.account.model.Account +import tech.relaycorp.letro.main.MainViewModel import javax.inject.Inject interface AccountRepository { @@ -34,6 +36,7 @@ class AccountRepositoryImpl @Inject constructor( } databaseScope.launch { _allAccounts.collect { list -> + Log.d(MainViewModel.TAG, "AccountRepository.emit(currentAccount)") _currentAccount.emit( list.firstOrNull { it.isCurrent }, ) diff --git a/app/src/main/java/tech/relaycorp/letro/contacts/ContactsViewModel.kt b/app/src/main/java/tech/relaycorp/letro/contacts/ContactsViewModel.kt new file mode 100644 index 00000000..d542c96f --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/contacts/ContactsViewModel.kt @@ -0,0 +1,138 @@ +package tech.relaycorp.letro.contacts + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import tech.relaycorp.letro.account.model.Account +import tech.relaycorp.letro.account.storage.AccountRepository +import tech.relaycorp.letro.contacts.model.Contact +import tech.relaycorp.letro.contacts.model.ContactPairingStatus +import tech.relaycorp.letro.contacts.storage.ContactsRepository +import javax.inject.Inject + +@HiltViewModel +class ContactsViewModel @Inject constructor( + private val contactsRepository: ContactsRepository, + private val accountRepository: AccountRepository, +) : ViewModel() { + + private val _contacts: MutableStateFlow> = MutableStateFlow(emptyList()) + val contacts: StateFlow> + get() = _contacts + + private val _editContactBottomSheetStateState = MutableStateFlow(EditContactBottomSheetState()) + val editContactBottomSheetState: StateFlow + get() = _editContactBottomSheetStateState + + private val _deleteContactDialogStateState = MutableStateFlow(DeleteContactDialogState()) + val deleteContactDialogState: StateFlow + get() = _deleteContactDialogStateState + + private val _showContactDeletedSnackbarSignal = MutableSharedFlow() + val showContactDeletedSnackbarSignal: SharedFlow + get() = _showContactDeletedSnackbarSignal + + private var contactsCollectionJob: Job? = null + + init { + viewModelScope.launch { + accountRepository.currentAccount.collect { + observeContacts(it) + } + } + } + + fun onActionsButtonClick(contact: Contact) { + viewModelScope.launch { + _editContactBottomSheetStateState.update { + it.copy( + isShown = true, + contact = contact, + ) + } + } + } + + fun onEditBottomSheetDismissed() { + closeEditBottomSheet() + } + + fun onEditContactClick() { + closeEditBottomSheet() + } + + fun onDeleteContactDialogDismissed() { + closeDeleteContactDialog() + } + + fun onDeleteContactClick(contact: Contact) { + closeEditBottomSheet() + viewModelScope.launch { + _deleteContactDialogStateState.update { + it.copy( + isShown = true, + contact = contact, + ) + } + } + } + + fun onConfirmDeletingContactClick(contact: Contact) { + contactsRepository.deleteContact(contact) + closeDeleteContactDialog() + viewModelScope.launch { + _showContactDeletedSnackbarSignal.emit(Unit) + } + } + + private fun closeDeleteContactDialog() { + viewModelScope.launch { + _deleteContactDialogStateState.update { + it.copy( + isShown = false, + contact = null, + ) + } + } + } + + private fun closeEditBottomSheet() { + viewModelScope.launch { + _editContactBottomSheetStateState.update { + it.copy( + isShown = false, + contact = null, + ) + } + } + } + + private fun observeContacts(account: Account?) { + contactsCollectionJob?.cancel() + contactsCollectionJob = null + if (account != null) { + contactsCollectionJob = viewModelScope.launch { + contactsRepository.getContacts(account.veraId).collect { + _contacts.emit(it.filter { it.status == ContactPairingStatus.COMPLETED }) + } + } + } + } +} + +data class EditContactBottomSheetState( + val isShown: Boolean = false, + val contact: Contact? = null, +) + +data class DeleteContactDialogState( + val isShown: Boolean = false, + val contact: Contact? = null, +) diff --git a/app/src/main/java/tech/relaycorp/letro/contacts/ManageContactViewModel.kt b/app/src/main/java/tech/relaycorp/letro/contacts/ManageContactViewModel.kt new file mode 100644 index 00000000..95dfecf7 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/contacts/ManageContactViewModel.kt @@ -0,0 +1,185 @@ +package tech.relaycorp.letro.contacts + +import androidx.annotation.IntDef +import androidx.annotation.StringRes +import androidx.compose.runtime.Immutable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import tech.relaycorp.letro.R +import tech.relaycorp.letro.contacts.ManageContactViewModel.Type.Companion.EDIT_CONTACT +import tech.relaycorp.letro.contacts.ManageContactViewModel.Type.Companion.NEW_CONTACT +import tech.relaycorp.letro.contacts.model.Contact +import tech.relaycorp.letro.contacts.model.ContactPairingStatus +import tech.relaycorp.letro.contacts.storage.ContactsRepository +import tech.relaycorp.letro.ui.navigation.Route +import tech.relaycorp.letro.utils.ext.nullIfBlankOrEmpty +import javax.inject.Inject + +@HiltViewModel +class ManageContactViewModel @Inject constructor( + private val contactsRepository: ContactsRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + @Type + private val screenType: Int = savedStateHandle[Route.ManageContact.KEY_SCREEN_TYPE]!! + private val currentAccountId: String? = savedStateHandle[Route.ManageContact.KEY_CURRENT_ACCOUNT_ID] + private val contactIdToEdit: Long? = savedStateHandle[Route.ManageContact.KEY_CONTACT_ID_TO_EDIT] + + private val _uiState = MutableStateFlow( + PairWithOthersUiState( + manageContactTexts = when (screenType) { + NEW_CONTACT -> ManageContactTexts.PairWithOthers() + EDIT_CONTACT -> ManageContactTexts.EditContact() + else -> throw IllegalStateException("Unknown screen type: $screenType") + }, + ), + ) + val uiState: StateFlow + get() = _uiState + + private val _onActionCompleted = MutableSharedFlow() + val onActionCompleted: SharedFlow + get() = _onActionCompleted + + private val contacts: HashSet = hashSetOf() + + private var editingContact: Contact? = null + + init { + viewModelScope.launch { + contactIdToEdit?.let { id -> + contactsRepository.getContactById(id)?.let { contactToEdit -> + editingContact = contactToEdit + _uiState.update { + it.copy( + veraId = contactToEdit.contactVeraId, + alias = contactToEdit.alias, + isVeraIdInputEnabled = false, + ) + } + } + } + } + viewModelScope.launch { + currentAccountId?.let { currentAccountId -> + contactsRepository.getContacts(currentAccountId).collect { + contacts.clear() + contacts.addAll(it) + } + } + } + } + + fun onIdChanged(id: String) { + viewModelScope.launch { + val trimmedId = id.trim() + _uiState.update { + it.copy( + veraId = trimmedId, + isSentRequestAgainHintVisible = contacts.any { it.contactVeraId == trimmedId && it.status == ContactPairingStatus.REQUEST_SENT }, + pairingErrorCaption = getPairingErrorMessage(trimmedId), + ) + } + } + } + + fun onAliasChanged(alias: String) { + viewModelScope.launch { + _uiState.update { + it.copy( + alias = alias, + ) + } + } + } + + fun onActionButtonClick() { + when (screenType) { + NEW_CONTACT -> sendNewContactRequest() + EDIT_CONTACT -> updateContact() + else -> throw IllegalStateException("Unknown screen type: $screenType") + } + viewModelScope.launch { + _onActionCompleted.emit(uiState.value.veraId) + } + } + + private fun updateContact() { + editingContact?.let { editingContact -> + contactsRepository.updateContact( + editingContact.copy( + alias = uiState.value.alias?.nullIfBlankOrEmpty(), + ), + ) + } + } + + private fun sendNewContactRequest() { + currentAccountId?.let { currentAccountId -> + contactsRepository.addNewContact( + contact = Contact( + ownerVeraId = currentAccountId, + contactVeraId = uiState.value.veraId, + alias = uiState.value.alias?.nullIfBlankOrEmpty(), + status = ContactPairingStatus.REQUEST_SENT, + ), + ) + } + } + + private fun getPairingErrorMessage(contactId: String): PairingErrorCaption? { + val contact = contacts.find { it.contactVeraId == contactId } + return when { + contact == null -> null + contact.status == ContactPairingStatus.COMPLETED -> PairingErrorCaption(R.string.pair_request_already_paired) + contact.status >= ContactPairingStatus.MATCH -> PairingErrorCaption(R.string.pair_request_already_in_progress) + else -> null + } + } + + @IntDef(NEW_CONTACT, EDIT_CONTACT) + annotation class Type { + companion object { + const val NEW_CONTACT = 0 + const val EDIT_CONTACT = 1 + } + } +} + +data class PairWithOthersUiState( + val manageContactTexts: ManageContactTexts, + val veraId: String = "", + val alias: String? = null, + val isSentRequestAgainHintVisible: Boolean = false, + val isVeraIdInputEnabled: Boolean = true, + val pairingErrorCaption: PairingErrorCaption? = null, +) + +@Immutable +sealed class ManageContactTexts( + @StringRes val title: Int, + @StringRes val button: Int, +) { + class PairWithOthers : ManageContactTexts( + title = R.string.general_pair_with_others, + button = R.string.onboarding_pair_with_people_button, + ) + + class EditContact : ManageContactTexts( + title = R.string.edit_name, + button = R.string.save_changes, + ) +} + +data class PairingErrorCaption( + @StringRes val message: Int, +) diff --git a/app/src/main/java/tech/relaycorp/letro/contacts/storage/ContactsDao.kt b/app/src/main/java/tech/relaycorp/letro/contacts/storage/ContactsDao.kt index e82a591d..49d4b288 100644 --- a/app/src/main/java/tech/relaycorp/letro/contacts/storage/ContactsDao.kt +++ b/app/src/main/java/tech/relaycorp/letro/contacts/storage/ContactsDao.kt @@ -1,6 +1,7 @@ package tech.relaycorp.letro.contacts.storage import androidx.room.Dao +import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query @@ -28,4 +29,7 @@ interface ContactsDao { @Query("SELECT * FROM $TABLE_NAME_CONTACTS WHERE ownerVeraId = :accountVeraId") fun getContactsForAccount(accountVeraId: String): Flow> + + @Delete + suspend fun deleteContact(contact: Contact) } diff --git a/app/src/main/java/tech/relaycorp/letro/contacts/storage/ContactsRepository.kt b/app/src/main/java/tech/relaycorp/letro/contacts/storage/ContactsRepository.kt index de7a4761..a6ff876b 100644 --- a/app/src/main/java/tech/relaycorp/letro/contacts/storage/ContactsRepository.kt +++ b/app/src/main/java/tech/relaycorp/letro/contacts/storage/ContactsRepository.kt @@ -1,10 +1,11 @@ package tech.relaycorp.letro.contacts.storage +import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import tech.relaycorp.letro.account.model.Account @@ -15,13 +16,17 @@ import tech.relaycorp.letro.awala.message.MessageRecipient import tech.relaycorp.letro.awala.message.MessageType import tech.relaycorp.letro.contacts.model.Contact import tech.relaycorp.letro.contacts.model.ContactPairingStatus +import tech.relaycorp.letro.main.MainViewModel import javax.inject.Inject interface ContactsRepository { val isPairedContactsExist: Flow fun getContacts(ownerVeraId: String): Flow> + fun getContactById(id: Long): Contact? fun addNewContact(contact: Contact) + fun deleteContact(contact: Contact) + fun updateContact(contact: Contact) } class ContactsRepositoryImpl @Inject constructor( @@ -35,7 +40,7 @@ class ContactsRepositoryImpl @Inject constructor( private var currentAccount: Account? = null private val _isPairedContactsExist: MutableStateFlow = MutableStateFlow(false) - override val isPairedContactsExist: StateFlow + override val isPairedContactsExist: SharedFlow get() = _isPairedContactsExist init { @@ -43,7 +48,9 @@ class ContactsRepositoryImpl @Inject constructor( contactsDao.getAll().collect { contacts.emit(it) startCollectAccountFlow() - updatePairedContactExist(currentAccount) + if (currentAccount != null) { + updatePairedContactExist(currentAccount) + } } } } @@ -53,6 +60,10 @@ class ContactsRepositoryImpl @Inject constructor( .map { it.filter { it.ownerVeraId == ownerVeraId } } } + override fun getContactById(id: Long): Contact? { + return contacts.value.find { it.id == id } + } + override fun addNewContact(contact: Contact) { scope.launch { val existingContact = contactsDao.getContact( @@ -85,6 +96,18 @@ class ContactsRepositoryImpl @Inject constructor( } } + override fun deleteContact(contact: Contact) { + scope.launch { + contactsDao.deleteContact(contact) + } + } + + override fun updateContact(contact: Contact) { + scope.launch { + contactsDao.update(contact) + } + } + private fun startCollectAccountFlow() { scope.launch { accountRepository.currentAccount.collect { @@ -95,6 +118,7 @@ class ContactsRepositoryImpl @Inject constructor( } private suspend fun updatePairedContactExist(account: Account?) { + Log.d(MainViewModel.TAG, "ContactsRepository.emit(pairedContactExist)") account ?: run { _isPairedContactsExist.emit(false) return 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 new file mode 100644 index 00000000..8760e1fa --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/contacts/ui/ContactsScreen.kt @@ -0,0 +1,256 @@ +package tech.relaycorp.letro.contacts.ui + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.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.material3.AlertDialog +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SnackbarHostState +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.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 tech.relaycorp.letro.R +import tech.relaycorp.letro.contacts.ContactsViewModel +import tech.relaycorp.letro.contacts.model.Contact +import tech.relaycorp.letro.ui.common.text.BoldText +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 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContactsScreen( + viewModel: ContactsViewModel, + snackbarHostState: SnackbarHostState, + snackbarStringsProvider: SnackbarStringsProvider, + onEditContactClick: (Contact) -> Unit, +) { + val contacts by viewModel.contacts.collectAsState() + val editContactBottomSheetState by viewModel.editContactBottomSheetState.collectAsState() + val deleteContactDialogState by viewModel.deleteContactDialogState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.showContactDeletedSnackbarSignal.collect { + snackbarHostState.showSnackbar( + message = snackbarStringsProvider.contactDeleted, + ) + } + } + + val editBottomSheet = editContactBottomSheetState + val deleteContactDialog = deleteContactDialogState + Box { + if (editBottomSheet.isShown && editBottomSheet.contact != null) { + ModalBottomSheet( + onDismissRequest = { viewModel.onEditBottomSheetDismissed() }, + ) { + EditContactBottomSheet( + title = editBottomSheet.contact.alias ?: editBottomSheet.contact.contactVeraId, + onEditClick = { + viewModel.onEditContactClick() + onEditContactClick(editBottomSheet.contact) + }, + onDeleteClick = { + viewModel.onDeleteContactClick(editBottomSheet.contact) + }, + ) + } + } else if (deleteContactDialog.isShown && deleteContactDialog.contact != null) { + AlertDialog( + onDismissRequest = { + viewModel.onDeleteContactDialogDismissed() + }, + title = { + Text( + text = stringResource(id = R.string.delete_contact_dialog_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + }, + text = { + BoldText( + fullText = stringResource(id = R.string.delete_contact_dialog_message, deleteContactDialog.contact.contactVeraId), + boldParts = listOf(deleteContactDialog.contact.contactVeraId), + textStyle = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton( + onClick = { + viewModel.onConfirmDeletingContactClick(deleteContactDialog.contact) + }, + ) { + Text( + text = stringResource(id = R.string.delete), + style = MaterialTheme.typography.LargeProminent, + color = MaterialTheme.colorScheme.primary, + ) + } + }, + dismissButton = { + TextButton( + onClick = { + viewModel.onDeleteContactDialogDismissed() + }, + ) { + Text( + text = stringResource(id = R.string.cancel), + style = MaterialTheme.typography.LargeProminent, + color = MaterialTheme.colorScheme.primary, + ) + } + }, + ) + } + LazyColumn( + contentPadding = PaddingValues( + top = 8.dp, + ), + ) { + items(contacts.size) { index -> + ContactView( + contact = contacts[index], + onActionsButtonClick = { viewModel.onActionsButtonClick(contacts[index]) }, + ) + } + } + } +} + +@Composable +fun ContactView( + contact: Contact, + onActionsButtonClick: () -> Unit, +) { + Box( + modifier = Modifier + .padding( + vertical = if (contact.alias == null) 16.dp else 10.dp, + horizontal = 16.dp, + ) + .fillMaxWidth(), + ) { + Column( + verticalArrangement = Arrangement.Center, + ) { + if (contact.alias != null) { + Text( + text = contact.alias, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleMedium, + ) + } + Text( + text = contact.contactVeraId, + color = MaterialTheme.colorScheme.onSurface, + style = if (contact.alias == null) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyMedium, + ) + } + Icon( + painter = painterResource(id = R.drawable.ic_more), + contentDescription = stringResource(id = R.string.icon_more_content_description), + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .align(Alignment.CenterEnd) + .clickable { onActionsButtonClick() }, + ) + } +} + +@Composable +private fun EditContactBottomSheet( + title: String, + onEditClick: () -> Unit, + onDeleteClick: () -> Unit, +) { + Column( + modifier = Modifier.padding( + PaddingValues( + bottom = 44.dp, + ), + ), + ) { + Text( + text = title, + style = MaterialTheme.typography.SmallProminent, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + ), + ) + Spacer( + modifier = Modifier.height(14.dp), + ) + Divider( + color = MaterialTheme.colorScheme.outlineVariant, + ) + EditContactAction( + icon = R.drawable.edit, + title = R.string.edit, + onClick = onEditClick, + ) + EditContactAction( + icon = R.drawable.ic_delete, + title = R.string.delete, + onClick = onDeleteClick, + ) + } +} + +@Composable +private fun EditContactAction( + @DrawableRes icon: Int, + @StringRes title: Int, + onClick: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .padding( + vertical = 14.dp, + horizontal = 16.dp, + ), + ) { + Icon( + painter = painterResource(id = icon), + contentDescription = null, + tint = LetroColor.OnSurfaceContainer, + ) + Spacer( + modifier = Modifier.width(16.dp), + ) + Text( + text = stringResource(id = title), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} diff --git a/app/src/main/java/tech/relaycorp/letro/contacts/ui/ContactsScreenOverlayFloatingMenu.kt b/app/src/main/java/tech/relaycorp/letro/contacts/ui/ContactsScreenOverlayFloatingMenu.kt new file mode 100644 index 00000000..b33c105f --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/contacts/ui/ContactsScreenOverlayFloatingMenu.kt @@ -0,0 +1,83 @@ +package tech.relaycorp.letro.contacts.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +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 tech.relaycorp.letro.R +import tech.relaycorp.letro.home.HomeViewModel +import tech.relaycorp.letro.ui.common.LetroButton +import tech.relaycorp.letro.ui.theme.FloatingActionButtonPadding +import tech.relaycorp.letro.ui.theme.LetroColor + +@Composable +fun ContactsScreenOverlayFloatingMenu( + homeViewModel: HomeViewModel, + onShareIdClick: () -> Unit, + onPairWithOthersClick: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(LetroColor.FoggingBackgroundColor), + ) { + Column( + horizontalAlignment = Alignment.End, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(FloatingActionButtonPadding), + ) { + LetroButton( + text = stringResource(id = R.string.onboarding_account_confirmation_share_your_id), + contentPadding = PaddingValues( + start = 16.dp, + end = 24.dp, + ), + leadingIconResId = R.drawable.ic_share, + onClick = { onShareIdClick() }, + ) + Spacer( + modifier = Modifier.height(16.dp), + ) + LetroButton( + text = stringResource(id = R.string.general_pair_with_others), + contentPadding = PaddingValues( + start = 16.dp, + end = 24.dp, + ), + leadingIconResId = R.drawable.ic_pair, + onClick = { onPairWithOthersClick() }, + ) + Spacer( + modifier = Modifier.height(16.dp), + ) + FloatingActionButton( + onClick = { homeViewModel.onFloatingActionButtonClick() }, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_close), + contentDescription = stringResource( + id = R.string.floating_action_button_close_content_description, + ), + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } + } +} diff --git a/app/src/main/java/tech/relaycorp/letro/pairing/ui/PairWithOthersScreen.kt b/app/src/main/java/tech/relaycorp/letro/contacts/ui/ManageContactScreen.kt similarity index 85% rename from app/src/main/java/tech/relaycorp/letro/pairing/ui/PairWithOthersScreen.kt rename to app/src/main/java/tech/relaycorp/letro/contacts/ui/ManageContactScreen.kt index 37dff129..472e7f17 100644 --- a/app/src/main/java/tech/relaycorp/letro/pairing/ui/PairWithOthersScreen.kt +++ b/app/src/main/java/tech/relaycorp/letro/contacts/ui/ManageContactScreen.kt @@ -1,4 +1,4 @@ -package tech.relaycorp.letro.pairing.ui +package tech.relaycorp.letro.contacts.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -22,7 +22,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import tech.relaycorp.letro.R -import tech.relaycorp.letro.pairing.PairWithOthersViewModel +import tech.relaycorp.letro.contacts.ManageContactViewModel import tech.relaycorp.letro.ui.common.LetroButtonMaxWidthFilled import tech.relaycorp.letro.ui.common.LetroInfoView import tech.relaycorp.letro.ui.common.LetroOutlinedTextField @@ -30,14 +30,16 @@ import tech.relaycorp.letro.ui.theme.HorizontalScreenPadding import tech.relaycorp.letro.ui.theme.LetroTheme @Composable -fun PairWithOthersScreen( +fun ManageContactScreen( onBackClick: () -> Unit, - viewModel: PairWithOthersViewModel = hiltViewModel(), + onActionCompleted: (String) -> Unit, + viewModel: ManageContactViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsState() + LaunchedEffect(Unit) { - viewModel.backSignal.collect { - onBackClick() + viewModel.onActionCompleted.collect { + onActionCompleted(it) } } @@ -66,7 +68,7 @@ fun PairWithOthersScreen( modifier = Modifier.width(16.dp), ) Text( - text = stringResource(id = R.string.general_pair_with_others), + text = stringResource(id = uiState.manageContactTexts.title), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) @@ -75,11 +77,12 @@ fun PairWithOthersScreen( modifier = Modifier.height(8.dp), ) LetroOutlinedTextField( - value = uiState.id, + value = uiState.veraId, onValueChange = viewModel::onIdChanged, label = R.string.general_id, hintText = stringResource(id = R.string.new_contact_id_hint), isError = errorCaption != null, + isEnabled = uiState.isVeraIdInputEnabled, ) { if (errorCaption != null) { Spacer(modifier = Modifier.height(6.dp)) @@ -106,7 +109,7 @@ fun PairWithOthersScreen( modifier = Modifier.height(24.dp), ) LetroOutlinedTextField( - value = uiState.alias, + value = uiState.alias ?: "", onValueChange = viewModel::onAliasChanged, label = R.string.onboarding_pair_with_people_alias, hintText = stringResource(id = R.string.new_contact_alias_hint), @@ -115,8 +118,8 @@ fun PairWithOthersScreen( modifier = Modifier.height(32.dp), ) LetroButtonMaxWidthFilled( - text = stringResource(id = R.string.onboarding_pair_with_people_button), - onClick = { viewModel.onPairRequestClick() }, + text = stringResource(id = uiState.manageContactTexts.button), + onClick = { viewModel.onActionButtonClick() }, isEnabled = errorCaption == null, ) } @@ -126,8 +129,9 @@ fun PairWithOthersScreen( @Composable private fun UseExistingAccountPreview() { LetroTheme { - PairWithOthersScreen( + ManageContactScreen( onBackClick = {}, + onActionCompleted = {}, viewModel = hiltViewModel(), ) } diff --git a/app/src/main/java/tech/relaycorp/letro/di/MainModule.kt b/app/src/main/java/tech/relaycorp/letro/di/MainModule.kt new file mode 100644 index 00000000..cd4e2a04 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/di/MainModule.kt @@ -0,0 +1,18 @@ +package tech.relaycorp.letro.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import tech.relaycorp.letro.ui.utils.SnackbarStringsProvider +import tech.relaycorp.letro.ui.utils.SnackbarStringsProviderImpl + +@Module +@InstallIn(ActivityComponent::class) +interface MainModule { + + @Binds + fun bindSnackbarStringsProvider( + impl: SnackbarStringsProviderImpl, + ): SnackbarStringsProvider +} diff --git a/app/src/main/java/tech/relaycorp/letro/home/HomeFloatingActionButtonConfig.kt b/app/src/main/java/tech/relaycorp/letro/home/HomeFloatingActionButtonConfig.kt new file mode 100644 index 00000000..6ed2b012 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/home/HomeFloatingActionButtonConfig.kt @@ -0,0 +1,21 @@ +package tech.relaycorp.letro.home + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import tech.relaycorp.letro.R + +sealed class HomeFloatingActionButtonConfig( + @DrawableRes val icon: Int, + @StringRes val contentDescription: Int, +) { + + object ChatListFloatingActionButtonConfig : HomeFloatingActionButtonConfig( + icon = R.drawable.pencil, + contentDescription = R.string.floating_action_button_write_new_message_content_description, + ) + + object ContactsFloatingActionButtonConfig : HomeFloatingActionButtonConfig( + icon = R.drawable.ic_plus, + contentDescription = R.string.floating_action_button_add_contact_content_description, + ) +} diff --git a/app/src/main/java/tech/relaycorp/letro/home/HomeScreen.kt b/app/src/main/java/tech/relaycorp/letro/home/HomeScreen.kt new file mode 100644 index 00000000..13a3dd45 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/home/HomeScreen.kt @@ -0,0 +1,51 @@ +package tech.relaycorp.letro.home + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +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.ui.utils.SnackbarStringsProvider + +@Composable +fun HomeScreen( + homeViewModel: HomeViewModel, + snackbarStringsProvider: SnackbarStringsProvider, + onEditContactClick: (Contact) -> Unit, + snackbarHostState: SnackbarHostState, + contactsViewModel: ContactsViewModel = hiltViewModel(), +) { + val uiState by homeViewModel.uiState.collectAsState() + + Box { + Column { + LetroTabs( + viewModel = homeViewModel, + ) + Box( + modifier = Modifier.fillMaxSize(), + ) { + when (uiState.currentTab) { + TAB_CHATS -> Column { + } + TAB_CONTACTS -> ContactsScreen( + viewModel = contactsViewModel, + snackbarHostState = snackbarHostState, + snackbarStringsProvider = snackbarStringsProvider, + onEditContactClick = { onEditContactClick(it) }, + ) + TAB_NOTIFICATIONS -> Column { + } + else -> throw IllegalStateException("Unsupported tab with index ${uiState.currentTab}") + } + } + } + } +} diff --git a/app/src/main/java/tech/relaycorp/letro/home/HomeViewModel.kt b/app/src/main/java/tech/relaycorp/letro/home/HomeViewModel.kt new file mode 100644 index 00000000..5a11538e --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/home/HomeViewModel.kt @@ -0,0 +1,73 @@ +package tech.relaycorp.letro.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class HomeViewModel @Inject constructor() : ViewModel() { + + private val _uiState = MutableStateFlow(HomeUiState()) + val uiState: StateFlow + get() = _uiState + + fun onTabClick(index: Int) { + viewModelScope.launch { + _uiState.update { + it.copy( + currentTab = index, + floatingActionButtonConfig = getFloatingActionButtonConfig(index), + ) + } + } + } + + fun onOptionFromContactsFloatingMenuClicked() { + viewModelScope.launch { + _uiState.update { + it.copy( + isAddContactFloatingMenuVisible = false, + ) + } + } + } + + fun onFloatingActionButtonClick() { + viewModelScope.launch { + when (uiState.value.currentTab) { + TAB_CHATS -> { + // TODO: compose a new message + } + TAB_CONTACTS -> { + _uiState.update { + it.copy( + isAddContactFloatingMenuVisible = !it.isAddContactFloatingMenuVisible, + ) + } + } + } + } + } + + private fun getFloatingActionButtonConfig(tabIndex: Int) = when (tabIndex) { + TAB_CHATS -> HomeFloatingActionButtonConfig.ChatListFloatingActionButtonConfig + TAB_CONTACTS -> HomeFloatingActionButtonConfig.ContactsFloatingActionButtonConfig + TAB_NOTIFICATIONS -> null + else -> throw IllegalStateException("Unsupported tab with index $tabIndex") + } +} + +data class HomeUiState( + val currentTab: Int = TAB_CHATS, + val floatingActionButtonConfig: HomeFloatingActionButtonConfig? = HomeFloatingActionButtonConfig.ChatListFloatingActionButtonConfig, + val isAddContactFloatingMenuVisible: Boolean = false, +) + +const val TAB_CHATS = 0 +const val TAB_CONTACTS = 1 +const val TAB_NOTIFICATIONS = 2 diff --git a/app/src/main/java/tech/relaycorp/letro/home/LetroTabs.kt b/app/src/main/java/tech/relaycorp/letro/home/LetroTabs.kt new file mode 100644 index 00000000..3376d637 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/home/LetroTabs.kt @@ -0,0 +1,313 @@ +package tech.relaycorp.letro.home + +import android.annotation.SuppressLint +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +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 = MaterialTheme.colorScheme.primary, + 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 +fun ScrollableTabRow( + selectedTabIndex: Int, + modifier: Modifier = Modifier, + containerColor: Color = TabRowDefaults.containerColor, + contentColor: Color = TabRowDefaults.contentColor, + tabPaddingEnd: Dp = 8.dp, + edgePadding: Dp = 9.dp, + indicator: @Composable (tabPositions: List) -> Unit = @Composable { tabPositions -> + TabRowDefaults.Indicator( + Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]), + ) + }, + divider: @Composable () -> Unit = @Composable { + Divider() + }, + tabs: @Composable () -> Unit, +) { + Surface( + modifier = modifier, + color = containerColor, + contentColor = contentColor, + ) { + val scrollState = rememberScrollState() + val coroutineScope = rememberCoroutineScope() + val scrollableTabData = remember(scrollState, coroutineScope) { + ScrollableTabData( + scrollState = scrollState, + coroutineScope = coroutineScope, + ) + } + SubcomposeLayout( + Modifier + .fillMaxWidth() + .wrapContentSize(align = Alignment.CenterStart) + .horizontalScroll(scrollState) + .selectableGroup() + .clipToBounds(), + ) { constraints -> + val minTabWidth = ScrollableTabRowMinimumTabWidth.roundToPx() + val padding = edgePadding.roundToPx() + + val tabMeasurables = subcompose(TabSlots.Tabs, tabs) + + val layoutHeight = tabMeasurables.fold(initial = 0) { curr, measurable -> + maxOf(curr, measurable.maxIntrinsicHeight(Constraints.Infinity)) + } + + val tabConstraints = constraints.copy( + minWidth = minTabWidth, + minHeight = layoutHeight, + maxHeight = layoutHeight, + ) + val tabPlaceables = tabMeasurables + .map { it.measure(tabConstraints) } + + val layoutWidth = tabPlaceables.fold(initial = padding * 2) { curr, measurable -> + curr + measurable.width + tabPaddingEnd.value.toInt() + } + + // Position the children. + layout(layoutWidth, layoutHeight) { + // Place the tabs + val tabPositions = mutableListOf() + var left = padding + tabPlaceables.forEach { + it.placeRelative(left, 0) + tabPositions.add(TabPosition(left = left.toDp(), width = it.width.toDp())) + left += it.width + tabPaddingEnd.value.toInt() + } + + // The divider is measured with its own height, and width equal to the total width + // of the tab row, and then placed on top of the tabs. + subcompose(TabSlots.Divider, divider).forEach { + val placeable = it.measure( + constraints.copy( + minHeight = 0, + minWidth = layoutWidth, + maxWidth = layoutWidth, + ), + ) + placeable.placeRelative(0, layoutHeight - placeable.height) + } + + // The indicator container is measured to fill the entire space occupied by the tab + // row, and then placed on top of the divider. + subcompose(TabSlots.Indicator) { + indicator(tabPositions) + }.forEach { + it.measure(Constraints.fixed(layoutWidth, layoutHeight)).placeRelative(0, 0) + } + + scrollableTabData.onLaidOut( + density = this@SubcomposeLayout, + edgeOffset = padding, + tabPositions = tabPositions, + selectedTab = selectedTabIndex, + ) + } + } + } +} + +private class ScrollableTabData( + private val scrollState: ScrollState, + private val coroutineScope: CoroutineScope, +) { + private var selectedTab: Int? = null + + fun onLaidOut( + density: Density, + edgeOffset: Int, + tabPositions: List, + selectedTab: Int, + ) { + // Animate if the new tab is different from the old tab, or this is called for the first + // time (i.e selectedTab is `null`). + if (this.selectedTab != selectedTab) { + this.selectedTab = selectedTab + tabPositions.getOrNull(selectedTab)?.let { + // Scrolls to the tab with [tabPosition], trying to place it in the center of the + // screen or as close to the center as possible. + val calculatedOffset = it.calculateTabOffset(density, edgeOffset, tabPositions) + if (scrollState.value != calculatedOffset) { + coroutineScope.launch { + scrollState.animateScrollTo( + calculatedOffset, + animationSpec = ScrollableTabRowScrollSpec, + ) + } + } + } + } + } + + /** + * @return the offset required to horizontally center the tab inside this TabRow. + * If the tab is at the start / end, and there is not enough space to fully centre the tab, this + * will just clamp to the min / max position given the max width. + */ + private fun TabPosition.calculateTabOffset( + density: Density, + edgeOffset: Int, + tabPositions: List, + ): Int = with(density) { + val totalTabRowWidth = tabPositions.last().right.roundToPx() + edgeOffset + val visibleWidth = totalTabRowWidth - scrollState.maxValue + val tabOffset = left.roundToPx() + val scrollerCenter = visibleWidth / 2 + val tabWidth = width.roundToPx() + val centeredTabOffset = tabOffset - (scrollerCenter - tabWidth / 2) + // How much space we have to scroll. If the visible width is <= to the total width, then + // we have no space to scroll as everything is always visible. + val availableSpace = (totalTabRowWidth - visibleWidth).coerceAtLeast(0) + return centeredTabOffset.coerceIn(0, availableSpace) + } +} + +private val ScrollableTabRowScrollSpec: AnimationSpec = tween( + durationMillis = 250, + easing = FastOutSlowInEasing, +) + +private enum class TabSlots { + Tabs, + Divider, + Indicator, +} + +private val ScrollableTabRowMinimumTabWidth = 76.dp + +@Immutable +class TabPosition internal constructor(val left: Dp, val width: Dp) { + val right: Dp get() = left + width + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is TabPosition) return false + + if (left != other.left) return false + if (width != other.width) return false + + return true + } + + override fun hashCode(): Int { + var result = left.hashCode() + result = 31 * result + width.hashCode() + return result + } + + override fun toString(): String { + return "TabPosition(left=$left, right=$right, width=$width)" + } +} + +fun Modifier.tabIndicatorOffset( + currentTabPosition: TabPosition, +): Modifier = composed( + inspectorInfo = debugInspectorInfo { + name = "tabIndicatorOffset" + value = currentTabPosition + }, +) { + val currentTabWidth by animateDpAsState( + targetValue = currentTabPosition.width, + animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing), + ) + val indicatorOffset by animateDpAsState( + targetValue = currentTabPosition.left, + animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing), + ) + fillMaxWidth() + .wrapContentSize(Alignment.BottomStart) + .offset(x = indicatorOffset) + .width(currentTabWidth) +} diff --git a/app/src/main/java/tech/relaycorp/letro/main/MainViewModel.kt b/app/src/main/java/tech/relaycorp/letro/main/MainViewModel.kt index 54239e32..05ee2214 100644 --- a/app/src/main/java/tech/relaycorp/letro/main/MainViewModel.kt +++ b/app/src/main/java/tech/relaycorp/letro/main/MainViewModel.kt @@ -9,9 +9,9 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import tech.relaycorp.letro.account.model.Account @@ -77,6 +77,7 @@ class MainViewModel @Inject constructor( Pair(currentAccount, isPairedContactExist) } .distinctUntilChanged() + .onStart { Log.d(TAG, "Start collecting the combined Flow") } .collect { val currentAccount = it.first val isPairedContactExist = it.second @@ -95,7 +96,7 @@ class MainViewModel @Inject constructor( } isRegistration = false } - isPairedContactExist -> _rootNavigationScreen.emit(RootNavigationScreen.Conversations) + isPairedContactExist -> _rootNavigationScreen.emit(RootNavigationScreen.Home) } } else { _rootNavigationScreen.emit(RootNavigationScreen.Registration) @@ -125,8 +126,8 @@ class MainViewModel @Inject constructor( } } - private companion object { - private const val TAG = "MainViewModel" + companion object { + const val TAG = "MainViewModel" private const val AWALA_GOOGLE_PLAY_LINK = "https://play.google.com/store/apps/details?id=tech.relaycorp.gateway" } } diff --git a/app/src/main/java/tech/relaycorp/letro/onboarding/actionTaking/ActionTakingScreen.kt b/app/src/main/java/tech/relaycorp/letro/onboarding/actionTaking/ActionTakingScreen.kt index 8b5967cd..764fbd6c 100644 --- a/app/src/main/java/tech/relaycorp/letro/onboarding/actionTaking/ActionTakingScreen.kt +++ b/app/src/main/java/tech/relaycorp/letro/onboarding/actionTaking/ActionTakingScreen.kt @@ -14,10 +14,12 @@ 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.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import tech.relaycorp.letro.ui.common.ButtonType import tech.relaycorp.letro.ui.common.LetroButtonMaxWidthFilled +import tech.relaycorp.letro.ui.common.text.BoldText import tech.relaycorp.letro.ui.theme.HorizontalScreenPadding import tech.relaycorp.letro.ui.theme.LetroTheme @@ -46,16 +48,30 @@ fun ActionTakingScreen( Text( text = stringResource(id = actionTakingScreenUIStateModel.titleStringRes), style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, ) } if (actionTakingScreenUIStateModel.messageStringRes != null) { Spacer( modifier = Modifier.height(24.dp), ) - Text( - text = stringResource(id = actionTakingScreenUIStateModel.messageStringRes), - style = MaterialTheme.typography.bodyLarge, - ) + val boldPartOfMessage = actionTakingScreenUIStateModel.boldPartOfMessage + if (boldPartOfMessage == null) { + Text( + text = stringResource(id = actionTakingScreenUIStateModel.messageStringRes), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } else { + BoldText( + fullText = stringResource( + id = actionTakingScreenUIStateModel.messageStringRes, + boldPartOfMessage, + ), + boldParts = listOf(boldPartOfMessage), + textAlign = TextAlign.Center, + ) + } } if (actionTakingScreenUIStateModel.buttonFilledStringRes != null) { Spacer( diff --git a/app/src/main/java/tech/relaycorp/letro/onboarding/actionTaking/ActionTakingScreenUIStateModel.kt b/app/src/main/java/tech/relaycorp/letro/onboarding/actionTaking/ActionTakingScreenUIStateModel.kt index 639ee76a..b19f0a43 100644 --- a/app/src/main/java/tech/relaycorp/letro/onboarding/actionTaking/ActionTakingScreenUIStateModel.kt +++ b/app/src/main/java/tech/relaycorp/letro/onboarding/actionTaking/ActionTakingScreenUIStateModel.kt @@ -8,6 +8,7 @@ sealed class ActionTakingScreenUIStateModel( @StringRes val titleStringRes: Int?, @DrawableRes val image: Int, @StringRes val messageStringRes: Int? = null, + val boldPartOfMessage: String? = null, @StringRes val buttonFilledStringRes: Int? = null, @StringRes val buttonOutlinedStringRes: Int? = null, val onButtonFilledClicked: () -> Unit = {}, @@ -17,7 +18,7 @@ sealed class ActionTakingScreenUIStateModel( class NoContacts( @DrawableRes image: Int, onPairWithOthersClick: () -> Unit, - onShareId: () -> Unit, + onShareIdClick: () -> Unit, @StringRes title: Int? = null, @StringRes message: Int? = null, ) : ActionTakingScreenUIStateModel( @@ -27,7 +28,7 @@ sealed class ActionTakingScreenUIStateModel( buttonFilledStringRes = R.string.general_pair_with_others, buttonOutlinedStringRes = R.string.onboarding_account_confirmation_share_your_id, onButtonFilledClicked = onPairWithOthersClick, - onButtonOutlinedClicked = onShareId, + onButtonOutlinedClicked = onShareIdClick, ) object RegistrationWaiting : ActionTakingScreenUIStateModel( @@ -37,11 +38,13 @@ sealed class ActionTakingScreenUIStateModel( ) class PairingRequestSent( + boldPartOfMessage: String? = null, onGotItClicked: () -> Unit, ) : ActionTakingScreenUIStateModel( titleStringRes = R.string.onboarding_pairing_request_sent_title, image = R.drawable.pairing_request_sent, messageStringRes = R.string.onboarding_pairing_request_sent_message, + boldPartOfMessage = boldPartOfMessage, buttonFilledStringRes = R.string.onboarding_pairing_request_sent_button, onButtonFilledClicked = onGotItClicked, ) diff --git a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/ui/RegistrationScreen.kt b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/ui/RegistrationScreen.kt index 08dc45da..06f286ac 100644 --- a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/ui/RegistrationScreen.kt +++ b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/ui/RegistrationScreen.kt @@ -25,10 +25,10 @@ import androidx.hilt.navigation.compose.hiltViewModel import tech.relaycorp.letro.R import tech.relaycorp.letro.onboarding.registration.RegistrationViewModel import tech.relaycorp.letro.ui.common.ButtonType -import tech.relaycorp.letro.ui.common.HyperlinkText import tech.relaycorp.letro.ui.common.LetroButtonMaxWidthFilled import tech.relaycorp.letro.ui.common.LetroInfoView import tech.relaycorp.letro.ui.common.LetroOutlinedTextField +import tech.relaycorp.letro.ui.common.text.HyperlinkText import tech.relaycorp.letro.ui.theme.HorizontalScreenPadding import tech.relaycorp.letro.ui.theme.LetroTheme diff --git a/app/src/main/java/tech/relaycorp/letro/pairing/PairWithOthersViewModel.kt b/app/src/main/java/tech/relaycorp/letro/pairing/PairWithOthersViewModel.kt deleted file mode 100644 index c0ca8cb2..00000000 --- a/app/src/main/java/tech/relaycorp/letro/pairing/PairWithOthersViewModel.kt +++ /dev/null @@ -1,113 +0,0 @@ -package tech.relaycorp.letro.pairing - -import androidx.annotation.StringRes -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import tech.relaycorp.letro.R -import tech.relaycorp.letro.contacts.model.Contact -import tech.relaycorp.letro.contacts.model.ContactPairingStatus -import tech.relaycorp.letro.contacts.storage.ContactsRepository -import javax.inject.Inject - -@HiltViewModel -class PairWithOthersViewModel @Inject constructor( - private val contactsRepository: ContactsRepository, - savedStateHandle: SavedStateHandle, -) : ViewModel() { - - private val currentAccountId: String? = savedStateHandle[KEY_CURRENT_ACCOUNT_ID] - - private val _uiState = MutableStateFlow(PairWithOthersUiState()) - val uiState: StateFlow - get() = _uiState - - private val _backSignal = MutableSharedFlow() - val backSignal: SharedFlow - get() = _backSignal - - private val contacts: HashSet = hashSetOf() - - init { - viewModelScope.launch { - currentAccountId?.let { currentAccountId -> - contactsRepository.getContacts(currentAccountId).collect { - contacts.clear() - contacts.addAll(it) - } - } - } - } - - fun onIdChanged(id: String) { - viewModelScope.launch { - val trimmedId = id.trim() - _uiState.update { - it.copy( - id = trimmedId, - isSentRequestAgainHintVisible = contacts.any { it.contactVeraId == trimmedId && it.status == ContactPairingStatus.REQUEST_SENT }, - pairingErrorCaption = getPairingErrorMessage(trimmedId), - ) - } - } - } - - fun onAliasChanged(alias: String) { - viewModelScope.launch { - _uiState.update { - it.copy( - alias = alias, - ) - } - } - } - - fun onPairRequestClick() { - currentAccountId?.let { currentAccountId -> - contactsRepository.addNewContact( - contact = Contact( - ownerVeraId = currentAccountId, - contactVeraId = uiState.value.id, - alias = uiState.value.alias, - status = ContactPairingStatus.REQUEST_SENT, - ), - ) - } - viewModelScope.launch(Dispatchers.IO) { - _backSignal.emit(Unit) - } - } - - private fun getPairingErrorMessage(contactId: String): PairingErrorCaption? { - val contact = contacts.find { it.contactVeraId == contactId } - return when { - contact == null -> null - contact.status == ContactPairingStatus.COMPLETED -> PairingErrorCaption(R.string.pair_request_already_paired) - contact.status >= ContactPairingStatus.MATCH -> PairingErrorCaption(R.string.pair_request_already_in_progress) - else -> null - } - } - - companion object { - const val KEY_CURRENT_ACCOUNT_ID = "current_account_id" - } -} - -data class PairWithOthersUiState( - val id: String = "", - val alias: String = "", - val isSentRequestAgainHintVisible: Boolean = false, - val pairingErrorCaption: PairingErrorCaption? = null, -) - -data class PairingErrorCaption( - @StringRes val message: Int, -) 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 2207b338..5fc555c8 100644 --- a/app/src/main/java/tech/relaycorp/letro/ui/MainActivity.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/MainActivity.kt @@ -21,12 +21,17 @@ 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 javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { private val viewModel by viewModels() + @Inject + lateinit var snackbarStringsProvider: SnackbarStringsProvider + override fun onCreate(savedInstanceState: Bundle?) { setTheme(R.style.Theme_Letro) super.onCreate(savedInstanceState) @@ -42,6 +47,7 @@ class MainActivity : ComponentActivity() { ) { LetroNavHost( navController = navController, + snackbarStringsProvider = snackbarStringsProvider, ) } } diff --git a/app/src/main/java/tech/relaycorp/letro/ui/common/LetroButton.kt b/app/src/main/java/tech/relaycorp/letro/ui/common/LetroButton.kt index 270bbc5b..286da346 100644 --- a/app/src/main/java/tech/relaycorp/letro/ui/common/LetroButton.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/common/LetroButton.kt @@ -2,7 +2,9 @@ package tech.relaycorp.letro.ui.common import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -22,6 +24,9 @@ fun LetroButton( buttonType: ButtonType = ButtonType.Filled, enabled: Boolean = true, leadingIconResId: Int? = null, + contentPadding: PaddingValues = PaddingValues( + vertical = 14.dp, + ), onClick: () -> Unit, ) { Button( @@ -48,16 +53,18 @@ fun LetroButton( } else { null }, - contentPadding = PaddingValues( - vertical = 14.dp, - ), + contentPadding = contentPadding, onClick = onClick, ) { if (leadingIconResId != null) { Icon( painter = painterResource(id = leadingIconResId), + tint = MaterialTheme.colorScheme.onPrimary, contentDescription = null, ) + Spacer( + modifier = Modifier.width(8.dp), + ) } Text( text = text, diff --git a/app/src/main/java/tech/relaycorp/letro/ui/common/LetroTextField.kt b/app/src/main/java/tech/relaycorp/letro/ui/common/LetroTextField.kt index e4543c72..705b7925 100644 --- a/app/src/main/java/tech/relaycorp/letro/ui/common/LetroTextField.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/common/LetroTextField.kt @@ -66,6 +66,7 @@ fun LetroOutlinedTextField( isError: Boolean = false, maxLines: Int = 1, singleLine: Boolean = true, + isEnabled: Boolean = true, content: (@Composable () -> Unit)? = null, ) { Column { @@ -106,6 +107,7 @@ fun LetroOutlinedTextField( maxLines = maxLines, singleLine = singleLine, isError = isError, + enabled = isEnabled, ) if (content != null) { content() diff --git a/app/src/main/java/tech/relaycorp/letro/ui/common/Views.kt b/app/src/main/java/tech/relaycorp/letro/ui/common/Views.kt index ac68c6d7..4fed6e90 100644 --- a/app/src/main/java/tech/relaycorp/letro/ui/common/Views.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/common/Views.kt @@ -9,6 +9,7 @@ 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.ui.common.text.HyperlinkText import tech.relaycorp.letro.ui.theme.LetroTheme @Preview(showBackground = true) diff --git a/app/src/main/java/tech/relaycorp/letro/ui/common/text/BoldText.kt b/app/src/main/java/tech/relaycorp/letro/ui/common/text/BoldText.kt new file mode 100644 index 00000000..0ca30928 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/ui/common/text/BoldText.kt @@ -0,0 +1,56 @@ +package tech.relaycorp.letro.ui.common.text + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.TextUnit + +@Composable +fun BoldText( + fullText: String, + boldParts: List, + modifier: Modifier = Modifier, + textColor: Color = MaterialTheme.colorScheme.onSurface, + fontSize: TextUnit = TextUnit.Unspecified, + textStyle: TextStyle = MaterialTheme.typography.bodyLarge, + textAlign: TextAlign? = null, +) { + val annotatedString = buildAnnotatedString { + append(fullText) + addStyle( + style = SpanStyle( + fontSize = fontSize, + color = textColor, + ), + start = 0, + end = fullText.length, + ) + for (part in boldParts) { + val startIndex = fullText.indexOf(part) + val endIndex = startIndex + part.length + addStyle( + style = SpanStyle( + color = textColor, + fontSize = fontSize, + fontWeight = FontWeight.SemiBold, + ), + start = startIndex, + end = endIndex, + ) + } + } + + Text( + text = annotatedString, + modifier = modifier, + style = textStyle, + textAlign = textAlign, + ) +} diff --git a/app/src/main/java/tech/relaycorp/letro/ui/common/HyperlinkText.kt b/app/src/main/java/tech/relaycorp/letro/ui/common/text/HyperlinkText.kt similarity index 98% rename from app/src/main/java/tech/relaycorp/letro/ui/common/HyperlinkText.kt rename to app/src/main/java/tech/relaycorp/letro/ui/common/text/HyperlinkText.kt index 4b26c05c..0a49e850 100644 --- a/app/src/main/java/tech/relaycorp/letro/ui/common/HyperlinkText.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/common/text/HyperlinkText.kt @@ -1,4 +1,4 @@ -package tech.relaycorp.letro.ui.common +package tech.relaycorp.letro.ui.common.text import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.MaterialTheme 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 1e57568e..093efa19 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 @@ -1,15 +1,28 @@ package tech.relaycorp.letro.ui.navigation import android.util.Log +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState 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.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.navigation.NavHostController @@ -19,31 +32,45 @@ 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 +import tech.relaycorp.letro.contacts.ui.ContactsScreenOverlayFloatingMenu +import tech.relaycorp.letro.contacts.ui.ManageContactScreen +import tech.relaycorp.letro.home.HomeScreen +import tech.relaycorp.letro.home.HomeViewModel import tech.relaycorp.letro.main.MainViewModel 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.pairing.PairWithOthersViewModel -import tech.relaycorp.letro.pairing.ui.PairWithOthersScreen 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.utils.compose.rememberLifecycleEvent +import tech.relaycorp.letro.utils.navigation.navigateWithDropCurrentScreen import tech.relaycorp.letro.utils.navigation.navigateWithPoppingAllBackStack @Composable fun LetroNavHost( navController: NavHostController, + snackbarStringsProvider: SnackbarStringsProvider, mainViewModel: MainViewModel = hiltViewModel(), + homeViewModel: HomeViewModel = hiltViewModel(), ) { + val scope = rememberCoroutineScope() val systemUiController: SystemUiController = rememberSystemUiController() var currentRoute: Route by remember { mutableStateOf(Route.Splash) } val uiState by mainViewModel.uiState.collectAsState() val showAwalaNotInstalledScreen by mainViewModel.showInstallAwalaScreen.collectAsState() + val homeUiState by homeViewModel.uiState.collectAsState() + val floatingActionButtonConfig = homeUiState.floatingActionButtonConfig + + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(navController) { navController.currentBackStackEntryFlow.collect { backStackEntry -> currentRoute = backStackEntry.destination.route.toRoute() @@ -74,89 +101,209 @@ fun LetroNavHost( ) } else { systemUiController.isStatusBarVisible = true - systemUiController.setStatusBarColor( - if (currentRoute.isStatusBarPrimaryColor) LetroColor.SurfaceContainerHigh else MaterialTheme.colorScheme.surface, - ) val currentAccount = uiState.currentAccount - Column { - if (currentRoute.showTopBar && currentAccount != null) { - LetroTopBar( - accountVeraId = currentAccount, - isAccountCreated = uiState.isCurrentAccountCreated, - onChangeAccountClicked = { /*TODO*/ }, - onSettingsClicked = { }, - ) - } - NavHost( - navController = navController, - startDestination = Route.Splash.name, - ) { - composable(Route.Splash.name) { - SplashScreen() - } - composable(Route.Registration.name) { - RegistrationScreen( - onUseExistingAccountClick = {}, - ) - } - composable(Route.WelcomeToLetro.name) { - ActionTakingScreen( - actionTakingScreenUIStateModel = ActionTakingScreenUIStateModel.NoContacts( - title = R.string.onboarding_account_confirmation, - image = R.drawable.account_created, - onPairWithOthersClick = { - navController.navigate("${Route.PairWithOthers.name}/${uiState.currentAccount}") - }, - onShareId = { + Scaffold( + content = { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + Column { + if (currentRoute.showTopBar && currentAccount != null) { + LetroTopBar( + accountVeraId = currentAccount, + isAccountCreated = uiState.isCurrentAccountCreated, + onChangeAccountClicked = { /*TODO*/ }, + onSettingsClicked = { }, + ) + } + NavHost( + navController = navController, + startDestination = Route.Splash.name, + ) { + composable(Route.Splash.name) { + SplashScreen() + } + composable(Route.Registration.name) { + RegistrationScreen( + onUseExistingAccountClick = {}, + ) + } + composable(Route.WelcomeToLetro.name) { + ActionTakingScreen( + actionTakingScreenUIStateModel = ActionTakingScreenUIStateModel.NoContacts( + title = R.string.onboarding_account_confirmation, + image = R.drawable.account_created, + onPairWithOthersClick = { + navController.navigate( + Route.ManageContact.getRouteName( + screenType = ManageContactViewModel.Type.NEW_CONTACT, + currentAccountId = uiState.currentAccount, + ), + ) + }, + onShareIdClick = { + mainViewModel.onShareIdClick() + }, + ), + ) + } + composable(Route.NoContacts.name) { + ActionTakingScreen( + actionTakingScreenUIStateModel = ActionTakingScreenUIStateModel.NoContacts( + title = null, + message = R.string.no_contacts_text, + image = R.drawable.no_contacts_image, + onPairWithOthersClick = { + navController.navigate( + Route.ManageContact.getRouteName( + screenType = ManageContactViewModel.Type.NEW_CONTACT, + currentAccountId = uiState.currentAccount, + ), + ) + }, + onShareIdClick = { + mainViewModel.onShareIdClick() + }, + ), + ) + } + composable(Route.RegistrationProcessWaiting.name) { + ActionTakingScreen( + actionTakingScreenUIStateModel = ActionTakingScreenUIStateModel.RegistrationWaiting, + ) + } + composable( + route = "${Route.PairingRequestSent.name}/{${Route.PairingRequestSent.RECEIVER_ARGUMENT_VERA_ID}}", + arguments = listOf( + navArgument(Route.PairingRequestSent.RECEIVER_ARGUMENT_VERA_ID) { + type = NavType.StringType + nullable = false + }, + ), + ) { + ActionTakingScreen( + actionTakingScreenUIStateModel = ActionTakingScreenUIStateModel.PairingRequestSent( + boldPartOfMessage = it.arguments?.getString(Route.PairingRequestSent.RECEIVER_ARGUMENT_VERA_ID)!!, + onGotItClicked = { + navController.popBackStack() + }, + ), + ) + } + composable( + route = "${Route.ManageContact.name}/{${Route.ManageContact.KEY_CURRENT_ACCOUNT_ID}}&{${Route.ManageContact.KEY_SCREEN_TYPE}}&{${Route.ManageContact.KEY_CONTACT_ID_TO_EDIT}}", + arguments = listOf( + navArgument(Route.ManageContact.KEY_CURRENT_ACCOUNT_ID) { + type = NavType.StringType + nullable = true + }, + navArgument(Route.ManageContact.KEY_SCREEN_TYPE) { + type = NavType.IntType + nullable = false + }, + navArgument(Route.ManageContact.KEY_CONTACT_ID_TO_EDIT) { + type = NavType.LongType + nullable = false + defaultValue = Route.ManageContact.NO_ID + }, + ), + ) { entry -> + ManageContactScreen( + onBackClick = { + navController.popBackStack() + }, + onActionCompleted = { + when (val type = entry.arguments?.getInt(Route.ManageContact.KEY_SCREEN_TYPE)) { + ManageContactViewModel.Type.NEW_CONTACT -> { + navController.navigateWithDropCurrentScreen( + Route.PairingRequestSent.getRouteName( + receiverVeraId = it, + ), + ) + } + ManageContactViewModel.Type.EDIT_CONTACT -> { + navController.popBackStack() + scope.launch { + snackbarHostState.showSnackbar( + message = snackbarStringsProvider.contactEdited, + ) + } + } + else -> throw IllegalStateException("Unknown screen type: $type") + } + }, + ) + } + composable(Route.Home.name) { + HomeScreen( + homeViewModel = homeViewModel, + snackbarHostState = snackbarHostState, + snackbarStringsProvider = snackbarStringsProvider, + onEditContactClick = { contact -> + navController.navigate( + Route.ManageContact.getRouteName( + screenType = ManageContactViewModel.Type.EDIT_CONTACT, + currentAccountId = uiState.currentAccount, + contactIdToEdit = contact.id, + ), + ) + }, + ) + } + } + } + if (homeUiState.isAddContactFloatingMenuVisible && currentRoute == Route.Home) { + systemUiController.setStatusBarColor(LetroColor.statusBarUnderDialogOverlay()) + ContactsScreenOverlayFloatingMenu( + homeViewModel = homeViewModel, + onShareIdClick = { mainViewModel.onShareIdClick() + homeViewModel.onOptionFromContactsFloatingMenuClicked() }, - ), - ) - } - composable(Route.NoContacts.name) { - ActionTakingScreen( - actionTakingScreenUIStateModel = ActionTakingScreenUIStateModel.NoContacts( - title = null, - message = R.string.no_contacts_text, - image = R.drawable.no_contacts_image, onPairWithOthersClick = { - navController.navigate("${Route.PairWithOthers.name}/${uiState.currentAccount}") + navController.navigate( + Route.ManageContact.getRouteName( + screenType = ManageContactViewModel.Type.NEW_CONTACT, + currentAccountId = uiState.currentAccount, + ), + ) + homeViewModel.onOptionFromContactsFloatingMenuClicked() }, - onShareId = { - mainViewModel.onShareIdClick() - }, - ), - ) - } - composable(Route.RegistrationProcessWaiting.name) { - ActionTakingScreen( - actionTakingScreenUIStateModel = ActionTakingScreenUIStateModel.RegistrationWaiting, - ) - } - composable(Route.PairingRequestSent.name) { - ActionTakingScreen( - actionTakingScreenUIStateModel = ActionTakingScreenUIStateModel.PairingRequestSent( - onGotItClicked = { /* TODO */ }, - ), - ) + ) + } else { + systemUiController.setStatusBarColor( + if (currentRoute.isStatusBarPrimaryColor) LetroColor.SurfaceContainerHigh else MaterialTheme.colorScheme.surface, + ) + } } - composable( - route = "${Route.PairWithOthers.name}/{${PairWithOthersViewModel.KEY_CURRENT_ACCOUNT_ID}}", - arguments = listOf( - navArgument(PairWithOthersViewModel.KEY_CURRENT_ACCOUNT_ID) { - type = NavType.StringType - nullable = true - }, - ), + }, + floatingActionButton = { + if ( + floatingActionButtonConfig != null && + !homeUiState.isAddContactFloatingMenuVisible && + currentRoute == Route.Home ) { - PairWithOthersScreen( - onBackClick = { - navController.popBackStack() - }, - ) + FloatingActionButton( + onClick = { homeViewModel.onFloatingActionButtonClick() }, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + painter = painterResource(id = floatingActionButtonConfig.icon), + contentDescription = stringResource( + id = floatingActionButtonConfig.contentDescription, + ), + tint = MaterialTheme.colorScheme.onPrimary, + ) + } } - } - } + }, + snackbarHost = { + SnackbarHost(snackbarHostState) + }, + ) } } @@ -172,8 +319,8 @@ private fun handleFirstNavigation( navController.navigateWithPoppingAllBackStack(Route.Registration) } - RootNavigationScreen.Conversations -> { - navController.navigateWithPoppingAllBackStack(Route.WelcomeToLetro) + RootNavigationScreen.Home -> { + navController.navigateWithPoppingAllBackStack(Route.Home) } RootNavigationScreen.NoContactsScreen -> { diff --git a/app/src/main/java/tech/relaycorp/letro/ui/navigation/RootNavigationScreen.kt b/app/src/main/java/tech/relaycorp/letro/ui/navigation/RootNavigationScreen.kt index 6ca32e14..d76cc08a 100644 --- a/app/src/main/java/tech/relaycorp/letro/ui/navigation/RootNavigationScreen.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/navigation/RootNavigationScreen.kt @@ -4,7 +4,7 @@ sealed interface RootNavigationScreen { object Splash : RootNavigationScreen object Registration : RootNavigationScreen object RegistrationWaiting : RootNavigationScreen - object Conversations : RootNavigationScreen object WelcomeToLetro : RootNavigationScreen object NoContactsScreen : RootNavigationScreen + object Home : RootNavigationScreen } 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 d0adb8bd..58c1c31b 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 @@ -1,5 +1,7 @@ package tech.relaycorp.letro.ui.navigation +import tech.relaycorp.letro.contacts.ManageContactViewModel + /** * Class which contains all possible routes * @@ -48,10 +50,34 @@ sealed class Route( name = "pairing_request_sent_route", showTopBar = true, isStatusBarPrimaryColor = true, - ) + ) { + + const val RECEIVER_ARGUMENT_VERA_ID = "receiver_vera_id" + + fun getRouteName(receiverVeraId: String): String { + return "$name/$receiverVeraId" + } + } + + object ManageContact : Route( + name = "manage_contact_route", + showTopBar = true, + isStatusBarPrimaryColor = true, + ) { + const val KEY_CURRENT_ACCOUNT_ID = "current_account_id" + const val KEY_SCREEN_TYPE = "screen_type" + const val KEY_CONTACT_ID_TO_EDIT = "contact_id" + const val NO_ID = -1L + + fun getRouteName( + @ManageContactViewModel.Type screenType: Int, + currentAccountId: String?, + contactIdToEdit: Long = NO_ID, + ) = "${ManageContact.name}/$currentAccountId&$screenType&$contactIdToEdit" + } - object PairWithOthers : Route( - name = "pair_with_others_route", + object Home : Route( + name = "home_route", showTopBar = true, isStatusBarPrimaryColor = true, ) @@ -66,8 +92,9 @@ fun String?.toRoute(): Route { it.startsWith(Route.RegistrationProcessWaiting.name) -> Route.RegistrationProcessWaiting it.startsWith(Route.WelcomeToLetro.name) -> Route.WelcomeToLetro it.startsWith(Route.NoContacts.name) -> Route.NoContacts - it.startsWith(Route.PairWithOthers.name) -> Route.PairWithOthers + it.startsWith(Route.ManageContact.name) -> Route.ManageContact it.startsWith(Route.PairingRequestSent.name) -> Route.PairingRequestSent + it.startsWith(Route.Home.name) -> Route.Home else -> throw IllegalArgumentException("Define the Route by the name of the Route $it") } } diff --git a/app/src/main/java/tech/relaycorp/letro/ui/theme/Color.kt b/app/src/main/java/tech/relaycorp/letro/ui/theme/Color.kt index fb4a0585..79afe792 100644 --- a/app/src/main/java/tech/relaycorp/letro/ui/theme/Color.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/theme/Color.kt @@ -30,6 +30,8 @@ val NeutralVariant8 = Color(0xFFEFF0F3) object LetroColor { + val FoggingBackgroundColor = Color(0x52000000) + val SurfaceContainerHigh: Color @Composable get() = if (isSystemInDarkTheme()) NeutralVariant2 else Primary2 @@ -38,6 +40,15 @@ object LetroColor { @Composable get() = if (isSystemInDarkTheme()) NeutralVariant8 else Neutral8 + val OnSurfaceContainer: Color + @Composable + get() = if (isSystemInDarkTheme()) NeutralVariant5 else Neutral4 + + @Composable + fun statusBarUnderDialogOverlay(): Color { + return if (isSystemInDarkTheme()) Color(0xFF1C1B1F) else Color(0xFF4A3C99) + } + @Composable fun disabledButtonBackgroundColor(): Color { return if (isSystemInDarkTheme()) Color(0x1AEFF0F3) else Color(0x1A0C1B44) diff --git a/app/src/main/java/tech/relaycorp/letro/ui/theme/Dimensions.kt b/app/src/main/java/tech/relaycorp/letro/ui/theme/Dimensions.kt index 82700e93..be14da7d 100644 --- a/app/src/main/java/tech/relaycorp/letro/ui/theme/Dimensions.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/theme/Dimensions.kt @@ -3,3 +3,5 @@ package tech.relaycorp.letro.ui.theme import androidx.compose.ui.unit.dp val HorizontalScreenPadding = 16.dp + +val FloatingActionButtonPadding = 16.dp diff --git a/app/src/main/java/tech/relaycorp/letro/ui/theme/Type.kt b/app/src/main/java/tech/relaycorp/letro/ui/theme/Type.kt index 9a129ee5..57d95992 100644 --- a/app/src/main/java/tech/relaycorp/letro/ui/theme/Type.kt +++ b/app/src/main/java/tech/relaycorp/letro/ui/theme/Type.kt @@ -93,16 +93,34 @@ val Typography = Typography( ), bodyMedium = TextStyle( fontFamily = Inter, - fontWeight = FontWeight.Medium, + fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = (-0.25).sp, ), bodySmall = TextStyle( fontFamily = Inter, - fontWeight = FontWeight.Medium, + fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.sp, ), ) + +val Typography.SmallProminent: TextStyle + get() = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = (-0.1).sp, + ) + +val Typography.LargeProminent: TextStyle + get() = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = (-0.1).sp, + ) 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 new file mode 100644 index 00000000..cd6a9ddd --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/ui/utils/SnackbarStringsProvider.kt @@ -0,0 +1,21 @@ +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 SnackbarStringsProvider { + val contactDeleted: String + val contactEdited: String +} + +class SnackbarStringsProviderImpl @Inject constructor( + @ActivityContext private val activity: Context, +) : SnackbarStringsProvider { + override val contactEdited: String + get() = activity.getString(R.string.snackbar_contact_edited) + + override val contactDeleted: String + get() = activity.getString(R.string.snackbar_contact_deleted) +} diff --git a/app/src/main/java/tech/relaycorp/letro/utils/ext/StringExt.kt b/app/src/main/java/tech/relaycorp/letro/utils/ext/StringExt.kt new file mode 100644 index 00000000..8dcdf132 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/utils/ext/StringExt.kt @@ -0,0 +1,3 @@ +package tech.relaycorp.letro.utils.ext + +fun String?.nullIfBlankOrEmpty() = if (this.isNullOrBlank() || this.isEmpty()) null else this diff --git a/app/src/main/java/tech/relaycorp/letro/utils/navigation/NavigationUtils.kt b/app/src/main/java/tech/relaycorp/letro/utils/navigation/NavigationUtils.kt index 1460d611..0be55cbd 100644 --- a/app/src/main/java/tech/relaycorp/letro/utils/navigation/NavigationUtils.kt +++ b/app/src/main/java/tech/relaycorp/letro/utils/navigation/NavigationUtils.kt @@ -10,3 +10,9 @@ fun NavController.navigateWithPoppingAllBackStack(route: Route) { } } } + +fun NavController.navigateWithDropCurrentScreen(route: String) { + navigate(route) { + popBackStack() + } +} diff --git a/app/src/main/res/drawable-v24/edit.xml b/app/src/main/res/drawable-v24/edit.xml new file mode 100644 index 00000000..acd86b2c --- /dev/null +++ b/app/src/main/res/drawable-v24/edit.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-v24/ic_pair.xml b/app/src/main/res/drawable-v24/ic_pair.xml new file mode 100644 index 00000000..a8233d16 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_pair.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/app/src/main/res/drawable/edit.xml b/app/src/main/res/drawable/edit.xml new file mode 100644 index 00000000..96eb1451 --- /dev/null +++ b/app/src/main/res/drawable/edit.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 00000000..62ca0c1c --- /dev/null +++ b/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 00000000..05b96ef9 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_more.xml b/app/src/main/res/drawable/ic_more.xml new file mode 100644 index 00000000..7175fd91 --- /dev/null +++ b/app/src/main/res/drawable/ic_more.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_pair.xml b/app/src/main/res/drawable/ic_pair.xml new file mode 100644 index 00000000..8efd8260 --- /dev/null +++ b/app/src/main/res/drawable/ic_pair.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_plus.xml b/app/src/main/res/drawable/ic_plus.xml new file mode 100644 index 00000000..c5c09567 --- /dev/null +++ b/app/src/main/res/drawable/ic_plus.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 00000000..5187cb96 --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/pencil.xml b/app/src/main/res/drawable/pencil.xml new file mode 100644 index 00000000..943e66c1 --- /dev/null +++ b/app/src/main/res/drawable/pencil.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 14049a94..8af5c1cc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -42,6 +42,28 @@ You two are already connected. Your pairing with this user is already under way. + Conversations + Contacts + Notifications + + Write new message + Add contact + Close + More + + Edit name + Save changes + + Edit + Cancel + Delete + + Contact deleted. + Changes saved! + + Delete contact + Are you sure you want to delete %s from your contact list? + Join me on Letro: https://letro.app/connect/#u=%1$s Go back