From 2eabd76d93e9998c773e15ddd550e816208fb173 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Mon, 7 Sep 2026 19:16:28 +0200 Subject: [PATCH] Improve Android profile management and account settings UI. Add profile update API calls and richer edit/manage flows on TV and mobile, with version bumps. Co-authored-by: Cursor --- apps/android-tv/app-mobile/build.gradle.kts | 4 +- .../vonas/mediacluster/tv/data/ApiClient.kt | 20 + .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 72 +- .../tv/ui/profiles/ProfilesScreen.kt | 902 +++++++++++------- apps/android-tv/app/build.gradle.kts | 4 +- .../vonas/mediacluster/tv/data/ApiClient.kt | 20 + .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 351 +++++-- .../tv/ui/profiles/ProfilesScreen.kt | 374 +++++--- 8 files changed, 1168 insertions(+), 579 deletions(-) diff --git a/apps/android-tv/app-mobile/build.gradle.kts b/apps/android-tv/app-mobile/build.gradle.kts index f8e1048..8c56075 100644 --- a/apps/android-tv/app-mobile/build.gradle.kts +++ b/apps/android-tv/app-mobile/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "nl.vonas.mediacluster" minSdk = 24 targetSdk = 36 - versionCode = 9 - versionName = "1.0.8" + versionCode = 11 + versionName = "1.0.10" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index 7f8d29a..4db5559 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -244,6 +244,26 @@ class ApiClient( parseObject(execute(req)) { o -> o.getJSONObject("profile").toProfile() } } + suspend fun updateProfile( + profileId: String, + name: String? = null, + avatarId: String? = null, + isKids: Boolean? = null, + pin: String? = null, + clearPin: Boolean = false, + ): ViewerProfile = withContext(Dispatchers.IO) { + val body = JSONObject() + if (name != null) body.put("name", name) + if (avatarId != null) body.put("avatarId", avatarId) + if (isKids != null) body.put("isKids", isKids) + if (clearPin) body.put("clearPin", true) + else if (!pin.isNullOrBlank()) body.put("pin", pin) + val req = authed("$baseUrl/api/v1/client/profiles/$profileId") + .patch(body.toString().toRequestBody(jsonMedia)) + .build() + parseObject(execute(req)) { o -> o.getJSONObject("profile").toProfile() } + } + suspend fun deleteProfile(profileId: String) = withContext(Dispatchers.IO) { val req = authed("$baseUrl/api/v1/client/profiles/$profileId").delete().build() execute(req) diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index 6c9dff8..fc2df94 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -498,6 +498,8 @@ private fun MainHub( ) MainTab.Settings -> SettingsScreen( api = api, + profileAvatarId = profileAvatarId, + profileName = profileName, onBack = { onTabChange(MainTab.Home) focusTopNav() @@ -509,38 +511,6 @@ private fun MainHub( } } StatusBarGlassOverlay(Modifier.align(Alignment.TopCenter)) - // Profiel-avatar rechtsboven → terug naar switcher - Row( - Modifier - .align(Alignment.TopEnd) - .statusBarsPadding() - .padding(top = 8.dp, end = 12.dp) - .clip(RoundedCornerShape(20.dp)) - .background(Color(0xCC1A1A22)) - .clickable(onClick = onSwitchProfile) - .padding(horizontal = 10.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Box( - Modifier - .size(28.dp) - .clip(CircleShape) - .background(Color(0xFF2E2E3A)), - contentAlignment = Alignment.Center, - ) { - Text(avatarEmoji(profileAvatarId ?: "fox"), fontSize = 16.sp) - } - if (!profileName.isNullOrBlank()) { - Text( - profileName, - color = Color.White, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - ) - } - } } Box(Modifier.focusRequester(navFocus)) { TopNavBar( @@ -1117,6 +1087,8 @@ private fun ShelfBlock( @Composable private fun SettingsScreen( api: ApiClient, + profileAvatarId: String? = null, + profileName: String? = null, onBack: () -> Unit, onSwitchProfile: () -> Unit = {}, onManageProfiles: () -> Unit = {}, @@ -1189,20 +1161,40 @@ private fun SettingsScreen( ) } Spacer(Modifier.height(8.dp)) - Box( + Row( Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .background(McColors.BgElevated) .clickable(onClick = onSwitchProfile) - .padding(horizontal = 16.dp, vertical = 16.dp), + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Text( - "Profiel wisselen", - color = Color.White, - fontSize = McDimens.body, - fontWeight = FontWeight.SemiBold, - ) + Box( + Modifier + .size(44.dp) + .clip(CircleShape) + .background(Color(0xFF2E2E3A)), + contentAlignment = Alignment.Center, + ) { + Text(avatarEmoji(profileAvatarId ?: "fox"), fontSize = 22.sp) + } + Column(Modifier.weight(1f)) { + Text( + profileName?.takeIf { it.isNotBlank() } ?: "Profiel", + color = Color.White, + fontSize = McDimens.body, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + Text( + "Tik om van profiel te wisselen", + color = McColors.TextDim, + fontSize = McDimens.meta, + ) + } + Text("›", color = Color(0xFF888888), fontSize = 22.sp) } Spacer(Modifier.height(8.dp)) Box( diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt index 1c720f5..e98d4d9 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt @@ -1,6 +1,9 @@ package nl.vonas.mediacluster.tv.ui.profiles import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,14 +13,18 @@ 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.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.statusBarsPadding +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.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.runtime.Composable @@ -30,28 +37,29 @@ 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.Brush 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.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.Surface import androidx.tv.material3.Text +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.SessionStore import nl.vonas.mediacluster.tv.data.ViewerProfile +import nl.vonas.mediacluster.tv.ui.components.McButton import nl.vonas.mediacluster.tv.ui.theme.McColors +import nl.vonas.mediacluster.tv.ui.theme.McDimens -private enum class ProfilesMode { Pick, Pin, Create, DeleteConfirm } +private enum class ProfilesMode { Pick, Pin, Create, Edit, DeleteConfirm } -@OptIn(ExperimentalTvMaterial3Api::class) +@OptIn(ExperimentalTvMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable fun ProfilesScreen( api: ApiClient, @@ -63,6 +71,7 @@ fun ProfilesScreen( var profiles by remember { mutableStateOf>(emptyList()) } var canManage by remember { mutableStateOf(false) } var loading by remember { mutableStateOf(true) } + var busy by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } var mode by remember { mutableStateOf(ProfilesMode.Pick) } var pending by remember { mutableStateOf(null) } @@ -71,7 +80,20 @@ fun ProfilesScreen( var createAvatar by remember { mutableStateOf("fox") } var createKids by remember { mutableStateOf(false) } var createPin by remember { mutableStateOf("") } - val firstFocus = remember { FocusRequester() } + var clearPin by remember { mutableStateOf(false) } + var editingId by remember { mutableStateOf(null) } + + fun openEdit(profile: ViewerProfile) { + editingId = profile.id + createName = profile.name + createAvatar = profile.avatarId + createKids = profile.isKids + createPin = "" + clearPin = false + pending = profile + mode = ProfilesMode.Edit + error = null + } fun reload() { scope.launch { @@ -93,11 +115,12 @@ fun ProfilesScreen( store.setProfile(null) } reload() - runCatching { firstFocus.requestFocus() } } fun select(profile: ViewerProfile, pinValue: String? = null) { + if (busy) return scope.launch { + busy = true error = null runCatching { api.selectProfile(profile.id, pinValue) } .onSuccess { selected -> @@ -112,6 +135,7 @@ fun ProfilesScreen( pin = "" } } + busy = false } } @@ -120,368 +144,568 @@ fun ProfilesScreen( .fillMaxSize() .background( Brush.verticalGradient( - listOf(Color(0xFF0B0B0F), Color(0xFF14141C), Color(0xFF0B0B0F)), + listOf(Color(0xFF0A0A0E), Color(0xFF15151E), Color(0xFF0A0A0E)), ), - ), + ) + .statusBarsPadding() + .navigationBarsPadding(), ) { - Column( - Modifier - .fillMaxSize() - .padding(horizontal = 48.dp, vertical = 36.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - if (switcherMode) "Wie kijkt er?" else "Profielen beheren", - color = Color.White, - fontSize = 36.sp, - fontWeight = FontWeight.Bold, - ) - Spacer(Modifier.height(8.dp)) - Text( - when { - !switcherMode && canManage -> "Voeg toe of verwijder (lang drukken)" - switcherMode -> "Kies je profiel" - else -> "Alleen het hoofdprofiel mag beheren" - }, - color = Color(0xFFAAAAAA), - fontSize = 16.sp, - ) - Spacer(Modifier.height(28.dp)) - - when (mode) { - ProfilesMode.Pick -> { - if (loading) { - Text("Profielen laden…", color = Color.White) + when (mode) { + ProfilesMode.Pick -> PickContent( + switcherMode = switcherMode, + canManage = canManage, + loading = loading, + busy = busy, + profiles = profiles, + error = error, + onSelect = { profile -> + if (!switcherMode && canManage) { + openEdit(profile) + } else if (profile.hasPin) { + pending = profile + pin = "" + mode = ProfilesMode.Pin } else { - LazyRow( - contentPadding = PaddingValues(horizontal = 24.dp), - horizontalArrangement = Arrangement.spacedBy(20.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - items(profiles, key = { it.id }) { p -> - ProfileTile( - profile = p, - modifier = if (profiles.firstOrNull()?.id == p.id) { - Modifier.focusRequester(firstFocus) - } else Modifier, - onClick = { - if (p.hasPin) { - pending = p - pin = "" - mode = ProfilesMode.Pin - } else { - select(p) - } - }, - onLongDelete = if (canManage && !p.isOwner) { - { - pending = p - mode = ProfilesMode.DeleteConfirm - } - } else null, + select(profile) + } + }, + onLongDelete = if (canManage) { + { profile -> + if (!profile.isOwner) { + pending = profile + mode = ProfilesMode.DeleteConfirm + } + } + } else null, + onAdd = { + editingId = null + pending = null + createName = "" + createAvatar = "fox" + createKids = false + createPin = "" + clearPin = false + mode = ProfilesMode.Create + }, + ) + + ProfilesMode.Pin -> PinContent( + name = pending?.name ?: "profiel", + pin = pin, + onPinChange = { pin = it.filter(Char::isDigit).take(4) }, + busy = busy, + error = error, + onBack = { + mode = ProfilesMode.Pick + pending = null + pin = "" + error = null + }, + onUnlock = { + val target = pending ?: return@PinContent + select(target, pin) + }, + ) + + ProfilesMode.Create, ProfilesMode.Edit -> ProfileFormContent( + title = if (mode == ProfilesMode.Edit) "Profiel bewerken" else "Nieuw profiel", + name = createName, + onNameChange = { createName = it.take(32) }, + avatarId = createAvatar, + onAvatarChange = { createAvatar = it }, + isKids = createKids, + onKidsToggle = { createKids = !createKids }, + showKidsToggle = canManage && (mode == ProfilesMode.Create || pending?.isOwner != true), + pin = createPin, + onPinChange = { + createPin = it.filter(Char::isDigit).take(4) + if (it.isNotBlank()) clearPin = false + }, + hasExistingPin = pending?.hasPin == true && mode == ProfilesMode.Edit, + clearPin = clearPin, + onClearPinToggle = { + clearPin = !clearPin + if (clearPin) createPin = "" + }, + saveLabel = if (mode == ProfilesMode.Edit) "Opslaan" else "Aanmaken", + busy = busy, + error = error, + onCancel = { + mode = ProfilesMode.Pick + editingId = null + pending = null + error = null + }, + onSave = { + if (busy) return@ProfileFormContent + scope.launch { + busy = true + error = null + val editId = editingId + runCatching { + if (editId != null) { + api.updateProfile( + profileId = editId, + name = createName.trim(), + avatarId = createAvatar, + isKids = if (canManage && pending?.isOwner != true) createKids else null, + pin = createPin.ifBlank { null }, + clearPin = clearPin, + ) + } else { + api.createProfile( + name = createName.trim(), + avatarId = createAvatar, + isKids = createKids, + pin = createPin.ifBlank { null }, ) } - if (canManage && profiles.size < 8) { - item { - AddProfileTile(onClick = { - createName = "" - createAvatar = "fox" - createKids = false - createPin = "" - mode = ProfilesMode.Create - }) - } + }.onSuccess { saved -> + val localId = store.profileId.first() + if (localId == saved.id || editId == localId) { + store.setProfile(saved.id, saved.name, saved.avatarId) } - } - if (canManage) { - Spacer(Modifier.height(20.dp)) - Text( - "Houd OK lang ingedrukt op een profiel om te verwijderen", - color = Color(0xFF666666), - fontSize = 13.sp, - ) - } - } - } - - ProfilesMode.Pin -> { - val target = pending - Text( - "PIN voor ${target?.name ?: "profiel"}", - color = Color.White, - fontSize = 24.sp, - fontWeight = FontWeight.SemiBold, - ) - Spacer(Modifier.height(16.dp)) - OutlinedTextField( - value = pin, - onValueChange = { v -> pin = v.filter { it.isDigit() }.take(4) }, - label = { androidx.compose.material3.Text("4-cijferige PIN") }, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), - singleLine = true, - colors = OutlinedTextFieldDefaults.colors( - focusedTextColor = Color.White, - unfocusedTextColor = Color.White, - focusedBorderColor = McColors.Brand, - unfocusedBorderColor = Color(0xFF555555), - focusedLabelColor = McColors.Brand, - unfocusedLabelColor = Color(0xFF888888), - cursorColor = McColors.Brand, - ), - ) - Spacer(Modifier.height(16.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - ActionChip("Terug") { mode = ProfilesMode.Pick + editingId = null pending = null - pin = "" - } - ActionChip("Ontgrendel", primary = true) { - if (target != null) select(target, pin) + reload() + }.onFailure { + error = it.message ?: "Opslaan mislukt" } + busy = false } - } + }, + ) - ProfilesMode.Create -> { - Text("Nieuw profiel", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold) - Spacer(Modifier.height(12.dp)) - OutlinedTextField( - value = createName, - onValueChange = { createName = it.take(32) }, - label = { androidx.compose.material3.Text("Naam") }, - singleLine = true, - colors = OutlinedTextFieldDefaults.colors( - focusedTextColor = Color.White, - unfocusedTextColor = Color.White, - focusedBorderColor = McColors.Brand, - unfocusedBorderColor = Color(0xFF555555), - focusedLabelColor = McColors.Brand, - unfocusedLabelColor = Color(0xFF888888), - cursorColor = McColors.Brand, - ), - ) - Spacer(Modifier.height(12.dp)) - Text("Kies een avatar", color = Color(0xFFCCCCCC), fontSize = 14.sp) - Spacer(Modifier.height(8.dp)) - LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - items(PROFILE_AVATARS) { av -> - Surface( - onClick = { createAvatar = av.id }, - shape = ClickableSurfaceDefaults.shape(shape = CircleShape), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.12f), - colors = ClickableSurfaceDefaults.colors( - containerColor = if (createAvatar == av.id) McColors.Brand else Color(0xFF2A2A32), - focusedContainerColor = McColors.Brand, - ), - ) { - Box( - Modifier.size(56.dp), - contentAlignment = Alignment.Center, - ) { - Text(av.emoji, fontSize = 26.sp) - } + ProfilesMode.DeleteConfirm -> DeleteContent( + name = pending?.name ?: "profiel", + busy = busy, + error = error, + onCancel = { + mode = ProfilesMode.Pick + pending = null + error = null + }, + onConfirm = { + val id = pending?.id ?: return@DeleteContent + if (busy) return@DeleteContent + scope.launch { + busy = true + error = null + runCatching { api.deleteProfile(id) } + .onSuccess { + mode = ProfilesMode.Pick + pending = null + reload() } - } + .onFailure { error = it.message ?: "Verwijderen mislukt" } + busy = false } - Spacer(Modifier.height(14.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - ActionChip(if (createKids) "Kids: aan" else "Kids: uit") { - createKids = !createKids - } - } - Spacer(Modifier.height(10.dp)) - OutlinedTextField( - value = createPin, - onValueChange = { v -> createPin = v.filter { it.isDigit() }.take(4) }, - label = { androidx.compose.material3.Text("PIN (optioneel)") }, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), - singleLine = true, - colors = OutlinedTextFieldDefaults.colors( - focusedTextColor = Color.White, - unfocusedTextColor = Color.White, - focusedBorderColor = McColors.Brand, - unfocusedBorderColor = Color(0xFF555555), - focusedLabelColor = McColors.Brand, - unfocusedLabelColor = Color(0xFF888888), - cursorColor = McColors.Brand, - ), - ) - Spacer(Modifier.height(16.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - ActionChip("Annuleer") { mode = ProfilesMode.Pick } - ActionChip("Aanmaken", primary = true) { - scope.launch { - error = null - runCatching { - api.createProfile( - name = createName.trim(), - avatarId = createAvatar, - isKids = createKids, - pin = createPin.ifBlank { null }, - ) - }.onSuccess { - mode = ProfilesMode.Pick - reload() - }.onFailure { - error = it.message ?: "Aanmaken mislukt" - } - } - } - } - } - - ProfilesMode.DeleteConfirm -> { - val target = pending - Text( - "Verwijder ${target?.name ?: "profiel"}?", - color = Color.White, - fontSize = 24.sp, - fontWeight = FontWeight.SemiBold, - ) - Spacer(Modifier.height(8.dp)) - Text( - "Kijkgeschiedenis van dit profiel gaat verloren.", - color = Color(0xFFAAAAAA), - fontSize = 14.sp, - ) - Spacer(Modifier.height(16.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - ActionChip("Annuleer") { - mode = ProfilesMode.Pick - pending = null - } - ActionChip("Verwijderen", primary = true) { - val id = target?.id ?: return@ActionChip - scope.launch { - runCatching { api.deleteProfile(id) } - .onSuccess { - mode = ProfilesMode.Pick - pending = null - reload() - } - .onFailure { error = it.message ?: "Verwijderen mislukt" } - } - } - } - } - } - - error?.let { - Spacer(Modifier.height(16.dp)) - Text(it, color = Color(0xFFFF6B6B), fontSize = 14.sp) - } + }, + ) } } } -@OptIn(ExperimentalTvMaterial3Api::class) +@OptIn(ExperimentalTvMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) +@Composable +private fun PickContent( + switcherMode: Boolean, + canManage: Boolean, + loading: Boolean, + busy: Boolean, + profiles: List, + error: String?, + onSelect: (ViewerProfile) -> Unit, + onLongDelete: ((ViewerProfile) -> Unit)?, + onAdd: () -> Unit, +) { + Column( + Modifier + .fillMaxSize() + .padding(horizontal = McDimens.screenPad), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(28.dp)) + Text( + if (switcherMode) "Wie kijkt er?" else "Profielen beheren", + color = Color.White, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + when { + !switcherMode && canManage -> "Tik om te bewerken · houd vast om te verwijderen" + switcherMode -> "Kies je profiel om verder te gaan" + else -> "Alleen het hoofdprofiel mag beheren" + }, + color = Color(0xFF9A9AA8), + fontSize = 14.sp, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 12.dp), + ) + + Box( + Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + when { + loading -> androidx.compose.material3.CircularProgressIndicator( + color = McColors.Brand, + strokeWidth = 3.dp, + ) + else -> { + val cells = profiles.size + if (canManage && profiles.size < 8) 1 else 0 + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + contentPadding = PaddingValues(vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + userScrollEnabled = cells > 4, + ) { + items(profiles, key = { it.id }) { profile -> + ProfileTile( + profile = profile, + enabled = !busy, + onClick = { onSelect(profile) }, + onLongClick = onLongDelete?.let { handler -> + { handler(profile) } + }, + ) + } + if (canManage && profiles.size < 8) { + item { + AddProfileTile(enabled = !busy, onClick = onAdd) + } + } + } + } + } + } + + error?.let { + Text( + it, + color = Color(0xFFFF6B6B), + fontSize = 13.sp, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 16.dp), + ) + } + if (busy) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier + .padding(bottom = 20.dp) + .size(22.dp), + color = McColors.Text, + strokeWidth = 2.dp, + ) + } else { + Spacer(Modifier.height(12.dp)) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable private fun ProfileTile( profile: ViewerProfile, + enabled: Boolean, onClick: () -> Unit, - modifier: Modifier = Modifier, - onLongDelete: (() -> Unit)? = null, + onLongClick: (() -> Unit)?, ) { - Surface( - onClick = onClick, - onLongClick = onLongDelete, - modifier = modifier.width(140.dp), - shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), - colors = ClickableSurfaceDefaults.colors( - containerColor = Color(0xFF1C1C24), - focusedContainerColor = Color(0xFF2A2A36), - ), - ) { - Column( - Modifier.padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Box( - Modifier - .size(88.dp) - .clip(CircleShape) - .background(Color(0xFF2E2E3A)), - contentAlignment = Alignment.Center, - ) { - Text(avatarEmoji(profile.avatarId), fontSize = 40.sp) - } - Spacer(Modifier.height(12.dp)) - Text( - profile.name, - color = Color.White, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, + Column( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + enabled = enabled, + onClick = onClick, + onLongClick = onLongClick, ) - val badge = when { - profile.isKids -> "Kids" - profile.isOwner -> "Hoofd" - profile.hasPin -> "PIN" - else -> null - } - if (badge != null) { - Spacer(Modifier.height(4.dp)) - Text(badge, color = Color(0xFF999999), fontSize = 12.sp) - } - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun AddProfileTile(onClick: () -> Unit) { - Surface( - onClick = onClick, - modifier = Modifier.width(140.dp), - shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), - colors = ClickableSurfaceDefaults.colors( - containerColor = Color(0xFF16161E), - focusedContainerColor = Color(0xFF242430), - ), + .padding(vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - Column( - Modifier.padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, + Box( + Modifier + .size(96.dp) + .clip(CircleShape) + .background(Color(0xFF2A2A36)) + .border(2.dp, Color(0x33FFFFFF), CircleShape), + contentAlignment = Alignment.Center, ) { - Box( - Modifier - .size(88.dp) - .clip(CircleShape) - .background(Color(0xFF2A2A32)), - contentAlignment = Alignment.Center, - ) { - Text("+", color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.Light) - } - Spacer(Modifier.height(12.dp)) - Text("Toevoegen", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + Text(avatarEmoji(profile.avatarId), fontSize = 42.sp) + } + Spacer(Modifier.height(10.dp)) + Text( + profile.name, + color = Color.White, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + val badge = when { + profile.isKids -> "Kids" + profile.isOwner -> "Hoofd" + profile.hasPin -> "PIN" + else -> null + } + if (badge != null) { + Spacer(Modifier.height(4.dp)) + Text(badge, color = Color(0xFF8E8E9A), fontSize = 12.sp) + } else { + Spacer(Modifier.height(16.dp)) } } } @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun ActionChip(label: String, primary: Boolean = false, onClick: () -> Unit) { - Surface( - onClick = onClick, - shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), - colors = ClickableSurfaceDefaults.colors( - containerColor = if (primary) McColors.Brand else Color(0xFF2A2A32), - focusedContainerColor = if (primary) McColors.Brand else Color(0xFF3A3A48), - contentColor = Color.White, - focusedContentColor = Color.White, - ), +private fun AddProfileTile(enabled: Boolean, onClick: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick) + .padding(vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { + Box( + Modifier + .size(96.dp) + .clip(CircleShape) + .background(Color(0xFF1C1C26)) + .border(2.dp, Color(0x44FFFFFF), CircleShape), + contentAlignment = Alignment.Center, + ) { + Text("+", color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.Light) + } + Spacer(Modifier.height(10.dp)) Text( - label, - Modifier.padding(horizontal = 18.dp, vertical = 10.dp), - color = Color.Unspecified, + "Toevoegen", + color = Color.White, fontSize = 15.sp, - fontWeight = FontWeight.Medium, + fontWeight = FontWeight.SemiBold, ) + Spacer(Modifier.height(16.dp)) } } + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun PinContent( + name: String, + pin: String, + onPinChange: (String) -> Unit, + busy: Boolean, + error: String?, + onBack: () -> Unit, + onUnlock: () -> Unit, +) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text("PIN invoeren", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + Text("Voor $name", color = Color(0xFF9A9AA8), fontSize = 14.sp) + Spacer(Modifier.height(24.dp)) + OutlinedTextField( + value = pin, + onValueChange = onPinChange, + label = { androidx.compose.material3.Text("4-cijferige PIN") }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = fieldColors(), + ) + error?.let { + Spacer(Modifier.height(12.dp)) + Text(it, color = Color(0xFFFF6B6B), fontSize = 13.sp, textAlign = TextAlign.Center) + } + Spacer(Modifier.height(24.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + McButton("Terug", onClick = onBack) + McButton("Ontgrendel", primary = true, onClick = onUnlock) + } + if (busy) { + Spacer(Modifier.height(16.dp)) + androidx.compose.material3.CircularProgressIndicator(modifier = Modifier.size(22.dp), color = McColors.Text, strokeWidth = 2.dp) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun ProfileFormContent( + title: String, + name: String, + onNameChange: (String) -> Unit, + avatarId: String, + onAvatarChange: (String) -> Unit, + isKids: Boolean, + onKidsToggle: () -> Unit, + showKidsToggle: Boolean, + pin: String, + onPinChange: (String) -> Unit, + hasExistingPin: Boolean, + clearPin: Boolean, + onClearPinToggle: () -> Unit, + saveLabel: String, + busy: Boolean, + error: String?, + onCancel: () -> Unit, + onSave: () -> Unit, +) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(title, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(20.dp)) + OutlinedTextField( + value = name, + onValueChange = onNameChange, + label = { androidx.compose.material3.Text("Naam") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = fieldColors(), + ) + Spacer(Modifier.height(16.dp)) + Text("Avatar", color = Color(0xFFCCCCCC), fontSize = 14.sp, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.height(10.dp)) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + PROFILE_AVATARS.chunked(5).forEach { row -> + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + row.forEach { av -> + val selected = avatarId == av.id + Box( + Modifier + .size(52.dp) + .clip(CircleShape) + .background(if (selected) McColors.Brand else Color(0xFF2A2A32)) + .clickable { onAvatarChange(av.id) }, + contentAlignment = Alignment.Center, + ) { + Text(av.emoji, fontSize = 24.sp) + } + } + } + } + } + if (showKidsToggle) { + Spacer(Modifier.height(16.dp)) + McButton(if (isKids) "Kids-profiel: aan" else "Kids-profiel: uit", onClick = onKidsToggle) + } + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = pin, + onValueChange = onPinChange, + label = { + androidx.compose.material3.Text( + if (hasExistingPin) "Nieuwe PIN (leeg = ongewijzigd)" else "PIN (optioneel)" + ) + }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + singleLine = true, + enabled = !clearPin, + modifier = Modifier.fillMaxWidth(), + colors = fieldColors(), + ) + if (hasExistingPin) { + Spacer(Modifier.height(10.dp)) + McButton( + if (clearPin) "PIN verwijderen: aan" else "PIN verwijderen", + onClick = onClearPinToggle, + ) + } + error?.let { + Spacer(Modifier.height(12.dp)) + Text(it, color = Color(0xFFFF6B6B), fontSize = 13.sp, textAlign = TextAlign.Center) + } + Spacer(Modifier.height(24.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + McButton("Annuleer", onClick = onCancel) + McButton(saveLabel, primary = true, onClick = onSave) + } + if (busy) { + Spacer(Modifier.height(16.dp)) + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(22.dp), + color = McColors.Text, + strokeWidth = 2.dp, + ) + } + Spacer(Modifier.height(24.dp)) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun DeleteContent( + name: String, + busy: Boolean, + error: String?, + onCancel: () -> Unit, + onConfirm: () -> Unit, +) { + Column( + Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text("Profiel verwijderen?", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + Spacer(Modifier.height(10.dp)) + Text( + "“$name” en de kijkgeschiedenis verdwijnen.", + color = Color(0xFF9A9AA8), + fontSize = 14.sp, + textAlign = TextAlign.Center, + ) + error?.let { + Spacer(Modifier.height(12.dp)) + Text(it, color = Color(0xFFFF6B6B), fontSize = 13.sp, textAlign = TextAlign.Center) + } + Spacer(Modifier.height(24.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + McButton("Annuleer", onClick = onCancel) + McButton("Verwijderen", primary = true, onClick = onConfirm) + } + if (busy) { + Spacer(Modifier.height(16.dp)) + androidx.compose.material3.CircularProgressIndicator(modifier = Modifier.size(22.dp), color = McColors.Text, strokeWidth = 2.dp) + } + } +} + +@Composable +private fun fieldColors() = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + focusedBorderColor = McColors.Brand, + unfocusedBorderColor = Color(0xFF555555), + focusedLabelColor = McColors.Brand, + unfocusedLabelColor = Color(0xFF888888), + cursorColor = McColors.Brand, +) diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index a79ff4f..4988640 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "nl.vonas.mediacluster.tv" minSdk = 24 targetSdk = 36 - versionCode = 71 - versionName = "0.14.8" + versionCode = 74 + versionName = "0.14.11" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index 627e6aa..64c767c 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -244,6 +244,26 @@ class ApiClient( parseObject(execute(req)) { o -> o.getJSONObject("profile").toProfile() } } + suspend fun updateProfile( + profileId: String, + name: String? = null, + avatarId: String? = null, + isKids: Boolean? = null, + pin: String? = null, + clearPin: Boolean = false, + ): ViewerProfile = withContext(Dispatchers.IO) { + val body = JSONObject() + if (name != null) body.put("name", name) + if (avatarId != null) body.put("avatarId", avatarId) + if (isKids != null) body.put("isKids", isKids) + if (clearPin) body.put("clearPin", true) + else if (!pin.isNullOrBlank()) body.put("pin", pin) + val req = authed("$baseUrl/api/v1/client/profiles/$profileId") + .patch(body.toString().toRequestBody(jsonMedia)) + .build() + parseObject(execute(req)) { o -> o.getJSONObject("profile").toProfile() } + } + suspend fun deleteProfile(profileId: String) = withContext(Dispatchers.IO) { val req = authed("$baseUrl/api/v1/client/profiles/$profileId").delete().build() execute(req) diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index d064942..f116494 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -21,12 +21,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -57,6 +59,7 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -628,6 +631,8 @@ private fun MainHub( ) MainTab.Settings -> SettingsScreen( api = api, + profileAvatarId = profileAvatarId, + profileName = profileName, onBack = { onTabChange(MainTab.Home) focusTopNav() @@ -1329,6 +1334,8 @@ private fun ShelfBlock( @Composable private fun SettingsScreen( api: ApiClient, + profileAvatarId: String? = null, + profileName: String? = null, onBack: () -> Unit, onSwitchProfile: () -> Unit = {}, onManageProfiles: () -> Unit = {}, @@ -1337,6 +1344,7 @@ private fun SettingsScreen( var prefs by remember { mutableStateOf>(emptyList()) } var error by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() + val firstFocus = remember { FocusRequester() } LaunchedEffect(Unit) { try { @@ -1344,6 +1352,7 @@ private fun SettingsScreen( } catch (e: Exception) { error = e.message } + runCatching { firstFocus.requestFocus() } } fun persist(next: List) { @@ -1357,101 +1366,271 @@ private fun SettingsScreen( } } - LazyColumn( - Modifier.fillMaxSize().background(McColors.Bg).padding(40.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + Box( + Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + listOf(Color(0xFF0B0B0F), Color(0xFF12121A), Color(0xFF0B0B0F)), + ), + ), ) { - item { - Text("Instellingen", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(16.dp)) - SectionLabel("Account") - Spacer(Modifier.height(8.dp)) - Column( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .background(McColors.BgElevated) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text("Vonas Media Cluster", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) - Text("Versie ${BuildConfig.VERSION_NAME}", color = McColors.TextDim, fontSize = 14.sp) + LazyColumn( + Modifier + .fillMaxSize() + .padding(horizontal = 48.dp, vertical = 28.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(bottom = 40.dp), + ) { + item { + Text( + "Instellingen", + color = Color.White, + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(6.dp)) + Text( + "Profiel, account en home-indeling", + color = Color(0xFF8E8E9A), + fontSize = 15.sp, + ) + Spacer(Modifier.height(22.dp)) } - Spacer(Modifier.height(8.dp)) - McButton("Profiel wisselen", onClick = onSwitchProfile) - Spacer(Modifier.height(12.dp)) - McButton("Profielen beheren", onClick = onManageProfiles) - Spacer(Modifier.height(12.dp)) - McButton("Uitloggen", onClick = onLogout) - Spacer(Modifier.height(20.dp)) - SectionLabel("Home-planken") - Spacer(Modifier.height(4.dp)) - Text( - "Kies welke rijen op Home staan en in welke volgorde.", - color = McColors.TextDim, - fontSize = 15.sp, - ) - if (error != null) { + + item { + SectionLabel("Profiel") + Spacer(Modifier.height(10.dp)) + Surface( + onClick = onSwitchProfile, + modifier = Modifier + .fillMaxWidth() + .focusRequester(firstFocus), + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(16.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color(0xFF1A1A22), + focusedContainerColor = Color(0xFF262632), + ), + ) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 22.dp, vertical = 18.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(18.dp), + ) { + Box( + Modifier + .size(64.dp) + .clip(CircleShape) + .background(Color(0xFF2E2E3A)), + contentAlignment = Alignment.Center, + ) { + Text( + avatarEmoji(profileAvatarId ?: "fox"), + fontSize = 30.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + Column(Modifier.weight(1f)) { + Text( + profileName?.takeIf { it.isNotBlank() } ?: "Profiel", + color = Color.White, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + Spacer(Modifier.height(4.dp)) + Text( + "Wissel van kijkprofiel", + color = Color(0xFF9A9AA8), + fontSize = 14.sp, + ) + } + Text("›", color = Color(0xFF888888), fontSize = 28.sp, fontWeight = FontWeight.Light) + } + } + Spacer(Modifier.height(10.dp)) + SettingsActionRow( + title = "Profielen beheren", + subtitle = "Toevoegen, bewerken of verwijderen", + onClick = onManageProfiles, + ) + } + + item { + Spacer(Modifier.height(16.dp)) + SectionLabel("Account") + Spacer(Modifier.height(10.dp)) + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Color(0xFF1A1A22)) + .padding(horizontal = 22.dp, vertical = 18.dp), + ) { + Column { + Text( + "Vonas Media Cluster", + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) + Text( + "Versie ${BuildConfig.VERSION_NAME}", + color = Color(0xFF8E8E9A), + fontSize = 14.sp, + ) + } + } + Spacer(Modifier.height(10.dp)) + SettingsActionRow( + title = "Uitloggen", + subtitle = "Apparaat ontkoppelen van dit account", + danger = true, + onClick = onLogout, + ) + } + + item { + Spacer(Modifier.height(16.dp)) + SectionLabel("Home-planken") + Spacer(Modifier.height(6.dp)) + Text( + "Kies welke rijen op Home staan en in welke volgorde.", + color = Color(0xFF8E8E9A), + fontSize = 14.sp, + ) + if (error != null) { + Spacer(Modifier.height(8.dp)) + Text(error!!, color = McColors.Danger, fontSize = 14.sp) + } Spacer(Modifier.height(8.dp)) - Text(error!!, color = McColors.Danger) } - Spacer(Modifier.height(4.dp)) - } - itemsIndexed(prefs) { index, p -> - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .background(McColors.BgElevated) - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f)) { - Text(p.name, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium) - Text( - if (p.visible) "Zichtbaar op Home" else "Verborgen", - color = if (p.visible) Color(0xFF888888) else Color(0xFF555555), - fontSize = 13.sp, - ) - } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - McButton("↑") { - if (index > 0) { - val next = prefs.toMutableList() - val tmp = next[index - 1] - next[index - 1] = next[index] - next[index] = tmp - persist(next) + + itemsIndexed(prefs) { index, p -> + Surface( + onClick = { + val next = prefs.toMutableList() + next[index] = p.copy(visible = !p.visible) + persist(next) + }, + modifier = Modifier.fillMaxWidth(), + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.015f), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color(0xFF1A1A22), + focusedContainerColor = Color(0xFF262632), + ), + ) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + p.name, + color = Color.White, + fontSize = 17.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + ) + Spacer(Modifier.height(3.dp)) + Text( + if (p.visible) "Zichtbaar op Home" else "Verborgen", + color = if (p.visible) Color(0xFF8E8E9A) else Color(0xFF555555), + fontSize = 13.sp, + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + McButton("↑") { + if (index > 0) { + val next = prefs.toMutableList() + val tmp = next[index - 1] + next[index - 1] = next[index] + next[index] = tmp + persist(next) + } + } + McButton("↓") { + if (index < prefs.lastIndex) { + val next = prefs.toMutableList() + val tmp = next[index + 1] + next[index + 1] = next[index] + next[index] = tmp + persist(next) + } + } + Switch( + checked = p.visible, + onCheckedChange = { checked -> + val next = prefs.toMutableList() + next[index] = p.copy(visible = checked) + persist(next) + }, + ) } } - McButton("↓") { - if (index < prefs.lastIndex) { - val next = prefs.toMutableList() - val tmp = next[index + 1] - next[index + 1] = next[index] - next[index] = tmp - persist(next) - } - } - Switch( - checked = p.visible, - onCheckedChange = { checked -> - val next = prefs.toMutableList() - next[index] = p.copy(visible = checked) - persist(next) - }, - ) } } + + item { + Spacer(Modifier.height(20.dp)) + Text( + "Vonas Media Cluster · ${BuildConfig.VERSION_NAME}", + color = Color(0xFF44444E), + fontSize = 12.sp, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + } } - item { - Spacer(Modifier.height(12.dp)) - Text( - "Vonas Media Cluster · ${BuildConfig.VERSION_NAME}", - color = Color(0xFF555555), - fontSize = 12.sp, - ) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun SettingsActionRow( + title: String, + subtitle: String, + danger: Boolean = false, + onClick: () -> Unit, +) { + Surface( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color(0xFF1A1A22), + focusedContainerColor = Color(0xFF262632), + ), + ) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 22.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + title, + color = if (danger) McColors.Danger else Color.White, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(3.dp)) + Text(subtitle, color = Color(0xFF8E8E9A), fontSize = 13.sp) + } + Text("›", color = Color(0xFF888888), fontSize = 26.sp, fontWeight = FontWeight.Light) } } } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt index 1c720f5..cd4ef76 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt @@ -15,9 +15,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.runtime.Composable @@ -35,21 +36,21 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Brush 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.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Surface import androidx.tv.material3.Text +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.SessionStore import nl.vonas.mediacluster.tv.data.ViewerProfile import nl.vonas.mediacluster.tv.ui.theme.McColors -private enum class ProfilesMode { Pick, Pin, Create, DeleteConfirm } +private enum class ProfilesMode { Pick, Pin, Create, Edit, DeleteConfirm } @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -71,8 +72,22 @@ fun ProfilesScreen( var createAvatar by remember { mutableStateOf("fox") } var createKids by remember { mutableStateOf(false) } var createPin by remember { mutableStateOf("") } + var clearPin by remember { mutableStateOf(false) } + var editingId by remember { mutableStateOf(null) } val firstFocus = remember { FocusRequester() } + fun openEdit(profile: ViewerProfile) { + editingId = profile.id + pending = profile + createName = profile.name + createAvatar = profile.avatarId + createKids = profile.isKids + createPin = "" + clearPin = false + error = null + mode = ProfilesMode.Edit + } + fun reload() { scope.launch { loading = true @@ -127,6 +142,7 @@ fun ProfilesScreen( Column( Modifier .fillMaxSize() + .verticalScroll(rememberScrollState()) .padding(horizontal = 48.dp, vertical = 36.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -139,7 +155,7 @@ fun ProfilesScreen( Spacer(Modifier.height(8.dp)) Text( when { - !switcherMode && canManage -> "Voeg toe of verwijder (lang drukken)" + !switcherMode && canManage -> "OK = bewerken · lang drukken = verwijderen" switcherMode -> "Kies je profiel" else -> "Alleen het hoofdprofiel mag beheren" }, @@ -153,50 +169,59 @@ fun ProfilesScreen( if (loading) { Text("Profielen laden…", color = Color.White) } else { - LazyRow( - contentPadding = PaddingValues(horizontal = 24.dp), - horizontalArrangement = Arrangement.spacedBy(20.dp), - verticalAlignment = Alignment.CenterVertically, + Box( + Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, ) { - items(profiles, key = { it.id }) { p -> - ProfileTile( - profile = p, - modifier = if (profiles.firstOrNull()?.id == p.id) { - Modifier.focusRequester(firstFocus) - } else Modifier, - onClick = { - if (p.hasPin) { - pending = p - pin = "" - mode = ProfilesMode.Pin + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + profiles.forEachIndexed { index, p -> + ProfileTile( + profile = p, + modifier = if (index == 0) { + Modifier.focusRequester(firstFocus) } else { - select(p) - } - }, - onLongDelete = if (canManage && !p.isOwner) { - { - pending = p - mode = ProfilesMode.DeleteConfirm - } - } else null, - ) - } - if (canManage && profiles.size < 8) { - item { + Modifier + }, + onClick = { + if (!switcherMode && canManage) { + openEdit(p) + } else if (p.hasPin) { + pending = p + pin = "" + mode = ProfilesMode.Pin + } else { + select(p) + } + }, + onLongDelete = if (!switcherMode && canManage && !p.isOwner) { + { + pending = p + mode = ProfilesMode.DeleteConfirm + } + } else null, + ) + } + if (!switcherMode && canManage && profiles.size < 8) { AddProfileTile(onClick = { + editingId = null + pending = null createName = "" createAvatar = "fox" createKids = false createPin = "" + clearPin = false mode = ProfilesMode.Create }) } } } - if (canManage) { + if (!switcherMode && canManage) { Spacer(Modifier.height(20.dp)) Text( - "Houd OK lang ingedrukt op een profiel om te verwijderen", + "Houd OK lang ingedrukt om te verwijderen", color = Color(0xFF666666), fontSize = 13.sp, ) @@ -213,38 +238,29 @@ fun ProfilesScreen( fontWeight = FontWeight.SemiBold, ) Spacer(Modifier.height(16.dp)) - OutlinedTextField( + PinNumpad( value = pin, - onValueChange = { v -> pin = v.filter { it.isDigit() }.take(4) }, - label = { androidx.compose.material3.Text("4-cijferige PIN") }, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), - singleLine = true, - colors = OutlinedTextFieldDefaults.colors( - focusedTextColor = Color.White, - unfocusedTextColor = Color.White, - focusedBorderColor = McColors.Brand, - unfocusedBorderColor = Color(0xFF555555), - focusedLabelColor = McColors.Brand, - unfocusedLabelColor = Color(0xFF888888), - cursorColor = McColors.Brand, - ), + onValueChange = { next -> + pin = next + if (next.length == 4 && target != null) select(target, next) + }, ) Spacer(Modifier.height(16.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - ActionChip("Terug") { - mode = ProfilesMode.Pick - pending = null - pin = "" - } - ActionChip("Ontgrendel", primary = true) { - if (target != null) select(target, pin) - } + ActionChip("Terug") { + mode = ProfilesMode.Pick + pending = null + pin = "" } } - ProfilesMode.Create -> { - Text("Nieuw profiel", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold) + ProfilesMode.Create, ProfilesMode.Edit -> { + val isEdit = mode == ProfilesMode.Edit + Text( + if (isEdit) "Profiel bewerken" else "Nieuw profiel", + color = Color.White, + fontSize = 24.sp, + fontWeight = FontWeight.SemiBold, + ) Spacer(Modifier.height(12.dp)) OutlinedTextField( value = createName, @@ -284,52 +300,86 @@ fun ProfilesScreen( } } } - Spacer(Modifier.height(14.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - ActionChip(if (createKids) "Kids: aan" else "Kids: uit") { - createKids = !createKids + if (canManage && (!isEdit || pending?.isOwner != true)) { + Spacer(Modifier.height(14.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + ActionChip(if (createKids) "Kids: aan" else "Kids: uit") { + createKids = !createKids + } } } - Spacer(Modifier.height(10.dp)) - OutlinedTextField( - value = createPin, - onValueChange = { v -> createPin = v.filter { it.isDigit() }.take(4) }, - label = { androidx.compose.material3.Text("PIN (optioneel)") }, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), - singleLine = true, - colors = OutlinedTextFieldDefaults.colors( - focusedTextColor = Color.White, - unfocusedTextColor = Color.White, - focusedBorderColor = McColors.Brand, - unfocusedBorderColor = Color(0xFF555555), - focusedLabelColor = McColors.Brand, - unfocusedLabelColor = Color(0xFF888888), - cursorColor = McColors.Brand, - ), - ) Spacer(Modifier.height(16.dp)) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - ActionChip("Annuleer") { mode = ProfilesMode.Pick } - ActionChip("Aanmaken", primary = true) { + ActionChip("Annuleer") { + mode = ProfilesMode.Pick + editingId = null + pending = null + error = null + } + ActionChip(if (isEdit) "Opslaan" else "Aanmaken", primary = true) { scope.launch { error = null + val editId = editingId runCatching { - api.createProfile( - name = createName.trim(), - avatarId = createAvatar, - isKids = createKids, - pin = createPin.ifBlank { null }, - ) - }.onSuccess { + if (editId != null) { + api.updateProfile( + profileId = editId, + name = createName.trim(), + avatarId = createAvatar, + isKids = if (canManage && pending?.isOwner != true) createKids else null, + pin = createPin.ifBlank { null }, + clearPin = clearPin, + ) + } else { + api.createProfile( + name = createName.trim(), + avatarId = createAvatar, + isKids = createKids, + pin = createPin.ifBlank { null }, + ) + } + }.onSuccess { saved -> + val localId = store.profileId.first() + if (localId == saved.id || editId == localId) { + store.setProfile(saved.id, saved.name, saved.avatarId) + } mode = ProfilesMode.Pick + editingId = null + pending = null reload() }.onFailure { - error = it.message ?: "Aanmaken mislukt" + error = it.message ?: if (isEdit) "Opslaan mislukt" else "Aanmaken mislukt" } } } } + Spacer(Modifier.height(18.dp)) + Text( + when { + clearPin -> "PIN wordt verwijderd bij opslaan" + isEdit && pending?.hasPin == true -> "Nieuwe PIN (optioneel)" + else -> "PIN (optioneel)" + }, + color = Color(0xFFCCCCCC), + fontSize = 14.sp, + ) + if (isEdit && pending?.hasPin == true) { + Spacer(Modifier.height(10.dp)) + ActionChip(if (clearPin) "PIN verwijderen: aan" else "PIN verwijderen") { + clearPin = !clearPin + if (clearPin) createPin = "" + } + } + if (!clearPin) { + Spacer(Modifier.height(12.dp)) + PinNumpad( + value = createPin, + onValueChange = { next -> + createPin = next + if (next.isNotEmpty()) clearPin = false + }, + ) + } } ProfilesMode.DeleteConfirm -> { @@ -387,8 +437,8 @@ private fun ProfileTile( Surface( onClick = onClick, onLongClick = onLongDelete, - modifier = modifier.width(140.dp), - shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)), + modifier = modifier.width(152.dp), + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), colors = ClickableSurfaceDefaults.colors( containerColor = Color(0xFF1C1C24), @@ -396,25 +446,34 @@ private fun ProfileTile( ), ) { Column( - Modifier.padding(16.dp), + Modifier + .fillMaxWidth() + .padding(vertical = 18.dp, horizontal = 12.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Box( Modifier - .size(88.dp) + .size(92.dp) .clip(CircleShape) .background(Color(0xFF2E2E3A)), contentAlignment = Alignment.Center, ) { - Text(avatarEmoji(profile.avatarId), fontSize = 40.sp) + Text( + avatarEmoji(profile.avatarId), + fontSize = 42.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) } - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(14.dp)) Text( profile.name, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), ) val badge = when { profile.isKids -> "Kids" @@ -422,10 +481,14 @@ private fun ProfileTile( profile.hasPin -> "PIN" else -> null } - if (badge != null) { - Spacer(Modifier.height(4.dp)) - Text(badge, color = Color(0xFF999999), fontSize = 12.sp) - } + Spacer(Modifier.height(4.dp)) + Text( + badge ?: " ", + color = if (badge != null) Color(0xFF999999) else Color.Transparent, + fontSize = 12.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) } } } @@ -435,8 +498,8 @@ private fun ProfileTile( private fun AddProfileTile(onClick: () -> Unit) { Surface( onClick = onClick, - modifier = Modifier.width(140.dp), - shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)), + modifier = Modifier.width(152.dp), + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), colors = ClickableSurfaceDefaults.colors( containerColor = Color(0xFF16161E), @@ -444,20 +507,43 @@ private fun AddProfileTile(onClick: () -> Unit) { ), ) { Column( - Modifier.padding(16.dp), + Modifier + .fillMaxWidth() + .padding(vertical = 18.dp, horizontal = 12.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Box( Modifier - .size(88.dp) + .size(92.dp) .clip(CircleShape) .background(Color(0xFF2A2A32)), contentAlignment = Alignment.Center, ) { - Text("+", color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.Light) + Text( + "+", + color = Color.White, + fontSize = 36.sp, + fontWeight = FontWeight.Light, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) } - Spacer(Modifier.height(12.dp)) - Text("Toevoegen", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(14.dp)) + Text( + "Toevoegen", + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(4.dp)) + Text( + " ", + color = Color.Transparent, + fontSize = 12.sp, + modifier = Modifier.fillMaxWidth(), + ) } } } @@ -485,3 +571,71 @@ private fun ActionChip(label: String, primary: Boolean = false, onClick: () -> U ) } } + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun PinNumpad( + value: String, + onValueChange: (String) -> Unit, + maxLen: Int = 4, +) { + fun append(digit: String) { + if (value.length < maxLen) onValueChange(value + digit) + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + repeat(maxLen) { index -> + Box( + Modifier + .size(16.dp) + .clip(CircleShape) + .background( + if (index < value.length) Color.White else Color(0xFF3A3A48), + ), + ) + } + } + Spacer(Modifier.height(14.dp)) + listOf( + listOf("1", "2", "3"), + listOf("4", "5", "6"), + listOf("7", "8", "9"), + listOf("⌫", "0", "C"), + ).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + row.forEach { key -> + Surface( + onClick = { + when (key) { + "⌫" -> if (value.isNotEmpty()) onValueChange(value.dropLast(1)) + "C" -> onValueChange("") + else -> append(key) + } + }, + modifier = Modifier.size(56.dp), + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.1f), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color(0xFF2A2A32), + focusedContainerColor = McColors.Brand, + contentColor = Color.White, + focusedContentColor = Color.White, + ), + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + key, + color = Color.Unspecified, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + } + } + } + } + Spacer(Modifier.height(10.dp)) + } + } +}