feat: add Config UI screens with ViewModel and Koin module (Step 4)
Add ConfigSectionListScreen, ConfigSectionDetailScreen, ConfigViewModel, and supporting composables for the backend config editor. Extract UI state into a dedicated ConfigUiState.kt with separate ListState/DetailState types. Centralize icons-lucide dependency in version catalog. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ea460f7ef0
commit
dc85cdd174
10 changed files with 789 additions and 1 deletions
|
|
@ -41,5 +41,7 @@ dependencies {
|
||||||
|
|
||||||
implementation(libs.coroutines.android)
|
implementation(libs.coroutines.android)
|
||||||
|
|
||||||
|
implementation(libs.icons.lucide)
|
||||||
|
|
||||||
debugImplementation(libs.compose.ui.tooling)
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
package io.clawdroid.backend.config
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import io.clawdroid.core.ui.theme.DeepBlack
|
||||||
|
import io.clawdroid.core.ui.theme.GradientCyan
|
||||||
|
import io.clawdroid.core.ui.theme.GradientPurple
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ConfigBackground(content: @Composable BoxScope.() -> Unit) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(DeepBlack)
|
||||||
|
.drawBehind {
|
||||||
|
drawCircle(
|
||||||
|
brush = Brush.radialGradient(
|
||||||
|
colors = listOf(
|
||||||
|
GradientCyan.copy(alpha = 0.07f),
|
||||||
|
Color.Transparent,
|
||||||
|
),
|
||||||
|
center = Offset(size.width * 0.15f, size.height * 0.1f),
|
||||||
|
radius = size.width * 0.8f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
drawCircle(
|
||||||
|
brush = Brush.radialGradient(
|
||||||
|
colors = listOf(
|
||||||
|
GradientPurple.copy(alpha = 0.07f),
|
||||||
|
Color.Transparent,
|
||||||
|
),
|
||||||
|
center = Offset(size.width * 0.85f, size.height * 0.9f),
|
||||||
|
radius = size.width * 0.7f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package io.clawdroid.backend.config
|
||||||
|
|
||||||
|
import org.koin.core.definition.Callbacks
|
||||||
|
import org.koin.core.module.dsl.viewModel
|
||||||
|
import org.koin.core.module.dsl.withOptions
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
val configModule = module {
|
||||||
|
single { ConfigApiClient() } withOptions { callbacks = Callbacks(onClose = { it?.close() }) }
|
||||||
|
viewModel { ConfigViewModel(get()) }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,292 @@
|
||||||
|
package io.clawdroid.backend.config
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import com.composables.icons.lucide.R as LucideR
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.SwitchDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.clawdroid.core.ui.theme.DeepBlack
|
||||||
|
import io.clawdroid.core.ui.theme.GlassBorder
|
||||||
|
import io.clawdroid.core.ui.theme.GlassWhite
|
||||||
|
import io.clawdroid.core.ui.theme.NeonCyan
|
||||||
|
import io.clawdroid.core.ui.theme.TextPrimary
|
||||||
|
import io.clawdroid.core.ui.theme.TextSecondary
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ConfigSectionDetailScreen(
|
||||||
|
sectionKey: String,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: ConfigViewModel = koinViewModel(),
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(sectionKey) {
|
||||||
|
viewModel.onSectionSelected(sectionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
val detail = uiState.detailState
|
||||||
|
val isDirty = detail?.fields?.any { it.value != it.originalValue } == true
|
||||||
|
val isSaving = uiState.saveState is SaveState.Saving
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.saveState) {
|
||||||
|
when (val state = uiState.saveState) {
|
||||||
|
is SaveState.Success -> {
|
||||||
|
val msg = if (state.restart) "Saved. Server restarting..." else "Saved."
|
||||||
|
snackbarHostState.showSnackbar(msg)
|
||||||
|
viewModel.dismissSaveResult()
|
||||||
|
}
|
||||||
|
is SaveState.Error -> {
|
||||||
|
snackbarHostState.showSnackbar("Error: ${state.message}")
|
||||||
|
viewModel.dismissSaveResult()
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfigBackground {
|
||||||
|
Scaffold(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(detail?.sectionLabel ?: "") },
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = {
|
||||||
|
viewModel.onNavigateBackToList()
|
||||||
|
onNavigateBack()
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(LucideR.drawable.lucide_ic_arrow_left),
|
||||||
|
contentDescription = "Back",
|
||||||
|
tint = TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = viewModel::onSave,
|
||||||
|
enabled = isDirty && !isSaving,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = NeonCyan,
|
||||||
|
contentColor = DeepBlack,
|
||||||
|
disabledContainerColor = NeonCyan.copy(alpha = 0.3f),
|
||||||
|
disabledContentColor = DeepBlack.copy(alpha = 0.5f),
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
) {
|
||||||
|
if (isSaving) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color = DeepBlack,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
if (detail != null) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
detail.fields.forEach { field ->
|
||||||
|
ConfigField(
|
||||||
|
field = field,
|
||||||
|
onValueChanged = { viewModel.onFieldValueChanged(field.key, it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = NeonCyan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConfigField(
|
||||||
|
field: FieldState,
|
||||||
|
onValueChanged: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
when (field.type) {
|
||||||
|
"bool" -> BoolField(field, onValueChanged)
|
||||||
|
"int" -> NumberField(field, onValueChanged, KeyboardType.Number)
|
||||||
|
"float" -> NumberField(field, onValueChanged, KeyboardType.Decimal)
|
||||||
|
"[]string" -> StringArrayField(field, onValueChanged)
|
||||||
|
"map", "[]any" -> ReadOnlyField(field)
|
||||||
|
else -> StringField(field, onValueChanged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StringField(field: FieldState, onValueChanged: (String) -> Unit) {
|
||||||
|
var hidden by remember(field.key) { mutableStateOf(field.secret) }
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = field.value,
|
||||||
|
onValueChange = onValueChanged,
|
||||||
|
label = { Text(field.label, color = TextSecondary) },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = if (hidden) PasswordVisualTransformation() else VisualTransformation.None,
|
||||||
|
trailingIcon = if (field.secret) {
|
||||||
|
{
|
||||||
|
TextButton(onClick = { hidden = !hidden }) {
|
||||||
|
Text(
|
||||||
|
if (hidden) "Show" else "Hide",
|
||||||
|
color = NeonCyan,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else null,
|
||||||
|
colors = configFieldColors(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BoolField(field: FieldState, onValueChanged: (String) -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
field.label,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = TextPrimary,
|
||||||
|
)
|
||||||
|
Switch(
|
||||||
|
checked = field.value.toBooleanStrictOrNull() == true,
|
||||||
|
onCheckedChange = { onValueChanged(it.toString()) },
|
||||||
|
colors = SwitchDefaults.colors(
|
||||||
|
checkedThumbColor = NeonCyan,
|
||||||
|
checkedTrackColor = NeonCyan.copy(alpha = 0.3f),
|
||||||
|
uncheckedThumbColor = GlassWhite,
|
||||||
|
uncheckedTrackColor = GlassBorder,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NumberField(
|
||||||
|
field: FieldState,
|
||||||
|
onValueChanged: (String) -> Unit,
|
||||||
|
keyboardType: KeyboardType,
|
||||||
|
) {
|
||||||
|
val filter: (String) -> Boolean = if (keyboardType == KeyboardType.Decimal) {
|
||||||
|
{ it.isEmpty() || it == "-" || it.toDoubleOrNull() != null }
|
||||||
|
} else {
|
||||||
|
{ it.isEmpty() || it == "-" || it.toLongOrNull() != null }
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = field.value,
|
||||||
|
onValueChange = { if (filter(it)) onValueChanged(it) },
|
||||||
|
label = { Text(field.label, color = TextSecondary) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
|
||||||
|
colors = configFieldColors(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StringArrayField(field: FieldState, onValueChanged: (String) -> Unit) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = field.value,
|
||||||
|
onValueChange = onValueChanged,
|
||||||
|
label = { Text(field.label, color = TextSecondary) },
|
||||||
|
placeholder = { Text("Comma-separated values", color = TextSecondary.copy(alpha = 0.5f)) },
|
||||||
|
singleLine = true,
|
||||||
|
colors = configFieldColors(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReadOnlyField(field: FieldState) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = field.value.ifEmpty { "(complex value)" },
|
||||||
|
onValueChange = {},
|
||||||
|
label = { Text(field.label, color = TextSecondary) },
|
||||||
|
readOnly = true,
|
||||||
|
enabled = false,
|
||||||
|
colors = configFieldColors(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun configFieldColors() = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = NeonCyan.copy(alpha = 0.5f),
|
||||||
|
unfocusedBorderColor = GlassBorder,
|
||||||
|
focusedContainerColor = GlassWhite,
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
focusedTextColor = TextPrimary,
|
||||||
|
unfocusedTextColor = TextPrimary,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
package io.clawdroid.backend.config
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
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.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import com.composables.icons.lucide.R as LucideR
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.clawdroid.core.ui.theme.DeepBlack
|
||||||
|
import io.clawdroid.core.ui.theme.GlassBorder
|
||||||
|
import io.clawdroid.core.ui.theme.GlassWhite
|
||||||
|
import io.clawdroid.core.ui.theme.NeonCyan
|
||||||
|
import io.clawdroid.core.ui.theme.TextPrimary
|
||||||
|
import io.clawdroid.core.ui.theme.TextSecondary
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ConfigSectionListScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
onSectionSelected: (sectionKey: String) -> Unit,
|
||||||
|
viewModel: ConfigViewModel = koinViewModel(),
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
ConfigBackground {
|
||||||
|
Scaffold(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Backend Config") },
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(LucideR.drawable.lucide_ic_arrow_left),
|
||||||
|
contentDescription = "Back",
|
||||||
|
tint = TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
when (val listState = uiState.listState) {
|
||||||
|
is ListState.Loading -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = NeonCyan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ListState.Error -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
listState.message,
|
||||||
|
color = TextSecondary,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Button(
|
||||||
|
onClick = viewModel::retry,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = NeonCyan,
|
||||||
|
contentColor = DeepBlack,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text("Retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ListState.Loaded -> {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
contentPadding = PaddingValues(vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
items(listState.sections, key = { it.key }) { section ->
|
||||||
|
SectionCard(
|
||||||
|
section = section,
|
||||||
|
onClick = { onSectionSelected(section.key) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionCard(
|
||||||
|
section: SectionSummary,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(GlassWhite, RoundedCornerShape(16.dp))
|
||||||
|
.border(0.5.dp, GlassBorder, RoundedCornerShape(16.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
section.label,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = TextPrimary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${section.fieldCount} fields",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(LucideR.drawable.lucide_ic_chevron_right),
|
||||||
|
contentDescription = "Open",
|
||||||
|
tint = TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package io.clawdroid.backend.config
|
||||||
|
|
||||||
|
data class ConfigUiState(
|
||||||
|
val listState: ListState = ListState.Loading,
|
||||||
|
val detailState: DetailState? = null,
|
||||||
|
val saveState: SaveState = SaveState.Idle,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface ListState {
|
||||||
|
data object Loading : ListState
|
||||||
|
data class Error(val message: String) : ListState
|
||||||
|
data class Loaded(val sections: List<SectionSummary>) : ListState
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DetailState(
|
||||||
|
val sectionKey: String,
|
||||||
|
val sectionLabel: String,
|
||||||
|
val fields: List<FieldState>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SectionSummary(val key: String, val label: String, val fieldCount: Int)
|
||||||
|
|
||||||
|
data class FieldState(
|
||||||
|
val key: String,
|
||||||
|
val label: String,
|
||||||
|
val type: String,
|
||||||
|
val secret: Boolean,
|
||||||
|
val value: String,
|
||||||
|
val originalValue: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface SaveState {
|
||||||
|
data object Idle : SaveState
|
||||||
|
data object Saving : SaveState
|
||||||
|
data class Success(val restart: Boolean) : SaveState
|
||||||
|
data class Error(val message: String) : SaveState
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,225 @@
|
||||||
|
package io.clawdroid.backend.config
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
|
||||||
|
class ConfigViewModel(private val apiClient: ConfigApiClient) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(ConfigUiState())
|
||||||
|
val uiState: StateFlow<ConfigUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private var schema: ConfigSchema? = null
|
||||||
|
private var configValues: JsonObject? = null
|
||||||
|
private var saveJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun retry() {
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSectionSelected(sectionKey: String) {
|
||||||
|
val current = _uiState.value.detailState
|
||||||
|
if (current != null && current.sectionKey == sectionKey) return
|
||||||
|
loadSection(sectionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadSection(sectionKey: String) {
|
||||||
|
val s = schema ?: return
|
||||||
|
val config = configValues ?: return
|
||||||
|
val section = s.sections.find { it.key == sectionKey } ?: return
|
||||||
|
|
||||||
|
val fields = section.fields.map { field ->
|
||||||
|
val raw = extractNestedValue(config, field.key)
|
||||||
|
val display = jsonElementToEditString(raw, field.type)
|
||||||
|
FieldState(
|
||||||
|
key = field.key,
|
||||||
|
label = field.label,
|
||||||
|
type = field.type,
|
||||||
|
secret = field.secret,
|
||||||
|
value = display,
|
||||||
|
originalValue = display,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
detailState = DetailState(
|
||||||
|
sectionKey = sectionKey,
|
||||||
|
sectionLabel = section.label,
|
||||||
|
fields = fields,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onFieldValueChanged(fieldKey: String, newValue: String) {
|
||||||
|
_uiState.update { state ->
|
||||||
|
val detail = state.detailState ?: return@update state
|
||||||
|
val updated = detail.fields.map { field ->
|
||||||
|
if (field.key == fieldKey) field.copy(value = newValue) else field
|
||||||
|
}
|
||||||
|
state.copy(detailState = detail.copy(fields = updated))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSave() {
|
||||||
|
val detail = _uiState.value.detailState ?: return
|
||||||
|
|
||||||
|
val changed = detail.fields.filter { it.value != it.originalValue }
|
||||||
|
if (changed.isEmpty()) return
|
||||||
|
|
||||||
|
_uiState.update { it.copy(saveState = SaveState.Saving) }
|
||||||
|
|
||||||
|
saveJob = viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val payload = buildNestedJsonObject(changed)
|
||||||
|
val result = apiClient.saveConfig(payload)
|
||||||
|
if (result.error != null) {
|
||||||
|
_uiState.update { it.copy(saveState = SaveState.Error(result.error)) }
|
||||||
|
} else {
|
||||||
|
_uiState.update { it.copy(saveState = SaveState.Success(result.restart)) }
|
||||||
|
refreshConfig()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(saveState = SaveState.Error(e.message ?: "Save failed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onNavigateBackToList() {
|
||||||
|
saveJob?.cancel()
|
||||||
|
saveJob = null
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(detailState = null, saveState = SaveState.Idle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissSaveResult() {
|
||||||
|
_uiState.update { it.copy(saveState = SaveState.Idle) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadData() {
|
||||||
|
_uiState.update { it.copy(listState = ListState.Loading) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val schemaDeferred = async { apiClient.getSchema() }
|
||||||
|
val configDeferred = async { apiClient.getConfig() }
|
||||||
|
val s = schemaDeferred.await()
|
||||||
|
val c = configDeferred.await()
|
||||||
|
schema = s
|
||||||
|
configValues = c
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(listState = ListState.Loaded(s.toSummaries()))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
listState = ListState.Error(
|
||||||
|
e.message ?: "Failed to load config"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshConfig() {
|
||||||
|
try {
|
||||||
|
val c = apiClient.getConfig()
|
||||||
|
configValues = c
|
||||||
|
val current = _uiState.value.detailState
|
||||||
|
if (current != null) {
|
||||||
|
loadSection(current.sectionKey)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Refresh failure is non-critical; keep existing values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ConfigSchema.toSummaries(): List<SectionSummary> =
|
||||||
|
sections.map { SectionSummary(it.key, it.label, it.fields.size) }
|
||||||
|
|
||||||
|
// ── JSON mapping utilities ───────────────────────────────────
|
||||||
|
|
||||||
|
private fun extractNestedValue(element: JsonElement, keyPath: String): JsonElement {
|
||||||
|
val parts = keyPath.split(".")
|
||||||
|
var current: JsonElement = element
|
||||||
|
for (part in parts) {
|
||||||
|
current = (current as? JsonObject)?.get(part) ?: return JsonNull
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonElementToEditString(element: JsonElement, type: String): String {
|
||||||
|
if (element is JsonNull) return ""
|
||||||
|
return when (type) {
|
||||||
|
"bool" -> element.jsonPrimitive.booleanOrNull?.toString() ?: ""
|
||||||
|
"[]string" -> {
|
||||||
|
if (element is JsonArray) {
|
||||||
|
element.map { it.jsonPrimitive.contentOrNull ?: "" }.joinToString(", ")
|
||||||
|
} else ""
|
||||||
|
}
|
||||||
|
else -> (element as? JsonPrimitive)?.contentOrNull ?: element.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNestedJsonObject(fields: List<FieldState>): JsonObject {
|
||||||
|
val root = mutableMapOf<String, Any>()
|
||||||
|
for (field in fields) {
|
||||||
|
val parts = field.key.split(".")
|
||||||
|
var current = root
|
||||||
|
for (i in 0 until parts.size - 1) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
current = current.getOrPut(parts[i]) { mutableMapOf<String, Any>() }
|
||||||
|
as MutableMap<String, Any>
|
||||||
|
}
|
||||||
|
current[parts.last()] = fieldValueToJsonElement(field)
|
||||||
|
}
|
||||||
|
return mapToJsonObject(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun mapToJsonObject(map: Map<String, Any>): JsonObject = buildJsonObject {
|
||||||
|
for ((key, value) in map) {
|
||||||
|
when (value) {
|
||||||
|
is JsonElement -> put(key, value)
|
||||||
|
is Map<*, *> -> put(key, mapToJsonObject(value as Map<String, Any>))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fieldValueToJsonElement(field: FieldState): JsonElement = when (field.type) {
|
||||||
|
"bool" -> JsonPrimitive(field.value.toBooleanStrictOrNull() ?: false)
|
||||||
|
"int" -> JsonPrimitive(field.value.toLongOrNull() ?: 0L)
|
||||||
|
"float" -> JsonPrimitive(field.value.toDoubleOrNull() ?: 0.0)
|
||||||
|
"[]string" -> JsonArray(
|
||||||
|
field.value.split(",")
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.map { JsonPrimitive(it) }
|
||||||
|
)
|
||||||
|
else -> JsonPrimitive(field.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -30,7 +30,7 @@ dependencies {
|
||||||
implementation(libs.compose.ui.tooling.preview)
|
implementation(libs.compose.ui.tooling.preview)
|
||||||
implementation(libs.compose.material3)
|
implementation(libs.compose.material3)
|
||||||
implementation(libs.compose.icons.extended)
|
implementation(libs.compose.icons.extended)
|
||||||
implementation("com.composables:icons-lucide-android:2.2.1")
|
implementation(libs.icons.lucide)
|
||||||
implementation(libs.activity.compose)
|
implementation(libs.activity.compose)
|
||||||
implementation(libs.lifecycle.runtime.compose)
|
implementation(libs.lifecycle.runtime.compose)
|
||||||
implementation(libs.lifecycle.viewmodel.compose)
|
implementation(libs.lifecycle.viewmodel.compose)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ markdown-renderer = "0.39.0"
|
||||||
navigation = "2.9.0"
|
navigation = "2.9.0"
|
||||||
datastore = "1.1.7"
|
datastore = "1.1.7"
|
||||||
camerax = "1.5.1"
|
camerax = "1.5.1"
|
||||||
|
icons-lucide = "2.2.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
# Compose
|
# Compose
|
||||||
|
|
@ -72,6 +73,9 @@ camerax-camera2 = { group = "androidx.camera", name = "camera-camera2", version.
|
||||||
camerax-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
|
camerax-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
|
||||||
camerax-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
|
camerax-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
|
||||||
|
|
||||||
|
# Icons
|
||||||
|
icons-lucide = { group = "com.composables", name = "icons-lucide-android", version.ref = "icons-lucide" }
|
||||||
|
|
||||||
# Markdown
|
# Markdown
|
||||||
markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" }
|
markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" }
|
||||||
|
|
||||||
|
|
|
||||||
BIN
cmd/clawdroid/clawdroid
Normal file
BIN
cmd/clawdroid/clawdroid
Normal file
Binary file not shown.
Loading…
Add table
Reference in a new issue