From 609bd0ff15c7f19edff2333e44b87ada769f50b3 Mon Sep 17 00:00:00 2001 From: KhooLy <73142442+KhooLy@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:38:57 +0300 Subject: [PATCH] Migrate Profile list/switch/edit to shared Compose Multiplatform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ProfileListScreen (avatar grid, manage mode, PIN unlock overlay with biometric fast-path) and ProfileEditScreen (name, avatar upload, PIN, biometric toggle, delete) under shared/commonMain, extending the existing ProfileContracts/Store stub rather than replacing it. AndroidProfileDataSource gains PIN-gated profile switching, save/delete, all backed by the existing ProfileManager/PinHasher — no new business logic. The avatar image picker and BiometricPrompt stay Android-side and are bubbled into the shared screen via plain callbacks. Mobile now routes Screen.Profiles through the shared ProfileList destination; Android TV keeps native ProfileScreen/ProfileEditScreen since TV migration is deferred. --- .../ui/profile/AndroidProfileDataSource.kt | 83 ++++- .../com/fluxa/app/ui/routes/AppRoutesHost.kt | 41 +++ .../kotlin/com/fluxa/app/shared/FluxaApp.kt | 42 ++- .../com/fluxa/app/shared/FluxaAppHost.kt | 62 ++++ .../com/fluxa/app/shared/FluxaAppState.kt | 7 +- .../feature/profile/ProfileContracts.kt | 35 +- .../feature/profile/ProfileEditScreen.kt | 242 ++++++++++++++ .../feature/profile/ProfileListScreen.kt | 315 ++++++++++++++++++ .../shared/feature/profile/ProfileStore.kt | 14 + 9 files changed, 833 insertions(+), 8 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/profile/ProfileEditScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/profile/ProfileListScreen.kt diff --git a/app/src/main/java/com/fluxa/app/ui/profile/AndroidProfileDataSource.kt b/app/src/main/java/com/fluxa/app/ui/profile/AndroidProfileDataSource.kt index 8fb1a43..7d0996f 100644 --- a/app/src/main/java/com/fluxa/app/ui/profile/AndroidProfileDataSource.kt +++ b/app/src/main/java/com/fluxa/app/ui/profile/AndroidProfileDataSource.kt @@ -1,20 +1,33 @@ package com.fluxa.app.ui.profile import android.content.SharedPreferences +import com.fluxa.app.data.local.PinHasher import com.fluxa.app.data.local.ProfileManager import com.fluxa.app.data.local.UserProfile import com.fluxa.app.shared.feature.profile.ProfileDataSource +import com.fluxa.app.shared.feature.profile.ProfileEditUiModel import com.fluxa.app.shared.feature.profile.ProfileUiModel import com.fluxa.app.shared.feature.profile.ProfileUiState import com.fluxa.app.shared.feature.profile.SettingsUiState import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine class AndroidProfileDataSource( private val profileManager: ProfileManager ) : ProfileDataSource { - override fun observeProfiles(): Flow = profilesFlow() + + private val pendingPinId = MutableStateFlow(null) + private val pinError = MutableStateFlow(false) + + override fun observeProfiles(): Flow = combine(profilesFlow(), pendingPinId, pinError) { state, pendingId, error -> + state.copy( + pendingPinProfile = state.profiles.firstOrNull { it.id == pendingId }, + pinError = error + ) + } override fun observeSettings(profileId: String): Flow = callbackFlow { val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> @@ -26,7 +39,69 @@ class AndroidProfileDataSource( } override suspend fun selectProfile(id: String) { - profileManager.setLastActiveProfile(profileManager.getProfiles().firstOrNull { it.id == id }) + val target = profileManager.getProfiles().firstOrNull { it.id == id } ?: return + if (target.pinHash.isNullOrBlank()) { + profileManager.setLastActiveProfile(target) + } else { + pendingPinId.value = target.id + pinError.value = false + } + } + + override suspend fun attemptPin(profileId: String, pin: String) { + val target = profileManager.getProfiles().firstOrNull { it.id == profileId } ?: return + if (PinHasher.hash(pin) == target.pinHash) { + profileManager.setLastActiveProfile(target) + pendingPinId.value = null + pinError.value = false + } else { + pinError.value = true + } + } + + override suspend fun confirmBiometricUnlock(profileId: String) { + val target = profileManager.getProfiles().firstOrNull { it.id == profileId } ?: return + profileManager.setLastActiveProfile(target) + pendingPinId.value = null + pinError.value = false + } + + override suspend fun cancelPinUnlock() { + pendingPinId.value = null + pinError.value = false + } + + override suspend fun deleteProfile(id: String) { + profileManager.deleteProfileById(id) + } + + override suspend fun saveProfile(edit: ProfileEditUiModel): String { + val existing = edit.id?.let { id -> profileManager.getProfiles().firstOrNull { it.id == id } } + val newPin = edit.newPin + val pinHash = when { + newPin != null -> PinHasher.hash(newPin) + edit.keepExistingPin -> existing?.pinHash + else -> null + } + val profile = existing?.copy( + profileName = edit.name, + avatarUrl = edit.avatarUrl, + pinHash = pinHash, + biometricEnabled = edit.biometricEnabled + ) ?: UserProfile( + id = java.util.UUID.randomUUID().toString(), + email = edit.name, + profileName = edit.name, + authKey = "", + isGuest = false, + language = "en", + avatarUrl = edit.avatarUrl, + pinHash = pinHash, + biometricEnabled = edit.biometricEnabled, + localAddons = listOf("https://v3-cinemeta.strem.io/manifest.json") + ) + profileManager.saveProfile(profile) + return profile.id } override suspend fun updateSettings(profileId: String, settings: SettingsUiState) { @@ -62,7 +137,9 @@ private fun UserProfile.toProfileUiModel(): ProfileUiModel = ProfileUiModel( name = profileName?.takeIf { it.isNotBlank() } ?: email, avatarUrl = avatarUrl, language = safeLanguage, - accentColorArgb = safeAccentColorArgb.toLong() and 0xffffffffL + accentColorArgb = safeAccentColorArgb.toLong() and 0xffffffffL, + hasPin = !pinHash.isNullOrBlank(), + biometricEnabled = biometricEnabled == true ) private fun UserProfile.toSettingsUiState(): SettingsUiState = SettingsUiState( diff --git a/app/src/main/java/com/fluxa/app/ui/routes/AppRoutesHost.kt b/app/src/main/java/com/fluxa/app/ui/routes/AppRoutesHost.kt index 96f0af6..4cfbba6 100644 --- a/app/src/main/java/com/fluxa/app/ui/routes/AppRoutesHost.kt +++ b/app/src/main/java/com/fluxa/app/ui/routes/AppRoutesHost.kt @@ -24,8 +24,14 @@ import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.ui.unit.dp +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.media3.exoplayer.ExoPlayer import com.fluxa.app.data.local.OfflineDownloadManager @@ -57,7 +63,12 @@ import com.fluxa.app.ui.catalog.tvNavDestination import com.fluxa.app.ui.catalog.WatchlistScreen import com.fluxa.app.ui.catalog.WelcomeScreen import com.fluxa.app.ui.catalog.FluxaDimensions +import com.fluxa.app.ui.catalog.BiometricLockHelper +import com.fluxa.app.ui.catalog.copyProfileImageToLocalUri import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Composable internal fun AppRoutesHost( @@ -124,12 +135,24 @@ internal fun AppRoutesHost( is Screen.Calendar -> com.fluxa.app.shared.FluxaDestination.Calendar is Screen.AddonStore -> com.fluxa.app.shared.FluxaDestination.AddonStore is Screen.Welcome, is Screen.Login -> com.fluxa.app.shared.FluxaDestination.Auth + is Screen.Profiles -> com.fluxa.app.shared.FluxaDestination.ProfileList else -> null } } else { null } + var pendingAvatarPicked by remember { mutableStateOf<((String?) -> Unit)?>(null) } + val avatarPicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + val callback = pendingAvatarPicked + pendingAvatarPicked = null + if (uri == null || callback == null) return@rememberLauncherForActivityResult + coroutineScope.launch { + val copied = withContext(Dispatchers.IO) { copyProfileImageToLocalUri(context, uri) ?: uri.toString() } + callback(copied) + } + } + if (deviceType == DeviceType.Mobile) { com.fluxa.app.shared.FluxaAppHost( platformServices = androidFluxaPlatformServices!!, @@ -144,6 +167,24 @@ internal fun AppRoutesHost( onAuthBackRequested = navigateBackSafely, onAuthCompleted = { navigator.navigateTo(Screen.Home, true) }, authStartOnNuvio = (currentScreen as? Screen.Login)?.startOnNuvio == true, + biometricAvailable = BiometricLockHelper.isAvailable(context), + onPickAvatarRequested = { onPicked -> + pendingAvatarPicked = onPicked + avatarPicker.launch("image/*") + }, + onBiometricAuthRequested = { profile, onResult -> + val activity = context as? androidx.fragment.app.FragmentActivity + if (activity != null) { + BiometricLockHelper.authenticate( + activity = activity, + lang = profile.language, + onSuccess = { onResult(true) }, + onFailure = { onResult(false) } + ) + } else { + onResult(false) + } + }, nuvioIcon = { androidx.compose.foundation.Image( painter = androidx.compose.ui.res.painterResource(id = com.fluxa.app.R.drawable.ic_nuvio), diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaApp.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaApp.kt index f682520..881b338 100644 --- a/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaApp.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaApp.kt @@ -59,6 +59,11 @@ import com.fluxa.app.shared.feature.discover.DiscoverScreen import com.fluxa.app.shared.feature.discover.DiscoverUiState import com.fluxa.app.shared.feature.library.LibraryScreen import com.fluxa.app.shared.feature.library.LibraryUiState +import com.fluxa.app.shared.feature.profile.ProfileAction +import com.fluxa.app.shared.feature.profile.ProfileEditScreen +import com.fluxa.app.shared.feature.profile.ProfileEditTarget +import com.fluxa.app.shared.feature.profile.ProfileEditUiModel +import com.fluxa.app.shared.feature.profile.ProfileListScreen import com.fluxa.app.shared.feature.profile.ProfileSettingsScreen import com.fluxa.app.shared.feature.profile.ProfileUiState import com.fluxa.app.shared.feature.profile.SettingsUiState @@ -91,14 +96,16 @@ enum class FluxaDestination(val titleKey: String) { Library("nav.library"), Settings("nav.settings"), AddonStore("auto.addons"), - Auth("auth.log_in") + Auth("auth.log_in"), + ProfileList("auto.profile") } data class FluxaAppUiState( val language: String? = null, val destination: FluxaDestination = FluxaDestination.Home, val catalogHome: CatalogHomeUiState = CatalogHomeUiState(), - val selectedDetail: CatalogItemUiModel? = null + val selectedDetail: CatalogItemUiModel? = null, + val editingProfile: ProfileEditTarget? = null ) @Composable @@ -128,6 +135,15 @@ fun FluxaApp( onAuthAction: (AuthAction) -> Unit = {}, nuvioIcon: @Composable () -> Unit = {}, stremioIcon: @Composable () -> Unit = {}, + onProfileListAction: (ProfileAction) -> Unit = {}, + onProfileBiometricRequested: (com.fluxa.app.shared.feature.profile.ProfileUiModel) -> Unit = {}, + profileEditAvatarUrl: String? = null, + onPickAvatarClick: () -> Unit = {}, + onRemoveAvatarClick: () -> Unit = {}, + onProfileSave: (ProfileEditUiModel) -> Unit = {}, + onProfileDelete: (() -> Unit)? = null, + onProfileEditCancel: () -> Unit = {}, + biometricAvailable: Boolean = false, showNavigationBar: Boolean = true, modifier: Modifier = Modifier ) { @@ -145,6 +161,20 @@ fun FluxaApp( ) } when { + state.editingProfile != null && profileState != null -> ProfileEditScreen( + initialProfile = (state.editingProfile as? ProfileEditTarget.Existing)?.let { target -> + profileState.profiles.firstOrNull { it.id == target.id } + }, + avatarUrl = profileEditAvatarUrl, + biometricAvailable = biometricAvailable, + language = state.language, + onPickAvatarClick = onPickAvatarClick, + onRemoveAvatarClick = onRemoveAvatarClick, + onSave = onProfileSave, + onDelete = onProfileDelete, + onCancel = onProfileEditCancel, + modifier = Modifier.weight(1f) + ) state.selectedDetail != null && detailState != null -> DetailScreen( state = detailState, language = state.language, @@ -208,6 +238,14 @@ fun FluxaApp( stremioIcon = stremioIcon, modifier = Modifier.weight(1f) ) + state.destination == FluxaDestination.ProfileList && profileState != null -> ProfileListScreen( + state = profileState, + language = state.language, + biometricAvailable = biometricAvailable, + onAction = onProfileListAction, + onBiometricRequested = onProfileBiometricRequested, + modifier = Modifier.weight(1f) + ) state.destination == FluxaDestination.Home -> FluxaHomeContent( state = state, onCatalogAction = onCatalogAction, diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaAppHost.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaAppHost.kt index 7b03b97..24e978a 100644 --- a/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaAppHost.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaAppHost.kt @@ -4,8 +4,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import com.fluxa.app.shared.feature.addonstore.AddonStoreAction import com.fluxa.app.shared.feature.addonstore.AddonStoreDataSource @@ -33,9 +35,13 @@ import com.fluxa.app.shared.feature.library.LibraryStore import com.fluxa.app.shared.feature.search.SearchAction import com.fluxa.app.shared.feature.search.SearchDataSource import com.fluxa.app.shared.feature.search.SearchStore +import com.fluxa.app.shared.feature.profile.ProfileAction import com.fluxa.app.shared.feature.profile.ProfileDataSource +import com.fluxa.app.shared.feature.profile.ProfileEditTarget +import com.fluxa.app.shared.feature.profile.ProfileEditUiModel import com.fluxa.app.shared.feature.profile.ProfileSettingsStore import com.fluxa.app.shared.feature.profile.ProfileStore +import com.fluxa.app.shared.feature.profile.ProfileUiModel import com.fluxa.app.shared.platform.FluxaAddonStoreServices import com.fluxa.app.shared.platform.FluxaAuthServices import com.fluxa.app.shared.platform.FluxaDetailServices @@ -62,6 +68,9 @@ fun FluxaAppHost( authStartOnNuvio: Boolean = false, nuvioIcon: @Composable () -> Unit = {}, stremioIcon: @Composable () -> Unit = {}, + biometricAvailable: Boolean = false, + onPickAvatarRequested: (onPicked: (String?) -> Unit) -> Unit = {}, + onBiometricAuthRequested: (ProfileUiModel, onResult: (Boolean) -> Unit) -> Unit = { _, _ -> }, modifier: Modifier = Modifier ) { FluxaAppHost( @@ -86,6 +95,9 @@ fun FluxaAppHost( authStartOnNuvio = authStartOnNuvio, nuvioIcon = nuvioIcon, stremioIcon = stremioIcon, + biometricAvailable = biometricAvailable, + onPickAvatarRequested = onPickAvatarRequested, + onBiometricAuthRequested = onBiometricAuthRequested, modifier = modifier ) } @@ -113,6 +125,9 @@ fun FluxaAppHost( authStartOnNuvio: Boolean = false, nuvioIcon: @Composable () -> Unit = {}, stremioIcon: @Composable () -> Unit = {}, + biometricAvailable: Boolean = false, + onPickAvatarRequested: (onPicked: (String?) -> Unit) -> Unit = {}, + onBiometricAuthRequested: (ProfileUiModel, onResult: (Boolean) -> Unit) -> Unit = { _, _ -> }, modifier: Modifier = Modifier ) { val scope = rememberCoroutineScope() @@ -155,6 +170,13 @@ fun FluxaAppHost( } val authState = authStore?.state?.collectAsState()?.value val appState = rememberFluxaAppState() + var profileAvatarUrl by remember(appState.uiState.editingProfile) { + val target = appState.uiState.editingProfile + val initial = (target as? ProfileEditTarget.Existing)?.let { existing -> + profileState?.profiles?.firstOrNull { it.id == existing.id }?.avatarUrl + } + mutableStateOf(initial) + } val selectedDetail = appState.uiState.selectedDetail val detailStore = selectedDetail?.let { item -> detailDataSource?.let { source -> @@ -296,6 +318,46 @@ fun FluxaAppHost( }, nuvioIcon = nuvioIcon, stremioIcon = stremioIcon, + onProfileListAction = { action -> + when (action) { + ProfileAction.AddRequested -> { + profileAvatarUrl = null + appState.beginProfileEdit(ProfileEditTarget.New) + } + is ProfileAction.EditRequested -> { + profileAvatarUrl = action.profile.avatarUrl + appState.beginProfileEdit(ProfileEditTarget.Existing(action.profile.id)) + } + else -> scope.launch { profileStore?.dispatch(action) } + } + }, + onProfileBiometricRequested = { profile -> + onBiometricAuthRequested(profile) { success -> + if (success) { + scope.launch { profileDataSource?.confirmBiometricUnlock(profile.id) } + } + } + }, + profileEditAvatarUrl = profileAvatarUrl, + onPickAvatarClick = { onPickAvatarRequested { url -> profileAvatarUrl = url } }, + onRemoveAvatarClick = { profileAvatarUrl = null }, + onProfileSave = { edit -> + scope.launch { + profileStore?.saveProfile(edit) + appState.beginProfileEdit(null) + } + }, + onProfileDelete = (appState.uiState.editingProfile as? ProfileEditTarget.Existing)?.let { existing -> + { + scope.launch { + profileStore?.deleteProfile(existing.id) + appState.beginProfileEdit(null) + } + Unit + } + }, + onProfileEditCancel = { appState.beginProfileEdit(null) }, + biometricAvailable = biometricAvailable, showNavigationBar = showNavigationBar, modifier = modifier ) diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaAppState.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaAppState.kt index 2af290b..9af0c75 100644 --- a/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaAppState.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaAppState.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.Composable import com.fluxa.app.shared.feature.catalog.CatalogHomeUiState import com.fluxa.app.shared.feature.catalog.CatalogItemUiModel +import com.fluxa.app.shared.feature.profile.ProfileEditTarget @Stable class FluxaAppState internal constructor(initialState: FluxaAppUiState) { @@ -15,13 +16,17 @@ class FluxaAppState internal constructor(initialState: FluxaAppUiState) { private set fun selectDestination(destination: FluxaDestination) { - uiState = uiState.copy(destination = destination, selectedDetail = null) + uiState = uiState.copy(destination = destination, selectedDetail = null, editingProfile = null) } fun selectDetail(item: CatalogItemUiModel) { uiState = uiState.copy(selectedDetail = item) } + fun beginProfileEdit(target: ProfileEditTarget?) { + uiState = uiState.copy(editingProfile = target) + } + fun updateCatalogHome(catalogHome: CatalogHomeUiState) { uiState = uiState.copy(catalogHome = catalogHome) } diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/profile/ProfileContracts.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/profile/ProfileContracts.kt index 42ad6e2..3785866 100644 --- a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/profile/ProfileContracts.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/profile/ProfileContracts.kt @@ -7,13 +7,17 @@ data class ProfileUiModel( val name: String, val avatarUrl: String?, val language: String, - val accentColorArgb: Long + val accentColorArgb: Long, + val hasPin: Boolean = false, + val biometricEnabled: Boolean = false ) data class ProfileUiState( val activeProfile: ProfileUiModel? = null, val profiles: List = emptyList(), - val isLoading: Boolean = false + val isLoading: Boolean = false, + val pendingPinProfile: ProfileUiModel? = null, + val pinError: Boolean = false ) data class SettingsUiState( @@ -25,9 +29,36 @@ data class SettingsUiState( val preferredSubtitleLanguage: String = "none" ) +data class ProfileEditUiModel( + val id: String? = null, + val name: String, + val avatarUrl: String?, + val newPin: String? = null, + val keepExistingPin: Boolean = false, + val biometricEnabled: Boolean +) + +sealed interface ProfileAction { + data class Selected(val profile: ProfileUiModel) : ProfileAction + data object AddRequested : ProfileAction + data class EditRequested(val profile: ProfileUiModel) : ProfileAction + data class PinAttempt(val profileId: String, val pin: String) : ProfileAction + data object PinCancelled : ProfileAction +} + +sealed interface ProfileEditTarget { + data object New : ProfileEditTarget + data class Existing(val id: String) : ProfileEditTarget +} + interface ProfileDataSource { fun observeProfiles(): Flow fun observeSettings(profileId: String): Flow suspend fun selectProfile(id: String) suspend fun updateSettings(profileId: String, settings: SettingsUiState) + suspend fun attemptPin(profileId: String, pin: String) + suspend fun confirmBiometricUnlock(profileId: String) + suspend fun cancelPinUnlock() + suspend fun deleteProfile(id: String) + suspend fun saveProfile(edit: ProfileEditUiModel): String } diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/profile/ProfileEditScreen.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/profile/ProfileEditScreen.kt new file mode 100644 index 0000000..6e31b64 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/profile/ProfileEditScreen.kt @@ -0,0 +1,242 @@ +package com.fluxa.app.shared.feature.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.fluxa.app.common.AppStrings +import com.fluxa.app.shared.image.FluxaRemoteImage +import com.fluxa.app.ui.catalog.FluxaColors + +@Composable +fun ProfileEditScreen( + initialProfile: ProfileUiModel?, + avatarUrl: String?, + biometricAvailable: Boolean, + language: String?, + onPickAvatarClick: () -> Unit, + onRemoveAvatarClick: () -> Unit, + onSave: (ProfileEditUiModel) -> Unit, + onDelete: (() -> Unit)?, + onCancel: () -> Unit, + modifier: Modifier = Modifier +) { + var name by remember(initialProfile?.id) { mutableStateOf(initialProfile?.name.orEmpty()) } + var pin by remember(initialProfile?.id) { mutableStateOf("") } + var removePin by remember(initialProfile?.id) { mutableStateOf(false) } + var biometricEnabled by remember(initialProfile?.id) { mutableStateOf(initialProfile?.biometricEnabled == true) } + + val pinValid = pin.isEmpty() || pin.length == 4 + val willHavePin = !removePin && (pin.length == 4 || initialProfile?.hasPin == true) + + Column(modifier = modifier.fillMaxSize().background(Color.Black).verticalScroll(rememberScrollState()).padding(horizontal = 20.dp)) { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier.size(40.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.05f)).clickable(onClick = onCancel), + contentAlignment = Alignment.Center + ) { + Text("←", color = Color.White) + } + Spacer(Modifier.width(12.dp)) + Text( + AppStrings.t(language, if (initialProfile == null) "profiles.add" else "profiles.edit"), + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + modifier = Modifier.weight(1f) + ) + TextButton( + onClick = { + onSave( + ProfileEditUiModel( + id = initialProfile?.id, + name = name, + avatarUrl = avatarUrl, + newPin = pin.takeIf { it.length == 4 }, + keepExistingPin = !removePin && pin.length != 4 && initialProfile?.hasPin == true, + biometricEnabled = willHavePin && biometricEnabled + ) + ) + }, + enabled = name.isNotBlank() && pinValid + ) { + Text( + AppStrings.t(language, "profiles.done"), + fontWeight = FontWeight.Bold, + color = if (name.isNotBlank() && pinValid) Color.White else Color.White.copy(alpha = 0.35f) + ) + } + } + + Spacer(Modifier.height(8.dp)) + + Box(contentAlignment = Alignment.BottomEnd, modifier = Modifier.size(140.dp)) { + Box( + modifier = Modifier.size(140.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center + ) { + if (!avatarUrl.isNullOrBlank()) { + FluxaRemoteImage( + imageUrl = avatarUrl, + cacheKey = "profile-avatar-edit:$avatarUrl", + contentDescription = null, + modifier = Modifier.fillMaxSize().clip(CircleShape), + contentScale = androidx.compose.ui.layout.ContentScale.Crop + ) + } else { + ProfileDefaultAvatar(modifier = Modifier.size(90.dp)) + } + } + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(Color(0xFF2C2C2C)) + .border(2.dp, Color.Black, CircleShape) + .clickable(onClick = onPickAvatarClick), + contentAlignment = Alignment.Center + ) { + Text("✎", color = Color.White, fontSize = 14.sp) + } + } + + Spacer(Modifier.height(32.dp)) + + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(AppStrings.t(language, "profiles.name")) }, + modifier = Modifier.fillMaxWidth(), + colors = profileFieldColors(), + singleLine = true + ) + + Spacer(Modifier.height(16.dp)) + + OutlinedTextField( + value = pin, + onValueChange = { + pin = it.filter(Char::isDigit).take(4) + if (pin.isNotEmpty()) removePin = false + }, + label = { Text(AppStrings.t(language, "profiles.pin_lock")) }, + placeholder = { + Text( + if (initialProfile?.hasPin == true && !removePin) { + AppStrings.t(language, "profiles.pin_set_placeholder") + } else { + AppStrings.t(language, "profiles.pin_placeholder") + } + ) + }, + isError = !pinValid, + supportingText = if (!pinValid) { { Text(AppStrings.t(language, "profiles.pin_invalid")) } } else null, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = profileFieldColors() + ) + if (initialProfile?.hasPin == true && !removePin) { + TextButton(onClick = { removePin = true; pin = "" }) { + Text(AppStrings.t(language, "profiles.pin_remove"), color = FluxaColors.errorRed) + } + } + + if (biometricAvailable && willHavePin) { + Spacer(Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(AppStrings.t(language, "profiles.biometric_lock"), color = Color.White, fontWeight = FontWeight.Medium) + Text(AppStrings.t(language, "profiles.biometric_lock_desc"), color = Color.Gray, fontSize = 12.sp) + } + Switch(checked = biometricEnabled, onCheckedChange = { biometricEnabled = it }) + } + } + + Spacer(Modifier.height(24.dp)) + + Text( + AppStrings.t(language, "profiles.choose_image"), + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.height(4.dp)) + Text(AppStrings.t(language, "profiles.choose_image_desc"), color = Color.Gray, fontSize = 12.sp) + Spacer(Modifier.height(16.dp)) + + OutlinedButton( + onClick = onPickAvatarClick, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White) + ) { + Text(AppStrings.t(language, "profiles.upload_image")) + } + + if (!avatarUrl.isNullOrBlank()) { + Spacer(Modifier.height(8.dp)) + TextButton(onClick = onRemoveAvatarClick) { + Text(AppStrings.t(language, "profiles.remove_image"), color = Color.White.copy(alpha = 0.6f)) + } + } + + if (initialProfile != null && onDelete != null) { + Spacer(Modifier.height(8.dp)) + TextButton(onClick = onDelete) { + Text(AppStrings.t(language, "profiles.delete"), color = FluxaColors.errorRed, fontWeight = FontWeight.Bold) + } + } + + Spacer(Modifier.height(32.dp)) + } +} + +private const val EXISTING_PIN_MARKER = "existing-pin" + +@Composable +private fun profileFieldColors() = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + focusedBorderColor = Color.White, + unfocusedBorderColor = Color.White.copy(alpha = 0.3f), + focusedLabelColor = Color.White, + unfocusedLabelColor = Color.White.copy(alpha = 0.5f), + cursorColor = Color.White +) diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/profile/ProfileListScreen.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/profile/ProfileListScreen.kt new file mode 100644 index 0000000..92b2ad2 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/profile/ProfileListScreen.kt @@ -0,0 +1,315 @@ +package com.fluxa.app.shared.feature.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.fluxa.app.common.AppStrings +import com.fluxa.app.shared.image.FluxaRemoteImage +import com.fluxa.app.ui.catalog.FluxaColors + +@Composable +fun ProfileListScreen( + state: ProfileUiState, + language: String?, + biometricAvailable: Boolean, + onAction: (ProfileAction) -> Unit, + onBiometricRequested: (ProfileUiModel) -> Unit, + modifier: Modifier = Modifier +) { + var isManaging by remember { mutableStateOf(false) } + + LaunchedEffect(state.pendingPinProfile?.id) { + val pending = state.pendingPinProfile + if (pending != null && pending.biometricEnabled && biometricAvailable) { + onBiometricRequested(pending) + } + } + + Box(modifier = modifier.fillMaxSize().background(FluxaColors.background)) { + Column( + modifier = Modifier.fillMaxSize().padding(top = 48.dp, bottom = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = AppStrings.t(language, "profiles.who_watching"), + color = Color.White, + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.height(32.dp)) + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.fillMaxWidth().weight(1f), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + items(state.profiles, key = { it.id }) { profile -> + ProfileGridItem( + profile = profile, + isManaging = isManaging, + onClick = { onAction(ProfileAction.Selected(profile)) }, + onEditClick = { onAction(ProfileAction.EditRequested(profile)) } + ) + } + item { + AddProfileGridItem( + language = language, + onClick = { onAction(ProfileAction.AddRequested) } + ) + } + } + TextButton(onClick = { isManaging = true }, enabled = !isManaging) { + Text( + text = AppStrings.t(language, "profiles.manage"), + color = Color.White, + fontWeight = FontWeight.Bold + ) + } + } + + state.pendingPinProfile?.let { profile -> + ProfilePinOverlay( + profile = profile, + language = language, + pinError = state.pinError, + onSubmit = { pin -> onAction(ProfileAction.PinAttempt(profile.id, pin)) }, + onDismiss = { onAction(ProfileAction.PinCancelled) } + ) + } + } +} + +@Composable +private fun ProfileGridItem( + profile: ProfileUiModel, + isManaging: Boolean, + onClick: () -> Unit, + onEditClick: () -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.CenterHorizontally), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(100.dp) + .clip(CircleShape) + .background(Color(profile.accentColorArgb)) + .clickable(enabled = !isManaging, onClick = onClick), + contentAlignment = Alignment.Center + ) { + if (!profile.avatarUrl.isNullOrBlank()) { + FluxaRemoteImage( + imageUrl = profile.avatarUrl, + cacheKey = "profile-avatar:${profile.avatarUrl}", + contentDescription = null, + modifier = Modifier.fillMaxSize().clip(CircleShape), + contentScale = androidx.compose.ui.layout.ContentScale.Crop + ) + } else { + ProfileDefaultAvatar(modifier = Modifier.size(66.dp)) + } + } + Spacer(Modifier.height(10.dp)) + Text(profile.name, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.Medium) + if (isManaging) { + Spacer(Modifier.height(6.dp)) + Box( + modifier = Modifier + .size(30.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.1f)) + .clickable(onClick = onEditClick), + contentAlignment = Alignment.Center + ) { + Text("✎", color = Color.White.copy(alpha = 0.82f), fontSize = 13.sp) + } + } + } +} + +@Composable +private fun AddProfileGridItem(language: String?, onClick: () -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.CenterHorizontally).clickable(onClick = onClick) + ) { + Box( + modifier = Modifier.size(100.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center + ) { + Text("+", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold) + } + Spacer(Modifier.height(12.dp)) + Text(AppStrings.t(language, "profiles.add_profile"), color = Color.White, fontSize = 16.sp) + } +} + +private const val PIN_LENGTH = 4 + +@Composable +private fun ProfilePinOverlay( + profile: ProfileUiModel, + language: String?, + pinError: Boolean, + onSubmit: (String) -> Unit, + onDismiss: () -> Unit +) { + var pin by remember(profile.id) { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(profile.id) { + runCatching { focusRequester.requestFocus() } + } + LaunchedEffect(pinError) { + if (pinError) pin = "" + } + + Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.78f)), contentAlignment = Alignment.Center) { + Column( + modifier = Modifier + .widthIn(max = 400.dp) + .clip(RoundedCornerShape(32.dp)) + .background(Color(0xFF15121C)) + .padding(horizontal = 40.dp, vertical = 36.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier.size(72.dp).clip(CircleShape).background(Color(profile.accentColorArgb)), + contentAlignment = Alignment.Center + ) { + if (!profile.avatarUrl.isNullOrBlank()) { + FluxaRemoteImage( + imageUrl = profile.avatarUrl, + cacheKey = "profile-avatar:${profile.avatarUrl}", + contentDescription = null, + modifier = Modifier.fillMaxSize().clip(CircleShape), + contentScale = androidx.compose.ui.layout.ContentScale.Crop + ) + } else { + ProfileDefaultAvatar(modifier = Modifier.size(46.dp)) + } + } + Spacer(Modifier.height(18.dp)) + Text(profile.name, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(6.dp)) + Text( + AppStrings.t(language, "profiles.pin_prompt"), + color = Color.White.copy(alpha = 0.6f), + fontSize = 15.sp, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(32.dp)) + + Box { + BasicTextField( + value = pin, + onValueChange = { + val digits = it.filter(Char::isDigit).take(PIN_LENGTH) + pin = digits + if (digits.length == PIN_LENGTH) onSubmit(digits) + }, + modifier = Modifier.focusRequester(focusRequester).size(1.dp), + singleLine = true, + textStyle = TextStyle(color = Color.Transparent, fontSize = 1.sp), + cursorBrush = SolidColor(Color.Transparent), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { if (pin.length == PIN_LENGTH) onSubmit(pin) }) + ) + Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + repeat(PIN_LENGTH) { index -> + val filled = index < pin.length + Box( + modifier = Modifier + .size(52.dp) + .clip(RoundedCornerShape(16.dp)) + .background(Color.White.copy(alpha = if (filled) 0.14f else 0.06f)), + contentAlignment = Alignment.Center + ) { + if (filled) { + Box(modifier = Modifier.size(12.dp).clip(CircleShape).background(Color.White)) + } + } + } + } + } + + Spacer(Modifier.height(18.dp)) + Text( + text = if (pinError) AppStrings.t(language, "profiles.pin_error") else "", + color = FluxaColors.errorRed, + fontSize = 14.sp + ) + Spacer(Modifier.height(18.dp)) + + TextButton(onClick = onDismiss) { + Text(AppStrings.t(language, "common.cancel"), color = Color.White) + } + } + } +} + +@Composable +fun ProfileDefaultAvatar(modifier: Modifier = Modifier, tint: Color = Color.White) { + androidx.compose.foundation.Canvas(modifier = modifier.fillMaxSize()) { + val w = size.width + val h = size.height + val strokeWidth = size.minDimension * 0.075f + drawCircle(color = tint, radius = w * 0.085f, center = androidx.compose.ui.geometry.Offset(w * 0.30f, h * 0.37f)) + drawCircle(color = tint, radius = w * 0.075f, center = androidx.compose.ui.geometry.Offset(w * 0.67f, h * 0.34f)) + val smile = androidx.compose.ui.graphics.Path().apply { + moveTo(w * 0.22f, h * 0.62f) + cubicTo(w * 0.20f, h * 0.54f, w * 0.26f, h * 0.57f, w * 0.30f, h * 0.65f) + cubicTo(w * 0.42f, h * 0.75f, w * 0.66f, h * 0.68f, w * 0.74f, h * 0.57f) + } + drawPath( + path = smile, + color = tint, + style = androidx.compose.ui.graphics.drawscope.Stroke(width = strokeWidth, cap = androidx.compose.ui.graphics.StrokeCap.Round) + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/profile/ProfileStore.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/profile/ProfileStore.kt index 41a1a50..04f2856 100644 --- a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/profile/ProfileStore.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/profile/ProfileStore.kt @@ -15,6 +15,20 @@ class ProfileStore( suspend fun selectProfile(profile: ProfileUiModel) { dataSource.selectProfile(profile.id) } + + suspend fun dispatch(action: ProfileAction) { + when (action) { + is ProfileAction.Selected -> dataSource.selectProfile(action.profile.id) + ProfileAction.AddRequested -> Unit + is ProfileAction.EditRequested -> Unit + is ProfileAction.PinAttempt -> dataSource.attemptPin(action.profileId, action.pin) + ProfileAction.PinCancelled -> dataSource.cancelPinUnlock() + } + } + + suspend fun deleteProfile(id: String) = dataSource.deleteProfile(id) + + suspend fun saveProfile(edit: ProfileEditUiModel): String = dataSource.saveProfile(edit) } class ProfileSettingsStore(