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:
parent
6a0eee0313
commit
2eabd76d93
8 changed files with 1168 additions and 579 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -498,6 +498,8 @@ private fun MainHub(
|
|||
)
|
||||
MainTab.Settings -> SettingsScreen(
|
||||
api = api,
|
||||
profileAvatarId = profileAvatarId,
|
||||
profileName = profileName,
|
||||
onBack = {
|
||||
onTabChange(MainTab.Home)
|
||||
focusTopNav()
|
||||
|
|
@ -509,38 +511,6 @@ private fun MainHub(
|
|||
}
|
||||
}
|
||||
StatusBarGlassOverlay(Modifier.align(Alignment.TopCenter))
|
||||
// Profiel-avatar rechtsboven → terug naar switcher
|
||||
Row(
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.statusBarsPadding()
|
||||
.padding(top = 8.dp, end = 12.dp)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(Color(0xCC1A1A22))
|
||||
.clickable(onClick = onSwitchProfile)
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF2E2E3A)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(avatarEmoji(profileAvatarId ?: "fox"), fontSize = 16.sp)
|
||||
}
|
||||
if (!profileName.isNullOrBlank()) {
|
||||
Text(
|
||||
profileName,
|
||||
color = Color.White,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.focusRequester(navFocus)) {
|
||||
TopNavBar(
|
||||
|
|
@ -1117,6 +1087,8 @@ private fun ShelfBlock(
|
|||
@Composable
|
||||
private fun SettingsScreen(
|
||||
api: ApiClient,
|
||||
profileAvatarId: String? = null,
|
||||
profileName: String? = null,
|
||||
onBack: () -> Unit,
|
||||
onSwitchProfile: () -> Unit = {},
|
||||
onManageProfiles: () -> Unit = {},
|
||||
|
|
@ -1189,20 +1161,40 @@ private fun SettingsScreen(
|
|||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Box(
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(McColors.BgElevated)
|
||||
.clickable(onClick = onSwitchProfile)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF2E2E3A)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(avatarEmoji(profileAvatarId ?: "fox"), fontSize = 22.sp)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Profiel wisselen",
|
||||
profileName?.takeIf { it.isNotBlank() } ?: "Profiel",
|
||||
color = Color.White,
|
||||
fontSize = McDimens.body,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
"Tik om van profiel te wisselen",
|
||||
color = McColors.TextDim,
|
||||
fontSize = McDimens.meta,
|
||||
)
|
||||
}
|
||||
Text("›", color = Color(0xFF888888), fontSize = 22.sp)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Box(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package nl.vonas.mediacluster.tv.ui.profiles
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -10,14 +13,18 @@ import androidx.compose.foundation.layout.Spacer
|
|||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -30,28 +37,29 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||
import nl.vonas.mediacluster.tv.data.SessionStore
|
||||
import nl.vonas.mediacluster.tv.data.ViewerProfile
|
||||
import nl.vonas.mediacluster.tv.ui.components.McButton
|
||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||
import nl.vonas.mediacluster.tv.ui.theme.McDimens
|
||||
|
||||
private enum class ProfilesMode { Pick, Pin, Create, DeleteConfirm }
|
||||
private enum class ProfilesMode { Pick, Pin, Create, Edit, DeleteConfirm }
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@OptIn(ExperimentalTvMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ProfilesScreen(
|
||||
api: ApiClient,
|
||||
|
|
@ -63,6 +71,7 @@ fun ProfilesScreen(
|
|||
var profiles by remember { mutableStateOf<List<ViewerProfile>>(emptyList()) }
|
||||
var canManage by remember { mutableStateOf(false) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var mode by remember { mutableStateOf(ProfilesMode.Pick) }
|
||||
var pending by remember { mutableStateOf<ViewerProfile?>(null) }
|
||||
|
|
@ -71,7 +80,20 @@ fun ProfilesScreen(
|
|||
var createAvatar by remember { mutableStateOf("fox") }
|
||||
var createKids by remember { mutableStateOf(false) }
|
||||
var createPin by remember { mutableStateOf("") }
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
var clearPin by remember { mutableStateOf(false) }
|
||||
var editingId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun openEdit(profile: ViewerProfile) {
|
||||
editingId = profile.id
|
||||
createName = profile.name
|
||||
createAvatar = profile.avatarId
|
||||
createKids = profile.isKids
|
||||
createPin = ""
|
||||
clearPin = false
|
||||
pending = profile
|
||||
mode = ProfilesMode.Edit
|
||||
error = null
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
scope.launch {
|
||||
|
|
@ -93,11 +115,12 @@ fun ProfilesScreen(
|
|||
store.setProfile(null)
|
||||
}
|
||||
reload()
|
||||
runCatching { firstFocus.requestFocus() }
|
||||
}
|
||||
|
||||
fun select(profile: ViewerProfile, pinValue: String? = null) {
|
||||
if (busy) return
|
||||
scope.launch {
|
||||
busy = true
|
||||
error = null
|
||||
runCatching { api.selectProfile(profile.id, pinValue) }
|
||||
.onSuccess { selected ->
|
||||
|
|
@ -112,6 +135,7 @@ fun ProfilesScreen(
|
|||
pin = ""
|
||||
}
|
||||
}
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -120,241 +144,154 @@ fun ProfilesScreen(
|
|||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
listOf(Color(0xFF0B0B0F), Color(0xFF14141C), Color(0xFF0B0B0F)),
|
||||
listOf(Color(0xFF0A0A0E), Color(0xFF15151E), Color(0xFF0A0A0E)),
|
||||
),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 48.dp, vertical = 36.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
if (switcherMode) "Wie kijkt er?" else "Profielen beheren",
|
||||
color = Color.White,
|
||||
fontSize = 36.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
when {
|
||||
!switcherMode && canManage -> "Voeg toe of verwijder (lang drukken)"
|
||||
switcherMode -> "Kies je profiel"
|
||||
else -> "Alleen het hoofdprofiel mag beheren"
|
||||
},
|
||||
color = Color(0xFFAAAAAA),
|
||||
fontSize = 16.sp,
|
||||
)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
when (mode) {
|
||||
ProfilesMode.Pick -> {
|
||||
if (loading) {
|
||||
Text("Profielen laden…", color = Color.White)
|
||||
} else {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 24.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
items(profiles, key = { it.id }) { p ->
|
||||
ProfileTile(
|
||||
profile = p,
|
||||
modifier = if (profiles.firstOrNull()?.id == p.id) {
|
||||
Modifier.focusRequester(firstFocus)
|
||||
} else Modifier,
|
||||
onClick = {
|
||||
if (p.hasPin) {
|
||||
pending = p
|
||||
ProfilesMode.Pick -> PickContent(
|
||||
switcherMode = switcherMode,
|
||||
canManage = canManage,
|
||||
loading = loading,
|
||||
busy = busy,
|
||||
profiles = profiles,
|
||||
error = error,
|
||||
onSelect = { profile ->
|
||||
if (!switcherMode && canManage) {
|
||||
openEdit(profile)
|
||||
} else if (profile.hasPin) {
|
||||
pending = profile
|
||||
pin = ""
|
||||
mode = ProfilesMode.Pin
|
||||
} else {
|
||||
select(p)
|
||||
select(profile)
|
||||
}
|
||||
},
|
||||
onLongDelete = if (canManage && !p.isOwner) {
|
||||
{
|
||||
pending = p
|
||||
onLongDelete = if (canManage) {
|
||||
{ profile ->
|
||||
if (!profile.isOwner) {
|
||||
pending = profile
|
||||
mode = ProfilesMode.DeleteConfirm
|
||||
}
|
||||
} else null,
|
||||
)
|
||||
}
|
||||
if (canManage && profiles.size < 8) {
|
||||
item {
|
||||
AddProfileTile(onClick = {
|
||||
} else null,
|
||||
onAdd = {
|
||||
editingId = null
|
||||
pending = null
|
||||
createName = ""
|
||||
createAvatar = "fox"
|
||||
createKids = false
|
||||
createPin = ""
|
||||
clearPin = false
|
||||
mode = ProfilesMode.Create
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (canManage) {
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
"Houd OK lang ingedrukt op een profiel om te verwijderen",
|
||||
color = Color(0xFF666666),
|
||||
fontSize = 13.sp,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProfilesMode.Pin -> {
|
||||
val target = pending
|
||||
Text(
|
||||
"PIN voor ${target?.name ?: "profiel"}",
|
||||
color = Color.White,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedTextField(
|
||||
value = pin,
|
||||
onValueChange = { v -> pin = v.filter { it.isDigit() }.take(4) },
|
||||
label = { androidx.compose.material3.Text("4-cijferige PIN") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White,
|
||||
focusedBorderColor = McColors.Brand,
|
||||
unfocusedBorderColor = Color(0xFF555555),
|
||||
focusedLabelColor = McColors.Brand,
|
||||
unfocusedLabelColor = Color(0xFF888888),
|
||||
cursorColor = McColors.Brand,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
ActionChip("Terug") {
|
||||
ProfilesMode.Pin -> PinContent(
|
||||
name = pending?.name ?: "profiel",
|
||||
pin = pin,
|
||||
onPinChange = { pin = it.filter(Char::isDigit).take(4) },
|
||||
busy = busy,
|
||||
error = error,
|
||||
onBack = {
|
||||
mode = ProfilesMode.Pick
|
||||
pending = null
|
||||
pin = ""
|
||||
}
|
||||
ActionChip("Ontgrendel", primary = true) {
|
||||
if (target != null) select(target, pin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProfilesMode.Create -> {
|
||||
Text("Nieuw profiel", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = createName,
|
||||
onValueChange = { createName = it.take(32) },
|
||||
label = { androidx.compose.material3.Text("Naam") },
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White,
|
||||
focusedBorderColor = McColors.Brand,
|
||||
unfocusedBorderColor = Color(0xFF555555),
|
||||
focusedLabelColor = McColors.Brand,
|
||||
unfocusedLabelColor = Color(0xFF888888),
|
||||
cursorColor = McColors.Brand,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("Kies een avatar", color = Color(0xFFCCCCCC), fontSize = 14.sp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(PROFILE_AVATARS) { av ->
|
||||
Surface(
|
||||
onClick = { createAvatar = av.id },
|
||||
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.12f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (createAvatar == av.id) McColors.Brand else Color(0xFF2A2A32),
|
||||
focusedContainerColor = McColors.Brand,
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(56.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(av.emoji, fontSize = 26.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
ActionChip(if (createKids) "Kids: aan" else "Kids: uit") {
|
||||
createKids = !createKids
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = createPin,
|
||||
onValueChange = { v -> createPin = v.filter { it.isDigit() }.take(4) },
|
||||
label = { androidx.compose.material3.Text("PIN (optioneel)") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White,
|
||||
focusedBorderColor = McColors.Brand,
|
||||
unfocusedBorderColor = Color(0xFF555555),
|
||||
focusedLabelColor = McColors.Brand,
|
||||
unfocusedLabelColor = Color(0xFF888888),
|
||||
cursorColor = McColors.Brand,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
ActionChip("Annuleer") { mode = ProfilesMode.Pick }
|
||||
ActionChip("Aanmaken", primary = true) {
|
||||
scope.launch {
|
||||
error = null
|
||||
},
|
||||
onUnlock = {
|
||||
val target = pending ?: return@PinContent
|
||||
select(target, pin)
|
||||
},
|
||||
)
|
||||
|
||||
ProfilesMode.Create, ProfilesMode.Edit -> ProfileFormContent(
|
||||
title = if (mode == ProfilesMode.Edit) "Profiel bewerken" else "Nieuw profiel",
|
||||
name = createName,
|
||||
onNameChange = { createName = it.take(32) },
|
||||
avatarId = createAvatar,
|
||||
onAvatarChange = { createAvatar = it },
|
||||
isKids = createKids,
|
||||
onKidsToggle = { createKids = !createKids },
|
||||
showKidsToggle = canManage && (mode == ProfilesMode.Create || pending?.isOwner != true),
|
||||
pin = createPin,
|
||||
onPinChange = {
|
||||
createPin = it.filter(Char::isDigit).take(4)
|
||||
if (it.isNotBlank()) clearPin = false
|
||||
},
|
||||
hasExistingPin = pending?.hasPin == true && mode == ProfilesMode.Edit,
|
||||
clearPin = clearPin,
|
||||
onClearPinToggle = {
|
||||
clearPin = !clearPin
|
||||
if (clearPin) createPin = ""
|
||||
},
|
||||
saveLabel = if (mode == ProfilesMode.Edit) "Opslaan" else "Aanmaken",
|
||||
busy = busy,
|
||||
error = error,
|
||||
onCancel = {
|
||||
mode = ProfilesMode.Pick
|
||||
editingId = null
|
||||
pending = null
|
||||
error = null
|
||||
},
|
||||
onSave = {
|
||||
if (busy) return@ProfileFormContent
|
||||
scope.launch {
|
||||
busy = true
|
||||
error = null
|
||||
val editId = editingId
|
||||
runCatching {
|
||||
if (editId != null) {
|
||||
api.updateProfile(
|
||||
profileId = editId,
|
||||
name = createName.trim(),
|
||||
avatarId = createAvatar,
|
||||
isKids = if (canManage && pending?.isOwner != true) createKids else null,
|
||||
pin = createPin.ifBlank { null },
|
||||
clearPin = clearPin,
|
||||
)
|
||||
} else {
|
||||
api.createProfile(
|
||||
name = createName.trim(),
|
||||
avatarId = createAvatar,
|
||||
isKids = createKids,
|
||||
pin = createPin.ifBlank { null },
|
||||
)
|
||||
}.onSuccess {
|
||||
}
|
||||
}.onSuccess { saved ->
|
||||
val localId = store.profileId.first()
|
||||
if (localId == saved.id || editId == localId) {
|
||||
store.setProfile(saved.id, saved.name, saved.avatarId)
|
||||
}
|
||||
mode = ProfilesMode.Pick
|
||||
editingId = null
|
||||
pending = null
|
||||
reload()
|
||||
}.onFailure {
|
||||
error = it.message ?: "Aanmaken mislukt"
|
||||
}
|
||||
}
|
||||
}
|
||||
error = it.message ?: "Opslaan mislukt"
|
||||
}
|
||||
busy = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
ProfilesMode.DeleteConfirm -> {
|
||||
val target = pending
|
||||
Text(
|
||||
"Verwijder ${target?.name ?: "profiel"}?",
|
||||
color = Color.White,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Kijkgeschiedenis van dit profiel gaat verloren.",
|
||||
color = Color(0xFFAAAAAA),
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
ActionChip("Annuleer") {
|
||||
ProfilesMode.DeleteConfirm -> DeleteContent(
|
||||
name = pending?.name ?: "profiel",
|
||||
busy = busy,
|
||||
error = error,
|
||||
onCancel = {
|
||||
mode = ProfilesMode.Pick
|
||||
pending = null
|
||||
}
|
||||
ActionChip("Verwijderen", primary = true) {
|
||||
val id = target?.id ?: return@ActionChip
|
||||
error = null
|
||||
},
|
||||
onConfirm = {
|
||||
val id = pending?.id ?: return@DeleteContent
|
||||
if (busy) return@DeleteContent
|
||||
scope.launch {
|
||||
busy = true
|
||||
error = null
|
||||
runCatching { api.deleteProfile(id) }
|
||||
.onSuccess {
|
||||
mode = ProfilesMode.Pick
|
||||
|
|
@ -362,6 +299,91 @@ fun ProfilesScreen(
|
|||
reload()
|
||||
}
|
||||
.onFailure { error = it.message ?: "Verwijderen mislukt" }
|
||||
busy = false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun PickContent(
|
||||
switcherMode: Boolean,
|
||||
canManage: Boolean,
|
||||
loading: Boolean,
|
||||
busy: Boolean,
|
||||
profiles: List<ViewerProfile>,
|
||||
error: String?,
|
||||
onSelect: (ViewerProfile) -> Unit,
|
||||
onLongDelete: ((ViewerProfile) -> Unit)?,
|
||||
onAdd: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = McDimens.screenPad),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(Modifier.height(28.dp))
|
||||
Text(
|
||||
if (switcherMode) "Wie kijkt er?" else "Profielen beheren",
|
||||
color = Color.White,
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
when {
|
||||
!switcherMode && canManage -> "Tik om te bewerken · houd vast om te verwijderen"
|
||||
switcherMode -> "Kies je profiel om verder te gaan"
|
||||
else -> "Alleen het hoofdprofiel mag beheren"
|
||||
},
|
||||
color = Color(0xFF9A9AA8),
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when {
|
||||
loading -> androidx.compose.material3.CircularProgressIndicator(
|
||||
color = McColors.Brand,
|
||||
strokeWidth = 3.dp,
|
||||
)
|
||||
else -> {
|
||||
val cells = profiles.size + if (canManage && profiles.size < 8) 1 else 0
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
contentPadding = PaddingValues(vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
userScrollEnabled = cells > 4,
|
||||
) {
|
||||
items(profiles, key = { it.id }) { profile ->
|
||||
ProfileTile(
|
||||
profile = profile,
|
||||
enabled = !busy,
|
||||
onClick = { onSelect(profile) },
|
||||
onLongClick = onLongDelete?.let { handler ->
|
||||
{ handler(profile) }
|
||||
},
|
||||
)
|
||||
}
|
||||
if (canManage && profiles.size < 8) {
|
||||
item {
|
||||
AddProfileTile(enabled = !busy, onClick = onAdd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -369,52 +391,66 @@ fun ProfilesScreen(
|
|||
}
|
||||
|
||||
error?.let {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(it, color = Color(0xFFFF6B6B), fontSize = 14.sp)
|
||||
Text(
|
||||
it,
|
||||
color = Color(0xFFFF6B6B),
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
if (busy) {
|
||||
androidx.compose.material3.CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 20.dp)
|
||||
.size(22.dp),
|
||||
color = McColors.Text,
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@OptIn(ExperimentalTvMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ProfileTile(
|
||||
profile: ViewerProfile,
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onLongDelete: (() -> Unit)? = null,
|
||||
onLongClick: (() -> Unit)?,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongDelete,
|
||||
modifier = modifier.width(140.dp),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color(0xFF1C1C24),
|
||||
focusedContainerColor = Color(0xFF2A2A36),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(88.dp)
|
||||
.size(96.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF2E2E3A)),
|
||||
.background(Color(0xFF2A2A36))
|
||||
.border(2.dp, Color(0x33FFFFFF), CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(avatarEmoji(profile.avatarId), fontSize = 40.sp)
|
||||
Text(avatarEmoji(profile.avatarId), fontSize = 42.sp)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
profile.name,
|
||||
color = Color.White,
|
||||
fontSize = 16.sp,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
val badge = when {
|
||||
profile.isKids -> "Kids"
|
||||
|
|
@ -424,64 +460,252 @@ private fun ProfileTile(
|
|||
}
|
||||
if (badge != null) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(badge, color = Color(0xFF999999), fontSize = 12.sp)
|
||||
}
|
||||
Text(badge, color = Color(0xFF8E8E9A), fontSize = 12.sp)
|
||||
} else {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AddProfileTile(onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.width(140.dp),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color(0xFF16161E),
|
||||
focusedContainerColor = Color(0xFF242430),
|
||||
),
|
||||
) {
|
||||
private fun AddProfileTile(enabled: Boolean, onClick: () -> Unit) {
|
||||
Column(
|
||||
Modifier.padding(16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(88.dp)
|
||||
.size(96.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF2A2A32)),
|
||||
.background(Color(0xFF1C1C26))
|
||||
.border(2.dp, Color(0x44FFFFFF), CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("+", color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.Light)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
"Toevoegen",
|
||||
color = Color.White,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun PinContent(
|
||||
name: String,
|
||||
pin: String,
|
||||
onPinChange: (String) -> Unit,
|
||||
busy: Boolean,
|
||||
error: String?,
|
||||
onBack: () -> Unit,
|
||||
onUnlock: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text("PIN invoeren", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Voor $name", color = Color(0xFF9A9AA8), fontSize = 14.sp)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
OutlinedTextField(
|
||||
value = pin,
|
||||
onValueChange = onPinChange,
|
||||
label = { androidx.compose.material3.Text("4-cijferige PIN") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = fieldColors(),
|
||||
)
|
||||
error?.let {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("Toevoegen", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text(it, color = Color(0xFFFF6B6B), fontSize = 13.sp, textAlign = TextAlign.Center)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
McButton("Terug", onClick = onBack)
|
||||
McButton("Ontgrendel", primary = true, onClick = onUnlock)
|
||||
}
|
||||
if (busy) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
androidx.compose.material3.CircularProgressIndicator(modifier = Modifier.size(22.dp), color = McColors.Text, strokeWidth = 2.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ActionChip(label: String, primary: Boolean = false, onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (primary) McColors.Brand else Color(0xFF2A2A32),
|
||||
focusedContainerColor = if (primary) McColors.Brand else Color(0xFF3A3A48),
|
||||
contentColor = Color.White,
|
||||
focusedContentColor = Color.White,
|
||||
),
|
||||
private fun ProfileFormContent(
|
||||
title: String,
|
||||
name: String,
|
||||
onNameChange: (String) -> Unit,
|
||||
avatarId: String,
|
||||
onAvatarChange: (String) -> Unit,
|
||||
isKids: Boolean,
|
||||
onKidsToggle: () -> Unit,
|
||||
showKidsToggle: Boolean,
|
||||
pin: String,
|
||||
onPinChange: (String) -> Unit,
|
||||
hasExistingPin: Boolean,
|
||||
clearPin: Boolean,
|
||||
onClearPinToggle: () -> Unit,
|
||||
saveLabel: String,
|
||||
busy: Boolean,
|
||||
error: String?,
|
||||
onCancel: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||
color = Color.Unspecified,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
Text(title, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = onNameChange,
|
||||
label = { androidx.compose.material3.Text("Naam") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = fieldColors(),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("Avatar", color = Color(0xFFCCCCCC), fontSize = 14.sp, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
PROFILE_AVATARS.chunked(5).forEach { row ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
row.forEach { av ->
|
||||
val selected = avatarId == av.id
|
||||
Box(
|
||||
Modifier
|
||||
.size(52.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (selected) McColors.Brand else Color(0xFF2A2A32))
|
||||
.clickable { onAvatarChange(av.id) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(av.emoji, fontSize = 24.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showKidsToggle) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
McButton(if (isKids) "Kids-profiel: aan" else "Kids-profiel: uit", onClick = onKidsToggle)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = pin,
|
||||
onValueChange = onPinChange,
|
||||
label = {
|
||||
androidx.compose.material3.Text(
|
||||
if (hasExistingPin) "Nieuwe PIN (leeg = ongewijzigd)" else "PIN (optioneel)"
|
||||
)
|
||||
},
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
singleLine = true,
|
||||
enabled = !clearPin,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = fieldColors(),
|
||||
)
|
||||
if (hasExistingPin) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
McButton(
|
||||
if (clearPin) "PIN verwijderen: aan" else "PIN verwijderen",
|
||||
onClick = onClearPinToggle,
|
||||
)
|
||||
}
|
||||
error?.let {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(it, color = Color(0xFFFF6B6B), fontSize = 13.sp, textAlign = TextAlign.Center)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
McButton("Annuleer", onClick = onCancel)
|
||||
McButton(saveLabel, primary = true, onClick = onSave)
|
||||
}
|
||||
if (busy) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
androidx.compose.material3.CircularProgressIndicator(
|
||||
modifier = Modifier.size(22.dp),
|
||||
color = McColors.Text,
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DeleteContent(
|
||||
name: String,
|
||||
busy: Boolean,
|
||||
error: String?,
|
||||
onCancel: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text("Profiel verwijderen?", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
"“$name” en de kijkgeschiedenis verdwijnen.",
|
||||
color = Color(0xFF9A9AA8),
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
error?.let {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(it, color = Color(0xFFFF6B6B), fontSize = 13.sp, textAlign = TextAlign.Center)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
McButton("Annuleer", onClick = onCancel)
|
||||
McButton("Verwijderen", primary = true, onClick = onConfirm)
|
||||
}
|
||||
if (busy) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
androidx.compose.material3.CircularProgressIndicator(modifier = Modifier.size(22.dp), color = McColors.Text, strokeWidth = 2.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun fieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White,
|
||||
focusedBorderColor = McColors.Brand,
|
||||
unfocusedBorderColor = Color(0xFF555555),
|
||||
focusedLabelColor = McColors.Brand,
|
||||
unfocusedLabelColor = Color(0xFF888888),
|
||||
cursorColor = McColors.Brand,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -21,12 +21,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
|
|
@ -57,6 +59,7 @@ import androidx.compose.ui.platform.LocalConfiguration
|
|||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -628,6 +631,8 @@ private fun MainHub(
|
|||
)
|
||||
MainTab.Settings -> SettingsScreen(
|
||||
api = api,
|
||||
profileAvatarId = profileAvatarId,
|
||||
profileName = profileName,
|
||||
onBack = {
|
||||
onTabChange(MainTab.Home)
|
||||
focusTopNav()
|
||||
|
|
@ -1329,6 +1334,8 @@ private fun ShelfBlock(
|
|||
@Composable
|
||||
private fun SettingsScreen(
|
||||
api: ApiClient,
|
||||
profileAvatarId: String? = null,
|
||||
profileName: String? = null,
|
||||
onBack: () -> Unit,
|
||||
onSwitchProfile: () -> Unit = {},
|
||||
onManageProfiles: () -> Unit = {},
|
||||
|
|
@ -1337,6 +1344,7 @@ private fun SettingsScreen(
|
|||
var prefs by remember { mutableStateOf<List<ShelfPref>>(emptyList()) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
|
|
@ -1344,6 +1352,7 @@ private fun SettingsScreen(
|
|||
} catch (e: Exception) {
|
||||
error = e.message
|
||||
}
|
||||
runCatching { firstFocus.requestFocus() }
|
||||
}
|
||||
|
||||
fun persist(next: List<ShelfPref>) {
|
||||
|
|
@ -1357,65 +1366,191 @@ private fun SettingsScreen(
|
|||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
listOf(Color(0xFF0B0B0F), Color(0xFF12121A), Color(0xFF0B0B0F)),
|
||||
),
|
||||
),
|
||||
) {
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().background(McColors.Bg).padding(40.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 48.dp, vertical = 28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
contentPadding = PaddingValues(bottom = 40.dp),
|
||||
) {
|
||||
item {
|
||||
Text("Instellingen", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SectionLabel("Account")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(McColors.BgElevated)
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text("Vonas Media Cluster", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text("Versie ${BuildConfig.VERSION_NAME}", color = McColors.TextDim, fontSize = 14.sp)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
McButton("Profiel wisselen", onClick = onSwitchProfile)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
McButton("Profielen beheren", onClick = onManageProfiles)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
McButton("Uitloggen", onClick = onLogout)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
SectionLabel("Home-planken")
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Kies welke rijen op Home staan en in welke volgorde.",
|
||||
color = McColors.TextDim,
|
||||
"Instellingen",
|
||||
color = Color.White,
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
"Profiel, account en home-indeling",
|
||||
color = Color(0xFF8E8E9A),
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
if (error != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(error!!, color = McColors.Danger)
|
||||
Spacer(Modifier.height(22.dp))
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
itemsIndexed(prefs) { index, p ->
|
||||
|
||||
item {
|
||||
SectionLabel("Profiel")
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Surface(
|
||||
onClick = onSwitchProfile,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(firstFocus),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(16.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color(0xFF1A1A22),
|
||||
focusedContainerColor = Color(0xFF262632),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(McColors.BgElevated)
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
.padding(horizontal = 22.dp, vertical = 18.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(64.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF2E2E3A)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
avatarEmoji(profileAvatarId ?: "fox"),
|
||||
fontSize = 30.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
profileName?.takeIf { it.isNotBlank() } ?: "Profiel",
|
||||
color = Color.White,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Wissel van kijkprofiel",
|
||||
color = Color(0xFF9A9AA8),
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
Text("›", color = Color(0xFF888888), fontSize = 28.sp, fontWeight = FontWeight.Light)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
SettingsActionRow(
|
||||
title = "Profielen beheren",
|
||||
subtitle = "Toevoegen, bewerken of verwijderen",
|
||||
onClick = onManageProfiles,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SectionLabel("Account")
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(Color(0xFF1A1A22))
|
||||
.padding(horizontal = 22.dp, vertical = 18.dp),
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
"Vonas Media Cluster",
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Versie ${BuildConfig.VERSION_NAME}",
|
||||
color = Color(0xFF8E8E9A),
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
SettingsActionRow(
|
||||
title = "Uitloggen",
|
||||
subtitle = "Apparaat ontkoppelen van dit account",
|
||||
danger = true,
|
||||
onClick = onLogout,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SectionLabel("Home-planken")
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
"Kies welke rijen op Home staan en in welke volgorde.",
|
||||
color = Color(0xFF8E8E9A),
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
if (error != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(error!!, color = McColors.Danger, fontSize = 14.sp)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
itemsIndexed(prefs) { index, p ->
|
||||
Surface(
|
||||
onClick = {
|
||||
val next = prefs.toMutableList()
|
||||
next[index] = p.copy(visible = !p.visible)
|
||||
persist(next)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.015f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color(0xFF1A1A22),
|
||||
focusedContainerColor = Color(0xFF262632),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 18.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(p.name, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
p.name,
|
||||
color = Color.White,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
if (p.visible) "Zichtbaar op Home" else "Verborgen",
|
||||
color = if (p.visible) Color(0xFF888888) else Color(0xFF555555),
|
||||
color = if (p.visible) Color(0xFF8E8E9A) else Color(0xFF555555),
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
McButton("↑") {
|
||||
if (index > 0) {
|
||||
val next = prefs.toMutableList()
|
||||
|
|
@ -1445,15 +1580,59 @@ private fun SettingsScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
"Vonas Media Cluster · ${BuildConfig.VERSION_NAME}",
|
||||
color = Color(0xFF555555),
|
||||
color = Color(0xFF44444E),
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SettingsActionRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
danger: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color(0xFF1A1A22),
|
||||
focusedContainerColor = Color(0xFF262632),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 22.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
title,
|
||||
color = if (danger) McColors.Danger else Color.White,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(subtitle, color = Color(0xFF8E8E9A), fontSize = 13.sp)
|
||||
}
|
||||
Text("›", color = Color(0xFF888888), fontSize = 26.sp, fontWeight = FontWeight.Light)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Android JSONObject.optString returns the literal "null" for JSON null — filter that. */
|
||||
|
|
|
|||
|
|
@ -15,9 +15,10 @@ import androidx.compose.foundation.layout.size
|
|||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -35,21 +36,21 @@ import androidx.compose.ui.focus.focusRequester
|
|||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||
import nl.vonas.mediacluster.tv.data.SessionStore
|
||||
import nl.vonas.mediacluster.tv.data.ViewerProfile
|
||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||
|
||||
private enum class ProfilesMode { Pick, Pin, Create, DeleteConfirm }
|
||||
private enum class ProfilesMode { Pick, Pin, Create, Edit, DeleteConfirm }
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -71,8 +72,22 @@ fun ProfilesScreen(
|
|||
var createAvatar by remember { mutableStateOf("fox") }
|
||||
var createKids by remember { mutableStateOf(false) }
|
||||
var createPin by remember { mutableStateOf("") }
|
||||
var clearPin by remember { mutableStateOf(false) }
|
||||
var editingId by remember { mutableStateOf<String?>(null) }
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
|
||||
fun openEdit(profile: ViewerProfile) {
|
||||
editingId = profile.id
|
||||
pending = profile
|
||||
createName = profile.name
|
||||
createAvatar = profile.avatarId
|
||||
createKids = profile.isKids
|
||||
createPin = ""
|
||||
clearPin = false
|
||||
error = null
|
||||
mode = ProfilesMode.Edit
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
scope.launch {
|
||||
loading = true
|
||||
|
|
@ -127,6 +142,7 @@ fun ProfilesScreen(
|
|||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 48.dp, vertical = 36.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
|
|
@ -139,7 +155,7 @@ fun ProfilesScreen(
|
|||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
when {
|
||||
!switcherMode && canManage -> "Voeg toe of verwijder (lang drukken)"
|
||||
!switcherMode && canManage -> "OK = bewerken · lang drukken = verwijderen"
|
||||
switcherMode -> "Kies je profiel"
|
||||
else -> "Alleen het hoofdprofiel mag beheren"
|
||||
},
|
||||
|
|
@ -153,19 +169,26 @@ fun ProfilesScreen(
|
|||
if (loading) {
|
||||
Text("Profielen laden…", color = Color.White)
|
||||
} else {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 24.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
||||
Box(
|
||||
Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
items(profiles, key = { it.id }) { p ->
|
||||
profiles.forEachIndexed { index, p ->
|
||||
ProfileTile(
|
||||
profile = p,
|
||||
modifier = if (profiles.firstOrNull()?.id == p.id) {
|
||||
modifier = if (index == 0) {
|
||||
Modifier.focusRequester(firstFocus)
|
||||
} else Modifier,
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
onClick = {
|
||||
if (p.hasPin) {
|
||||
if (!switcherMode && canManage) {
|
||||
openEdit(p)
|
||||
} else if (p.hasPin) {
|
||||
pending = p
|
||||
pin = ""
|
||||
mode = ProfilesMode.Pin
|
||||
|
|
@ -173,7 +196,7 @@ fun ProfilesScreen(
|
|||
select(p)
|
||||
}
|
||||
},
|
||||
onLongDelete = if (canManage && !p.isOwner) {
|
||||
onLongDelete = if (!switcherMode && canManage && !p.isOwner) {
|
||||
{
|
||||
pending = p
|
||||
mode = ProfilesMode.DeleteConfirm
|
||||
|
|
@ -181,22 +204,24 @@ fun ProfilesScreen(
|
|||
} else null,
|
||||
)
|
||||
}
|
||||
if (canManage && profiles.size < 8) {
|
||||
item {
|
||||
if (!switcherMode && canManage && profiles.size < 8) {
|
||||
AddProfileTile(onClick = {
|
||||
editingId = null
|
||||
pending = null
|
||||
createName = ""
|
||||
createAvatar = "fox"
|
||||
createKids = false
|
||||
createPin = ""
|
||||
clearPin = false
|
||||
mode = ProfilesMode.Create
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (canManage) {
|
||||
if (!switcherMode && canManage) {
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
"Houd OK lang ingedrukt op een profiel om te verwijderen",
|
||||
"Houd OK lang ingedrukt om te verwijderen",
|
||||
color = Color(0xFF666666),
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
|
|
@ -213,38 +238,29 @@ fun ProfilesScreen(
|
|||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedTextField(
|
||||
PinNumpad(
|
||||
value = pin,
|
||||
onValueChange = { v -> pin = v.filter { it.isDigit() }.take(4) },
|
||||
label = { androidx.compose.material3.Text("4-cijferige PIN") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White,
|
||||
focusedBorderColor = McColors.Brand,
|
||||
unfocusedBorderColor = Color(0xFF555555),
|
||||
focusedLabelColor = McColors.Brand,
|
||||
unfocusedLabelColor = Color(0xFF888888),
|
||||
cursorColor = McColors.Brand,
|
||||
),
|
||||
onValueChange = { next ->
|
||||
pin = next
|
||||
if (next.length == 4 && target != null) select(target, next)
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
ActionChip("Terug") {
|
||||
mode = ProfilesMode.Pick
|
||||
pending = null
|
||||
pin = ""
|
||||
}
|
||||
ActionChip("Ontgrendel", primary = true) {
|
||||
if (target != null) select(target, pin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProfilesMode.Create -> {
|
||||
Text("Nieuw profiel", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold)
|
||||
ProfilesMode.Create, ProfilesMode.Edit -> {
|
||||
val isEdit = mode == ProfilesMode.Edit
|
||||
Text(
|
||||
if (isEdit) "Profiel bewerken" else "Nieuw profiel",
|
||||
color = Color.White,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = createName,
|
||||
|
|
@ -284,52 +300,86 @@ fun ProfilesScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
if (canManage && (!isEdit || pending?.isOwner != true)) {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
ActionChip(if (createKids) "Kids: aan" else "Kids: uit") {
|
||||
createKids = !createKids
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = createPin,
|
||||
onValueChange = { v -> createPin = v.filter { it.isDigit() }.take(4) },
|
||||
label = { androidx.compose.material3.Text("PIN (optioneel)") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White,
|
||||
focusedBorderColor = McColors.Brand,
|
||||
unfocusedBorderColor = Color(0xFF555555),
|
||||
focusedLabelColor = McColors.Brand,
|
||||
unfocusedLabelColor = Color(0xFF888888),
|
||||
cursorColor = McColors.Brand,
|
||||
),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
ActionChip("Annuleer") { mode = ProfilesMode.Pick }
|
||||
ActionChip("Aanmaken", primary = true) {
|
||||
ActionChip("Annuleer") {
|
||||
mode = ProfilesMode.Pick
|
||||
editingId = null
|
||||
pending = null
|
||||
error = null
|
||||
}
|
||||
ActionChip(if (isEdit) "Opslaan" else "Aanmaken", primary = true) {
|
||||
scope.launch {
|
||||
error = null
|
||||
val editId = editingId
|
||||
runCatching {
|
||||
if (editId != null) {
|
||||
api.updateProfile(
|
||||
profileId = editId,
|
||||
name = createName.trim(),
|
||||
avatarId = createAvatar,
|
||||
isKids = if (canManage && pending?.isOwner != true) createKids else null,
|
||||
pin = createPin.ifBlank { null },
|
||||
clearPin = clearPin,
|
||||
)
|
||||
} else {
|
||||
api.createProfile(
|
||||
name = createName.trim(),
|
||||
avatarId = createAvatar,
|
||||
isKids = createKids,
|
||||
pin = createPin.ifBlank { null },
|
||||
)
|
||||
}.onSuccess {
|
||||
}
|
||||
}.onSuccess { saved ->
|
||||
val localId = store.profileId.first()
|
||||
if (localId == saved.id || editId == localId) {
|
||||
store.setProfile(saved.id, saved.name, saved.avatarId)
|
||||
}
|
||||
mode = ProfilesMode.Pick
|
||||
editingId = null
|
||||
pending = null
|
||||
reload()
|
||||
}.onFailure {
|
||||
error = it.message ?: "Aanmaken mislukt"
|
||||
error = it.message ?: if (isEdit) "Opslaan mislukt" else "Aanmaken mislukt"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(18.dp))
|
||||
Text(
|
||||
when {
|
||||
clearPin -> "PIN wordt verwijderd bij opslaan"
|
||||
isEdit && pending?.hasPin == true -> "Nieuwe PIN (optioneel)"
|
||||
else -> "PIN (optioneel)"
|
||||
},
|
||||
color = Color(0xFFCCCCCC),
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
if (isEdit && pending?.hasPin == true) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
ActionChip(if (clearPin) "PIN verwijderen: aan" else "PIN verwijderen") {
|
||||
clearPin = !clearPin
|
||||
if (clearPin) createPin = ""
|
||||
}
|
||||
}
|
||||
if (!clearPin) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
PinNumpad(
|
||||
value = createPin,
|
||||
onValueChange = { next ->
|
||||
createPin = next
|
||||
if (next.isNotEmpty()) clearPin = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ProfilesMode.DeleteConfirm -> {
|
||||
|
|
@ -387,8 +437,8 @@ private fun ProfileTile(
|
|||
Surface(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongDelete,
|
||||
modifier = modifier.width(140.dp),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
|
||||
modifier = modifier.width(152.dp),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color(0xFF1C1C24),
|
||||
|
|
@ -396,25 +446,34 @@ private fun ProfileTile(
|
|||
),
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 18.dp, horizontal = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(88.dp)
|
||||
.size(92.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF2E2E3A)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(avatarEmoji(profile.avatarId), fontSize = 40.sp)
|
||||
Text(
|
||||
avatarEmoji(profile.avatarId),
|
||||
fontSize = 42.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Text(
|
||||
profile.name,
|
||||
color = Color.White,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
val badge = when {
|
||||
profile.isKids -> "Kids"
|
||||
|
|
@ -422,10 +481,14 @@ private fun ProfileTile(
|
|||
profile.hasPin -> "PIN"
|
||||
else -> null
|
||||
}
|
||||
if (badge != null) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(badge, color = Color(0xFF999999), fontSize = 12.sp)
|
||||
}
|
||||
Text(
|
||||
badge ?: " ",
|
||||
color = if (badge != null) Color(0xFF999999) else Color.Transparent,
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -435,8 +498,8 @@ private fun ProfileTile(
|
|||
private fun AddProfileTile(onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.width(140.dp),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
|
||||
modifier = Modifier.width(152.dp),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color(0xFF16161E),
|
||||
|
|
@ -444,20 +507,43 @@ private fun AddProfileTile(onClick: () -> Unit) {
|
|||
),
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 18.dp, horizontal = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(88.dp)
|
||||
.size(92.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF2A2A32)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("+", color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.Light)
|
||||
Text(
|
||||
"+",
|
||||
color = Color.White,
|
||||
fontSize = 36.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("Toevoegen", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Text(
|
||||
"Toevoegen",
|
||||
color = Color.White,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
" ",
|
||||
color = Color.Transparent,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -485,3 +571,71 @@ private fun ActionChip(label: String, primary: Boolean = false, onClick: () -> U
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun PinNumpad(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
maxLen: Int = 4,
|
||||
) {
|
||||
fun append(digit: String) {
|
||||
if (value.length < maxLen) onValueChange(value + digit)
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
repeat(maxLen) { index ->
|
||||
Box(
|
||||
Modifier
|
||||
.size(16.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (index < value.length) Color.White else Color(0xFF3A3A48),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(14.dp))
|
||||
listOf(
|
||||
listOf("1", "2", "3"),
|
||||
listOf("4", "5", "6"),
|
||||
listOf("7", "8", "9"),
|
||||
listOf("⌫", "0", "C"),
|
||||
).forEach { row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
row.forEach { key ->
|
||||
Surface(
|
||||
onClick = {
|
||||
when (key) {
|
||||
"⌫" -> if (value.isNotEmpty()) onValueChange(value.dropLast(1))
|
||||
"C" -> onValueChange("")
|
||||
else -> append(key)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(56.dp),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.1f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color(0xFF2A2A32),
|
||||
focusedContainerColor = McColors.Brand,
|
||||
contentColor = Color.White,
|
||||
focusedContentColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
key,
|
||||
color = Color.Unspecified,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue