diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/android/IntentUtils.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/android/IntentUtils.kt
index 3f2a2f240..df54d80a4 100644
--- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/android/IntentUtils.kt
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/android/IntentUtils.kt
@@ -5,10 +5,12 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
+import android.provider.ContactsContract
import android.provider.MediaStore
import android.provider.Settings
import androidx.core.content.FileProvider
import androidx.core.net.toUri
+import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.app.core.util.Linkify
import java.io.File
@@ -59,6 +61,23 @@ object IntentUtils {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
+ fun openContact(contact: DeviceContact): Intent = if (!contact.isUnknown) {
+ openContact(contact.androidContactId)
+ } else {
+ insertOrEditContact(contact.e164)
+ }
+
+ fun openContact(contactId: Long) = Intent(Intent.ACTION_VIEW).apply {
+ data = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_URI, contactId.toString())
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ }
+
+ fun insertOrEditContact(phoneNumber: String) = Intent(Intent.ACTION_INSERT_OR_EDIT).apply {
+ type = ContactsContract.Contacts.CONTENT_ITEM_TYPE
+ putExtra(ContactsContract.Intents.Insert.PHONE, phoneNumber)
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ }
+
fun shareFile(context: Context, file: File, mimeType: String): Intent {
val uri = FileProvider.getUriForFile(
context,
diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index a04eef56e..cbfd24df0 100644
--- a/apps/flipcash/core/src/main/res/values/strings.xml
+++ b/apps/flipcash/core/src/main/res/values/strings.xml
@@ -779,4 +779,7 @@
Send Money To Your Friends
Send money to friends as easily as a text. Connect your phone number to get started
+ From Your Contacts
+ Add Contact
+
\ No newline at end of file
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
index f07750d25..36c49baa5 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
@@ -123,6 +123,7 @@ internal class ChatViewModel @Inject constructor(
sealed interface Event {
data class OnChatOpened(val identifier: ChatIdentifier) : Event
data class OnContactFound(val contact: DeviceContact): Event
+ data object RefreshContact : Event
data class ChatFound(val chatId: ChatId) : Event
data object OnSendCash: Event
data object OnStartMessageInput: Event
@@ -303,6 +304,18 @@ internal class ChatViewModel @Inject constructor(
}
).launchIn(viewModelScope)
+ // Re-resolve the contact from the device (e.g. after adding via system contacts)
+ eventFlow
+ .filterIsInstance()
+ .mapNotNull { stateFlow.value.chattingWith?.e164 }
+ .onEach { e164 ->
+ val refreshed = contactCoordinator.refreshContact(e164)
+ if (refreshed != null) {
+ dispatchEvent(Event.OnContactFound(refreshed))
+ }
+ }
+ .launchIn(viewModelScope)
+
// Advance read pointer when user scrolls to messages
eventFlow
.filterIsInstance()
@@ -641,6 +654,7 @@ internal class ChatViewModel @Inject constructor(
chattingWith = event.contact
)
}
+ is Event.RefreshContact -> { state -> state }
is Event.ChatFound -> { state -> state.copy(chatId = event.chatId) }
Event.OnSendCash -> { state -> state }
Event.OnStartMessageInput -> { state -> state.copy(userState = UserState.Typing) }
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt
index 28f978417..2f59ca668 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt
@@ -106,6 +106,9 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
onAdvanceReadPointer = { messageId ->
viewModel.dispatchEvent(ChatViewModel.Event.AdvanceReadPointer(messageId))
},
+ onRefreshContact = {
+ viewModel.dispatchEvent(ChatViewModel.Event.RefreshContact)
+ },
)
}
}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt
index ab6a4d850..52e4fdb0b 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt
@@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -15,14 +16,19 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
+import android.content.Intent
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import com.flipcash.app.contacts.ui.ContactAvatar
+import com.flipcash.app.core.android.IntentUtils
import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.features.messenger.R
import com.getcode.theme.CodeTheme
@@ -31,6 +37,7 @@ import com.getcode.theme.CodeTheme
internal fun ContactInfoContainer(
contact: DeviceContact?,
modifier: Modifier = Modifier,
+ onRefreshContact: () -> Unit = {},
) {
Column(
modifier = modifier
@@ -61,17 +68,39 @@ internal fun ContactInfoContainer(
color = CodeTheme.colors.textMain,
)
+ if (contact?.isUnknown == false) {
+ Text(
+ modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1),
+ text = contact.displayNumber,
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.textSecondary,
+ )
+ }
+
+ val launcher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.StartActivityForResult()
+ ) { onRefreshContact() }
+
ContactPill(
modifier = Modifier.padding(top = CodeTheme.dimens.inset),
contact = contact
- )
+ ) {
+ contact ?: return@ContactPill
+ val intent = IntentUtils.openContact(contact).apply {
+ // Remove NEW_TASK so the result callback fires when the user returns,
+ // not immediately.
+ flags = flags and Intent.FLAG_ACTIVITY_NEW_TASK.inv()
+ }
+ launcher.launch(intent)
+ }
}
}
@Composable
private fun ContactPill(
contact: DeviceContact?,
- modifier: Modifier = Modifier
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
) {
AnimatedContent(contact) { c ->
if (c == null) {
@@ -79,18 +108,19 @@ private fun ContactPill(
return@AnimatedContent
}
- val isContact = !c.isUnknown
val backgroundColor by animateColorAsState(
- if (isContact) CodeTheme.colors.surfaceVariant else CodeTheme.colors.warning.copy(alpha = 0.10f)
+ if (!c.isUnknown) CodeTheme.colors.surfaceVariant else CodeTheme.colors.warning.copy(alpha = 0.10f)
)
val contentColor by animateColorAsState(
- if (isContact) CodeTheme.colors.textSecondary else CodeTheme.colors.warning
+ if (!c.isUnknown) CodeTheme.colors.textSecondary else CodeTheme.colors.warning
)
Row(
modifier = modifier
.background(color = backgroundColor, shape = CircleShape)
+ .clip(CircleShape)
+ .clickable { onClick() }
.padding(horizontal = CodeTheme.dimens.grid.x2, vertical = CodeTheme.dimens.grid.x1),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1)
@@ -98,7 +128,7 @@ private fun ContactPill(
Icon(
modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4),
painter = painterResource(
- if (isContact) {
+ if (!c.isUnknown) {
R.drawable.ic_existing_contact
} else {
R.drawable.ic_unknown_contact
@@ -109,7 +139,7 @@ private fun ContactPill(
)
Text(
- text = if (isContact) "From Your Contacts" else "Unknown Contact",
+ text = if (!c.isUnknown) stringResource(R.string.label_fromYourContacts) else stringResource(R.string.label_addContact),
color = contentColor,
style = CodeTheme.typography.textSmall,
)
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt
index 0d28a1227..30165c4b1 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt
@@ -57,6 +57,7 @@ internal fun MessageList(
separatorConfig: SeparatorConfig,
otherReadPointer: MessagePointer? = null,
onAdvanceReadPointer: ((Long) -> Unit)? = null,
+ onRefreshContact: () -> Unit = {},
) {
val keyboard = rememberKeyboardController()
val listState = rememberLazyListState()
@@ -208,7 +209,8 @@ internal fun MessageList(
ContactInfoContainer(
contact = state.chattingWith,
modifier = Modifier
- .padding(horizontal = CodeTheme.dimens.grid.x12)
+ .padding(horizontal = CodeTheme.dimens.grid.x12),
+ onRefreshContact = onRefreshContact,
)
}
}
diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt
index 05fc2866b..66517ae33 100644
--- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt
+++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt
@@ -13,6 +13,7 @@ import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
+import com.flipcash.app.contacts.device.DeviceContactLookup
import com.flipcash.app.contacts.device.PickedContactData
import com.flipcash.app.contacts.device.ScopeAwareContactReader
import com.flipcash.app.featureflags.FeatureFlag
@@ -70,6 +71,7 @@ class ContactCoordinator @Inject constructor(
private val contactVerificationController: ContactVerificationController,
private val resolverController: ResolverController,
private val networkObserver: NetworkConnectivityListener,
+ private val deviceContactLookup: DeviceContactLookup,
private val contactReader: ScopeAwareContactReader,
private val phoneUtils: PhoneUtils,
private val contactDataSource: ContactDataSource,
@@ -197,6 +199,13 @@ class ContactCoordinator @Inject constructor(
return resolverController.resolve(ContactMethod.Phone(e164))
}
+ fun refreshContact(e164: String): DeviceContact? {
+ val refreshed = deviceContactLookup.lookupContact(e164) ?: return null
+ val enriched = refreshed.copy(displayNumber = phoneUtils.formatNumber(e164))
+ _state.update { it.copy(contacts = it.contacts + (e164 to enriched)) }
+ return enriched
+ }
+
fun lookupContact(e164: String): Result {
val contact = _state.value.contacts[e164]
?: return Result.failure(NoSuchElementException("No contact found for $e164"))
diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactLookup.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactLookup.kt
index f55af5330..e6ba8a743 100644
--- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactLookup.kt
+++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactLookup.kt
@@ -1,8 +1,11 @@
package com.flipcash.app.contacts.device
+import com.flipcash.app.core.contacts.DeviceContact
+
interface DeviceContactLookup {
fun lookupDisplayName(e164: String): String?
fun lookupPhotoUri(e164: String): String?
fun lookupPhotoBytes(e164: String): ByteArray?
fun lookupProfilePhoto(): ByteArray?
+ fun lookupContact(e164: String): DeviceContact?
}
\ No newline at end of file
diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/AndroidDeviceContactLookup.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/AndroidDeviceContactLookup.kt
index 0d120fc28..0c7d361b6 100644
--- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/AndroidDeviceContactLookup.kt
+++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/AndroidDeviceContactLookup.kt
@@ -7,6 +7,7 @@ import android.content.pm.PackageManager
import android.provider.ContactsContract
import androidx.core.content.ContextCompat
import com.flipcash.app.contacts.device.DeviceContactLookup
+import com.flipcash.app.core.contacts.DeviceContact
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@@ -50,6 +51,40 @@ internal class AndroidDeviceContactLookup @Inject constructor(
}
}
+ override fun lookupContact(e164: String): DeviceContact? {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS)
+ != PackageManager.PERMISSION_GRANTED
+ ) return null
+
+ val uri = ContactsContract.PhoneLookup.CONTENT_FILTER_URI
+ .buildUpon()
+ .appendPath(e164)
+ .build()
+
+ return try {
+ context.contentResolver.query(
+ uri,
+ arrayOf(
+ ContactsContract.PhoneLookup._ID,
+ ContactsContract.PhoneLookup.DISPLAY_NAME,
+ ContactsContract.PhoneLookup.PHOTO_URI,
+ ),
+ null, null, null
+ )?.use { cursor ->
+ if (cursor.moveToFirst()) {
+ DeviceContact(
+ e164 = e164,
+ androidContactId = cursor.getLong(0),
+ displayName = cursor.getString(1) ?: e164,
+ photoUri = cursor.getString(2),
+ )
+ } else null
+ }
+ } catch (e: Exception) {
+ null
+ }
+ }
+
private fun lookupContactId(e164: String): Long? =
lookupField(e164, ContactsContract.PhoneLookup._ID)?.toLongOrNull()