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:
Kohei 2026-02-27 07:14:52 +09:00
parent ea460f7ef0
commit dc85cdd174
10 changed files with 789 additions and 1 deletions

View file

@ -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)
} }

View file

@ -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,
)
}

View file

@ -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()) }
}

View file

@ -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,
)

View file

@ -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,
)
}
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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

Binary file not shown.