diff --git a/android/backend/config/build.gradle.kts b/android/backend/config/build.gradle.kts index 3e0090952..5b6161fd2 100644 --- a/android/backend/config/build.gradle.kts +++ b/android/backend/config/build.gradle.kts @@ -41,5 +41,7 @@ dependencies { implementation(libs.coroutines.android) + implementation(libs.icons.lucide) + debugImplementation(libs.compose.ui.tooling) } diff --git a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigBackground.kt b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigBackground.kt new file mode 100644 index 000000000..0e2471375 --- /dev/null +++ b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigBackground.kt @@ -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, + ) +} diff --git a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigModule.kt b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigModule.kt new file mode 100644 index 000000000..584d50a4f --- /dev/null +++ b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigModule.kt @@ -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()) } +} diff --git a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionDetailScreen.kt b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionDetailScreen.kt new file mode 100644 index 000000000..6a509e2e4 --- /dev/null +++ b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionDetailScreen.kt @@ -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, +) diff --git a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionListScreen.kt b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionListScreen.kt new file mode 100644 index 000000000..237d4b818 --- /dev/null +++ b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionListScreen.kt @@ -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, + ) + } +} diff --git a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigUiState.kt b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigUiState.kt new file mode 100644 index 000000000..a4e1c48f3 --- /dev/null +++ b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigUiState.kt @@ -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) : ListState +} + +data class DetailState( + val sectionKey: String, + val sectionLabel: String, + val fields: List, +) + +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 +} diff --git a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigViewModel.kt b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigViewModel.kt new file mode 100644 index 000000000..edae1f1fd --- /dev/null +++ b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigViewModel.kt @@ -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 = _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 = + 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): JsonObject { + val root = mutableMapOf() + 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() } + as MutableMap + } + current[parts.last()] = fieldValueToJsonElement(field) + } + return mapToJsonObject(root) + } + + @Suppress("UNCHECKED_CAST") + private fun mapToJsonObject(map: Map): JsonObject = buildJsonObject { + for ((key, value) in map) { + when (value) { + is JsonElement -> put(key, value) + is Map<*, *> -> put(key, mapToJsonObject(value as Map)) + } + } + } + + 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) + } +} diff --git a/android/feature/chat/build.gradle.kts b/android/feature/chat/build.gradle.kts index d9b5b4f44..3c6e9d413 100644 --- a/android/feature/chat/build.gradle.kts +++ b/android/feature/chat/build.gradle.kts @@ -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) diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 58f91e597..8213a3d9b 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -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" } diff --git a/cmd/clawdroid/clawdroid b/cmd/clawdroid/clawdroid new file mode 100644 index 000000000..99645a815 Binary files /dev/null and b/cmd/clawdroid/clawdroid differ