Skip to content

Commit f428106

Browse files
committed
chore(chat): add ability to view contact #, open contact and add contact directly from info container
Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 7890c32 commit f428106

9 files changed

Lines changed: 126 additions & 8 deletions

File tree

apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/android/IntentUtils.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import android.content.Context
55
import android.content.Intent
66
import android.content.pm.PackageManager
77
import android.net.Uri
8+
import android.provider.ContactsContract
89
import android.provider.MediaStore
910
import android.provider.Settings
1011
import androidx.core.content.FileProvider
1112
import androidx.core.net.toUri
13+
import com.flipcash.app.core.contacts.DeviceContact
1214
import com.flipcash.app.core.util.Linkify
1315
import java.io.File
1416

@@ -59,6 +61,23 @@ object IntentUtils {
5961
flags = Intent.FLAG_ACTIVITY_NEW_TASK
6062
}
6163

64+
fun openContact(contact: DeviceContact): Intent = if (!contact.isUnknown) {
65+
openContact(contact.androidContactId)
66+
} else {
67+
insertOrEditContact(contact.e164)
68+
}
69+
70+
fun openContact(contactId: Long) = Intent(Intent.ACTION_VIEW).apply {
71+
data = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_URI, contactId.toString())
72+
flags = Intent.FLAG_ACTIVITY_NEW_TASK
73+
}
74+
75+
fun insertOrEditContact(phoneNumber: String) = Intent(Intent.ACTION_INSERT_OR_EDIT).apply {
76+
type = ContactsContract.Contacts.CONTENT_ITEM_TYPE
77+
putExtra(ContactsContract.Intents.Insert.PHONE, phoneNumber)
78+
flags = Intent.FLAG_ACTIVITY_NEW_TASK
79+
}
80+
6281
fun shareFile(context: Context, file: File, mimeType: String): Intent {
6382
val uri = FileProvider.getUriForFile(
6483
context,

apps/flipcash/core/src/main/res/values/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,4 +779,7 @@
779779
<string name="title_sendFeatureIntro">Send Money To Your Friends</string>
780780
<string name="subtitle_sendFeatureIntro">Send money to friends as easily as a text. Connect your phone number to get started</string>
781781

782+
<string name="label_fromYourContacts">From Your Contacts</string>
783+
<string name="label_addContact">Add Contact</string>
784+
782785
</resources>

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ internal class ChatViewModel @Inject constructor(
123123
sealed interface Event {
124124
data class OnChatOpened(val identifier: ChatIdentifier) : Event
125125
data class OnContactFound(val contact: DeviceContact): Event
126+
data object RefreshContact : Event
126127
data class ChatFound(val chatId: ChatId) : Event
127128
data object OnSendCash: Event
128129
data object OnStartMessageInput: Event
@@ -303,6 +304,18 @@ internal class ChatViewModel @Inject constructor(
303304
}
304305
).launchIn(viewModelScope)
305306

307+
// Re-resolve the contact from the device (e.g. after adding via system contacts)
308+
eventFlow
309+
.filterIsInstance<Event.RefreshContact>()
310+
.mapNotNull { stateFlow.value.chattingWith?.e164 }
311+
.onEach { e164 ->
312+
val refreshed = contactCoordinator.refreshContact(e164)
313+
if (refreshed != null) {
314+
dispatchEvent(Event.OnContactFound(refreshed))
315+
}
316+
}
317+
.launchIn(viewModelScope)
318+
306319
// Advance read pointer when user scrolls to messages
307320
eventFlow
308321
.filterIsInstance<Event.AdvanceReadPointer>()
@@ -641,6 +654,7 @@ internal class ChatViewModel @Inject constructor(
641654
chattingWith = event.contact
642655
)
643656
}
657+
is Event.RefreshContact -> { state -> state }
644658
is Event.ChatFound -> { state -> state.copy(chatId = event.chatId) }
645659
Event.OnSendCash -> { state -> state }
646660
Event.OnStartMessageInput -> { state -> state.copy(userState = UserState.Typing) }

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
106106
onAdvanceReadPointer = { messageId ->
107107
viewModel.dispatchEvent(ChatViewModel.Event.AdvanceReadPointer(messageId))
108108
},
109+
onRefreshContact = {
110+
viewModel.dispatchEvent(ChatViewModel.Event.RefreshContact)
111+
},
109112
)
110113
}
111114
}

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedContent
44
import androidx.compose.animation.animateColorAsState
55
import androidx.compose.foundation.background
66
import androidx.compose.foundation.border
7+
import androidx.compose.foundation.clickable
78
import androidx.compose.foundation.layout.Arrangement
89
import androidx.compose.foundation.layout.Column
910
import androidx.compose.foundation.layout.Row
@@ -15,14 +16,19 @@ import androidx.compose.foundation.shape.CircleShape
1516
import androidx.compose.foundation.text.TextAutoSize
1617
import androidx.compose.material3.Icon
1718
import androidx.compose.material3.Text
19+
import android.content.Intent
20+
import androidx.activity.compose.rememberLauncherForActivityResult
21+
import androidx.activity.result.contract.ActivityResultContracts
1822
import androidx.compose.runtime.Composable
1923
import androidx.compose.runtime.getValue
2024
import androidx.compose.ui.Alignment
2125
import androidx.compose.ui.Modifier
2226
import androidx.compose.ui.draw.clip
2327
import androidx.compose.ui.res.painterResource
28+
import androidx.compose.ui.res.stringResource
2429
import androidx.compose.ui.text.style.TextOverflow
2530
import com.flipcash.app.contacts.ui.ContactAvatar
31+
import com.flipcash.app.core.android.IntentUtils
2632
import com.flipcash.app.core.contacts.DeviceContact
2733
import com.flipcash.features.messenger.R
2834
import com.getcode.theme.CodeTheme
@@ -31,6 +37,7 @@ import com.getcode.theme.CodeTheme
3137
internal fun ContactInfoContainer(
3238
contact: DeviceContact?,
3339
modifier: Modifier = Modifier,
40+
onRefreshContact: () -> Unit = {},
3441
) {
3542
Column(
3643
modifier = modifier
@@ -61,44 +68,67 @@ internal fun ContactInfoContainer(
6168
color = CodeTheme.colors.textMain,
6269
)
6370

71+
if (contact?.isUnknown == false) {
72+
Text(
73+
modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1),
74+
text = contact.displayNumber,
75+
style = CodeTheme.typography.textSmall,
76+
color = CodeTheme.colors.textSecondary,
77+
)
78+
}
79+
80+
val launcher = rememberLauncherForActivityResult(
81+
contract = ActivityResultContracts.StartActivityForResult()
82+
) { onRefreshContact() }
83+
6484
ContactPill(
6585
modifier = Modifier.padding(top = CodeTheme.dimens.inset),
6686
contact = contact
67-
)
87+
) {
88+
contact ?: return@ContactPill
89+
val intent = IntentUtils.openContact(contact).apply {
90+
// Remove NEW_TASK so the result callback fires when the user returns,
91+
// not immediately.
92+
flags = flags and Intent.FLAG_ACTIVITY_NEW_TASK.inv()
93+
}
94+
launcher.launch(intent)
95+
}
6896
}
6997
}
7098

