Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,27 @@
package io.askimo.ui.common.components

import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
Expand Down Expand Up @@ -149,13 +162,41 @@ fun linkButton(
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit,
) {
TextButton(
onClick = onClick,
enabled = enabled,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onSurface,
),
modifier = modifier.pointerHoverIcon(PointerIcon.Hand, overrideDescendants = true),
content = content,
)
val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState()
val contentColor = if (enabled) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
}

CompositionLocalProvider(LocalContentColor provides contentColor) {
Row(
modifier = modifier
.hoverable(interactionSource)
.clickable(
interactionSource = interactionSource,
indication = null,
enabled = enabled,
onClick = onClick,
)
.pointerHoverIcon(PointerIcon.Hand, overrideDescendants = true)
.padding(horizontal = 8.dp, vertical = 4.dp)
.drawBehind {
if (isHovered && enabled) {
val strokeWidth = 1.dp.toPx()
drawLine(
color = contentColor,
start = Offset(0f, size.height),
end = Offset(size.width, size.height),
strokeWidth = strokeWidth,
)
}
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
content()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
Expand Down Expand Up @@ -67,6 +69,7 @@ import androidx.compose.material3.NavigationRailItemDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
Expand Down Expand Up @@ -405,6 +408,52 @@ object AppComponents {
)
}

// ── Form Fields ───────────────────────────────────────────────────────────

/**
* Standardised form-field layout that enforces a consistent vertical rhythm:
*
* ```
* [Label] ┐
* [Description] ┘ extraSmall gap — label and description belong together
* small gap — breath before the interactive control
* [content slot] ← text field, secret field, button row, etc.
* [hint slot] extraSmall gap — hint is a sub-annotation of the input
* ```
*
*
* @param label Field label rendered in [AppTextStyles.groupTitle].
* @param description One-line helper text rendered in [AppTextStyles.caption].
* @param required When `true`, appends " *" to the label.
* @param hint Optional composable rendered below [content] with [Spacing.extraSmall]
* top gap (e.g. the currently-selected option description for a SelectField).
* @param content The interactive control (text field, button row, etc.).
*/
@Composable
fun formField(
label: String,
description: String,
modifier: Modifier = Modifier,
required: Boolean = false,
hint: (@Composable () -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = if (required) "$label *" else label,
style = AppTextStyles.groupTitle,
)
Spacer(Modifier.height(Spacing.extraSmall))
Text(text = description, style = AppTextStyles.caption)
Spacer(Modifier.height(Spacing.small))
content()
if (hint != null) {
Spacer(Modifier.height(Spacing.extraSmall))
hint()
}
}
}

@Composable
fun menuItemColors(): MenuItemColors = MenuDefaults.itemColors(
textColor = MaterialTheme.colorScheme.onSurface,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ private fun mcpTemplateCatalogCard(
onClick = onSelect,
modifier = Modifier.fillMaxWidth().pointerHoverIcon(PointerIcon.Hand),
) {
Text(stringResource("mcp.catalog.card.configure"), style = AppTextStyles.fieldLabel)
Text(stringResource("mcp.catalog.card.configure"))
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -604,9 +604,21 @@ private fun instanceConfigScreen(viewModel: ProviderWizardViewModel) {
}

else -> {
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(Spacing.extraSmall)) {
Text(text = field.label + if (field.required) " *" else "", style = AppTextStyles.groupTitle)
Text(text = field.description, style = AppTextStyles.caption)
val fieldHint: (@Composable () -> Unit)? = if (field is ProviderConfigField.SelectField) {
val currentValue = viewModel.providerFieldValues[field.name] ?: field.value
field.options.find { it.value == currentValue }?.description
?.takeIf { it.isNotBlank() }
?.let { desc -> { Text(text = desc, style = AppTextStyles.caption) } }
} else {
null
}

AppComponents.formField(
label = field.label,
description = field.description,
required = field.required,
hint = fieldHint,
) {
when (field) {
is ProviderConfigField.ApiKeyField -> {
AppComponents.appSecretTextField(
Expand Down Expand Up @@ -660,14 +672,9 @@ private fun instanceConfigScreen(viewModel: ProviderWizardViewModel) {
}
}
}
field.options.find { it.value == currentValue }?.description
?.takeIf { it.isNotBlank() }?.let { endpoint ->
Text(
text = endpoint,
style = AppTextStyles.caption,
)
}
}

else -> {}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,15 @@ class ProviderWizardViewModel(
var providerFieldValues by mutableStateOf<Map<String, String>>(emptyMap())
private set

/**
* Base settings pre-filled from a [OpenAiCompatibleTemplate] during the add-wizard flow.
* Used by [buildValidatedSettings] so that template-locked values ([OpenAiCompatibleSettings.isTemplate],
* [OpenAiCompatibleSettings.apiMode], [OpenAiCompatibleSettings.httpVersionConfig]) survive
* the [applyConfigFields] call when saving a new template-based instance.
* Reset to null whenever the wizard is reset or a non-template provider is selected.
*/
private var templateBaseSettings: ProviderSettings? = null

// ── Connection / fetch state ──────────────────────────────────────────────────────────────

var isTestingConnection by mutableStateOf(false)
Expand Down Expand Up @@ -335,14 +344,20 @@ class ProviderWizardViewModel(
wizardStep = WizardStep.CONFIG
resetWizardFormState()

val prefilled = OpenAiCompatibleSettings(baseUrl = template.baseUrl, apiMode = template.apiMode)
val prefilled = OpenAiCompatibleSettings(
baseUrl = template.baseUrl,
apiMode = template.apiMode,
httpVersionConfig = template.httpVersion,
isTemplate = true,
)
templateBaseSettings = prefilled
providerConfigFields = prefilled.getConfigFields(LocalizationManager.messageResolver)
providerFieldValues = buildMap {
providerConfigFields.forEach { field ->
when (field) {
is ProviderConfigField.ApiKeyField -> put(field.name, field.value)
is ProviderConfigField.BaseUrlField -> put(field.name, template.baseUrl)
is ProviderConfigField.SelectField -> put(field.name, template.apiMode.name)
is ProviderConfigField.SelectField -> put(field.name, field.value)
is ProviderConfigField.InfoField -> Unit
}
}
Expand Down Expand Up @@ -512,7 +527,11 @@ class ProviderWizardViewModel(
* callers inside a `withContext` block can simply `return@withContext e.failure`.
*/
private fun buildValidatedSettings(provider: ModelProvider): ProviderSettings {
// For new template instances, use templateBaseSettings (which carries isTemplate=true,
// the preset apiMode, and httpVersionConfig) instead of the generic defaultSettings().
// For editing an existing instance, the persisted settings already carry isTemplate.
val baseSettings = editingInstance?.settings
?: templateBaseSettings
?: ProviderRegistry.getFactory(provider)?.defaultSettings()

val settings = baseSettings?.applyConfigFields(providerFieldValues)
Expand Down Expand Up @@ -643,6 +662,7 @@ class ProviderWizardViewModel(

private fun resetWizardFormState() {
autoFetchJob?.cancel()
templateBaseSettings = null
connectionError = null
connectionErrorHelp = null
connectionTestSuccess = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -651,16 +651,11 @@ private fun instanceEditForm(
}

is ProviderConfigField.ApiKeyField -> {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.extraSmall)) {
Text(
text = field.label + if (field.required) " *" else "",
style = AppTextStyles.fieldLabel,
)
Text(
text = field.description,
style = AppTextStyles.caption,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
AppComponents.formField(
label = field.label,
description = field.description,
required = field.required,
) {
AppComponents.appSecretTextField(
value = state.editFieldValues[field.name] ?: "",
onValueChange = { state.updateEditField(field.name, it) },
Expand All @@ -676,16 +671,11 @@ private fun instanceEditForm(
}

is ProviderConfigField.BaseUrlField -> {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.extraSmall)) {
Text(
text = field.label + if (field.required) " *" else "",
style = AppTextStyles.fieldLabel,
)
Text(
text = field.description,
style = AppTextStyles.caption,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
AppComponents.formField(
label = field.label,
description = field.description,
required = field.required,
) {
OutlinedTextField(
value = state.editFieldValues[field.name] ?: "",
onValueChange = { state.updateEditField(field.name, it) },
Expand All @@ -699,17 +689,25 @@ private fun instanceEditForm(
}

is ProviderConfigField.SelectField -> {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.extraSmall)) {
Text(
text = field.label,
style = AppTextStyles.fieldLabel,
)
Text(
text = field.description,
style = AppTextStyles.caption,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val currentValue = state.editFieldValues[field.name] ?: field.value
val currentValue = state.editFieldValues[field.name] ?: field.value
val selectHint: (@Composable () -> Unit)? = field.options
.find { it.value == currentValue }?.description
?.takeIf { it.isNotBlank() }
?.let { desc ->
{
Text(
text = desc,
style = AppTextStyles.caption,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
AppComponents.formField(
label = field.label,
description = field.description,
required = field.required,
hint = selectHint,
) {
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.small)) {
field.options.forEach { option ->
if (currentValue == option.value) {
Expand All @@ -723,14 +721,6 @@ private fun instanceEditForm(
}
}
}
field.options.find { it.value == currentValue }?.description
?.takeIf { it.isNotBlank() }?.let { endpoint ->
Text(
text = endpoint,
style = AppTextStyles.caption,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Expand Down
Loading