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:
KhooLy 2026-07-14 00:38:57 +03:00
parent 7fdf667f7e
commit 609bd0ff15
9 changed files with 833 additions and 8 deletions

View file

@ -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(

View file

@ -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),

View file

@ -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,

View file

@ -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
)

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
)

View file

@ -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)
)
}
}

View file

@ -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(