diff --git a/app/src/main/java/com/chaomixian/vflow/core/types/complex/VUiComponent.kt b/app/src/main/java/com/chaomixian/vflow/core/types/complex/VUiComponent.kt index d4518d80..bbb6261f 100644 --- a/app/src/main/java/com/chaomixian/vflow/core/types/complex/VUiComponent.kt +++ b/app/src/main/java/com/chaomixian/vflow/core/types/complex/VUiComponent.kt @@ -6,6 +6,7 @@ import com.chaomixian.vflow.core.types.EnhancedBaseVObject import com.chaomixian.vflow.core.types.VObject import com.chaomixian.vflow.core.types.VTypeRegistry import com.chaomixian.vflow.core.types.basic.VBoolean +import com.chaomixian.vflow.core.types.basic.VList import com.chaomixian.vflow.core.types.basic.VNull import com.chaomixian.vflow.core.types.basic.VString import com.chaomixian.vflow.core.types.properties.PropertyRegistry @@ -51,6 +52,7 @@ class VUiComponent( com.chaomixian.vflow.core.workflow.module.ui.model.UiElementType.BUTTON -> element.label com.chaomixian.vflow.core.workflow.module.ui.model.UiElementType.INPUT -> currentValue?.toString() ?: element.defaultValue com.chaomixian.vflow.core.workflow.module.ui.model.UiElementType.SWITCH -> currentValue?.toString() ?: element.defaultValue + com.chaomixian.vflow.core.workflow.module.ui.model.UiElementType.LIST_PICKER -> currentValue?.toString() ?: element.defaultValue } override fun asNumber(): Double? = null @@ -108,6 +110,13 @@ class VUiComponent( register("isswitch", returnType = VTypeRegistry.BOOLEAN, displayName = "是否开关", nameStringRes = R.string.vtype_uicomponent_isswitch, getter = { host -> VBoolean((host as VUiComponent).element.type == com.chaomixian.vflow.core.workflow.module.ui.model.UiElementType.SWITCH) }) + register("ispicker", "ispick", "islistpicker", returnType = VTypeRegistry.BOOLEAN, displayName = "是否列表选择", nameStringRes = R.string.vtype_uicomponent_ispicker, getter = { host -> + VBoolean((host as VUiComponent).element.type == com.chaomixian.vflow.core.workflow.module.ui.model.UiElementType.LIST_PICKER) + }) + register("options", "items", returnType = VTypeRegistry.LIST, displayName = "选项", nameStringRes = R.string.vtype_uicomponent_options, getter = { host -> + val options = (host as VUiComponent).element.options + VList(options.map { VString(it) }) + }) } fun propertyDefs(): List = registry.toPropertyDefs() diff --git a/app/src/main/java/com/chaomixian/vflow/core/workflow/module/ModuleRegistry.kt b/app/src/main/java/com/chaomixian/vflow/core/workflow/module/ModuleRegistry.kt index 60330e2f..eb4d0566 100644 --- a/app/src/main/java/com/chaomixian/vflow/core/workflow/module/ModuleRegistry.kt +++ b/app/src/main/java/com/chaomixian/vflow/core/workflow/module/ModuleRegistry.kt @@ -266,11 +266,12 @@ object ModuleRegistry { register(EndFloatWindowModule(), context) - // UI 组件 (文本 / 输入 / 按钮 / 开关) + // UI 组件 (文本 / 输入 / 按钮 / 开关 / 列表选择) register(UiTextModule(), context) register(UiInputModule(), context) register(UiButtonModule(), context) register(UiSwitchModule(), context) + register(UiListPickerModule(), context) register(ScreenFlashModule(), context) // 交互逻辑 (事件监听 / 更新 / 关闭 / 获取值) diff --git a/app/src/main/java/com/chaomixian/vflow/core/workflow/module/ui/components/UiListPickerModule.kt b/app/src/main/java/com/chaomixian/vflow/core/workflow/module/ui/components/UiListPickerModule.kt new file mode 100644 index 00000000..59ef16ac --- /dev/null +++ b/app/src/main/java/com/chaomixian/vflow/core/workflow/module/ui/components/UiListPickerModule.kt @@ -0,0 +1,135 @@ +// 文件: java/com/chaomixian/vflow/core/workflow/module/ui/components/UiListPickerModule.kt +package com.chaomixian.vflow.core.workflow.module.ui.components + +import android.content.Context +import com.chaomixian.vflow.R +import com.chaomixian.vflow.core.execution.ExecutionContext +import com.chaomixian.vflow.core.execution.VariableResolver +import com.chaomixian.vflow.core.module.* +import com.chaomixian.vflow.core.workflow.model.ActionStep +import com.chaomixian.vflow.core.workflow.module.ui.model.UiElement +import com.chaomixian.vflow.core.workflow.module.ui.model.UiElementType +import com.chaomixian.vflow.ui.workflow_editor.PillUtil + +/** + * 列表选择(单选)组件 + * + * 让用户从一个候选列表里挑一个值。底层使用 Material 3 下拉菜单 + * (MaterialAutoCompleteTextView)。 + * + * 参数: + * - key: 组件 ID,也是变量名(必填) + * - label: 选择框上方的提示文案 + * - options: 候选项,按换行符分隔(每行一项) + * - default_value: 默认选中的项(按内容匹配),留空则选中第一项 + * - trigger_event: 选中变化时是否触发事件 + * + * 输出: + * - id: 组件的唯一标识符 + * + * 选中的值是一个字符串(候选项原文),可通过组件的 .value 访问。 + * 候选项可通过 .options 访问。 + * + * 使用场景: + * - 让用户在 Activity / 悬浮窗表单里从一组固定值中选一个 + * - 与 "当组件被操作" 模块配合实现选中即触发的逻辑 + */ +class UiListPickerModule : BaseUiComponentModule() { + override val id = "vflow.ui.component.listpicker" + override val metadata = ActionMetadata( + name = "列表选择", // Fallback + nameStringRes = R.string.module_vflow_ui_component_listpicker_name, + description = "让用户从一组选项中挑选一个值。", // Fallback + descriptionStringRes = R.string.module_vflow_ui_component_listpicker_desc, + iconRes = R.drawable.rounded_arrow_drop_down_24, + category = "UI 组件", + categoryId = "ui" + ) + + override fun getInputs() = listOf( + InputDefinition( + "key", "ID (变量名)", + ParameterType.STRING, + "picker1", + acceptsMagicVariable = false, + nameStringRes = R.string.param_vflow_ui_component_listpicker_key_name + ), + InputDefinition( + "label", "标签", + ParameterType.STRING, + "请选择", + acceptsMagicVariable = true, + nameStringRes = R.string.param_vflow_ui_component_listpicker_label_name + ), + InputDefinition( + "options", "选项 (每行一项)", + ParameterType.STRING, + "选项 1\n选项 2\n选项 3", + acceptsMagicVariable = true, + nameStringRes = R.string.param_vflow_ui_component_listpicker_options_name + ), + InputDefinition( + "default_value", "默认选中", + ParameterType.STRING, + "", + acceptsMagicVariable = true, + nameStringRes = R.string.param_vflow_ui_component_listpicker_default_value_name + ), + InputDefinition( + "trigger_event", "选中时触发事件", + ParameterType.BOOLEAN, + true, + nameStringRes = R.string.param_vflow_ui_component_listpicker_trigger_event_name + ) + ) + + override fun getSummary(context: Context, step: ActionStep): CharSequence = + PillUtil.buildSpannable( + context, + context.getString(R.string.summary_prefix_listpicker), + PillUtil.createPillFromParam(step.parameters["key"], getInputs()[0]) + ) + + override fun createUiElement(context: ExecutionContext, step: ActionStep): UiElement { + val label = VariableResolver.resolve(step.parameters["label"]?.toString() ?: "", context) + val optionsRaw = VariableResolver.resolve(step.parameters["options"]?.toString() ?: "", context) + val defaultRaw = VariableResolver.resolve(step.parameters["default_value"]?.toString() ?: "", context) + val key = step.parameters["key"]?.toString()?.takeIf { it.isNotEmpty() } + ?: "picker_${System.currentTimeMillis()}" + val trigger = step.parameters["trigger_event"] as? Boolean ?: true + + val options = parseOptions(optionsRaw) + // 默认值:匹配用户显式给定的内容,否则取第一项;都没有则空字符串 + val resolvedDefault = when { + defaultRaw.isNotEmpty() && options.contains(defaultRaw) -> defaultRaw + options.isNotEmpty() -> options.first() + else -> "" + } + + return UiElement( + id = key, + type = UiElementType.LIST_PICKER, + label = label, + defaultValue = resolvedDefault, + placeholder = "", + isRequired = false, + triggerEvent = trigger, + options = options + ) + } + + companion object { + /** + * 把多行文本解析成选项列表。 + * - 同时支持 "\n" 和 "\\n" 两种换行形式,兼容手输和序列化。 + * - 自动忽略空行和纯空白行。 + * - 去除每项首尾空白。 + */ + fun parseOptions(raw: String): List { + if (raw.isBlank()) return emptyList() + return raw.split('\n') + .map { it.trim().replace("\\n", "\n") } + .filter { it.isNotEmpty() } + } + } +} diff --git a/app/src/main/java/com/chaomixian/vflow/core/workflow/module/ui/model/UiElement.kt b/app/src/main/java/com/chaomixian/vflow/core/workflow/module/ui/model/UiElement.kt index 1663c9a6..cad939b8 100644 --- a/app/src/main/java/com/chaomixian/vflow/core/workflow/module/ui/model/UiElement.kt +++ b/app/src/main/java/com/chaomixian/vflow/core/workflow/module/ui/model/UiElement.kt @@ -7,23 +7,28 @@ import kotlinx.parcelize.Parcelize * UI 元素的类型枚举 */ enum class UiElementType { - TEXT, // 纯文本展示 - INPUT, // 输入框 - BUTTON, // 按钮 - SWITCH, // 开关 - // 后续可扩展 LIST, CHECKBOX 等 + TEXT, // 纯文本展示 + INPUT, // 输入框 + BUTTON, // 按钮 + SWITCH, // 开关 + LIST_PICKER // 下拉列表选择(单选) } /** * UI 元素的数据定义,用于在 Intent 中传递 + * + * 新增的 [options] 字段用于承载 LIST_PICKER 等组件的额外配置。 + * 字段位于构造器末尾并带有默认值,向后兼容旧 Parcel 序列化数据时, + * 如果 parcel 中缺少该字段,会被解析为 null 并落回 emptyList()。 */ @Parcelize data class UiElement( val id: String, // 对应的变量名 (key) val type: UiElementType, // 组件类型 val label: String, // 显示的标签或标题 - val defaultValue: String,// 默认值 + val defaultValue: String,// 默认值(LIST_PICKER 下为默认选中项;其他类型保持原语义) val placeholder: String, // 提示词 val isRequired: Boolean, // 是否必填 - val triggerEvent: Boolean = true -) : Parcelable \ No newline at end of file + val triggerEvent: Boolean = true, + val options: List = emptyList() // LIST_PICKER 的候选项;其他类型忽略 +) : Parcelable diff --git a/app/src/main/java/com/chaomixian/vflow/ui/common/DynamicUiRenderer.kt b/app/src/main/java/com/chaomixian/vflow/ui/common/DynamicUiRenderer.kt index a11ac1ad..96ba7bbf 100644 --- a/app/src/main/java/com/chaomixian/vflow/ui/common/DynamicUiRenderer.kt +++ b/app/src/main/java/com/chaomixian/vflow/ui/common/DynamicUiRenderer.kt @@ -14,6 +14,7 @@ import com.chaomixian.vflow.core.workflow.module.ui.model.UiElement import com.chaomixian.vflow.core.workflow.module.ui.model.UiElementType import com.google.android.material.button.MaterialButton import com.google.android.material.materialswitch.MaterialSwitch +import com.google.android.material.textfield.MaterialAutoCompleteTextView import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout import kotlinx.coroutines.CoroutineScope @@ -57,6 +58,7 @@ object DynamicUiRenderer { viewsMap.forEach { (id, view) -> when (view) { is TextInputEditText -> result[id] = view.text.toString() + is MaterialAutoCompleteTextView -> result[id] = view.text.toString() is MaterialSwitch -> result[id] = view.isChecked } } @@ -157,6 +159,47 @@ object DynamicUiRenderer { viewToRegister = switch switch } + UiElementType.LIST_PICKER -> { + val layout = TextInputLayout(context).apply { + hint = element.label + boxBackgroundMode = TextInputLayout.BOX_BACKGROUND_OUTLINE + endIconMode = TextInputLayout.END_ICON_DROPDOWN_MENU + } + val picker = MaterialAutoCompleteTextView(context).apply { + // 关闭输入校验,避免用户输入的内容与下拉项不匹配时报错 + threshold = 1 + val items = element.options + val adapter = android.widget.ArrayAdapter( + context, + android.R.layout.simple_list_item_1, + items + ) + setAdapter(adapter) + + // 设置默认值 + val defaultIdx = items.indexOf(element.defaultValue) + if (defaultIdx >= 0) { + setText(element.defaultValue, false) + } else if (items.isNotEmpty()) { + setText(items.first(), false) + } + + // 选中变化时同步 currentValue 到事件总线 + if (element.triggerEvent && lifecycleScope != null && sessionId != null) { + setOnItemClickListener { _, _, position, _ -> + val picked = items.getOrNull(position) ?: "" + setText(picked, false) + val allValues = collectAllValues() + lifecycleScope.launch { + UiSessionBus.emitEvent(UiEvent(sessionId, element.id, "change", picked, allValues)) + } + } + } + } + layout.addView(picker) + viewToRegister = picker + layout + } UiElementType.BUTTON -> { val button = MaterialButton(context).apply { text = element.label @@ -197,6 +240,7 @@ object DynamicUiRenderer { viewsMap.forEach { (id, view) -> when (view) { is TextInputEditText -> result[id] = view.text.toString() + is MaterialAutoCompleteTextView -> result[id] = view.text.toString() is MaterialSwitch -> result[id] = view.isChecked } } diff --git a/app/src/main/res/values-en/strings_module.xml b/app/src/main/res/values-en/strings_module.xml index 5a93f6f1..3939e977 100644 --- a/app/src/main/res/values-en/strings_module.xml +++ b/app/src/main/res/values-en/strings_module.xml @@ -2293,8 +2293,19 @@ Text Display Content ID (Optional) - VUiComponent object (contains all component information) - Component ID (for backward compatibility) + VUiComponent object (contains all component info) + Component ID (backward compatibility) + + + Let the user pick one value from a list of options. + List Picker + ID (Variable Name) + Label + Options (one per line) + Default Selection + Trigger Event on Selection + VUiComponent object (contains all component info) + Unique identifier of the component Float window lifecycle end. @@ -3054,6 +3065,8 @@ Input Switch: Switch + List Picker: + List Picker Display text Select Yes diff --git a/app/src/main/res/values-en/vtype_properties.xml b/app/src/main/res/values-en/vtype_properties.xml index 34422228..ab7aa2dd 100644 --- a/app/src/main/res/values-en/vtype_properties.xml +++ b/app/src/main/res/values-en/vtype_properties.xml @@ -87,6 +87,8 @@ Is button Is input Is switch + Is list picker + Options X coordinate diff --git a/app/src/main/res/values-ja/strings_module.xml b/app/src/main/res/values-ja/strings_module.xml index 77a91f52..d75e5b3d 100644 --- a/app/src/main/res/values-ja/strings_module.xml +++ b/app/src/main/res/values-ja/strings_module.xml @@ -2041,6 +2041,15 @@ ID (任意) VUiComponent オブジェクト(すべてのコンポーネント情報を含む) コンポーネント ID(後方互換) + ユーザーにオプションのリストから1つを選ばせます。 + リスト選択 + ID (変数名) + ラベル + オプション(1 行に 1 つ) + デフォルト選択 + 選択時にイベントを発火 + VUiComponent オブジェクト(すべてのコンポーネント情報を含む) + コンポーネントの一意識別子 フローティングウィンドウのライフサイクルが終了します。 フローティング終了 フローティング終了 @@ -2702,6 +2711,8 @@ 入力欄 スイッチ: スイッチ + リスト選択: + リスト選択 表示テキスト 選択 はい diff --git a/app/src/main/res/values/strings_module.xml b/app/src/main/res/values/strings_module.xml index 4652e93f..364400a4 100644 --- a/app/src/main/res/values/strings_module.xml +++ b/app/src/main/res/values/strings_module.xml @@ -2315,6 +2315,17 @@ VUiComponent 对象(包含所有组件信息) 组件 ID(向后兼容) + + 让用户从一组选项中挑选一个值。 + 列表选择 + ID (变量名) + 标签 + 选项 (每行一项) + 默认选中 + 选中时触发事件 + VUiComponent 对象(包含所有组件信息) + 组件唯一标识符 + 悬浮窗生命周期结束。 结束悬浮窗 @@ -3072,6 +3083,8 @@ 输入框 开关: 开关 + 列表选择: + 列表选择 显示文本 选择 diff --git a/app/src/main/res/values/vtype_properties.xml b/app/src/main/res/values/vtype_properties.xml index 248126a8..f7279a21 100644 --- a/app/src/main/res/values/vtype_properties.xml +++ b/app/src/main/res/values/vtype_properties.xml @@ -87,6 +87,8 @@ 是否按钮 是否输入框 是否开关 + 是否列表选择 + 选项 X 坐标 diff --git a/app/src/test/java/com/chaomixian/vflow/core/workflow/module/ui/components/UiListPickerModuleTest.kt b/app/src/test/java/com/chaomixian/vflow/core/workflow/module/ui/components/UiListPickerModuleTest.kt new file mode 100644 index 00000000..bb3edb10 --- /dev/null +++ b/app/src/test/java/com/chaomixian/vflow/core/workflow/module/ui/components/UiListPickerModuleTest.kt @@ -0,0 +1,76 @@ +package com.chaomixian.vflow.core.workflow.module.ui.components + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * UiListPickerModule 的核心逻辑单元测试。 + * + * 主要验证: + * - 选项解析行为(换行分割、空行忽略、首尾空白裁剪)。 + * - 输入定义的稳定性(id 与字段顺序)。 + * - 模块元数据兜底文案。 + */ +class UiListPickerModuleTest { + + private val module = UiListPickerModule() + + @Test + fun `module id is the documented vflow ui component listpicker id`() { + assertEquals("vflow.ui.component.listpicker", module.id) + } + + @Test + fun `module category is UI components`() { + assertEquals("UI 组件", module.metadata.category) + assertEquals("ui", module.metadata.categoryId) + } + + @Test + fun `module exposes five inputs in the expected order`() { + val ids = module.getInputs().map { it.id } + assertEquals( + listOf("key", "label", "options", "default_value", "trigger_event"), + ids + ) + } + + @Test + fun `parseOptions splits on newline and trims whitespace`() { + val parsed = UiListPickerModule.parseOptions(" Apple \n Banana\nCherry ") + assertEquals(listOf("Apple", "Banana", "Cherry"), parsed) + } + + @Test + fun `parseOptions ignores empty lines`() { + val parsed = UiListPickerModule.parseOptions("Apple\n\n\nBanana\n \nCherry\n") + assertEquals(listOf("Apple", "Banana", "Cherry"), parsed) + } + + @Test + fun `parseOptions returns empty list for blank input`() { + assertEquals(emptyList(), UiListPickerModule.parseOptions("")) + assertEquals(emptyList(), UiListPickerModule.parseOptions(" \n\n ")) + } + + @Test + fun `parseOptions treats backslash-n as a newline marker`() { + // 支持常见被 JSON 转义后的 \\n + val parsed = UiListPickerModule.parseOptions("Apple\\nBanana\\nCherry") + assertEquals(listOf("Apple\nBanana\nCherry"), parsed) + } + + @Test + fun `parseOptions preserves internal spaces`() { + val parsed = UiListPickerModule.parseOptions("Hello World\nFoo Bar") + assertEquals(listOf("Hello World", "Foo Bar"), parsed) + } + + @Test + fun `metadata name fallback is the localized Chinese label`() { + // 当 R.string 资源不可用时,模块应仍能 fallback 到写死的字符串 + assertEquals("列表选择", module.metadata.name) + assertTrue(module.metadata.description.isNotEmpty()) + } +}