diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index fc70e3fe9..c1626b903 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -236,6 +236,7 @@ dependencies {
implementation(libs.bouncycastle.provider.jdk)
implementation(libs.ldk.node.android) { exclude(group = "net.java.dev.jna", module = "jna") }
implementation(libs.bitkit.core)
+ implementation(libs.paykit)
implementation(libs.vss.client)
// Firebase
implementation(platform(libs.firebase.bom))
@@ -266,6 +267,9 @@ dependencies {
implementation(libs.charts)
implementation(libs.haze)
implementation(libs.haze.materials)
+ // Image Loading
+ implementation(platform(libs.coil.bom))
+ implementation(libs.coil.compose)
// Compose Navigation
implementation(libs.navigation.compose)
androidTestImplementation(libs.navigation.testing)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 838a05b4e..176129bfc 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,10 @@
+
+
+
+
{
+ override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
+ val uri = data.toString()
+ if (!uri.startsWith(PUBKY_SCHEME)) return null
+ return PubkyImageFetcher(uri, options, pubkyService)
+ }
+ }
+}
diff --git a/app/src/main/java/to/bitkit/data/PubkyStore.kt b/app/src/main/java/to/bitkit/data/PubkyStore.kt
new file mode 100644
index 000000000..98d971964
--- /dev/null
+++ b/app/src/main/java/to/bitkit/data/PubkyStore.kt
@@ -0,0 +1,39 @@
+package to.bitkit.data
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.dataStore
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.Flow
+import kotlinx.serialization.Serializable
+import to.bitkit.data.serializers.PubkyStoreSerializer
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private val Context.pubkyDataStore: DataStore by dataStore(
+ fileName = "pubky.json",
+ serializer = PubkyStoreSerializer,
+)
+
+@Singleton
+class PubkyStore @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+ private val store = context.pubkyDataStore
+
+ val data: Flow = store.data
+
+ suspend fun update(transform: (PubkyStoreData) -> PubkyStoreData) {
+ store.updateData(transform)
+ }
+
+ suspend fun reset() {
+ store.updateData { PubkyStoreData() }
+ }
+}
+
+@Serializable
+data class PubkyStoreData(
+ val cachedName: String? = null,
+ val cachedImageUri: String? = null,
+)
diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt
index 288a4d6b8..946161e24 100644
--- a/app/src/main/java/to/bitkit/data/SettingsStore.kt
+++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt
@@ -99,6 +99,7 @@ data class SettingsData(
val hasSeenSavingsIntro: Boolean = false,
val hasSeenShopIntro: Boolean = false,
val hasSeenProfileIntro: Boolean = false,
+ val hasSeenContactsIntro: Boolean = false,
val quickPayIntroSeen: Boolean = false,
val bgPaymentsIntroSeen: Boolean = false,
val isQuickPayEnabled: Boolean = false,
diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt
index 9b777174a..b6c35d935 100644
--- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt
+++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt
@@ -127,6 +127,7 @@ class Keychain @Inject constructor(
BIP39_PASSPHRASE,
PIN,
PIN_ATTEMPTS_REMAINING,
+ PAYKIT_SESSION,
}
}
diff --git a/app/src/main/java/to/bitkit/data/serializers/PubkyStoreSerializer.kt b/app/src/main/java/to/bitkit/data/serializers/PubkyStoreSerializer.kt
new file mode 100644
index 000000000..9ecbbcebe
--- /dev/null
+++ b/app/src/main/java/to/bitkit/data/serializers/PubkyStoreSerializer.kt
@@ -0,0 +1,27 @@
+package to.bitkit.data.serializers
+
+import androidx.datastore.core.Serializer
+import to.bitkit.data.PubkyStoreData
+import to.bitkit.di.json
+import to.bitkit.utils.Logger
+import java.io.InputStream
+import java.io.OutputStream
+
+object PubkyStoreSerializer : Serializer {
+ private const val TAG = "PubkyStoreSerializer"
+
+ override val defaultValue: PubkyStoreData = PubkyStoreData()
+
+ override suspend fun readFrom(input: InputStream): PubkyStoreData {
+ return runCatching {
+ json.decodeFromString(input.readBytes().decodeToString())
+ }.getOrElse {
+ Logger.error("Failed to deserialize PubkyStoreData", it, context = TAG)
+ defaultValue
+ }
+ }
+
+ override suspend fun writeTo(t: PubkyStoreData, output: OutputStream) {
+ output.write(json.encodeToString(t).encodeToByteArray())
+ }
+}
diff --git a/app/src/main/java/to/bitkit/di/ImageModule.kt b/app/src/main/java/to/bitkit/di/ImageModule.kt
new file mode 100644
index 000000000..8ad67091e
--- /dev/null
+++ b/app/src/main/java/to/bitkit/di/ImageModule.kt
@@ -0,0 +1,41 @@
+package to.bitkit.di
+
+import android.content.Context
+import coil3.ImageLoader
+import coil3.disk.DiskCache
+import coil3.disk.directory
+import coil3.memory.MemoryCache
+import coil3.request.crossfade
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import to.bitkit.data.PubkyImageFetcher
+import to.bitkit.services.PubkyService
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object ImageModule {
+
+ @Provides
+ @Singleton
+ fun provideImageLoader(
+ @ApplicationContext context: Context,
+ pubkyService: PubkyService,
+ ): ImageLoader = ImageLoader.Builder(context)
+ .crossfade(true)
+ .components { add(PubkyImageFetcher.Factory(pubkyService)) }
+ .memoryCache {
+ MemoryCache.Builder()
+ .maxSizePercent(context, percent = 0.15)
+ .build()
+ }
+ .diskCache {
+ DiskCache.Builder()
+ .directory(context.cacheDir.resolve("pubky-images"))
+ .build()
+ }
+ .build()
+}
diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt
index 319a4051a..a2e38275d 100644
--- a/app/src/main/java/to/bitkit/env/Env.kt
+++ b/app/src/main/java/to/bitkit/env/Env.kt
@@ -149,6 +149,16 @@ internal object Env {
const val BITREFILL_APP = "Bitkit"
const val BITREFILL_REF = "AL6dyZYt"
+ val pubkyCapabilities: String
+ get() {
+ val prefix = when (network) {
+ Network.BITCOIN -> ""
+ else -> "staging."
+ }
+ return "/pub/${prefix}paykit.app/v0/:rw," +
+ "/pub/${prefix}pubky.app/profile.json:rw,/pub/${prefix}pubky.app/follows/:rw"
+ }
+
val rnBackupServerHost: String
get() = when (network) {
Network.BITCOIN -> "https://blocktank.synonym.to/backups-ldk"
diff --git a/app/src/main/java/to/bitkit/models/PubkyProfile.kt b/app/src/main/java/to/bitkit/models/PubkyProfile.kt
new file mode 100644
index 000000000..11e680ae5
--- /dev/null
+++ b/app/src/main/java/to/bitkit/models/PubkyProfile.kt
@@ -0,0 +1,55 @@
+package to.bitkit.models
+
+import to.bitkit.ext.ellipsisMiddle
+import com.synonym.bitkitcore.PubkyProfile as CorePubkyProfile
+
+data class PubkyProfileLink(val label: String, val url: String)
+
+data class PubkyProfile(
+ val publicKey: String,
+ val name: String,
+ val bio: String,
+ val imageUrl: String?,
+ val links: List,
+ val status: String?,
+) {
+ companion object {
+ private const val TRUNCATED_PK_LENGTH = 11
+
+ fun fromFfi(publicKey: String, ffiProfile: CorePubkyProfile): PubkyProfile {
+ return PubkyProfile(
+ publicKey = publicKey,
+ name = ffiProfile.name,
+ bio = ffiProfile.bio ?: "",
+ imageUrl = ffiProfile.image,
+ links = ffiProfile.links.orEmpty().map { PubkyProfileLink(label = it.title, url = it.url) },
+ status = ffiProfile.status,
+ )
+ }
+
+ fun placeholder(publicKey: String) = PubkyProfile(
+ publicKey = publicKey,
+ name = publicKey.ellipsisMiddle(TRUNCATED_PK_LENGTH),
+ bio = "",
+ imageUrl = null,
+ links = emptyList(),
+ status = null,
+ )
+
+ fun forDisplay(
+ publicKey: String,
+ name: String?,
+ imageUrl: String?,
+ ) = PubkyProfile(
+ publicKey = publicKey,
+ name = name ?: publicKey.ellipsisMiddle(TRUNCATED_PK_LENGTH),
+ bio = "",
+ imageUrl = imageUrl,
+ links = emptyList(),
+ status = null,
+ )
+ }
+
+ val truncatedPublicKey: String
+ get() = publicKey.ellipsisMiddle(TRUNCATED_PK_LENGTH)
+}
diff --git a/app/src/main/java/to/bitkit/models/Suggestion.kt b/app/src/main/java/to/bitkit/models/Suggestion.kt
index 6c70d0b72..8ae3c576d 100644
--- a/app/src/main/java/to/bitkit/models/Suggestion.kt
+++ b/app/src/main/java/to/bitkit/models/Suggestion.kt
@@ -52,8 +52,8 @@ enum class Suggestion(
PROFILE(
title = R.string.cards__slashtagsProfile__title,
description = R.string.cards__slashtagsProfile__description,
- color = Colors.Brand24,
- icon = R.drawable.crown
+ color = Colors.PubkyGreen24,
+ icon = R.drawable.crown,
),
SHOP(
title = R.string.cards__shop__title,
diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
new file mode 100644
index 000000000..43565ef54
--- /dev/null
+++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@@ -0,0 +1,281 @@
+package to.bitkit.repositories
+
+import coil3.ImageLoader
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.withContext
+import to.bitkit.data.PubkyStore
+import to.bitkit.data.keychain.Keychain
+import to.bitkit.di.IoDispatcher
+import to.bitkit.models.PubkyProfile
+import to.bitkit.services.PubkyService
+import to.bitkit.utils.Logger
+import javax.inject.Inject
+import javax.inject.Singleton
+
+enum class PubkyAuthState { Idle, Authenticating, Authenticated }
+
+@Singleton
+class PubkyRepo @Inject constructor(
+ @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
+ private val pubkyService: PubkyService,
+ private val keychain: Keychain,
+ private val imageLoader: ImageLoader,
+ private val pubkyStore: PubkyStore,
+) {
+ companion object {
+ private const val TAG = "PubkyRepo"
+ private const val PUBKY_SCHEME = "pubky://"
+ }
+
+ private val scope = CoroutineScope(ioDispatcher + SupervisorJob())
+ private val loadProfileMutex = Mutex()
+ private val loadContactsMutex = Mutex()
+
+ private val _authState = MutableStateFlow(PubkyAuthState.Idle)
+
+ private val _profile = MutableStateFlow(null)
+ val profile: StateFlow = _profile.asStateFlow()
+
+ private val _publicKey = MutableStateFlow(null)
+ val publicKey: StateFlow = _publicKey.asStateFlow()
+
+ private val _isLoadingProfile = MutableStateFlow(false)
+ val isLoadingProfile: StateFlow = _isLoadingProfile.asStateFlow()
+
+ private val _contacts = MutableStateFlow>(emptyList())
+ val contacts: StateFlow> = _contacts.asStateFlow()
+
+ private val _isLoadingContacts = MutableStateFlow(false)
+ val isLoadingContacts: StateFlow = _isLoadingContacts.asStateFlow()
+
+ val isAuthenticated: StateFlow = _authState.map { it == PubkyAuthState.Authenticated }
+ .stateIn(scope, SharingStarted.Eagerly, false)
+
+ val displayName: StateFlow = combine(_profile, pubkyStore.data) { profile, cached ->
+ profile?.name ?: cached.cachedName
+ }.stateIn(scope, SharingStarted.Eagerly, null)
+
+ val displayImageUri: StateFlow = combine(_profile, pubkyStore.data) { profile, cached ->
+ profile?.imageUrl ?: cached.cachedImageUri
+ }.stateIn(scope, SharingStarted.Eagerly, null)
+
+ private sealed interface InitResult {
+ data object NoSession : InitResult
+ data class Restored(val publicKey: String) : InitResult
+ data object RestorationFailed : InitResult
+ }
+
+ init {
+ scope.launch { initialize() }
+ }
+
+ private suspend fun initialize() {
+ val result = runCatching {
+ withContext(ioDispatcher) {
+ pubkyService.initialize()
+
+ val savedSecret = runCatching {
+ keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)
+ }.getOrNull()
+
+ if (savedSecret.isNullOrEmpty()) {
+ return@withContext InitResult.NoSession
+ }
+
+ runCatching {
+ val pk = pubkyService.importSession(savedSecret)
+ InitResult.Restored(pk)
+ }.getOrElse {
+ Logger.warn("Failed to restore paykit session", it, context = TAG)
+ InitResult.RestorationFailed
+ }
+ }
+ }.onFailure {
+ Logger.error("Failed to initialize paykit", it, context = TAG)
+ }.getOrNull() ?: return
+
+ when (result) {
+ is InitResult.NoSession -> Logger.debug("No saved paykit session found", context = TAG)
+ is InitResult.Restored -> {
+ _publicKey.update { result.publicKey }
+ _authState.update { PubkyAuthState.Authenticated }
+ Logger.info("Paykit session restored for '${result.publicKey}'", context = TAG)
+ loadProfile()
+ loadContacts()
+ }
+ is InitResult.RestorationFailed -> {
+ runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) }
+ }
+ }
+ }
+
+ suspend fun startAuthentication(): Result {
+ _authState.update { PubkyAuthState.Authenticating }
+ return runCatching {
+ withContext(ioDispatcher) { pubkyService.startAuth() }
+ }.onFailure {
+ _authState.update { PubkyAuthState.Idle }
+ }
+ }
+
+ suspend fun completeAuthentication(): Result {
+ return runCatching {
+ withContext(ioDispatcher) {
+ val sessionSecret = pubkyService.completeAuth()
+ val pk = pubkyService.importSession(sessionSecret)
+
+ runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) }
+ keychain.saveString(Keychain.Key.PAYKIT_SESSION.name, sessionSecret)
+
+ pk
+ }
+ }.onFailure {
+ _authState.update { PubkyAuthState.Idle }
+ }.onSuccess { pk ->
+ _publicKey.update { pk }
+ _authState.update { PubkyAuthState.Authenticated }
+ Logger.info("Pubky auth completed for '$pk'", context = TAG)
+ loadProfile()
+ loadContacts()
+ }.map { }
+ }
+
+ suspend fun cancelAuthentication() {
+ runCatching {
+ withContext(ioDispatcher) { pubkyService.cancelAuth() }
+ }.onFailure { Logger.warn("Cancel auth failed", it, context = TAG) }
+ _authState.update { PubkyAuthState.Idle }
+ }
+
+ fun cancelAuthenticationSync() {
+ scope.launch { cancelAuthentication() }
+ }
+
+ suspend fun loadProfile() {
+ val pk = _publicKey.value ?: return
+ if (!loadProfileMutex.tryLock()) return
+
+ _isLoadingProfile.update { true }
+ try {
+ runCatching {
+ withContext(ioDispatcher) {
+ val ffiProfile = pubkyService.getProfile(pk)
+ Logger.debug(
+ "Profile loaded — name: '${ffiProfile.name}', image: '${ffiProfile.image}'",
+ context = TAG,
+ )
+ PubkyProfile.fromFfi(pk, ffiProfile)
+ }
+ }.onSuccess { loadedProfile ->
+ if (_publicKey.value == null) return@onSuccess
+ _profile.update { loadedProfile }
+ cacheMetadata(loadedProfile)
+ }.onFailure {
+ Logger.error("Failed to load profile", it, context = TAG)
+ }
+ } finally {
+ _isLoadingProfile.update { false }
+ loadProfileMutex.unlock()
+ }
+ }
+
+ suspend fun loadContacts() {
+ val pk = _publicKey.value ?: return
+ if (!loadContactsMutex.tryLock()) return
+
+ _isLoadingContacts.update { true }
+ try {
+ runCatching {
+ withContext(ioDispatcher) {
+ val contactKeys = pubkyService.getContacts(pk)
+ Logger.debug("Fetched '${contactKeys.size}' contact keys", context = TAG)
+ coroutineScope {
+ contactKeys.map { contactPk ->
+ val prefixedKey = contactPk.ensurePubkyPrefix()
+ async {
+ runCatching {
+ val ffiProfile = pubkyService.getProfile(prefixedKey)
+ PubkyProfile.fromFfi(prefixedKey, ffiProfile)
+ }.onFailure {
+ Logger.warn("Failed to load contact profile '$prefixedKey'", it, context = TAG)
+ }.getOrElse {
+ PubkyProfile.placeholder(prefixedKey)
+ }
+ }
+ }.awaitAll().sortedBy { it.name.lowercase() }
+ }
+ }
+ }.onSuccess { loadedContacts ->
+ if (_publicKey.value == null) return@onSuccess
+ _contacts.update { loadedContacts }
+ }.onFailure {
+ Logger.error("Failed to load contacts", it, context = TAG)
+ }
+ } finally {
+ _isLoadingContacts.update { false }
+ loadContactsMutex.unlock()
+ }
+ }
+
+ suspend fun fetchContactProfile(publicKey: String): Result = runCatching {
+ withContext(ioDispatcher) {
+ val prefixedKey = publicKey.ensurePubkyPrefix()
+ val ffiProfile = pubkyService.getProfile(prefixedKey)
+ PubkyProfile.fromFfi(prefixedKey, ffiProfile)
+ }
+ }.onFailure {
+ Logger.error("Failed to load contact profile '$publicKey'", it, context = TAG)
+ }
+
+ suspend fun signOut(): Result = runCatching {
+ withContext(ioDispatcher) { pubkyService.signOut() }
+ }.recoverCatching {
+ Logger.warn("Server sign out failed, forcing local sign out", it, context = TAG)
+ withContext(ioDispatcher) { pubkyService.forceSignOut() }
+ }.also {
+ runCatching { withContext(ioDispatcher) { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } }
+ evictPubkyImages()
+ runCatching { withContext(ioDispatcher) { pubkyStore.reset() } }
+ _publicKey.update { null }
+ _profile.update { null }
+ _contacts.update { emptyList() }
+ _authState.update { PubkyAuthState.Idle }
+ }
+
+ private fun evictPubkyImages() {
+ imageLoader.memoryCache?.let { cache ->
+ cache.keys.filter { it.key.startsWith(PUBKY_SCHEME) }.forEach { cache.remove(it) }
+ }
+ val imageUris = buildList {
+ _profile.value?.imageUrl?.let { add(it) }
+ addAll(_contacts.value.mapNotNull { it.imageUrl })
+ }
+ imageLoader.diskCache?.let { cache ->
+ imageUris.forEach { cache.remove(it) }
+ }
+ }
+
+ private suspend fun cacheMetadata(profile: PubkyProfile) {
+ pubkyStore.update {
+ it.copy(cachedName = profile.name, cachedImageUri = profile.imageUrl)
+ }
+ }
+
+ private fun String.ensurePubkyPrefix(): String =
+ if (startsWith(PUBKY_SCHEME)) this else "$PUBKY_SCHEME$this"
+}
diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt
new file mode 100644
index 000000000..3ae1054a4
--- /dev/null
+++ b/app/src/main/java/to/bitkit/services/PubkyService.kt
@@ -0,0 +1,74 @@
+package to.bitkit.services
+
+import com.synonym.bitkitcore.PubkyProfile
+import com.synonym.bitkitcore.cancelPubkyAuth
+import com.synonym.bitkitcore.completePubkyAuth
+import com.synonym.bitkitcore.fetchPubkyContacts
+import com.synonym.bitkitcore.fetchPubkyFile
+import com.synonym.bitkitcore.fetchPubkyProfile
+import com.synonym.bitkitcore.startPubkyAuth
+import com.synonym.paykit.paykitForceSignOut
+import com.synonym.paykit.paykitImportSession
+import com.synonym.paykit.paykitInitialize
+import com.synonym.paykit.paykitSignOut
+import kotlinx.coroutines.CompletableDeferred
+import to.bitkit.async.ServiceQueue
+import to.bitkit.env.Env
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class PubkyService @Inject constructor() {
+
+ private val isSetup = CompletableDeferred()
+
+ suspend fun initialize() = ServiceQueue.CORE.background {
+ paykitInitialize()
+ isSetup.complete(Unit)
+ }
+
+ suspend fun importSession(secret: String): String = ServiceQueue.CORE.background {
+ isSetup.await()
+ paykitImportSession(secret)
+ }
+
+ suspend fun startAuth(): String = ServiceQueue.CORE.background {
+ isSetup.await()
+ startPubkyAuth(Env.pubkyCapabilities)
+ }
+
+ suspend fun completeAuth(): String = ServiceQueue.CORE.background {
+ isSetup.await()
+ completePubkyAuth()
+ }
+
+ suspend fun cancelAuth() = ServiceQueue.CORE.background {
+ isSetup.await()
+ cancelPubkyAuth()
+ }
+
+ suspend fun fetchFile(uri: String): ByteArray = ServiceQueue.CORE.background {
+ isSetup.await()
+ fetchPubkyFile(uri)
+ }
+
+ suspend fun getProfile(publicKey: String): PubkyProfile = ServiceQueue.CORE.background {
+ isSetup.await()
+ fetchPubkyProfile(publicKey)
+ }
+
+ suspend fun getContacts(publicKey: String): List = ServiceQueue.CORE.background {
+ isSetup.await()
+ fetchPubkyContacts(publicKey)
+ }
+
+ suspend fun signOut() = ServiceQueue.CORE.background {
+ isSetup.await()
+ paykitSignOut()
+ }
+
+ suspend fun forceSignOut() = ServiceQueue.CORE.background {
+ isSetup.await()
+ paykitForceSignOut()
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt
index cb1892a51..5c8c5204b 100644
--- a/app/src/main/java/to/bitkit/ui/ContentView.kt
+++ b/app/src/main/java/to/bitkit/ui/ContentView.kt
@@ -59,9 +59,16 @@ import to.bitkit.ui.onboarding.InitializingWalletView
import to.bitkit.ui.onboarding.WalletRestoreErrorView
import to.bitkit.ui.onboarding.WalletRestoreSuccessView
import to.bitkit.ui.screens.CriticalUpdateScreen
-import to.bitkit.ui.screens.common.ComingSoonScreen
-import to.bitkit.ui.screens.profile.CreateProfileScreen
+import to.bitkit.ui.screens.contacts.ContactDetailScreen
+import to.bitkit.ui.screens.contacts.ContactDetailViewModel
+import to.bitkit.ui.screens.contacts.ContactsIntroScreen
+import to.bitkit.ui.screens.contacts.ContactsScreen
+import to.bitkit.ui.screens.contacts.ContactsViewModel
import to.bitkit.ui.screens.profile.ProfileIntroScreen
+import to.bitkit.ui.screens.profile.ProfileScreen
+import to.bitkit.ui.screens.profile.ProfileViewModel
+import to.bitkit.ui.screens.profile.PubkyRingAuthScreen
+import to.bitkit.ui.screens.profile.PubkyRingAuthViewModel
import to.bitkit.ui.screens.recovery.RecoveryMnemonicScreen
import to.bitkit.ui.screens.recovery.RecoveryModeScreen
import to.bitkit.ui.screens.settings.DevSettingsScreen
@@ -362,6 +369,9 @@ fun ContentView(
val hasSeenWidgetsIntro by settingsViewModel.hasSeenWidgetsIntro.collectAsStateWithLifecycle()
val hasSeenShopIntro by settingsViewModel.hasSeenShopIntro.collectAsStateWithLifecycle()
+ val hasSeenProfileIntro by settingsViewModel.hasSeenProfileIntro.collectAsStateWithLifecycle()
+ val hasSeenContactsIntro by settingsViewModel.hasSeenContactsIntro.collectAsStateWithLifecycle()
+ val isProfileAuthenticated by settingsViewModel.isPubkyAuthenticated.collectAsStateWithLifecycle()
val currentSheet by appViewModel.currentSheet.collectAsStateWithLifecycle()
Box(
@@ -489,6 +499,9 @@ fun ContentView(
rootNavController = navController,
hasSeenWidgetsIntro = hasSeenWidgetsIntro,
hasSeenShopIntro = hasSeenShopIntro,
+ hasSeenProfileIntro = hasSeenProfileIntro,
+ hasSeenContactsIntro = hasSeenContactsIntro,
+ isProfileAuthenticated = isProfileAuthenticated,
modifier = Modifier.align(Alignment.TopEnd),
)
}
@@ -522,7 +535,7 @@ private fun RootNavHost(
navController = navController,
)
settings(navController, settingsViewModel)
- comingSoon(navController)
+ contacts(navController, settingsViewModel)
profile(navController, settingsViewModel)
shop(navController, settingsViewModel, appViewModel)
generalSettings(navController)
@@ -899,19 +912,40 @@ private fun NavGraphBuilder.settings(
}
}
-private fun NavGraphBuilder.comingSoon(
+private fun NavGraphBuilder.contacts(
navController: NavHostController,
+ settingsViewModel: SettingsViewModel,
) {
composableWithDefaultTransitions {
- ComingSoonScreen(
- onWalletOverviewClick = { navController.navigateToHome() },
- onBackClick = { navController.popBackStack() }
+ val viewModel: ContactsViewModel = hiltViewModel()
+ ContactsScreen(
+ viewModel = viewModel,
+ onBackClick = { navController.popBackStack() },
+ onClickMyProfile = { navController.navigateTo(Routes.Profile) },
+ onClickContact = { navController.navigateTo(Routes.ContactDetail(it)) },
)
}
- composableWithDefaultTransitions {
- ComingSoonScreen(
- onWalletOverviewClick = { navController.navigateToHome() },
- onBackClick = { navController.popBackStack() }
+ composableWithDefaultTransitions {
+ val isAuthenticated by settingsViewModel.isPubkyAuthenticated.collectAsStateWithLifecycle()
+ val hasSeenProfileIntro by settingsViewModel.hasSeenProfileIntro.collectAsStateWithLifecycle()
+ ContactsIntroScreen(
+ onContinue = {
+ settingsViewModel.setHasSeenContactsIntro(true)
+ val destination = when {
+ isAuthenticated -> Routes.Contacts
+ hasSeenProfileIntro -> Routes.PubkyRingAuth
+ else -> Routes.ProfileIntro
+ }
+ navController.navigateTo(destination) { popUpTo(Routes.Home) }
+ },
+ onBackClick = { navController.popBackStack() },
+ )
+ }
+ composableWithDefaultTransitions {
+ val viewModel: ContactDetailViewModel = hiltViewModel()
+ ContactDetailScreen(
+ viewModel = viewModel,
+ onBackClick = { navController.popBackStack() },
)
}
}
@@ -920,18 +954,30 @@ private fun NavGraphBuilder.profile(
navController: NavHostController,
settingsViewModel: SettingsViewModel,
) {
+ composableWithDefaultTransitions {
+ val viewModel: ProfileViewModel = hiltViewModel()
+ ProfileScreen(
+ viewModel = viewModel,
+ onBackClick = { navController.popBackStack() },
+ )
+ }
composableWithDefaultTransitions {
ProfileIntroScreen(
onContinue = {
settingsViewModel.setHasSeenProfileIntro(true)
- navController.navigateTo(Routes.CreateProfile)
+ navController.navigateTo(Routes.PubkyRingAuth)
},
- onBackClick = { navController.popBackStack() }
+ onBackClick = { navController.popBackStack() },
)
}
- composableWithDefaultTransitions {
- CreateProfileScreen(
- onBack = { navController.popBackStack() },
+ composableWithDefaultTransitions {
+ val viewModel: PubkyRingAuthViewModel = hiltViewModel()
+ PubkyRingAuthScreen(
+ viewModel = viewModel,
+ onBackClick = { navController.popBackStack() },
+ onAuthenticated = {
+ navController.navigateTo(Routes.Profile) { popUpTo(Routes.Home) }
+ },
)
}
}
@@ -1505,6 +1551,15 @@ inline fun NavController.navigateTo(
fun NavController.navigateToGeneralSettings() = navigateTo(Routes.GeneralSettings)
+fun NavController.navigateToProfile(
+ isAuthenticated: Boolean,
+ hasSeenIntro: Boolean,
+) = when {
+ isAuthenticated -> navigateTo(Routes.Profile)
+ hasSeenIntro -> navigateTo(Routes.PubkyRingAuth)
+ else -> navigateTo(Routes.ProfileIntro)
+}
+
fun NavController.navigateToSecuritySettings() = navigateTo(Routes.SecuritySettings)
fun NavController.navigateToDisablePin() = navigateTo(Routes.DisablePin)
@@ -1815,6 +1870,12 @@ sealed interface Routes {
@Serializable
data object Contacts : Routes
+ @Serializable
+ data object ContactsIntro : Routes
+
+ @Serializable
+ data class ContactDetail(val publicKey: String) : Routes
+
@Serializable
data object Profile : Routes
@@ -1822,7 +1883,7 @@ sealed interface Routes {
data object ProfileIntro : Routes
@Serializable
- data object CreateProfile : Routes
+ data object PubkyRingAuth : Routes
@Serializable
data object ShopIntro : Routes
diff --git a/app/src/main/java/to/bitkit/ui/components/ActionButton.kt b/app/src/main/java/to/bitkit/ui/components/ActionButton.kt
new file mode 100644
index 000000000..b29abf726
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/components/ActionButton.kt
@@ -0,0 +1,56 @@
+package to.bitkit.ui.components
+
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import to.bitkit.ui.shared.modifiers.rememberDebouncedClick
+import to.bitkit.ui.theme.Colors
+
+@Composable
+fun ActionButton(
+ onClick: () -> Unit,
+ @DrawableRes iconRes: Int? = null,
+ imageVector: ImageVector? = null,
+ enabled: Boolean = true,
+ modifier: Modifier = Modifier,
+) {
+ IconButton(
+ onClick = rememberDebouncedClick(onClick = onClick),
+ enabled = enabled,
+ modifier = modifier
+ .size(48.dp)
+ .clip(CircleShape)
+ .background(
+ Brush.verticalGradient(listOf(Colors.Gray5, Colors.Gray6)),
+ CircleShape,
+ )
+ .border(1.dp, Colors.White10, CircleShape)
+ ) {
+ val tint = if (enabled) Colors.White else Colors.White32
+ when {
+ iconRes != null -> Icon(
+ painter = painterResource(iconRes),
+ contentDescription = null,
+ tint = tint,
+ modifier = Modifier.size(24.dp)
+ )
+ imageVector != null -> Icon(
+ imageVector = imageVector,
+ contentDescription = null,
+ tint = tint,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt b/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt
index 4e5c4b176..0d379da33 100644
--- a/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt
+++ b/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt
@@ -44,6 +44,7 @@ import to.bitkit.R
import to.bitkit.ui.Routes
import to.bitkit.ui.navigateTo
import to.bitkit.ui.navigateToHome
+import to.bitkit.ui.navigateToProfile
import to.bitkit.ui.shared.modifiers.clickableAlpha
import to.bitkit.ui.shared.util.blockPointerInputPassthrough
import to.bitkit.ui.theme.AppThemeSurface
@@ -69,6 +70,9 @@ fun DrawerMenu(
hasSeenWidgetsIntro: Boolean,
hasSeenShopIntro: Boolean,
modifier: Modifier = Modifier,
+ hasSeenProfileIntro: Boolean = false,
+ hasSeenContactsIntro: Boolean = false,
+ isProfileAuthenticated: Boolean = false,
) {
val scope = rememberCoroutineScope()
@@ -116,6 +120,20 @@ fun DrawerMenu(
rootNavController.navigateIfNotCurrent(Routes.ShopDiscover)
}
},
+ onClickContacts = {
+ when {
+ !hasSeenContactsIntro -> rootNavController.navigateIfNotCurrent(Routes.ContactsIntro)
+ isProfileAuthenticated -> rootNavController.navigateIfNotCurrent(Routes.Contacts)
+ hasSeenProfileIntro -> rootNavController.navigateIfNotCurrent(Routes.PubkyRingAuth)
+ else -> rootNavController.navigateIfNotCurrent(Routes.ProfileIntro)
+ }
+ },
+ onClickProfile = {
+ rootNavController.navigateToProfile(
+ isAuthenticated = isProfileAuthenticated,
+ hasSeenIntro = hasSeenProfileIntro,
+ )
+ },
)
}
}
@@ -126,6 +144,8 @@ private fun Menu(
drawerState: DrawerState,
onClickAddWidget: () -> Unit,
onClickShop: () -> Unit,
+ onClickContacts: () -> Unit,
+ onClickProfile: () -> Unit,
) {
val scope = rememberCoroutineScope()
@@ -163,7 +183,7 @@ private fun Menu(
label = stringResource(R.string.wallet__drawer__contacts),
iconRes = R.drawable.ic_users,
onClick = {
- rootNavController.navigateIfNotCurrent(Routes.Contacts)
+ onClickContacts()
scope.launch { drawerState.close() }
},
modifier = Modifier.testTag("DrawerContacts")
@@ -173,7 +193,7 @@ private fun Menu(
label = stringResource(R.string.wallet__drawer__profile),
iconRes = R.drawable.ic_user_square,
onClick = {
- rootNavController.navigateIfNotCurrent(Routes.Profile)
+ onClickProfile()
scope.launch { drawerState.close() }
},
modifier = Modifier.testTag("DrawerProfile")
diff --git a/app/src/main/java/to/bitkit/ui/components/LinkRow.kt b/app/src/main/java/to/bitkit/ui/components/LinkRow.kt
new file mode 100644
index 000000000..2d7bca804
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/components/LinkRow.kt
@@ -0,0 +1,25 @@
+package to.bitkit.ui.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import to.bitkit.ui.theme.Colors
+
+@Composable
+fun LinkRow(
+ label: String,
+ value: String,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier.fillMaxWidth()) {
+ VerticalSpacer(16.dp)
+ Text13Up(text = label, color = Colors.White64)
+ VerticalSpacer(8.dp)
+ BodySSB(text = value)
+ VerticalSpacer(16.dp)
+ HorizontalDivider()
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt b/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt
new file mode 100644
index 000000000..3e59c5ba9
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt
@@ -0,0 +1,159 @@
+package to.bitkit.ui.components
+
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import to.bitkit.R
+import to.bitkit.ui.theme.AppThemeSurface
+import to.bitkit.ui.theme.Colors
+
+@Composable
+fun PubkyImage(
+ uri: String,
+ size: Dp,
+ modifier: Modifier = Modifier,
+) {
+ var imageState by remember { mutableStateOf(ImageState.Loading) }
+
+ val scale by animateFloatAsState(
+ targetValue = if (imageState == ImageState.Success) 1f else 0.8f,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioMediumBouncy,
+ stiffness = Spring.StiffnessLow,
+ ),
+ label = "pubky_image_scale",
+ )
+
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = modifier
+ .size(size)
+ .clip(CircleShape)
+ ) {
+ AsyncImage(
+ model = uri,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ onSuccess = { imageState = ImageState.Success },
+ onError = { imageState = ImageState.Error },
+ modifier = Modifier
+ .matchParentSize()
+ .graphicsLayer {
+ scaleX = scale
+ scaleY = scale
+ }
+ )
+
+ ImageOverlay(state = imageState, size = size)
+ }
+}
+
+private enum class ImageState { Loading, Success, Error }
+
+@Composable
+private fun ImageOverlay(state: ImageState, size: Dp) {
+ val loadingAlpha by animateFloatAsState(
+ targetValue = if (state == ImageState.Loading) 1f else 0f,
+ animationSpec = tween(durationMillis = 300),
+ label = "loading_alpha",
+ )
+ val errorAlpha by animateFloatAsState(
+ targetValue = if (state == ImageState.Error) 1f else 0f,
+ animationSpec = tween(durationMillis = 300),
+ label = "error_alpha",
+ )
+
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ if (loadingAlpha > 0f) {
+ GradientCircularProgressIndicator(
+ strokeWidth = 2.dp,
+ modifier = Modifier
+ .size(size / 3)
+ .graphicsLayer { alpha = loadingAlpha }
+ )
+ }
+
+ if (errorAlpha > 0f) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .fillMaxSize()
+ .graphicsLayer { alpha = errorAlpha }
+ .background(Colors.Gray5, CircleShape)
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_user_square),
+ contentDescription = null,
+ tint = Colors.White32,
+ modifier = Modifier.size(size / 2)
+ )
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun Preview() {
+ AppThemeSurface {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier
+ .background(Colors.Gray7)
+ .padding(16.dp)
+ ) {
+ ImageState.entries.forEach { state ->
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ BodyMSB(state.name)
+ VerticalSpacer(16.dp)
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(64.dp)
+ .clip(CircleShape)
+ .background(Colors.Black)
+ ) {
+ if (state == ImageState.Success) {
+ Icon(
+ painter = painterResource(R.drawable.ic_user_square),
+ contentDescription = null,
+ tint = Colors.Brand,
+ modifier = Modifier.size(32.dp)
+ )
+ }
+ ImageOverlay(state = state, size = 64.dp)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt
new file mode 100644
index 000000000..8a04e64b0
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt
@@ -0,0 +1,211 @@
+package to.bitkit.ui.screens.contacts
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+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.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import to.bitkit.R
+import to.bitkit.models.PubkyProfile
+import to.bitkit.models.PubkyProfileLink
+import to.bitkit.ui.components.ActionButton
+import to.bitkit.ui.components.BodyM
+import to.bitkit.ui.components.BodySSB
+import to.bitkit.ui.components.GradientCircularProgressIndicator
+import to.bitkit.ui.components.Headline
+import to.bitkit.ui.components.LinkRow
+import to.bitkit.ui.components.PubkyImage
+import to.bitkit.ui.components.SecondaryButton
+import to.bitkit.ui.components.VerticalSpacer
+import to.bitkit.ui.scaffold.AppTopBar
+import to.bitkit.ui.scaffold.DrawerNavIcon
+import to.bitkit.ui.scaffold.ScreenColumn
+import to.bitkit.ui.shared.util.shareText
+import to.bitkit.ui.theme.AppThemeSurface
+import to.bitkit.ui.theme.Colors
+
+@Composable
+fun ContactDetailScreen(
+ viewModel: ContactDetailViewModel,
+ onBackClick: () -> Unit,
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+
+ Content(
+ uiState = uiState,
+ onBackClick = onBackClick,
+ onClickCopy = { viewModel.copyPublicKey() },
+ onClickShare = { uiState.profile?.publicKey?.let { shareText(context, it) } },
+ onClickRetry = { viewModel.loadContact() },
+ )
+}
+
+@Composable
+private fun Content(
+ uiState: ContactDetailUiState,
+ onBackClick: () -> Unit,
+ onClickCopy: () -> Unit,
+ onClickShare: () -> Unit,
+ onClickRetry: () -> Unit,
+) {
+ val currentProfile = uiState.profile
+
+ ScreenColumn {
+ AppTopBar(
+ titleText = stringResource(R.string.contacts__detail_title),
+ onBackClick = onBackClick,
+ actions = { DrawerNavIcon() },
+ )
+
+ when {
+ uiState.isLoading && currentProfile == null -> LoadingState()
+ currentProfile != null -> ContactBody(
+ profile = currentProfile,
+ onClickCopy = onClickCopy,
+ onClickShare = onClickShare,
+ )
+ else -> EmptyState(onClickRetry = onClickRetry)
+ }
+ }
+}
+
+@Composable
+private fun ContactBody(
+ profile: PubkyProfile,
+ onClickCopy: () -> Unit,
+ onClickShare: () -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 32.dp)
+ ) {
+ VerticalSpacer(24.dp)
+
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Headline(text = AnnotatedString(profile.name))
+ VerticalSpacer(8.dp)
+ BodySSB(text = profile.truncatedPublicKey)
+ }
+
+ if (profile.imageUrl != null) {
+ PubkyImage(uri = profile.imageUrl, size = 64.dp)
+ } else {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(64.dp)
+ .clip(CircleShape)
+ .background(Colors.PubkyGreen)
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_user_square),
+ contentDescription = null,
+ tint = Colors.White32,
+ modifier = Modifier.size(32.dp)
+ )
+ }
+ }
+ }
+
+ VerticalSpacer(16.dp)
+
+ if (profile.bio.isNotEmpty()) {
+ BodyM(text = profile.bio, color = Colors.White64)
+ VerticalSpacer(8.dp)
+ }
+ HorizontalDivider()
+
+ VerticalSpacer(24.dp)
+
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ ActionButton(onClick = onClickCopy, iconRes = R.drawable.ic_copy)
+ ActionButton(onClick = onClickShare, iconRes = R.drawable.ic_share)
+ }
+
+ VerticalSpacer(32.dp)
+
+ profile.links.forEach { LinkRow(label = it.label, value = it.url) }
+ }
+}
+
+@Composable
+private fun LoadingState() {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ GradientCircularProgressIndicator(modifier = Modifier.size(24.dp))
+ }
+}
+
+@Composable
+private fun EmptyState(onClickRetry: () -> Unit) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 32.dp)
+ ) {
+ BodyM(text = stringResource(R.string.profile__empty_state), color = Colors.White64)
+ VerticalSpacer(16.dp)
+ SecondaryButton(
+ text = stringResource(R.string.profile__retry_load),
+ onClick = onClickRetry,
+ )
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun Preview() {
+ AppThemeSurface {
+ Content(
+ uiState = ContactDetailUiState(
+ profile = PubkyProfile(
+ publicKey = "pk8e3qm5...gxag",
+ name = "John Carvalho",
+ bio = "CEO at @synonym_to\n// Host of @thebizbtc",
+ imageUrl = null,
+ links = listOf(
+ PubkyProfileLink("Email", "john@synonym.to"),
+ PubkyProfileLink("Website", "https://bitcoinerrorlog.substack.com"),
+ ),
+ status = null,
+ ),
+ ),
+ onBackClick = {},
+ onClickCopy = {},
+ onClickShare = {},
+ onClickRetry = {},
+ )
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt
new file mode 100644
index 000000000..4b5c33aab
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt
@@ -0,0 +1,73 @@
+package to.bitkit.ui.screens.contacts
+
+import android.content.Context
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import to.bitkit.R
+import to.bitkit.ext.setClipboardText
+import to.bitkit.models.PubkyProfile
+import to.bitkit.models.Toast
+import to.bitkit.repositories.PubkyRepo
+import to.bitkit.ui.shared.toast.ToastEventBus
+import javax.inject.Inject
+
+@HiltViewModel
+class ContactDetailViewModel @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val pubkyRepo: PubkyRepo,
+ savedStateHandle: SavedStateHandle,
+) : ViewModel() {
+
+ private val publicKey: String = checkNotNull(
+ savedStateHandle["publicKey"],
+ ) { "publicKey not found in SavedStateHandle" }
+
+ private val _uiState = MutableStateFlow(ContactDetailUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ loadContact()
+ }
+
+ fun loadContact() {
+ viewModelScope.launch {
+ val cached = pubkyRepo.contacts.value.find { it.publicKey == publicKey }
+ _uiState.update { it.copy(isLoading = true, profile = cached) }
+ pubkyRepo.fetchContactProfile(publicKey)
+ .onSuccess { profile ->
+ _uiState.update { it.copy(profile = profile, isLoading = false) }
+ }
+ .onFailure {
+ _uiState.update {
+ it.copy(
+ profile = it.profile ?: PubkyProfile.placeholder(publicKey),
+ isLoading = false,
+ )
+ }
+ }
+ }
+ }
+
+ fun copyPublicKey() {
+ context.setClipboardText(publicKey, context.getString(R.string.profile__public_key))
+ viewModelScope.launch {
+ ToastEventBus.send(
+ type = Toast.ToastType.SUCCESS,
+ title = context.getString(R.string.common__copied),
+ )
+ }
+ }
+}
+
+data class ContactDetailUiState(
+ val profile: PubkyProfile? = null,
+ val isLoading: Boolean = false,
+)
diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsIntroScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsIntroScreen.kt
new file mode 100644
index 000000000..2f3804b1f
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsIntroScreen.kt
@@ -0,0 +1,85 @@
+package to.bitkit.ui.screens.contacts
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import to.bitkit.R
+import to.bitkit.ui.components.BodyM
+import to.bitkit.ui.components.Display
+import to.bitkit.ui.components.PrimaryButton
+import to.bitkit.ui.components.VerticalSpacer
+import to.bitkit.ui.scaffold.AppTopBar
+import to.bitkit.ui.scaffold.DrawerNavIcon
+import to.bitkit.ui.scaffold.ScreenColumn
+import to.bitkit.ui.theme.AppThemeSurface
+import to.bitkit.ui.theme.Colors
+import to.bitkit.ui.utils.withAccent
+
+@Composable
+fun ContactsIntroScreen(
+ onContinue: () -> Unit,
+ onBackClick: () -> Unit,
+) {
+ Content(
+ onContinue = onContinue,
+ onBackClick = onBackClick,
+ )
+}
+
+@Composable
+private fun Content(
+ onContinue: () -> Unit,
+ onBackClick: () -> Unit,
+) {
+ ScreenColumn {
+ AppTopBar(
+ titleText = stringResource(R.string.contacts__nav_title),
+ onBackClick = onBackClick,
+ actions = { DrawerNavIcon() },
+ )
+
+ Column(
+ modifier = Modifier.padding(horizontal = 32.dp)
+ ) {
+ Image(
+ painter = painterResource(R.drawable.contacts_intro),
+ contentDescription = null,
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ )
+
+ Display(
+ text = stringResource(R.string.contacts__intro_title)
+ .withAccent(accentColor = Colors.PubkyGreen),
+ color = Colors.White,
+ )
+ VerticalSpacer(8.dp)
+ BodyM(text = stringResource(R.string.contacts__intro_description), color = Colors.White64)
+ VerticalSpacer(32.dp)
+ PrimaryButton(
+ text = stringResource(R.string.common__continue),
+ onClick = onContinue,
+ )
+ VerticalSpacer(16.dp)
+ }
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun Preview() {
+ AppThemeSurface {
+ Content(
+ onContinue = {},
+ onBackClick = {},
+ )
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt
new file mode 100644
index 000000000..52ac4998e
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt
@@ -0,0 +1,237 @@
+package to.bitkit.ui.screens.contacts
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import to.bitkit.R
+import to.bitkit.models.PubkyProfile
+import to.bitkit.ui.components.BodyM
+import to.bitkit.ui.components.BodyS
+import to.bitkit.ui.components.BodySSB
+import to.bitkit.ui.components.GradientCircularProgressIndicator
+import to.bitkit.ui.components.PubkyImage
+import to.bitkit.ui.components.SearchInput
+import to.bitkit.ui.components.Text13Up
+import to.bitkit.ui.components.VerticalSpacer
+import to.bitkit.ui.scaffold.AppTopBar
+import to.bitkit.ui.scaffold.DrawerNavIcon
+import to.bitkit.ui.scaffold.ScreenColumn
+import to.bitkit.ui.shared.modifiers.clickableAlpha
+import to.bitkit.ui.theme.AppThemeSurface
+import to.bitkit.ui.theme.Colors
+
+@Composable
+fun ContactsScreen(
+ viewModel: ContactsViewModel,
+ onBackClick: () -> Unit,
+ onClickMyProfile: () -> Unit,
+ onClickContact: (String) -> Unit,
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) { viewModel.refresh() }
+
+ Content(
+ uiState = uiState,
+ onBackClick = onBackClick,
+ onClickMyProfile = onClickMyProfile,
+ onClickContact = onClickContact,
+ onSearchTextChange = { viewModel.onSearchTextChange(it) },
+ )
+}
+
+@Composable
+private fun Content(
+ uiState: ContactsUiState,
+ onBackClick: () -> Unit,
+ onClickMyProfile: () -> Unit,
+ onClickContact: (String) -> Unit,
+ onSearchTextChange: (String) -> Unit,
+) {
+ ScreenColumn {
+ AppTopBar(
+ titleText = stringResource(R.string.contacts__nav_title),
+ onBackClick = onBackClick,
+ actions = { DrawerNavIcon() },
+ )
+
+ Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+ SearchInput(
+ value = uiState.searchText,
+ onValueChange = onSearchTextChange,
+ )
+ VerticalSpacer(8.dp)
+ }
+
+ when {
+ uiState.isLoading && uiState.groupedContacts.isEmpty() -> LoadingState()
+ uiState.isEmpty && uiState.searchText.isBlank() -> EmptyState()
+ else -> ContactsList(
+ groupedContacts = uiState.groupedContacts,
+ myProfile = uiState.myProfile,
+ showMyProfile = uiState.searchText.isBlank(),
+ onClickMyProfile = onClickMyProfile,
+ onClickContact = onClickContact,
+ )
+ }
+ }
+}
+
+@Composable
+private fun ContactsList(
+ groupedContacts: Map>,
+ myProfile: PubkyProfile?,
+ showMyProfile: Boolean,
+ onClickMyProfile: () -> Unit,
+ onClickContact: (String) -> Unit,
+) {
+ LazyColumn(modifier = Modifier.fillMaxSize()) {
+ if (showMyProfile && myProfile != null) {
+ item {
+ Text13Up(
+ text = stringResource(R.string.contacts__my_profile),
+ color = Colors.White64,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp)
+ )
+ ContactRow(
+ profile = myProfile,
+ onClick = onClickMyProfile,
+ )
+ HorizontalDivider()
+ }
+ }
+
+ groupedContacts.forEach { (letter, contacts) ->
+ item {
+ Text13Up(
+ text = letter.toString(),
+ color = Colors.White64,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp)
+ )
+ HorizontalDivider()
+ }
+
+ items(contacts, key = { it.publicKey }) { contact ->
+ ContactRow(
+ profile = contact,
+ onClick = { onClickContact(contact.publicKey) },
+ )
+ HorizontalDivider()
+ }
+ }
+ }
+}
+
+@Composable
+private fun ContactRow(
+ profile: PubkyProfile,
+ onClick: () -> Unit,
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickableAlpha(onClick = onClick)
+ .padding(horizontal = 16.dp, vertical = 12.dp)
+ ) {
+ ContactAvatar(profile = profile)
+
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ BodySSB(
+ text = profile.name,
+ color = Colors.White,
+ )
+ BodyS(
+ text = profile.truncatedPublicKey,
+ color = Colors.White64,
+ )
+ }
+ }
+}
+
+@Composable
+private fun ContactAvatar(profile: PubkyProfile) {
+ if (profile.imageUrl != null) {
+ PubkyImage(uri = profile.imageUrl, size = 48.dp)
+ } else {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(48.dp)
+ .clip(CircleShape)
+ .background(Colors.White10)
+ ) {
+ BodySSB(
+ text = profile.name.firstOrNull()?.uppercase().orEmpty(),
+ color = Colors.White,
+ )
+ }
+ }
+}
+
+@Composable
+private fun LoadingState() {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ GradientCircularProgressIndicator(modifier = Modifier.size(24.dp))
+ }
+}
+
+@Composable
+private fun EmptyState() {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 32.dp)
+ ) {
+ BodyM(text = stringResource(R.string.contacts__empty_state), color = Colors.White64)
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun Preview() {
+ val contacts = listOf(
+ PubkyProfile("pk1", "Alex Stronghand", "", null, emptyList(), null),
+ PubkyProfile("pk2", "Anna Pleb", "", null, emptyList(), null),
+ PubkyProfile("pk3", "Areem Holden", "", null, emptyList(), null),
+ PubkyProfile("pk4", "Craig Wrong", "", null, emptyList(), null),
+ )
+ AppThemeSurface {
+ Content(
+ uiState = ContactsUiState(
+ groupedContacts = contacts.groupBy { it.name.first() }.toSortedMap(),
+ myProfile = PubkyProfile("pk0", "Satoshi Nakamoto", "", null, emptyList(), null),
+ ),
+ onBackClick = {},
+ onClickMyProfile = {},
+ onClickContact = {},
+ onSearchTextChange = {},
+ )
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt
new file mode 100644
index 000000000..239b2c764
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt
@@ -0,0 +1,75 @@
+package to.bitkit.ui.screens.contacts
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import to.bitkit.models.PubkyProfile
+import to.bitkit.repositories.PubkyRepo
+import javax.inject.Inject
+
+@HiltViewModel
+class ContactsViewModel @Inject constructor(
+ private val pubkyRepo: PubkyRepo,
+) : ViewModel() {
+
+ private val _searchText = MutableStateFlow("")
+
+ private val myProfile: StateFlow = combine(
+ pubkyRepo.profile,
+ pubkyRepo.publicKey,
+ pubkyRepo.displayName,
+ pubkyRepo.displayImageUri,
+ ) { profile, publicKey, displayName, displayImageUri ->
+ profile ?: publicKey?.let { PubkyProfile.forDisplay(it, displayName, displayImageUri) }
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
+
+ val uiState: StateFlow = combine(
+ pubkyRepo.contacts,
+ pubkyRepo.isLoadingContacts,
+ myProfile,
+ _searchText,
+ ) { contacts, isLoading, myProfileValue, search ->
+ val filtered = if (search.isBlank()) {
+ contacts
+ } else {
+ contacts.filter {
+ it.name.contains(search, ignoreCase = true) ||
+ it.publicKey.contains(search, ignoreCase = true)
+ }
+ }
+ val grouped = filtered.groupBy {
+ val firstChar = it.name.firstOrNull()?.uppercaseChar()
+ if (firstChar?.isLetter() == true) firstChar else '#'
+ }.toSortedMap()
+ ContactsUiState(
+ groupedContacts = grouped,
+ myProfile = myProfileValue,
+ isLoading = isLoading,
+ searchText = search,
+ )
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ContactsUiState())
+
+ fun onSearchTextChange(text: String) {
+ _searchText.update { text.trim() }
+ }
+
+ fun refresh() {
+ viewModelScope.launch { pubkyRepo.loadContacts() }
+ }
+}
+
+data class ContactsUiState(
+ val groupedContacts: Map> = emptyMap(),
+ val myProfile: PubkyProfile? = null,
+ val isLoading: Boolean = false,
+ val searchText: String = "",
+) {
+ val isEmpty: Boolean get() = groupedContacts.isEmpty() && !isLoading
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt
deleted file mode 100644
index 8d8e11c83..000000000
--- a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-package to.bitkit.ui.screens.profile
-
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.padding
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import to.bitkit.R
-import to.bitkit.ui.components.Display
-import to.bitkit.ui.scaffold.AppTopBar
-import to.bitkit.ui.scaffold.DrawerNavIcon
-import to.bitkit.ui.scaffold.ScreenColumn
-import to.bitkit.ui.theme.AppThemeSurface
-import to.bitkit.ui.theme.Colors
-
-@Composable
-fun CreateProfileScreen(
- onBack: () -> Unit,
-) { // TODO IMPLEMENT
- ScreenColumn {
- AppTopBar(
- titleText = stringResource(R.string.slashtags__profile_create),
- onBackClick = onBack,
- actions = { DrawerNavIcon() },
- )
-
- Column(
- modifier = Modifier.padding(horizontal = 32.dp)
- ) {
- Spacer(Modifier.weight(1f))
-
- Display(
- text = stringResource(R.string.other__coming_soon),
- color = Colors.White
- )
- Spacer(Modifier.weight(1f))
- }
- }
-}
-
-@Preview(showBackground = true)
-@Composable
-private fun Preview() {
- AppThemeSurface {
- CreateProfileScreen(
- onBack = {},
- )
- }
-}
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileIntroScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileIntroScreen.kt
index 27fe39a66..ade8291be 100644
--- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileIntroScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileIntroScreen.kt
@@ -2,9 +2,7 @@ package to.bitkit.ui.screens.profile
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
-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.runtime.Composable
import androidx.compose.ui.Modifier
@@ -16,6 +14,7 @@ import to.bitkit.R
import to.bitkit.ui.components.BodyM
import to.bitkit.ui.components.Display
import to.bitkit.ui.components.PrimaryButton
+import to.bitkit.ui.components.VerticalSpacer
import to.bitkit.ui.scaffold.AppTopBar
import to.bitkit.ui.scaffold.DrawerNavIcon
import to.bitkit.ui.scaffold.ScreenColumn
@@ -30,7 +29,7 @@ fun ProfileIntroScreen(
) {
ScreenColumn {
AppTopBar(
- titleText = stringResource(R.string.slashtags__profile),
+ titleText = stringResource(R.string.profile__nav_title),
onBackClick = onBackClick,
actions = { DrawerNavIcon() },
)
@@ -47,19 +46,17 @@ fun ProfileIntroScreen(
)
Display(
- text = stringResource(
- R.string.slashtags__onboarding_profile1_header
- ).withAccent(accentColor = Colors.Brand),
- color = Colors.White
+ text = stringResource(R.string.profile__intro_title).withAccent(accentColor = Colors.PubkyGreen),
+ color = Colors.White,
)
- Spacer(Modifier.height(8.dp))
- BodyM(text = stringResource(R.string.slashtags__onboarding_profile1_text), color = Colors.White64)
- Spacer(Modifier.height(32.dp))
+ VerticalSpacer(8.dp)
+ BodyM(text = stringResource(R.string.profile__intro_description), color = Colors.White64)
+ VerticalSpacer(32.dp)
PrimaryButton(
text = stringResource(R.string.common__continue),
- onClick = onContinue
+ onClick = onContinue,
)
- Spacer(Modifier.height(16.dp))
+ VerticalSpacer(16.dp)
}
}
}
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt
new file mode 100644
index 000000000..a4655fd55
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt
@@ -0,0 +1,292 @@
+package to.bitkit.ui.screens.profile
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Logout
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import to.bitkit.R
+import to.bitkit.models.PubkyProfile
+import to.bitkit.models.PubkyProfileLink
+import to.bitkit.ui.components.ActionButton
+import to.bitkit.ui.components.BodyM
+import to.bitkit.ui.components.BodyS
+import to.bitkit.ui.components.BodySSB
+import to.bitkit.ui.components.GradientCircularProgressIndicator
+import to.bitkit.ui.components.Headline
+import to.bitkit.ui.components.LinkRow
+import to.bitkit.ui.components.PubkyImage
+import to.bitkit.ui.components.QrCodeImage
+import to.bitkit.ui.components.SecondaryButton
+import to.bitkit.ui.components.VerticalSpacer
+import to.bitkit.ui.scaffold.AppAlertDialog
+import to.bitkit.ui.scaffold.AppTopBar
+import to.bitkit.ui.scaffold.DrawerNavIcon
+import to.bitkit.ui.scaffold.ScreenColumn
+import to.bitkit.ui.shared.modifiers.rememberDebouncedClick
+import to.bitkit.ui.shared.util.shareText
+import to.bitkit.ui.theme.AppThemeSurface
+import to.bitkit.ui.theme.Colors
+
+@Composable
+fun ProfileScreen(
+ viewModel: ProfileViewModel,
+ onBackClick: () -> Unit,
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect {
+ when (it) {
+ ProfileEffect.SignedOut -> onBackClick()
+ }
+ }
+ }
+
+ Content(
+ uiState = uiState,
+ onBackClick = onBackClick,
+ onClickCopy = { viewModel.copyPublicKey() },
+ onClickShare = { uiState.publicKey?.let { shareText(context, it) } },
+ onClickSignOut = { viewModel.showSignOutConfirmation() },
+ onDismissSignOutDialog = { viewModel.dismissSignOutDialog() },
+ onConfirmSignOut = { viewModel.signOut() },
+ onClickRetry = { viewModel.loadProfile() },
+ )
+}
+
+@Composable
+private fun Content(
+ uiState: ProfileUiState,
+ onBackClick: () -> Unit,
+ onClickCopy: () -> Unit,
+ onClickShare: () -> Unit,
+ onClickSignOut: () -> Unit,
+ onDismissSignOutDialog: () -> Unit,
+ onConfirmSignOut: () -> Unit,
+ onClickRetry: () -> Unit,
+) {
+ val currentProfile = uiState.profile
+
+ ScreenColumn {
+ AppTopBar(
+ titleText = stringResource(R.string.profile__nav_title),
+ onBackClick = onBackClick,
+ actions = { DrawerNavIcon() },
+ )
+
+ when {
+ uiState.isLoading && currentProfile == null -> LoadingState()
+ currentProfile != null -> ProfileBody(
+ profile = currentProfile,
+ isSigningOut = uiState.isSigningOut,
+ onClickCopy = onClickCopy,
+ onClickShare = onClickShare,
+ onClickSignOut = onClickSignOut,
+ )
+ else -> EmptyState(onClickRetry = onClickRetry, onClickSignOut = onClickSignOut)
+ }
+ }
+
+ if (uiState.showSignOutDialog) {
+ AppAlertDialog(
+ title = stringResource(R.string.profile__sign_out_title),
+ text = stringResource(R.string.profile__sign_out_description),
+ confirmText = stringResource(R.string.profile__sign_out),
+ onConfirm = onConfirmSignOut,
+ onDismiss = onDismissSignOutDialog,
+ )
+ }
+}
+
+@Composable
+private fun ProfileBody(
+ profile: PubkyProfile,
+ isSigningOut: Boolean,
+ onClickCopy: () -> Unit,
+ onClickShare: () -> Unit,
+ onClickSignOut: () -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 32.dp)
+ ) {
+ VerticalSpacer(24.dp)
+
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Headline(text = AnnotatedString(profile.name))
+ VerticalSpacer(8.dp)
+ BodySSB(text = profile.truncatedPublicKey)
+ }
+
+ if (profile.imageUrl != null) {
+ PubkyImage(uri = profile.imageUrl, size = 64.dp)
+ } else {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(64.dp)
+ .clip(CircleShape)
+ .background(Colors.PubkyGreen)
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_user_square),
+ contentDescription = null,
+ tint = Colors.White32,
+ modifier = Modifier.size(32.dp)
+ )
+ }
+ }
+ }
+
+ VerticalSpacer(16.dp)
+
+ if (profile.bio.isNotEmpty()) {
+ BodyM(text = profile.bio, color = Colors.White64)
+ VerticalSpacer(8.dp)
+ }
+ HorizontalDivider()
+
+ VerticalSpacer(24.dp)
+
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ ActionButton(onClick = onClickCopy, iconRes = R.drawable.ic_copy)
+ ActionButton(onClick = onClickShare, iconRes = R.drawable.ic_share)
+ ActionButton(
+ onClick = onClickSignOut,
+ imageVector = Icons.AutoMirrored.Filled.Logout,
+ enabled = !isSigningOut,
+ )
+ }
+
+ VerticalSpacer(24.dp)
+
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ QrCodeImage(
+ content = profile.publicKey,
+ modifier = Modifier.fillMaxWidth()
+ )
+ if (profile.imageUrl != null) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(68.dp)
+ .background(Color.White, CircleShape)
+ ) {
+ PubkyImage(
+ uri = profile.imageUrl,
+ size = 50.dp,
+ )
+ }
+ }
+ }
+ VerticalSpacer(12.dp)
+ BodyS(
+ text = stringResource(R.string.profile__qr_scan_label).replace("{name}", profile.name),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ VerticalSpacer(32.dp)
+
+ profile.links.forEach { LinkRow(label = it.label, value = it.url) }
+ }
+}
+
+@Composable
+private fun LoadingState() {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ GradientCircularProgressIndicator(modifier = Modifier.size(24.dp))
+ }
+}
+
+@Composable
+private fun EmptyState(
+ onClickRetry: () -> Unit,
+ onClickSignOut: () -> Unit,
+) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 32.dp)
+ ) {
+ BodyM(text = stringResource(R.string.profile__empty_state), color = Colors.White64)
+ VerticalSpacer(16.dp)
+ SecondaryButton(
+ text = stringResource(R.string.profile__retry_load),
+ onClick = onClickRetry,
+ )
+ VerticalSpacer(8.dp)
+ TextButton(onClick = rememberDebouncedClick(onClick = onClickSignOut)) {
+ BodyS(text = stringResource(R.string.profile__sign_out), color = Colors.White64)
+ }
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun Preview() {
+ AppThemeSurface {
+ Content(
+ uiState = ProfileUiState(
+ profile = PubkyProfile(
+ publicKey = "pk8e3qm5...gxag",
+ name = "Satoshi",
+ bio = "Building a peer-to-peer electronic cash system.",
+ imageUrl = null,
+ links = listOf(PubkyProfileLink("Website", "https://bitcoin.org")),
+ status = null,
+ ),
+ ),
+ onBackClick = {},
+ onClickCopy = {},
+ onClickShare = {},
+ onClickSignOut = {},
+ onDismissSignOutDialog = {},
+ onConfirmSignOut = {},
+ onClickRetry = {},
+ )
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt
new file mode 100644
index 000000000..976c9f3ac
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt
@@ -0,0 +1,115 @@
+package to.bitkit.ui.screens.profile
+
+import android.content.Context
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import to.bitkit.R
+import to.bitkit.ext.setClipboardText
+import to.bitkit.models.PubkyProfile
+import to.bitkit.models.Toast
+import to.bitkit.repositories.PubkyRepo
+import to.bitkit.ui.shared.toast.ToastEventBus
+import to.bitkit.utils.Logger
+import javax.inject.Inject
+
+@HiltViewModel
+class ProfileViewModel @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val pubkyRepo: PubkyRepo,
+) : ViewModel() {
+ companion object {
+ private const val TAG = "ProfileViewModel"
+ }
+
+ private val _showSignOutDialog = MutableStateFlow(false)
+ private val _isSigningOut = MutableStateFlow(false)
+
+ val uiState: StateFlow = combine(
+ pubkyRepo.profile,
+ pubkyRepo.publicKey,
+ pubkyRepo.isLoadingProfile,
+ _showSignOutDialog,
+ _isSigningOut,
+ ) { profile, publicKey, isLoading, showSignOutDialog, isSigningOut ->
+ ProfileUiState(
+ profile = profile,
+ publicKey = publicKey,
+ isLoading = isLoading,
+ showSignOutDialog = showSignOutDialog,
+ isSigningOut = isSigningOut,
+ )
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ProfileUiState())
+
+ private val _effects = MutableSharedFlow(extraBufferCapacity = 1)
+ val effects = _effects.asSharedFlow()
+
+ init {
+ loadProfile()
+ }
+
+ fun loadProfile() {
+ viewModelScope.launch { pubkyRepo.loadProfile() }
+ }
+
+ fun showSignOutConfirmation() {
+ _showSignOutDialog.update { true }
+ }
+
+ fun dismissSignOutDialog() {
+ _showSignOutDialog.update { false }
+ }
+
+ fun signOut() {
+ viewModelScope.launch {
+ _isSigningOut.update { true }
+ _showSignOutDialog.update { false }
+ pubkyRepo.signOut()
+ .onSuccess {
+ _effects.emit(ProfileEffect.SignedOut)
+ }
+ .onFailure {
+ Logger.error("Sign out failed", it, context = TAG)
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.profile__sign_out_title),
+ description = it.message,
+ )
+ }
+ _isSigningOut.update { false }
+ }
+ }
+
+ fun copyPublicKey() {
+ val pk = pubkyRepo.publicKey.value ?: return
+ context.setClipboardText(pk, context.getString(R.string.profile__public_key))
+ viewModelScope.launch {
+ ToastEventBus.send(
+ type = Toast.ToastType.SUCCESS,
+ title = context.getString(R.string.common__copied),
+ )
+ }
+ }
+}
+
+data class ProfileUiState(
+ val profile: PubkyProfile? = null,
+ val publicKey: String? = null,
+ val isLoading: Boolean = false,
+ val showSignOutDialog: Boolean = false,
+ val isSigningOut: Boolean = false,
+)
+
+sealed interface ProfileEffect {
+ data object SignedOut : ProfileEffect
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyRingAuthScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyRingAuthScreen.kt
new file mode 100644
index 000000000..dec42a166
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyRingAuthScreen.kt
@@ -0,0 +1,196 @@
+package to.bitkit.ui.screens.profile
+
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import to.bitkit.R
+import to.bitkit.ui.components.BodyM
+import to.bitkit.ui.components.Display
+import to.bitkit.ui.components.FillHeight
+import to.bitkit.ui.components.HorizontalSpacer
+import to.bitkit.ui.components.PrimaryButton
+import to.bitkit.ui.components.SecondaryButton
+import to.bitkit.ui.components.VerticalSpacer
+import to.bitkit.ui.scaffold.AppAlertDialog
+import to.bitkit.ui.scaffold.AppTopBar
+import to.bitkit.ui.shared.util.screen
+import to.bitkit.ui.theme.AppThemeSurface
+import to.bitkit.ui.theme.Colors
+import to.bitkit.ui.utils.withAccent
+
+private const val PUBKY_RING_PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=to.pubky.ring"
+private const val BG_IMAGE_WIDTH_FRACTION = 0.83f
+private const val TAG_OFFSET_X = -0.179f
+private const val TAG_OFFSET_Y = -0.124f
+private const val KEYRING_OFFSET_X = 0.341f
+private const val KEYRING_OFFSET_Y = -0.195f
+private const val TAG_ALPHA = 0.6f
+private const val KEYRING_ALPHA = 0.9f
+
+@Composable
+fun PubkyRingAuthScreen(
+ viewModel: PubkyRingAuthViewModel,
+ onBackClick: () -> Unit,
+ onAuthenticated: () -> Unit,
+) {
+ val context = LocalContext.current
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect {
+ when (it) {
+ PubkyRingAuthEffect.Authenticated -> onAuthenticated()
+ }
+ }
+ }
+
+ Content(
+ uiState = uiState,
+ onBackClick = onBackClick,
+ onDownload = {
+ context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PUBKY_RING_PLAY_STORE_URL)))
+ },
+ onAuthorize = { viewModel.authenticate() },
+ onDismissDialog = { viewModel.dismissRingNotInstalledDialog() },
+ )
+}
+
+@Composable
+private fun Content(
+ uiState: PubkyRingAuthUiState,
+ onBackClick: () -> Unit,
+ onDownload: () -> Unit,
+ onAuthorize: () -> Unit,
+ onDismissDialog: () -> Unit,
+) {
+ Box(
+ modifier = Modifier
+ .screen()
+ .clipToBounds()
+ ) {
+ BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
+ Image(
+ painter = painterResource(R.drawable.tag_pubky),
+ contentDescription = null,
+ contentScale = ContentScale.Fit,
+ modifier = Modifier
+ .fillMaxWidth(BG_IMAGE_WIDTH_FRACTION)
+ .align(Alignment.Center)
+ .offset(x = maxWidth * TAG_OFFSET_X, y = maxHeight * TAG_OFFSET_Y)
+ .alpha(TAG_ALPHA)
+ )
+
+ Image(
+ painter = painterResource(R.drawable.keyring),
+ contentDescription = null,
+ contentScale = ContentScale.Fit,
+ modifier = Modifier
+ .fillMaxWidth(BG_IMAGE_WIDTH_FRACTION)
+ .align(Alignment.Center)
+ .offset(x = maxWidth * KEYRING_OFFSET_X, y = maxHeight * KEYRING_OFFSET_Y)
+ .alpha(KEYRING_ALPHA)
+ )
+ }
+
+ Column(modifier = Modifier.fillMaxSize()) {
+ AppTopBar(
+ titleText = stringResource(R.string.profile__nav_title),
+ onBackClick = onBackClick,
+ )
+
+ FillHeight()
+
+ Column(modifier = Modifier.padding(horizontal = 32.dp)) {
+ Image(
+ painter = painterResource(R.drawable.pubky_ring_logo),
+ contentDescription = null,
+ modifier = Modifier.height(36.dp)
+ )
+ VerticalSpacer(24.dp)
+
+ Display(
+ text = stringResource(R.string.profile__ring_auth_title)
+ .withAccent(accentColor = Colors.PubkyGreen),
+ color = Colors.White,
+ )
+ VerticalSpacer(8.dp)
+
+ BodyM(
+ text = if (uiState.isWaitingForRing) {
+ stringResource(R.string.profile__ring_waiting)
+ } else {
+ stringResource(R.string.profile__ring_auth_description)
+ },
+ color = Colors.White64,
+ )
+ VerticalSpacer(24.dp)
+
+ Row {
+ SecondaryButton(
+ text = stringResource(R.string.profile__ring_download),
+ onClick = onDownload,
+ modifier = Modifier.weight(1f)
+ )
+ HorizontalSpacer(16.dp)
+ PrimaryButton(
+ text = stringResource(R.string.profile__ring_authorize),
+ isLoading = uiState.isAuthenticating,
+ onClick = onAuthorize,
+ modifier = Modifier.weight(1f)
+ )
+ }
+ VerticalSpacer(16.dp)
+ }
+ }
+ }
+
+ if (uiState.showRingNotInstalledDialog) {
+ AppAlertDialog(
+ title = stringResource(R.string.profile__ring_not_installed_title),
+ text = stringResource(R.string.profile__ring_not_installed_description),
+ confirmText = stringResource(R.string.profile__ring_download),
+ onConfirm = {
+ onDismissDialog()
+ onDownload()
+ },
+ onDismiss = onDismissDialog,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun Preview() {
+ AppThemeSurface {
+ Content(
+ uiState = PubkyRingAuthUiState(),
+ onBackClick = {},
+ onDownload = {},
+ onAuthorize = {},
+ onDismissDialog = {},
+ )
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyRingAuthViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyRingAuthViewModel.kt
new file mode 100644
index 000000000..3c9f9c3c8
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyRingAuthViewModel.kt
@@ -0,0 +1,124 @@
+package to.bitkit.ui.screens.profile
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import to.bitkit.R
+import to.bitkit.models.Toast
+import to.bitkit.repositories.PubkyRepo
+import to.bitkit.ui.shared.toast.ToastEventBus
+import to.bitkit.utils.Logger
+import javax.inject.Inject
+
+@HiltViewModel
+class PubkyRingAuthViewModel @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val pubkyRepo: PubkyRepo,
+) : ViewModel() {
+ companion object {
+ private const val TAG = "PubkyRingAuthViewModel"
+ }
+
+ private val _uiState = MutableStateFlow(PubkyRingAuthUiState())
+ val uiState = _uiState.asStateFlow()
+
+ private val _effects = MutableSharedFlow(extraBufferCapacity = 1)
+ val effects = _effects.asSharedFlow()
+
+ private var approvalJob: Job? = null
+
+ override fun onCleared() {
+ super.onCleared()
+ if (_uiState.value.isWaitingForRing || _uiState.value.isAuthenticating) {
+ pubkyRepo.cancelAuthenticationSync()
+ }
+ }
+
+ fun authenticate() {
+ viewModelScope.launch {
+ if (_uiState.value.isWaitingForRing) {
+ approvalJob?.cancel()
+ approvalJob = null
+ _uiState.update { it.copy(isWaitingForRing = false) }
+ pubkyRepo.cancelAuthentication()
+ }
+
+ _uiState.update { it.copy(isAuthenticating = true) }
+
+ pubkyRepo.startAuthentication()
+ .onSuccess { authUrl ->
+ _uiState.update { it.copy(isAuthenticating = false) }
+
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl)).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ val canOpen = intent.resolveActivity(context.packageManager) != null
+ if (!canOpen) {
+ approvalJob?.cancel()
+ pubkyRepo.cancelAuthentication()
+ _uiState.update { it.copy(showRingNotInstalledDialog = true) }
+ return@launch
+ }
+
+ _uiState.update { it.copy(isWaitingForRing = true) }
+ context.startActivity(intent)
+ waitForApproval()
+ }
+ .onFailure {
+ Logger.error("Authentication failed", it, context = TAG)
+ _uiState.update { it.copy(isAuthenticating = false) }
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.profile__auth_error_title),
+ description = it.message,
+ )
+ }
+ }
+ }
+
+ private fun waitForApproval() {
+ if (approvalJob?.isActive == true) return
+
+ approvalJob = viewModelScope.launch {
+ pubkyRepo.completeAuthentication()
+ .onSuccess {
+ _uiState.update { it.copy(isWaitingForRing = false) }
+ _effects.emit(PubkyRingAuthEffect.Authenticated)
+ }
+ .onFailure {
+ Logger.error("Auth approval failed", it, context = TAG)
+ _uiState.update { it.copy(isWaitingForRing = false) }
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.profile__auth_error_title),
+ description = it.message,
+ )
+ }
+ }
+ }
+
+ fun dismissRingNotInstalledDialog() {
+ _uiState.update { it.copy(showRingNotInstalledDialog = false) }
+ }
+}
+
+data class PubkyRingAuthUiState(
+ val isAuthenticating: Boolean = false,
+ val isWaitingForRing: Boolean = false,
+ val showRingNotInstalledDialog: Boolean = false,
+)
+
+sealed interface PubkyRingAuthEffect {
+ data object Authenticated : PubkyRingAuthEffect
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt
index 8d92cfe56..6b11cf04c 100644
--- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt
@@ -16,11 +16,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue
@@ -41,6 +43,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
@@ -77,12 +80,14 @@ import to.bitkit.ui.components.AppStatus
import to.bitkit.ui.components.BalanceHeaderView
import to.bitkit.ui.components.EmptyStateView
import to.bitkit.ui.components.HorizontalSpacer
+import to.bitkit.ui.components.PubkyImage
import to.bitkit.ui.components.Sheet
import to.bitkit.ui.components.StatusBarSpacer
import to.bitkit.ui.components.SuggestionCard
import to.bitkit.ui.components.TabBar
import to.bitkit.ui.components.TertiaryButton
import to.bitkit.ui.components.Text13Up
+import to.bitkit.ui.components.Title
import to.bitkit.ui.components.TopBarSpacer
import to.bitkit.ui.components.VerticalSpacer
import to.bitkit.ui.components.WalletBalanceView
@@ -90,6 +95,7 @@ import to.bitkit.ui.currencyViewModel
import to.bitkit.ui.navigateTo
import to.bitkit.ui.navigateToActivityItem
import to.bitkit.ui.navigateToAllActivity
+import to.bitkit.ui.navigateToProfile
import to.bitkit.ui.navigateToTransferFunding
import to.bitkit.ui.navigateToTransferIntro
import to.bitkit.ui.scaffold.AppAlertDialog
@@ -132,6 +138,9 @@ fun HomeScreen(
val hasSeenTransferIntro by settingsViewModel.hasSeenTransferIntro.collectAsStateWithLifecycle()
val hasSeenShopIntro by settingsViewModel.hasSeenShopIntro.collectAsStateWithLifecycle()
val hasSeenProfileIntro by settingsViewModel.hasSeenProfileIntro.collectAsStateWithLifecycle()
+ val isPubkyAuthenticated by settingsViewModel.isPubkyAuthenticated.collectAsStateWithLifecycle()
+ val profileDisplayName by homeViewModel.profileDisplayName.collectAsStateWithLifecycle()
+ val profileDisplayImageUri by homeViewModel.profileDisplayImageUri.collectAsStateWithLifecycle()
val hasSeenWidgetsIntro: Boolean by settingsViewModel.hasSeenWidgetsIntro.collectAsStateWithLifecycle()
val bgPaymentsIntroSeen: Boolean by settingsViewModel.bgPaymentsIntroSeen.collectAsStateWithLifecycle()
val quickPayIntroSeen by settingsViewModel.quickPayIntroSeen.collectAsStateWithLifecycle()
@@ -153,12 +162,22 @@ fun HomeScreen(
DeleteWidgetAlert(type, homeViewModel)
}
+ val navigateToProfile = {
+ rootNavController.navigateToProfile(
+ isAuthenticated = isPubkyAuthenticated,
+ hasSeenIntro = hasSeenProfileIntro,
+ )
+ }
+
Content(
isRefreshing = isRefreshing,
homeUiState = homeUiState,
rootNavController = rootNavController,
walletNavController = walletNavController,
drawerState = drawerState,
+ profileDisplayName = profileDisplayName,
+ profileDisplayImageUri = profileDisplayImageUri,
+ onClickProfile = navigateToProfile,
latestActivities = latestActivities,
onRefresh = {
activityListViewModel.resync()
@@ -203,9 +222,7 @@ fun HomeScreen(
)
}
- Suggestion.PROFILE -> {
- rootNavController.navigateTo(Routes.Profile)
- }
+ Suggestion.PROFILE -> navigateToProfile()
Suggestion.SHOP -> {
if (!hasSeenShopIntro) {
@@ -271,6 +288,9 @@ private fun Content(
rootNavController: NavController,
walletNavController: NavController,
drawerState: DrawerState,
+ profileDisplayName: String? = null,
+ profileDisplayImageUri: String? = null,
+ onClickProfile: () -> Unit = {},
hazeState: HazeState = rememberHazeState(),
latestActivities: List?,
onRefresh: () -> Unit = {},
@@ -294,6 +314,9 @@ private fun Content(
rootNavController = rootNavController,
scope = scope,
drawerState = drawerState,
+ profileDisplayName = profileDisplayName,
+ profileDisplayImageUri = profileDisplayImageUri,
+ onClickProfile = onClickProfile,
)
val pullToRefreshState = rememberPullToRefreshState()
PullToRefreshBox(
@@ -614,6 +637,9 @@ private fun TopBar(
rootNavController: NavController,
scope: CoroutineScope,
drawerState: DrawerState,
+ profileDisplayName: String? = null,
+ profileDisplayImageUri: String? = null,
+ onClickProfile: () -> Unit = {},
) {
val topbarGradient = Brush.verticalGradient(
colorStops = arrayOf(
@@ -632,7 +658,13 @@ private fun TopBar(
.zIndex(1f)
) {
TopAppBar(
- title = {},
+ title = {
+ ProfileButton(
+ displayName = profileDisplayName,
+ displayImageUri = profileDisplayImageUri,
+ onClick = onClickProfile,
+ )
+ },
actions = {
AppStatus(onClick = { rootNavController.navigateTo(Routes.AppStatus) })
HorizontalSpacer(4.dp)
@@ -652,6 +684,48 @@ private fun TopBar(
}
}
+@Composable
+private fun ProfileButton(
+ displayName: String?,
+ displayImageUri: String?,
+ onClick: () -> Unit,
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier
+ .clickableAlpha(onClick = onClick)
+ .testTag("ProfileButton")
+ ) {
+ if (displayImageUri != null) {
+ PubkyImage(
+ uri = displayImageUri,
+ size = 32.dp,
+ )
+ } else {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(32.dp)
+ .clip(CircleShape)
+ .background(Colors.Gray4)
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_user_square),
+ contentDescription = null,
+ tint = Colors.White32,
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ }
+
+ Title(
+ text = displayName ?: stringResource(R.string.profile__your_name),
+ maxLines = 1,
+ )
+ }
+}
+
@Composable
private fun DeleteWidgetAlert(
type: WidgetType,
diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt
index a2e6e563d..69f9ffa15 100644
--- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt
@@ -24,6 +24,7 @@ import to.bitkit.models.toSuggestionOrNull
import to.bitkit.models.widget.ArticleModel
import to.bitkit.models.widget.toArticleModel
import to.bitkit.models.widget.toBlockModel
+import to.bitkit.repositories.PubkyRepo
import to.bitkit.repositories.TransferRepo
import to.bitkit.repositories.WalletRepo
import to.bitkit.repositories.WidgetsRepo
@@ -38,11 +39,15 @@ class HomeViewModel @Inject constructor(
private val widgetsRepo: WidgetsRepo,
private val settingsStore: SettingsStore,
private val transferRepo: TransferRepo,
+ private val pubkyRepo: PubkyRepo,
) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow = _uiState.asStateFlow()
+ val profileDisplayName = pubkyRepo.displayName
+ val profileDisplayImageUri = pubkyRepo.displayImageUri
+
private val _currentArticle = MutableStateFlow(null)
private val _currentFact = MutableStateFlow(null)
@@ -248,7 +253,8 @@ class HomeViewModel @Inject constructor(
walletRepo.balanceState,
settingsStore.data,
transferRepo.activeTransfers,
- ) { balanceState, settings, transfers ->
+ pubkyRepo.isAuthenticated,
+ ) { balanceState, settings, transfers, profileAuthenticated ->
val baseSuggestions = when {
balanceState.totalLightningSats > 0uL -> { // With Lightning
listOfNotNull(
@@ -260,7 +266,7 @@ class HomeViewModel @Inject constructor(
Suggestion.QUICK_PAY,
Suggestion.NOTIFICATIONS.takeIf { !settings.notificationsGranted },
Suggestion.SHOP,
- Suggestion.PROFILE,
+ Suggestion.PROFILE.takeIf { !profileAuthenticated },
)
}
@@ -275,7 +281,7 @@ class HomeViewModel @Inject constructor(
Suggestion.SUPPORT,
Suggestion.INVITE,
Suggestion.SHOP,
- Suggestion.PROFILE,
+ Suggestion.PROFILE.takeIf { !profileAuthenticated },
)
}
@@ -289,7 +295,7 @@ class HomeViewModel @Inject constructor(
Suggestion.SECURE.takeIf { !settings.isPinEnabled },
Suggestion.SUPPORT,
Suggestion.INVITE,
- Suggestion.PROFILE,
+ Suggestion.PROFILE.takeIf { !profileAuthenticated },
)
}
}
diff --git a/app/src/main/java/to/bitkit/ui/theme/Colors.kt b/app/src/main/java/to/bitkit/ui/theme/Colors.kt
index cd5c38b47..c4dd6f7b7 100644
--- a/app/src/main/java/to/bitkit/ui/theme/Colors.kt
+++ b/app/src/main/java/to/bitkit/ui/theme/Colors.kt
@@ -10,6 +10,7 @@ object Colors {
val Purple = Color(0xFFB95CE8)
val Red = Color(0xFFE95164)
val Yellow = Color(0xFFFFD200)
+ val PubkyGreen = Color(0xFFBEFF00)
// Base
val Black = Color(0xFF000000)
@@ -55,4 +56,5 @@ object Colors {
val Red24 = Red.copy(alpha = 0.24f)
val Yellow16 = Yellow.copy(alpha = 0.16f)
val Yellow24 = Yellow.copy(alpha = 0.24f)
+ val PubkyGreen24 = PubkyGreen.copy(alpha = 0.24f)
}
diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
index b4cdfb8d1..df106a0e0 100644
--- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
@@ -108,6 +108,7 @@ import to.bitkit.repositories.PendingPaymentNotification
import to.bitkit.repositories.PendingPaymentRepo
import to.bitkit.repositories.PendingPaymentResolution
import to.bitkit.repositories.PreActivityMetadataRepo
+import to.bitkit.repositories.PubkyRepo
import to.bitkit.repositories.TransferRepo
import to.bitkit.repositories.WalletRepo
import to.bitkit.services.AppUpdaterService
@@ -164,6 +165,7 @@ class AppViewModel @Inject constructor(
private val transferRepo: TransferRepo,
private val migrationService: MigrationService,
private val coreService: CoreService,
+ @Suppress("UnusedPrivateProperty") private val pubkyRepo: PubkyRepo,
private val appUpdateSheet: AppUpdateTimedSheet,
private val backupSheet: BackupTimedSheet,
private val notificationsSheet: NotificationsTimedSheet,
diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt
index ba3e42286..f89ebb13d 100644
--- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt
@@ -11,12 +11,14 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import to.bitkit.data.SettingsStore
import to.bitkit.models.TransactionSpeed
+import to.bitkit.repositories.PubkyRepo
import javax.inject.Inject
@Suppress("TooManyFunctions")
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val settingsStore: SettingsStore,
+ private val pubkyRepo: PubkyRepo,
) : ViewModel() {
fun reset() = viewModelScope.launch { settingsStore.reset() }
@@ -91,6 +93,17 @@ class SettingsViewModel @Inject constructor(
}
}
+ val hasSeenContactsIntro = settingsStore.data.map { it.hasSeenContactsIntro }
+ .asStateFlow(initialValue = false)
+
+ fun setHasSeenContactsIntro(value: Boolean) {
+ viewModelScope.launch {
+ settingsStore.update { it.copy(hasSeenContactsIntro = value) }
+ }
+ }
+
+ val isPubkyAuthenticated = pubkyRepo.isAuthenticated
+
val quickPayIntroSeen = settingsStore.data.map { it.quickPayIntroSeen }
.asStateFlow(initialValue = false)
diff --git a/app/src/main/res/drawable-nodpi/contacts_intro.webp b/app/src/main/res/drawable-nodpi/contacts_intro.webp
new file mode 100644
index 000000000..4d34b0d3a
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/contacts_intro.webp differ
diff --git a/app/src/main/res/drawable-nodpi/pubky_ring_logo.png b/app/src/main/res/drawable-nodpi/pubky_ring_logo.png
new file mode 100644
index 000000000..86109b892
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/pubky_ring_logo.png differ
diff --git a/app/src/main/res/drawable-nodpi/tag_pubky.webp b/app/src/main/res/drawable-nodpi/tag_pubky.webp
new file mode 100644
index 000000000..26ebd6bc9
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/tag_pubky.webp differ
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index fef823c9e..93df09f5a 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -716,10 +716,6 @@
الأدوات
عرض عناوين الأدوات
الأدوات
- امتلك\n<accent>ملفك الشخصي</accent>
- أعد ملفك الشخصي العام وروابطك، حتى تتمكن جهات اتصال Bitkit من الوصول إليك أو الدفع لك في أي وقت وأي مكان.
- الملف الشخصي
- إنشاء ملف شخصي
%1$s sats
يرجى الانتظار بينما يبحث Bitkit عن أموال في عناوين غير مدعومة (Legacy و Nested SegWit و Taproot).
جارٍ البحث عن أموال...
diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml
index 8c51ff1e6..282187600 100644
--- a/app/src/main/res/values-b+es+419/strings.xml
+++ b/app/src/main/res/values-b+es+419/strings.xml
@@ -716,10 +716,6 @@
Widgets
Mostrar títulos de widgets
Widgets
- Sea dueño de su\n<accent>perfil</accent>
- Configura tu perfil público y tus enlaces para que tus contactos en Bitkit puedan encontrarte o pagarte en cualquier momento y lugar.
- Perfil
- Crear perfil
%1$s sats
Por favor, espera mientras Bitkit busca fondos en direcciones no compatibles (Legacy, Nested SegWit y Taproot).
BUSCANDO FONDOS...
diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml
index 51078ac0b..28f2cf7ab 100644
--- a/app/src/main/res/values-ca/strings.xml
+++ b/app/src/main/res/values-ca/strings.xml
@@ -716,10 +716,6 @@
Ginys
Mostrar títols de ginys
Ginys
- Sigues propietari\n<accent>del teu perfil</accent>
- Configura el teu perfil públic i enllaços, perquè els teus contactes de Bitkit puguin contactar-te o pagar-te en qualsevol moment i lloc.
- Perfil
- Crear perfil
%1$s sats
Si us plau, espera mentre Bitkit cerca fons en adreces no compatibles (Legacy, Nested SegWit i Taproot).
CERCANT FONS...
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index ea6cd0eda..fd61ad70d 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -716,10 +716,6 @@
Widgety
Zobrazit názvy widgetů
Widgety
- Vlastněte svůj\n<accent>profil</accent>
- Nastavte si veřejný profil a odkazy, aby vás kontakty ze služby Bitkit mohly kdykoli a kdekoli kontaktovat nebo vám zaplatit.
- Profil
- Vytvořit profil
%1$s sats
Počkejte prosím, Bitkit hledá prostředky na nepodporovaných adresách (Legacy, Nested SegWit a Taproot).
HLEDÁNÍ PROSTŘEDKŮ...
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 253240aca..e50e26734 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -748,10 +748,6 @@
Gesamt
%1$s, %2$d UTXO
%1$s, %2$d UTXOs
- Besitzen Sie Ihr\n<accent>Profil</accent>
- Richte dein öffentliches Profil und Links ein, damit deine Bitkit-Kontakte dich erreichen oder bezahlen können, jederzeit und überall.
- Profil
- Profil erstellen
Wallet
Aktivität
Kontakte
diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml
index 77202c670..f6c7b5107 100644
--- a/app/src/main/res/values-el/strings.xml
+++ b/app/src/main/res/values-el/strings.xml
@@ -716,10 +716,6 @@
Widgets
Εμφάνιση τίτλων Widget
Widgets
- Κατέχεις το\n<accent>προφίλ σου</accent>
- Ρύθμισε το δημόσιο προφίλ και τους συνδέσμους σου, ώστε οι επαφές σου στο Bitkit να μπορούν να επικοινωνήσουν ή να σε πληρώσουν οποτεδήποτε, οπουδήποτε.
- Προφίλ
- Δημιουργία προφίλ
%1$s sats
Περίμενε όσο το Bitkit αναζητά κεφάλαια σε μη υποστηριζόμενες διευθύνσεις (Legacy, Nested SegWit και Taproot).
ΑΝΑΖΗΤΗΣΗ ΚΕΦΑΛΑΙΩΝ...
diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml
index 915e2af1f..89dcd00a6 100644
--- a/app/src/main/res/values-es-rES/strings.xml
+++ b/app/src/main/res/values-es-rES/strings.xml
@@ -716,10 +716,6 @@
Widgets
Mostrar Títulos de Widgets
Widgets
- Sé dueño de\n<accent>tu perfil</accent>
- Configura tu perfil público y enlaces para que tus contactos Bitkit puedan localizarte o pagarte en cualquier momento y lugar.
- Perfil
- Crear perfil
%1$s sats
Por favor espera mientras Bitkit busca fondos en direcciones no compatibles (Legacy, Nested SegWit y Taproot).
BUSCANDO FONDOS...
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 5ff4ae679..540dda0cd 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -716,10 +716,6 @@
Conectar
Bitkit se ha conectado correctamente al servidor Rapid-Gossip-Sync especificado.
Servidor Rapid-Gossip-Sync Actualizado
- Configura tu perfil público y enlaces para que tus contactos Bitkit puedan localizarte o pagarte en cualquier momento y lugar.
- Configura tu\n<accent>perfil público</accent>
- Perfil
- Crear perfil
Saldo: {balance}
Por favor espera mientras Bitkit comprueba si hay fondos en esta dirección.
Comprobando si hay fondos...
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 46726416b..cc929b7b5 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -716,10 +716,6 @@
Connecter
Mise à jour du serveur Rapid-Gossip-Sync
Il se peut que vous deviez redémarrer l\'application une ou deux fois pour que cette modification prenne effet.
- Détenez votre \n<accent>profil</accent>
- Configurez votre profil public et vos liens, afin que vos contacts Bitkit puissent vous joindre ou vous payer à tout moment et en tout lieu.
- Profil
- Créer un profil
%1$s sats
Veuillez patienter pendant que Bitkit recherche des fonds dans les adresses non prises en charge (Legacy, Nested SegWit et Taproot).
RECHERCHE DE FONDS...
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 29712f642..7eb018115 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -716,10 +716,6 @@
Widget
Mostra titoli dei widget
Widget
- Possiedi il tuo\n<accent>profilo</accent>
- Configura il tuo profilo pubblico e i tuoi collegamenti, in modo che i tuoi contatti Bitkit possano raggiungerti o pagarti sempre e ovunque.
- Profilo
- Crea Profilo
%1$s sats
Attendi mentre Bitkit cerca fondi in indirizzi non supportati (Legacy, Nested SegWit e Taproot).
RICERCA FONDI IN CORSO...
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index d061b0b86..d17e78db9 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -716,10 +716,6 @@
Widgets
Widgettitels tonen
Widgets
- Beheer je\n<accent>profiel</accent>
- Stel je openbare profiel en links in, zodat je Bitkit-contacten je kunnen bereiken of betalen, altijd en overal.
- Profiel
- Profiel aanmaken
%1$s sats
Even geduld terwijl Bitkit zoekt naar geld op niet-ondersteunde adressen (Legacy, Nested SegWit en Taproot).
ZOEKEN NAAR GELD...
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index 9e6f901f9..0f007f6a9 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -716,10 +716,6 @@
Połącz
Rapid-Gossip-Sync zaktualizowany
Zmiana może wymagać ponownego uruchomienia aplikacji raz lub dwa razy.
- Własny\n<accent>profil</accent>
- Skonfiguruj swój publiczny profil i linki, aby Twoje kontakty w Bitkit mogły skontaktować się z Tobą lub zapłacić Ci w dowolnym miejscu i czasie.
- Profil
- Utwórz profil
%1$s sats
Proszę czekać, Bitkit szuka środków na nieobsługiwanych adresach (Legacy, Nested SegWit i Taproot).
SZUKANIE ŚRODKÓW...
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index d6a47db31..bdb4da4d9 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -716,10 +716,6 @@
Widgets
Mostrar Títulos dos Widgets
Widgets
- Tenha seu próprio\n<accent>perfil</accent>
- Configure seu perfil público e seus links, para que seus contatos possam pagá-lo a qualquer hora e em qualquer lugar.
- Perfil
- Criar Perfil
%1$s sats
Aguarde enquanto a Bitkit procura fundos em endereços não suportados (Legacy, Nested SegWit e Taproot).
PROCURANDO FUNDOS...
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index fe2229dbc..85ffa3288 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -715,10 +715,6 @@
Seleção aleatória para privacidade
Single Random Draw
Idioma
- Tenha seu próprio\n<accent>perfil</accent>
- Configure seu perfil público e seus links, para que seus contatos possam pagá-lo a qualquer hora e em qualquer lugar.
- Perfil
- Criar Perfil
Carteira
Atividade
Contatos
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index e2733bb72..68a949842 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -732,10 +732,6 @@
Виджеты
Показывать Заголовки Виджетов
Виджеты
- Владейте своим\n<accent>профилем</accent>
- Настройте свой публичный профиль и ссылки, чтобы ваши контакты Bitkit могли связаться с вами или заплатить вам в любое время и в любом месте.
- Профиль
- Создать Профиль
%1\$s sats
Пожалуйста, подождите, пока Bitkit ищет средства на неподдерживаемых адресах (Legacy, Nested SegWit и Taproot).
ПОИСК СРЕДСТВ...
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index bb1cf10e5..1f408c00a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -67,6 +67,12 @@
Usable
Yes
Yes, Proceed
+ Contact
+ You don\'t have any contacts yet.
+ Get automatic updates from contacts, pay them, and follow their public profiles.
+ Dynamic\n<accent>contacts</accent>
+ MY PROFILE
+ Contacts
Depends on the fee
Depends on the fee
Custom
@@ -428,6 +434,25 @@
Update Available
Please update Bitkit to the latest version for new features and bug fixes!
Update\n<accent>Bitkit</accent>
+ Authorization Failed
+ Unable to load your profile.
+ Set up your portable pubky profile, so your contacts can reach you or pay you anytime, anywhere in the ecosystem.
+ Portable\n<accent>pubky profile</accent>
+ Profile
+ Public Key
+ Scan to add {name}
+ Try Again
+ Please authorize Bitkit with Pubky Ring, your mobile keychain for the next web.
+ Join the\n<accent>pubky web</accent>
+ Authorize
+ Download
+ Pubky Ring is required to authorize your profile. Would you like to download it?
+ Pubky Ring Not Installed
+ Waiting for authorization from Pubky Ring…
+ Disconnect
+ This will disconnect your Pubky profile from Bitkit. You can reconnect at any time.
+ Disconnect Profile
+ Your Name
Back Up
Now that you have some funds in your wallet, it is time to back up your money!
There are no funds in your wallet yet, but you can create a backup if you wish.
@@ -735,10 +760,6 @@
Widgets
Show Widget Titles
Widgets
- Own your\n<accent>profile</accent>
- Set up your public profile and links, so your Bitkit contacts can reach you or pay you anytime, anywhere.
- Profile
- Create Profile
%1$s sats
Please wait while Bitkit looks for funds in unsupported addresses (Legacy, Nested SegWit, and Taproot).
LOOKING FOR FUNDS...
diff --git a/app/src/test/java/to/bitkit/data/PubkyImageFetcherTest.kt b/app/src/test/java/to/bitkit/data/PubkyImageFetcherTest.kt
new file mode 100644
index 000000000..3c6b697f5
--- /dev/null
+++ b/app/src/test/java/to/bitkit/data/PubkyImageFetcherTest.kt
@@ -0,0 +1,88 @@
+package to.bitkit.data
+
+import androidx.test.core.app.ApplicationProvider
+import coil3.request.Options
+import coil3.size.Size
+import coil3.toUri
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import to.bitkit.services.PubkyService
+import to.bitkit.test.BaseUnitTest
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class PubkyImageFetcherTest : BaseUnitTest() {
+
+ private val pubkyService = mock()
+ private val factory = PubkyImageFetcher.Factory(pubkyService)
+ private val options = Options(ApplicationProvider.getApplicationContext(), size = Size.ORIGINAL)
+
+ @Test
+ fun `factory should return fetcher for pubky uris`() = test {
+ val fetcher = factory.create("pubky://image_uri".toUri(), options, mock())
+
+ assertNotNull(fetcher)
+ }
+
+ @Test
+ fun `factory should return null for non-pubky uris`() = test {
+ val fetcher = factory.create("https://example.com/image.png".toUri(), options, mock())
+
+ assertNull(fetcher)
+ }
+
+ @Test
+ fun `fetch should return raw data when response is not json`() = test {
+ val imageBytes = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47) // PNG header
+ whenever(pubkyService.fetchFile("pubky://image")).thenReturn(imageBytes)
+ val fetcher = PubkyImageFetcher("pubky://image", options, pubkyService)
+
+ val result = fetcher.fetch()
+
+ assertNotNull(result)
+ verify(pubkyService).fetchFile("pubky://image")
+ }
+
+ @Test
+ fun `fetch should follow json file descriptor with pubky src`() = test {
+ val descriptor = """{"src": "pubky://blob_uri"}""".toByteArray()
+ val blobBytes = byteArrayOf(0xFF.toByte(), 0xD8.toByte()) // JPEG header
+ whenever(pubkyService.fetchFile("pubky://image")).thenReturn(descriptor)
+ whenever(pubkyService.fetchFile("pubky://blob_uri")).thenReturn(blobBytes)
+ val fetcher = PubkyImageFetcher("pubky://image", options, pubkyService)
+
+ fetcher.fetch()
+
+ verify(pubkyService).fetchFile("pubky://blob_uri")
+ }
+
+ @Test
+ fun `fetch should not follow json src with non-pubky scheme`() = test {
+ val descriptor = """{"src": "https://example.com/image.png"}""".toByteArray()
+ whenever(pubkyService.fetchFile("pubky://image")).thenReturn(descriptor)
+ val fetcher = PubkyImageFetcher("pubky://image", options, pubkyService)
+
+ fetcher.fetch()
+
+ verify(pubkyService, never()).fetchFile("https://example.com/image.png")
+ }
+
+ @Test
+ fun `fetch should not follow json without src field`() = test {
+ val json = """{"name": "test"}""".toByteArray()
+ whenever(pubkyService.fetchFile("pubky://image")).thenReturn(json)
+ val fetcher = PubkyImageFetcher("pubky://image", options, pubkyService)
+
+ val result = fetcher.fetch()
+
+ assertNotNull(result)
+ }
+}
diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt
new file mode 100644
index 000000000..f79e80ae6
--- /dev/null
+++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt
@@ -0,0 +1,371 @@
+package to.bitkit.repositories
+
+import app.cash.turbine.test
+import coil3.ImageLoader
+import coil3.disk.DiskCache
+import coil3.memory.MemoryCache
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.atLeastOnce
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.verifyBlocking
+import org.mockito.kotlin.whenever
+import to.bitkit.data.PubkyStore
+import to.bitkit.data.PubkyStoreData
+import to.bitkit.data.keychain.Keychain
+import to.bitkit.services.PubkyService
+import to.bitkit.test.BaseUnitTest
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.milliseconds
+import com.synonym.bitkitcore.PubkyProfile as CorePubkyProfile
+
+class PubkyRepoTest : BaseUnitTest() {
+ private lateinit var sut: PubkyRepo
+
+ private val pubkyService = mock()
+ private val keychain = mock()
+ private val imageLoader = mock()
+ private val pubkyStore = mock()
+
+ @Before
+ fun setUp() = runBlocking {
+ whenever(pubkyStore.data).thenReturn(flowOf(PubkyStoreData()))
+ sut = createSut()
+ }
+
+ private fun createSut() = PubkyRepo(
+ ioDispatcher = testDispatcher,
+ pubkyService = pubkyService,
+ keychain = keychain,
+ imageLoader = imageLoader,
+ pubkyStore = pubkyStore,
+ )
+
+ @Test
+ fun `initial state should have no public key`() = test {
+ assertNull(sut.publicKey.value)
+ assertFalse(sut.isAuthenticated.value)
+ }
+
+ @Test
+ fun `startAuthentication should return auth uri on success`() = test {
+ val authUri = "pubky://auth?capabilities=..."
+ whenever(pubkyService.startAuth()).thenReturn(authUri)
+
+ val result = sut.startAuthentication()
+
+ assertTrue(result.isSuccess)
+ assertEquals(authUri, result.getOrNull())
+ }
+
+ @Test
+ fun `startAuthentication should reset state on failure`() = test {
+ whenever(pubkyService.startAuth()).thenThrow(RuntimeException("Auth failed"))
+
+ val result = sut.startAuthentication()
+
+ assertTrue(result.isFailure)
+ sut.isAuthenticated.test(timeout = 500.milliseconds) {
+ assertFalse(awaitItem())
+ }
+ }
+
+ @Test
+ fun `completeAuthentication should save session and update state`() = test {
+ val testSecret = "session_secret"
+ val testPk = "completed_pk"
+ whenever(pubkyService.completeAuth()).thenReturn(testSecret)
+ whenever(pubkyService.importSession(testSecret)).thenReturn(testPk)
+
+ val ffiProfile = mock()
+ whenever(ffiProfile.name).thenReturn("User")
+ whenever(pubkyService.getProfile(testPk)).thenReturn(ffiProfile)
+
+ val result = sut.completeAuthentication()
+
+ assertTrue(result.isSuccess)
+ assertEquals(testPk, sut.publicKey.value)
+ assertTrue(sut.isAuthenticated.value)
+ verifyBlocking(keychain) { saveString(Keychain.Key.PAYKIT_SESSION.name, testSecret) }
+ }
+
+ @Test
+ fun `completeAuthentication should reset state on failure`() = test {
+ whenever(pubkyService.completeAuth()).thenThrow(RuntimeException("Failed"))
+
+ val result = sut.completeAuthentication()
+
+ assertTrue(result.isFailure)
+ assertFalse(sut.isAuthenticated.value)
+ assertNull(sut.publicKey.value)
+ }
+
+ @Test
+ fun `cancelAuthentication should reset state to idle`() = test {
+ whenever(pubkyService.startAuth()).thenReturn("auth_uri")
+ sut.startAuthentication()
+
+ sut.cancelAuthentication()
+
+ assertFalse(sut.isAuthenticated.value)
+ }
+
+ @Test
+ fun `loadProfile should update profile on success`() = test {
+ authenticateForTesting()
+
+ val pk = checkNotNull(sut.publicKey.value) { "publicKey should be set after authentication" }
+ val ffiProfile = mock()
+ whenever(ffiProfile.name).thenReturn("Profile Name")
+ whenever(ffiProfile.bio).thenReturn("A bio")
+ whenever(ffiProfile.image).thenReturn("pubky://image_uri")
+ whenever(ffiProfile.status).thenReturn("active")
+ whenever(pubkyService.getProfile(pk)).thenReturn(ffiProfile)
+
+ sut.loadProfile()
+
+ val profile = sut.profile.value
+ assertNotNull(profile)
+ assertEquals("Profile Name", profile.name)
+ assertEquals("A bio", profile.bio)
+ assertEquals("pubky://image_uri", profile.imageUrl)
+ assertEquals("active", profile.status)
+ }
+
+ @Test
+ fun `loadProfile should keep existing profile on failure`() = test {
+ authenticateForTesting()
+ val existingProfile = sut.profile.value
+ assertNotNull(existingProfile)
+
+ val pk = checkNotNull(sut.publicKey.value) { "publicKey should be set after authentication" }
+ whenever(pubkyService.getProfile(pk)).thenThrow(RuntimeException("Network error"))
+
+ sut.loadProfile()
+
+ assertEquals(existingProfile, sut.profile.value)
+ assertFalse(sut.isLoadingProfile.value)
+ }
+
+ @Test
+ fun `loadProfile should return early when no public key`() = test {
+ sut.loadProfile()
+
+ verify(pubkyService, never()).getProfile(any())
+ }
+
+ @Test
+ fun `loadProfile should cache metadata on success`() = test {
+ authenticateForTesting()
+
+ val pk = checkNotNull(sut.publicKey.value) { "publicKey should be set after authentication" }
+ val ffiProfile = mock()
+ whenever(ffiProfile.name).thenReturn("Cached Name")
+ whenever(ffiProfile.bio).thenReturn("")
+ whenever(ffiProfile.image).thenReturn("pubky://cached_image")
+ whenever(pubkyService.getProfile(pk)).thenReturn(ffiProfile)
+
+ sut.loadProfile()
+
+ verifyBlocking(pubkyStore, atLeastOnce()) { update(any()) }
+ }
+
+ @Test
+ fun `signOut should clear state and keychain`() = test {
+ authenticateForTesting()
+
+ val result = sut.signOut()
+
+ assertTrue(result.isSuccess)
+ assertNull(sut.publicKey.value)
+ assertNull(sut.profile.value)
+ assertFalse(sut.isAuthenticated.value)
+ verifyBlocking(keychain, atLeastOnce()) { delete(Keychain.Key.PAYKIT_SESSION.name) }
+ verifyBlocking(pubkyStore) { reset() }
+ }
+
+ @Test
+ fun `signOut should evict pubky images from caches`() = test {
+ authenticateForTesting()
+ val pk = checkNotNull(sut.publicKey.value)
+ val ffiProfile = mock()
+ whenever(ffiProfile.name).thenReturn("Test")
+ whenever(ffiProfile.image).thenReturn("pubky://image_uri")
+ whenever(pubkyService.getProfile(pk)).thenReturn(ffiProfile)
+ sut.loadProfile()
+
+ val memoryCache = mock()
+ val diskCache = mock()
+ val memoryCacheKey = MemoryCache.Key("pubky://image_uri")
+ whenever(memoryCache.keys).thenReturn(setOf(memoryCacheKey))
+ whenever(imageLoader.memoryCache).thenReturn(memoryCache)
+ whenever(imageLoader.diskCache).thenReturn(diskCache)
+
+ sut.signOut()
+
+ verify(memoryCache).remove(memoryCacheKey)
+ verify(diskCache).remove("pubky://image_uri")
+ }
+
+ @Test
+ fun `signOut should force sign out when server sign out fails`() = test {
+ authenticateForTesting()
+ whenever(pubkyService.signOut()).thenThrow(RuntimeException("Server error"))
+
+ val result = sut.signOut()
+
+ assertTrue(result.isSuccess)
+ verifyBlocking(pubkyService) { forceSignOut() }
+ assertFalse(sut.isAuthenticated.value)
+ }
+
+ @Test
+ fun `displayName should return null when no profile and no cache`() = test {
+ sut.displayName.test(timeout = 500.milliseconds) {
+ assertNull(awaitItem())
+ }
+ }
+
+ @Test
+ fun `displayImageUri should return null when no profile and no cache`() = test {
+ sut.displayImageUri.test(timeout = 500.milliseconds) {
+ assertNull(awaitItem())
+ }
+ }
+
+ @Test
+ fun `displayName should return cached name when no profile`() = test {
+ whenever(pubkyStore.data).thenReturn(flowOf(PubkyStoreData(cachedName = "Cached")))
+ sut = createSut()
+
+ sut.displayName.test(timeout = 500.milliseconds) {
+ assertEquals("Cached", awaitItem())
+ }
+ }
+
+ @Test
+ fun `loadContacts should populate contacts on success`() = test {
+ authenticateForTesting()
+ val pk = checkNotNull(sut.publicKey.value) { "publicKey should be set after authentication" }
+ val contactKey = "pubky://contact1"
+ whenever(pubkyService.getContacts(pk)).thenReturn(listOf(contactKey))
+
+ val contactProfile = mock()
+ whenever(contactProfile.name).thenReturn("Alice")
+ whenever(pubkyService.getProfile(contactKey)).thenReturn(contactProfile)
+
+ sut.loadContacts()
+
+ val contacts = sut.contacts.value
+ assertEquals(1, contacts.size)
+ assertEquals("Alice", contacts.first().name)
+ assertFalse(sut.isLoadingContacts.value)
+ }
+
+ @Test
+ fun `loadContacts should return early when no public key`() = test {
+ sut.loadContacts()
+
+ verify(pubkyService, never()).getContacts(any())
+ }
+
+ @Test
+ fun `loadContacts should use placeholder when profile fetch fails`() = test {
+ authenticateForTesting()
+ val pk = checkNotNull(sut.publicKey.value) { "publicKey should be set after authentication" }
+ val contactKey = "pubky://contact2"
+ whenever(pubkyService.getContacts(pk)).thenReturn(listOf(contactKey))
+ whenever(pubkyService.getProfile(contactKey)).thenThrow(RuntimeException("Network error"))
+
+ sut.loadContacts()
+
+ val contacts = sut.contacts.value
+ assertEquals(1, contacts.size)
+ assertEquals(contactKey, contacts.first().publicKey)
+ assertFalse(sut.isLoadingContacts.value)
+ }
+
+ @Test
+ fun `fetchContactProfile should return profile on success`() = test {
+ val contactKey = "pubky://contact3"
+ val contactProfile = mock()
+ whenever(contactProfile.name).thenReturn("Bob")
+ whenever(contactProfile.bio).thenReturn("Bio")
+ whenever(pubkyService.getProfile(contactKey)).thenReturn(contactProfile)
+
+ val result = sut.fetchContactProfile(contactKey)
+
+ assertTrue(result.isSuccess)
+ assertEquals("Bob", result.getOrNull()?.name)
+ }
+
+ @Test
+ fun `fetchContactProfile should return failure on error`() = test {
+ val contactKey = "pubky://failing"
+ whenever(pubkyService.getProfile(contactKey)).thenThrow(RuntimeException("Failed"))
+
+ val result = sut.fetchContactProfile(contactKey)
+
+ assertTrue(result.isFailure)
+ }
+
+ @Test
+ fun `signOut should clear contacts`() = test {
+ authenticateForTesting()
+ val pk = checkNotNull(sut.publicKey.value) { "publicKey should be set after authentication" }
+ val contactKey = "pubky://contact4"
+ whenever(pubkyService.getContacts(pk)).thenReturn(listOf(contactKey))
+
+ val contactProfile = mock()
+ whenever(contactProfile.name).thenReturn("Charlie")
+ whenever(pubkyService.getProfile(contactKey)).thenReturn(contactProfile)
+
+ sut.loadContacts()
+ assertEquals(1, sut.contacts.value.size)
+
+ sut.signOut()
+
+ assertTrue(sut.contacts.value.isEmpty())
+ }
+
+ @Test
+ fun `loadContacts should prefix keys missing pubky prefix`() = test {
+ authenticateForTesting()
+ val pk = checkNotNull(sut.publicKey.value) { "publicKey should be set after authentication" }
+ val bareKey = "abc123"
+ val prefixedKey = "pubky://abc123"
+ whenever(pubkyService.getContacts(pk)).thenReturn(listOf(bareKey))
+
+ val contactProfile = mock()
+ whenever(contactProfile.name).thenReturn("Prefixed")
+ whenever(pubkyService.getProfile(prefixedKey)).thenReturn(contactProfile)
+
+ sut.loadContacts()
+
+ verify(pubkyService).getProfile(prefixedKey)
+ assertEquals("Prefixed", sut.contacts.value.first().name)
+ }
+
+ private suspend fun authenticateForTesting() {
+ val testSecret = "test_secret"
+ val testPk = "test_pk_12345"
+ whenever(pubkyService.completeAuth()).thenReturn(testSecret)
+ whenever(pubkyService.importSession(testSecret)).thenReturn(testPk)
+
+ val ffiProfile = mock()
+ whenever(ffiProfile.name).thenReturn("Test")
+ whenever(pubkyService.getProfile(testPk)).thenReturn(ffiProfile)
+ whenever(pubkyService.getContacts(testPk)).thenReturn(emptyList())
+
+ sut.completeAuthentication()
+ }
+}
diff --git a/docs/pubky.md b/docs/pubky.md
new file mode 100644
index 000000000..2fbdc0612
--- /dev/null
+++ b/docs/pubky.md
@@ -0,0 +1,160 @@
+# Pubky Integration
+
+## Overview
+
+Bitkit integrates [Pubky](https://pubky.org) decentralized identity, allowing users to connect their Pubky profile via [Pubky Ring](https://play.google.com/store/apps/details?id=to.pubky.ring) authentication. Once connected, the user's profile name and avatar appear on the home screen header, a full profile page shows their bio, links, and a shareable QR code, and the contacts screen shows followed Pubky users.
+
+## Auth Flow
+
+```
+ProfileIntroScreen → PubkyRingAuthScreen → ProfileScreen
+```
+
+1. **ProfileIntroScreen** — presents the Pubky feature and a "Continue" button
+2. **PubkyRingAuthScreen** — initiates authentication via Pubky Ring deep link (`pubkyauth://`), waits for approval via relay, then completes session import
+3. **ProfileScreen** — displays the authenticated user's profile (name, bio, links, QR code)
+
+### Deep Link Flow
+
+The auth handshake uses a relay-based protocol:
+
+1. `PubkyService.startAuth()` generates a `pubkyauth://` URL with required capabilities
+2. The URL is opened via `ACTION_VIEW` intent, launching Pubky Ring
+3. Pubky Ring prompts the user to approve the requested capabilities
+4. `PubkyService.completeAuth()` blocks on the relay until Ring sends approval, returning a session secret
+5. `PubkyService.importSession()` activates the session, returning the user's public key
+6. The session secret is persisted in Keychain for restoration on next launch
+
+### Auth State Machine (`PubkyAuthState`)
+
+- **Idle** — no authentication in progress
+- **Authenticating** — `startAuth()` has been called, waiting for relay setup
+- **Authenticated** — session active, profile available
+
+## Service Layer (`PubkyService`)
+
+Wraps two FFI libraries:
+
+- **paykit-ffi** (`com.synonym:paykit-android`) — session management
+ - `paykitInitialize()`, `paykitImportSession()`, `paykitSignOut()`, `paykitForceSignOut()`
+- **bitkit-core** (`com.synonym:bitkit-core-android`) — auth relay, profile/contacts fetching, and file fetching
+ - `startPubkyAuth()`, `completePubkyAuth()`, `cancelPubkyAuth()`, `fetchPubkyProfile()`, `fetchPubkyContacts()`, `fetchPubkyFile()`
+
+All calls are dispatched on `ServiceQueue.CORE` (single-thread executor) to ensure serial access to the underlying Rust state.
+
+## Repository Layer (`PubkyRepo`)
+
+Manages auth state, session lifecycle, and profile data. Singleton scoped.
+
+### Initialization
+
+- `PubkyRepo` self-initializes via `init {}` block — no external trigger needed
+- `AppViewModel` injects `PubkyRepo` to ensure Hilt creates it at app startup
+- `initialize()` attempts to restore any saved session via `importSession()`
+- If restoration fails, the stale keychain entry is deleted to allow a clean retry
+- Session secret is only persisted **after** `importSession()` succeeds to avoid stale entries on failure
+
+### Profile Loading
+
+- `loadProfile()` fetches the profile for the authenticated public key
+- Uses a `Mutex` with `tryLock()` to prevent concurrent loads (skips if already loading)
+- Re-checks `_publicKey` after the network call to guard against a concurrent `signOut()`
+- Profile name and image URI are cached in `PubkyStore` (DataStore) for instant display on launch before the full profile loads
+
+### Exposed State
+
+| StateFlow | Description |
+|---|---|
+| `profile` | Full `PubkyProfile` or null |
+| `publicKey` | Authenticated user's public key |
+| `isAuthenticated` | Derived from internal auth state |
+| `displayName` | Profile name with cached fallback |
+| `displayImageUri` | Profile image URI with cached fallback |
+| `isLoadingProfile` | Loading indicator |
+| `contacts` | List of followed `PubkyProfile` contacts |
+| `isLoadingContacts` | Contacts loading indicator |
+
+### Contacts
+
+- `loadContacts()` fetches the authenticated user's contact keys via `fetchPubkyContacts`, then concurrently fetches each contact's profile
+- Contact keys from the FFI may lack the `pubky` prefix; `ensurePubkyPrefix()` normalizes them before passing to `fetchPubkyProfile`
+- If a contact profile fetch fails, a `PubkyProfile.placeholder()` is used to ensure the contact still appears in the list with a truncated public key
+- `fetchContactProfile()` fetches a single contact's profile on demand (used by the detail screen)
+
+## Contacts Flow
+
+```
+ContactsIntroScreen → (if authenticated) ContactsScreen → ContactDetailScreen
+ → (if not authenticated) PubkyRingAuthScreen → ContactsScreen
+```
+
+1. **ContactsIntroScreen** — presents the contacts feature with a "Continue" button; marks `hasSeenContactsIntro` in settings
+2. **ContactsScreen** — displays a searchable, alphabetically grouped list of followed Pubky users
+3. **ContactDetailScreen** — shows a contact's profile details (name, bio, links) with copy and share actions
+
+## PubkyImage Component
+
+Composable for loading and displaying images from `pubky://` URIs, backed by Coil 3.
+
+### Architecture
+
+- `PubkyImage` is a stateless composable wrapping Coil's `AsyncImage`
+- `PubkyImageFetcher` is a Coil `Fetcher` that handles `pubky://` URIs via `PubkyService.fetchFile()`
+- `ImageModule` provides a singleton `ImageLoader` with `PubkyImageFetcher.Factory`, memory cache, and disk cache
+
+### Caching Strategy (Coil)
+
+Coil manages a two-tier cache automatically:
+
+1. **Memory** — Coil's `MemoryCache` (15% of app memory)
+2. **Disk** — Coil's `DiskCache` in `cacheDir/pubky-images/`
+
+### Loading Flow
+
+1. Coil checks memory cache → return if hit
+2. Coil checks disk cache → return if hit
+3. `PubkyImageFetcher.fetch()` calls `PubkyService.fetchFile(uri)`
+4. If response is a JSON file descriptor with a `src` field, follow the indirection and fetch the blob
+5. Coil decodes and caches the result
+
+### Display States
+
+- **Loading** — `CircularProgressIndicator`
+- **Loaded** — circular-clipped image (handled by Coil's success state)
+- **Error** — fallback user icon on gray background
+
+## Domain Model (`PubkyProfile`)
+
+- `publicKey`, `name`, `bio`, `imageUrl`, `links`, `status`
+- `truncatedPublicKey` — uses `String.ellipsisMiddle()` extension
+- `PubkyProfileLink` — `label` + `url` pair
+- `fromFfi()` — maps from bitkitcore's `PubkyProfile` FFI type
+- `placeholder()` — creates a stub profile with the truncated public key as the name
+
+## Home Screen Integration
+
+- `HomeViewModel` observes `PubkyRepo.displayName` and `PubkyRepo.displayImageUri`
+- The home screen header shows the profile name and avatar when authenticated
+- The `PROFILE` suggestion card is auto-dismissed when the user is authenticated
+
+## Key Files
+
+| File | Purpose |
+|---|---|
+| `services/PubkyService.kt` | FFI wrapper |
+| `repositories/PubkyRepo.kt` | Auth state and session management |
+| `data/PubkyImageFetcher.kt` | Coil fetcher for pubky:// URIs |
+| `di/ImageModule.kt` | Hilt module providing ImageLoader |
+| `data/PubkyStore.kt` | DataStore for cached profile metadata |
+| `models/PubkyProfile.kt` | Domain model |
+| `ui/components/PubkyImage.kt` | Image composable |
+| `ui/screens/profile/ProfileIntroScreen.kt` | Intro screen |
+| `ui/screens/profile/PubkyRingAuthScreen.kt` | Auth screen |
+| `ui/screens/profile/PubkyRingAuthViewModel.kt` | Auth ViewModel |
+| `ui/screens/profile/ProfileScreen.kt` | Profile display |
+| `ui/screens/profile/ProfileViewModel.kt` | Profile ViewModel |
+| `ui/screens/contacts/ContactsIntroScreen.kt` | Contacts intro screen |
+| `ui/screens/contacts/ContactsScreen.kt` | Contacts list |
+| `ui/screens/contacts/ContactsViewModel.kt` | Contacts list ViewModel |
+| `ui/screens/contacts/ContactDetailScreen.kt` | Contact detail display |
+| `ui/screens/contacts/ContactDetailViewModel.kt` | Contact detail ViewModel |
diff --git a/docs/screens-map.md b/docs/screens-map.md
index 727bd129b..9ba0fda3d 100644
--- a/docs/screens-map.md
+++ b/docs/screens-map.md
@@ -145,10 +145,10 @@ Legend: RN = React Native screen, Android = Compose screen
| - | - |
| Contacts.tsx | `todo` |
| Contact.tsx | `todo` |
-| Profile.tsx | CreateProfileScreen.kt / ProfileIntroScreen.kt |
-| ProfileEdit.tsx | CreateProfileScreen.kt |
-| ProfileOnboarding.tsx | ProfileIntroScreen.kt |
-| ProfileLink.tsx | CreateProfileScreen.kt |
+| Profile.tsx | ProfileScreen.kt |
+| ProfileEdit.tsx | `n/a` |
+| ProfileOnboarding.tsx | ProfileIntroScreen.kt / PubkyRingAuthScreen.kt |
+| ProfileLink.tsx | `n/a` |
## Widgets
| RN | Android |
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index f78fafcf0..9c07ee8a9 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,6 +1,7 @@
[versions]
agp = "8.13.2"
camera = "1.5.2"
+coil = "3.2.0"
detekt = "1.23.8"
hilt = "2.57.2"
hiltAndroidx = "1.3.0"
@@ -19,7 +20,8 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1
appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" }
barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" }
biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" }
-bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.38" }
+bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.46" }
+paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc1" }
bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" }
camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" }
camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" }
@@ -87,6 +89,8 @@ work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.
zxing = { module = "com.google.zxing:core", version = "3.5.4" }
lottie = { module = "com.airbnb.android:lottie-compose", version = "6.7.1" }
charts = { module = "io.github.ehsannarmani:compose-charts", version = "0.2.0" }
+coil-bom = { module = "io.coil-kt.coil3:coil-bom", version.ref = "coil" }
+coil-compose = { module = "io.coil-kt.coil3:coil-compose" }
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 9510f42e0..e3a1da778 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -61,6 +61,14 @@ dependencyResolutionManagement {
password = pass
}
}
+ maven {
+ url = uri("https://maven.pkg.github.com/pubky/paykit-rs")
+ credentials {
+ val (user, pass) = getGithubCredentials()
+ username = user
+ password = pass
+ }
+ }
}
}
rootProject.name = "bitkit-android"