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

View file

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

View file

@ -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),
) {
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(
"Profiel wisselen",
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(

View file

@ -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<List<ViewerProfile>>(emptyList()) }
var canManage by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(true) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var mode by remember { mutableStateOf(ProfilesMode.Pick) }
var pending by remember { mutableStateOf<ViewerProfile?>(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<String?>(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,241 +144,154 @@ fun ProfilesScreen(
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(Color(0xFF0B0B0F), Color(0xFF14141C), Color(0xFF0B0B0F)),
listOf(Color(0xFF0A0A0E), Color(0xFF15151E), Color(0xFF0A0A0E)),
),
),
) {
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))
.statusBarsPadding()
.navigationBarsPadding(),
) {
when (mode) {
ProfilesMode.Pick -> {
if (loading) {
Text("Profielen laden…", color = Color.White)
} 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
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 {
select(p)
select(profile)
}
},
onLongDelete = if (canManage && !p.isOwner) {
{
pending = p
onLongDelete = if (canManage) {
{ profile ->
if (!profile.isOwner) {
pending = profile
mode = ProfilesMode.DeleteConfirm
}
} else null,
)
}
if (canManage && profiles.size < 8) {
item {
AddProfileTile(onClick = {
} else null,
onAdd = {
editingId = null
pending = null
createName = ""
createAvatar = "fox"
createKids = false
createPin = ""
clearPin = false
mode = ProfilesMode.Create
})
}
}
}
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") {
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 = ""
}
ActionChip("Ontgrendel", primary = true) {
if (target != null) select(target, pin)
}
}
}
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)
}
}
}
}
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
},
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 },
)
}.onSuccess {
}
}.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 ?: "Opslaan mislukt"
}
busy = false
}
},
)
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") {
ProfilesMode.DeleteConfirm -> DeleteContent(
name = pending?.name ?: "profiel",
busy = busy,
error = error,
onCancel = {
mode = ProfilesMode.Pick
pending = null
}
ActionChip("Verwijderen", primary = true) {
val id = target?.id ?: return@ActionChip
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
@ -362,6 +299,91 @@ fun ProfilesScreen(
reload()
}
.onFailure { error = it.message ?: "Verwijderen mislukt" }
busy = false
}
},
)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
private fun PickContent(
switcherMode: Boolean,
canManage: Boolean,
loading: Boolean,
busy: Boolean,
profiles: List<ViewerProfile>,
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)
}
}
}
}
@ -369,52 +391,66 @@ fun ProfilesScreen(
}
error?.let {
Spacer(Modifier.height(16.dp))
Text(it, color = Color(0xFFFF6B6B), fontSize = 14.sp)
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)
@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),
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
enabled = enabled,
onClick = onClick,
onLongClick = onLongClick,
)
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
Modifier
.size(88.dp)
.size(96.dp)
.clip(CircleShape)
.background(Color(0xFF2E2E3A)),
.background(Color(0xFF2A2A36))
.border(2.dp, Color(0x33FFFFFF), CircleShape),
contentAlignment = Alignment.Center,
) {
Text(avatarEmoji(profile.avatarId), fontSize = 40.sp)
Text(avatarEmoji(profile.avatarId), fontSize = 42.sp)
}
Spacer(Modifier.height(12.dp))
Spacer(Modifier.height(10.dp))
Text(
profile.name,
color = Color.White,
fontSize = 16.sp,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
val badge = when {
profile.isKids -> "Kids"
@ -424,64 +460,252 @@ private fun ProfileTile(
}
if (badge != null) {
Spacer(Modifier.height(4.dp))
Text(badge, color = Color(0xFF999999), fontSize = 12.sp)
}
Text(badge, color = Color(0xFF8E8E9A), fontSize = 12.sp)
} else {
Spacer(Modifier.height(16.dp))
}
}
}
@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),
),
) {
private fun AddProfileTile(enabled: Boolean, onClick: () -> Unit) {
Column(
Modifier.padding(16.dp),
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = enabled, onClick = onClick)
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
Modifier
.size(88.dp)
.size(96.dp)
.clip(CircleShape)
.background(Color(0xFF2A2A32)),
.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(
"Toevoegen",
color = Color.White,
fontSize = 15.sp,
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("Toevoegen", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
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 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 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(
label,
Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
color = Color.Unspecified,
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
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,
)

View file

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

View file

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

View file

@ -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<List<ShelfPref>>(emptyList()) }
var error by remember { mutableStateOf<String?>(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<ShelfPref>) {
@ -1357,65 +1366,191 @@ private fun SettingsScreen(
}
}
Box(
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(Color(0xFF0B0B0F), Color(0xFF12121A), Color(0xFF0B0B0F)),
),
),
) {
LazyColumn(
Modifier.fillMaxSize().background(McColors.Bg).padding(40.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
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 = 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)
}
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,
"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,
)
if (error != null) {
Spacer(Modifier.height(8.dp))
Text(error!!, color = McColors.Danger)
Spacer(Modifier.height(22.dp))
}
Spacer(Modifier.height(4.dp))
}
itemsIndexed(prefs) { index, p ->
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()
.clip(RoundedCornerShape(14.dp))
.background(McColors.BgElevated)
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
.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))
}
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 = 18.sp, fontWeight = FontWeight.Medium)
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(0xFF888888) else Color(0xFF555555),
color = if (p.visible) Color(0xFF8E8E9A) else Color(0xFF555555),
fontSize = 13.sp,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
McButton("↑") {
if (index > 0) {
val next = prefs.toMutableList()
@ -1445,15 +1580,59 @@ private fun SettingsScreen(
}
}
}
}
item {
Spacer(Modifier.height(12.dp))
Spacer(Modifier.height(20.dp))
Text(
"Vonas Media Cluster · ${BuildConfig.VERSION_NAME}",
color = Color(0xFF555555),
color = Color(0xFF44444E),
fontSize = 12.sp,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
}
}
}
}
@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)
}
}
}
/** Android JSONObject.optString returns the literal "null" for JSON null — filter that. */

View file

@ -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<String?>(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,19 +169,26 @@ fun ProfilesScreen(
if (loading) {
Text("Profielen laden…", color = Color.White)
} else {
LazyRow(
contentPadding = PaddingValues(horizontal = 24.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp),
Box(
Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(24.dp),
verticalAlignment = Alignment.CenterVertically,
) {
items(profiles, key = { it.id }) { p ->
profiles.forEachIndexed { index, p ->
ProfileTile(
profile = p,
modifier = if (profiles.firstOrNull()?.id == p.id) {
modifier = if (index == 0) {
Modifier.focusRequester(firstFocus)
} else Modifier,
} else {
Modifier
},
onClick = {
if (p.hasPin) {
if (!switcherMode && canManage) {
openEdit(p)
} else if (p.hasPin) {
pending = p
pin = ""
mode = ProfilesMode.Pin
@ -173,7 +196,7 @@ fun ProfilesScreen(
select(p)
}
},
onLongDelete = if (canManage && !p.isOwner) {
onLongDelete = if (!switcherMode && canManage && !p.isOwner) {
{
pending = p
mode = ProfilesMode.DeleteConfirm
@ -181,22 +204,24 @@ fun ProfilesScreen(
} else null,
)
}
if (canManage && profiles.size < 8) {
item {
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)
}
}
}
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(
}
}
}
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 {
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 {
}
}.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)
}
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))
}
}
}