Skip to content

Commit 619eeac

Browse files
JOHNJOHN
authored andcommitted
chore: update Kotlin code
1 parent 0898b0d commit 619eeac

File tree

17 files changed

+1116
-319
lines changed

17 files changed

+1116
-319
lines changed

app/src/main/java/to/bitkit/paykit/services/DirectoryService.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ class DirectoryService @Inject constructor(
149149
*/
150150
fun configureWithPubkySession(session: PubkySession, homeserver: HomeserverURL? = null) {
151151
homeserverURL = homeserver ?: HomeserverDefaults.defaultHomeserverURL
152+
ownerPubkey = session.pubkey
152153

153154
// Configure authenticated transport and adapter
154155
val adapter = pubkyStorage.createAuthenticatedAdapter(session.sessionSecret, session.pubkey, homeserverURL)
@@ -162,6 +163,35 @@ class DirectoryService @Inject constructor(
162163
Logger.info("Configured DirectoryService with Pubky session for ${session.pubkey}", context = TAG)
163164
}
164165

166+
/** Owner pubkey for the current session, cached for profile operations */
167+
private var ownerPubkey: String? = null
168+
169+
/** Cached profile for the current session */
170+
private var cachedProfile: PubkyProfile? = null
171+
172+
/**
173+
* Prefetch and cache the user's profile after session establishment.
174+
* This enables instant profile loading in the UI.
175+
*/
176+
suspend fun prefetchProfile() {
177+
val pubkey = ownerPubkey ?: return
178+
runCatching {
179+
cachedProfile = fetchProfile(pubkey)
180+
Logger.debug("Prefetched profile for $pubkey", context = TAG)
181+
}.onFailure { e ->
182+
Logger.debug("Could not prefetch profile: ${e.message}", context = TAG)
183+
}
184+
}
185+
186+
/**
187+
* Get the cached profile if available, otherwise fetch it.
188+
*/
189+
suspend fun getOrFetchProfile(): PubkyProfile? {
190+
val pubkey = ownerPubkey ?: keyManager.getCurrentPublicKeyZ32() ?: return null
191+
cachedProfile?.let { return it }
192+
return fetchProfile(pubkey).also { cachedProfile = it }
193+
}
194+
165195
/**
166196
* Attempt to auto-configure from stored keychain credentials.
167197
* Returns true if successfully configured, false if no stored session exists.

app/src/main/java/to/bitkit/paykit/storage/ContactStorage.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,21 @@ class ContactStorage @Inject constructor(
9292
contactsCache.remove(identity)
9393
}
9494

95+
/**
96+
* Import contacts with upsert semantics: update existing, insert new, preserve local-only fields.
97+
*/
9598
suspend fun importContacts(newContacts: List<Contact>) {
9699
val contacts = listContacts().toMutableList()
97100
for (newContact in newContacts) {
98-
if (!contacts.any { it.id == newContact.id }) {
101+
val index = contacts.indexOfFirst { it.id == newContact.id }
102+
if (index >= 0) {
103+
// Upsert: update existing, preserving local-only fields (payment history)
104+
val existing = contacts[index]
105+
contacts[index] = newContact.copy(
106+
lastPaymentAt = existing.lastPaymentAt,
107+
paymentCount = existing.paymentCount,
108+
)
109+
} else {
99110
contacts.add(newContact)
100111
}
101112
}

app/src/main/java/to/bitkit/paykit/viewmodels/ContactsViewModel.kt

Lines changed: 124 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,33 @@ import kotlinx.coroutines.withContext
1212
import to.bitkit.paykit.models.Contact
1313
import to.bitkit.paykit.services.DirectoryService
1414
import to.bitkit.paykit.services.DiscoveredContact
15+
import to.bitkit.paykit.services.PubkySDKService
1516
import to.bitkit.paykit.storage.ContactStorage
17+
import to.bitkit.utils.Logger
1618
import dagger.hilt.android.lifecycle.HiltViewModel
1719
import javax.inject.Inject
1820

1921
/**
20-
* ViewModel for Contacts management
22+
* ViewModel for Contacts management.
23+
* Contacts are synchronized with Pubky follows - the homeserver is the source of truth.
2124
*/
2225
@HiltViewModel
2326
class ContactsViewModel @Inject constructor(
2427
private val contactStorage: ContactStorage,
2528
private val directoryService: DirectoryService,
29+
private val pubkySDKService: PubkySDKService,
2630
) : ViewModel() {
2731

32+
companion object {
33+
private const val TAG = "ContactsViewModel"
34+
}
35+
2836
private val _contacts = MutableStateFlow<List<Contact>>(emptyList())
2937
val contacts: StateFlow<List<Contact>> = _contacts.asStateFlow()
3038

39+
/** Unfiltered list of all contacts (used for searching) */
40+
private var allContacts: List<Contact> = emptyList()
41+
3142
private val _searchQuery = MutableStateFlow("")
3243
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
3344

@@ -50,29 +61,89 @@ class ContactsViewModel @Inject constructor(
5061
fun loadContacts() {
5162
viewModelScope.launch {
5263
_isLoading.value = true
53-
_contacts.value = contactStorage.listContacts()
64+
runCatching {
65+
// Sync contacts from Pubky follows (source of truth)
66+
val follows = withContext(Dispatchers.IO) {
67+
directoryService.discoverContactsFromFollows()
68+
}
69+
70+
// Convert discovered contacts to Contact model and merge with local data
71+
val followContacts = follows.map { discovered ->
72+
val existing = contactStorage.getContact(discovered.pubkey)
73+
Contact(
74+
id = discovered.pubkey,
75+
publicKeyZ32 = discovered.pubkey,
76+
name = discovered.name ?: existing?.name ?: "",
77+
notes = existing?.notes,
78+
createdAt = existing?.createdAt ?: System.currentTimeMillis(),
79+
lastPaymentAt = existing?.lastPaymentAt,
80+
paymentCount = existing?.paymentCount ?: 0,
81+
)
82+
}
83+
84+
// Persist synced contacts locally for offline access
85+
contactStorage.importContacts(followContacts)
86+
87+
// Store unfiltered list for searching
88+
allContacts = followContacts
89+
90+
_contacts.value = if (_searchQuery.value.isEmpty()) {
91+
followContacts
92+
} else {
93+
followContacts.filter { contact ->
94+
contact.name.contains(_searchQuery.value, ignoreCase = true) ||
95+
contact.publicKeyZ32.contains(_searchQuery.value, ignoreCase = true)
96+
}
97+
}
98+
99+
// Also update discovered contacts for the discovery screen
100+
_discoveredContacts.value = follows
101+
102+
Logger.debug("Loaded ${followContacts.size} contacts from Pubky follows", context = TAG)
103+
}.onFailure { e ->
104+
Logger.error("Failed to load contacts from follows", e, context = TAG)
105+
// Fallback to local storage
106+
val localContacts = contactStorage.listContacts()
107+
allContacts = localContacts
108+
_contacts.value = localContacts
109+
}
54110
_isLoading.value = false
55111
}
56112
}
57113

58114
fun searchContacts() {
59-
viewModelScope.launch {
60-
if (_searchQuery.value.isEmpty()) {
61-
_contacts.value = contactStorage.listContacts()
62-
} else {
63-
_contacts.value = contactStorage.searchContacts(_searchQuery.value)
115+
// Filter from the in-memory list (not storage) to match visible list after network sync
116+
val source = allContacts.ifEmpty { contactStorage.listContacts() }
117+
_contacts.value = if (_searchQuery.value.isEmpty()) {
118+
source
119+
} else {
120+
source.filter { contact ->
121+
contact.name.contains(_searchQuery.value, ignoreCase = true) ||
122+
contact.publicKeyZ32.contains(_searchQuery.value, ignoreCase = true)
64123
}
65124
}
66125
}
67126

68127
fun addContact(contact: Contact) {
69128
viewModelScope.launch {
70129
runCatching {
130+
// Save locally first for immediate feedback
71131
contactStorage.saveContact(contact)
72132
loadContacts()
73133
}.onFailure { e ->
74134
_errorMessage.update { "Failed to add contact: ${e.message}" }
75135
}
136+
137+
// Then create Pubky follow in background
138+
runCatching {
139+
withContext(Dispatchers.IO) {
140+
directoryService.addFollow(contact.publicKeyZ32)
141+
}
142+
Logger.info("Added follow for contact: ${contact.publicKeyZ32.take(12)}...", context = TAG)
143+
}.onFailure { e ->
144+
_errorMessage.update { "Failed to sync follow to Pubky: ${e.message}" }
145+
Logger.error("Failed to add follow: ${e.message}", context = TAG)
146+
}
76147
}
77148
}
78149

@@ -90,26 +161,31 @@ class ContactsViewModel @Inject constructor(
90161
fun deleteContact(contact: Contact) {
91162
viewModelScope.launch {
92163
runCatching {
164+
// Delete locally first for immediate feedback
93165
contactStorage.deleteContact(contact.id)
94166
loadContacts()
95167
}.onFailure { e ->
96168
_errorMessage.update { "Failed to delete contact: ${e.message}" }
97169
}
98-
}
99-
}
100-
101-
fun discoverContacts() {
102-
viewModelScope.launch {
103-
_isLoading.value = true
170+
171+
// Then remove Pubky follow in background
104172
runCatching {
105-
_discoveredContacts.value = directoryService.discoverContactsFromFollows()
173+
withContext(Dispatchers.IO) {
174+
directoryService.removeFollow(contact.publicKeyZ32)
175+
}
176+
Logger.info("Removed follow for contact: ${contact.publicKeyZ32.take(12)}...", context = TAG)
106177
}.onFailure { e ->
107-
_errorMessage.update { "Failed to discover contacts: ${e.message}" }
178+
_errorMessage.update { "Failed to sync unfollow to Pubky: ${e.message}" }
179+
Logger.error("Failed to remove follow: ${e.message}", context = TAG)
108180
}
109-
_isLoading.value = false
110181
}
111182
}
112183

184+
fun discoverContacts() {
185+
// Discovery is now unified with loadContacts - just refresh from follows
186+
loadContacts()
187+
}
188+
113189
fun importDiscovered(contacts: List<Contact>) {
114190
viewModelScope.launch {
115191
runCatching {
@@ -132,24 +208,56 @@ class ContactsViewModel @Inject constructor(
132208

133209
fun followContact(pubkey: String) {
134210
viewModelScope.launch {
211+
_isLoading.value = true
135212
runCatching {
136213
withContext(Dispatchers.IO) {
214+
// 1. Add follow to Pubky homeserver
137215
directoryService.addFollow(pubkey)
216+
217+
// 2. Fetch profile for the followed user
218+
val profile = runCatching { pubkySDKService.fetchProfile(pubkey) }.getOrNull()
219+
220+
// 3. Add to local contacts
221+
val contact = Contact(
222+
id = pubkey,
223+
publicKeyZ32 = pubkey,
224+
name = profile?.name ?: "",
225+
notes = null,
226+
createdAt = System.currentTimeMillis(),
227+
lastPaymentAt = null,
228+
paymentCount = 0,
229+
)
230+
contactStorage.saveContact(contact)
231+
232+
Logger.info("Followed and added contact: $pubkey", context = TAG)
138233
}
234+
// 4. Refresh contacts list
235+
loadContacts()
139236
}.onFailure { e ->
237+
Logger.error("Failed to follow: ${e.message}", context = TAG)
140238
_errorMessage.update { "Failed to follow: ${e.message}" }
239+
_isLoading.value = false
141240
}
142241
}
143242
}
144243

145244
fun unfollowContact(pubkey: String) {
146245
viewModelScope.launch {
246+
_isLoading.value = true
147247
runCatching {
148248
withContext(Dispatchers.IO) {
249+
// Remove follow from Pubky homeserver
149250
directoryService.removeFollow(pubkey)
251+
// Remove from local contacts
252+
contactStorage.deleteContact(pubkey)
253+
Logger.info("Unfollowed and removed contact: $pubkey", context = TAG)
150254
}
255+
// Refresh contacts list
256+
loadContacts()
151257
}.onFailure { e ->
258+
Logger.error("Failed to unfollow: ${e.message}", context = TAG)
152259
_errorMessage.update { "Failed to unfollow: ${e.message}" }
260+
_isLoading.value = false
153261
}
154262
}
155263
}

app/src/main/java/to/bitkit/paykit/viewmodels/PubkyRingAuthViewModel.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ class PubkyRingAuthViewModel @Inject constructor(
5555
directoryService.configureWithPubkySession(session)
5656
// Update PaykitManager with owner pubkey for polling and other operations
5757
paykitManager.setOwnerPubkey(session.pubkey)
58+
// Prefetch profile for instant loading in UI
59+
directoryService.prefetchProfile()
5860
onSuccess(session)
5961
} catch (e: Exception) {
6062
_errorMessage.value = e.message
@@ -81,7 +83,7 @@ class PubkyRingAuthViewModel @Inject constructor(
8183

8284
fun startPollingForSession(onSuccess: (PubkySession) -> Unit) {
8385
val request = _crossDeviceRequest.value ?: return
84-
86+
8587
viewModelScope.launch {
8688
_isPolling.value = true
8789
_errorMessage.value = null
@@ -91,6 +93,8 @@ class PubkyRingAuthViewModel @Inject constructor(
9193
directoryService.configureWithPubkySession(session)
9294
// Update PaykitManager with owner pubkey for polling and other operations
9395
paykitManager.setOwnerPubkey(session.pubkey)
96+
// Prefetch profile for instant loading in UI
97+
directoryService.prefetchProfile()
9498
onSuccess(session)
9599
} catch (e: Exception) {
96100
_errorMessage.value = e.message
@@ -111,6 +115,8 @@ class PubkyRingAuthViewModel @Inject constructor(
111115
directoryService.configureWithPubkySession(session)
112116
// Update PaykitManager with owner pubkey for polling and other operations
113117
paykitManager.setOwnerPubkey(session.pubkey)
118+
// Prefetch profile for instant loading in UI
119+
directoryService.prefetchProfile()
114120
onSuccess(session)
115121
} catch (e: Exception) {
116122
_errorMessage.value = e.message

app/src/main/java/to/bitkit/ui/ContentView.kt

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1662,7 +1662,15 @@ private fun NavGraphBuilder.paykit(navController: NavHostController) {
16621662
to.bitkit.ui.paykit.PaykitContactsScreen(
16631663
onNavigateBack = { navController.popBackStack() },
16641664
onNavigateToContactDiscovery = { navController.navigate(Routes.PaykitContactDiscovery) },
1665-
onNavigateToContactDetail = { id -> /* TODO: Navigate to contact detail */ }
1665+
onNavigateToContactDetail = { id -> navController.navigate(Routes.PaykitContactDetail(id)) },
1666+
)
1667+
}
1668+
composableWithDefaultTransitions<Routes.PaykitContactDetail> { backStackEntry ->
1669+
val id = backStackEntry.toRoute<Routes.PaykitContactDetail>().id
1670+
to.bitkit.ui.paykit.ContactDetailScreen(
1671+
contactId = id,
1672+
onNavigateBack = { navController.popBackStack() },
1673+
onNavigateToNoisePayment = { pubkey -> navController.navigate(Routes.PaykitNoisePayment(pubkey)) },
16661674
)
16671675
}
16681676
composableWithDefaultTransitions<Routes.PaykitContactDiscovery> {
@@ -1711,9 +1719,11 @@ private fun NavGraphBuilder.paykit(navController: NavHostController) {
17111719
onNavigateBack = { navController.popBackStack() }
17121720
)
17131721
}
1714-
composableWithDefaultTransitions<Routes.PaykitNoisePayment> {
1722+
composableWithDefaultTransitions<Routes.PaykitNoisePayment> { backStackEntry ->
1723+
val route = backStackEntry.toRoute<Routes.PaykitNoisePayment>()
17151724
to.bitkit.ui.paykit.NoisePaymentScreen(
1716-
onNavigateBack = { navController.popBackStack() }
1725+
onNavigateBack = { navController.popBackStack() },
1726+
prefillRecipient = route.recipientPubkey,
17171727
)
17181728
}
17191729
composableWithDefaultTransitions<Routes.PaykitPrivateEndpoints> {
@@ -2068,6 +2078,9 @@ sealed interface Routes {
20682078
@Serializable
20692079
data object PaykitContactDiscovery : Routes
20702080

2081+
@Serializable
2082+
data class PaykitContactDetail(val id: String) : Routes
2083+
20712084
@Serializable
20722085
data object PaykitReceipts : Routes
20732086

@@ -2090,7 +2103,7 @@ sealed interface Routes {
20902103
data object PaykitPaymentRequests : Routes
20912104

20922105
@Serializable
2093-
data object PaykitNoisePayment : Routes
2106+
data class PaykitNoisePayment(val recipientPubkey: String? = null) : Routes
20942107

20952108
@Serializable
20962109
data object PaykitPrivateEndpoints : Routes

app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,11 @@ private fun Menu(
169169
DrawerItem(
170170
label = stringResource(R.string.wallet__drawer__profile),
171171
iconRes = R.drawable.ic_user_square,
172-
onClick = null, // TODO IMPLEMENT PROFILE
173-
modifier = Modifier.testTag("DrawerProfile")
172+
onClick = {
173+
rootNavController.navigateIfNotCurrent(Routes.PaykitProfileEdit)
174+
scope.launch { drawerState.close() }
175+
},
176+
modifier = Modifier.testTag("DrawerProfile"),
174177
)
175178

176179
DrawerItem(

0 commit comments

Comments
 (0)