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