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),
) { ) {
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( Text(
"Profiel wisselen", profileName?.takeIf { it.isNotBlank() } ?: "Profiel",
color = Color.White, color = Color.White,
fontSize = McDimens.body, fontSize = McDimens.body,
fontWeight = FontWeight.SemiBold, 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

@ -1,6 +1,9 @@
package nl.vonas.mediacluster.tv.ui.profiles package nl.vonas.mediacluster.tv.ui.profiles
import androidx.compose.foundation.background 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.items 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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions 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
@ -30,28 +37,29 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.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.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
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.components.McButton
import nl.vonas.mediacluster.tv.ui.theme.McColors 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 @Composable
fun ProfilesScreen( fun ProfilesScreen(
api: ApiClient, api: ApiClient,
@ -63,6 +71,7 @@ fun ProfilesScreen(
var profiles by remember { mutableStateOf<List<ViewerProfile>>(emptyList()) } var profiles by remember { mutableStateOf<List<ViewerProfile>>(emptyList()) }
var canManage by remember { mutableStateOf(false) } var canManage by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var mode by remember { mutableStateOf(ProfilesMode.Pick) } var mode by remember { mutableStateOf(ProfilesMode.Pick) }
var pending by remember { mutableStateOf<ViewerProfile?>(null) } var pending by remember { mutableStateOf<ViewerProfile?>(null) }
@ -71,7 +80,20 @@ 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("") }
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() { fun reload() {
scope.launch { scope.launch {
@ -93,11 +115,12 @@ fun ProfilesScreen(
store.setProfile(null) store.setProfile(null)
} }
reload() reload()
runCatching { firstFocus.requestFocus() }
} }
fun select(profile: ViewerProfile, pinValue: String? = null) { fun select(profile: ViewerProfile, pinValue: String? = null) {
if (busy) return
scope.launch { scope.launch {
busy = true
error = null error = null
runCatching { api.selectProfile(profile.id, pinValue) } runCatching { api.selectProfile(profile.id, pinValue) }
.onSuccess { selected -> .onSuccess { selected ->
@ -112,6 +135,7 @@ fun ProfilesScreen(
pin = "" pin = ""
} }
} }
busy = false
} }
} }
@ -120,241 +144,154 @@ fun ProfilesScreen(
.fillMaxSize() .fillMaxSize()
.background( .background(
Brush.verticalGradient( 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)) .statusBarsPadding()
Text( .navigationBarsPadding(),
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) { when (mode) {
ProfilesMode.Pick -> { ProfilesMode.Pick -> PickContent(
if (loading) { switcherMode = switcherMode,
Text("Profielen laden…", color = Color.White) canManage = canManage,
} else { loading = loading,
LazyRow( busy = busy,
contentPadding = PaddingValues(horizontal = 24.dp), profiles = profiles,
horizontalArrangement = Arrangement.spacedBy(20.dp), error = error,
verticalAlignment = Alignment.CenterVertically, onSelect = { profile ->
) { if (!switcherMode && canManage) {
items(profiles, key = { it.id }) { p -> openEdit(profile)
ProfileTile( } else if (profile.hasPin) {
profile = p, pending = profile
modifier = if (profiles.firstOrNull()?.id == p.id) {
Modifier.focusRequester(firstFocus)
} else Modifier,
onClick = {
if (p.hasPin) {
pending = p
pin = "" pin = ""
mode = ProfilesMode.Pin mode = ProfilesMode.Pin
} else { } else {
select(p) select(profile)
} }
}, },
onLongDelete = if (canManage && !p.isOwner) { onLongDelete = if (canManage) {
{ { profile ->
pending = p if (!profile.isOwner) {
pending = profile
mode = ProfilesMode.DeleteConfirm mode = ProfilesMode.DeleteConfirm
} }
} else null,
)
} }
if (canManage && profiles.size < 8) { } else null,
item { onAdd = {
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) {
Spacer(Modifier.height(20.dp))
Text(
"Houd OK lang ingedrukt op een profiel om te verwijderen",
color = Color(0xFF666666),
fontSize = 13.sp,
) )
}
}
}
ProfilesMode.Pin -> { ProfilesMode.Pin -> PinContent(
val target = pending name = pending?.name ?: "profiel",
Text( pin = pin,
"PIN voor ${target?.name ?: "profiel"}", onPinChange = { pin = it.filter(Char::isDigit).take(4) },
color = Color.White, busy = busy,
fontSize = 24.sp, error = error,
fontWeight = FontWeight.SemiBold, onBack = {
)
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 mode = ProfilesMode.Pick
pending = null pending = null
pin = "" 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 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 { 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( api.createProfile(
name = createName.trim(), name = createName.trim(),
avatarId = createAvatar, avatarId = createAvatar,
isKids = createKids, isKids = createKids,
pin = createPin.ifBlank { null }, 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 mode = ProfilesMode.Pick
editingId = null
pending = null
reload() reload()
}.onFailure { }.onFailure {
error = it.message ?: "Aanmaken mislukt" error = it.message ?: "Opslaan mislukt"
}
}
}
} }
busy = false
} }
},
)
ProfilesMode.DeleteConfirm -> { ProfilesMode.DeleteConfirm -> DeleteContent(
val target = pending name = pending?.name ?: "profiel",
Text( busy = busy,
"Verwijder ${target?.name ?: "profiel"}?", error = error,
color = Color.White, onCancel = {
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 mode = ProfilesMode.Pick
pending = null pending = null
} error = null
ActionChip("Verwijderen", primary = true) { },
val id = target?.id ?: return@ActionChip onConfirm = {
val id = pending?.id ?: return@DeleteContent
if (busy) return@DeleteContent
scope.launch { scope.launch {
busy = true
error = null
runCatching { api.deleteProfile(id) } runCatching { api.deleteProfile(id) }
.onSuccess { .onSuccess {
mode = ProfilesMode.Pick mode = ProfilesMode.Pick
@ -362,6 +299,91 @@ fun ProfilesScreen(
reload() reload()
} }
.onFailure { error = it.message ?: "Verwijderen mislukt" } .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 { error?.let {
Spacer(Modifier.height(16.dp)) Text(
Text(it, color = Color(0xFFFF6B6B), fontSize = 14.sp) 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 @Composable
private fun ProfileTile( private fun ProfileTile(
profile: ViewerProfile, profile: ViewerProfile,
enabled: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, onLongClick: (() -> Unit)?,
onLongDelete: (() -> Unit)? = null,
) { ) {
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( Column(
Modifier.padding(16.dp), modifier = Modifier
.fillMaxWidth()
.combinedClickable(
enabled = enabled,
onClick = onClick,
onLongClick = onLongClick,
)
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Box( Box(
Modifier Modifier
.size(88.dp) .size(96.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color(0xFF2E2E3A)), .background(Color(0xFF2A2A36))
.border(2.dp, Color(0x33FFFFFF), CircleShape),
contentAlignment = Alignment.Center, 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( Text(
profile.name, profile.name,
color = Color.White, color = Color.White,
fontSize = 16.sp, fontSize = 15.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
) )
val badge = when { val badge = when {
profile.isKids -> "Kids" profile.isKids -> "Kids"
@ -424,64 +460,252 @@ private fun ProfileTile(
} }
if (badge != null) { if (badge != null) {
Spacer(Modifier.height(4.dp)) 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) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
private fun AddProfileTile(onClick: () -> Unit) { private fun AddProfileTile(enabled: Boolean, 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),
),
) {
Column( Column(
Modifier.padding(16.dp), modifier = Modifier
.fillMaxWidth()
.clickable(enabled = enabled, onClick = onClick)
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Box( Box(
Modifier Modifier
.size(88.dp) .size(96.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color(0xFF2A2A32)), .background(Color(0xFF1C1C26))
.border(2.dp, Color(0x44FFFFFF), CircleShape),
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)
} }
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)) 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) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
private fun ActionChip(label: String, primary: Boolean = false, onClick: () -> Unit) { private fun ProfileFormContent(
Surface( title: String,
onClick = onClick, name: String,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)), onNameChange: (String) -> Unit,
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), avatarId: String,
colors = ClickableSurfaceDefaults.colors( onAvatarChange: (String) -> Unit,
containerColor = if (primary) McColors.Brand else Color(0xFF2A2A32), isKids: Boolean,
focusedContainerColor = if (primary) McColors.Brand else Color(0xFF3A3A48), onKidsToggle: () -> Unit,
contentColor = Color.White, showKidsToggle: Boolean,
focusedContentColor = Color.White, 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( Text(title, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Bold)
label, Spacer(Modifier.height(20.dp))
Modifier.padding(horizontal = 18.dp, vertical = 10.dp), OutlinedTextField(
color = Color.Unspecified, value = name,
fontSize = 15.sp, onValueChange = onNameChange,
fontWeight = FontWeight.Medium, 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" 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,65 +1366,191 @@ private fun SettingsScreen(
} }
} }
Box(
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(Color(0xFF0B0B0F), Color(0xFF12121A), Color(0xFF0B0B0F)),
),
),
) {
LazyColumn( LazyColumn(
Modifier.fillMaxSize().background(McColors.Bg).padding(40.dp), Modifier
verticalArrangement = Arrangement.spacedBy(12.dp), .fillMaxSize()
.padding(horizontal = 48.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(bottom = 40.dp),
) { ) {
item { 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( Text(
"Kies welke rijen op Home staan en in welke volgorde.", "Instellingen",
color = McColors.TextDim, 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, fontSize = 15.sp,
) )
if (error != null) { Spacer(Modifier.height(22.dp))
Spacer(Modifier.height(8.dp))
Text(error!!, color = McColors.Danger)
} }
Spacer(Modifier.height(4.dp))
} item {
itemsIndexed(prefs) { index, p -> 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( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(14.dp)) .padding(horizontal = 22.dp, vertical = 18.dp),
.background(McColors.BgElevated) verticalAlignment = Alignment.CenterVertically,
.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(18.dp),
horizontalArrangement = Arrangement.SpaceBetween, ) {
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, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column(Modifier.weight(1f)) { 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( Text(
if (p.visible) "Zichtbaar op Home" else "Verborgen", 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, fontSize = 13.sp,
) )
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
McButton("↑") { McButton("↑") {
if (index > 0) { if (index > 0) {
val next = prefs.toMutableList() val next = prefs.toMutableList()
@ -1445,15 +1580,59 @@ private fun SettingsScreen(
} }
} }
} }
}
item { item {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(20.dp))
Text( Text(
"Vonas Media Cluster · ${BuildConfig.VERSION_NAME}", "Vonas Media Cluster · ${BuildConfig.VERSION_NAME}",
color = Color(0xFF555555), color = Color(0xFF44444E),
fontSize = 12.sp, 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. */ /** 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.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,19 +169,26 @@ 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,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(24.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
items(profiles, key = { it.id }) { p -> profiles.forEachIndexed { index, p ->
ProfileTile( ProfileTile(
profile = p, profile = p,
modifier = if (profiles.firstOrNull()?.id == p.id) { modifier = if (index == 0) {
Modifier.focusRequester(firstFocus) Modifier.focusRequester(firstFocus)
} else Modifier, } else {
Modifier
},
onClick = { onClick = {
if (p.hasPin) { if (!switcherMode && canManage) {
openEdit(p)
} else if (p.hasPin) {
pending = p pending = p
pin = "" pin = ""
mode = ProfilesMode.Pin mode = ProfilesMode.Pin
@ -173,7 +196,7 @@ fun ProfilesScreen(
select(p) select(p)
} }
}, },
onLongDelete = if (canManage && !p.isOwner) { onLongDelete = if (!switcherMode && canManage && !p.isOwner) {
{ {
pending = p pending = p
mode = ProfilesMode.DeleteConfirm mode = ProfilesMode.DeleteConfirm
@ -181,22 +204,24 @@ fun ProfilesScreen(
} else null, } else null,
) )
} }
if (canManage && profiles.size < 8) { if (!switcherMode && canManage && profiles.size < 8) {
item {
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(
} }
} }
} }
if (canManage && (!isEdit || pending?.isOwner != true)) {
Spacer(Modifier.height(14.dp)) Spacer(Modifier.height(14.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ActionChip(if (createKids) "Kids: aan" else "Kids: uit") { ActionChip(if (createKids) "Kids: aan" else "Kids: uit") {
createKids = !createKids 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 {
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( api.createProfile(
name = createName.trim(), name = createName.trim(),
avatarId = createAvatar, avatarId = createAvatar,
isKids = createKids, isKids = createKids,
pin = createPin.ifBlank { null }, 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 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(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) { 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))
}
}
}