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.icons.lucide)
|
||||
|
||||
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.material3)
|
||||
implementation(libs.compose.icons.extended)
|
||||
implementation("com.composables:icons-lucide-android:2.2.1")
|
||||
implementation(libs.icons.lucide)
|
||||
implementation(libs.activity.compose)
|
||||
implementation(libs.lifecycle.runtime.compose)
|
||||
implementation(libs.lifecycle.viewmodel.compose)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ markdown-renderer = "0.39.0"
|
|||
navigation = "2.9.0"
|
||||
datastore = "1.1.7"
|
||||
camerax = "1.5.1"
|
||||
icons-lucide = "2.2.1"
|
||||
|
||||
[libraries]
|
||||
# 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-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-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