diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 70a708adc..84367ac07 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -237,6 +237,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))
@@ -267,6 +268,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..5eb974fff 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,10 @@
+
+
+
+
+
diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt
index 27b3a7c17..d9f4a7d91 100644
--- a/app/src/main/java/to/bitkit/App.kt
+++ b/app/src/main/java/to/bitkit/App.kt
@@ -7,6 +7,8 @@ import android.app.Application.ActivityLifecycleCallbacks
import android.os.Bundle
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
+import coil3.ImageLoader
+import coil3.SingletonImageLoader
import dagger.hilt.android.HiltAndroidApp
import to.bitkit.env.Env
import javax.inject.Inject
@@ -16,6 +18,9 @@ internal open class App : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory
+ @Inject
+ lateinit var imageLoader: ImageLoader
+
override val workManagerConfiguration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
@@ -23,6 +28,7 @@ internal open class App : Application(), Configuration.Provider {
override fun onCreate() {
super.onCreate()
+ SingletonImageLoader.setSafe { imageLoader }
currentActivity = CurrentActivity().also { registerActivityLifecycleCallbacks(it) }
Env.initAppStoragePath(filesDir.absolutePath)
}
diff --git a/app/src/main/java/to/bitkit/data/PubkyImageFetcher.kt b/app/src/main/java/to/bitkit/data/PubkyImageFetcher.kt
new file mode 100644
index 000000000..b3880a52f
--- /dev/null
+++ b/app/src/main/java/to/bitkit/data/PubkyImageFetcher.kt
@@ -0,0 +1,50 @@
+package to.bitkit.data
+
+import coil3.ImageLoader
+import coil3.Uri
+import coil3.decode.DataSource
+import coil3.decode.ImageSource
+import coil3.fetch.FetchResult
+import coil3.fetch.Fetcher
+import coil3.fetch.SourceFetchResult
+import coil3.request.Options
+import okio.Buffer
+import org.json.JSONObject
+import to.bitkit.services.PubkyService
+import to.bitkit.utils.Logger
+
+private const val TAG = "PubkyImageFetcher"
+private const val PUBKY_SCHEME = "pubky://"
+
+class PubkyImageFetcher(
+ private val uri: String,
+ private val options: Options,
+ private val pubkyService: PubkyService,
+) : Fetcher {
+
+ override suspend fun fetch(): FetchResult {
+ val data = pubkyService.fetchFile(uri)
+ val blobData = resolveImageData(data)
+ val source = ImageSource(Buffer().apply { write(blobData) }, options.fileSystem)
+ return SourceFetchResult(source, null, dataSource = DataSource.NETWORK)
+ }
+
+ private suspend fun resolveImageData(data: ByteArray): ByteArray = runCatching {
+ val json = JSONObject(String(data))
+ val src = json.optString("src", "")
+ if (src.isNotEmpty() && src.startsWith(PUBKY_SCHEME)) {
+ Logger.debug("File descriptor found, fetching blob from '$src'", context = TAG)
+ pubkyService.fetchFile(src)
+ } else {
+ data
+ }
+ }.getOrDefault(data)
+
+ class Factory(private val pubkyService: PubkyService) : Fetcher.Factory {
+ 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 836395638..965fd3da9 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..ec8234a99 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,8 @@ class Keychain @Inject constructor(
BIP39_PASSPHRASE,
PIN,
PIN_ATTEMPTS_REMAINING,
+ PAYKIT_SESSION,
+ PUBKY_SECRET_KEY,
}
}
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 313092cea..fad6eb6cc 100644
--- a/app/src/main/java/to/bitkit/env/Env.kt
+++ b/app/src/main/java/to/bitkit/env/Env.kt
@@ -154,6 +154,33 @@ internal object Env {
const val BITREFILL_APP = "Bitkit"
const val BITREFILL_REF = "AL6dyZYt"
+ private val pubkyDomain: String
+ get() = when (network) {
+ Network.BITCOIN -> "bitkit.to"
+ else -> "staging.bitkit.to"
+ }
+
+ val pubkyCapabilities: String
+ get() {
+ val prefix = when (network) {
+ Network.BITCOIN -> ""
+ else -> "staging."
+ }
+ return "/pub/$pubkyDomain/:rw,/pub/${prefix}pubky.app/:r,/pub/${prefix}paykit/v0/:rw"
+ }
+
+ // Switch to production for mainnet once available
+ const val homegateUrl = "https://homegate.staging.pubky.app"
+
+ val profilePath: String
+ get() = "/pub/$pubkyDomain/profile.json"
+
+ val contactsBasePath: String
+ get() = "/pub/$pubkyDomain/contacts/"
+
+ val blobsBasePath: String
+ get() = "/pub/$pubkyDomain/blobs/"
+
val rnBackupServerHost: String
get() = when (network) {
Network.BITCOIN -> "https://blocktank.synonym.to/backups-ldk"
diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt
new file mode 100644
index 000000000..5ac9d6c7f
--- /dev/null
+++ b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt
@@ -0,0 +1,41 @@
+package to.bitkit.models
+
+data class PubkyAuthPermission(
+ val path: String,
+ val accessLevel: String,
+) {
+ val displayAccess: String
+ get() = accessLevel.map { char ->
+ when (char) {
+ 'r' -> "READ"
+ 'w' -> "WRITE"
+ else -> ""
+ }
+ }.filter { it.isNotEmpty() }.joinToString(", ")
+}
+
+data class PubkyAuthRequest(
+ val rawUrl: String,
+ val relay: String,
+ val permissions: List,
+ val serviceNames: List,
+) {
+ companion object {
+ fun parseCapabilities(caps: String): List =
+ caps.split(",")
+ .filter { it.isNotBlank() }
+ .mapNotNull { segment ->
+ val lastColon = segment.lastIndexOf(':')
+ if (lastColon <= 0) return@mapNotNull null
+ val path = segment.substring(0, lastColon)
+ val access = segment.substring(lastColon + 1)
+ PubkyAuthPermission(path = path, accessLevel = access)
+ }
+
+ fun extractServiceName(path: String): String? {
+ val parts = path.trimStart('/').split("/")
+ val pubIndex = parts.indexOf("pub")
+ return if (pubIndex >= 0 && pubIndex + 1 < parts.size) parts[pubIndex + 1] else null
+ }
+ }
+}
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..e85fb857f
--- /dev/null
+++ b/app/src/main/java/to/bitkit/models/PubkyProfile.kt
@@ -0,0 +1,105 @@
+package to.bitkit.models
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+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 tags: List = emptyList(),
+ 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) },
+ tags = emptyList(),
+ status = ffiProfile.status,
+ )
+ }
+
+ fun placeholder(publicKey: String) = PubkyProfile(
+ publicKey = publicKey,
+ name = publicKey.ellipsisMiddle(TRUNCATED_PK_LENGTH),
+ bio = "",
+ imageUrl = null,
+ links = emptyList(),
+ tags = 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(),
+ tags = emptyList(),
+ status = null,
+ )
+ }
+
+ val truncatedPublicKey: String
+ get() = publicKey.ellipsisMiddle(TRUNCATED_PK_LENGTH)
+
+ fun toProfileData() = PubkyProfileData(
+ name = name,
+ bio = bio,
+ image = imageUrl,
+ links = links.map { PubkyProfileDataLink(label = it.label, url = it.url) },
+ tags = tags,
+ )
+}
+
+@Serializable
+data class PubkyProfileDataLink(val label: String, val url: String)
+
+@Serializable
+data class PubkyProfileData(
+ val name: String,
+ val bio: String,
+ val image: String? = null,
+ val links: List = emptyList(),
+ val tags: List = emptyList(),
+) {
+ companion object {
+ fun decode(json: String): PubkyProfileData =
+ Json { ignoreUnknownKeys = true }.decodeFromString(json)
+ }
+
+ fun encode(): ByteArray =
+ Json.encodeToString(this).toByteArray(Charsets.UTF_8)
+
+ fun toPubkyProfile(publicKey: String) = PubkyProfile(
+ publicKey = publicKey,
+ name = name,
+ bio = bio,
+ imageUrl = image,
+ links = links.map { PubkyProfileLink(label = it.label, url = it.url) },
+ tags = tags,
+ status = null,
+ )
+}
+
+@Serializable
+data class HomegateResponse(
+ val signupCode: String,
+ val homeserverPubky: String,
+)
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..b4b7d449b
--- /dev/null
+++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@@ -0,0 +1,674 @@
+package to.bitkit.repositories
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import coil3.ImageLoader
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.request.post
+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.env.Env
+import to.bitkit.models.HomegateResponse
+import to.bitkit.models.PubkyProfile
+import to.bitkit.models.PubkyProfileData
+import to.bitkit.models.PubkyProfileLink
+import to.bitkit.services.PubkyService
+import to.bitkit.utils.Logger
+import java.io.ByteArrayOutputStream
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.math.min
+
+enum class PubkyAuthState { Idle, Authenticating, Authenticated }
+
+@Suppress("TooManyFunctions")
+@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,
+ private val httpClient: HttpClient,
+) {
+ companion object {
+ private const val TAG = "PubkyRepo"
+ private const val PUBKY_PREFIX = "pubky"
+ private const val PUBKY_SCHEME = "pubky://"
+ private const val AVATAR_MAX_SIZE = 400
+ private const val AVATAR_QUALITY = 80
+ }
+
+ 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()
+
+ private val _sessionRestorationFailed = MutableStateFlow(false)
+ val sessionRestorationFailed: StateFlow = _sessionRestorationFailed.asStateFlow()
+
+ private val _pendingImportProfile = MutableStateFlow(null)
+ val pendingImportProfile: StateFlow = _pendingImportProfile.asStateFlow()
+
+ private val _pendingImportContacts = MutableStateFlow>(emptyList())
+ val pendingImportContacts: StateFlow> = _pendingImportContacts.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() }
+ }
+
+ // region Initialization
+
+ 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 { importError ->
+ Logger.warn("Failed to restore paykit session, attempting re-sign-in", importError, context = TAG)
+ tryReSignIn()
+ }
+ }
+ }.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 -> {
+ _sessionRestorationFailed.update { true }
+ }
+ }
+ }
+
+ private suspend fun tryReSignIn(): InitResult {
+ val secretKeyHex = runCatching {
+ keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
+ }.getOrNull()
+
+ if (secretKeyHex.isNullOrEmpty()) {
+ Logger.warn("No secret key available for re-sign-in recovery", context = TAG)
+ return InitResult.RestorationFailed
+ }
+
+ return runCatching {
+ val newSession = pubkyService.signIn(secretKeyHex)
+ runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) }
+ keychain.saveString(Keychain.Key.PAYKIT_SESSION.name, newSession)
+ val pk = pubkyService.importSession(newSession)
+ Logger.info("Re-signed in and restored session for '$pk'", context = TAG)
+ InitResult.Restored(pk)
+ }.getOrElse {
+ Logger.error("Re-sign-in recovery failed", it, context = TAG)
+ runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) }
+ InitResult.RestorationFailed
+ }
+ }
+
+ fun clearSessionRestorationFailed() {
+ _sessionRestorationFailed.update { false }
+ }
+
+ // endregion
+
+ // region Ring auth flow
+
+ 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() }
+ }
+
+ // endregion
+
+ // region Profile loading
+
+ suspend fun loadProfile() {
+ val pk = _publicKey.value ?: return
+ if (!loadProfileMutex.tryLock()) return
+
+ _isLoadingProfile.update { true }
+ try {
+ runCatching {
+ withContext(ioDispatcher) {
+ fetchBitkitProfile(pk) ?: run {
+ val ffiProfile = pubkyService.getProfile(pk)
+ 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()
+ }
+ }
+
+ private suspend fun fetchBitkitProfile(publicKey: String): PubkyProfile? = runCatching {
+ val strippedKey = publicKey.removePrefix(PUBKY_PREFIX)
+ val uri = "$PUBKY_SCHEME$strippedKey${Env.profilePath}"
+ val json = pubkyService.fetchFileString(uri)
+ PubkyProfileData.decode(json).toPubkyProfile(publicKey)
+ }.onFailure {
+ Logger.debug("No bitkit profile found, falling back to FFI", context = TAG)
+ }.getOrNull()
+
+ suspend fun fetchRemoteProfile(publicKey: String): Result = runCatching {
+ withContext(ioDispatcher) {
+ fetchBitkitProfile(publicKey) ?: run {
+ val ffiProfile = pubkyService.getProfile(publicKey)
+ PubkyProfile.fromFfi(publicKey, ffiProfile)
+ }
+ }
+ }
+
+ // endregion
+
+ // region Profile creation & editing
+
+ suspend fun deriveKeys(): Result> = runCatching {
+ withContext(ioDispatcher) {
+ val mnemonic = requireNotNull(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)) {
+ "BIP39 mnemonic not found in keychain"
+ }
+ val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)
+ val seed = pubkyService.mnemonicToSeed(mnemonic, passphrase)
+ val secretKeyHex = pubkyService.deriveSecretKey(seed)
+ val rawKey = pubkyService.publicKeyFromSecret(secretKeyHex)
+ val publicKeyZ32 = rawKey.ensurePubkyPrefix()
+ Pair(publicKeyZ32, secretKeyHex)
+ }
+ }
+
+ private suspend fun fetchHomegateSignupCode(): HomegateResponse =
+ httpClient.post("${Env.homegateUrl}/ip_verification").body()
+
+ suspend fun createIdentity(
+ name: String,
+ bio: String,
+ links: List,
+ tags: List,
+ avatarBytes: ByteArray?,
+ ): Result = runCatching {
+ withContext(ioDispatcher) {
+ val (publicKeyZ32, secretKeyHex) = deriveKeys().getOrThrow()
+
+ val homegate = fetchHomegateSignupCode()
+
+ val session = runCatching {
+ pubkyService.signUp(secretKeyHex, homegate.homeserverPubky, homegate.signupCode)
+ }.getOrElse {
+ Logger.warn("signUp failed (likely already registered), trying signIn", it, context = TAG)
+ pubkyService.signIn(secretKeyHex)
+ }
+
+ keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secretKeyHex)
+ keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, session)
+ pubkyService.importSession(session)
+
+ val imageUrl = avatarBytes?.let { uploadAvatar(it, secretKeyHex).getOrNull() }
+ writeProfile(session, name, bio, links, tags, imageUrl)
+
+ _publicKey.update { publicKeyZ32 }
+ _authState.update { PubkyAuthState.Authenticated }
+ Logger.info("Identity created for '$publicKeyZ32'", context = TAG)
+ loadProfile()
+ loadContacts()
+ }
+ }
+
+ suspend fun uploadAvatar(imageBytes: ByteArray, secretKeyHex: String): Result = runCatching {
+ withContext(ioDispatcher) {
+ val compressed = compressAvatar(imageBytes)
+ val path = "${Env.blobsBasePath}${System.currentTimeMillis()}.jpg"
+ pubkyService.putWithSecretKey(secretKeyHex, path, compressed)
+ val strippedKey = pubkyService.publicKeyFromSecret(secretKeyHex).removePrefix(PUBKY_PREFIX)
+ "$PUBKY_SCHEME$strippedKey$path"
+ }
+ }
+
+ suspend fun uploadAvatar(imageBytes: ByteArray): Result = runCatching {
+ withContext(ioDispatcher) {
+ val secretKeyHex = requireNotNull(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)) {
+ "No secret key available"
+ }
+ uploadAvatar(imageBytes, secretKeyHex).getOrThrow()
+ }
+ }
+
+ suspend fun saveProfile(
+ name: String,
+ bio: String,
+ links: List,
+ tags: List,
+ imageUrl: String?,
+ ): Result = runCatching {
+ withContext(ioDispatcher) {
+ val session = requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) {
+ "No session available"
+ }
+ writeProfile(session, name, bio, links, tags, imageUrl)
+ val pk = requireNotNull(_publicKey.value)
+ val profile = PubkyProfile(
+ publicKey = pk,
+ name = name,
+ bio = bio,
+ imageUrl = imageUrl ?: _profile.value?.imageUrl,
+ links = links,
+ tags = tags,
+ status = _profile.value?.status,
+ )
+ _profile.update { profile }
+ cacheMetadata(profile)
+ }
+ }
+
+ suspend fun deleteProfile(): Result = runCatching {
+ withContext(ioDispatcher) {
+ val session = requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) {
+ "No session available"
+ }
+ pubkyService.sessionDelete(session, Env.profilePath)
+ }
+ signOut()
+ }
+
+ @Suppress("LongParameterList")
+ private suspend fun writeProfile(
+ sessionSecret: String,
+ name: String,
+ bio: String,
+ links: List,
+ tags: List,
+ imageUrl: String?,
+ ) {
+ val data = PubkyProfile(
+ publicKey = "",
+ name = name,
+ bio = bio,
+ imageUrl = imageUrl,
+ links = links,
+ tags = tags,
+ status = null,
+ ).toProfileData()
+ pubkyService.sessionPut(sessionSecret, Env.profilePath, data.encode())
+ }
+
+ private fun compressAvatar(imageBytes: ByteArray): ByteArray {
+ val original = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) ?: return imageBytes
+ val scale = min(AVATAR_MAX_SIZE.toFloat() / original.width, AVATAR_MAX_SIZE.toFloat() / original.height)
+ val scaled = if (scale < 1f) {
+ Bitmap.createScaledBitmap(
+ original,
+ (original.width * scale).toInt(),
+ (original.height * scale).toInt(),
+ true,
+ )
+ } else {
+ original
+ }
+ return ByteArrayOutputStream().use { out ->
+ scaled.compress(Bitmap.CompressFormat.JPEG, AVATAR_QUALITY, out)
+ out.toByteArray()
+ }
+ }
+
+ // endregion
+
+ // region Contact management
+
+ suspend fun loadContacts() {
+ val pk = _publicKey.value ?: return
+ if (!loadContactsMutex.tryLock()) return
+
+ _isLoadingContacts.update { true }
+ try {
+ runCatching {
+ withContext(ioDispatcher) {
+ val session = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)
+ ?: return@withContext emptyList()
+
+ val contactPaths = pubkyService.sessionList(session, Env.contactsBasePath)
+ val strippedOwnerKey = pk.removePrefix(PUBKY_PREFIX)
+
+ coroutineScope {
+ contactPaths.map { path ->
+ val contactKey = path.substringAfterLast("/")
+ async {
+ runCatching {
+ val uri = "$PUBKY_SCHEME$strippedOwnerKey${Env.contactsBasePath}$contactKey"
+ val json = pubkyService.fetchFileString(uri)
+ PubkyProfileData.decode(json).toPubkyProfile(contactKey)
+ }.onFailure {
+ Logger.warn("Failed to load contact '$contactKey'", it, context = TAG)
+ }.getOrElse {
+ PubkyProfile.placeholder(contactKey)
+ }
+ }
+ }.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 addContact(publicKey: String, existingProfile: PubkyProfile? = null): Result = runCatching {
+ withContext(ioDispatcher) {
+ val session = requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) {
+ "No session available"
+ }
+ val prefixedKey = publicKey.ensurePubkyPrefix()
+ val profile = existingProfile ?: run {
+ val ffiProfile = pubkyService.getProfile(prefixedKey)
+ PubkyProfile.fromFfi(prefixedKey, ffiProfile)
+ }
+ val data = profile.toProfileData().encode()
+ pubkyService.sessionPut(session, "${Env.contactsBasePath}$prefixedKey", data)
+ _contacts.update { current ->
+ (current + profile).sortedBy { it.name.lowercase() }
+ }
+ Logger.info("Added contact '$prefixedKey'", context = TAG)
+ }
+ }
+
+ @Suppress("LongParameterList")
+ suspend fun updateContact(
+ publicKey: String,
+ name: String,
+ bio: String,
+ imageUrl: String?,
+ links: List,
+ tags: List,
+ ): Result = runCatching {
+ withContext(ioDispatcher) {
+ val session = requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) {
+ "No session available"
+ }
+ val prefixedKey = publicKey.ensurePubkyPrefix()
+ val updatedProfile = PubkyProfile(
+ publicKey = prefixedKey,
+ name = name,
+ bio = bio,
+ imageUrl = imageUrl,
+ links = links,
+ tags = tags,
+ status = null,
+ )
+ val data = updatedProfile.toProfileData().encode()
+ pubkyService.sessionPut(session, "${Env.contactsBasePath}$prefixedKey", data)
+ _contacts.update { current ->
+ current.map { if (it.publicKey == prefixedKey) updatedProfile else it }
+ .sortedBy { it.name.lowercase() }
+ }
+ Logger.info("Updated contact '$prefixedKey'", context = TAG)
+ }
+ }
+
+ suspend fun removeContact(publicKey: String): Result = runCatching {
+ withContext(ioDispatcher) {
+ val session = requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) {
+ "No session available"
+ }
+ val prefixedKey = publicKey.ensurePubkyPrefix()
+ pubkyService.sessionDelete(session, "${Env.contactsBasePath}$prefixedKey")
+ _contacts.update { current -> current.filter { it.publicKey != prefixedKey } }
+ Logger.info("Removed contact '$prefixedKey'", context = TAG)
+ }
+ }
+
+ suspend fun importContacts(publicKeys: List): Result = runCatching {
+ withContext(ioDispatcher) {
+ val session = requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) {
+ "No session available"
+ }
+ val imported = coroutineScope {
+ publicKeys.map { contactPk ->
+ val prefixedKey = contactPk.ensurePubkyPrefix()
+ async {
+ runCatching {
+ val ffiProfile = pubkyService.getProfile(prefixedKey)
+ val profile = PubkyProfile.fromFfi(prefixedKey, ffiProfile)
+ val data = profile.toProfileData().encode()
+ pubkyService.sessionPut(session, "${Env.contactsBasePath}$prefixedKey", data)
+ profile
+ }.onFailure {
+ Logger.warn("Failed to import contact '$prefixedKey'", it, context = TAG)
+ }.getOrNull()
+ }
+ }.awaitAll().filterNotNull()
+ }
+ _contacts.update { current ->
+ val existing = current.map { it.publicKey }.toSet()
+ (current + imported.filter { it.publicKey !in existing })
+ .sortedBy { it.name.lowercase() }
+ }
+ Logger.info("Imported '${imported.size}' contacts", context = TAG)
+ }
+ }
+
+ suspend fun prepareImport(): Result = runCatching {
+ val pk = requireNotNull(_publicKey.value) { "Not authenticated" }
+ withContext(ioDispatcher) {
+ val contactKeys = pubkyService.getContacts(pk)
+ Logger.debug("Discovered '${contactKeys.size}' contacts for import", context = TAG)
+
+ val contacts = coroutineScope {
+ contactKeys.map { contactPk ->
+ val prefixedKey = contactPk.ensurePubkyPrefix()
+ async {
+ runCatching {
+ val ffiProfile = pubkyService.getProfile(prefixedKey)
+ PubkyProfile.fromFfi(prefixedKey, ffiProfile)
+ }.getOrElse { PubkyProfile.placeholder(prefixedKey) }
+ }
+ }.awaitAll().sortedBy { it.name.lowercase() }
+ }
+
+ val ownProfile = runCatching {
+ val ffiProfile = pubkyService.getProfile(pk)
+ PubkyProfile.fromFfi(pk, ffiProfile)
+ }.getOrNull()
+
+ _pendingImportProfile.update { ownProfile }
+ _pendingImportContacts.update { contacts }
+ }
+ }
+
+ // endregion
+
+ // region Auth approval
+
+ suspend fun hasSecretKey(): Boolean = runCatching {
+ withContext(ioDispatcher) {
+ !keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrEmpty()
+ }
+ }.getOrDefault(false)
+
+ suspend fun approveAuth(authUrl: String): Result = runCatching {
+ withContext(ioDispatcher) {
+ val secretKeyHex = requireNotNull(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)) {
+ "No secret key available — use Ring to manage authorizations"
+ }
+ pubkyService.approveAuth(authUrl, secretKeyHex)
+ }
+ }
+
+ // endregion
+
+ // region Sign out
+
+ 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) } }
+ runCatching { withContext(ioDispatcher) { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } }
+ evictPubkyImages()
+ runCatching { withContext(ioDispatcher) { pubkyStore.reset() } }
+ _publicKey.update { null }
+ _profile.update { null }
+ _contacts.update { emptyList() }
+ _pendingImportProfile.update { null }
+ _pendingImportContacts.update { emptyList() }
+ _authState.update { PubkyAuthState.Idle }
+ }
+
+ // endregion
+
+ // region Private helpers
+
+ 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_PREFIX)) this else "$PUBKY_PREFIX$this"
+
+ // endregion
+}
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..120e30469
--- /dev/null
+++ b/app/src/main/java/to/bitkit/services/PubkyService.kt
@@ -0,0 +1,198 @@
+package to.bitkit.services
+
+import com.synonym.bitkitcore.PubkyProfile
+import com.synonym.bitkitcore.approvePubkyAuth
+import com.synonym.bitkitcore.cancelPubkyAuth
+import com.synonym.bitkitcore.completePubkyAuth
+import com.synonym.bitkitcore.derivePubkySecretKey
+import com.synonym.bitkitcore.fetchPubkyContacts
+import com.synonym.bitkitcore.fetchPubkyFile
+import com.synonym.bitkitcore.fetchPubkyProfile
+import com.synonym.bitkitcore.mnemonicToSeed
+import com.synonym.bitkitcore.parsePubkyAuthUrl
+import com.synonym.bitkitcore.pubkyPublicKeyFromSecret
+import com.synonym.bitkitcore.pubkyPutWithSecretKey
+import com.synonym.bitkitcore.pubkySessionDelete
+import com.synonym.bitkitcore.pubkySessionList
+import com.synonym.bitkitcore.pubkySessionPut
+import com.synonym.bitkitcore.pubkySignIn
+import com.synonym.bitkitcore.pubkySignUp
+import com.synonym.bitkitcore.startPubkyAuth
+import com.synonym.paykit.paykitExportSession
+import com.synonym.paykit.paykitForceSignOut
+import com.synonym.paykit.paykitGetCurrentPublicKey
+import com.synonym.paykit.paykitImportSession
+import com.synonym.paykit.paykitInitialize
+import com.synonym.paykit.paykitIsAuthenticated
+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
+
+@Suppress("TooManyFunctions")
+@Singleton
+class PubkyService @Inject constructor() {
+
+ private val isSetup = CompletableDeferred()
+
+ suspend fun initialize() = ServiceQueue.CORE.background {
+ paykitInitialize()
+ isSetup.complete(Unit)
+ }
+
+ // region Session management
+
+ suspend fun importSession(secret: String): String = ServiceQueue.CORE.background {
+ isSetup.await()
+ paykitImportSession(secret)
+ }
+
+ suspend fun exportSession(): String = ServiceQueue.CORE.background {
+ isSetup.await()
+ paykitExportSession()
+ }
+
+ suspend fun isAuthenticated(): Boolean = ServiceQueue.CORE.background {
+ isSetup.await()
+ paykitIsAuthenticated()
+ }
+
+ suspend fun currentPublicKey(): String? = ServiceQueue.CORE.background {
+ isSetup.await()
+ paykitGetCurrentPublicKey()
+ }
+
+ suspend fun signOut() = ServiceQueue.CORE.background {
+ isSetup.await()
+ paykitSignOut()
+ }
+
+ suspend fun forceSignOut() = ServiceQueue.CORE.background {
+ isSetup.await()
+ paykitForceSignOut()
+ }
+
+ // endregion
+
+ // region Key derivation
+
+ suspend fun mnemonicToSeed(mnemonic: String, passphrase: String?): ByteArray =
+ ServiceQueue.CORE.background {
+ isSetup.await()
+ mnemonicToSeed(mnemonicPhrase = mnemonic, passphrase = passphrase ?: "")
+ }
+
+ suspend fun deriveSecretKey(seed: ByteArray): String = ServiceQueue.CORE.background {
+ isSetup.await()
+ derivePubkySecretKey(seed)
+ }
+
+ suspend fun publicKeyFromSecret(secretKeyHex: String): String = ServiceQueue.CORE.background {
+ isSetup.await()
+ pubkyPublicKeyFromSecret(secretKeyHex)
+ }
+
+ // endregion
+
+ // region Homeserver auth
+
+ suspend fun signUp(secretKeyHex: String, homeserverZ32: String, signupCode: String?): String =
+ ServiceQueue.CORE.background {
+ isSetup.await()
+ pubkySignUp(secretKeyHex, homeserverZ32, signupCode)
+ }
+
+ suspend fun signIn(secretKeyHex: String): String = ServiceQueue.CORE.background {
+ isSetup.await()
+ pubkySignIn(secretKeyHex)
+ }
+
+ // endregion
+
+ // region Auth flow (Ring)
+
+ 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()
+ }
+
+ // endregion
+
+ // region Auth approval
+
+ suspend fun parseAuthUrl(url: String) = ServiceQueue.CORE.background {
+ isSetup.await()
+ parsePubkyAuthUrl(url)
+ }
+
+ suspend fun approveAuth(authUrl: String, secretKeyHex: String) = ServiceQueue.CORE.background {
+ isSetup.await()
+ approvePubkyAuth(authUrl, secretKeyHex)
+ }
+
+ // endregion
+
+ // region File operations
+
+ suspend fun fetchFile(uri: String): ByteArray = ServiceQueue.CORE.background {
+ isSetup.await()
+ fetchPubkyFile(uri)
+ }
+
+ suspend fun fetchFileString(uri: String): String = ServiceQueue.CORE.background {
+ isSetup.await()
+ fetchPubkyFile(uri).toString(Charsets.UTF_8)
+ }
+
+ suspend fun sessionPut(sessionSecret: String, path: String, content: ByteArray) =
+ ServiceQueue.CORE.background {
+ isSetup.await()
+ pubkySessionPut(sessionSecret, path, content)
+ }
+
+ suspend fun sessionDelete(sessionSecret: String, path: String) =
+ ServiceQueue.CORE.background {
+ isSetup.await()
+ pubkySessionDelete(sessionSecret, path)
+ }
+
+ suspend fun sessionList(sessionSecret: String, dirPath: String): List =
+ ServiceQueue.CORE.background {
+ isSetup.await()
+ pubkySessionList(sessionSecret, dirPath)
+ }
+
+ suspend fun putWithSecretKey(secretKeyHex: String, path: String, content: ByteArray) =
+ ServiceQueue.CORE.background {
+ isSetup.await()
+ pubkyPutWithSecretKey(secretKeyHex, path, content)
+ }
+
+ // endregion
+
+ // region Profile & contacts
+
+ 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)
+ }
+
+ // endregion
+}
diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt
index f4e30ee01..5db5c7764 100644
--- a/app/src/main/java/to/bitkit/ui/ContentView.kt
+++ b/app/src/main/java/to/bitkit/ui/ContentView.kt
@@ -59,9 +59,30 @@ 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.contacts.AddContactScreen
+import to.bitkit.ui.screens.contacts.AddContactViewModel
+import to.bitkit.ui.screens.contacts.ContactDetailScreen
+import to.bitkit.ui.screens.contacts.ContactDetailViewModel
+import to.bitkit.ui.screens.contacts.ContactImportOverviewScreen
+import to.bitkit.ui.screens.contacts.ContactImportOverviewViewModel
+import to.bitkit.ui.screens.contacts.ContactImportSelectScreen
+import to.bitkit.ui.screens.contacts.ContactImportSelectViewModel
+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.contacts.EditContactScreen
+import to.bitkit.ui.screens.contacts.EditContactViewModel
import to.bitkit.ui.screens.profile.CreateProfileScreen
+import to.bitkit.ui.screens.profile.CreateProfileViewModel
+import to.bitkit.ui.screens.profile.EditProfileScreen
+import to.bitkit.ui.screens.profile.EditProfileViewModel
+import to.bitkit.ui.screens.profile.PayContactsScreen
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.PubkyAuthApprovalSheet
+import to.bitkit.ui.screens.profile.PubkyChoiceScreen
+import to.bitkit.ui.screens.profile.PubkyChoiceViewModel
import to.bitkit.ui.screens.recovery.RecoveryMnemonicScreen
import to.bitkit.ui.screens.recovery.RecoveryModeScreen
import to.bitkit.ui.screens.settings.DevSettingsScreen
@@ -359,6 +380,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(
@@ -403,6 +427,11 @@ fun ContentView(
is Sheet.Gift -> GiftSheet(sheet, appViewModel)
Sheet.QrScanner -> QrScanningSheet(appViewModel)
+ is Sheet.PubkyAuth -> PubkyAuthApprovalSheet(
+ authUrl = sheet.authUrl,
+ viewModel = hiltViewModel(),
+ onDismiss = { appViewModel.hideSheet() },
+ )
is Sheet.TimedSheet -> {
when (sheet.type) {
TimedSheetType.APP_UPDATE -> {
@@ -487,7 +516,10 @@ fun ContentView(
rootNavController = navController,
hasSeenWidgetsIntro = hasSeenWidgetsIntro,
hasSeenShopIntro = hasSeenShopIntro,
- modifier = Modifier.align(Alignment.TopEnd)
+ hasSeenProfileIntro = hasSeenProfileIntro,
+ hasSeenContactsIntro = hasSeenContactsIntro,
+ isProfileAuthenticated = isProfileAuthenticated,
+ modifier = Modifier.align(Alignment.TopEnd),
)
}
}
@@ -520,7 +552,7 @@ private fun RootNavHost(
navController = navController,
)
settings(navController, settingsViewModel)
- comingSoon(navController)
+ contacts(navController, settingsViewModel, appViewModel)
profile(navController, settingsViewModel)
shop(navController, settingsViewModel, appViewModel)
generalSettingsSubScreens(navController)
@@ -888,39 +920,154 @@ private fun NavGraphBuilder.settings(
}
}
-private fun NavGraphBuilder.comingSoon(
+@Suppress("LongMethod")
+private fun NavGraphBuilder.contacts(
navController: NavHostController,
+ settingsViewModel: SettingsViewModel,
+ appViewModel: AppViewModel,
) {
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)) },
+ onAddContact = { navController.navigateTo(Routes.AddContact(it)) },
+ onScanQr = {
+ appViewModel.showScannerSheet { scannedData ->
+ navController.navigateTo(Routes.AddContact(scannedData))
+ }
+ },
)
}
- 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.PubkyChoice
+ else -> Routes.ProfileIntro
+ }
+ navController.navigateTo(destination) { popUpTo(Routes.Home) }
+ },
+ onBackClick = { navController.popBackStack() },
+ )
+ }
+ composableWithDefaultTransitions {
+ val viewModel: ContactDetailViewModel = hiltViewModel()
+ ContactDetailScreen(
+ viewModel = viewModel,
+ onBackClick = { navController.popBackStack() },
+ onEditContact = { navController.navigateTo(Routes.EditContact(it)) },
+ )
+ }
+ composableWithDefaultTransitions {
+ val viewModel: AddContactViewModel = hiltViewModel()
+ AddContactScreen(
+ viewModel = viewModel,
+ onBackClick = { navController.popBackStack() },
+ onContactSaved = { navController.popBackStack() },
+ )
+ }
+ composableWithDefaultTransitions {
+ val viewModel: EditContactViewModel = hiltViewModel()
+ EditContactScreen(
+ viewModel = viewModel,
+ onBackClick = { navController.popBackStack() },
+ onContactDeleted = {
+ navController.navigateTo(Routes.Contacts) { popUpTo(Routes.Home) }
+ },
+ )
+ }
+ composableWithDefaultTransitions {
+ val viewModel: ContactImportOverviewViewModel = hiltViewModel()
+ ContactImportOverviewScreen(
+ viewModel = viewModel,
+ onBackClick = { navController.popBackStack() },
+ onNavigateToSelect = { navController.navigateTo(Routes.ContactImportSelect) },
+ onImportComplete = {
+ navController.navigateTo(Routes.PayContacts) { popUpTo(Routes.Home) }
+ },
+ )
+ }
+ composableWithDefaultTransitions {
+ val viewModel: ContactImportSelectViewModel = hiltViewModel()
+ ContactImportSelectScreen(
+ viewModel = viewModel,
+ onBackClick = { navController.popBackStack() },
+ onImportComplete = {
+ navController.navigateTo(Routes.PayContacts) { popUpTo(Routes.Home) }
+ },
)
}
}
+@Suppress("LongMethod")
private fun NavGraphBuilder.profile(
navController: NavHostController,
settingsViewModel: SettingsViewModel,
) {
+ composableWithDefaultTransitions {
+ val viewModel: ProfileViewModel = hiltViewModel()
+ ProfileScreen(
+ viewModel = viewModel,
+ onBackClick = { navController.popBackStack() },
+ onEditProfile = { navController.navigateTo(Routes.EditProfile) },
+ )
+ }
composableWithDefaultTransitions {
ProfileIntroScreen(
onContinue = {
settingsViewModel.setHasSeenProfileIntro(true)
- navController.navigateTo(Routes.CreateProfile)
+ navController.navigateTo(Routes.PubkyChoice)
},
- onBackClick = { navController.popBackStack() }
+ onBackClick = { navController.popBackStack() },
+ )
+ }
+ composableWithDefaultTransitions {
+ val viewModel: PubkyChoiceViewModel = hiltViewModel()
+ PubkyChoiceScreen(
+ viewModel = viewModel,
+ onNavigateToCreateProfile = { navController.navigateTo(Routes.CreateProfile) },
+ onNavigateToContactImportOverview = {
+ navController.navigateTo(Routes.ContactImportOverview) { popUpTo(Routes.Home) }
+ },
+ onNavigateToPayContacts = {
+ navController.navigateTo(Routes.PayContacts) { popUpTo(Routes.Home) }
+ },
+ onBackClick = { navController.popBackStack() },
)
}
composableWithDefaultTransitions {
+ val viewModel: CreateProfileViewModel = hiltViewModel()
CreateProfileScreen(
- onBack = { navController.popBackStack() },
+ viewModel = viewModel,
+ onNavigateToPayContacts = {
+ navController.navigateTo(Routes.PayContacts) { popUpTo(Routes.Home) }
+ },
+ onBackClick = { navController.popBackStack() },
+ )
+ }
+ composableWithDefaultTransitions {
+ val viewModel: EditProfileViewModel = hiltViewModel()
+ EditProfileScreen(
+ viewModel = viewModel,
+ onBackClick = { navController.popBackStack() },
+ onProfileDeleted = {
+ navController.navigateTo(Routes.PubkyChoice) { popUpTo(Routes.Home) }
+ },
+ )
+ }
+ composableWithDefaultTransitions {
+ PayContactsScreen(
+ onContinue = {
+ navController.navigateTo(Routes.Profile) { popUpTo(Routes.Home) }
+ },
+ onBackClick = { navController.popBackStack() },
)
}
}
@@ -1456,6 +1603,15 @@ inline fun NavController.navigateTo(
}
}
+fun NavController.navigateToProfile(
+ isAuthenticated: Boolean,
+ hasSeenIntro: Boolean,
+) = when {
+ isAuthenticated -> navigateTo(Routes.Profile)
+ hasSeenIntro -> navigateTo(Routes.PubkyChoice)
+ else -> navigateTo(Routes.ProfileIntro)
+}
+
fun NavController.navigateToPinManagement() = navigateTo(Routes.PinManagement)
fun NavController.navigateToAuthCheck(
@@ -1727,15 +1883,42 @@ 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
@Serializable
data object ProfileIntro : Routes
+ @Serializable
+ data object PubkyChoice : Routes
+
@Serializable
data object CreateProfile : Routes
+ @Serializable
+ data object EditProfile : Routes
+
+ @Serializable
+ data object PayContacts : Routes
+
+ @Serializable
+ data class AddContact(val publicKey: String) : Routes
+
+ @Serializable
+ data class EditContact(val publicKey: String) : Routes
+
+ @Serializable
+ data object ContactImportOverview : Routes
+
+ @Serializable
+ data object ContactImportSelect : 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/AddLinkSheet.kt b/app/src/main/java/to/bitkit/ui/components/AddLinkSheet.kt
new file mode 100644
index 000000000..7795f6296
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/components/AddLinkSheet.kt
@@ -0,0 +1,176 @@
+package to.bitkit.ui.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberModalBottomSheetState
+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.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 kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import to.bitkit.R
+import to.bitkit.ui.scaffold.SheetTopBar
+import to.bitkit.ui.theme.AppThemeSurface
+import to.bitkit.ui.theme.Colors
+
+private val LINK_SUGGESTIONS = persistentListOf(
+ "Email", "Phone", "Website", "Twitter", "Telegram", "Instagram",
+ "Facebook", "LinkedIn", "Github", "Calendly", "Vimeo", "YouTube",
+ "Twitch", "Pinterest", "TikTok", "Spotify",
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AddLinkSheet(
+ onDismiss: () -> Unit,
+ onSave: (label: String, url: String) -> Unit,
+) {
+ var label by remember { mutableStateOf("") }
+ var url by remember { mutableStateOf("") }
+ var showSuggestions by remember { mutableStateOf(false) }
+
+ BottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ ) {
+ if (showSuggestions) {
+ SuggestionsContent(
+ title = stringResource(R.string.profile__suggestions_to_add),
+ suggestions = LINK_SUGGESTIONS,
+ onSelect = {
+ label = it
+ showSuggestions = false
+ },
+ onBack = { showSuggestions = false },
+ )
+ } else {
+ LinkFormContent(
+ label = label,
+ url = url,
+ onLabelChange = { label = it },
+ onUrlChange = { url = it },
+ onShowSuggestions = { showSuggestions = true },
+ onSave = { onSave(label, url) },
+ isSaveEnabled = label.isNotBlank() && url.isNotBlank(),
+ )
+ }
+ }
+}
+
+@Composable
+private fun LinkFormContent(
+ label: String,
+ url: String,
+ onLabelChange: (String) -> Unit,
+ onUrlChange: (String) -> Unit,
+ onShowSuggestions: () -> Unit,
+ onSave: () -> Unit,
+ isSaveEnabled: Boolean,
+) {
+ Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+ SheetTopBar(titleText = stringResource(R.string.profile__add_link))
+ VerticalSpacer(16.dp)
+ Text13Up(text = stringResource(R.string.profile__add_link_label))
+ VerticalSpacer(8.dp)
+ TextInput(
+ value = label,
+ onValueChange = onLabelChange,
+ placeholder = stringResource(R.string.profile__add_link_label_placeholder),
+ trailingIcon = { SuggestionsButton(onClick = onShowSuggestions) },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ VerticalSpacer(16.dp)
+ Text13Up(text = stringResource(R.string.profile__add_link_url))
+ VerticalSpacer(8.dp)
+ TextInput(
+ value = url,
+ onValueChange = onUrlChange,
+ placeholder = stringResource(R.string.profile__add_link_url_placeholder),
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ VerticalSpacer(8.dp)
+ BodyS(
+ text = stringResource(R.string.profile__add_link_note),
+ color = Colors.White64,
+ )
+ VerticalSpacer(24.dp)
+ PrimaryButton(
+ text = stringResource(R.string.common__save),
+ onClick = onSave,
+ enabled = isSaveEnabled,
+ )
+ VerticalSpacer(16.dp)
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+internal fun SuggestionsContent(
+ title: String,
+ suggestions: ImmutableList,
+ onSelect: (String) -> Unit,
+ onBack: () -> Unit,
+) {
+ Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+ SheetTopBar(titleText = title, onBack = onBack)
+ VerticalSpacer(16.dp)
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ suggestions.forEach { suggestion ->
+ TagButton(text = suggestion, onClick = { onSelect(suggestion) })
+ }
+ }
+ VerticalSpacer(24.dp)
+ }
+}
+
+@Composable
+private fun SuggestionsButton(onClick: () -> Unit) {
+ TextButton(onClick = onClick) {
+ Icon(
+ painter = painterResource(R.drawable.ic_lightbulb),
+ contentDescription = null,
+ tint = Colors.PubkyGreen,
+ modifier = Modifier.padding(end = 4.dp),
+ )
+ BodySSB(
+ text = stringResource(R.string.profile__suggestions),
+ color = Colors.PubkyGreen,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun Preview() {
+ AppThemeSurface {
+ LinkFormContent(
+ label = "Twitter",
+ url = "@satoshinakamoto",
+ onLabelChange = {},
+ onUrlChange = {},
+ onShowSuggestions = {},
+ onSave = {},
+ isSaveEnabled = true,
+ )
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/components/AddTagSheet.kt b/app/src/main/java/to/bitkit/ui/components/AddTagSheet.kt
new file mode 100644
index 000000000..5f6f57967
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/components/AddTagSheet.kt
@@ -0,0 +1,122 @@
+package to.bitkit.ui.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberModalBottomSheetState
+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.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 kotlinx.collections.immutable.persistentListOf
+import to.bitkit.R
+import to.bitkit.ui.scaffold.SheetTopBar
+import to.bitkit.ui.theme.AppThemeSurface
+import to.bitkit.ui.theme.Colors
+
+private val TAG_SUGGESTIONS = persistentListOf(
+ "Developer", "Designer", "Founder", "CEO", "CTO", "CDO", "CFO",
+ "Serious", "Funny", "Candid",
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AddTagSheet(
+ onDismiss: () -> Unit,
+ onSave: (tag: String) -> Unit,
+) {
+ var tag by remember { mutableStateOf("") }
+ var showSuggestions by remember { mutableStateOf(false) }
+
+ BottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ ) {
+ if (showSuggestions) {
+ SuggestionsContent(
+ title = stringResource(R.string.profile__suggestions_to_add),
+ suggestions = TAG_SUGGESTIONS,
+ onSelect = {
+ tag = it
+ showSuggestions = false
+ },
+ onBack = { showSuggestions = false },
+ )
+ } else {
+ TagFormContent(
+ tag = tag,
+ onTagChange = { tag = it },
+ onShowSuggestions = { showSuggestions = true },
+ onSave = { onSave(tag) },
+ isSaveEnabled = tag.isNotBlank(),
+ )
+ }
+ }
+}
+
+@Composable
+private fun TagFormContent(
+ tag: String,
+ onTagChange: (String) -> Unit,
+ onShowSuggestions: () -> Unit,
+ onSave: () -> Unit,
+ isSaveEnabled: Boolean,
+) {
+ Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+ SheetTopBar(titleText = stringResource(R.string.profile__add_tag))
+ VerticalSpacer(16.dp)
+ Text13Up(text = stringResource(R.string.profile__add_tag_label))
+ VerticalSpacer(8.dp)
+ TextInput(
+ value = tag,
+ onValueChange = onTagChange,
+ placeholder = stringResource(R.string.profile__add_tag_placeholder),
+ trailingIcon = {
+ TextButton(onClick = onShowSuggestions) {
+ Icon(
+ painter = painterResource(R.drawable.ic_lightbulb),
+ contentDescription = null,
+ tint = Colors.PubkyGreen,
+ modifier = Modifier.padding(end = 4.dp),
+ )
+ BodySSB(
+ text = stringResource(R.string.profile__suggestions),
+ color = Colors.PubkyGreen,
+ )
+ }
+ },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ VerticalSpacer(24.dp)
+ PrimaryButton(
+ text = stringResource(R.string.common__save),
+ onClick = onSave,
+ enabled = isSaveEnabled,
+ )
+ VerticalSpacer(16.dp)
+ }
+}
+
+@Preview
+@Composable
+private fun Preview() {
+ AppThemeSurface {
+ TagFormContent(
+ tag = "Founder",
+ onTagChange = {},
+ onShowSuggestions = {},
+ onSave = {},
+ isSaveEnabled = true,
+ )
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/components/CenteredProfileHeader.kt b/app/src/main/java/to/bitkit/ui/components/CenteredProfileHeader.kt
new file mode 100644
index 000000000..8801bb713
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/components/CenteredProfileHeader.kt
@@ -0,0 +1,89 @@
+package to.bitkit.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+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.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import to.bitkit.R
+import to.bitkit.ext.ellipsisMiddle
+import to.bitkit.ui.theme.AppThemeSurface
+import to.bitkit.ui.theme.Colors
+
+private const val TRUNCATED_PK_LENGTH = 11
+
+@Composable
+fun CenteredProfileHeader(
+ publicKey: String,
+ name: String,
+ bio: String,
+ imageUrl: String?,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = modifier,
+ ) {
+ Text13Up(
+ text = publicKey.ellipsisMiddle(TRUNCATED_PK_LENGTH),
+ color = Colors.White64,
+ textAlign = TextAlign.Center,
+ )
+
+ VerticalSpacer(16.dp)
+
+ if (imageUrl != null) {
+ PubkyImage(uri = imageUrl, size = 100.dp)
+ } else {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(100.dp)
+ .clip(CircleShape)
+ .background(Colors.Gray5),
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_user_square),
+ contentDescription = null,
+ tint = Colors.White32,
+ modifier = Modifier.size(50.dp),
+ )
+ }
+ }
+
+ VerticalSpacer(16.dp)
+
+ Display(text = name)
+
+ if (bio.isNotEmpty()) {
+ VerticalSpacer(8.dp)
+ BodyM(
+ text = bio,
+ color = Colors.White64,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun CenteredProfileHeaderPreview() {
+ AppThemeSurface {
+ CenteredProfileHeader(
+ publicKey = "pk8e3qm5f4kgczagxhertyuiop1gxag",
+ name = "Satoshi Nakamoto",
+ bio = "Building a peer-to-peer electronic cash system.",
+ imageUrl = null,
+ )
+ }
+}
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 859a21fa7..1da500092 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.PubkyChoice)
+ 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/ProfileEditForm.kt b/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt
new file mode 100644
index 000000000..65cc19332
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt
@@ -0,0 +1,294 @@
+package to.bitkit.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+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.shape.RoundedCornerShape
+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.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.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import to.bitkit.R
+import to.bitkit.ui.theme.AppThemeSurface
+import to.bitkit.ui.theme.Colors
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun ProfileEditForm(
+ name: String,
+ onNameChange: (String) -> Unit,
+ publicKey: String,
+ bio: String,
+ onBioChange: (String) -> Unit,
+ links: ImmutableList,
+ onRemoveLink: (Int) -> Unit,
+ onAddLink: () -> Unit,
+ tags: ImmutableList,
+ onRemoveTag: (Int) -> Unit,
+ onAddTag: () -> Unit,
+ onSave: () -> Unit,
+ onCancel: () -> Unit,
+ isSaveEnabled: Boolean,
+ modifier: Modifier = Modifier,
+ avatarContent: @Composable () -> Unit = {},
+ onDelete: (() -> Unit)? = null,
+ deleteLabel: String = "",
+) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 32.dp)
+ ) {
+ VerticalSpacer(16.dp)
+ avatarContent()
+ VerticalSpacer(12.dp)
+
+ TextInput(
+ value = name,
+ onValueChange = onNameChange,
+ placeholder = stringResource(R.string.profile__edit_name_placeholder),
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ HorizontalDivider()
+ VerticalSpacer(12.dp)
+
+ Text13Up(
+ text = stringResource(R.string.profile__your_pubky),
+ color = Colors.White64,
+ )
+ VerticalSpacer(4.dp)
+ BodyS(
+ text = publicKey,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ HorizontalDivider(modifier = Modifier.padding(top = 12.dp))
+
+ VerticalSpacer(16.dp)
+ Text13Up(
+ text = stringResource(R.string.profile__edit_bio),
+ color = Colors.White64,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ VerticalSpacer(8.dp)
+ TextInput(
+ value = bio,
+ onValueChange = onBioChange,
+ placeholder = stringResource(R.string.profile__edit_bio_placeholder),
+ minLines = 2,
+ maxLines = 4,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ VerticalSpacer(16.dp)
+ links.forEachIndexed { index, link ->
+ HorizontalDivider(color = Colors.White10)
+ VerticalSpacer(8.dp)
+ Text13Up(text = link.label, color = Colors.White64)
+ VerticalSpacer(8.dp)
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(Colors.White10, RoundedCornerShape(8.dp))
+ .padding(16.dp),
+ ) {
+ BodySSB(text = link.url, modifier = Modifier.weight(1f))
+ HorizontalSpacer(8.dp)
+ Icon(
+ painter = painterResource(R.drawable.ic_trash),
+ contentDescription = null,
+ tint = Colors.White64,
+ modifier = Modifier
+ .size(16.dp)
+ .clickable { onRemoveLink(index) },
+ )
+ }
+ VerticalSpacer(8.dp)
+ }
+ Row(modifier = Modifier.fillMaxWidth()) {
+ PrimaryButton(
+ text = stringResource(R.string.profile__add_link),
+ onClick = onAddLink,
+ size = ButtonSize.Small,
+ fullWidth = false,
+ icon = {
+ Icon(
+ painter = painterResource(R.drawable.ic_link),
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ )
+ },
+ )
+ }
+
+ VerticalSpacer(16.dp)
+ Text13Up(
+ text = stringResource(R.string.profile__edit_tags),
+ color = Colors.White64,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ VerticalSpacer(8.dp)
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ tags.forEachIndexed { index, tag ->
+ TagButton(
+ text = tag,
+ onClick = { onRemoveTag(index) },
+ displayIconClose = true,
+ )
+ }
+ }
+ VerticalSpacer(8.dp)
+ Row(modifier = Modifier.fillMaxWidth()) {
+ PrimaryButton(
+ text = stringResource(R.string.profile__add_tag),
+ onClick = onAddTag,
+ size = ButtonSize.Small,
+ fullWidth = false,
+ icon = {
+ Icon(
+ painter = painterResource(R.drawable.ic_tag),
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ )
+ },
+ )
+ }
+
+ VerticalSpacer(16.dp)
+ BodyS(
+ text = stringResource(R.string.profile__edit_public_note),
+ color = Colors.White64,
+ )
+
+ if (onDelete != null) {
+ VerticalSpacer(16.dp)
+ HorizontalDivider()
+ VerticalSpacer(16.dp)
+ Text13Up(
+ text = stringResource(R.string.profile__edit_delete_section),
+ color = Colors.White64,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ VerticalSpacer(8.dp)
+ Row(modifier = Modifier.fillMaxWidth()) {
+ PrimaryButton(
+ text = deleteLabel,
+ onClick = onDelete,
+ size = ButtonSize.Small,
+ fullWidth = false,
+ icon = {
+ Icon(
+ painter = painterResource(R.drawable.ic_trash),
+ contentDescription = null,
+ tint = Colors.Red,
+ modifier = Modifier.size(16.dp),
+ )
+ },
+ )
+ }
+ }
+
+ FillHeight()
+ VerticalSpacer(16.dp)
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ SecondaryButton(
+ text = stringResource(R.string.common__cancel),
+ onClick = onCancel,
+ modifier = Modifier.weight(1f),
+ )
+ PrimaryButton(
+ text = stringResource(R.string.common__save),
+ onClick = onSave,
+ enabled = isSaveEnabled,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ VerticalSpacer(16.dp)
+ }
+}
+
+data class ProfileEditLink(val label: String, val url: String)
+
+@Composable
+fun AvatarCameraOverlay() {
+ Box(
+ contentAlignment = Alignment.BottomEnd,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(24.dp)
+ .background(Colors.Brand, CircleShape),
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_camera),
+ contentDescription = null,
+ tint = Colors.Black,
+ modifier = Modifier.size(14.dp),
+ )
+ }
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun Preview() {
+ AppThemeSurface {
+ var name by remember { mutableStateOf("Satoshi") }
+ var bio by remember { mutableStateOf("Authored the Bitcoin white paper") }
+ ProfileEditForm(
+ name = name,
+ onNameChange = { name = it },
+ publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg",
+ bio = bio,
+ onBioChange = { bio = it },
+ links = persistentListOf(ProfileEditLink("X", "https://x.com/satoshinakamoto")),
+ onRemoveLink = {},
+ onAddLink = {},
+ tags = persistentListOf("Founder"),
+ onRemoveTag = {},
+ onAddTag = {},
+ onSave = {},
+ onCancel = {},
+ isSaveEnabled = true,
+ onDelete = {},
+ deleteLabel = "Delete Profile",
+ )
+ }
+}
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/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt
index e1e7e4396..4f859d19b 100644
--- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt
+++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt
@@ -49,6 +49,7 @@ sealed interface Sheet {
data class Gift(val code: String, val amount: ULong) : Sheet
data object ConnectionClosed : Sheet
data object QrScanner : Sheet
+ data class PubkyAuth(val authUrl: String) : Sheet
data class TimedSheet(val type: TimedSheetType) : Sheet
}
diff --git a/app/src/main/java/to/bitkit/ui/components/Text.kt b/app/src/main/java/to/bitkit/ui/components/Text.kt
index 5150545fe..fceccde49 100644
--- a/app/src/main/java/to/bitkit/ui/components/Text.kt
+++ b/app/src/main/java/to/bitkit/ui/components/Text.kt
@@ -24,6 +24,7 @@ fun Display(
fontWeight: FontWeight = FontWeight.Black,
fontSize: TextUnit = 44.sp,
color: Color = MaterialTheme.colorScheme.primary,
+ textAlign: TextAlign? = null,
) {
Text(
text = text.uppercase(),
@@ -32,6 +33,7 @@ fun Display(
fontSize = fontSize,
color = color,
),
+ textAlign = textAlign,
modifier = modifier,
)
}
diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt
new file mode 100644
index 000000000..db394b10b
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt
@@ -0,0 +1,499 @@
+package to.bitkit.ui.screens.contacts
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Image
+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.shape.CircleShape
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.PathEffect
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.drawscope.rotate
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import to.bitkit.R
+import to.bitkit.ext.ellipsisMiddle
+import to.bitkit.ext.getClipboardText
+import to.bitkit.models.PubkyProfile
+import to.bitkit.models.PubkyProfileLink
+import to.bitkit.ui.components.BodyM
+import to.bitkit.ui.components.BodyS
+import to.bitkit.ui.components.BottomSheet
+import to.bitkit.ui.components.CenteredProfileHeader
+import to.bitkit.ui.components.Display
+import to.bitkit.ui.components.PrimaryButton
+import to.bitkit.ui.components.SecondaryButton
+import to.bitkit.ui.components.Text13Up
+import to.bitkit.ui.components.TextInput
+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.scaffold.SheetTopBar
+import to.bitkit.ui.theme.AppThemeSurface
+import to.bitkit.ui.theme.Colors
+import to.bitkit.ui.utils.withAccent
+
+// region AddContactSheet (bottom sheet)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AddContactSheet(
+ onDismiss: () -> Unit,
+ onSubmit: (publicKey: String) -> Unit,
+ onScanQr: () -> Unit,
+) {
+ val context = LocalContext.current
+ var publicKeyInput by remember { mutableStateOf("") }
+
+ BottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ ) {
+ AddContactSheetContent(
+ publicKeyInput = publicKeyInput,
+ onPublicKeyChange = { publicKeyInput = it },
+ onPaste = { context.getClipboardText()?.trim()?.let { publicKeyInput = it } },
+ onScanQr = onScanQr,
+ onSubmit = { onSubmit(publicKeyInput) },
+ )
+ }
+}
+
+@Composable
+private fun AddContactSheetContent(
+ publicKeyInput: String,
+ onPublicKeyChange: (String) -> Unit,
+ onPaste: () -> Unit,
+ onScanQr: () -> Unit,
+ onSubmit: () -> Unit,
+) {
+ Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+ SheetTopBar(titleText = stringResource(R.string.contacts__add_sheet_title))
+ VerticalSpacer(16.dp)
+
+ BodyM(
+ text = stringResource(R.string.contacts__add_description),
+ color = Colors.White64,
+ )
+ VerticalSpacer(16.dp)
+
+ Text13Up(text = stringResource(R.string.contacts__add_pubky_label))
+ VerticalSpacer(8.dp)
+ TextInput(
+ value = publicKeyInput,
+ onValueChange = onPublicKeyChange,
+ placeholder = stringResource(R.string.contacts__add_pubky_placeholder),
+ singleLine = true,
+ trailingIcon = {
+ IconButton(onClick = onPaste) {
+ Icon(
+ painter = painterResource(R.drawable.ic_clipboard_text),
+ contentDescription = null,
+ tint = Colors.White64,
+ )
+ }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ VerticalSpacer(16.dp)
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ SecondaryButton(
+ text = stringResource(R.string.contacts__add_scan_qr),
+ onClick = onScanQr,
+ modifier = Modifier.weight(1f),
+ )
+ PrimaryButton(
+ text = stringResource(R.string.contacts__add_button),
+ onClick = onSubmit,
+ enabled = publicKeyInput.isNotBlank(),
+ modifier = Modifier.weight(1f),
+ )
+ }
+ VerticalSpacer(16.dp)
+ }
+}
+
+// endregion
+
+// region AddContactScreen (full screen)
+
+@Composable
+fun AddContactScreen(
+ viewModel: AddContactViewModel,
+ onBackClick: () -> Unit,
+ onContactSaved: () -> Unit,
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect {
+ when (it) {
+ AddContactEffect.ContactSaved -> onContactSaved()
+ }
+ }
+ }
+
+ Content(
+ uiState = uiState,
+ onBackClick = onBackClick,
+ onSave = { viewModel.saveContact() },
+ onRetry = { viewModel.fetchProfile(uiState.publicKeyInput) },
+ )
+}
+
+@Composable
+private fun Content(
+ uiState: AddContactUiState,
+ onBackClick: () -> Unit,
+ onSave: () -> Unit,
+ onRetry: () -> Unit,
+) {
+ ScreenColumn {
+ AppTopBar(
+ titleText = stringResource(R.string.contacts__add_contact_title),
+ onBackClick = onBackClick,
+ actions = { DrawerNavIcon() },
+ )
+
+ when {
+ uiState.isLoading && uiState.fetchedProfile == null -> LoadingContent(
+ publicKey = uiState.publicKeyInput,
+ )
+ uiState.error != null && uiState.fetchedProfile == null -> ErrorContent(
+ error = uiState.error,
+ onRetry = onRetry,
+ )
+ uiState.fetchedProfile != null -> LoadedContent(
+ profile = uiState.fetchedProfile,
+ isLoading = uiState.isLoading,
+ onDiscard = onBackClick,
+ onSave = onSave,
+ )
+ }
+ }
+}
+
+private const val TRUNCATED_PK_LENGTH = 11
+private const val ELLIPSE_ANIMATION_DURATION_MS = 8000
+
+@Composable
+private fun LoadingContent(publicKey: String) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 32.dp),
+ ) {
+ VerticalSpacer(24.dp)
+
+ Text13Up(
+ text = publicKey.ellipsisMiddle(TRUNCATED_PK_LENGTH),
+ color = Colors.White64,
+ )
+
+ VerticalSpacer(16.dp)
+
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(80.dp)
+ .clip(CircleShape)
+ .background(Colors.Gray5),
+ ) {
+ Display(
+ text = publicKey.take(1).uppercase(),
+ fontSize = 28.sp,
+ )
+ }
+
+ VerticalSpacer(24.dp)
+
+ Display(
+ text = stringResource(R.string.contacts__add_retrieving)
+ .withAccent(accentColor = Colors.PubkyGreen),
+ )
+
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ ) {
+ RotatingEllipses(modifier = Modifier.size(256.dp))
+ }
+ }
+}
+
+@Composable
+private fun RotatingEllipses(modifier: Modifier = Modifier) {
+ val transition = rememberInfiniteTransition(label = "ellipses")
+ val outerRotation by transition.animateFloat(
+ initialValue = 0f,
+ targetValue = 360f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(ELLIPSE_ANIMATION_DURATION_MS, easing = LinearEasing),
+ repeatMode = RepeatMode.Restart,
+ ),
+ label = "outer",
+ )
+ val innerRotation by transition.animateFloat(
+ initialValue = 360f,
+ targetValue = 0f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(ELLIPSE_ANIMATION_DURATION_MS, easing = LinearEasing),
+ repeatMode = RepeatMode.Restart,
+ ),
+ label = "inner",
+ )
+ val green = Colors.PubkyGreen
+
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = modifier
+ .drawBehind {
+ val dashOn = 12.dp.toPx()
+ val dashOff = 8.dp.toPx()
+ val dashedStroke = Stroke(
+ width = 2.dp.toPx(),
+ cap = StrokeCap.Round,
+ pathEffect = PathEffect.dashPathEffect(floatArrayOf(dashOn, dashOff)),
+ )
+
+ val outerDiameter = size.width * 1.2f
+ val outerSize = Size(outerDiameter, outerDiameter)
+ val outerOffset = Offset(
+ (size.width - outerDiameter) / 2f,
+ (size.height - outerDiameter) / 2f,
+ )
+ rotate(outerRotation) {
+ drawArc(
+ brush = Brush.sweepGradient(
+ 0f to green,
+ 0.5f to green.copy(alpha = 0.1f),
+ 1f to green,
+ ),
+ startAngle = 0f,
+ sweepAngle = 360f,
+ useCenter = false,
+ topLeft = outerOffset,
+ size = outerSize,
+ style = dashedStroke,
+ )
+ }
+
+ val innerDiameter = size.width * 0.8f
+ val innerSize = Size(innerDiameter, innerDiameter)
+ val innerOffset = Offset(
+ (size.width - innerDiameter) / 2f,
+ (size.height - innerDiameter) / 2f,
+ )
+ rotate(innerRotation) {
+ drawArc(
+ brush = Brush.sweepGradient(
+ 0f to green,
+ 0.5f to green.copy(alpha = 0.1f),
+ 1f to green,
+ ),
+ startAngle = 0f,
+ sweepAngle = 360f,
+ useCenter = false,
+ topLeft = innerOffset,
+ size = innerSize,
+ style = dashedStroke,
+ )
+ }
+ },
+ ) {
+ Image(
+ painter = painterResource(R.drawable.card),
+ contentDescription = null,
+ modifier = Modifier.size(128.dp),
+ )
+ }
+}
+
+@Composable
+private fun ErrorContent(
+ error: String,
+ onRetry: () -> Unit,
+) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 32.dp),
+ ) {
+ BodyM(text = error, color = Colors.White64, textAlign = TextAlign.Center)
+ VerticalSpacer(16.dp)
+ SecondaryButton(
+ text = stringResource(R.string.common__retry),
+ onClick = onRetry,
+ )
+ }
+}
+
+@Composable
+private fun LoadedContent(
+ profile: PubkyProfile,
+ isLoading: Boolean,
+ onDiscard: () -> Unit,
+ onSave: () -> Unit,
+) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 32.dp),
+ ) {
+ VerticalSpacer(24.dp)
+
+ CenteredProfileHeader(
+ publicKey = profile.publicKey,
+ name = profile.name,
+ bio = profile.bio,
+ imageUrl = profile.imageUrl,
+ )
+
+ Box(modifier = Modifier.weight(1f))
+
+ BodyS(
+ text = stringResource(R.string.contacts__add_privacy_notice, profile.name),
+ color = Colors.White64,
+ )
+ VerticalSpacer(16.dp)
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ SecondaryButton(
+ text = stringResource(R.string.contacts__add_discard),
+ onClick = onDiscard,
+ modifier = Modifier.weight(1f),
+ )
+ PrimaryButton(
+ text = stringResource(R.string.common__save),
+ onClick = onSave,
+ enabled = !isLoading,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ VerticalSpacer(16.dp)
+ }
+}
+
+// endregion
+
+// region Previews
+
+@Preview
+@Composable
+private fun SheetPreview() {
+ AppThemeSurface {
+ AddContactSheetContent(
+ publicKeyInput = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg",
+ onPublicKeyChange = {},
+ onPaste = {},
+ onScanQr = {},
+ onSubmit = {},
+ )
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun LoadingPreview() {
+ AppThemeSurface {
+ Content(
+ uiState = AddContactUiState(
+ publicKeyInput = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg",
+ isLoading = true,
+ ),
+ onBackClick = {},
+ onSave = {},
+ onRetry = {},
+ )
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun LoadedPreview() {
+ AppThemeSurface {
+ Content(
+ uiState = AddContactUiState(
+ publicKeyInput = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg",
+ fetchedProfile = PubkyProfile(
+ publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg",
+ name = "John Carvalho",
+ bio = "CEO at @synonym_to",
+ imageUrl = null,
+ links = listOf(PubkyProfileLink("Website", "https://synonym.to")),
+ status = null,
+ ),
+ ),
+ onBackClick = {},
+ onSave = {},
+ onRetry = {},
+ )
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun ErrorPreview() {
+ AppThemeSurface {
+ Content(
+ uiState = AddContactUiState(
+ publicKeyInput = "invalid_key",
+ error = "Could not retrieve contact info. Please check the public key and try again.",
+ ),
+ onBackClick = {},
+ onSave = {},
+ onRetry = {},
+ )
+ }
+}
+
+// endregion
diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt
new file mode 100644
index 000000000..a161086c6
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt
@@ -0,0 +1,107 @@
+package to.bitkit.ui.screens.contacts
+
+import android.content.Context
+import androidx.compose.runtime.Stable
+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.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+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.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 AddContactViewModel @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val pubkyRepo: PubkyRepo,
+ savedStateHandle: SavedStateHandle,
+) : ViewModel() {
+
+ companion object {
+ private const val TAG = "AddContactViewModel"
+ }
+
+ private val publicKey: String = checkNotNull(
+ savedStateHandle["publicKey"],
+ ) { "publicKey not found in SavedStateHandle" }
+
+ private val _uiState = MutableStateFlow(AddContactUiState(publicKeyInput = publicKey))
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _effects = MutableSharedFlow(extraBufferCapacity = 1)
+ val effects = _effects.asSharedFlow()
+
+ init {
+ fetchProfile(publicKey)
+ }
+
+ fun onPublicKeyChange(value: String) {
+ _uiState.update { it.copy(publicKeyInput = value, error = null) }
+ }
+
+ fun fetchProfile(publicKey: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, error = null, publicKeyInput = publicKey) }
+ pubkyRepo.fetchContactProfile(publicKey)
+ .onSuccess { profile ->
+ _uiState.update { it.copy(fetchedProfile = profile, isLoading = false) }
+ }
+ .onFailure {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ error = context.getString(R.string.contacts__add_error_fetch),
+ )
+ }
+ }
+ }
+ }
+
+ fun saveContact() {
+ val profile = _uiState.value.fetchedProfile ?: return
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true) }
+ pubkyRepo.addContact(profile.publicKey, profile)
+ .onSuccess {
+ ToastEventBus.send(
+ type = Toast.ToastType.SUCCESS,
+ title = context.getString(R.string.contacts__add_contact_saved),
+ )
+ _effects.emit(AddContactEffect.ContactSaved)
+ }
+ .onFailure {
+ Logger.error("Failed to save contact", it, context = TAG)
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ error = context.getString(R.string.common__error_body),
+ )
+ }
+ }
+ }
+ }
+}
+
+@Stable
+data class AddContactUiState(
+ val publicKeyInput: String = "",
+ val fetchedProfile: PubkyProfile? = null,
+ val isLoading: Boolean = false,
+ val error: String? = null,
+)
+
+sealed interface AddContactEffect {
+ data object ContactSaved : AddContactEffect
+}
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..6e1f64aab
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt
@@ -0,0 +1,280 @@
+package to.bitkit.ui.screens.contacts
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+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.verticalScroll
+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.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 kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+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.AddTagSheet
+import to.bitkit.ui.components.BodyM
+import to.bitkit.ui.components.CenteredProfileHeader
+import to.bitkit.ui.components.GradientCircularProgressIndicator
+import to.bitkit.ui.components.LinkRow
+import to.bitkit.ui.components.SecondaryButton
+import to.bitkit.ui.components.TagButton
+import to.bitkit.ui.components.Text13Up
+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.util.shareText
+import to.bitkit.ui.theme.AppThemeSurface
+import to.bitkit.ui.theme.Colors
+
+@Composable
+fun ContactDetailScreen(
+ viewModel: ContactDetailViewModel,
+ onBackClick: () -> Unit,
+ onEditContact: (String) -> Unit = {},
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect {
+ when (it) {
+ ContactDetailEffect.DeleteSuccess -> onBackClick()
+ }
+ }
+ }
+
+ Content(
+ uiState = uiState,
+ onBackClick = onBackClick,
+ onClickEdit = { uiState.profile?.publicKey?.let { onEditContact(it) } },
+ onClickCopy = { viewModel.copyPublicKey() },
+ onClickShare = { uiState.profile?.publicKey?.let { shareText(context, it) } },
+ onClickDelete = { viewModel.showDeleteConfirmation() },
+ onClickRetry = { viewModel.loadContact() },
+ onAddTag = { viewModel.showAddTagSheet() },
+ onRemoveTag = { viewModel.removeTag(it) },
+ onDismissAddTagSheet = { viewModel.dismissAddTagSheet() },
+ onSaveTag = { viewModel.addTag(it) },
+ onDismissDeleteDialog = { viewModel.dismissDeleteDialog() },
+ onConfirmDelete = { viewModel.deleteContact() },
+ )
+}
+
+@Composable
+private fun Content(
+ uiState: ContactDetailUiState,
+ onBackClick: () -> Unit,
+ onClickEdit: () -> Unit,
+ onClickCopy: () -> Unit,
+ onClickShare: () -> Unit,
+ onClickDelete: () -> Unit,
+ onClickRetry: () -> Unit,
+ onAddTag: () -> Unit,
+ onRemoveTag: (Int) -> Unit,
+ onDismissAddTagSheet: () -> Unit,
+ onSaveTag: (String) -> Unit,
+ onDismissDeleteDialog: () -> Unit,
+ onConfirmDelete: () -> 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,
+ tags = uiState.tags,
+ onClickEdit = onClickEdit,
+ onClickCopy = onClickCopy,
+ onClickShare = onClickShare,
+ onClickDelete = onClickDelete,
+ onAddTag = onAddTag,
+ onRemoveTag = onRemoveTag,
+ )
+ else -> EmptyState(onClickRetry = onClickRetry)
+ }
+ }
+
+ if (uiState.showDeleteDialog && currentProfile != null) {
+ AppAlertDialog(
+ title = stringResource(R.string.contacts__delete_confirm_title, currentProfile.name),
+ text = stringResource(R.string.contacts__delete_confirm_text),
+ confirmText = stringResource(R.string.contacts__delete_contact),
+ onConfirm = onConfirmDelete,
+ onDismiss = onDismissDeleteDialog,
+ )
+ }
+
+ if (uiState.showAddTagSheet) {
+ AddTagSheet(
+ onDismiss = onDismissAddTagSheet,
+ onSave = onSaveTag,
+ )
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun ContactBody(
+ profile: PubkyProfile,
+ tags: ImmutableList,
+ onClickEdit: () -> Unit,
+ onClickCopy: () -> Unit,
+ onClickShare: () -> Unit,
+ onClickDelete: () -> Unit,
+ onAddTag: () -> Unit,
+ onRemoveTag: (Int) -> Unit,
+) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 32.dp)
+ ) {
+ VerticalSpacer(24.dp)
+
+ CenteredProfileHeader(
+ publicKey = profile.publicKey,
+ name = profile.name,
+ bio = profile.bio,
+ imageUrl = profile.imageUrl,
+ )
+
+ VerticalSpacer(24.dp)
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ ActionButton(onClick = onClickCopy, iconRes = R.drawable.ic_copy)
+ ActionButton(onClick = onClickShare, iconRes = R.drawable.ic_share)
+ ActionButton(onClick = onClickEdit, iconRes = R.drawable.ic_edit)
+ ActionButton(onClick = onClickDelete, iconRes = R.drawable.ic_trash)
+ }
+
+ VerticalSpacer(32.dp)
+
+ profile.links.forEach { LinkRow(label = it.label, value = it.url) }
+
+ VerticalSpacer(16.dp)
+ Text13Up(
+ text = stringResource(R.string.profile__edit_tags),
+ color = Colors.White64,
+ modifier = Modifier.fillMaxWidth()
+ )
+ VerticalSpacer(8.dp)
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ tags.forEachIndexed { index, tag ->
+ TagButton(
+ text = tag,
+ onClick = { onRemoveTag(index) },
+ displayIconClose = true,
+ )
+ }
+ }
+ VerticalSpacer(8.dp)
+ Row(modifier = Modifier.fillMaxWidth()) {
+ TagButton(
+ text = stringResource(R.string.profile__add_tag),
+ onClick = onAddTag,
+ icon = painterResource(R.drawable.ic_tag),
+ displayIconClose = true,
+ )
+ }
+ }
+}
+
+@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.contacts__detail_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"),
+ ),
+ tags = listOf("CEO", "Bitcoin"),
+ status = null,
+ ),
+ tags = persistentListOf("CEO", "Bitcoin"),
+ ),
+ onBackClick = {},
+ onClickEdit = {},
+ onClickCopy = {},
+ onClickShare = {},
+ onClickDelete = {},
+ onClickRetry = {},
+ onAddTag = {},
+ onRemoveTag = {},
+ onDismissAddTagSheet = {},
+ onSaveTag = {},
+ onDismissDeleteDialog = {},
+ onConfirmDelete = {},
+ )
+ }
+}
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..30a47fba7
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt
@@ -0,0 +1,184 @@
+package to.bitkit.ui.screens.contacts
+
+import android.content.Context
+import androidx.compose.runtime.Stable
+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.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+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.ext.setClipboardText
+import to.bitkit.models.PubkyProfile
+import to.bitkit.models.PubkyProfileLink
+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 ContactDetailViewModel @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val pubkyRepo: PubkyRepo,
+ savedStateHandle: SavedStateHandle,
+) : ViewModel() {
+
+ companion object {
+ private const val TAG = "ContactDetailViewModel"
+ }
+
+ private val publicKey: String = checkNotNull(
+ savedStateHandle["publicKey"],
+ ) { "publicKey not found in SavedStateHandle" }
+
+ private val _uiState = MutableStateFlow(ContactDetailUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _effects = MutableSharedFlow(extraBufferCapacity = 1)
+ val effects = _effects.asSharedFlow()
+
+ init {
+ loadContact()
+ observeContactUpdates()
+ }
+
+ fun loadContact() {
+ viewModelScope.launch {
+ val cached = pubkyRepo.contacts.value.find { it.publicKey == publicKey }
+ if (cached != null) {
+ _uiState.update {
+ it.copy(
+ profile = cached,
+ tags = cached.tags.toImmutableList(),
+ isLoading = false,
+ )
+ }
+ return@launch
+ }
+ _uiState.update { it.copy(isLoading = true) }
+ pubkyRepo.fetchContactProfile(publicKey)
+ .onSuccess { profile ->
+ _uiState.update {
+ it.copy(
+ profile = profile,
+ tags = profile.tags.toImmutableList(),
+ isLoading = false,
+ )
+ }
+ }
+ .onFailure {
+ _uiState.update {
+ it.copy(
+ profile = it.profile ?: PubkyProfile.placeholder(publicKey),
+ isLoading = false,
+ )
+ }
+ }
+ }
+ }
+
+ private fun observeContactUpdates() {
+ viewModelScope.launch {
+ pubkyRepo.contacts.collect { contacts ->
+ val updated = contacts.find { it.publicKey == publicKey } ?: return@collect
+ _uiState.update {
+ it.copy(
+ profile = updated,
+ tags = updated.tags.toImmutableList(),
+ )
+ }
+ }
+ }
+ }
+
+ 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),
+ )
+ }
+ }
+
+ fun showAddTagSheet() {
+ _uiState.update { it.copy(showAddTagSheet = true) }
+ }
+
+ fun dismissAddTagSheet() {
+ _uiState.update { it.copy(showAddTagSheet = false) }
+ }
+
+ fun addTag(tag: String) {
+ val newTags = (_uiState.value.tags + tag).distinct().toImmutableList()
+ _uiState.update { it.copy(tags = newTags, showAddTagSheet = false) }
+ persistTags(newTags)
+ }
+
+ fun removeTag(index: Int) {
+ val newTags = _uiState.value.tags.filterIndexed { i, _ -> i != index }.toImmutableList()
+ _uiState.update { it.copy(tags = newTags) }
+ persistTags(newTags)
+ }
+
+ fun showDeleteConfirmation() {
+ _uiState.update { it.copy(showDeleteDialog = true) }
+ }
+
+ fun dismissDeleteDialog() {
+ _uiState.update { it.copy(showDeleteDialog = false) }
+ }
+
+ fun deleteContact() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(showDeleteDialog = false) }
+ pubkyRepo.removeContact(publicKey)
+ .onSuccess {
+ _effects.emit(ContactDetailEffect.DeleteSuccess)
+ }
+ .onFailure {
+ Logger.error("Failed to delete contact '$publicKey'", it, context = TAG)
+ }
+ }
+ }
+
+ private fun persistTags(tags: List) {
+ val profile = _uiState.value.profile ?: return
+ viewModelScope.launch {
+ pubkyRepo.updateContact(
+ publicKey = publicKey,
+ name = profile.name,
+ bio = profile.bio,
+ imageUrl = profile.imageUrl,
+ links = profile.links.map { PubkyProfileLink(it.label, it.url) },
+ tags = tags,
+ ).onFailure {
+ Logger.error("Failed to update tags for contact '$publicKey'", it, context = TAG)
+ }
+ }
+ }
+}
+
+@Stable
+data class ContactDetailUiState(
+ val profile: PubkyProfile? = null,
+ val tags: ImmutableList = persistentListOf(),
+ val isLoading: Boolean = false,
+ val showAddTagSheet: Boolean = false,
+ val showDeleteDialog: Boolean = false,
+)
+
+sealed interface ContactDetailEffect {
+ data object DeleteSuccess : ContactDetailEffect
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewScreen.kt
new file mode 100644
index 000000000..7547a8cee
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewScreen.kt
@@ -0,0 +1,283 @@
+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.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+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 kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import to.bitkit.R
+import to.bitkit.models.PubkyProfile
+import to.bitkit.ui.components.BodyM
+import to.bitkit.ui.components.BodyMSB
+import to.bitkit.ui.components.BodySSB
+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.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.theme.AppThemeSurface
+import to.bitkit.ui.theme.Colors
+import to.bitkit.ui.utils.withAccent
+
+@Composable
+fun ContactImportOverviewScreen(
+ viewModel: ContactImportOverviewViewModel,
+ onBackClick: () -> Unit,
+ onImportComplete: () -> Unit,
+ onNavigateToSelect: () -> Unit,
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect {
+ when (it) {
+ ContactImportOverviewEffect.ImportComplete -> onImportComplete()
+ ContactImportOverviewEffect.NavigateToSelect -> onNavigateToSelect()
+ }
+ }
+ }
+
+ Content(
+ uiState = uiState,
+ onBackClick = onBackClick,
+ onClickSelect = { viewModel.navigateToSelect() },
+ onClickImportAll = { viewModel.importAll() },
+ )
+}
+
+@Composable
+private fun Content(
+ uiState: ContactImportOverviewUiState,
+ onBackClick: () -> Unit,
+ onClickSelect: () -> Unit,
+ onClickImportAll: () -> Unit,
+) {
+ ScreenColumn {
+ AppTopBar(
+ titleText = stringResource(R.string.contacts__import_title),
+ onBackClick = onBackClick,
+ actions = { DrawerNavIcon() },
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 32.dp)
+ ) {
+ VerticalSpacer(24.dp)
+
+ Display(
+ text = stringResource(R.string.contacts__import_overview_headline)
+ .withAccent(accentColor = Colors.PubkyGreen),
+ )
+
+ VerticalSpacer(8.dp)
+
+ val truncatedKey = uiState.profile?.truncatedPublicKey.orEmpty()
+ BodyM(
+ text = stringResource(R.string.contacts__import_overview_subtitle, truncatedKey),
+ color = Colors.White64,
+ )
+
+ VerticalSpacer(32.dp)
+
+ if (uiState.profile != null) {
+ ProfileRow(profile = uiState.profile)
+ VerticalSpacer(24.dp)
+ }
+
+ if (uiState.contacts.isNotEmpty()) {
+ ContactCountRow(contacts = uiState.contacts)
+ }
+
+ FillHeight()
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ SecondaryButton(
+ text = stringResource(R.string.contacts__import_select),
+ onClick = onClickSelect,
+ modifier = Modifier.weight(1f),
+ )
+ PrimaryButton(
+ text = stringResource(R.string.contacts__import_all),
+ onClick = onClickImportAll,
+ isLoading = uiState.isImporting,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ VerticalSpacer(16.dp)
+ }
+ }
+}
+
+@Composable
+private fun ProfileRow(profile: PubkyProfile) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Display(
+ text = profile.name,
+ modifier = Modifier.weight(1f),
+ )
+
+ HorizontalSpacer(16.dp)
+
+ 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.White10)
+ ) {
+ BodySSB(
+ text = profile.name.firstOrNull()?.uppercase().orEmpty(),
+ color = Colors.White,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ContactCountRow(contacts: ImmutableList) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ BodyMSB(
+ text = stringResource(R.string.contacts__import_friends_count, contacts.size),
+ )
+
+ AvatarStack(contacts = contacts)
+ }
+}
+
+@Composable
+private fun AvatarStack(contacts: ImmutableList) {
+ val visibleCount = minOf(contacts.size, 4)
+ val overflow = contacts.size - visibleCount
+
+ Box {
+ contacts.take(visibleCount).forEachIndexed { index, contact ->
+ Box(modifier = Modifier.offset(x = (index * 24).dp)) {
+ if (contact.imageUrl != null) {
+ PubkyImage(uri = contact.imageUrl, size = 36.dp)
+ } else {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(36.dp)
+ .clip(CircleShape)
+ .background(Colors.White10)
+ ) {
+ BodySSB(
+ text = contact.name.firstOrNull()?.uppercase().orEmpty(),
+ color = Colors.White,
+ )
+ }
+ }
+ }
+ }
+
+ if (overflow > 0) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .offset(x = (visibleCount * 24).dp)
+ .size(36.dp)
+ .clip(CircleShape)
+ .background(Colors.Gray4)
+ ) {
+ BodySSB(text = "+$overflow", color = Colors.White)
+ }
+ }
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun Preview() {
+ val contacts = listOf(
+ PubkyProfile("pk1", "Alex Stronghand", "", null, emptyList(), status = null),
+ PubkyProfile("pk2", "Anna Pleb", "", null, emptyList(), status = null),
+ PubkyProfile("pk3", "Craig Wrong", "", null, emptyList(), status = null),
+ PubkyProfile("pk4", "Satoshi Nakamoto", "", null, emptyList(), status = null),
+ PubkyProfile("pk5", "Hal Finney", "", null, emptyList(), status = null),
+ PubkyProfile("pk6", "Nick Szabo", "", null, emptyList(), status = null),
+ ).toImmutableList()
+
+ AppThemeSurface {
+ Content(
+ uiState = ContactImportOverviewUiState(
+ profile = PubkyProfile(
+ publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg",
+ name = "John Carvalho",
+ bio = "CEO at @synonym_to",
+ imageUrl = null,
+ links = emptyList(),
+ status = null,
+ ),
+ contacts = contacts,
+ ),
+ onBackClick = {},
+ onClickSelect = {},
+ onClickImportAll = {},
+ )
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun PreviewLoading() {
+ AppThemeSurface {
+ Content(
+ uiState = ContactImportOverviewUiState(
+ profile = PubkyProfile(
+ publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg",
+ name = "John Carvalho",
+ bio = "",
+ imageUrl = null,
+ links = emptyList(),
+ status = null,
+ ),
+ contacts = persistentListOf(),
+ isImporting = true,
+ ),
+ onBackClick = {},
+ onClickSelect = {},
+ onClickImportAll = {},
+ )
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewViewModel.kt
new file mode 100644
index 000000000..cfcb9adce
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewViewModel.kt
@@ -0,0 +1,96 @@
+package to.bitkit.ui.screens.contacts
+
+import android.content.Context
+import androidx.compose.runtime.Stable
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+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.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 ContactImportOverviewViewModel @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val pubkyRepo: PubkyRepo,
+) : ViewModel() {
+
+ companion object {
+ private const val TAG = "ContactImportOverviewVM"
+ }
+
+ private val _uiState = MutableStateFlow(ContactImportOverviewUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _effects = MutableSharedFlow(extraBufferCapacity = 1)
+ val effects = _effects.asSharedFlow()
+
+ init {
+ viewModelScope.launch {
+ val profile = pubkyRepo.pendingImportProfile.value
+ val contacts = pubkyRepo.pendingImportContacts.value
+ _uiState.update {
+ it.copy(
+ profile = profile,
+ contacts = contacts.toImmutableList(),
+ )
+ }
+ }
+ }
+
+ fun importAll() {
+ val contacts = _uiState.value.contacts
+ if (contacts.isEmpty()) return
+
+ viewModelScope.launch {
+ _uiState.update { it.copy(isImporting = true) }
+ pubkyRepo.importContacts(contacts.map { it.publicKey })
+ .onSuccess {
+ _uiState.update { it.copy(isImporting = false) }
+ _effects.emit(ContactImportOverviewEffect.ImportComplete)
+ }
+ .onFailure {
+ Logger.error("Failed to import all contacts", it, context = TAG)
+ _uiState.update { it.copy(isImporting = false) }
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.common__error),
+ description = it.message,
+ )
+ }
+ }
+ }
+
+ fun navigateToSelect() {
+ viewModelScope.launch {
+ _effects.emit(ContactImportOverviewEffect.NavigateToSelect)
+ }
+ }
+}
+
+@Stable
+data class ContactImportOverviewUiState(
+ val profile: PubkyProfile? = null,
+ val contacts: ImmutableList = persistentListOf(),
+ val isImporting: Boolean = false,
+)
+
+sealed interface ContactImportOverviewEffect {
+ data object ImportComplete : ContactImportOverviewEffect
+ data object NavigateToSelect : ContactImportOverviewEffect
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectScreen.kt
new file mode 100644
index 000000000..ab6041b0c
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectScreen.kt
@@ -0,0 +1,273 @@
+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.Icon
+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.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 kotlinx.collections.immutable.toImmutableList
+import to.bitkit.R
+import to.bitkit.models.PubkyProfile
+import to.bitkit.ui.components.BodyM
+import to.bitkit.ui.components.BodyMSB
+import to.bitkit.ui.components.BodyS
+import to.bitkit.ui.components.BodySSB
+import to.bitkit.ui.components.Display
+import to.bitkit.ui.components.HorizontalSpacer
+import to.bitkit.ui.components.PrimaryButton
+import to.bitkit.ui.components.PubkyImage
+import to.bitkit.ui.components.TagButton
+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
+import to.bitkit.ui.utils.withAccent
+
+@Composable
+fun ContactImportSelectScreen(
+ viewModel: ContactImportSelectViewModel,
+ onBackClick: () -> Unit,
+ onImportComplete: () -> Unit,
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect {
+ when (it) {
+ ContactImportSelectEffect.ImportComplete -> onImportComplete()
+ }
+ }
+ }
+
+ Content(
+ uiState = uiState,
+ onBackClick = onBackClick,
+ onToggleContact = { viewModel.toggleContact(it) },
+ onSelectAll = { viewModel.selectAll() },
+ onSelectNone = { viewModel.selectNone() },
+ onClickContinue = { viewModel.importSelected() },
+ )
+}
+
+@Composable
+private fun Content(
+ uiState: ContactImportSelectUiState,
+ onBackClick: () -> Unit,
+ onToggleContact: (String) -> Unit,
+ onSelectAll: () -> Unit,
+ onSelectNone: () -> Unit,
+ onClickContinue: () -> Unit,
+) {
+ ScreenColumn {
+ AppTopBar(
+ titleText = stringResource(R.string.contacts__import_title),
+ onBackClick = onBackClick,
+ actions = { DrawerNavIcon() },
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 32.dp)
+ ) {
+ VerticalSpacer(24.dp)
+
+ Display(
+ text = stringResource(R.string.contacts__import_select_headline)
+ .withAccent(accentColor = Colors.PubkyGreen),
+ )
+
+ VerticalSpacer(8.dp)
+
+ BodyM(
+ text = stringResource(R.string.contacts__import_select_subtitle),
+ color = Colors.White64,
+ )
+
+ VerticalSpacer(16.dp)
+
+ LazyColumn(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ ) {
+ items(uiState.contacts, key = { it.profile.publicKey }) { contact ->
+ SelectableContactRow(
+ contact = contact,
+ onToggle = { onToggleContact(contact.profile.publicKey) },
+ )
+ }
+ }
+
+ VerticalSpacer(16.dp)
+
+ FooterBar(
+ selectedCount = uiState.selectedCount,
+ totalCount = uiState.contacts.size,
+ onSelectAll = onSelectAll,
+ onSelectNone = onSelectNone,
+ )
+
+ VerticalSpacer(16.dp)
+
+ PrimaryButton(
+ text = stringResource(R.string.common__continue),
+ onClick = onClickContinue,
+ isLoading = uiState.isImporting,
+ enabled = uiState.selectedCount > 0,
+ )
+ VerticalSpacer(16.dp)
+ }
+ }
+}
+
+@Composable
+private fun SelectableContactRow(
+ contact: SelectableContact,
+ onToggle: () -> Unit,
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickableAlpha(onClick = onToggle)
+ .padding(vertical = 12.dp)
+ ) {
+ ContactAvatar(profile = contact.profile)
+
+ HorizontalSpacer(16.dp)
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ modifier = Modifier.weight(1f),
+ ) {
+ BodyS(text = contact.profile.truncatedPublicKey, color = Colors.White64)
+ BodyMSB(text = contact.profile.name)
+ }
+
+ HorizontalSpacer(16.dp)
+
+ if (contact.isSelected) {
+ Icon(
+ painter = painterResource(R.drawable.ic_check),
+ contentDescription = null,
+ tint = Colors.PubkyGreen,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ }
+}
+
+@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 FooterBar(
+ selectedCount: Int,
+ totalCount: Int,
+ onSelectAll: () -> Unit,
+ onSelectNone: () -> Unit,
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ BodyMSB(
+ text = stringResource(R.string.contacts__import_selected_count, selectedCount),
+ )
+
+ HorizontalSpacer(16.dp)
+
+ val allSelected = selectedCount == totalCount
+ TagButton(
+ text = if (allSelected) {
+ stringResource(R.string.contacts__import_select_none)
+ } else {
+ stringResource(R.string.contacts__import_select_all)
+ },
+ onClick = if (allSelected) onSelectNone else onSelectAll,
+ )
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun Preview() {
+ val contacts = listOf(
+ SelectableContact(PubkyProfile("pk1", "Alex Stronghand", "", null, emptyList(), status = null), true),
+ SelectableContact(PubkyProfile("pk2", "Anna Pleb", "", null, emptyList(), status = null), true),
+ SelectableContact(PubkyProfile("pk3", "Craig Wrong", "", null, emptyList(), status = null), false),
+ SelectableContact(PubkyProfile("pk4", "Satoshi Nakamoto", "", null, emptyList(), status = null), true),
+ SelectableContact(PubkyProfile("pk5", "Hal Finney", "", null, emptyList(), status = null), false),
+ ).toImmutableList()
+
+ AppThemeSurface {
+ Content(
+ uiState = ContactImportSelectUiState(contacts = contacts),
+ onBackClick = {},
+ onToggleContact = {},
+ onSelectAll = {},
+ onSelectNone = {},
+ onClickContinue = {},
+ )
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun PreviewAllSelected() {
+ val contacts = listOf(
+ SelectableContact(PubkyProfile("pk1", "Alex Stronghand", "", null, emptyList(), status = null), true),
+ SelectableContact(PubkyProfile("pk2", "Anna Pleb", "", null, emptyList(), status = null), true),
+ ).toImmutableList()
+
+ AppThemeSurface {
+ Content(
+ uiState = ContactImportSelectUiState(contacts = contacts),
+ onBackClick = {},
+ onToggleContact = {},
+ onSelectAll = {},
+ onSelectNone = {},
+ onClickContinue = {},
+ )
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectViewModel.kt
new file mode 100644
index 000000000..884ac5328
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectViewModel.kt
@@ -0,0 +1,122 @@
+package to.bitkit.ui.screens.contacts
+
+import android.content.Context
+import androidx.compose.runtime.Stable
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+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.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 ContactImportSelectViewModel @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val pubkyRepo: PubkyRepo,
+) : ViewModel() {
+
+ companion object {
+ private const val TAG = "ContactImportSelectVM"
+ }
+
+ private val _uiState = MutableStateFlow(ContactImportSelectUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _effects = MutableSharedFlow(extraBufferCapacity = 1)
+ val effects = _effects.asSharedFlow()
+
+ init {
+ viewModelScope.launch {
+ val contacts = pubkyRepo.pendingImportContacts.value
+ _uiState.update {
+ it.copy(
+ contacts = contacts.map { profile ->
+ SelectableContact(profile = profile, isSelected = true)
+ }.toImmutableList(),
+ )
+ }
+ }
+ }
+
+ fun toggleContact(publicKey: String) {
+ _uiState.update { state ->
+ state.copy(
+ contacts = state.contacts.map {
+ if (it.profile.publicKey == publicKey) it.copy(isSelected = !it.isSelected) else it
+ }.toImmutableList(),
+ )
+ }
+ }
+
+ fun selectAll() {
+ _uiState.update { state ->
+ state.copy(
+ contacts = state.contacts.map { it.copy(isSelected = true) }.toImmutableList(),
+ )
+ }
+ }
+
+ fun selectNone() {
+ _uiState.update { state ->
+ state.copy(
+ contacts = state.contacts.map { it.copy(isSelected = false) }.toImmutableList(),
+ )
+ }
+ }
+
+ fun importSelected() {
+ val selected = _uiState.value.contacts.filter { it.isSelected }
+ if (selected.isEmpty()) return
+
+ viewModelScope.launch {
+ _uiState.update { it.copy(isImporting = true) }
+ pubkyRepo.importContacts(selected.map { it.profile.publicKey })
+ .onSuccess {
+ _uiState.update { it.copy(isImporting = false) }
+ _effects.emit(ContactImportSelectEffect.ImportComplete)
+ }
+ .onFailure {
+ Logger.error("Failed to import selected contacts", it, context = TAG)
+ _uiState.update { it.copy(isImporting = false) }
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.common__error),
+ description = it.message,
+ )
+ }
+ }
+ }
+}
+
+@Stable
+data class SelectableContact(
+ val profile: PubkyProfile,
+ val isSelected: Boolean,
+)
+
+@Stable
+data class ContactImportSelectUiState(
+ val contacts: ImmutableList = persistentListOf(),
+ val isImporting: Boolean = false,
+) {
+ val selectedCount: Int get() = contacts.count { it.isSelected }
+}
+
+sealed interface ContactImportSelectEffect {
+ data object ImportComplete : ContactImportSelectEffect
+}
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..e15be8154
--- /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.contacts__intro_add_contact),
+ 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..6f531965a
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt
@@ -0,0 +1,278 @@
+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.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.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import to.bitkit.R
+import to.bitkit.models.PubkyProfile
+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.HorizontalSpacer
+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,
+ onAddContact: (String) -> Unit = {},
+ onScanQr: () -> Unit = {},
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) { viewModel.refresh() }
+
+ Content(
+ uiState = uiState,
+ onBackClick = onBackClick,
+ onClickMyProfile = onClickMyProfile,
+ onClickContact = onClickContact,
+ onSearchTextChange = { viewModel.onSearchTextChange(it) },
+ onAddContact = onAddContact,
+ onScanQr = onScanQr,
+ )
+}
+
+@Composable
+private fun Content(
+ uiState: ContactsUiState,
+ onBackClick: () -> Unit,
+ onClickMyProfile: () -> Unit,
+ onClickContact: (String) -> Unit,
+ onSearchTextChange: (String) -> Unit,
+ onAddContact: (String) -> Unit,
+ onScanQr: () -> Unit,
+) {
+ var showAddContactSheet by remember { mutableStateOf(false) }
+
+ ScreenColumn {
+ AppTopBar(
+ titleText = stringResource(R.string.contacts__nav_title),
+ onBackClick = onBackClick,
+ actions = { DrawerNavIcon() },
+ )
+
+ Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ SearchInput(
+ value = uiState.searchText,
+ onValueChange = onSearchTextChange,
+ modifier = Modifier.weight(1f),
+ )
+ HorizontalSpacer(8.dp)
+ ActionButton(
+ onClick = { showAddContactSheet = true },
+ iconRes = R.drawable.ic_plus,
+ )
+ }
+ VerticalSpacer(8.dp)
+ }
+
+ when {
+ uiState.isLoading && uiState.contacts.isEmpty() -> LoadingState()
+ uiState.isEmpty && uiState.searchText.isBlank() -> EmptyState()
+ else -> ContactsList(
+ contacts = uiState.contacts,
+ myProfile = uiState.myProfile,
+ showMyProfile = uiState.searchText.isBlank(),
+ onClickMyProfile = onClickMyProfile,
+ onClickContact = onClickContact,
+ )
+ }
+ }
+
+ if (showAddContactSheet) {
+ AddContactSheet(
+ onDismiss = { showAddContactSheet = false },
+ onSubmit = { publicKey ->
+ showAddContactSheet = false
+ onAddContact(publicKey)
+ },
+ onScanQr = {
+ showAddContactSheet = false
+ onScanQr()
+ },
+ )
+ }
+}
+
+@Composable
+private fun ContactsList(
+ contacts: ImmutableList,
+ 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()
+ }
+ }
+
+ if (contacts.isNotEmpty()) {
+ item {
+ Text13Up(
+ text = stringResource(R.string.contacts__contacts_header),
+ 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)) {
+ BodyS(
+ text = profile.truncatedPublicKey,
+ color = Colors.White64,
+ )
+ BodySSB(
+ text = profile.name,
+ color = Colors.White,
+ )
+ }
+ }
+}
+
+@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() {
+ AppThemeSurface {
+ Content(
+ uiState = ContactsUiState(
+ contacts = persistentListOf(
+ PubkyProfile("pk1", "Alex Stronghand", "", null, emptyList(), status = null),
+ PubkyProfile("pk2", "Anna Pleb", "", null, emptyList(), status = null),
+ PubkyProfile("pk3", "Areem Holden", "", null, emptyList(), status = null),
+ PubkyProfile("pk4", "Craig Wrong", "", null, emptyList(), status = null),
+ ),
+ myProfile = PubkyProfile("pk0", "Satoshi Nakamoto", "", null, emptyList(), status = null),
+ ),
+ onBackClick = {},
+ onClickMyProfile = {},
+ onClickContact = {},
+ onSearchTextChange = {},
+ onAddContact = {},
+ onScanQr = {},
+ )
+ }
+}
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..4bc530ec8
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt
@@ -0,0 +1,77 @@
+package to.bitkit.ui.screens.contacts
+
+import androidx.compose.runtime.Stable
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+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 sorted = filtered.sortedBy { it.name.lowercase() }.toImmutableList()
+ ContactsUiState(
+ contacts = sorted,
+ 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() }
+ }
+}
+
+@Stable
+data class ContactsUiState(
+ val contacts: ImmutableList = persistentListOf(),
+ val myProfile: PubkyProfile? = null,
+ val isLoading: Boolean = false,
+ val searchText: String = "",
+) {
+ val isEmpty: Boolean get() = contacts.isEmpty() && !isLoading
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactScreen.kt
new file mode 100644
index 000000000..2866e0efb
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactScreen.kt
@@ -0,0 +1,171 @@
+package to.bitkit.ui.screens.contacts
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.collections.immutable.persistentListOf
+import to.bitkit.R
+import to.bitkit.ui.components.AddLinkSheet
+import to.bitkit.ui.components.AddTagSheet
+import to.bitkit.ui.components.CenteredProfileHeader
+import to.bitkit.ui.components.ProfileEditForm
+import to.bitkit.ui.components.ProfileEditLink
+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.theme.AppThemeSurface
+
+@Composable
+fun EditContactScreen(
+ viewModel: EditContactViewModel,
+ onBackClick: () -> Unit,
+ onContactDeleted: () -> Unit,
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect {
+ when (it) {
+ EditContactEffect.SaveSuccess -> onBackClick()
+ EditContactEffect.DeleteSuccess -> onContactDeleted()
+ }
+ }
+ }
+
+ Content(
+ uiState = uiState,
+ onBackClick = onBackClick,
+ onNameChange = { viewModel.onNameChange(it) },
+ onBioChange = { viewModel.onBioChange(it) },
+ onRemoveLink = { viewModel.removeLink(it) },
+ onAddLink = { viewModel.showAddLinkSheet() },
+ onRemoveTag = { viewModel.removeTag(it) },
+ onAddTag = { viewModel.showAddTagSheet() },
+ onSave = { viewModel.save() },
+ onDelete = { viewModel.showDeleteConfirmation() },
+ onDismissDeleteDialog = { viewModel.dismissDeleteDialog() },
+ onConfirmDelete = { viewModel.deleteContact() },
+ onDismissAddLinkSheet = { viewModel.dismissAddLinkSheet() },
+ onSaveLink = { label, url -> viewModel.addLink(label, url) },
+ onDismissAddTagSheet = { viewModel.dismissAddTagSheet() },
+ onSaveTag = { viewModel.addTag(it) },
+ )
+}
+
+@Composable
+private fun Content(
+ uiState: EditContactUiState,
+ onBackClick: () -> Unit,
+ onNameChange: (String) -> Unit,
+ onBioChange: (String) -> Unit,
+ onRemoveLink: (Int) -> Unit,
+ onAddLink: () -> Unit,
+ onRemoveTag: (Int) -> Unit,
+ onAddTag: () -> Unit,
+ onSave: () -> Unit,
+ onDelete: () -> Unit,
+ onDismissDeleteDialog: () -> Unit,
+ onConfirmDelete: () -> Unit,
+ onDismissAddLinkSheet: () -> Unit,
+ onSaveLink: (label: String, url: String) -> Unit,
+ onDismissAddTagSheet: () -> Unit,
+ onSaveTag: (String) -> Unit,
+) {
+ ScreenColumn {
+ AppTopBar(
+ titleText = stringResource(R.string.contacts__edit_contact_title),
+ onBackClick = onBackClick,
+ actions = { DrawerNavIcon() },
+ )
+
+ ProfileEditForm(
+ name = uiState.name,
+ onNameChange = onNameChange,
+ publicKey = uiState.publicKey,
+ bio = uiState.bio,
+ onBioChange = onBioChange,
+ links = uiState.links,
+ onRemoveLink = onRemoveLink,
+ onAddLink = onAddLink,
+ tags = uiState.tags,
+ onRemoveTag = onRemoveTag,
+ onAddTag = onAddTag,
+ onSave = onSave,
+ onCancel = onBackClick,
+ isSaveEnabled = uiState.name.isNotBlank() && !uiState.isSaving,
+ avatarContent = {
+ CenteredProfileHeader(
+ publicKey = uiState.publicKey,
+ name = "",
+ bio = "",
+ imageUrl = uiState.imageUrl,
+ )
+ },
+ onDelete = onDelete,
+ deleteLabel = stringResource(R.string.contacts__delete_contact),
+ )
+ }
+
+ if (uiState.showDeleteDialog) {
+ AppAlertDialog(
+ title = stringResource(R.string.contacts__delete_confirm_title, uiState.name),
+ text = stringResource(R.string.contacts__delete_confirm_text),
+ confirmText = stringResource(R.string.common__delete_yes),
+ onConfirm = onConfirmDelete,
+ onDismiss = onDismissDeleteDialog,
+ )
+ }
+
+ if (uiState.showAddLinkSheet) {
+ AddLinkSheet(
+ onDismiss = onDismissAddLinkSheet,
+ onSave = onSaveLink,
+ )
+ }
+
+ if (uiState.showAddTagSheet) {
+ AddTagSheet(
+ onDismiss = onDismissAddTagSheet,
+ onSave = onSaveTag,
+ )
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun Preview() {
+ AppThemeSurface {
+ Content(
+ uiState = EditContactUiState(
+ publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg",
+ name = "John Carvalho",
+ bio = "CEO at @synonym_to",
+ links = persistentListOf(
+ ProfileEditLink("Website", "https://synonym.to"),
+ ProfileEditLink("X", "https://x.com/BitcoinErrorLog"),
+ ),
+ tags = persistentListOf("CEO", "Founder"),
+ isLoading = false,
+ ),
+ onBackClick = {},
+ onNameChange = {},
+ onBioChange = {},
+ onRemoveLink = {},
+ onAddLink = {},
+ onRemoveTag = {},
+ onAddTag = {},
+ onSave = {},
+ onDelete = {},
+ onDismissDeleteDialog = {},
+ onConfirmDelete = {},
+ onDismissAddLinkSheet = {},
+ onSaveLink = { _, _ -> },
+ onDismissAddTagSheet = {},
+ onSaveTag = {},
+ )
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactViewModel.kt
new file mode 100644
index 000000000..39ac30f97
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactViewModel.kt
@@ -0,0 +1,194 @@
+package to.bitkit.ui.screens.contacts
+
+import android.content.Context
+import androidx.compose.runtime.Immutable
+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.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+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.PubkyProfileLink
+import to.bitkit.models.Toast
+import to.bitkit.repositories.PubkyRepo
+import to.bitkit.ui.components.ProfileEditLink
+import to.bitkit.ui.shared.toast.ToastEventBus
+import to.bitkit.utils.Logger
+import javax.inject.Inject
+
+@Suppress("TooManyFunctions")
+@HiltViewModel
+class EditContactViewModel @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val pubkyRepo: PubkyRepo,
+ savedStateHandle: SavedStateHandle,
+) : ViewModel() {
+
+ companion object {
+ private const val TAG = "EditContactViewModel"
+ }
+
+ private val publicKey: String = checkNotNull(
+ savedStateHandle["publicKey"],
+ ) { "publicKey not found in SavedStateHandle" }
+
+ private val _uiState = MutableStateFlow(EditContactUiState(publicKey = publicKey))
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _effects = MutableSharedFlow(extraBufferCapacity = 1)
+ val effects = _effects.asSharedFlow()
+
+ init {
+ loadContact()
+ }
+
+ private fun loadContact() {
+ val contact = pubkyRepo.contacts.value.find { it.publicKey == publicKey }
+ if (contact == null) {
+ Logger.warn("Contact '$publicKey' not found in local contacts", context = TAG)
+ return
+ }
+ _uiState.update {
+ it.copy(
+ name = contact.name,
+ bio = contact.bio,
+ imageUrl = contact.imageUrl,
+ links = contact.links.map { link ->
+ ProfileEditLink(label = link.label, url = link.url)
+ }.toImmutableList(),
+ tags = contact.tags.toImmutableList(),
+ isLoading = false,
+ )
+ }
+ }
+
+ fun onNameChange(name: String) {
+ _uiState.update { it.copy(name = name) }
+ }
+
+ fun onBioChange(bio: String) {
+ _uiState.update { it.copy(bio = bio) }
+ }
+
+ fun addLink(label: String, url: String) {
+ _uiState.update {
+ it.copy(
+ links = (it.links + ProfileEditLink(label, url)).toImmutableList(),
+ showAddLinkSheet = false,
+ )
+ }
+ }
+
+ fun removeLink(index: Int) {
+ _uiState.update {
+ it.copy(links = it.links.filterIndexed { i, _ -> i != index }.toImmutableList())
+ }
+ }
+
+ fun addTag(tag: String) {
+ _uiState.update {
+ it.copy(
+ tags = (it.tags + tag).toImmutableList(),
+ showAddTagSheet = false,
+ )
+ }
+ }
+
+ fun removeTag(index: Int) {
+ _uiState.update {
+ it.copy(tags = it.tags.filterIndexed { i, _ -> i != index }.toImmutableList())
+ }
+ }
+
+ fun showAddLinkSheet() {
+ _uiState.update { it.copy(showAddLinkSheet = true) }
+ }
+
+ fun dismissAddLinkSheet() {
+ _uiState.update { it.copy(showAddLinkSheet = false) }
+ }
+
+ fun showAddTagSheet() {
+ _uiState.update { it.copy(showAddTagSheet = true) }
+ }
+
+ fun dismissAddTagSheet() {
+ _uiState.update { it.copy(showAddTagSheet = false) }
+ }
+
+ fun showDeleteConfirmation() {
+ _uiState.update { it.copy(showDeleteDialog = true) }
+ }
+
+ fun dismissDeleteDialog() {
+ _uiState.update { it.copy(showDeleteDialog = false) }
+ }
+
+ fun save() {
+ val state = _uiState.value
+ viewModelScope.launch {
+ _uiState.update { it.copy(isSaving = true) }
+ pubkyRepo.updateContact(
+ publicKey = publicKey,
+ name = state.name,
+ bio = state.bio,
+ imageUrl = state.imageUrl,
+ links = state.links.map { PubkyProfileLink(it.label, it.url) },
+ tags = state.tags,
+ ).onSuccess {
+ ToastEventBus.send(
+ type = Toast.ToastType.SUCCESS,
+ title = context.getString(R.string.contacts__edit_contact_saved),
+ )
+ _effects.emit(EditContactEffect.SaveSuccess)
+ }.onFailure {
+ Logger.error("Failed to save contact '$publicKey'", it, context = TAG)
+ _uiState.update { it.copy(isSaving = false) }
+ }
+ }
+ }
+
+ fun deleteContact() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(showDeleteDialog = false, isSaving = true) }
+ pubkyRepo.removeContact(publicKey)
+ .onSuccess {
+ _effects.emit(EditContactEffect.DeleteSuccess)
+ }
+ .onFailure {
+ Logger.error("Failed to delete contact '$publicKey'", it, context = TAG)
+ _uiState.update { it.copy(isSaving = false) }
+ }
+ }
+ }
+}
+
+@Immutable
+data class EditContactUiState(
+ val publicKey: String = "",
+ val name: String = "",
+ val bio: String = "",
+ val imageUrl: String? = null,
+ val links: ImmutableList = persistentListOf(),
+ val tags: ImmutableList = persistentListOf(),
+ val isLoading: Boolean = true,
+ val isSaving: Boolean = false,
+ val showDeleteDialog: Boolean = false,
+ val showAddLinkSheet: Boolean = false,
+ val showAddTagSheet: Boolean = false,
+)
+
+sealed interface EditContactEffect {
+ data object SaveSuccess : EditContactEffect
+ data object DeleteSuccess : EditContactEffect
+}
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
index 8d8e11c83..84f07c576 100644
--- a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt
@@ -1,52 +1,249 @@
package to.bitkit.ui.screens.profile
+import android.net.Uri
+import android.os.Build
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.PickVisualMediaRequest
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
+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.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.layout.ContentScale
+import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import coil3.compose.AsyncImage
import to.bitkit.R
-import to.bitkit.ui.components.Display
+import to.bitkit.ui.components.BodyM
+import to.bitkit.ui.components.BodyS
+import to.bitkit.ui.components.FillHeight
+import to.bitkit.ui.components.GradientCircularProgressIndicator
+import to.bitkit.ui.components.PrimaryButton
+import to.bitkit.ui.components.Text13Up
+import to.bitkit.ui.components.TextInput
+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.AppTextFieldDefaults
+import to.bitkit.ui.theme.AppTextStyles
import to.bitkit.ui.theme.AppThemeSurface
import to.bitkit.ui.theme.Colors
@Composable
fun CreateProfileScreen(
- onBack: () -> Unit,
-) { // TODO IMPLEMENT
+ viewModel: CreateProfileViewModel,
+ onNavigateToPayContacts: () -> Unit,
+ onBackClick: () -> Unit,
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect {
+ when (it) {
+ CreateProfileEffect.CreateSuccess -> onNavigateToPayContacts()
+ }
+ }
+ }
+
+ Content(
+ uiState = uiState,
+ onBackClick = onBackClick,
+ onNameChange = viewModel::onNameChange,
+ onAvatarSelected = viewModel::onAvatarSelected,
+ onSave = viewModel::save,
+ )
+}
+
+@Composable
+private fun Content(
+ uiState: CreateProfileUiState,
+ onBackClick: () -> Unit,
+ onNameChange: (String) -> Unit,
+ onAvatarSelected: (Uri) -> Unit,
+ onSave: () -> Unit,
+) {
+ val pickMedia = rememberLauncherForActivityResult(
+ ActivityResultContracts.PickVisualMedia()
+ ) { uri -> uri?.let { onAvatarSelected(it) } }
+
+ val galleryLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.GetContent(),
+ onResult = { uri -> uri?.let { onAvatarSelected(it) } },
+ )
+
+ val launchPhotoPicker = {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
+ } else {
+ galleryLauncher.launch("image/*")
+ }
+ }
+
ScreenColumn {
+ val navTitleRes = if (uiState.isRestoring) {
+ R.string.profile__restore_nav_title
+ } else {
+ R.string.profile__create_nav_title
+ }
AppTopBar(
- titleText = stringResource(R.string.slashtags__profile_create),
- onBackClick = onBack,
- actions = { DrawerNavIcon() },
+ titleText = stringResource(navTitleRes),
+ onBackClick = onBackClick,
)
- Column(
- modifier = Modifier.padding(horizontal = 32.dp)
- ) {
- Spacer(Modifier.weight(1f))
+ if (uiState.isLoading) {
+ LoadingState(text = stringResource(R.string.profile__deriving_keys))
+ } else {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 32.dp)
+ ) {
+ VerticalSpacer(32.dp)
+
+ AvatarPickerButton(
+ avatarUri = uiState.avatarUri,
+ onClick = launchPhotoPicker,
+ )
+
+ VerticalSpacer(24.dp)
+
+ TextInput(
+ value = uiState.name,
+ onValueChange = onNameChange,
+ placeholder = stringResource(R.string.profile__edit_name_placeholder),
+ singleLine = true,
+ textStyle = AppTextStyles.Display.copy(textAlign = TextAlign.Center),
+ colors = AppTextFieldDefaults.transparent,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ VerticalSpacer(16.dp)
+ HorizontalDivider()
+ VerticalSpacer(16.dp)
+
+ Text13Up(
+ text = stringResource(R.string.profile__your_pubky),
+ color = Colors.White64,
+ )
+ VerticalSpacer(8.dp)
+ BodyS(
+ text = uiState.derivedPublicKey ?: "...",
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
- Display(
- text = stringResource(R.string.other__coming_soon),
- color = Colors.White
+ FillHeight()
+ VerticalSpacer(16.dp)
+
+ PrimaryButton(
+ text = stringResource(R.string.common__continue),
+ onClick = onSave,
+ enabled = uiState.name.isNotBlank() && !uiState.isSaving,
+ isLoading = uiState.isSaving,
+ )
+ VerticalSpacer(16.dp)
+ }
+ }
+ }
+}
+
+@Composable
+private fun AvatarPickerButton(
+ avatarUri: Uri?,
+ onClick: () -> Unit,
+) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(100.dp)
+ .clip(CircleShape)
+ .background(Colors.Gray5)
+ .clickable(onClick = onClick),
+ ) {
+ if (avatarUri != null) {
+ AsyncImage(
+ model = avatarUri,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+ } else {
+ Icon(
+ painter = painterResource(R.drawable.ic_user_square),
+ contentDescription = null,
+ tint = Colors.White32,
+ modifier = Modifier.size(40.dp),
)
- Spacer(Modifier.weight(1f))
}
}
}
-@Preview(showBackground = true)
+@Composable
+private fun LoadingState(text: String) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ GradientCircularProgressIndicator(modifier = Modifier.size(24.dp))
+ VerticalSpacer(12.dp)
+ BodyM(text = text, color = Colors.White64)
+ }
+ }
+}
+
+@Preview(showSystemUi = true)
@Composable
private fun Preview() {
AppThemeSurface {
- CreateProfileScreen(
- onBack = {},
+ Content(
+ uiState = CreateProfileUiState(
+ derivedPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg",
+ name = "",
+ ),
+ onBackClick = {},
+ onNameChange = {},
+ onAvatarSelected = {},
+ onSave = {},
+ )
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun RestoringPreview() {
+ AppThemeSurface {
+ Content(
+ uiState = CreateProfileUiState(
+ derivedPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg",
+ name = "Satoshi",
+ isRestoring = true,
+ ),
+ onBackClick = {},
+ onNameChange = {},
+ onAvatarSelected = {},
+ onSave = {},
)
}
}
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileViewModel.kt
new file mode 100644
index 000000000..001710d25
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileViewModel.kt
@@ -0,0 +1,214 @@
+package to.bitkit.ui.screens.profile
+
+import android.content.Context
+import android.net.Uri
+import androidx.compose.runtime.Stable
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+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.PubkyProfileLink
+import to.bitkit.models.Toast
+import to.bitkit.repositories.PubkyRepo
+import to.bitkit.ui.components.ProfileEditLink
+import to.bitkit.ui.shared.toast.ToastEventBus
+import to.bitkit.utils.Logger
+import javax.inject.Inject
+
+@Suppress("TooManyFunctions")
+@HiltViewModel
+class CreateProfileViewModel @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val pubkyRepo: PubkyRepo,
+) : ViewModel() {
+ companion object {
+ private const val TAG = "CreateProfileViewModel"
+ }
+
+ private val _uiState = MutableStateFlow(CreateProfileUiState())
+ val uiState = _uiState.asStateFlow()
+
+ private val _effects = MutableSharedFlow(extraBufferCapacity = 1)
+ val effects = _effects.asSharedFlow()
+
+ init {
+ deriveAndCheckRemote()
+ }
+
+ private fun deriveAndCheckRemote() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true) }
+ pubkyRepo.deriveKeys()
+ .onSuccess { (publicKey, _) ->
+ _uiState.update { it.copy(derivedPublicKey = publicKey) }
+ checkForExistingProfile(publicKey)
+ }
+ .onFailure {
+ Logger.error("Failed to derive keys", it, context = TAG)
+ _uiState.update { it.copy(isLoading = false) }
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.profile__auth_error_title),
+ description = it.message,
+ )
+ }
+ }
+ }
+
+ private suspend fun checkForExistingProfile(publicKey: String) {
+ pubkyRepo.fetchRemoteProfile(publicKey)
+ .onSuccess { profile ->
+ if (profile != null) {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ isRestoring = true,
+ name = profile.name,
+ bio = profile.bio,
+ links = profile.links.map { link ->
+ ProfileEditLink(label = link.label, url = link.url)
+ }.toImmutableList(),
+ tags = profile.tags.toImmutableList(),
+ )
+ }
+ } else {
+ _uiState.update { it.copy(isLoading = false) }
+ }
+ }
+ .onFailure {
+ Logger.debug("No existing remote profile found for '$publicKey'", context = TAG)
+ _uiState.update { it.copy(isLoading = false) }
+ }
+ }
+
+ fun onAvatarSelected(uri: Uri) {
+ viewModelScope.launch {
+ runCatching {
+ context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
+ }.onSuccess { bytes ->
+ if (bytes != null) {
+ _uiState.update { it.copy(avatarUri = uri, avatarBytes = bytes) }
+ }
+ }.onFailure {
+ Logger.error("Failed to read avatar image", it, context = TAG)
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.profile__avatar_read_error),
+ )
+ }
+ }
+ }
+
+ fun onNameChange(name: String) {
+ _uiState.update { it.copy(name = name) }
+ }
+
+ fun onBioChange(bio: String) {
+ _uiState.update { it.copy(bio = bio) }
+ }
+
+ fun addLink(label: String, url: String) {
+ _uiState.update {
+ it.copy(
+ links = (it.links + ProfileEditLink(label = label, url = url)).toImmutableList(),
+ showAddLinkSheet = false,
+ )
+ }
+ }
+
+ fun removeLink(index: Int) {
+ _uiState.update {
+ it.copy(links = it.links.filterIndexed { i, _ -> i != index }.toImmutableList())
+ }
+ }
+
+ fun addTag(tag: String) {
+ _uiState.update {
+ it.copy(
+ tags = (it.tags + tag).toImmutableList(),
+ showAddTagSheet = false,
+ )
+ }
+ }
+
+ fun removeTag(index: Int) {
+ _uiState.update {
+ it.copy(tags = it.tags.filterIndexed { i, _ -> i != index }.toImmutableList())
+ }
+ }
+
+ fun showAddLinkSheet() {
+ _uiState.update { it.copy(showAddLinkSheet = true) }
+ }
+
+ fun dismissAddLinkSheet() {
+ _uiState.update { it.copy(showAddLinkSheet = false) }
+ }
+
+ fun showAddTagSheet() {
+ _uiState.update { it.copy(showAddTagSheet = true) }
+ }
+
+ fun dismissAddTagSheet() {
+ _uiState.update { it.copy(showAddTagSheet = false) }
+ }
+
+ fun save() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isSaving = true) }
+ val state = _uiState.value
+ pubkyRepo.createIdentity(
+ name = state.name,
+ bio = state.bio,
+ links = state.links.map { PubkyProfileLink(label = it.label, url = it.url) },
+ tags = state.tags,
+ avatarBytes = state.avatarBytes,
+ ).onSuccess {
+ _uiState.update { it.copy(isSaving = false) }
+ ToastEventBus.send(
+ type = Toast.ToastType.SUCCESS,
+ title = context.getString(R.string.profile__create_success),
+ )
+ _effects.emit(CreateProfileEffect.CreateSuccess)
+ }.onFailure {
+ Logger.error("Failed to create identity", it, context = TAG)
+ _uiState.update { it.copy(isSaving = false) }
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.profile__create_error),
+ description = it.message,
+ )
+ }
+ }
+ }
+}
+
+@Stable
+data class CreateProfileUiState(
+ val derivedPublicKey: String? = null,
+ val name: String = "",
+ val bio: String = "",
+ val links: ImmutableList = persistentListOf(),
+ val tags: ImmutableList = persistentListOf(),
+ val avatarUri: Uri? = null,
+ val avatarBytes: ByteArray? = null,
+ val isLoading: Boolean = false,
+ val isSaving: Boolean = false,
+ val isRestoring: Boolean = false,
+ val showAddLinkSheet: Boolean = false,
+ val showAddTagSheet: Boolean = false,
+)
+
+sealed interface CreateProfileEffect {
+ data object CreateSuccess : CreateProfileEffect
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt
new file mode 100644
index 000000000..875115aa4
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt
@@ -0,0 +1,248 @@
+package to.bitkit.ui.screens.profile
+
+import android.net.Uri
+import android.os.Build
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.PickVisualMediaRequest
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+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.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.layout.ContentScale
+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 coil3.compose.AsyncImage
+import kotlinx.collections.immutable.persistentListOf
+import to.bitkit.R
+import to.bitkit.ui.components.AddLinkSheet
+import to.bitkit.ui.components.AddTagSheet
+import to.bitkit.ui.components.AvatarCameraOverlay
+import to.bitkit.ui.components.GradientCircularProgressIndicator
+import to.bitkit.ui.components.ProfileEditForm
+import to.bitkit.ui.components.ProfileEditLink
+import to.bitkit.ui.components.PubkyImage
+import to.bitkit.ui.scaffold.AppAlertDialog
+import to.bitkit.ui.scaffold.AppTopBar
+import to.bitkit.ui.scaffold.ScreenColumn
+import to.bitkit.ui.theme.AppThemeSurface
+import to.bitkit.ui.theme.Colors
+
+@Composable
+fun EditProfileScreen(
+ viewModel: EditProfileViewModel,
+ onBackClick: () -> Unit,
+ onProfileDeleted: () -> Unit,
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect {
+ when (it) {
+ EditProfileEffect.SaveSuccess -> onBackClick()
+ EditProfileEffect.DeleteSuccess -> onProfileDeleted()
+ }
+ }
+ }
+
+ Content(
+ uiState = uiState,
+ onBackClick = onBackClick,
+ onNameChange = viewModel::onNameChange,
+ onBioChange = viewModel::onBioChange,
+ onAvatarSelected = viewModel::onAvatarSelected,
+ onAddLink = viewModel::showAddLinkSheet,
+ onRemoveLink = viewModel::removeLink,
+ onAddTag = viewModel::showAddTagSheet,
+ onRemoveTag = viewModel::removeTag,
+ onSave = viewModel::save,
+ onDelete = viewModel::showDeleteConfirmation,
+ onDismissDeleteDialog = viewModel::dismissDeleteDialog,
+ onConfirmDelete = viewModel::deleteProfile,
+ onDismissAddLinkSheet = viewModel::dismissAddLinkSheet,
+ onSaveLink = viewModel::addLink,
+ onDismissAddTagSheet = viewModel::dismissAddTagSheet,
+ onSaveTag = viewModel::addTag,
+ )
+}
+
+@Suppress("LongParameterList")
+@Composable
+private fun Content(
+ uiState: EditProfileUiState,
+ onBackClick: () -> Unit,
+ onNameChange: (String) -> Unit,
+ onBioChange: (String) -> Unit,
+ onAvatarSelected: (Uri) -> Unit,
+ onAddLink: () -> Unit,
+ onRemoveLink: (Int) -> Unit,
+ onAddTag: () -> Unit,
+ onRemoveTag: (Int) -> Unit,
+ onSave: () -> Unit,
+ onDelete: () -> Unit,
+ onDismissDeleteDialog: () -> Unit,
+ onConfirmDelete: () -> Unit,
+ onDismissAddLinkSheet: () -> Unit,
+ onSaveLink: (String, String) -> Unit,
+ onDismissAddTagSheet: () -> Unit,
+ onSaveTag: (String) -> Unit,
+) {
+ val pickMedia = rememberLauncherForActivityResult(
+ ActivityResultContracts.PickVisualMedia()
+ ) { uri -> uri?.let { onAvatarSelected(it) } }
+
+ val galleryLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.GetContent(),
+ onResult = { uri -> uri?.let { onAvatarSelected(it) } },
+ )
+
+ val launchPhotoPicker = {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
+ } else {
+ galleryLauncher.launch("image/*")
+ }
+ }
+
+ ScreenColumn {
+ AppTopBar(
+ titleText = stringResource(R.string.profile__edit_nav_title),
+ onBackClick = onBackClick,
+ )
+
+ if (uiState.isLoading) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ GradientCircularProgressIndicator(modifier = Modifier.size(24.dp))
+ }
+ } else {
+ ProfileEditForm(
+ name = uiState.name,
+ onNameChange = onNameChange,
+ publicKey = uiState.publicKey,
+ bio = uiState.bio,
+ onBioChange = onBioChange,
+ links = uiState.links,
+ onRemoveLink = onRemoveLink,
+ onAddLink = onAddLink,
+ tags = uiState.tags,
+ onRemoveTag = onRemoveTag,
+ onAddTag = onAddTag,
+ onSave = onSave,
+ onCancel = onBackClick,
+ isSaveEnabled = uiState.name.isNotBlank() && !uiState.isSaving,
+ avatarContent = {
+ AvatarSection(
+ imageUrl = uiState.imageUrl,
+ newAvatarUri = uiState.newAvatarUri,
+ onClick = launchPhotoPicker,
+ )
+ },
+ onDelete = onDelete,
+ deleteLabel = stringResource(R.string.profile__delete_profile),
+ )
+ }
+ }
+
+ if (uiState.showDeleteDialog) {
+ AppAlertDialog(
+ title = stringResource(R.string.profile__delete_confirm_title),
+ text = stringResource(R.string.profile__delete_confirm_description),
+ confirmText = stringResource(R.string.common__delete_yes),
+ onConfirm = onConfirmDelete,
+ onDismiss = onDismissDeleteDialog,
+ )
+ }
+
+ if (uiState.showAddLinkSheet) {
+ AddLinkSheet(
+ onDismiss = onDismissAddLinkSheet,
+ onSave = onSaveLink,
+ )
+ }
+
+ if (uiState.showAddTagSheet) {
+ AddTagSheet(
+ onDismiss = onDismissAddTagSheet,
+ onSave = onSaveTag,
+ )
+ }
+}
+
+@Composable
+private fun AvatarSection(
+ imageUrl: String?,
+ newAvatarUri: Uri?,
+ onClick: () -> Unit,
+) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(100.dp)
+ .clip(CircleShape)
+ .background(Colors.Gray5)
+ .clickable(onClick = onClick),
+ ) {
+ when {
+ newAvatarUri != null -> AsyncImage(
+ model = newAvatarUri,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+ imageUrl != null -> PubkyImage(uri = imageUrl, size = 100.dp)
+ else -> Icon(
+ painter = painterResource(R.drawable.ic_user_square),
+ contentDescription = null,
+ tint = Colors.White32,
+ modifier = Modifier.size(40.dp),
+ )
+ }
+ AvatarCameraOverlay()
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun Preview() {
+ AppThemeSurface {
+ Content(
+ uiState = EditProfileUiState(
+ name = "Satoshi",
+ bio = "Authored the Bitcoin white paper",
+ links = persistentListOf(ProfileEditLink("X", "https://x.com/satoshi")),
+ imageUrl = null,
+ ),
+ onBackClick = {},
+ onNameChange = {},
+ onBioChange = {},
+ onAvatarSelected = {},
+ onAddLink = {},
+ onRemoveLink = {},
+ onAddTag = {},
+ onRemoveTag = {},
+ onSave = {},
+ onDelete = {},
+ onDismissDeleteDialog = {},
+ onConfirmDelete = {},
+ onDismissAddLinkSheet = {},
+ onSaveLink = { _, _ -> },
+ onDismissAddTagSheet = {},
+ onSaveTag = {},
+ )
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt
new file mode 100644
index 000000000..26e58bcbf
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt
@@ -0,0 +1,243 @@
+package to.bitkit.ui.screens.profile
+
+import android.content.Context
+import android.net.Uri
+import androidx.compose.runtime.Stable
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+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.PubkyProfileLink
+import to.bitkit.models.Toast
+import to.bitkit.repositories.PubkyRepo
+import to.bitkit.ui.components.ProfileEditLink
+import to.bitkit.ui.shared.toast.ToastEventBus
+import to.bitkit.utils.Logger
+import javax.inject.Inject
+
+@Suppress("TooManyFunctions")
+@HiltViewModel
+class EditProfileViewModel @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val pubkyRepo: PubkyRepo,
+) : ViewModel() {
+ companion object {
+ private const val TAG = "EditProfileViewModel"
+ }
+
+ private val _uiState = MutableStateFlow(EditProfileUiState())
+ val uiState = _uiState.asStateFlow()
+
+ private val _effects = MutableSharedFlow(extraBufferCapacity = 1)
+ val effects = _effects.asSharedFlow()
+
+ init {
+ loadProfile()
+ }
+
+ private fun loadProfile() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true) }
+ val profile = pubkyRepo.profile.value
+ val publicKey = pubkyRepo.publicKey.value.orEmpty()
+ if (profile != null) {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ publicKey = publicKey,
+ name = profile.name,
+ bio = profile.bio,
+ links = profile.links.map { link ->
+ ProfileEditLink(label = link.label, url = link.url)
+ }.toImmutableList(),
+ tags = profile.tags.toImmutableList(),
+ imageUrl = profile.imageUrl,
+ )
+ }
+ } else {
+ _uiState.update { it.copy(isLoading = false, publicKey = publicKey) }
+ }
+ }
+ }
+
+ fun onAvatarSelected(uri: Uri) {
+ viewModelScope.launch {
+ runCatching {
+ context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
+ }.onSuccess { bytes ->
+ if (bytes != null) {
+ _uiState.update { it.copy(newAvatarUri = uri, newAvatarBytes = bytes) }
+ }
+ }.onFailure {
+ Logger.error("Failed to read avatar image", it, context = TAG)
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.profile__avatar_read_error),
+ )
+ }
+ }
+ }
+
+ fun onNameChange(name: String) {
+ _uiState.update { it.copy(name = name) }
+ }
+
+ fun onBioChange(bio: String) {
+ _uiState.update { it.copy(bio = bio) }
+ }
+
+ fun addLink(label: String, url: String) {
+ _uiState.update {
+ it.copy(
+ links = (it.links + ProfileEditLink(label = label, url = url)).toImmutableList(),
+ showAddLinkSheet = false,
+ )
+ }
+ }
+
+ fun removeLink(index: Int) {
+ _uiState.update {
+ it.copy(links = it.links.filterIndexed { i, _ -> i != index }.toImmutableList())
+ }
+ }
+
+ fun addTag(tag: String) {
+ _uiState.update {
+ it.copy(
+ tags = (it.tags + tag).toImmutableList(),
+ showAddTagSheet = false,
+ )
+ }
+ }
+
+ fun removeTag(index: Int) {
+ _uiState.update {
+ it.copy(tags = it.tags.filterIndexed { i, _ -> i != index }.toImmutableList())
+ }
+ }
+
+ fun showAddLinkSheet() {
+ _uiState.update { it.copy(showAddLinkSheet = true) }
+ }
+
+ fun dismissAddLinkSheet() {
+ _uiState.update { it.copy(showAddLinkSheet = false) }
+ }
+
+ fun showAddTagSheet() {
+ _uiState.update { it.copy(showAddTagSheet = true) }
+ }
+
+ fun dismissAddTagSheet() {
+ _uiState.update { it.copy(showAddTagSheet = false) }
+ }
+
+ fun showDeleteConfirmation() {
+ _uiState.update { it.copy(showDeleteDialog = true) }
+ }
+
+ fun dismissDeleteDialog() {
+ _uiState.update { it.copy(showDeleteDialog = false) }
+ }
+
+ fun save() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isSaving = true) }
+ val state = _uiState.value
+
+ val imageUrl = if (state.newAvatarBytes != null) {
+ pubkyRepo.uploadAvatar(state.newAvatarBytes).getOrElse {
+ Logger.error("Failed to upload avatar", it, context = TAG)
+ _uiState.update { it.copy(isSaving = false) }
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.profile__edit_save_error),
+ description = it.message,
+ )
+ return@launch
+ }
+ } else {
+ state.imageUrl
+ }
+
+ pubkyRepo.saveProfile(
+ name = state.name,
+ bio = state.bio,
+ links = state.links.map { PubkyProfileLink(label = it.label, url = it.url) },
+ tags = state.tags,
+ imageUrl = imageUrl,
+ ).onSuccess {
+ _uiState.update { it.copy(isSaving = false) }
+ ToastEventBus.send(
+ type = Toast.ToastType.SUCCESS,
+ title = context.getString(R.string.profile__edit_save_success),
+ )
+ _effects.emit(EditProfileEffect.SaveSuccess)
+ }.onFailure {
+ Logger.error("Failed to save profile", it, context = TAG)
+ _uiState.update { it.copy(isSaving = false) }
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.profile__edit_save_error),
+ description = it.message,
+ )
+ }
+ }
+ }
+
+ fun deleteProfile() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(showDeleteDialog = false, isSaving = true) }
+ pubkyRepo.deleteProfile()
+ .onSuccess {
+ _uiState.update { it.copy(isSaving = false) }
+ ToastEventBus.send(
+ type = Toast.ToastType.SUCCESS,
+ title = context.getString(R.string.profile__delete_success),
+ )
+ _effects.emit(EditProfileEffect.DeleteSuccess)
+ }
+ .onFailure {
+ Logger.error("Failed to delete profile", it, context = TAG)
+ _uiState.update { it.copy(isSaving = false) }
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.profile__delete_error),
+ description = it.message,
+ )
+ }
+ }
+ }
+}
+
+@Stable
+data class EditProfileUiState(
+ val publicKey: String = "",
+ val name: String = "",
+ val bio: String = "",
+ val links: ImmutableList = persistentListOf(),
+ val tags: ImmutableList = persistentListOf(),
+ val imageUrl: String? = null,
+ val newAvatarUri: Uri? = null,
+ val newAvatarBytes: ByteArray? = null,
+ val isLoading: Boolean = false,
+ val isSaving: Boolean = false,
+ val showDeleteDialog: Boolean = false,
+ val showAddLinkSheet: Boolean = false,
+ val showAddTagSheet: Boolean = false,
+)
+
+sealed interface EditProfileEffect {
+ data object SaveSuccess : EditProfileEffect
+ data object DeleteSuccess : EditProfileEffect
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsScreen.kt
new file mode 100644
index 000000000..5f04534f3
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsScreen.kt
@@ -0,0 +1,125 @@
+package to.bitkit.ui.screens.profile
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchDefaults
+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.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.HorizontalSpacer
+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 PayContactsScreen(
+ onContinue: () -> Unit,
+ onBackClick: () -> Unit,
+) {
+ Content(
+ onContinue = onContinue,
+ onBackClick = onBackClick,
+ )
+}
+
+@Composable
+private fun Content(
+ onContinue: () -> Unit,
+ onBackClick: () -> Unit,
+) {
+ var isPaymentSharingEnabled by remember { mutableStateOf(true) }
+
+ ScreenColumn {
+ AppTopBar(
+ titleText = stringResource(R.string.profile__pay_contacts_title),
+ onBackClick = onBackClick,
+ actions = { DrawerNavIcon() },
+ )
+
+ Column(
+ modifier = Modifier.padding(horizontal = 32.dp)
+ ) {
+ Image(
+ painter = painterResource(R.drawable.coin_stack),
+ contentDescription = null,
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ )
+
+ Display(
+ text = stringResource(R.string.profile__pay_contacts_headline)
+ .withAccent(accentColor = Colors.PubkyGreen),
+ color = Colors.White,
+ )
+ VerticalSpacer(16.dp)
+ BodyM(
+ text = stringResource(R.string.profile__pay_contacts_description),
+ color = Colors.White64,
+ )
+ VerticalSpacer(24.dp)
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ BodyM(
+ text = stringResource(R.string.profile__pay_contacts_toggle),
+ color = Colors.White,
+ modifier = Modifier.weight(1f)
+ )
+ HorizontalSpacer(16.dp)
+ Switch(
+ checked = isPaymentSharingEnabled,
+ onCheckedChange = { isPaymentSharingEnabled = it },
+ colors = SwitchDefaults.colors(
+ checkedThumbColor = Colors.White,
+ checkedTrackColor = Colors.PubkyGreen,
+ checkedBorderColor = Colors.PubkyGreen,
+ uncheckedThumbColor = Colors.White,
+ uncheckedTrackColor = Colors.Gray4,
+ uncheckedBorderColor = Colors.Gray4,
+ ),
+ )
+ }
+
+ 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/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..0f00a63d0
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt
@@ -0,0 +1,307 @@
+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.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+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.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.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+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.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.CenteredProfileHeader
+import to.bitkit.ui.components.GradientCircularProgressIndicator
+import to.bitkit.ui.components.HorizontalSpacer
+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.TagButton
+import to.bitkit.ui.components.Text13Up
+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,
+ onEditProfile: () -> 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,
+ onClickEdit = onEditProfile,
+ 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,
+ onClickEdit: () -> 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,
+ onClickEdit = onClickEdit,
+ 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,
+ onClickEdit: () -> Unit,
+ onClickCopy: () -> Unit,
+ onClickShare: () -> Unit,
+ onClickSignOut: () -> Unit,
+) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 32.dp)
+ ) {
+ VerticalSpacer(24.dp)
+
+ CenteredProfileHeader(
+ publicKey = profile.publicKey,
+ name = profile.name,
+ bio = profile.bio,
+ imageUrl = profile.imageUrl,
+ )
+
+ 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(24.dp)
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ ActionButton(onClick = onClickEdit, iconRes = R.drawable.ic_edit)
+ ActionButton(onClick = onClickCopy, iconRes = R.drawable.ic_copy)
+ ActionButton(onClick = onClickShare, iconRes = R.drawable.ic_share)
+ }
+
+ VerticalSpacer(32.dp)
+
+ if (profile.links.isNotEmpty()) {
+ profile.links.forEach { LinkRow(label = it.label, value = it.url) }
+ }
+
+ if (profile.tags.isNotEmpty()) {
+ VerticalSpacer(16.dp)
+ Text13Up(
+ text = stringResource(R.string.profile__edit_tags),
+ color = Colors.White64,
+ modifier = Modifier.fillMaxWidth()
+ )
+ VerticalSpacer(8.dp)
+ @OptIn(ExperimentalLayoutApi::class)
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ profile.tags.forEach { tag ->
+ TagButton(text = tag, onClick = null)
+ }
+ }
+ }
+
+ VerticalSpacer(24.dp)
+ SignOutButton(isSigningOut = isSigningOut, onClick = onClickSignOut)
+ VerticalSpacer(16.dp)
+ }
+}
+
+@Composable
+private fun SignOutButton(
+ isSigningOut: Boolean,
+ onClick: () -> Unit,
+) {
+ TextButton(
+ onClick = rememberDebouncedClick(onClick = onClick),
+ enabled = !isSigningOut,
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.Logout,
+ contentDescription = null,
+ tint = Colors.White64,
+ modifier = Modifier.size(16.dp),
+ )
+ HorizontalSpacer(8.dp)
+ BodyS(text = stringResource(R.string.profile__sign_out), color = Colors.White64)
+ }
+}
+
+@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")),
+ tags = listOf("Founder", "Bitcoin"),
+ status = null,
+ ),
+ ),
+ onBackClick = {},
+ onClickEdit = {},
+ 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..c1315a3ad
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt
@@ -0,0 +1,117 @@
+package to.bitkit.ui.screens.profile
+
+import android.content.Context
+import androidx.compose.runtime.Stable
+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),
+ )
+ }
+ }
+}
+
+@Stable
+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/PubkyAuthApprovalSheet.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt
new file mode 100644
index 000000000..d04d3f87f
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt
@@ -0,0 +1,375 @@
+package to.bitkit.ui.screens.profile
+
+import androidx.compose.foundation.Image
+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.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.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 kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import to.bitkit.R
+import to.bitkit.models.PubkyAuthPermission
+import to.bitkit.models.PubkyProfile
+import to.bitkit.ui.components.BiometricsView
+import to.bitkit.ui.components.BodyM
+import to.bitkit.ui.components.BodySSB
+import to.bitkit.ui.components.BottomSheetPreview
+import to.bitkit.ui.components.CenteredProfileHeader
+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.SheetSize
+import to.bitkit.ui.components.Text13Up
+import to.bitkit.ui.components.VerticalSpacer
+import to.bitkit.ui.scaffold.SheetTopBar
+import to.bitkit.ui.settingsViewModel
+import to.bitkit.ui.shared.modifiers.sheetHeight
+import to.bitkit.ui.shared.util.gradientBackground
+import to.bitkit.ui.theme.AppThemeSurface
+import to.bitkit.ui.theme.Colors
+import to.bitkit.ui.utils.rememberBiometricAuthSupported
+import to.bitkit.ui.utils.withAccentBoldBright
+
+@Composable
+fun PubkyAuthApprovalSheet(
+ authUrl: String,
+ viewModel: PubkyAuthApprovalViewModel,
+ onDismiss: () -> Unit,
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ var showBiometrics by remember { mutableStateOf(false) }
+ var pendingAuthUrl by remember { mutableStateOf(null) }
+
+ val settings = settingsViewModel ?: return
+ val isPinEnabled by settings.isPinEnabled.collectAsStateWithLifecycle()
+ val isBiometricEnabled by settings.isBiometricEnabled.collectAsStateWithLifecycle()
+ val isBiometrySupported = rememberBiometricAuthSupported()
+
+ LaunchedEffect(authUrl) { viewModel.load(authUrl) }
+
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect {
+ when (it) {
+ is PubkyAuthApprovalEffect.RequestBiometric -> {
+ if (isPinEnabled && isBiometricEnabled && isBiometrySupported) {
+ pendingAuthUrl = it.authUrl
+ showBiometrics = true
+ } else {
+ viewModel.confirmAuthorize(it.authUrl)
+ }
+ }
+ PubkyAuthApprovalEffect.Dismiss -> onDismiss()
+ }
+ }
+ }
+
+ Box {
+ Content(
+ uiState = uiState,
+ onAuthorize = { viewModel.requestAuthorize(authUrl) },
+ onCancel = { viewModel.dismiss() },
+ onDismiss = { viewModel.dismiss() },
+ )
+
+ if (showBiometrics) {
+ BiometricsView(
+ onSuccess = {
+ showBiometrics = false
+ pendingAuthUrl?.let { viewModel.confirmAuthorize(it) }
+ pendingAuthUrl = null
+ },
+ onFailure = {
+ showBiometrics = false
+ pendingAuthUrl = null
+ },
+ )
+ }
+ }
+}
+
+@Composable
+private fun Content(
+ uiState: PubkyAuthApprovalUiState,
+ onAuthorize: () -> Unit,
+ onCancel: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val headerTitle = if (uiState.state == ApprovalState.Success) {
+ stringResource(R.string.profile__auth_approval_success)
+ } else {
+ stringResource(R.string.profile__auth_approval_title)
+ }
+
+ Column(
+ modifier = Modifier
+ .sheetHeight(SheetSize.LARGE)
+ .gradientBackground()
+ .navigationBarsPadding()
+ .padding(horizontal = 16.dp)
+ ) {
+ SheetTopBar(titleText = headerTitle)
+
+ when (uiState.state) {
+ ApprovalState.Authorize -> AuthorizeContent(
+ uiState = uiState,
+ onAuthorize = onAuthorize,
+ onCancel = onCancel,
+ )
+ ApprovalState.Authorizing -> AuthorizingContent(
+ uiState = uiState,
+ )
+ ApprovalState.Success -> SuccessContent(
+ uiState = uiState,
+ onDismiss = onDismiss,
+ )
+ }
+ }
+}
+
+@Composable
+private fun ColumnScope.AuthorizeContent(
+ uiState: PubkyAuthApprovalUiState,
+ onAuthorize: () -> Unit,
+ onCancel: () -> Unit,
+) {
+ DescriptionText(serviceName = uiState.serviceName)
+ VerticalSpacer(32.dp)
+
+ PermissionsSection(permissions = uiState.permissions)
+ VerticalSpacer(16.dp)
+
+ FillHeight()
+
+ TrustWarning()
+ VerticalSpacer(16.dp)
+
+ uiState.profile?.let { ProfileCard(it) }
+ VerticalSpacer(24.dp)
+
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ SecondaryButton(
+ text = stringResource(R.string.common__cancel),
+ onClick = onCancel,
+ modifier = Modifier.weight(1f),
+ )
+ PrimaryButton(
+ text = stringResource(R.string.profile__auth_approval_authorize),
+ onClick = onAuthorize,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ VerticalSpacer(16.dp)
+}
+
+@Composable
+private fun ColumnScope.AuthorizingContent(
+ uiState: PubkyAuthApprovalUiState,
+) {
+ DescriptionText(serviceName = uiState.serviceName)
+ VerticalSpacer(32.dp)
+
+ PermissionsSection(permissions = uiState.permissions)
+ VerticalSpacer(16.dp)
+
+ FillHeight()
+
+ TrustWarning()
+ VerticalSpacer(16.dp)
+
+ uiState.profile?.let { ProfileCard(it) }
+ VerticalSpacer(24.dp)
+
+ PrimaryButton(
+ text = stringResource(R.string.profile__auth_approval_authorizing),
+ onClick = {},
+ isLoading = true,
+ enabled = false,
+ )
+ VerticalSpacer(16.dp)
+}
+
+@Composable
+private fun ColumnScope.SuccessContent(
+ uiState: PubkyAuthApprovalUiState,
+ onDismiss: () -> Unit,
+) {
+ SuccessDescriptionText(
+ serviceName = uiState.serviceName,
+ truncatedKey = uiState.profile?.truncatedPublicKey ?: "",
+ )
+ VerticalSpacer(16.dp)
+
+ FillHeight()
+
+ Image(
+ painter = painterResource(R.drawable.check),
+ contentDescription = null,
+ modifier = Modifier
+ .size(256.dp)
+ .align(Alignment.CenterHorizontally),
+ )
+
+ FillHeight()
+
+ PrimaryButton(
+ text = stringResource(R.string.profile__auth_approval_ok),
+ onClick = onDismiss,
+ )
+ VerticalSpacer(16.dp)
+}
+
+@Composable
+private fun DescriptionText(serviceName: String) {
+ BodyM(
+ text = stringResource(R.string.profile__auth_approval_service, serviceName)
+ .withAccentBoldBright(),
+ color = Colors.White64,
+ )
+}
+
+@Composable
+private fun SuccessDescriptionText(serviceName: String, truncatedKey: String) {
+ BodyM(
+ text = stringResource(R.string.profile__auth_approval_success_detail, truncatedKey, serviceName)
+ .withAccentBoldBright(),
+ color = Colors.White64,
+ )
+}
+
+@Composable
+private fun PermissionsSection(permissions: ImmutableList) {
+ Text13Up(
+ text = stringResource(R.string.profile__auth_approval_permissions),
+ color = Colors.White64,
+ )
+ VerticalSpacer(8.dp)
+
+ permissions.forEach { permission ->
+ PermissionRow(permission)
+ VerticalSpacer(4.dp)
+ }
+
+ VerticalSpacer(4.dp)
+ HorizontalDivider(color = Colors.White10)
+}
+
+@Composable
+private fun PermissionRow(permission: PubkyAuthPermission) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_folder),
+ contentDescription = null,
+ tint = Colors.White,
+ modifier = Modifier.size(14.dp),
+ )
+ HorizontalSpacer(4.dp)
+ BodySSB(
+ text = permission.path,
+ modifier = Modifier.weight(1f),
+ )
+ Text13Up(
+ text = permission.displayAccess,
+ color = Colors.Gray1,
+ )
+ }
+}
+
+@Composable
+private fun TrustWarning() {
+ BodyM(
+ text = stringResource(R.string.profile__auth_approval_trust_warning),
+ color = Colors.White64,
+ )
+}
+
+@Composable
+private fun ProfileCard(profile: PubkyProfile) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(Colors.Gray6, RoundedCornerShape(16.dp))
+ .padding(24.dp),
+ ) {
+ CenteredProfileHeader(
+ publicKey = profile.publicKey,
+ name = profile.name,
+ bio = "",
+ imageUrl = profile.imageUrl,
+ )
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun AuthorizePreview() {
+ AppThemeSurface {
+ BottomSheetPreview {
+ Content(
+ uiState = PubkyAuthApprovalUiState(
+ state = ApprovalState.Authorize,
+ serviceName = "pubky.app",
+ permissions = persistentListOf(
+ PubkyAuthPermission(path = "/pub/pubky.app/", accessLevel = "rw"),
+ PubkyAuthPermission(path = "/pub/paykit/v0/", accessLevel = "rw"),
+ ),
+ profile = PubkyProfile(
+ publicKey = "pk8e3qm5f4kgczagxhertyuiop1gxag",
+ name = "Satoshi Nakamoto",
+ bio = "",
+ imageUrl = null,
+ links = emptyList(),
+ status = null,
+ ),
+ ),
+ onAuthorize = {},
+ onCancel = {},
+ onDismiss = {},
+ )
+ }
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun SuccessPreview() {
+ AppThemeSurface {
+ BottomSheetPreview {
+ Content(
+ uiState = PubkyAuthApprovalUiState(
+ state = ApprovalState.Success,
+ serviceName = "pubky.app",
+ ),
+ onAuthorize = {},
+ onCancel = {},
+ onDismiss = {},
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt
new file mode 100644
index 000000000..7468d2850
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt
@@ -0,0 +1,117 @@
+package to.bitkit.ui.screens.profile
+
+import android.content.Context
+import android.net.Uri
+import androidx.compose.runtime.Stable
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+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.PubkyAuthPermission
+import to.bitkit.models.PubkyAuthRequest
+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 PubkyAuthApprovalViewModel @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val pubkyRepo: PubkyRepo,
+) : ViewModel() {
+ companion object {
+ private const val TAG = "PubkyAuthApprovalVM"
+ }
+
+ private val _uiState = MutableStateFlow(PubkyAuthApprovalUiState())
+ val uiState = _uiState.asStateFlow()
+
+ private val _effects = MutableSharedFlow(extraBufferCapacity = 1)
+ val effects = _effects.asSharedFlow()
+
+ fun load(authUrl: String) {
+ viewModelScope.launch {
+ val caps = extractCaps(authUrl)
+ val permissions = PubkyAuthRequest.parseCapabilities(caps)
+ val serviceNames = permissions.mapNotNull { PubkyAuthRequest.extractServiceName(it.path) }.distinct()
+ val unknownService = context.getString(R.string.profile__auth_approval_service_unknown)
+ val serviceName = serviceNames.firstOrNull() ?: unknownService
+ val profile = pubkyRepo.profile.value
+
+ _uiState.update {
+ it.copy(
+ state = ApprovalState.Authorize,
+ serviceName = serviceName,
+ permissions = permissions.toImmutableList(),
+ profile = profile,
+ )
+ }
+ }
+ }
+
+ fun requestAuthorize(authUrl: String) {
+ viewModelScope.launch {
+ _effects.emit(PubkyAuthApprovalEffect.RequestBiometric(authUrl))
+ }
+ }
+
+ fun confirmAuthorize(authUrl: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(state = ApprovalState.Authorizing) }
+ pubkyRepo.approveAuth(authUrl)
+ .onSuccess {
+ Logger.info("Auth approved for '${_uiState.value.serviceName}'", context = TAG)
+ _uiState.update { it.copy(state = ApprovalState.Success) }
+ }
+ .onFailure {
+ Logger.error("Auth approval failed", it, context = TAG)
+ _uiState.update { it.copy(state = ApprovalState.Authorize) }
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.profile__auth_error_title),
+ description = it.message,
+ )
+ }
+ }
+ }
+
+ fun dismiss() {
+ viewModelScope.launch { _effects.emit(PubkyAuthApprovalEffect.Dismiss) }
+ }
+
+ private fun extractCaps(authUrl: String): String {
+ val uri = Uri.parse(authUrl)
+ return uri.getQueryParameter("caps").orEmpty()
+ }
+}
+
+@Stable
+data class PubkyAuthApprovalUiState(
+ val state: ApprovalState = ApprovalState.Authorize,
+ val serviceName: String = "",
+ val permissions: ImmutableList = persistentListOf(),
+ val profile: PubkyProfile? = null,
+)
+
+sealed interface ApprovalState {
+ data object Authorize : ApprovalState
+ data object Authorizing : ApprovalState
+ data object Success : ApprovalState
+}
+
+sealed interface PubkyAuthApprovalEffect {
+ data class RequestBiometric(val authUrl: String) : PubkyAuthApprovalEffect
+ data object Dismiss : PubkyAuthApprovalEffect
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt
new file mode 100644
index 000000000..eadd100bf
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt
@@ -0,0 +1,278 @@
+package to.bitkit.ui.screens.profile
+
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+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.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+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.clip
+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.BodyMSB
+import to.bitkit.ui.components.Display
+import to.bitkit.ui.components.FillHeight
+import to.bitkit.ui.components.GradientCircularProgressIndicator
+import to.bitkit.ui.components.HorizontalSpacer
+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.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.13f
+private const val KEYRING_OFFSET_X = 0.341f
+private const val KEYRING_OFFSET_Y = 0.06f
+private const val TAG_ALPHA = 0.6f
+private const val KEYRING_ALPHA = 0.9f
+
+@Composable
+fun PubkyChoiceScreen(
+ viewModel: PubkyChoiceViewModel,
+ onNavigateToCreateProfile: () -> Unit,
+ onNavigateToContactImportOverview: () -> Unit,
+ onNavigateToPayContacts: () -> Unit,
+ onBackClick: () -> Unit,
+) {
+ val context = LocalContext.current
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect {
+ when (it) {
+ is PubkyChoiceEffect.OpenRingAuth -> {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(it.authUrl)).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ }
+ PubkyChoiceEffect.NavigateToCreateProfile -> onNavigateToCreateProfile()
+ PubkyChoiceEffect.NavigateToContactImportOverview -> onNavigateToContactImportOverview()
+ PubkyChoiceEffect.NavigateToPayContacts -> onNavigateToPayContacts()
+ }
+ }
+ }
+
+ Content(
+ uiState = uiState,
+ onBackClick = onBackClick,
+ onCreateProfile = onNavigateToCreateProfile,
+ onImportWithRing = { viewModel.startRingAuth() },
+ onCancelAuth = { viewModel.cancelAuth() },
+ onDownloadRing = {
+ viewModel.dismissRingNotInstalledDialog()
+ context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PUBKY_RING_PLAY_STORE_URL)))
+ },
+ onDismissDialog = { viewModel.dismissRingNotInstalledDialog() },
+ )
+}
+
+@Composable
+private fun Content(
+ uiState: PubkyChoiceUiState,
+ onBackClick: () -> Unit,
+ onCreateProfile: () -> Unit,
+ onImportWithRing: () -> Unit,
+ onCancelAuth: () -> Unit,
+ onDownloadRing: () -> 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,
+ actions = { DrawerNavIcon() },
+ )
+
+ Column(modifier = Modifier.padding(horizontal = 32.dp)) {
+ VerticalSpacer(24.dp)
+
+ Display(
+ text = stringResource(R.string.profile__choice_title)
+ .withAccent(accentColor = Colors.PubkyGreen),
+ color = Colors.White,
+ )
+ VerticalSpacer(8.dp)
+
+ BodyM(
+ text = stringResource(R.string.profile__choice_description),
+ color = Colors.White64,
+ )
+ VerticalSpacer(24.dp)
+
+ if (uiState.isLoadingAfterAuth) {
+ LoadingState(text = stringResource(R.string.profile__choice_loading_profile))
+ } else if (uiState.isWaitingForRing) {
+ WaitingForRingState(onCancel = onCancelAuth)
+ } else {
+ OptionCard(
+ iconResId = R.drawable.ic_user_plus,
+ text = stringResource(R.string.profile__choice_create),
+ onClick = onCreateProfile,
+ )
+ VerticalSpacer(8.dp)
+ OptionCard(
+ iconResId = R.drawable.ic_lock_key,
+ text = stringResource(R.string.profile__choice_import),
+ onClick = onImportWithRing,
+ )
+ }
+ }
+
+ FillHeight()
+ }
+ }
+
+ 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 = onDownloadRing,
+ onDismiss = onDismissDialog,
+ )
+ }
+}
+
+@Composable
+private fun OptionCard(
+ iconResId: Int,
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(Colors.Gray6)
+ .clickable(onClick = onClick)
+ .padding(16.dp)
+ ) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(40.dp)
+ .background(Colors.Black, CircleShape)
+ ) {
+ Icon(
+ painter = painterResource(iconResId),
+ contentDescription = null,
+ tint = Colors.PubkyGreen,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ HorizontalSpacer(16.dp)
+ BodyMSB(text = text, color = Colors.White)
+ }
+}
+
+@Composable
+private fun WaitingForRingState(onCancel: () -> Unit) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ GradientCircularProgressIndicator(modifier = Modifier.size(20.dp))
+ HorizontalSpacer(12.dp)
+ BodyM(
+ text = stringResource(R.string.profile__choice_waiting_ring),
+ color = Colors.White64,
+ )
+ }
+ VerticalSpacer(16.dp)
+ SecondaryButton(
+ text = stringResource(R.string.common__cancel),
+ onClick = onCancel,
+ )
+}
+
+@Composable
+private fun LoadingState(text: String) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ GradientCircularProgressIndicator(modifier = Modifier.size(20.dp))
+ HorizontalSpacer(12.dp)
+ BodyM(text = text, color = Colors.White64)
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun Preview() {
+ AppThemeSurface {
+ Content(
+ uiState = PubkyChoiceUiState(),
+ onBackClick = {},
+ onCreateProfile = {},
+ onImportWithRing = {},
+ onCancelAuth = {},
+ onDownloadRing = {},
+ onDismissDialog = {},
+ )
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt
new file mode 100644
index 000000000..c2948f05f
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt
@@ -0,0 +1,140 @@
+package to.bitkit.ui.screens.profile
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.runtime.Immutable
+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 PubkyChoiceViewModel @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val pubkyRepo: PubkyRepo,
+) : ViewModel() {
+ companion object {
+ private const val TAG = "PubkyChoiceViewModel"
+ }
+
+ private val _uiState = MutableStateFlow(PubkyChoiceUiState())
+ 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) {
+ pubkyRepo.cancelAuthenticationSync()
+ }
+ }
+
+ fun startRingAuth() {
+ viewModelScope.launch {
+ if (_uiState.value.isWaitingForRing) {
+ approvalJob?.cancel()
+ approvalJob = null
+ _uiState.update { it.copy(isWaitingForRing = false) }
+ pubkyRepo.cancelAuthentication()
+ }
+
+ pubkyRepo.startAuthentication()
+ .onSuccess { authUrl ->
+ 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) }
+ _effects.emit(PubkyChoiceEffect.OpenRingAuth(authUrl))
+ waitForApproval()
+ }
+ .onFailure {
+ Logger.error("Starting Ring auth failed", it, context = TAG)
+ 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, isLoadingAfterAuth = true) }
+ pubkyRepo.prepareImport()
+ _uiState.update { it.copy(isLoadingAfterAuth = false) }
+ val hasContacts = pubkyRepo.pendingImportContacts.value.isNotEmpty()
+ if (hasContacts) {
+ _effects.emit(PubkyChoiceEffect.NavigateToContactImportOverview)
+ } else {
+ _effects.emit(PubkyChoiceEffect.NavigateToPayContacts)
+ }
+ }
+ .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 cancelAuth() {
+ viewModelScope.launch {
+ approvalJob?.cancel()
+ approvalJob = null
+ pubkyRepo.cancelAuthentication()
+ _uiState.update { it.copy(isWaitingForRing = false, isLoadingAfterAuth = false) }
+ }
+ }
+
+ fun dismissRingNotInstalledDialog() {
+ _uiState.update { it.copy(showRingNotInstalledDialog = false) }
+ }
+}
+
+@Immutable
+data class PubkyChoiceUiState(
+ val isWaitingForRing: Boolean = false,
+ val isLoadingAfterAuth: Boolean = false,
+ val showRingNotInstalledDialog: Boolean = false,
+)
+
+sealed interface PubkyChoiceEffect {
+ data class OpenRingAuth(val authUrl: String) : PubkyChoiceEffect
+ data object NavigateToCreateProfile : PubkyChoiceEffect
+ data object NavigateToContactImportOverview : PubkyChoiceEffect
+ data object NavigateToPayContacts : PubkyChoiceEffect
+}
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 45af13c37..6b6dd21cb 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
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -29,6 +30,7 @@ import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState
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
@@ -50,6 +52,7 @@ import androidx.compose.runtime.remember
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.platform.LocalContext
@@ -103,6 +106,7 @@ import to.bitkit.ui.components.EmptyStateView
import to.bitkit.ui.components.FillHeight
import to.bitkit.ui.components.Headline24
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
@@ -110,6 +114,7 @@ import to.bitkit.ui.components.TAB_BAR_HEIGHT
import to.bitkit.ui.components.TAB_BAR_PADDING_BOTTOM
import to.bitkit.ui.components.TabBar
import to.bitkit.ui.components.TertiaryButton
+import to.bitkit.ui.components.Title
import to.bitkit.ui.components.TopBarSpacer
import to.bitkit.ui.components.VerticalSpacer
import to.bitkit.ui.components.WalletBalanceView
@@ -117,6 +122,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
@@ -165,6 +171,10 @@ fun HomeScreen(
val context = LocalContext.current
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()
@@ -192,10 +202,20 @@ fun HomeScreen(
DeleteWidgetAlert(type, homeViewModel)
}
+ val navigateToProfile = {
+ rootNavController.navigateToProfile(
+ isAuthenticated = isPubkyAuthenticated,
+ hasSeenIntro = hasSeenProfileIntro,
+ )
+ }
+
Content(
isRefreshing = isRefreshing,
homeUiState = homeUiState,
drawerState = drawerState,
+ profileDisplayName = profileDisplayName,
+ profileDisplayImageUri = profileDisplayImageUri,
+ onClickProfile = navigateToProfile,
latestActivities = latestActivities,
onRefresh = {
activityListViewModel.resync()
@@ -240,9 +260,7 @@ fun HomeScreen(
)
}
- Suggestion.PROFILE -> {
- rootNavController.navigateTo(Routes.Profile)
- }
+ Suggestion.PROFILE -> navigateToProfile()
Suggestion.SHOP -> {
if (!hasSeenShopIntro) {
@@ -313,6 +331,9 @@ private fun Content(
isRefreshing: Boolean,
homeUiState: HomeUiState,
drawerState: DrawerState,
+ profileDisplayName: String? = null,
+ profileDisplayImageUri: String? = null,
+ onClickProfile: () -> Unit = {},
latestActivities: ImmutableList?,
onRefresh: () -> Unit = {},
onRemoveSuggestion: (Suggestion) -> Unit = {},
@@ -362,6 +383,9 @@ private fun Content(
Box {
TopBar(
hazeState = hazeState,
+ profileDisplayName = profileDisplayName,
+ profileDisplayImageUri = profileDisplayImageUri,
+ onClickProfile = onClickProfile,
showEditWidgets = homeUiState.currentPage == 1 && homeUiState.showWidgets,
isEditingWidgets = homeUiState.isEditingWidgets,
onClickEditWidgetList = onClickEditWidgetList,
@@ -799,6 +823,9 @@ private fun Widgets(
@OptIn(ExperimentalMaterial3Api::class)
private fun TopBar(
hazeState: HazeState,
+ profileDisplayName: String? = null,
+ profileDisplayImageUri: String? = null,
+ onClickProfile: () -> Unit = {},
showEditWidgets: Boolean = false,
isEditingWidgets: Boolean = false,
onClickEditWidgetList: () -> Unit = {},
@@ -822,7 +849,13 @@ private fun TopBar(
.zIndex(1f)
) {
TopAppBar(
- title = {},
+ title = {
+ ProfileButton(
+ displayName = profileDisplayName,
+ displayImageUri = profileDisplayImageUri,
+ onClick = onClickProfile,
+ )
+ },
actions = {
AnimatedVisibility(showEditWidgets) {
IconButton(
@@ -860,6 +893,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 64bc48208..bb20a8f75 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
@@ -31,6 +31,7 @@ import to.bitkit.models.widget.ArticleModel
import to.bitkit.models.widget.toArticleModel
import to.bitkit.models.widget.toBlockModel
import to.bitkit.repositories.ActivityRepo
+import to.bitkit.repositories.PubkyRepo
import to.bitkit.repositories.TransferRepo
import to.bitkit.repositories.WalletRepo
import to.bitkit.repositories.WidgetsRepo
@@ -46,6 +47,7 @@ class HomeViewModel @Inject constructor(
private val widgetsRepo: WidgetsRepo,
private val settingsStore: SettingsStore,
private val transferRepo: TransferRepo,
+ private val pubkyRepo: PubkyRepo,
private val activityRepo: ActivityRepo,
) : ViewModel() {
@@ -56,6 +58,9 @@ class HomeViewModel @Inject constructor(
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)
@@ -299,21 +304,27 @@ 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 -> spendingSuggestions(settings)
- balanceState.totalOnchainSats > 0uL -> savingsOnlySuggestions(settings, transfers)
- else -> emptyWalletSuggestions(settings, transfers)
+ balanceState.totalLightningSats > 0uL ->
+ spendingSuggestions(settings, profileAuthenticated)
+ balanceState.totalOnchainSats > 0uL ->
+ savingsOnlySuggestions(settings, transfers, profileAuthenticated)
+ else -> emptyWalletSuggestions(settings, transfers, profileAuthenticated)
}
val dismissedList = settings.dismissedSuggestions.mapNotNull { it.toSuggestionOrNull() }
baseSuggestions.filterNot { it in dismissedList }.take(MAX_SUGGESTIONS)
}
- private fun spendingSuggestions(settings: SettingsData) = listOfNotNull(
+ private fun spendingSuggestions(
+ settings: SettingsData,
+ profileAuthenticated: Boolean,
+ ) = listOfNotNull(
Suggestion.QUICK_PAY.takeIf { !settings.isQuickPayEnabled },
Suggestion.NOTIFICATIONS.takeIf { !settings.notificationsGranted },
Suggestion.SHOP,
- Suggestion.PROFILE,
+ Suggestion.PROFILE.takeIf { !profileAuthenticated },
Suggestion.SUPPORT,
Suggestion.INVITE,
Suggestion.BUY,
@@ -322,6 +333,7 @@ class HomeViewModel @Inject constructor(
private fun savingsOnlySuggestions(
settings: SettingsData,
transfers: List,
+ profileAuthenticated: Boolean,
) = listOfNotNull(
Suggestion.BACK_UP.takeIf { !settings.backupVerified },
Suggestion.SECURE.takeIf { !settings.isPinEnabled },
@@ -329,7 +341,7 @@ class HomeViewModel @Inject constructor(
transfers.all { it.type != TransferType.TO_SPENDING }
},
Suggestion.SUPPORT,
- Suggestion.PROFILE,
+ Suggestion.PROFILE.takeIf { !profileAuthenticated },
Suggestion.INVITE,
Suggestion.BUY,
)
@@ -337,6 +349,7 @@ class HomeViewModel @Inject constructor(
private fun emptyWalletSuggestions(
settings: SettingsData,
transfers: List,
+ profileAuthenticated: Boolean,
) = listOfNotNull(
Suggestion.BUY,
Suggestion.LIGHTNING.takeIf {
@@ -345,7 +358,7 @@ class HomeViewModel @Inject constructor(
Suggestion.SUPPORT,
Suggestion.BACK_UP.takeIf { !settings.backupVerified },
Suggestion.SECURE.takeIf { !settings.isPinEnabled },
- Suggestion.PROFILE,
+ Suggestion.PROFILE.takeIf { !profileAuthenticated },
Suggestion.INVITE,
)
}
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 55ac8e0a1..42d147ac2 100644
--- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
@@ -114,6 +114,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.repositories.WidgetsRepo
@@ -171,6 +172,7 @@ class AppViewModel @Inject constructor(
private val transferRepo: TransferRepo,
private val migrationService: MigrationService,
private val coreService: CoreService,
+ private val pubkyRepo: PubkyRepo,
private val appUpdateSheet: AppUpdateTimedSheet,
private val backupSheet: BackupTimedSheet,
private val notificationsSheet: NotificationsTimedSheet,
@@ -292,7 +294,8 @@ class AppViewModel @Inject constructor(
val isHighPrioritySheetShowing = currentSheet is Sheet.Gift ||
currentSheet is Sheet.Send ||
currentSheet is Sheet.LnurlAuth ||
- currentSheet is Sheet.Pin
+ currentSheet is Sheet.Pin ||
+ currentSheet is Sheet.PubkyAuth
if (!isHighPrioritySheetShowing) {
showSheet(Sheet.TimedSheet(sheetType))
}
@@ -304,6 +307,17 @@ class AppViewModel @Inject constructor(
}
}
}
+ viewModelScope.launch {
+ pubkyRepo.sessionRestorationFailed.collect { failed ->
+ if (failed) {
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.profile__session_expired),
+ )
+ pubkyRepo.clearSessionRestorationFailed()
+ }
+ }
+ }
observeLdkNodeEvents()
observeSendEvents()
viewModelScope.launch {
@@ -1279,6 +1293,11 @@ class AppViewModel @Inject constructor(
return@withContext
}
+ if (input.startsWith("$PUBKYAUTH_SCHEME://")) {
+ handlePubkyAuth(input)
+ return@withContext
+ }
+
val scan = runCatching { coreService.decode(input) }
.onFailure { Logger.error("Failed to decode scan data: '$input'", it, context = TAG) }
.onSuccess { Logger.info("Handling decoded scan data: $it", context = TAG) }
@@ -2449,11 +2468,27 @@ class AppViewModel @Inject constructor(
return@launch
}
+ if (uri.scheme == PUBKYAUTH_SCHEME) {
+ handlePubkyAuth(uri.toString())
+ return@launch
+ }
+
if (!walletRepo.walletExists()) return@launch
launchScan(source = ScanSource.DEEPLINK, data = uri.toString(), startDelay = SCREEN_TRANSITION_DELAY)
}
+ private suspend fun handlePubkyAuth(authUrl: String) {
+ if (!pubkyRepo.hasSecretKey()) {
+ ToastEventBus.send(
+ type = Toast.ToastType.WARNING,
+ title = context.getString(R.string.profile__auth_approval_ring_only),
+ )
+ return
+ }
+ showSheet(Sheet.PubkyAuth(authUrl))
+ }
+
// TODO Temporary fix while these schemes can't be decoded https://github.com/synonymdev/bitkit-core/issues/70
private fun String.removeLightningSchemes(): String = LIGHTNING_SCHEME_PATTERNS.fold(this) { acc, regex ->
acc.replace(regex, "")
@@ -2513,6 +2548,7 @@ class AppViewModel @Inject constructor(
private const val AUTH_CHECK_INITIAL_DELAY_MS = 1000L
private const val AUTH_CHECK_SPLASH_DELAY_MS = 500L
private const val ADDRESS_VALIDATION_DEBOUNCE_MS = 1000L
+ private const val PUBKYAUTH_SCHEME = "pubkyauth"
}
}
diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt
index e464f2346..1044d1ee6 100644
--- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt
@@ -14,6 +14,7 @@ import kotlinx.coroutines.launch
import to.bitkit.data.SettingsStore
import to.bitkit.data.WidgetsStore
import to.bitkit.models.TransactionSpeed
+import to.bitkit.repositories.PubkyRepo
import to.bitkit.repositories.WidgetsRepo
import javax.inject.Inject
@@ -21,6 +22,7 @@ import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val settingsStore: SettingsStore,
+ private val pubkyRepo: PubkyRepo,
private val widgetsStore: WidgetsStore,
private val widgetsRepo: WidgetsRepo,
) : ViewModel() {
@@ -98,6 +100,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/drawable/ic_folder.xml b/app/src/main/res/drawable/ic_folder.xml
new file mode 100644
index 000000000..24594f708
--- /dev/null
+++ b/app/src/main/res/drawable/ic_folder.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_lightbulb.xml b/app/src/main/res/drawable/ic_lightbulb.xml
new file mode 100644
index 000000000..f70a3ef1c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_lightbulb.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_link.xml b/app/src/main/res/drawable/ic_link.xml
new file mode 100644
index 000000000..e01e25f87
--- /dev/null
+++ b/app/src/main/res/drawable/ic_link.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 86a43b716..78c7a0889 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -767,10 +767,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-es/strings.xml b/app/src/main/res/values-es/strings.xml
index f60beef35..7594e7c3a 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -735,10 +735,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 a23a42aa2..3b4e19078 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -735,10 +735,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-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index 408035dc8..52ca05c16 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -735,10 +735,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/strings.xml b/app/src/main/res/values-pt/strings.xml
index 49f87acfa..d9e69091d 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -734,10 +734,6 @@
Single Random Draw
Configurações do sistema
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/strings.xml b/app/src/main/res/values/strings.xml
index 94462606a..e6d19ea06 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -69,6 +69,43 @@
Usable
Yes
Yes, Proceed
+ Add
+ Contact saved
+ Add Contact
+ Add a new contact by scanning their QR or pasting their pubky below.
+ Discard
+ Could not retrieve contact info. Please check the public key and try again.
+ Please note that you and %1$s must add each other as contacts to pay each other privately. Otherwise, the payment will be visible publicly.
+ PUBKY
+ Paste a pubky
+ Retrieving\n<accent>contact info</accent>
+ Scan QR
+ Add Contact
+ CONTACTS
+ This contact will be removed from your list.
+ Delete %1$s?
+ Delete Contact
+ Unable to load contact.
+ Contact
+ Contact updated
+ Edit Contact
+ You don\'t have any contacts yet.
+ Import All
+ %1$d friends
+ Found\n<accent>profile & contacts</accent>
+ Bitkit found profile and contacts data connected to pubky %1$s
+ Select
+ Select all
+ Select\n<accent>contacts</accent>
+ Select none
+ Please select which friends you want to import.
+ %1$d selected
+ Import
+ Add Contact
+ 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
@@ -435,6 +472,81 @@
Update Available
Please update Bitkit to the latest version for new features and bug fixes!
Update\n<accent>Bitkit</accent>
+ Add Link
+ LABEL
+ For example \'Website\'
+ Note: Any link you add will be publicly visible.
+ LINK OR TEXT
+ https://
+ Add Tag
+ TAG
+ For example: \'Developer\'
+ Authorize
+ Authorizing…
+ OK
+ Requested permissions
+ Use Ring to manage authorizations
+ A service is requesting permission to access and edit your <accent>%1$s</accent> data.
+ Unknown service
+ Authorization Successful
+ You authorized with pubky <accent>%1$s</accent> and gave the service permission to access and edit your <accent>%2$s</accent> data.
+ Authorize
+ Make sure you trust the service, browser, or device before authorizing with your pubky.
+ Authorization Failed
+ Failed to read selected image
+ Create profile with Bitkit
+ Create a new pubky and profile in Bitkit, or import an existing profile with Pubky Ring.
+ Import with Pubky Ring
+ Loading your profile…
+ Join the\n<accent>pubky web</accent>
+ Waiting for Pubky Ring…
+ Failed to create profile
+ Create Profile
+ Restoring your existing profile…
+ Profile created successfully
+ This will permanently delete your Pubky profile.
+ Delete Profile?
+ Failed to delete profile
+ Delete Profile
+ Profile deleted
+ Deriving your keys…
+ BIO
+ Short bio. Tell a bit about yourself.
+ DELETE
+ YOUR NAME
+ Edit Profile
+ Please note that all your profile information will be publicly available and visible.
+ Failed to save profile
+ Profile saved
+ TAGS
+ 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\nprofile</accent>
+ Profile
+ Use Bitkit with your contacts to send payments directly, anytime, anywhere.
+ Let your\ncontacts\n<accent>pay you</accent>
+ Pay Contacts
+ Share payment data and enable payments with contacts
+ Public Key
+ Scan to add {name}
+ Restore Profile
+ Try Again
+ Please authorize Bitkit with Pubky Ring, your mobile keychain for the next web.
+ Join the\n<accent>pubky web</accent>
+ Authorize
+ Download
+ Loading your profile…
+ Pubky Ring is required to authorize your profile. Would you like to download it?
+ Pubky Ring Not Installed
+ Waiting for authorization from Pubky Ring…
+ Your profile session has expired. Please reconnect to restore your profile.
+ Disconnect
+ This will disconnect your Pubky profile from Bitkit. You can reconnect at any time.
+ Disconnect Profile
+ Suggestions
+ Suggestions To Add
+ Your Name
+ Your Pubky
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.
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/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt
new file mode 100644
index 000000000..6431bd2ba
--- /dev/null
+++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt
@@ -0,0 +1,84 @@
+package to.bitkit.models
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class PubkyAuthRequestTest {
+
+ @Test
+ fun `parseCapabilities parses single permission`() {
+ val permissions = PubkyAuthRequest.parseCapabilities("/pub/bitkit.to/:rw")
+
+ assertEquals(1, permissions.size)
+ assertEquals("/pub/bitkit.to/", permissions[0].path)
+ assertEquals("rw", permissions[0].accessLevel)
+ }
+
+ @Test
+ fun `parseCapabilities parses multiple permissions`() {
+ val caps = "/pub/bitkit.to/:rw,/pub/pubky.app/:r,/pub/paykit/v0/:rw"
+ val permissions = PubkyAuthRequest.parseCapabilities(caps)
+
+ assertEquals(3, permissions.size)
+ assertEquals("/pub/bitkit.to/", permissions[0].path)
+ assertEquals("rw", permissions[0].accessLevel)
+ assertEquals("/pub/pubky.app/", permissions[1].path)
+ assertEquals("r", permissions[1].accessLevel)
+ assertEquals("/pub/paykit/v0/", permissions[2].path)
+ assertEquals("rw", permissions[2].accessLevel)
+ }
+
+ @Test
+ fun `parseCapabilities handles empty string`() {
+ assertTrue(PubkyAuthRequest.parseCapabilities("").isEmpty())
+ }
+
+ @Test
+ fun `parseCapabilities skips malformed segments`() {
+ val permissions = PubkyAuthRequest.parseCapabilities("malformed,/pub/ok/:r")
+
+ assertEquals(1, permissions.size)
+ assertEquals("/pub/ok/", permissions[0].path)
+ }
+
+ @Test
+ fun `displayAccess maps r to READ`() {
+ val perm = PubkyAuthPermission(path = "/pub/test/", accessLevel = "r")
+ assertEquals("READ", perm.displayAccess)
+ }
+
+ @Test
+ fun `displayAccess maps w to WRITE`() {
+ val perm = PubkyAuthPermission(path = "/pub/test/", accessLevel = "w")
+ assertEquals("WRITE", perm.displayAccess)
+ }
+
+ @Test
+ fun `displayAccess maps rw to READ, WRITE`() {
+ val perm = PubkyAuthPermission(path = "/pub/test/", accessLevel = "rw")
+ assertEquals("READ, WRITE", perm.displayAccess)
+ }
+
+ @Test
+ fun `extractServiceName extracts from pub path`() {
+ assertEquals("bitkit.to", PubkyAuthRequest.extractServiceName("/pub/bitkit.to/"))
+ assertEquals("pubky.app", PubkyAuthRequest.extractServiceName("/pub/pubky.app/"))
+ assertEquals("paykit", PubkyAuthRequest.extractServiceName("/pub/paykit/v0/"))
+ }
+
+ @Test
+ fun `extractServiceName returns null for invalid path`() {
+ assertNull(PubkyAuthRequest.extractServiceName("/invalid"))
+ assertNull(PubkyAuthRequest.extractServiceName(""))
+ }
+
+ @Test
+ fun `extractServiceName handles staging prefix`() {
+ assertEquals(
+ "staging.bitkit.to",
+ PubkyAuthRequest.extractServiceName("/pub/staging.bitkit.to/profile.json"),
+ )
+ }
+}
diff --git a/app/src/test/java/to/bitkit/models/PubkyProfileDataTest.kt b/app/src/test/java/to/bitkit/models/PubkyProfileDataTest.kt
new file mode 100644
index 000000000..fdf3f442c
--- /dev/null
+++ b/app/src/test/java/to/bitkit/models/PubkyProfileDataTest.kt
@@ -0,0 +1,90 @@
+package to.bitkit.models
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class PubkyProfileDataTest {
+
+ @Test
+ fun `encode and decode round-trip preserves all fields`() {
+ val data = PubkyProfileData(
+ name = "Satoshi",
+ bio = "Bitcoin creator",
+ image = "pubky://abc/pub/bitkit.to/blobs/123.jpg",
+ links = listOf(PubkyProfileDataLink("Website", "https://bitcoin.org")),
+ tags = listOf("Founder", "Bitcoin"),
+ )
+ val json = data.encode().toString(Charsets.UTF_8)
+ val decoded = PubkyProfileData.decode(json)
+
+ assertEquals("Satoshi", decoded.name)
+ assertEquals("Bitcoin creator", decoded.bio)
+ assertEquals("pubky://abc/pub/bitkit.to/blobs/123.jpg", decoded.image)
+ assertEquals(1, decoded.links.size)
+ assertEquals("Website", decoded.links[0].label)
+ assertEquals("https://bitcoin.org", decoded.links[0].url)
+ assertEquals(listOf("Founder", "Bitcoin"), decoded.tags)
+ }
+
+ @Test
+ fun `decode with missing tags field defaults to empty list`() {
+ val json = """{"name":"Alice","bio":"hello","image":null,"links":[]}"""
+ val decoded = PubkyProfileData.decode(json)
+
+ assertEquals("Alice", decoded.name)
+ assertTrue(decoded.tags.isEmpty())
+ }
+
+ @Test
+ fun `decode with missing image field defaults to null`() {
+ val json = """{"name":"Bob","bio":"","links":[],"tags":["Dev"]}"""
+ val decoded = PubkyProfileData.decode(json)
+
+ assertNull(decoded.image)
+ assertEquals(listOf("Dev"), decoded.tags)
+ }
+
+ @Test
+ fun `toPubkyProfile converts correctly`() {
+ val data = PubkyProfileData(
+ name = "Satoshi",
+ bio = "Bio",
+ image = "pubky://img",
+ links = listOf(PubkyProfileDataLink("X", "https://x.com/s")),
+ tags = listOf("Founder"),
+ )
+ val profile = data.toPubkyProfile("pubkyabc123")
+
+ assertEquals("pubkyabc123", profile.publicKey)
+ assertEquals("Satoshi", profile.name)
+ assertEquals("Bio", profile.bio)
+ assertEquals("pubky://img", profile.imageUrl)
+ assertEquals(1, profile.links.size)
+ assertEquals("X", profile.links[0].label)
+ assertEquals(listOf("Founder"), profile.tags)
+ assertNull(profile.status)
+ }
+
+ @Test
+ fun `PubkyProfile toProfileData converts correctly`() {
+ val profile = PubkyProfile(
+ publicKey = "pubkyabc",
+ name = "Alice",
+ bio = "Hello",
+ imageUrl = "pubky://img",
+ links = listOf(PubkyProfileLink("Email", "alice@example.com")),
+ tags = listOf("Designer"),
+ status = null,
+ )
+ val data = profile.toProfileData()
+
+ assertEquals("Alice", data.name)
+ assertEquals("Hello", data.bio)
+ assertEquals("pubky://img", data.image)
+ assertEquals(1, data.links.size)
+ assertEquals("Email", data.links[0].label)
+ assertEquals(listOf("Designer"), data.tags)
+ }
+}
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..73a5fd752
--- /dev/null
+++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt
@@ -0,0 +1,393 @@
+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.env.Env
+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,
+ httpClient = mock(),
+ )
+
+ @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 contactKey = "pubkycontact1"
+ val contactPath = "${Env.contactsBasePath}$contactKey"
+ whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("test_secret")
+ whenever(pubkyService.sessionList("test_secret", Env.contactsBasePath))
+ .thenReturn(listOf(contactPath))
+
+ val json = """{"name":"Alice","bio":"Hello"}"""
+ val pk = checkNotNull(sut.publicKey.value)
+ val strippedPk = pk.removePrefix("pubky")
+ whenever(pubkyService.fetchFileString("pubky://$strippedPk${Env.contactsBasePath}$contactKey"))
+ .thenReturn(json)
+
+ sut.loadContacts()
+
+ val contacts = sut.contacts.value
+ assertEquals(1, contacts.size)
+ assertEquals("Alice", contacts.first().name)
+ assertEquals(contactKey, contacts.first().publicKey)
+ assertFalse(sut.isLoadingContacts.value)
+ }
+
+ @Test
+ fun `loadContacts should return early when no public key`() = test {
+ sut.loadContacts()
+
+ verify(pubkyService, never()).sessionList(any(), any())
+ }
+
+ @Test
+ fun `loadContacts should use placeholder when profile fetch fails`() = test {
+ authenticateForTesting()
+ val contactKey = "pubkycontact2"
+ val contactPath = "${Env.contactsBasePath}$contactKey"
+ whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("test_secret")
+ whenever(pubkyService.sessionList("test_secret", Env.contactsBasePath))
+ .thenReturn(listOf(contactPath))
+
+ val pk = checkNotNull(sut.publicKey.value)
+ val strippedPk = pk.removePrefix("pubky")
+ whenever(pubkyService.fetchFileString("pubky://$strippedPk${Env.contactsBasePath}$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 contactKey = "pubkycontact4"
+ val contactPath = "${Env.contactsBasePath}$contactKey"
+ whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("test_secret")
+ whenever(pubkyService.sessionList("test_secret", Env.contactsBasePath))
+ .thenReturn(listOf(contactPath))
+
+ val pk = checkNotNull(sut.publicKey.value)
+ val strippedPk = pk.removePrefix("pubky")
+ val json = """{"name":"Charlie","bio":""}"""
+ whenever(pubkyService.fetchFileString("pubky://$strippedPk${Env.contactsBasePath}$contactKey"))
+ .thenReturn(json)
+
+ sut.loadContacts()
+ assertEquals(1, sut.contacts.value.size)
+
+ sut.signOut()
+
+ assertTrue(sut.contacts.value.isEmpty())
+ }
+
+ @Test
+ fun `loadContacts should extract contact key from path`() = test {
+ authenticateForTesting()
+ val contactKey = "pubkyabc123"
+ val contactPath = "${Env.contactsBasePath}$contactKey"
+ whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("test_secret")
+ whenever(pubkyService.sessionList("test_secret", Env.contactsBasePath))
+ .thenReturn(listOf(contactPath))
+
+ val pk = checkNotNull(sut.publicKey.value)
+ val strippedPk = pk.removePrefix("pubky")
+ val expectedUri = "pubky://$strippedPk${Env.contactsBasePath}$contactKey"
+ val json = """{"name":"Extracted","bio":""}"""
+ whenever(pubkyService.fetchFileString(expectedUri)).thenReturn(json)
+
+ sut.loadContacts()
+
+ verify(pubkyService).fetchFileString(expectedUri)
+ assertEquals("Extracted", sut.contacts.value.first().name)
+ assertEquals(contactKey, sut.contacts.value.first().publicKey)
+ }
+
+ 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(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(testSecret)
+ whenever(pubkyService.sessionList(testSecret, Env.contactsBasePath)).thenReturn(emptyList())
+
+ sut.completeAuthentication()
+ }
+}
diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
index d1c479590..df4f5568b 100644
--- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
+++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
@@ -32,6 +32,7 @@ import to.bitkit.repositories.LightningRepo
import to.bitkit.repositories.LightningState
import to.bitkit.repositories.PendingPaymentRepo
import to.bitkit.repositories.PreActivityMetadataRepo
+import to.bitkit.repositories.PubkyRepo
import to.bitkit.repositories.TransferRepo
import to.bitkit.repositories.WalletRepo
import to.bitkit.repositories.WalletState
@@ -71,6 +72,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
private val migrationService = mock()
private val coreService = mock()
private val keychain = mock()
+ private val pubkyRepo = mock()
private val widgetsRepo = mock()
private val formatMoneyValue = mock()
@@ -96,6 +98,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
whenever { migrationService.isMigrationChecked() }.thenReturn(true)
whenever { widgetsRepo.refreshEnabledWidgets() }.thenReturn(Unit)
whenever { lightningRepo.updateGeoBlockState() }.thenReturn(Unit)
+ whenever(pubkyRepo.sessionRestorationFailed).thenReturn(MutableStateFlow(false))
whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull()))
.thenReturn(Result.failure(Exception("not mocked")))
whenever { lightningRepo.calculateTotalFee(any(), anyOrNull(), any(), anyOrNull(), anyOrNull()) }
@@ -134,6 +137,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
highBalanceSheet = mock(),
formatMoneyValue = formatMoneyValue,
widgetsRepo = widgetsRepo,
+ pubkyRepo = pubkyRepo,
)
}
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 ea7508a9a..7b221650f 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.56" }
+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" }
@@ -88,6 +90,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"