7199
@Composable
72100
private fun ContactPill(
73101
contact: DeviceContact?,
74-
modifier: Modifier = Modifier
102+
modifier: Modifier = Modifier,
103+
onClick: () -> Unit,
75104
) {
76105
AnimatedContent(contact) { c ->
77106
if (c == null) {
78107
Spacer(modifier = modifier.fillMaxWidth())
79108
return@AnimatedContent
80109
}
81110

82-
val isContact = !c.isUnknown
83111
val backgroundColor by animateColorAsState(
84-
if (isContact) CodeTheme.colors.surfaceVariant else CodeTheme.colors.warning.copy(alpha = 0.10f)
112+
if (!c.isUnknown) CodeTheme.colors.surfaceVariant else CodeTheme.colors.warning.copy(alpha = 0.10f)
85113
)
86114

87115
val contentColor by animateColorAsState(
88-
if (isContact) CodeTheme.colors.textSecondary else CodeTheme.colors.warning
116+
if (!c.isUnknown) CodeTheme.colors.textSecondary else CodeTheme.colors.warning
89117
)
90118

91119
Row(
92120
modifier = modifier
93121
.background(color = backgroundColor, shape = CircleShape)
122+
.clip(CircleShape)
123+
.clickable { onClick() }
94124
.padding(horizontal = CodeTheme.dimens.grid.x2, vertical = CodeTheme.dimens.grid.x1),
95125
verticalAlignment = Alignment.CenterVertically,
96126
horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1)
97127
) {
98128
Icon(
99129
modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4),
100130
painter = painterResource(
101-
if (isContact) {
131+
if (!c.isUnknown) {
102132
R.drawable.ic_existing_contact
103133
} else {
104134
R.drawable.ic_unknown_contact
@@ -109,7 +139,7 @@ private fun ContactPill(
109139
)
110140

111141
Text(
112-
text = if (isContact) "From Your Contacts" else "Unknown Contact",
142+
text = if (!c.isUnknown) stringResource(R.string.label_fromYourContacts) else stringResource(R.string.label_addContact),
113143
color = contentColor,
114144
style = CodeTheme.typography.textSmall,
115145
)

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ internal fun MessageList(
5757
separatorConfig: SeparatorConfig,
5858
otherReadPointer: MessagePointer? = null,
5959
onAdvanceReadPointer: ((Long) -> Unit)? = null,
60+
onRefreshContact: () -> Unit = {},
6061
) {
6162
val keyboard = rememberKeyboardController()
6263
val listState = rememberLazyListState()
@@ -208,7 +209,8 @@ internal fun MessageList(
208209
ContactInfoContainer(
209210
contact = state.chattingWith,
210211
modifier = Modifier
211-
.padding(horizontal = CodeTheme.dimens.grid.x12)
212+
.padding(horizontal = CodeTheme.dimens.grid.x12),
213+
onRefreshContact = onRefreshContact,
212214
)
213215
}
214216
}

apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import androidx.datastore.preferences.preferencesDataStoreFile
1313
import androidx.lifecycle.DefaultLifecycleObserver
1414
import androidx.lifecycle.LifecycleOwner
1515
import androidx.lifecycle.ProcessLifecycleOwner
16+
import com.flipcash.app.contacts.device.DeviceContactLookup
1617
import com.flipcash.app.contacts.device.PickedContactData
1718
import com.flipcash.app.contacts.device.ScopeAwareContactReader
1819
import com.flipcash.app.featureflags.FeatureFlag
@@ -70,6 +71,7 @@ class ContactCoordinator @Inject constructor(
7071
private val contactVerificationController: ContactVerificationController,
7172
private val resolverController: ResolverController,
7273
private val networkObserver: NetworkConnectivityListener,
74+
private val deviceContactLookup: DeviceContactLookup,
7375
private val contactReader: ScopeAwareContactReader,
7476
private val phoneUtils: PhoneUtils,
7577
private val contactDataSource: ContactDataSource,
@@ -197,6 +199,13 @@ class ContactCoordinator @Inject constructor(
197199
return resolverController.resolve(ContactMethod.Phone(e164))
198200
}
199201

202+
fun refreshContact(e164: String): DeviceContact? {
203+
val refreshed = deviceContactLookup.lookupContact(e164) ?: return null
204+
val enriched = refreshed.copy(displayNumber = phoneUtils.formatNumber(e164))
205+
_state.update { it.copy(contacts = it.contacts + (e164 to enriched)) }
206+
return enriched
207+
}
208+
200209
fun lookupContact(e164: String): Result<DeviceContact> {
201210
val contact = _state.value.contacts[e164]
202211
?: return Result.failure(NoSuchElementException("No contact found for $e164"))
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package com.flipcash.app.contacts.device
22

3+
import com.flipcash.app.core.contacts.DeviceContact
4+
35
interface DeviceContactLookup {
46
fun lookupDisplayName(e164: String): String?
57
fun lookupPhotoUri(e164: String): String?
68
fun lookupPhotoBytes(e164: String): ByteArray?
79
fun lookupProfilePhoto(): ByteArray?
10+
fun lookupContact(e164: String): DeviceContact?
811
}

apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/AndroidDeviceContactLookup.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import android.content.pm.PackageManager
77
import android.provider.ContactsContract
88
import androidx.core.content.ContextCompat
99
import com.flipcash.app.contacts.device.DeviceContactLookup
10+
import com.flipcash.app.core.contacts.DeviceContact
1011
import dagger.hilt.android.qualifiers.ApplicationContext
1112
import javax.inject.Inject
1213
import javax.inject.Singleton
@@ -50,6 +51,40 @@ internal class AndroidDeviceContactLookup @Inject constructor(
5051
}
5152
}
5253

54+
override fun lookupContact(e164: String): DeviceContact? {
55+
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS)
56+
!= PackageManager.PERMISSION_GRANTED
57+
) return null
58+
59+
val uri = ContactsContract.PhoneLookup.CONTENT_FILTER_URI
60+
.buildUpon()
61+
.appendPath(e164)
62+
.build()
63+
64+
return try {
65+
context.contentResolver.query(
66+
uri,
67+
arrayOf(
68+
ContactsContract.PhoneLookup._ID,
69+
ContactsContract.PhoneLookup.DISPLAY_NAME,
70+
ContactsContract.PhoneLookup.PHOTO_URI,
71+
),
72+
null, null, null
73+
)?.use { cursor ->
74+
if (cursor.moveToFirst()) {
75+
DeviceContact(
76+
e164 = e164,
77+
androidContactId = cursor.getLong(0),
78+
displayName = cursor.getString(1) ?: e164,
79+
photoUri = cursor.getString(2),
80+
)
81+
} else null
82+
}
83+
} catch (e: Exception) {
84+
null
85+
}
86+
}
87+
5388
private fun lookupContactId(e164: String): Long? =
5489
lookupField(e164, ContactsContract.PhoneLookup._ID)?.toLongOrNull()
5590

0 commit comments

Comments
 (0)