mirror of
https://github.com/FluxaMedia/fluxa.git
synced 2026-08-17 20:46:04 +00:00
Migrate Profile list/switch/edit to shared Compose Multiplatform
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.
This commit is contained in:
parent
7fdf667f7e
commit
609bd0ff15
9 changed files with 833 additions and 8 deletions
|
|
@ -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<ProfileUiState> = profilesFlow()
|
||||
|
||||
private val pendingPinId = MutableStateFlow<String?>(null)
|
||||
private val pinError = MutableStateFlow(false)
|
||||
|
||||
override fun observeProfiles(): Flow<ProfileUiState> = combine(profilesFlow(), pendingPinId, pinError) { state, pendingId, error ->
|
||||
state.copy(
|
||||
pendingPinProfile = state.profiles.firstOrNull { it.id == pendingId },
|
||||
pinError = error
|
||||
)
|
||||
}
|
||||
|
||||
override fun observeSettings(profileId: String): Flow<SettingsUiState> = 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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ProfileUiModel> = 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<ProfileUiState>
|
||||
fun observeSettings(profileId: String): Flow<SettingsUiState>
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = " | ||||