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),
) {
Text(
"Profiel wisselen",
color = Color.White,
fontSize = McDimens.body,
fontWeight = FontWeight.SemiBold,
)
Box(
Modifier
.size(44.dp)
.clip(CircleShape)
.background(Color(0xFF2E2E3A)),
contentAlignment = Alignment.Center,
) {
Text(avatarEmoji(profileAvatarId ?: "fox"), fontSize = 22.sp)
}
Column(Modifier.weight(1f)) {
Text(
profileName?.takeIf { it.isNotBlank() } ?: "Profiel",
color = Color.White,
fontSize = McDimens.body,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
Text(
"Tik om van profiel te wisselen",
color = McColors.TextDim,
fontSize = McDimens.meta,
)
}
Text("›", color = Color(0xFF888888), fontSize = 22.sp)
}
Spacer(Modifier.height(8.dp))
Box(

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,101 +1366,271 @@ private fun SettingsScreen(
}
}
LazyColumn(
Modifier.fillMaxSize().background(McColors.Bg).padding(40.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
Box(
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(Color(0xFF0B0B0F), Color(0xFF12121A), Color(0xFF0B0B0F)),
),
),
) {
item {
Text("Instellingen", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(16.dp))
SectionLabel("Account")
Spacer(Modifier.height(8.dp))
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(McColors.BgElevated)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text("Vonas Media Cluster", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
Text("Versie ${BuildConfig.VERSION_NAME}", color = McColors.TextDim, fontSize = 14.sp)
LazyColumn(
Modifier
.fillMaxSize()
.padding(horizontal = 48.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(bottom = 40.dp),
) {
item {
Text(
"Instellingen",
color = Color.White,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(6.dp))
Text(
"Profiel, account en home-indeling",
color = Color(0xFF8E8E9A),
fontSize = 15.sp,
)
Spacer(Modifier.height(22.dp))
}
Spacer(Modifier.height(8.dp))
McButton("Profiel wisselen", onClick = onSwitchProfile)
Spacer(Modifier.height(12.dp))
McButton("Profielen beheren", onClick = onManageProfiles)
Spacer(Modifier.height(12.dp))
McButton("Uitloggen", onClick = onLogout)
Spacer(Modifier.height(20.dp))
SectionLabel("Home-planken")
Spacer(Modifier.height(4.dp))
Text(
"Kies welke rijen op Home staan en in welke volgorde.",
color = McColors.TextDim,
fontSize = 15.sp,
)
if (error != null) {
item {
SectionLabel("Profiel")
Spacer(Modifier.height(10.dp))
Surface(
onClick = onSwitchProfile,
modifier = Modifier
.fillMaxWidth()
.focusRequester(firstFocus),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(16.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF1A1A22),
focusedContainerColor = Color(0xFF262632),
),
) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 22.dp, vertical = 18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(18.dp),
) {
Box(
Modifier
.size(64.dp)
.clip(CircleShape)
.background(Color(0xFF2E2E3A)),
contentAlignment = Alignment.Center,
) {
Text(
avatarEmoji(profileAvatarId ?: "fox"),
fontSize = 30.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
Column(Modifier.weight(1f)) {
Text(
profileName?.takeIf { it.isNotBlank() } ?: "Profiel",
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
Spacer(Modifier.height(4.dp))
Text(
"Wissel van kijkprofiel",
color = Color(0xFF9A9AA8),
fontSize = 14.sp,
)
}
Text("›", color = Color(0xFF888888), fontSize = 28.sp, fontWeight = FontWeight.Light)
}
}
Spacer(Modifier.height(10.dp))
SettingsActionRow(
title = "Profielen beheren",
subtitle = "Toevoegen, bewerken of verwijderen",
onClick = onManageProfiles,
)
}
item {
Spacer(Modifier.height(16.dp))
SectionLabel("Account")
Spacer(Modifier.height(10.dp))
Box(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Color(0xFF1A1A22))
.padding(horizontal = 22.dp, vertical = 18.dp),
) {
Column {
Text(
"Vonas Media Cluster",
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(4.dp))
Text(
"Versie ${BuildConfig.VERSION_NAME}",
color = Color(0xFF8E8E9A),
fontSize = 14.sp,
)
}
}
Spacer(Modifier.height(10.dp))
SettingsActionRow(
title = "Uitloggen",
subtitle = "Apparaat ontkoppelen van dit account",
danger = true,
onClick = onLogout,
)
}
item {
Spacer(Modifier.height(16.dp))
SectionLabel("Home-planken")
Spacer(Modifier.height(6.dp))
Text(
"Kies welke rijen op Home staan en in welke volgorde.",
color = Color(0xFF8E8E9A),
fontSize = 14.sp,
)
if (error != null) {
Spacer(Modifier.height(8.dp))
Text(error!!, color = McColors.Danger, fontSize = 14.sp)
}
Spacer(Modifier.height(8.dp))
Text(error!!, color = McColors.Danger)
}
Spacer(Modifier.height(4.dp))
}
itemsIndexed(prefs) { index, p ->
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(McColors.BgElevated)
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(p.name, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium)
Text(
if (p.visible) "Zichtbaar op Home" else "Verborgen",
color = if (p.visible) Color(0xFF888888) else Color(0xFF555555),
fontSize = 13.sp,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
McButton("↑") {
if (index > 0) {
val next = prefs.toMutableList()
val tmp = next[index - 1]
next[index - 1] = next[index]
next[index] = tmp
persist(next)
itemsIndexed(prefs) { index, p ->
Surface(
onClick = {
val next = prefs.toMutableList()
next[index] = p.copy(visible = !p.visible)
persist(next)
},
modifier = Modifier.fillMaxWidth(),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.015f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF1A1A22),
focusedContainerColor = Color(0xFF262632),
),
) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
p.name,
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
)
Spacer(Modifier.height(3.dp))
Text(
if (p.visible) "Zichtbaar op Home" else "Verborgen",
color = if (p.visible) Color(0xFF8E8E9A) else Color(0xFF555555),
fontSize = 13.sp,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
McButton("↑") {
if (index > 0) {
val next = prefs.toMutableList()
val tmp = next[index - 1]
next[index - 1] = next[index]
next[index] = tmp
persist(next)
}
}
McButton("↓") {
if (index < prefs.lastIndex) {
val next = prefs.toMutableList()
val tmp = next[index + 1]
next[index + 1] = next[index]
next[index] = tmp
persist(next)
}
}
Switch(
checked = p.visible,
onCheckedChange = { checked ->
val next = prefs.toMutableList()
next[index] = p.copy(visible = checked)
persist(next)
},
)
}
}
McButton("↓") {
if (index < prefs.lastIndex) {
val next = prefs.toMutableList()
val tmp = next[index + 1]
next[index + 1] = next[index]
next[index] = tmp
persist(next)
}
}
Switch(
checked = p.visible,
onCheckedChange = { checked ->
val next = prefs.toMutableList()
next[index] = p.copy(visible = checked)
persist(next)
},
)
}
}
item {
Spacer(Modifier.height(20.dp))
Text(
"Vonas Media Cluster · ${BuildConfig.VERSION_NAME}",
color = Color(0xFF44444E),
fontSize = 12.sp,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
}
}
item {
Spacer(Modifier.height(12.dp))
Text(
"Vonas Media Cluster · ${BuildConfig.VERSION_NAME}",
color = Color(0xFF555555),
fontSize = 12.sp,
)
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun SettingsActionRow(
title: String,
subtitle: String,
danger: Boolean = false,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF1A1A22),
focusedContainerColor = Color(0xFF262632),
),
) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 22.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
title,
color = if (danger) McColors.Danger else Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(3.dp))
Text(subtitle, color = Color(0xFF8E8E9A), fontSize = 13.sp)
}
Text("›", color = Color(0xFF888888), fontSize = 26.sp, fontWeight = FontWeight.Light)
}
}
}

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,50 +169,59 @@ fun ProfilesScreen(
if (loading) {
Text("Profielen laden…", color = Color.White)
} else {
LazyRow(
contentPadding = PaddingValues(horizontal = 24.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp),
verticalAlignment = Alignment.CenterVertically,
Box(
Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
items(profiles, key = { it.id }) { p ->
ProfileTile(
profile = p,
modifier = if (profiles.firstOrNull()?.id == p.id) {
Modifier.focusRequester(firstFocus)
} else Modifier,
onClick = {
if (p.hasPin) {
pending = p
pin = ""
mode = ProfilesMode.Pin
Row(
horizontalArrangement = Arrangement.spacedBy(24.dp),
verticalAlignment = Alignment.CenterVertically,
) {
profiles.forEachIndexed { index, p ->
ProfileTile(
profile = p,
modifier = if (index == 0) {
Modifier.focusRequester(firstFocus)
} else {
select(p)
}
},
onLongDelete = if (canManage && !p.isOwner) {
{
pending = p
mode = ProfilesMode.DeleteConfirm
}
} else null,
)
}
if (canManage && profiles.size < 8) {
item {
Modifier
},
onClick = {
if (!switcherMode && canManage) {
openEdit(p)
} else if (p.hasPin) {
pending = p
pin = ""
mode = ProfilesMode.Pin
} else {
select(p)
}
},
onLongDelete = if (!switcherMode && canManage && !p.isOwner) {
{
pending = p
mode = ProfilesMode.DeleteConfirm
}
} else null,
)
}
if (!switcherMode && canManage && profiles.size < 8) {
AddProfileTile(onClick = {
editingId = null
pending = null
createName = ""
createAvatar = "fox"
createKids = false
createPin = ""
clearPin = false
mode = ProfilesMode.Create
})
}
}
}
if (canManage) {
if (!switcherMode && canManage) {
Spacer(Modifier.height(20.dp))
Text(
"Houd OK lang ingedrukt op een profiel om te verwijderen",
"Houd OK lang ingedrukt om te verwijderen",
color = Color(0xFF666666),
fontSize = 13.sp,
)
@ -213,38 +238,29 @@ fun ProfilesScreen(
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
PinNumpad(
value = pin,
onValueChange = { v -> pin = v.filter { it.isDigit() }.take(4) },
label = { androidx.compose.material3.Text("4-cijferige PIN") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedBorderColor = McColors.Brand,
unfocusedBorderColor = Color(0xFF555555),
focusedLabelColor = McColors.Brand,
unfocusedLabelColor = Color(0xFF888888),
cursorColor = McColors.Brand,
),
onValueChange = { next ->
pin = next
if (next.length == 4 && target != null) select(target, next)
},
)
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ActionChip("Terug") {
mode = ProfilesMode.Pick
pending = null
pin = ""
}
ActionChip("Ontgrendel", primary = true) {
if (target != null) select(target, pin)
}
ActionChip("Terug") {
mode = ProfilesMode.Pick
pending = null
pin = ""
}
}
ProfilesMode.Create -> {
Text("Nieuw profiel", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold)
ProfilesMode.Create, ProfilesMode.Edit -> {
val isEdit = mode == ProfilesMode.Edit
Text(
if (isEdit) "Profiel bewerken" else "Nieuw profiel",
color = Color.White,
fontSize = 24.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = createName,
@ -284,52 +300,86 @@ fun ProfilesScreen(
}
}
}
Spacer(Modifier.height(14.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ActionChip(if (createKids) "Kids: aan" else "Kids: uit") {
createKids = !createKids
if (canManage && (!isEdit || pending?.isOwner != true)) {
Spacer(Modifier.height(14.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ActionChip(if (createKids) "Kids: aan" else "Kids: uit") {
createKids = !createKids
}
}
}
Spacer(Modifier.height(10.dp))
OutlinedTextField(
value = createPin,
onValueChange = { v -> createPin = v.filter { it.isDigit() }.take(4) },
label = { androidx.compose.material3.Text("PIN (optioneel)") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedBorderColor = McColors.Brand,
unfocusedBorderColor = Color(0xFF555555),
focusedLabelColor = McColors.Brand,
unfocusedLabelColor = Color(0xFF888888),
cursorColor = McColors.Brand,
),
)
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ActionChip("Annuleer") { mode = ProfilesMode.Pick }
ActionChip("Aanmaken", primary = true) {
ActionChip("Annuleer") {
mode = ProfilesMode.Pick
editingId = null
pending = null
error = null
}
ActionChip(if (isEdit) "Opslaan" else "Aanmaken", primary = true) {
scope.launch {
error = null
val editId = editingId
runCatching {
api.createProfile(
name = createName.trim(),
avatarId = createAvatar,
isKids = createKids,
pin = createPin.ifBlank { null },
)
}.onSuccess {
if (editId != null) {
api.updateProfile(
profileId = editId,
name = createName.trim(),
avatarId = createAvatar,
isKids = if (canManage && pending?.isOwner != true) createKids else null,
pin = createPin.ifBlank { null },
clearPin = clearPin,
)
} else {
api.createProfile(
name = createName.trim(),
avatarId = createAvatar,
isKids = createKids,
pin = createPin.ifBlank { null },
)
}
}.onSuccess { saved ->
val localId = store.profileId.first()
if (localId == saved.id || editId == localId) {
store.setProfile(saved.id, saved.name, saved.avatarId)
}
mode = ProfilesMode.Pick
editingId = null
pending = null
reload()
}.onFailure {
error = it.message ?: "Aanmaken mislukt"
error = it.message ?: if (isEdit) "Opslaan mislukt" else "Aanmaken mislukt"
}
}
}
}
Spacer(Modifier.height(18.dp))
Text(
when {
clearPin -> "PIN wordt verwijderd bij opslaan"
isEdit && pending?.hasPin == true -> "Nieuwe PIN (optioneel)"
else -> "PIN (optioneel)"
},
color = Color(0xFFCCCCCC),
fontSize = 14.sp,
)
if (isEdit && pending?.hasPin == true) {
Spacer(Modifier.height(10.dp))
ActionChip(if (clearPin) "PIN verwijderen: aan" else "PIN verwijderen") {
clearPin = !clearPin
if (clearPin) createPin = ""
}
}
if (!clearPin) {
Spacer(Modifier.height(12.dp))
PinNumpad(
value = createPin,
onValueChange = { next ->
createPin = next
if (next.isNotEmpty()) clearPin = false
},
)
}
}
ProfilesMode.DeleteConfirm -> {
@ -387,8 +437,8 @@ private fun ProfileTile(
Surface(
onClick = onClick,
onLongClick = onLongDelete,
modifier = modifier.width(140.dp),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
modifier = modifier.width(152.dp),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF1C1C24),
@ -396,25 +446,34 @@ private fun ProfileTile(
),
) {
Column(
Modifier.padding(16.dp),
Modifier
.fillMaxWidth()
.padding(vertical = 18.dp, horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
Modifier
.size(88.dp)
.size(92.dp)
.clip(CircleShape)
.background(Color(0xFF2E2E3A)),
contentAlignment = Alignment.Center,
) {
Text(avatarEmoji(profile.avatarId), fontSize = 40.sp)
Text(
avatarEmoji(profile.avatarId),
fontSize = 42.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(12.dp))
Spacer(Modifier.height(14.dp))
Text(
profile.name,
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
val badge = when {
profile.isKids -> "Kids"
@ -422,10 +481,14 @@ private fun ProfileTile(
profile.hasPin -> "PIN"
else -> null
}
if (badge != null) {
Spacer(Modifier.height(4.dp))
Text(badge, color = Color(0xFF999999), fontSize = 12.sp)
}
Spacer(Modifier.height(4.dp))
Text(
badge ?: " ",
color = if (badge != null) Color(0xFF999999) else Color.Transparent,
fontSize = 12.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@ -435,8 +498,8 @@ private fun ProfileTile(
private fun AddProfileTile(onClick: () -> Unit) {
Surface(
onClick = onClick,
modifier = Modifier.width(140.dp),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
modifier = Modifier.width(152.dp),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF16161E),
@ -444,20 +507,43 @@ private fun AddProfileTile(onClick: () -> Unit) {
),
) {
Column(
Modifier.padding(16.dp),
Modifier
.fillMaxWidth()
.padding(vertical = 18.dp, horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
Modifier
.size(88.dp)
.size(92.dp)
.clip(CircleShape)
.background(Color(0xFF2A2A32)),
contentAlignment = Alignment.Center,
) {
Text("+", color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.Light)
Text(
"+",
color = Color.White,
fontSize = 36.sp,
fontWeight = FontWeight.Light,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(12.dp))
Text("Toevoegen", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(14.dp))
Text(
"Toevoegen",
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(4.dp))
Text(
" ",
color = Color.Transparent,
fontSize = 12.sp,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@ -485,3 +571,71 @@ private fun ActionChip(label: String, primary: Boolean = false, onClick: () -> U
)
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun PinNumpad(
value: String,
onValueChange: (String) -> Unit,
maxLen: Int = 4,
) {
fun append(digit: String) {
if (value.length < maxLen) onValueChange(value + digit)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
repeat(maxLen) { index ->
Box(
Modifier
.size(16.dp)
.clip(CircleShape)
.background(
if (index < value.length) Color.White else Color(0xFF3A3A48),
),
)
}
}
Spacer(Modifier.height(14.dp))
listOf(
listOf("1", "2", "3"),
listOf("4", "5", "6"),
listOf("7", "8", "9"),
listOf("⌫", "0", "C"),
).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
row.forEach { key ->
Surface(
onClick = {
when (key) {
"⌫" -> if (value.isNotEmpty()) onValueChange(value.dropLast(1))
"C" -> onValueChange("")
else -> append(key)
}
},
modifier = Modifier.size(56.dp),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.1f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF2A2A32),
focusedContainerColor = McColors.Brand,
contentColor = Color.White,
focusedContentColor = Color.White,
),
) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
key,
color = Color.Unspecified,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
)
}
}
}
}
Spacer(Modifier.height(10.dp))
}
}
}