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