From 9ed33953f2e149c868641396c0a9d74ed7cb0fa7 Mon Sep 17 00:00:00 2001 From: rodvar Date: Tue, 17 Dec 2024 09:33:48 +1100 Subject: [PATCH] Feature/account profile settings (#120) * - initial layout for User Profile based on designs * - implement requested refactor on previous PR review * - use bisqScroll layout + add leaft to breadcrum navigation * - adaptat userprofilefacade to share a bisq-mobile-compatible model for general usage * - implementation on profile age + adaptations on user profile to avoid crash on client, seems trusted node call not yet implemented * - setup editable/non editable textfields on settings, added botid and profileid * - allow bisqScroll layout to modify modifier with callback - settings screen implementation for UserProfile UX best practices with header + scroll + footer * - last user activity implementation * - hide delete profile as its not part of the mvp - implementation for save profile, using incorrect spinner image for now * - final touches --- .../NodeUserProfileServiceFacade.kt | 36 +++- .../presentation/NodeSettingsPresenter.kt | 2 +- .../user/profile/UserProfile.kt | 24 +-- .../ClientUserProfileServiceFacade.kt | 2 +- .../bisq/mobile/domain/data/model/User.kt | 3 + .../user_profile/UserProfileServiceFacade.kt | 7 + .../network/bisq/mobile/utils/DateUtils.kt | 34 ++++ .../cathash/BaseClientCatHashService.kt | 22 ++- .../bisq/mobile/presentation/BasePresenter.kt | 1 + .../presentation/di/PresentationModule.kt | 4 + .../ui/components/atoms/SettingsTextField.kt | 60 ++++++ .../ui/components/layout/ScrollLayout.kt | 2 + .../molecules/settings/BreadcrumNavigation.kt | 37 ++++ .../molecules/settings/SettingsButton.kt | 40 ++++ .../molecules/settings/SettingsMenu.kt | 45 +++++ .../ui/uicases/settings/SettingsPresenter.kt | 3 +- .../ui/uicases/settings/SettingsScreen.kt | 103 +--------- .../settings/UserProfileSettingsPresenter.kt | 140 +++++++++++++ .../settings/UserProfileSettingsScreen.kt | 184 +++++++++++++++++- .../uicases/startup/CreateProfilePresenter.kt | 6 +- .../ui/uicases/startup/SplashPresenter.kt | 5 + 21 files changed, 628 insertions(+), 132 deletions(-) create mode 100644 shared/domain/src/commonMain/kotlin/network/bisq/mobile/utils/DateUtils.kt create mode 100644 shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/components/atoms/SettingsTextField.kt create mode 100644 shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/components/molecules/settings/BreadcrumNavigation.kt create mode 100644 shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/components/molecules/settings/SettingsButton.kt create mode 100644 shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/components/molecules/settings/SettingsMenu.kt create mode 100644 shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/settings/UserProfileSettingsPresenter.kt diff --git a/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/domain/user_profile/NodeUserProfileServiceFacade.kt b/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/domain/user_profile/NodeUserProfileServiceFacade.kt index efb9cfe7..12f4bdb5 100644 --- a/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/domain/user_profile/NodeUserProfileServiceFacade.kt +++ b/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/domain/user_profile/NodeUserProfileServiceFacade.kt @@ -6,9 +6,13 @@ import bisq.security.SecurityService import bisq.security.pow.ProofOfWork import bisq.user.UserService import bisq.user.identity.NymIdGenerator -import bisq.user.profile.UserProfile import network.bisq.mobile.android.node.AndroidApplicationService import network.bisq.mobile.android.node.service.AndroidNodeCatHashService +import network.bisq.mobile.client.replicated_model.common.network.Address +import network.bisq.mobile.client.replicated_model.common.network.TransportType +import network.bisq.mobile.client.replicated_model.network.identity.NetworkId +import network.bisq.mobile.client.replicated_model.security.keys.PubKey +import network.bisq.mobile.client.replicated_model.user.profile.UserProfile import network.bisq.mobile.domain.PlatformImage import network.bisq.mobile.domain.service.user_profile.UserProfileServiceFacade import network.bisq.mobile.utils.Logging @@ -100,8 +104,34 @@ class NodeUserProfileServiceFacade(private val applicationService: AndroidApplic } // Private - private fun getSelectedUserProfile(): UserProfile? { - return userService.userIdentityService.selectedUserIdentity?.userProfile + override suspend fun getSelectedUserProfile(): UserProfile? { + // TODO move to bridge mapper + return userService.userIdentityService.selectedUserIdentity?.userProfile?.let { + UserProfile( + it.nickName, + network.bisq.mobile.client.replicated_model.security.pow.ProofOfWork( + it.proofOfWork.solution, + it.proofOfWork.counter, + it.proofOfWork.challenge, + it.proofOfWork.difficulty, + it.proofOfWork.payload, + it.proofOfWork.duration + ), + NetworkId(it.networkId.addressByTransportTypeMap.map{ + (key, value) -> + TransportType.entries[key.ordinal] to Address(value.host, value.port) }.toMap(), + PubKey( it.networkId.pubKey.publicKey.toString(), it.networkId.pubKey.keyId)), + it.terms, + it.statement, + it.avatarVersion, + it.applicationVersion, + it.id, + it.nym, + it.userName, + it.pubKeyHash.toString(), + it.publishDate + ) + } } private fun createSimulatedDelay(powDuration: Long) { diff --git a/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/presentation/NodeSettingsPresenter.kt b/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/presentation/NodeSettingsPresenter.kt index e897a946..1f8905d3 100644 --- a/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/presentation/NodeSettingsPresenter.kt +++ b/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/presentation/NodeSettingsPresenter.kt @@ -2,8 +2,8 @@ package network.bisq.mobile.android.node.presentation import network.bisq.mobile.domain.data.repository.SettingsRepository import network.bisq.mobile.presentation.MainPresenter +import network.bisq.mobile.presentation.ui.components.molecules.settings.MenuItem import network.bisq.mobile.presentation.ui.uicases.settings.ISettingsPresenter -import network.bisq.mobile.presentation.ui.uicases.settings.MenuItem import network.bisq.mobile.presentation.ui.uicases.settings.SettingsPresenter class NodeSettingsPresenter( diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/replicated_model/user/profile/UserProfile.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/replicated_model/user/profile/UserProfile.kt index f2eea427..4a34d485 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/replicated_model/user/profile/UserProfile.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/replicated_model/user/profile/UserProfile.kt @@ -6,16 +6,16 @@ import network.bisq.mobile.client.replicated_model.security.pow.ProofOfWork @Serializable data class UserProfile( - val nickName: String, - val proofOfWork: ProofOfWork, - val networkId: NetworkId, - val terms: String, - val statement: String, - val avatarVersion: Int, - val applicationVersion: String, - val id: String, - val nym: String, - val userName: String, - val pubKeyHash: String, - val publishDate: Long + val nickName: String? = null, + val proofOfWork: ProofOfWork? = null, + val networkId: NetworkId? = null, + val terms: String? = null, + val statement: String? = null, + val avatarVersion: Int? = null, + val applicationVersion: String? = null, + val id: String? = null, + val nym: String? = null, + val userName: String? = null, + val pubKeyHash: String? = null, + val publishDate: Long? = null ) \ No newline at end of file diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/user_profile/ClientUserProfileServiceFacade.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/user_profile/ClientUserProfileServiceFacade.kt index f5f77fe5..e44f6623 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/user_profile/ClientUserProfileServiceFacade.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/user_profile/ClientUserProfileServiceFacade.kt @@ -63,7 +63,7 @@ class ClientUserProfileServiceFacade( } // Private - private suspend fun getSelectedUserProfile(): UserProfile { + override suspend fun getSelectedUserProfile(): UserProfile { return apiGateway.getSelectedUserProfile() } diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/data/model/User.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/data/model/User.kt index 5c5b5fc7..0f93258e 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/data/model/User.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/data/model/User.kt @@ -5,5 +5,8 @@ import network.bisq.mobile.domain.PlatformImage @Serializable open class User: BaseModel() { + var tradeTerms: String? = null + var statement: String? = null + var lastActivity: Long? = null var uniqueAvatar: PlatformImage? = null } \ No newline at end of file diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/service/user_profile/UserProfileServiceFacade.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/service/user_profile/UserProfileServiceFacade.kt index 5cec6f56..61e4b27c 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/service/user_profile/UserProfileServiceFacade.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/service/user_profile/UserProfileServiceFacade.kt @@ -1,5 +1,6 @@ package network.bisq.mobile.domain.service.user_profile +import network.bisq.mobile.client.replicated_model.user.profile.UserProfile import network.bisq.mobile.domain.PlatformImage interface UserProfileServiceFacade { @@ -40,6 +41,12 @@ interface UserProfileServiceFacade { /** * Applies the selected user identity to the user profile model + * @return Triple containing nickname, nym and id */ suspend fun applySelectedUserProfile():Triple + + /** + * @return UserProfile if existent, null otherwise + */ + suspend fun getSelectedUserProfile(): UserProfile? } \ No newline at end of file diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/utils/DateUtils.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/utils/DateUtils.kt new file mode 100644 index 00000000..b5dc1928 --- /dev/null +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/utils/DateUtils.kt @@ -0,0 +1,34 @@ +package network.bisq.mobile.utils + +import kotlinx.datetime.* +import kotlin.time.ExperimentalTime + +object DateUtils { + + /** + * @return years, months, days past since timestamp + */ + fun periodFrom(timetamp: Long): Triple { + val creationInstant = Instant.fromEpochMilliseconds(timetamp) + val creationDate = creationInstant.toLocalDateTime(TimeZone.currentSystemDefault()).date + val currentDate = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + + // Calculate the difference + val period = creationDate.until(currentDate, DateTimeUnit.DAY) + val years = period / 365 + val remainingDaysAfterYears = period % 365 + val months = remainingDaysAfterYears / 30 + val days = remainingDaysAfterYears % 30 + + // Format the result + return Triple(years, months, days) + } + + fun toDateString(epochMillis: Long, timeZone: TimeZone = TimeZone.currentSystemDefault()): String { + val instant = Instant.fromEpochMilliseconds(epochMillis) + val localDateTime = instant.toLocalDateTime(timeZone) + return localDateTime.toString() + .split(".")[0] // remove ms + .replace("T", " ") // separate date time + } +} \ No newline at end of file diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/client/cathash/BaseClientCatHashService.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/client/cathash/BaseClientCatHashService.kt index 0d970c35..9dad10e7 100644 --- a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/client/cathash/BaseClientCatHashService.kt +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/client/cathash/BaseClientCatHashService.kt @@ -19,6 +19,7 @@ abstract class BaseClientCatHashService(private val baseDirPath: String) : companion object { const val SIZE_OF_CACHED_ICONS = 60 const val MAX_CACHE_SIZE = 5000 + const val CATHASH_ICONS_PATH = "db/cache/cat_hash_icons" } private val fileSystem: FileSystem = FileSystem.SYSTEM @@ -29,13 +30,18 @@ abstract class BaseClientCatHashService(private val baseDirPath: String) : protected abstract fun readRawImage(iconFilePath: String): PlatformImage? fun getImage(userProfile: UserProfile, size: Int): PlatformImage? { - val pubKeyHash = userProfile.pubKeyHash.hexToByteArray() - return getImage( - pubKeyHash, - userProfile.proofOfWork.solution, - userProfile.avatarVersion, - size - ) + try { + val pubKeyHash = userProfile.pubKeyHash!!.hexToByteArray() + return getImage( + pubKeyHash, + userProfile.proofOfWork!!.solution, + userProfile.avatarVersion!!, + size + ) + } catch (e: Exception) { + log.e(e) { "Failed to get image from profile" } + return null + } } override fun getImage( @@ -103,7 +109,7 @@ abstract class BaseClientCatHashService(private val baseDirPath: String) : fun pruneOutdatedProfileIcons(userProfiles: Collection) { if (userProfiles.isEmpty()) return - val iconsDirectory = baseDirPath.toPath().resolve("db/cache/cat_hash_icons") + val iconsDirectory = baseDirPath.toPath().resolve(CATHASH_ICONS_PATH) val versionDirs = fileSystem.listOrNull(iconsDirectory)?.filter { fileSystem.metadata(it).isDirectory } ?: return diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/BasePresenter.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/BasePresenter.kt index 1be5d137..56c74b24 100644 --- a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/BasePresenter.kt +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/BasePresenter.kt @@ -48,6 +48,7 @@ abstract class BasePresenter(private val rootPresenter: MainPresenter?): ViewPre protected var view: Any? = null // Coroutine scope for the presenter protected val presenterScope = CoroutineScope(Dispatchers.Main + Job()) + protected val uiScope = CoroutineScope(Dispatchers.Main) protected val backgroundScope = CoroutineScope(BackgroundDispatcher) private val dependants = if (isRoot()) mutableListOf() else null diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/di/PresentationModule.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/di/PresentationModule.kt index a398336c..701cac82 100644 --- a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/di/PresentationModule.kt +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/di/PresentationModule.kt @@ -21,7 +21,9 @@ import network.bisq.mobile.presentation.ui.uicases.offers.createOffer.CreateOffe import network.bisq.mobile.presentation.ui.uicases.offers.createOffer.ICreateOfferPresenter import network.bisq.mobile.presentation.ui.uicases.offers.takeOffer.* import network.bisq.mobile.presentation.ui.uicases.settings.ISettingsPresenter +import network.bisq.mobile.presentation.ui.uicases.settings.IUserProfileSettingsPresenter import network.bisq.mobile.presentation.ui.uicases.settings.SettingsPresenter +import network.bisq.mobile.presentation.ui.uicases.settings.UserProfileSettingsPresenter import network.bisq.mobile.presentation.ui.uicases.startup.CreateProfilePresenter import network.bisq.mobile.presentation.ui.uicases.startup.IOnboardingPresenter import network.bisq.mobile.presentation.ui.uicases.startup.ITrustedNodeSetupPresenter @@ -58,6 +60,8 @@ val presentationModule = module { single { SettingsPresenter(get(), get()) } bind ISettingsPresenter::class + single { UserProfileSettingsPresenter(get(), get(), get()) } bind IUserProfileSettingsPresenter::class + single { GettingStartedPresenter( get(), diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/components/atoms/SettingsTextField.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/components/atoms/SettingsTextField.kt new file mode 100644 index 00000000..2151022c --- /dev/null +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/components/atoms/SettingsTextField.kt @@ -0,0 +1,60 @@ +package network.bisq.mobile.presentation.ui.components.atoms + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.TextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import network.bisq.mobile.presentation.ui.theme.BisqTheme + +@Composable +fun SettingsTextField( + label: String, + value: String, + editable: Boolean, + onValueChange: (String) -> Unit = {} +) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = label, + color = BisqTheme.colors.grey1, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 4.dp) + ) + TextField( + value = value, + enabled = editable, + onValueChange = onValueChange, + colors = TextFieldDefaults.colors( + disabledContainerColor = BisqTheme.colors.secondaryDisabled, + disabledTextColor = BisqTheme.colors.light5, + focusedTextColor = BisqTheme.colors.light3, + unfocusedTextColor = BisqTheme.colors.secondaryHover, + unfocusedIndicatorColor = BisqTheme.colors.secondary, + focusedIndicatorColor = Color.Transparent, + focusedContainerColor = BisqTheme.colors.secondary, + cursorColor = Color.Blue, + unfocusedContainerColor = BisqTheme.colors.secondary + ), +// fontSize = 14.sp, +// textAlign = TextAlign.Start, + modifier = Modifier + .fillMaxWidth() + .background(BisqTheme.colors.dark1, RoundedCornerShape(8.dp)) + .padding(8.dp) + ) + } +} \ No newline at end of file diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/components/layout/ScrollLayout.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/components/layout/ScrollLayout.kt index 8776b836..9e790008 100644 --- a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/components/layout/ScrollLayout.kt +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/components/layout/ScrollLayout.kt @@ -15,6 +15,7 @@ fun BisqScrollLayout( padding: PaddingValues = PaddingValues(all = BisqUIConstants.ScreenPadding), horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, verticalArrangement: Arrangement.Vertical = Arrangement.Top, + onModifier: ((Modifier) -> Modifier)? = null, // allows to customize modifier settings content: @Composable ColumnScope.() -> Unit ) { Column( @@ -25,6 +26,7 @@ fun BisqScrollLayout( .background(color = BisqTheme.colors.backgroundColor) .padding(padding) .verticalScroll(rememberScrollState()) + .run { onModifier?.invoke(this) ?: this } ) { content() } diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/components/molecules/settings/BreadcrumNavigation.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/components/molecules/settings/BreadcrumNavigation.kt new file mode 100644 index 00000000..d8f8ad17 --- /dev/null +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/components/molecules/settings/BreadcrumNavigation.kt @@ -0,0 +1,37 @@ +package network.bisq.mobile.presentation.ui.components.molecules.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import network.bisq.mobile.presentation.ui.theme.BisqTheme + +@Composable +fun BreadcrumbNavigation( + path: List, + onBreadcrumbClick: (Int) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + path.forEachIndexed { index, menuItem -> + Text( + text = menuItem.label, + style = MaterialTheme.typography.bodyLarge.copy(color = BisqTheme.colors.grey1), + modifier = Modifier.clickable { onBreadcrumbClick(index) } + ) + if (index != path.lastIndex) { + Text(" > ", color = BisqTheme.colors.grey1) // Separator + } + } + } +} \ No newline at end of file diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/components/molecules/settings/SettingsButton.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/components/molecules/settings/SettingsButton.kt new file mode 100644 index 00000000..17a69db1 --- /dev/null +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/components/molecules/settings/SettingsButton.kt @@ -0,0 +1,40 @@ +package network.bisq.mobile.presentation.ui.components.molecules.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import network.bisq.mobile.presentation.ui.theme.BisqTheme + +@Composable +fun SettingsButton(label: String, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(BisqTheme.colors.grey5) + .clickable(onClick = onClick) + .padding(vertical = 12.dp, horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge.copy(color = BisqTheme.colors.light1 , fontSize = 16.sp), + modifier = Modifier.weight(1f) + ) + Text( + text = ">", + textAlign = TextAlign.End, + style = MaterialTheme.typography.bodyLarge.copy(color = BisqTheme.colors.light1, fontSize = 16.sp), + modifier = Modifier.weight(1f) + ) + } +} \ No newline at end of file diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/components/molecules/settings/SettingsMenu.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/components/molecules/settings/SettingsMenu.kt new file mode 100644 index 00000000..04786ae2 --- /dev/null +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/components/molecules/settings/SettingsMenu.kt @@ -0,0 +1,45 @@ +package network.bisq.mobile.presentation.ui.components.molecules.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import network.bisq.mobile.presentation.ui.theme.BisqTheme + +// UI model +sealed class MenuItem(val label: String) { + class Leaf(label: String, val content: @Composable () -> Unit) : MenuItem(label) + class Parent(label: String, val children: List) : MenuItem(label) +} + +@Composable +fun SettingsMenu(menuItem: MenuItem, onNavigate: (MenuItem) -> Unit) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background(BisqTheme.colors.backgroundColor) + .padding(16.dp), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.Start + ) { + when (menuItem) { + is MenuItem.Parent -> menuItem.children.forEach { child -> + SettingsButton(label = child.label, onClick = { onNavigate(child) }) + Spacer(modifier = Modifier.height(8.dp)) + } + else -> { + SettingsButton(label = menuItem.label, onClick = { onNavigate(menuItem) }) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } +} \ No newline at end of file diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/settings/SettingsPresenter.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/settings/SettingsPresenter.kt index a3124353..f592f678 100644 --- a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/settings/SettingsPresenter.kt +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/settings/SettingsPresenter.kt @@ -3,6 +3,7 @@ package network.bisq.mobile.presentation.ui.uicases.settings import network.bisq.mobile.domain.data.repository.SettingsRepository import network.bisq.mobile.presentation.BasePresenter import network.bisq.mobile.presentation.MainPresenter +import network.bisq.mobile.presentation.ui.components.molecules.settings.MenuItem /** * SettingsPresenter with default implementation @@ -22,7 +23,7 @@ open class SettingsPresenter( ) ) return MenuItem.Parent( - label = "Application", + label = "Bisq", children = addCustomSettings(defaultList) ) } diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/settings/SettingsScreen.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/settings/SettingsScreen.kt index 0191ffb8..0beffa99 100644 --- a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/settings/SettingsScreen.kt +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/settings/SettingsScreen.kt @@ -1,114 +1,24 @@ package network.bisq.mobile.presentation.ui.uicases.settings -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import network.bisq.mobile.presentation.ViewPresenter +import network.bisq.mobile.presentation.ui.components.layout.BisqScrollLayout +import network.bisq.mobile.presentation.ui.components.molecules.settings.BreadcrumbNavigation +import network.bisq.mobile.presentation.ui.components.molecules.settings.MenuItem +import network.bisq.mobile.presentation.ui.components.molecules.settings.SettingsMenu import network.bisq.mobile.presentation.ui.helpers.RememberPresenterLifecycle -import network.bisq.mobile.presentation.ui.theme.BisqTheme import org.koin.compose.koinInject - interface ISettingsPresenter: ViewPresenter { fun menuTree(): MenuItem } -// UI model/s -sealed class MenuItem(val label: String) { - class Leaf(label: String, val content: @Composable () -> Unit) : MenuItem(label) - class Parent(label: String, val children: List) : MenuItem(label) -} - -@Composable -fun SettingsMenu(menuItem: MenuItem, onNavigate: (MenuItem) -> Unit) { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - Column( - modifier = Modifier - .fillMaxSize() - .background(BisqTheme.colors.backgroundColor) - .padding(16.dp), - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.Start - ) { - when (menuItem) { - is MenuItem.Parent -> menuItem.children.forEach { child -> - SettingsButton(label = child.label, onClick = { onNavigate(child) }) - Spacer(modifier = Modifier.height(8.dp)) - } - else -> { - SettingsButton(label = menuItem.label, onClick = { onNavigate(menuItem) }) - Spacer(modifier = Modifier.height(8.dp)) - } - } - } - } -} - -@Composable -fun SettingsButton(label: String, onClick: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(BisqTheme.colors.grey5) - .clickable(onClick = onClick) - .padding(vertical = 12.dp, horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = label, - style = MaterialTheme.typography.bodyLarge.copy(color = BisqTheme.colors.light1 , fontSize = 16.sp), - modifier = Modifier.weight(1f) - ) - Text( - text = ">", - textAlign = TextAlign.End, - style = MaterialTheme.typography.bodyLarge.copy(color = BisqTheme.colors.light1, fontSize = 16.sp), - modifier = Modifier.weight(1f) - ) - } -} - -@Composable -fun BreadcrumbNavigation( - path: List, - onBreadcrumbClick: (Int) -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - path.forEachIndexed { index, menuItem -> - Text( - text = menuItem.label, - style = MaterialTheme.typography.bodyLarge.copy(color = BisqTheme.colors.grey1), - modifier = Modifier.clickable { onBreadcrumbClick(index) } - ) - if (index != path.lastIndex) { - Text(" > ", color = BisqTheme.colors.grey1) // Separator - } - } - } -} - @Composable fun SettingsScreen(isTabSelected: Boolean) { -// val currentMenu = remember { mutableStateOf(menuTree) } val settingsPresenter: ISettingsPresenter = koinInject() val menuTree: MenuItem = settingsPresenter.menuTree() val currentMenu = remember { mutableStateOf(menuTree) } @@ -124,7 +34,8 @@ fun SettingsScreen(isTabSelected: Boolean) { } } - Column(modifier = Modifier.fillMaxSize()) { + // Column is used leaving the possibility to the leaf views to set the scrolling as they please + Column { BreadcrumbNavigation(path = menuPath) { index -> currentMenu.value = menuPath[index] // TODO might need complex index logic? @@ -134,10 +45,10 @@ fun SettingsScreen(isTabSelected: Boolean) { if (selectedLeaf.value == null) { SettingsMenu(menuItem = currentMenu.value) { selectedItem -> + menuPath.add(selectedItem) if (selectedItem is MenuItem.Parent) { selectedLeaf.value = null currentMenu.value = selectedItem - menuPath.add(selectedItem) } else { selectedLeaf.value = selectedItem as MenuItem.Leaf } diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/settings/UserProfileSettingsPresenter.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/settings/UserProfileSettingsPresenter.kt new file mode 100644 index 00000000..61fc39f0 --- /dev/null +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/settings/UserProfileSettingsPresenter.kt @@ -0,0 +1,140 @@ +package network.bisq.mobile.presentation.ui.uicases.settings + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import network.bisq.mobile.client.replicated_model.user.profile.UserProfile +import network.bisq.mobile.domain.PlatformImage +import network.bisq.mobile.domain.data.model.User +import network.bisq.mobile.domain.data.repository.UserRepository +import network.bisq.mobile.domain.service.user_profile.UserProfileServiceFacade +import network.bisq.mobile.presentation.BasePresenter +import network.bisq.mobile.presentation.MainPresenter +import network.bisq.mobile.utils.DateUtils + +class UserProfileSettingsPresenter( + private val userProfileServiceFacade: UserProfileServiceFacade, + private val userRepository: UserRepository, + mainPresenter: MainPresenter): BasePresenter(mainPresenter), IUserProfileSettingsPresenter { + + companion object { + const val DEFAULT_UNKNOWN_VALUE = "N/A" + } + + private val _uniqueAvatar = MutableStateFlow(userRepository.data.value?.uniqueAvatar) + override val uniqueAvatar: StateFlow get() = _uniqueAvatar + + private val _reputation = MutableStateFlow(DEFAULT_UNKNOWN_VALUE) + override val reputation: StateFlow = _reputation + private val _lastUserActivity = MutableStateFlow(DEFAULT_UNKNOWN_VALUE) + override val lastUserActivity: StateFlow = _lastUserActivity + private val _profileAge = MutableStateFlow(DEFAULT_UNKNOWN_VALUE) + override val profileAge: StateFlow = _profileAge + private val _profileId = MutableStateFlow(DEFAULT_UNKNOWN_VALUE) + override val profileId: StateFlow = _profileId + private val _nickname = MutableStateFlow(DEFAULT_UNKNOWN_VALUE) + override val nickname: StateFlow = _nickname + private val _botId = MutableStateFlow(DEFAULT_UNKNOWN_VALUE) + override val botId: StateFlow = _botId + private val _tradeTerms = MutableStateFlow("") + override val tradeTerms: StateFlow = _tradeTerms + private val _statement = MutableStateFlow("") + override val statement: StateFlow = _statement + + private val _showLoading = MutableStateFlow(false) + override val showLoading: StateFlow = _showLoading + + override fun onViewAttached() { + super.onViewAttached() + backgroundScope.launch { + userProfileServiceFacade.getSelectedUserProfile()?.let { it -> +// _reputation.value = it.reputation // TODO reputation? + setProfileAge(it) + setProfileId(it) + setBotId(it) + setNickname(it) + } + userRepository.fetch()?.let { + // The following should be local to the app + setLastActivity(it) + setTradeTerms(it) + setStatement(it) + } + _uniqueAvatar.value = userRepository.fetch()?.uniqueAvatar + } + } + + private fun setStatement(user: User) { + _statement.value = user.statement ?: DEFAULT_UNKNOWN_VALUE + } + + private fun setTradeTerms(user: User) { + _tradeTerms.value = user.tradeTerms ?: DEFAULT_UNKNOWN_VALUE + } + + private fun setLastActivity(user: User) { + _lastUserActivity.value = user.lastActivity?.let { DateUtils.toDateString(it) } ?: DEFAULT_UNKNOWN_VALUE + } + + private fun setBotId(userProfile: UserProfile) { + _botId.value = userProfile.nym ?: DEFAULT_UNKNOWN_VALUE + } + + private fun setNickname(userProfile: UserProfile) { + _nickname.value = userProfile.nickName ?: DEFAULT_UNKNOWN_VALUE + } + + private fun setProfileId(userProfile: UserProfile) { + _profileId.value = userProfile.id ?: DEFAULT_UNKNOWN_VALUE + } + + private fun setProfileAge(userProfile: UserProfile) { + userProfile.publishDate?.let { pd -> + _profileAge.value = DateUtils.periodFrom(pd).let { + listOfNotNull( + if (it.first > 0) "${it.first} years" else null, + if (it.second > 0) "${it.second} months" else null, + if (it.third > 0) "${it.third} days" else null + ).ifEmpty { listOf("less than a day") }.joinToString(", ") + } + } ?: DEFAULT_UNKNOWN_VALUE + } + + override fun onDelete() { + TODO("Not yet implemented") + } + + override fun onSave() { + backgroundScope.launch { + setShowLoading(true) + try { + userRepository.fetch()!!.let { + it.statement = statement.value + it.tradeTerms = tradeTerms.value + userRepository.update(it) + } + // avoid flicker + delay(500L) + } catch (e: Exception) { + log.e(e) { "Failed to save user profile settings" } + } finally { + setShowLoading(false) + } + } + } + + override fun updateTradeTerms(it: String) { + _tradeTerms.value = it + } + + override fun updateStatement(it: String) { + _statement.value = it + } + + private fun setShowLoading(show: Boolean = true) { + uiScope.launch { + _showLoading.value = show + } + } +} \ No newline at end of file diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/settings/UserProfileSettingsScreen.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/settings/UserProfileSettingsScreen.kt index bb4cd74f..2089211d 100644 --- a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/settings/UserProfileSettingsScreen.kt +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/settings/UserProfileSettingsScreen.kt @@ -1,24 +1,190 @@ package network.bisq.mobile.presentation.ui.uicases.settings -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import bisqapps.shared.presentation.generated.resources.Res +import bisqapps.shared.presentation.generated.resources.img_bitcoin_payment_waiting +import kotlinx.coroutines.flow.StateFlow +import network.bisq.mobile.domain.PlatformImage +import network.bisq.mobile.presentation.ViewPresenter +import network.bisq.mobile.presentation.ui.components.atoms.CircularLoadingImage +import network.bisq.mobile.presentation.ui.components.atoms.SettingsTextField +import network.bisq.mobile.presentation.ui.components.atoms.icons.UserIcon +import network.bisq.mobile.presentation.ui.components.layout.BisqScrollLayout import network.bisq.mobile.presentation.ui.helpers.RememberPresenterLifecycle import network.bisq.mobile.presentation.ui.theme.BisqTheme +import org.koin.compose.koinInject + +interface IUserProfileSettingsPresenter: ViewPresenter { + + val reputation: StateFlow + val lastUserActivity: StateFlow + val profileAge: StateFlow + val profileId: StateFlow + val nickname: StateFlow + val botId: StateFlow + val statement: StateFlow + val tradeTerms: StateFlow + + val uniqueAvatar: StateFlow + + val showLoading: StateFlow + + fun onDelete() + fun onSave() + fun updateTradeTerms(it: String) + fun updateStatement(it: String) +} @Composable fun UserProfileSettingsScreen() { + val presenter: IUserProfileSettingsPresenter = koinInject() + + + val botId = presenter.botId.collectAsState().value + val nickname = presenter.nickname.collectAsState().value + val profileId = presenter.profileId.collectAsState().value + val profileAge = presenter.profileAge.collectAsState().value + val lastUserActivity = presenter.lastUserActivity.collectAsState().value + val reputation = presenter.reputation.collectAsState().value + val statement = presenter.statement.collectAsState().value + val tradeTerms = presenter.tradeTerms.collectAsState().value + + val showLoading = presenter.showLoading.collectAsState().value + + RememberPresenterLifecycle(presenter) + + // Bot Icon + Spacer(modifier = Modifier.height(16.dp)) + + Column(modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally) { + + UserProfileScreenHeader(presenter) + + Spacer(modifier = Modifier.height(16.dp)) + BisqScrollLayout(onModifier = { modifier -> modifier.weight(1f) }) { + SettingsTextField(label = "Bot ID", value = botId, editable = false) + + Spacer(modifier = Modifier.height(8.dp)) + + SettingsTextField(label = "Nickname", value = nickname, editable = false) + + Spacer(modifier = Modifier.height(8.dp)) + + SettingsTextField(label = "Profile ID", value = profileId, editable = false) -// RememberPresenterLifecycle() - Column(modifier = Modifier.fillMaxSize()) { - Text( - text = "UserProfileSettingsScreen", - style = MaterialTheme.typography.bodyLarge.copy(color = BisqTheme.colors.light1 , fontSize = 16.sp), - modifier = Modifier.weight(1f) + Spacer(modifier = Modifier.height(8.dp)) + + SettingsTextField(label = "Profile age", value = profileAge, editable = false) + + Spacer(modifier = Modifier.height(8.dp)) + + SettingsTextField(label = "Last user activity", value = lastUserActivity, editable = false) + + Spacer(modifier = Modifier.height(8.dp)) + + // Reputation + SettingsTextField(label = "Reputation", value = reputation, editable = false) + + Spacer(modifier = Modifier.height(16.dp)) + + // Statement + SettingsTextField( + label = "Statement", + value = statement, + editable = true, + onValueChange = { presenter.updateStatement(it) } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Trade Terms + SettingsTextField( + label = "Trade terms", + value = tradeTerms, + editable = true, + onValueChange = { presenter.updateTradeTerms(it) } + ) + } + Spacer(modifier = Modifier.height(16.dp)) + UserProfileScreenFooter(presenter, showLoading) + } +} + +@Composable +private fun UserProfileScreenHeader(presenter: IUserProfileSettingsPresenter) { + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .padding(8.dp) + .fillMaxWidth() + .background(BisqTheme.colors.dark1), + contentAlignment = Alignment.Center + ) { + UserIcon( + presenter.uniqueAvatar.value, + modifier = Modifier.size(72.dp) ) } +} + +@Composable +private fun UserProfileScreenFooter(presenter: IUserProfileSettingsPresenter, showLoading: Boolean) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + if (showLoading) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f)) { + CircularLoadingImage( + // TODO specific image? + image = Res.drawable.img_bitcoin_payment_waiting, + isLoading = !showLoading + ) + } + } else { + // TODO uncomment when delete profile gets implemented + // Button( + // onClick = presenter::onDelete, + // colors = ButtonDefaults.buttonColors( + // containerColor = BisqTheme.colors.danger, + // contentColor = BisqTheme.colors.light1 + // ), + // modifier = Modifier.weight(1f).wrapContentWidth().padding(horizontal = 8.dp) + // ) { + // Text("Delete profile", fontSize = 14.sp) + // } + // + // Spacer(modifier = Modifier.width(16.dp)) + + Button( + onClick = presenter::onSave, + colors = ButtonDefaults.buttonColors( + containerColor = BisqTheme.colors.primary, + contentColor = BisqTheme.colors.light1 + ), + // TODO fixed height to match both cases? + modifier = Modifier.weight(1f) + .wrapContentWidth() + .padding(horizontal = 8.dp) + ) { + Text("Save", fontSize = 14.sp) + } + } + } } \ No newline at end of file diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/startup/CreateProfilePresenter.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/startup/CreateProfilePresenter.kt index dae83888..b7be29e9 100644 --- a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/startup/CreateProfilePresenter.kt +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/startup/CreateProfilePresenter.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import kotlinx.datetime.Clock import network.bisq.mobile.domain.PlatformImage import network.bisq.mobile.domain.data.model.User import network.bisq.mobile.domain.data.repository.UserRepository @@ -128,7 +129,10 @@ open class CreateProfilePresenter( setNym(nym) setProfileIcon(profileIcon) backgroundScope.launch { - userRepository.update(User().apply { uniqueAvatar = profileIcon }) + userRepository.update(User().apply { + uniqueAvatar = profileIcon + lastActivity = Clock.System.now().toEpochMilliseconds() + }) } } setGenerateKeyPairInProgress(false) diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/startup/SplashPresenter.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/startup/SplashPresenter.kt index 2e70f5af..5aac181e 100644 --- a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/startup/SplashPresenter.kt +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/startup/SplashPresenter.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import kotlinx.datetime.Clock import network.bisq.mobile.domain.data.model.Settings import network.bisq.mobile.domain.data.model.User import network.bisq.mobile.domain.data.repository.SettingsRepository @@ -30,6 +31,10 @@ open class SplashPresenter( override fun onViewAttached() { job = backgroundScope.launch { + userRepository.fetch()?.let { + it.lastActivity = Clock.System.now().toEpochMilliseconds() + userRepository.update(it) + } progress.collect { value -> when { value == 1.0f -> navigateToNextScreen()