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 <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-07 19:16:28 +02:00
parent 6a0eee0313
commit 2eabd76d93
8 changed files with 1168 additions and 579 deletions

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster" applicationId = "nl.vonas.mediacluster"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 9 versionCode = 11
versionName = "1.0.8" versionName = "1.0.10"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -244,6 +244,26 @@ class ApiClient(
parseObject(execute(req)) { o -> o.getJSONObject("profile").toProfile() } 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) { suspend fun deleteProfile(profileId: String) = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/profiles/$profileId").delete().build() val req = authed("$baseUrl/api/v1/client/profiles/$profileId").delete().build()
execute(req) execute(req)

View file

@ -498,6 +498,8 @@ private fun MainHub(
) )
MainTab.Settings -> SettingsScreen( MainTab.Settings -> SettingsScreen(
api = api, api = api,
profileAvatarId = profileAvatarId,
profileName = profileName,
onBack = { onBack = {
onTabChange(MainTab.Home) onTabChange(MainTab.Home)
focusTopNav() focusTopNav()
@ -509,38 +511,6 @@ private fun MainHub(
} }
} }
StatusBarGlassOverlay(Modifier.align(Alignment.TopCenter)) 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)) { Box(Modifier.focusRequester(navFocus)) {
TopNavBar( TopNavBar(
@ -1117,6 +1087,8 @@ private fun ShelfBlock(
@Composable @Composable
private fun SettingsScreen( private fun SettingsScreen(
api: ApiClient, api: ApiClient,
profileAvatarId: String? = null,
profileName: String? = null,
onBack: () -> Unit, onBack: () -> Unit,
onSwitchProfile: () -> Unit = {}, onSwitchProfile: () -> Unit = {},
onManageProfiles: () -> Unit = {}, onManageProfiles: () -> Unit = {},
@ -1189,20 +1161,40 @@ private fun SettingsScreen(
) )
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Box( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background(McColors.BgElevated) .background(McColors.BgElevated)
.clickable(onClick = onSwitchProfile) .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( Box(
"Profiel wisselen", Modifier
color = Color.White, .size(44.dp)
fontSize = McDimens.body, .clip(CircleShape)
fontWeight = FontWeight.SemiBold, .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)) Spacer(Modifier.height(8.dp))
Box( Box(

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 71 versionCode = 74
versionName = "0.14.8" versionName = "0.14.11"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -244,6 +244,26 @@ class ApiClient(
parseObject(execute(req)) { o -> o.getJSONObject("profile").toProfile() } 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) { suspend fun deleteProfile(profileId: String) = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/profiles/$profileId").delete().build() val req = authed("$baseUrl/api/v1/client/profiles/$profileId").delete().build()
execute(req) execute(req)

View file

@ -21,12 +21,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect 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.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -628,6 +631,8 @@ private fun MainHub(
) )
MainTab.Settings -> SettingsScreen( MainTab.Settings -> SettingsScreen(
api = api, api = api,
profileAvatarId = profileAvatarId,
profileName = profileName,
onBack = { onBack = {
onTabChange(MainTab.Home) onTabChange(MainTab.Home)
focusTopNav() focusTopNav()
@ -1329,6 +1334,8 @@ private fun ShelfBlock(
@Composable @Composable
private fun SettingsScreen( private fun SettingsScreen(
api: ApiClient, api: ApiClient,
profileAvatarId: String? = null,
profileName: String? = null,
onBack: () -> Unit, onBack: () -> Unit,
onSwitchProfile: () -> Unit = {}, onSwitchProfile: () -> Unit = {},
onManageProfiles: () -> Unit = {}, onManageProfiles: () -> Unit = {},
@ -1337,6 +1344,7 @@ private fun SettingsScreen(
var prefs by remember { mutableStateOf<List<ShelfPref>>(emptyList()) } var prefs by remember { mutableStateOf<List<ShelfPref>>(emptyList()) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val firstFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
try { try {
@ -1344,6 +1352,7 @@ private fun SettingsScreen(
} catch (e: Exception) { } catch (e: Exception) {
error = e.message error = e.message
} }
runCatching { firstFocus.requestFocus() }
} }
fun persist(next: List<ShelfPref>) { fun persist(next: List<ShelfPref>) {
@ -1357,101 +1366,271 @@ private fun SettingsScreen(
} }
} }
LazyColumn( Box(
Modifier.fillMaxSize().background(McColors.Bg).padding(40.dp), Modifier
verticalArrangement = Arrangement.spacedBy(12.dp), .fillMaxSize()
.background(
Brush.verticalGradient(
listOf(Color(0xFF0B0B0F), Color(0xFF12121A), Color(0xFF0B0B0F)),
),
),
) { ) {
item { LazyColumn(
Text("Instellingen", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold) Modifier
Spacer(Modifier.height(16.dp)) .fillMaxSize()
SectionLabel("Account") .padding(horizontal = 48.dp, vertical = 28.dp),
Spacer(Modifier.height(8.dp)) verticalArrangement = Arrangement.spacedBy(10.dp),
Column( contentPadding = PaddingValues(bottom = 40.dp),
Modifier ) {
.fillMaxWidth() item {
.clip(RoundedCornerShape(14.dp)) Text(
.background(McColors.BgElevated) "Instellingen",
.padding(20.dp), color = Color.White,
verticalArrangement = Arrangement.spacedBy(4.dp), fontSize = 32.sp,
) { fontWeight = FontWeight.Bold,
Text("Vonas Media Cluster", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) )
Text("Versie ${BuildConfig.VERSION_NAME}", color = McColors.TextDim, fontSize = 14.sp) 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) item {
Spacer(Modifier.height(12.dp)) SectionLabel("Profiel")
McButton("Profielen beheren", onClick = onManageProfiles) Spacer(Modifier.height(10.dp))
Spacer(Modifier.height(12.dp)) Surface(
McButton("Uitloggen", onClick = onLogout) onClick = onSwitchProfile,
Spacer(Modifier.height(20.dp)) modifier = Modifier
SectionLabel("Home-planken") .fillMaxWidth()
Spacer(Modifier.height(4.dp)) .focusRequester(firstFocus),
Text( shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(16.dp)),
"Kies welke rijen op Home staan en in welke volgorde.", scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f),
color = McColors.TextDim, colors = ClickableSurfaceDefaults.colors(
fontSize = 15.sp, containerColor = Color(0xFF1A1A22),
) focusedContainerColor = Color(0xFF262632),
if (error != null) { ),
) {
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)) Spacer(Modifier.height(8.dp))
Text(error!!, color = McColors.Danger)
} }
Spacer(Modifier.height(4.dp))
} itemsIndexed(prefs) { index, p ->
itemsIndexed(prefs) { index, p -> Surface(
Row( onClick = {
Modifier val next = prefs.toMutableList()
.fillMaxWidth() next[index] = p.copy(visible = !p.visible)
.clip(RoundedCornerShape(14.dp)) persist(next)
.background(McColors.BgElevated) },
.padding(16.dp), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)),
verticalAlignment = Alignment.CenterVertically, scale = ClickableSurfaceDefaults.scale(focusedScale = 1.015f),
) { colors = ClickableSurfaceDefaults.colors(
Column(Modifier.weight(1f)) { containerColor = Color(0xFF1A1A22),
Text(p.name, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium) focusedContainerColor = Color(0xFF262632),
Text( ),
if (p.visible) "Zichtbaar op Home" else "Verborgen", ) {
color = if (p.visible) Color(0xFF888888) else Color(0xFF555555), Row(
fontSize = 13.sp, Modifier
) .fillMaxWidth()
} .padding(horizontal = 18.dp, vertical = 14.dp),
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically,
McButton("↑") { ) {
if (index > 0) { Column(Modifier.weight(1f)) {
val next = prefs.toMutableList() Text(
val tmp = next[index - 1] p.name,
next[index - 1] = next[index] color = Color.White,
next[index] = tmp fontSize = 17.sp,
persist(next) 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}", @OptIn(ExperimentalTvMaterial3Api::class)
color = Color(0xFF555555), @Composable
fontSize = 12.sp, 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)
} }
} }
} }

View file

@ -15,9 +15,10 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.runtime.Composable 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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.SessionStore import nl.vonas.mediacluster.tv.data.SessionStore
import nl.vonas.mediacluster.tv.data.ViewerProfile import nl.vonas.mediacluster.tv.data.ViewerProfile
import nl.vonas.mediacluster.tv.ui.theme.McColors 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) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
@ -71,8 +72,22 @@ fun ProfilesScreen(
var createAvatar by remember { mutableStateOf("fox") } var createAvatar by remember { mutableStateOf("fox") }
var createKids by remember { mutableStateOf(false) } var createKids by remember { mutableStateOf(false) }
var createPin by remember { mutableStateOf("") } var createPin by remember { mutableStateOf("") }
var clearPin by remember { mutableStateOf(false) }
var editingId by remember { mutableStateOf<String?>(null) }
val firstFocus = remember { FocusRequester() } 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() { fun reload() {
scope.launch { scope.launch {
loading = true loading = true
@ -127,6 +142,7 @@ fun ProfilesScreen(
Column( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 48.dp, vertical = 36.dp), .padding(horizontal = 48.dp, vertical = 36.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
@ -139,7 +155,7 @@ fun ProfilesScreen(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
when { when {
!switcherMode && canManage -> "Voeg toe of verwijder (lang drukken)" !switcherMode && canManage -> "OK = bewerken · lang drukken = verwijderen"
switcherMode -> "Kies je profiel" switcherMode -> "Kies je profiel"
else -> "Alleen het hoofdprofiel mag beheren" else -> "Alleen het hoofdprofiel mag beheren"
}, },
@ -153,50 +169,59 @@ fun ProfilesScreen(
if (loading) { if (loading) {
Text("Profielen laden…", color = Color.White) Text("Profielen laden…", color = Color.White)
} else { } else {
LazyRow( Box(
contentPadding = PaddingValues(horizontal = 24.dp), Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(20.dp), contentAlignment = Alignment.Center,
verticalAlignment = Alignment.CenterVertically,
) { ) {
items(profiles, key = { it.id }) { p -> Row(
ProfileTile( horizontalArrangement = Arrangement.spacedBy(24.dp),
profile = p, verticalAlignment = Alignment.CenterVertically,
modifier = if (profiles.firstOrNull()?.id == p.id) { ) {
Modifier.focusRequester(firstFocus) profiles.forEachIndexed { index, p ->
} else Modifier, ProfileTile(
onClick = { profile = p,
if (p.hasPin) { modifier = if (index == 0) {
pending = p Modifier.focusRequester(firstFocus)
pin = ""
mode = ProfilesMode.Pin
} else { } else {
select(p) Modifier
} },
}, onClick = {
onLongDelete = if (canManage && !p.isOwner) { if (!switcherMode && canManage) {
{ openEdit(p)
pending = p } else if (p.hasPin) {
mode = ProfilesMode.DeleteConfirm pending = p
} pin = ""
} else null, mode = ProfilesMode.Pin
) } else {
} select(p)
if (canManage && profiles.size < 8) { }
item { },
onLongDelete = if (!switcherMode && canManage && !p.isOwner) {
{
pending = p
mode = ProfilesMode.DeleteConfirm
}
} else null,
)
}
if (!switcherMode && canManage && profiles.size < 8) {
AddProfileTile(onClick = { AddProfileTile(onClick = {
editingId = null
pending = null
createName = "" createName = ""
createAvatar = "fox" createAvatar = "fox"
createKids = false createKids = false
createPin = "" createPin = ""
clearPin = false
mode = ProfilesMode.Create mode = ProfilesMode.Create
}) })
} }
} }
} }
if (canManage) { if (!switcherMode && canManage) {
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
Text( Text(
"Houd OK lang ingedrukt op een profiel om te verwijderen", "Houd OK lang ingedrukt om te verwijderen",
color = Color(0xFF666666), color = Color(0xFF666666),
fontSize = 13.sp, fontSize = 13.sp,
) )
@ -213,38 +238,29 @@ fun ProfilesScreen(
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
OutlinedTextField( PinNumpad(
value = pin, value = pin,
onValueChange = { v -> pin = v.filter { it.isDigit() }.take(4) }, onValueChange = { next ->
label = { androidx.compose.material3.Text("4-cijferige PIN") }, pin = next
visualTransformation = PasswordVisualTransformation(), if (next.length == 4 && target != null) select(target, next)
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)) Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { ActionChip("Terug") {
ActionChip("Terug") { mode = ProfilesMode.Pick
mode = ProfilesMode.Pick pending = null
pending = null pin = ""
pin = ""
}
ActionChip("Ontgrendel", primary = true) {
if (target != null) select(target, pin)
}
} }
} }
ProfilesMode.Create -> { ProfilesMode.Create, ProfilesMode.Edit -> {
Text("Nieuw profiel", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold) 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)) Spacer(Modifier.height(12.dp))
OutlinedTextField( OutlinedTextField(
value = createName, value = createName,
@ -284,52 +300,86 @@ fun ProfilesScreen(
} }
} }
} }
Spacer(Modifier.height(14.dp)) if (canManage && (!isEdit || pending?.isOwner != true)) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Spacer(Modifier.height(14.dp))
ActionChip(if (createKids) "Kids: aan" else "Kids: uit") { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
createKids = !createKids 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)) Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ActionChip("Annuleer") { mode = ProfilesMode.Pick } ActionChip("Annuleer") {
ActionChip("Aanmaken", primary = true) { mode = ProfilesMode.Pick
editingId = null
pending = null
error = null
}
ActionChip(if (isEdit) "Opslaan" else "Aanmaken", primary = true) {
scope.launch { scope.launch {
error = null error = null
val editId = editingId
runCatching { runCatching {
api.createProfile( if (editId != null) {
name = createName.trim(), api.updateProfile(
avatarId = createAvatar, profileId = editId,
isKids = createKids, name = createName.trim(),
pin = createPin.ifBlank { null }, avatarId = createAvatar,
) isKids = if (canManage && pending?.isOwner != true) createKids else null,
}.onSuccess { 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 mode = ProfilesMode.Pick
editingId = null
pending = null
reload() reload()
}.onFailure { }.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 -> { ProfilesMode.DeleteConfirm -> {
@ -387,8 +437,8 @@ private fun ProfileTile(
Surface( Surface(
onClick = onClick, onClick = onClick,
onLongClick = onLongDelete, onLongClick = onLongDelete,
modifier = modifier.width(140.dp), modifier = modifier.width(152.dp),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF1C1C24), containerColor = Color(0xFF1C1C24),
@ -396,25 +446,34 @@ private fun ProfileTile(
), ),
) { ) {
Column( Column(
Modifier.padding(16.dp), Modifier
.fillMaxWidth()
.padding(vertical = 18.dp, horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Box( Box(
Modifier Modifier
.size(88.dp) .size(92.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color(0xFF2E2E3A)), .background(Color(0xFF2E2E3A)),
contentAlignment = Alignment.Center, 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( Text(
profile.name, profile.name,
color = Color.White, color = Color.White,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
) )
val badge = when { val badge = when {
profile.isKids -> "Kids" profile.isKids -> "Kids"
@ -422,10 +481,14 @@ private fun ProfileTile(
profile.hasPin -> "PIN" profile.hasPin -> "PIN"
else -> null else -> null
} }
if (badge != null) { Spacer(Modifier.height(4.dp))
Spacer(Modifier.height(4.dp)) Text(
Text(badge, color = Color(0xFF999999), fontSize = 12.sp) 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) { private fun AddProfileTile(onClick: () -> Unit) {
Surface( Surface(
onClick = onClick, onClick = onClick,
modifier = Modifier.width(140.dp), modifier = Modifier.width(152.dp),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF16161E), containerColor = Color(0xFF16161E),
@ -444,20 +507,43 @@ private fun AddProfileTile(onClick: () -> Unit) {
), ),
) { ) {
Column( Column(
Modifier.padding(16.dp), Modifier
.fillMaxWidth()
.padding(vertical = 18.dp, horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Box( Box(
Modifier Modifier
.size(88.dp) .size(92.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color(0xFF2A2A32)), .background(Color(0xFF2A2A32)),
contentAlignment = Alignment.Center, 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)) Spacer(Modifier.height(14.dp))
Text("Toevoegen", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) 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))
}
}
}