Compare commits

...

2 Commits

Author SHA1 Message Date
anran c0e04852a7 feat: control net
CI / android (push) Successful in 8m30s
2026-02-01 00:44:19 +08:00
anran 0c3ef2f6b4 feat: dynamic layout 2026-02-01 00:12:46 +08:00
15 changed files with 2398 additions and 487 deletions
@@ -0,0 +1,59 @@
package moe.uni.comfyKmp
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
@Composable
actual fun rememberImagePickerLauncher(
onResult: (PickedImage?) -> Unit
): () -> Unit {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
) { uri: Uri? ->
if (uri != null) {
val image = readImageFromUri(context, uri)
onResult(image)
} else {
onResult(null)
}
}
return {
launcher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
}
}
private fun readImageFromUri(context: Context, uri: Uri): PickedImage? {
return try {
val mimeType = context.contentResolver.getType(uri) ?: "image/png"
val filename = getFileName(context, uri) ?: "image_${System.currentTimeMillis()}.png"
val bytes = context.contentResolver.openInputStream(uri)?.use {
it.readBytes()
} ?: return null
PickedImage(filename, bytes, mimeType)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private fun getFileName(context: Context, uri: Uri): String? {
var name: String? = null
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (cursor.moveToFirst() && nameIndex >= 0) {
name = cursor.getString(nameIndex)
}
}
return name
}
@@ -0,0 +1,38 @@
package moe.uni.comfyKmp
import androidx.compose.runtime.Composable
/**
* Represents a picked image with its data
*/
data class PickedImage(
val filename: String,
val bytes: ByteArray,
val mimeType: String
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as PickedImage
return filename == other.filename &&
bytes.contentEquals(other.bytes) &&
mimeType == other.mimeType
}
override fun hashCode(): Int {
var result = filename.hashCode()
result = 31 * result + bytes.contentHashCode()
result = 31 * result + mimeType.hashCode()
return result
}
}
/**
* Remembers an image picker launcher for selecting images from gallery
* @param onResult Callback with the picked image, or null if cancelled
* @return A function to launch the image picker
*/
@Composable
expect fun rememberImagePickerLauncher(
onResult: (PickedImage?) -> Unit
): () -> Unit
@@ -6,21 +6,28 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
fun extractImagesFromHistory(history: JsonElement): List<ImageRef> {
val result = mutableListOf<ImageRef>()
/**
* Extract images from history with their associated node IDs
*/
fun extractImagesFromHistoryWithNodeId(history: JsonElement): List<NodeImageRef> {
val result = mutableListOf<NodeImageRef>()
val root = history as? JsonObject ?: return emptyList()
root.values.forEach { promptEntry ->
val outputs = promptEntry.jsonObject["outputs"]?.jsonObject ?: return@forEach
outputs.values.forEach { nodeOutput ->
outputs.forEach { (nodeId, nodeOutput) ->
val images = nodeOutput.jsonObject["images"] as? JsonArray ?: return@forEach
images.forEach { img ->
val obj = img.jsonObject
val filename = obj["filename"]?.jsonPrimitive?.content ?: return@forEach
val subfolder = obj["subfolder"]?.jsonPrimitive?.content
val type = obj["type"]?.jsonPrimitive?.content
result.add(ImageRef(filename, subfolder, type))
result.add(NodeImageRef(nodeId, ImageRef(filename, subfolder, type)))
}
}
}
return result
}
fun extractImagesFromHistory(history: JsonElement): List<ImageRef> {
return extractImagesFromHistoryWithNodeId(history).map { it.imageRef }
}
@@ -50,6 +50,21 @@ data class ImageRef(
val type: String? = null
)
/**
* Image reference with associated node ID for tracking which node produced the image
*/
data class NodeImageRef(
val nodeId: String,
val imageRef: ImageRef
)
@Serializable
data class ImageUploadResponse(
val name: String,
val subfolder: String = "",
val type: String = "input"
)
@Serializable
data class WsMessage(
val type: String,
@@ -3,13 +3,18 @@ package moe.uni.comfyKmp.network
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.forms.formData
import io.ktor.client.request.forms.submitFormWithBinaryData
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import moe.uni.comfyKmp.data.ImageUploadResponse
import moe.uni.comfyKmp.data.PromptRequest
import moe.uni.comfyKmp.data.PromptResponse
@@ -69,4 +74,23 @@ class ComfyApiClient(
suspend fun getBytes(url: String): ByteArray {
return httpClient.get(url).body()
}
suspend fun uploadImage(
baseUrl: String,
filename: String,
bytes: ByteArray,
mimeType: String = "image/png",
overwrite: Boolean = true
): ImageUploadResponse {
return httpClient.submitFormWithBinaryData(
url = "${baseUrl.trimEnd('/')}/upload/image",
formData = formData {
append("image", bytes, Headers.build {
append(HttpHeaders.ContentType, mimeType)
append(HttpHeaders.ContentDisposition, "filename=\"$filename\"")
})
append("overwrite", overwrite.toString())
}
).body()
}
}
@@ -0,0 +1,117 @@
package moe.uni.comfyKmp.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import moe.uni.comfyKmp.ui.theme.AdaptiveLayoutConstants
import moe.uni.comfyKmp.ui.theme.WindowSizeInfo
import moe.uni.comfyKmp.ui.theme.rememberWindowSizeInfo
/**
* 自适应双面板布局
*
* - Compact: 仅显示 listPane 或 detailPane(根据 showDetail 决定)
* - Medium/Expanded: 左右并排显示两个面板
*
* @param showDetail 在 Compact 模式下是否显示详情面板
* @param listPaneWidth 列表面板宽度(仅在非 Compact 模式下生效)
* @param listPane 列表面板内容
* @param detailPane 详情面板内容
* @param emptyDetailPane 当没有选中项时显示的占位内容
*/
@Composable
fun AdaptiveTwoPaneLayout(
modifier: Modifier = Modifier,
showDetail: Boolean = false,
listPaneWidth: Dp = AdaptiveLayoutConstants.listPanePreferredWidth,
listPane: @Composable (WindowSizeInfo) -> Unit,
detailPane: @Composable (WindowSizeInfo) -> Unit,
emptyDetailPane: @Composable (WindowSizeInfo) -> Unit = {}
) {
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val windowSizeInfo = rememberWindowSizeInfo(maxWidth, maxHeight)
if (windowSizeInfo.shouldUseTwoPane) {
// Expanded: 双面板布局
Row(modifier = Modifier.fillMaxSize()) {
// 左侧列表面板
Box(
modifier = Modifier
.width(listPaneWidth.coerceIn(
AdaptiveLayoutConstants.listPaneMinWidth,
AdaptiveLayoutConstants.listPaneMaxWidth
))
.fillMaxHeight()
) {
listPane(windowSizeInfo)
}
// 右侧详情面板
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
) {
if (showDetail) {
detailPane(windowSizeInfo)
} else {
emptyDetailPane(windowSizeInfo)
}
}
}
} else {
// Compact/Medium: 单面板布局
if (showDetail) {
detailPane(windowSizeInfo)
} else {
listPane(windowSizeInfo)
}
}
}
}
/**
* 自适应内容容器
*
* 在大屏幕上限制内容最大宽度并居中显示
*/
@Composable
fun AdaptiveContentContainer(
modifier: Modifier = Modifier,
maxWidth: Dp = AdaptiveLayoutConstants.cardMaxWidth,
content: @Composable () -> Unit
) {
Box(
modifier = modifier,
contentAlignment = Alignment.TopCenter
) {
Box(modifier = Modifier.widthIn(max = maxWidth)) {
content()
}
}
}
/**
* 计算网格列数
*
* @param availableWidth 可用宽度
* @param minItemWidth 每项最小宽度
* @param maxColumns 最大列数
*/
fun calculateGridColumns(
availableWidth: Dp,
minItemWidth: Dp = AdaptiveLayoutConstants.gridItemMinWidth,
maxColumns: Int = 3
): Int {
val columns = (availableWidth / minItemWidth).toInt()
return columns.coerceIn(1, maxColumns)
}
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
@@ -22,6 +23,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import moe.uni.comfyKmp.ui.theme.AdaptiveLayoutConstants
import moe.uni.comfyKmp.ui.theme.ComfySpacing
import moe.uni.comfyKmp.ui.theme.comfyColors
@@ -43,6 +45,7 @@ fun ComfyDialog(
) {
Surface(
modifier = Modifier
.widthIn(max = AdaptiveLayoutConstants.dialogMaxWidth)
.fillMaxWidth()
.clip(RoundedCornerShape(28.dp)),
color = MaterialTheme.comfyColors.cardBackground,
@@ -105,6 +108,7 @@ fun ComfyAlertDialog(
Dialog(onDismissRequest = onDismissRequest) {
Surface(
modifier = Modifier
.widthIn(max = AdaptiveLayoutConstants.dialogMaxWidth)
.fillMaxWidth()
.clip(RoundedCornerShape(28.dp)),
color = MaterialTheme.comfyColors.cardBackground,
@@ -6,6 +6,7 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -14,16 +15,21 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Casino
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
@@ -48,11 +54,15 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.floatOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import moe.uni.comfyKmp.data.NodeStatus
import moe.uni.comfyKmp.ui.theme.ComfySpacing
@@ -231,8 +241,13 @@ enum class EditableNodeType {
CHECKPOINT_LOADER,
VAE_LOADER,
LORA_LOADER,
CONTROLNET_LOADER,
CLIP_TEXT_ENCODE,
EMPTY_LATENT_IMAGE,
LOAD_IMAGE,
CANNY_PREPROCESSOR,
CONTROLNET_APPLY,
PREVIEW_IMAGE,
OTHER
}
@@ -249,12 +264,25 @@ fun detectNodeType(classType: String): EditableNodeType {
normalized.contains("loraloader") ||
normalized.contains("lora_loader") ||
normalized == "load lora" -> EditableNodeType.LORA_LOADER
normalized.contains("controlnetloader") ||
normalized.contains("controlnet_loader") ||
normalized == "load controlnet" -> EditableNodeType.CONTROLNET_LOADER
normalized.contains("cliptextencode") ||
normalized.contains("clip_text_encode") ||
normalized == "clip text encode" -> EditableNodeType.CLIP_TEXT_ENCODE
normalized.contains("emptylatentimage") ||
normalized.contains("empty_latent_image") ||
normalized == "empty latent image" -> EditableNodeType.EMPTY_LATENT_IMAGE
normalized.contains("loadimage") ||
normalized == "load image" -> EditableNodeType.LOAD_IMAGE
normalized.contains("cannyedge") ||
(normalized.contains("canny") && normalized.contains("preprocessor")) ->
EditableNodeType.CANNY_PREPROCESSOR
normalized.contains("apply controlnet") ||
normalized.contains("applycontrolnet") ||
normalized.contains("cr apply controlnet") -> EditableNodeType.CONTROLNET_APPLY
normalized.contains("previewimage") ||
normalized == "preview image" -> EditableNodeType.PREVIEW_IMAGE
else -> EditableNodeType.OTHER
}
}
@@ -264,6 +292,7 @@ fun getModelFolder(nodeType: EditableNodeType): String? {
EditableNodeType.CHECKPOINT_LOADER -> "checkpoints"
EditableNodeType.VAE_LOADER -> "vae"
EditableNodeType.LORA_LOADER -> "loras"
EditableNodeType.CONTROLNET_LOADER -> "controlnet"
else -> null
}
}
@@ -278,11 +307,16 @@ fun EditableNodeCard(
isRandomSeedEnabled: Boolean = false,
modelOptions: List<String> = emptyList(),
isLoadingModels: Boolean = false,
imagePreview: ImageBitmap? = null,
isUploadingImage: Boolean = false,
nodeOutputPreview: ImageBitmap? = null,
isLoadingNodeOutput: Boolean = false,
modifier: Modifier = Modifier,
onInputChange: (field: String, value: JsonElement) -> Unit = { _, _ -> },
onRandomSeedToggle: (enabled: Boolean) -> Unit = {},
onRandomizeSeed: () -> Unit = {},
onLoadModels: () -> Unit = {}
onLoadModels: () -> Unit = {},
onSelectImage: () -> Unit = {}
) {
val comfyColors = MaterialTheme.comfyColors
val nodeType = remember(classType) { detectNodeType(classType) }
@@ -352,8 +386,13 @@ fun EditableNodeCard(
EditableNodeType.CHECKPOINT_LOADER -> "模型"
EditableNodeType.VAE_LOADER -> "VAE"
EditableNodeType.LORA_LOADER -> "LoRA"
EditableNodeType.CONTROLNET_LOADER -> "ControlNet"
EditableNodeType.CLIP_TEXT_ENCODE -> "提示词"
EditableNodeType.EMPTY_LATENT_IMAGE -> "空潜图"
EditableNodeType.LOAD_IMAGE -> "图像"
EditableNodeType.CANNY_PREPROCESSOR -> "Canny边缘"
EditableNodeType.CONTROLNET_APPLY -> "应用CN"
EditableNodeType.PREVIEW_IMAGE -> "预览"
else -> ""
},
style = MaterialTheme.typography.labelSmall,
@@ -411,7 +450,8 @@ fun EditableNodeCard(
}
EditableNodeType.CHECKPOINT_LOADER,
EditableNodeType.VAE_LOADER,
EditableNodeType.LORA_LOADER -> {
EditableNodeType.LORA_LOADER,
EditableNodeType.CONTROLNET_LOADER -> {
ModelSelector(
nodeType = nodeType,
inputs = inputs,
@@ -433,6 +473,33 @@ fun EditableNodeCard(
onInputChange = onInputChange
)
}
EditableNodeType.LOAD_IMAGE -> {
LoadImageEditor(
inputs = inputs,
imagePreview = imagePreview,
isUploading = isUploadingImage,
onSelectImage = onSelectImage,
onInputChange = onInputChange
)
}
EditableNodeType.CANNY_PREPROCESSOR -> {
CannyEdgeEditor(
inputs = inputs,
onInputChange = onInputChange
)
}
EditableNodeType.CONTROLNET_APPLY -> {
ControlNetApplyEditor(
inputs = inputs,
onInputChange = onInputChange
)
}
EditableNodeType.PREVIEW_IMAGE -> {
PreviewImageViewer(
outputPreview = nodeOutputPreview,
isLoading = isLoadingNodeOutput
)
}
EditableNodeType.OTHER -> {
// Show read-only inputs for other node types
val previewInputs = inputs.entries
@@ -606,6 +673,7 @@ private fun ModelSelector(
EditableNodeType.CHECKPOINT_LOADER -> "ckpt_name"
EditableNodeType.VAE_LOADER -> "vae_name"
EditableNodeType.LORA_LOADER -> "lora_name"
EditableNodeType.CONTROLNET_LOADER -> "control_net_name"
else -> return
}
@@ -622,6 +690,7 @@ private fun ModelSelector(
EditableNodeType.CHECKPOINT_LOADER -> "模型"
EditableNodeType.VAE_LOADER -> "VAE"
EditableNodeType.LORA_LOADER -> "LoRA"
EditableNodeType.CONTROLNET_LOADER -> "ControlNet"
else -> "选择"
},
style = MaterialTheme.typography.labelMedium,
@@ -876,3 +945,285 @@ private fun PromptEditor(
)
}
}
@Composable
private fun LoadImageEditor(
inputs: Map<String, JsonElement>,
imagePreview: ImageBitmap?,
isUploading: Boolean,
onSelectImage: () -> Unit,
onInputChange: (String, JsonElement) -> Unit
) {
val currentImage = inputs["image"]?.let {
(it as? JsonPrimitive)?.contentOrNull
} ?: ""
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)) {
Text(
text = "图像",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// 缩略图预览
if (imagePreview != null) {
Image(
bitmap = imagePreview,
contentDescription = "预览",
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Fit
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = currentImage.ifEmpty { "未选择图片" },
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
FilledTonalButton(
onClick = onSelectImage,
enabled = !isUploading,
contentPadding = PaddingValues(horizontal = ComfySpacing.md)
) {
if (isUploading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
Spacer(Modifier.width(8.dp))
Text("上传中...")
} else {
Icon(
imageVector = Icons.Default.Image,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(4.dp))
Text("选择图片")
}
}
}
}
}
@Composable
private fun CannyEdgeEditor(
inputs: Map<String, JsonElement>,
onInputChange: (String, JsonElement) -> Unit
) {
val lowThreshold = inputs["low_threshold"]?.let {
(it as? JsonPrimitive)?.intOrNull
} ?: 100
val highThreshold = inputs["high_threshold"]?.let {
(it as? JsonPrimitive)?.intOrNull
} ?: 200
val resolution = inputs["resolution"]?.let {
(it as? JsonPrimitive)?.contentOrNull
} ?: "512"
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)) {
// Low Threshold Slider
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "低阈值",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = lowThreshold.toString(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
}
Slider(
value = lowThreshold.toFloat(),
onValueChange = {
onInputChange("low_threshold", JsonPrimitive(it.toInt()))
},
valueRange = 0f..255f,
modifier = Modifier.fillMaxWidth()
)
}
// High Threshold Slider
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "高阈值",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = highThreshold.toString(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
}
Slider(
value = highThreshold.toFloat(),
onValueChange = {
onInputChange("high_threshold", JsonPrimitive(it.toInt()))
},
valueRange = 0f..255f,
modifier = Modifier.fillMaxWidth()
)
}
// Resolution TextField
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.xs)) {
Text(
text = "分辨率",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
OutlinedTextField(
value = resolution,
onValueChange = { newValue ->
newValue.toIntOrNull()?.let {
onInputChange("resolution", JsonPrimitive(it))
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
placeholder = { Text("如 512") }
)
}
}
}
@Composable
private fun ControlNetApplyEditor(
inputs: Map<String, JsonElement>,
onInputChange: (String, JsonElement) -> Unit
) {
val switchValue = inputs["switch"]?.let {
(it as? JsonPrimitive)?.contentOrNull
} ?: "On"
val strength = inputs["strength"]?.let {
(it as? JsonPrimitive)?.floatOrNull
} ?: 1.0f
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)) {
// Switch toggle
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "启用",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Switch(
checked = switchValue == "On",
onCheckedChange = { enabled ->
onInputChange("switch", JsonPrimitive(if (enabled) "On" else "Off"))
}
)
}
// Strength slider
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "强度",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = formatTwoDecimals(strength),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
}
Slider(
value = strength,
onValueChange = {
onInputChange("strength", JsonPrimitive(it.toDouble()))
},
valueRange = 0f..2f,
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun PreviewImageViewer(
outputPreview: ImageBitmap?,
isLoading: Boolean
) {
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)) {
Text(
text = "预览输出",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
when {
outputPreview != null -> {
Image(
bitmap = outputPreview,
contentDescription = "预览输出",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
}
isLoading -> {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
Text(
text = "加载中...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
else -> {
Text(
text = "等待执行...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@@ -14,16 +14,12 @@ import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -32,7 +28,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.dp
import moe.uni.comfyKmp.ui.theme.ComfySpacing
import moe.uni.comfyKmp.ui.theme.comfyColors
@@ -49,8 +44,6 @@ enum class ExecutionStatus {
fun ExecutionStatusBar(
status: ExecutionStatus,
statusText: String,
progress: Float? = null,
progressText: String? = null,
modifier: Modifier = Modifier
) {
val comfyColors = MaterialTheme.comfyColors
@@ -66,69 +59,28 @@ fun ExecutionStatusBar(
animationSpec = tween(300)
)
Column(
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = ComfySpacing.lg, vertical = ComfySpacing.md),
verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
) {
// Animated status indicator
StatusIndicator(status = status, color = statusColor)
// Status text with animation
AnimatedContent(
targetState = statusText,
transitionSpec = {
fadeIn(tween(200)) togetherWith fadeOut(tween(200))
}
) { text ->
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface
)
}
Spacer(modifier = Modifier.weight(1f))
// Progress text
progressText?.let { text ->
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Animated status indicator
StatusIndicator(status = status, color = statusColor)
// Progress bar
if (status == ExecutionStatus.RUNNING) {
if (progress != null && progress > 0f) {
LinearProgressIndicator(
progress = { progress },
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(RoundedCornerShape(2.dp)),
color = statusColor,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
strokeCap = StrokeCap.Round
)
} else {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(RoundedCornerShape(2.dp)),
color = statusColor,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
strokeCap = StrokeCap.Round
)
// Status text with animation
AnimatedContent(
targetState = statusText,
transitionSpec = {
fadeIn(tween(200)) togetherWith fadeOut(tween(200))
}
) { text ->
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
@@ -0,0 +1,107 @@
package moe.uni.comfyKmp.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.draw.clip
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import moe.uni.comfyKmp.ui.theme.ComfySpacing
/**
* Toast 消息数据
*/
data class ToastMessage(
val message: String,
val duration: Long = 2000L
)
/**
* Toast 状态管理
*/
class ToastState {
var currentMessage by mutableStateOf<ToastMessage?>(null)
private set
fun show(message: String, duration: Long = 2000L) {
currentMessage = ToastMessage(message, duration)
}
fun dismiss() {
currentMessage = null
}
}
@Composable
fun rememberToastState(): ToastState {
return remember { ToastState() }
}
/**
* Toast 容器 - 包裹内容并在底部显示 Toast
*/
@Composable
fun ToastHost(
state: ToastState,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Box(modifier = modifier.fillMaxSize()) {
content()
val message = state.currentMessage
LaunchedEffect(message) {
if (message != null) {
delay(message.duration)
state.dismiss()
}
}
AnimatedVisibility(
visible = message != null,
enter = fadeIn() + slideInVertically { it },
exit = fadeOut() + slideOutVertically { it },
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 100.dp)
) {
message?.let {
ToastContent(message = it.message)
}
}
}
}
@Composable
private fun ToastContent(message: String) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(24.dp))
.background(MaterialTheme.colorScheme.inverseSurface)
.padding(horizontal = ComfySpacing.lg, vertical = ComfySpacing.md)
) {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.inverseOnSurface
)
}
}
@@ -9,16 +9,19 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
@@ -68,8 +71,10 @@ import moe.uni.comfyKmp.di.LocalAppContainer
import moe.uni.comfyKmp.storage.generateId
import moe.uni.comfyKmp.ui.components.ComfyDialog
import moe.uni.comfyKmp.ui.components.GlassCard
import moe.uni.comfyKmp.ui.theme.AdaptiveLayoutConstants
import moe.uni.comfyKmp.ui.theme.ComfySpacing
import moe.uni.comfyKmp.ui.theme.comfyColors
import moe.uni.comfyKmp.ui.theme.rememberWindowSizeInfo
class ServerScreen : Screen {
@Composable
@@ -80,6 +85,7 @@ class ServerScreen : Screen {
var showDialog by remember { mutableStateOf(false) }
var editingServer by remember { mutableStateOf<ServerConfig?>(null) }
var selectedServerId by remember { mutableStateOf<String?>(null) }
if (showDialog) {
ServerEditDialog(
@@ -95,131 +101,207 @@ class ServerScreen : Screen {
val comfyColors = MaterialTheme.comfyColors
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(
"Comfy KMP",
style = MaterialTheme.typography.headlineSmall
)
}
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val windowSizeInfo = rememberWindowSizeInfo(maxWidth, maxHeight)
if (windowSizeInfo.shouldUseTwoPane) {
// 大屏幕:双面板布局
ExpandedServerLayout(
model = model,
navigator = navigator,
comfyColors = comfyColors,
selectedServerId = selectedServerId,
onServerSelect = { selectedServerId = it },
onAddServer = {
editingServer = null
showDialog = true
},
actions = {
FilledTonalButton(
onClick = {
editingServer = null
showDialog = true
},
modifier = Modifier.padding(end = ComfySpacing.sm)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(ComfySpacing.xs))
Text("添加服务器")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
floatingActionButton = {
val canEnter = model.activeServerId != null
ExtendedFloatingActionButton(
onClick = {
val activeId = model.activeServerId ?: return@ExtendedFloatingActionButton
navigator.push(WorkflowListScreen(activeId))
},
icon = {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
},
text = { Text("进入工作流") },
containerColor = if (canEnter) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
contentColor = if (canEnter) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
onEditServer = { server ->
editingServer = server
showDialog = true
}
)
},
floatingActionButtonPosition = FabPosition.End,
containerColor = MaterialTheme.colorScheme.background
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(
Brush.verticalGradient(
colors = listOf(
comfyColors.gradientStart,
comfyColors.gradientEnd
)
)
)
) {
Column(modifier = Modifier.fillMaxSize()) {
if (model.servers.isEmpty()) {
EmptyServerState(
onAdd = {
editingServer = null
showDialog = true
},
modifier = Modifier
.weight(1f)
.fillMaxWidth()
)
} else {
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(ComfySpacing.lg),
verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)
) {
itemsIndexed(
items = model.servers,
key = { _, server -> server.id }
) { index, server ->
AnimatedVisibility(
visible = true,
enter = fadeIn(tween(300, delayMillis = index * 50)) +
slideInVertically(
initialOffsetY = { it / 2 },
animationSpec = tween(300, delayMillis = index * 50)
)
) {
ServerCard(
server = server,
isActive = server.id == model.activeServerId,
onSelect = { model.setActive(server.id) },
onEdit = {
editingServer = server
showDialog = true
},
onDelete = { model.delete(server.id) }
)
}
}
}
} else {
// 小屏幕:单面板布局
CompactServerLayout(
model = model,
navigator = navigator,
comfyColors = comfyColors,
onAddServer = {
editingServer = null
showDialog = true
},
onEditServer = { server ->
editingServer = server
showDialog = true
}
}
)
}
}
}
}
/**
* 小屏幕布局:单面板
*/
@Composable
private fun CompactServerLayout(
model: ServerScreenModel,
navigator: cafe.adriel.voyager.navigator.Navigator,
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors,
onAddServer: () -> Unit,
onEditServer: (ServerConfig) -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(
"Comfy KMP",
style = MaterialTheme.typography.headlineSmall
)
}
},
actions = {
FilledTonalButton(
onClick = onAddServer,
modifier = Modifier.padding(end = ComfySpacing.sm)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(ComfySpacing.xs))
Text("添加服务器")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
floatingActionButton = {
val canEnter = model.activeServerId != null
ExtendedFloatingActionButton(
onClick = {
val activeId = model.activeServerId ?: return@ExtendedFloatingActionButton
navigator.push(WorkflowListScreen(activeId))
},
icon = {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
},
text = { Text("进入工作流") },
containerColor = if (canEnter) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
contentColor = if (canEnter) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
},
floatingActionButtonPosition = FabPosition.End,
containerColor = MaterialTheme.colorScheme.background
) { padding ->
ServerListContent(
model = model,
comfyColors = comfyColors,
onAddServer = onAddServer,
onEditServer = onEditServer,
modifier = Modifier.padding(padding)
)
}
}
/**
* 大屏幕布局:双面板
*/
@Composable
private fun ExpandedServerLayout(
model: ServerScreenModel,
navigator: cafe.adriel.voyager.navigator.Navigator,
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors,
selectedServerId: String?,
onServerSelect: (String?) -> Unit,
onAddServer: () -> Unit,
onEditServer: (ServerConfig) -> Unit
) {
val selectedServer = model.servers.find { it.id == selectedServerId }
Row(modifier = Modifier.fillMaxSize()) {
// 左侧:服务器列表
Column(
modifier = Modifier
.widthIn(
min = AdaptiveLayoutConstants.listPaneMinWidth,
max = AdaptiveLayoutConstants.listPaneMaxWidth
)
.fillMaxHeight()
) {
// 顶部栏
TopAppBar(
title = {
Text("Comfy KMP", style = MaterialTheme.typography.headlineSmall)
},
actions = {
FilledTonalButton(
onClick = onAddServer,
modifier = Modifier.padding(end = ComfySpacing.sm)
) {
Icon(Icons.Default.Add, null, Modifier.size(18.dp))
Spacer(Modifier.width(ComfySpacing.xs))
Text("添加")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
// 服务器列表
ServerListContent(
model = model,
comfyColors = comfyColors,
onAddServer = onAddServer,
onEditServer = onEditServer,
selectedServerId = selectedServerId,
onServerClick = { onServerSelect(it) },
modifier = Modifier.weight(1f)
)
}
// 右侧:详情面板
ServerDetailPanel(
server = selectedServer,
isActive = selectedServer?.id == model.activeServerId,
comfyColors = comfyColors,
onSelect = { selectedServer?.let { model.setActive(it.id) } },
onEdit = { selectedServer?.let { onEditServer(it) } },
onDelete = {
selectedServer?.let {
model.delete(it.id)
onServerSelect(null)
}
},
onEnterWorkflow = {
val activeId = model.activeServerId ?: return@ServerDetailPanel
navigator.push(WorkflowListScreen(activeId))
},
canEnterWorkflow = model.activeServerId != null,
modifier = Modifier.weight(1f).fillMaxHeight()
)
}
}
private class ServerScreenModel(
private val repository: moe.uni.comfyKmp.storage.ServerRepository
) : ScreenModel {
@@ -249,6 +331,233 @@ private class ServerScreenModel(
}
}
/**
* 服务器列表内容(可复用)
*/
@Composable
private fun ServerListContent(
model: ServerScreenModel,
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors,
onAddServer: () -> Unit,
onEditServer: (ServerConfig) -> Unit,
modifier: Modifier = Modifier,
selectedServerId: String? = null,
onServerClick: ((String) -> Unit)? = null
) {
Box(
modifier = modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
comfyColors.gradientStart,
comfyColors.gradientEnd
)
)
)
) {
if (model.servers.isEmpty()) {
EmptyServerState(
onAdd = onAddServer,
modifier = Modifier.fillMaxSize()
)
} else {
LazyColumn(
contentPadding = PaddingValues(ComfySpacing.lg),
verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)
) {
itemsIndexed(
items = model.servers,
key = { _, server -> server.id }
) { index, server ->
AnimatedVisibility(
visible = true,
enter = fadeIn(tween(300, delayMillis = index * 50)) +
slideInVertically(
initialOffsetY = { it / 2 },
animationSpec = tween(300, delayMillis = index * 50)
)
) {
ServerCard(
server = server,
isActive = server.id == model.activeServerId,
isSelected = server.id == selectedServerId,
onSelect = {
model.setActive(server.id)
onServerClick?.invoke(server.id)
},
onEdit = { onEditServer(server) },
onDelete = { model.delete(server.id) },
showActions = onServerClick == null
)
}
}
}
}
}
}
/**
* 大屏幕右侧详情面板
*/
@Composable
private fun ServerDetailPanel(
server: ServerConfig?,
isActive: Boolean,
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors,
onSelect: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
onEnterWorkflow: () -> Unit,
canEnterWorkflow: Boolean,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.background(comfyColors.cardBackground)
.padding(ComfySpacing.xl),
contentAlignment = Alignment.Center
) {
if (server == null) {
// 未选择服务器时的占位内容
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Dns,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.outline
)
Spacer(Modifier.height(ComfySpacing.lg))
Text(
"选择一个服务器查看详情",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
// 显示服务器详情
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.weight(1f))
// 服务器图标
Box(
modifier = Modifier
.size(96.dp)
.clip(CircleShape)
.background(
if (isActive) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Dns,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = if (isActive) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(ComfySpacing.xl))
// 服务器名称
Text(
text = server.name,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.height(ComfySpacing.sm))
// 服务器地址
Text(
text = server.baseUrl,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (server.isDefault) {
Spacer(Modifier.height(ComfySpacing.md))
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(horizontal = ComfySpacing.md, vertical = ComfySpacing.sm)
) {
Text(
"默认服务器",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
Spacer(Modifier.height(ComfySpacing.xxl))
// 操作按钮
Row(horizontalArrangement = Arrangement.spacedBy(ComfySpacing.md)) {
OutlinedButton(
onClick = onSelect,
enabled = !isActive
) {
Icon(
if (isActive) Icons.Default.Check else Icons.Default.RadioButtonUnchecked,
null, Modifier.size(18.dp)
)
Spacer(Modifier.width(ComfySpacing.sm))
Text(if (isActive) "已选择" else "选择")
}
OutlinedButton(onClick = onEdit) {
Icon(Icons.Default.Edit, null, Modifier.size(18.dp))
Spacer(Modifier.width(ComfySpacing.sm))
Text("编辑")
}
OutlinedButton(
onClick = onDelete,
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(Icons.Default.Delete, null, Modifier.size(18.dp))
Spacer(Modifier.width(ComfySpacing.sm))
Text("删除")
}
}
Spacer(Modifier.weight(1f))
// 进入工作流按钮
ExtendedFloatingActionButton(
onClick = onEnterWorkflow,
icon = {
Icon(Icons.AutoMirrored.Filled.ArrowForward, null, Modifier.size(20.dp))
},
text = { Text("进入工作流") },
containerColor = if (canEnterWorkflow) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
contentColor = if (canEnterWorkflow) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
}
}
}
@Composable
private fun ServerEditDialog(
initial: ServerConfig?,
@@ -321,7 +630,9 @@ private fun ServerCard(
isActive: Boolean,
onSelect: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit
onDelete: () -> Unit,
isSelected: Boolean = false,
showActions: Boolean = true
) {
GlassCard(
modifier = Modifier.fillMaxWidth(),
@@ -386,58 +697,60 @@ private fun ServerCard(
}
}
// Actions
Row(
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
) {
OutlinedButton(
onClick = onSelect,
enabled = !isActive,
contentPadding = PaddingValues(
horizontal = ComfySpacing.md,
vertical = ComfySpacing.sm
)
// Actions - 仅在小屏幕模式下显示
if (showActions) {
Row(
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
) {
Icon(
imageVector = if (isActive) Icons.Default.Check else Icons.Default.RadioButtonUnchecked,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(ComfySpacing.xs))
Text(if (isActive) "已选择" else "选择")
}
OutlinedButton(
onClick = onEdit,
contentPadding = PaddingValues(
horizontal = ComfySpacing.md,
vertical = ComfySpacing.sm
)
) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(ComfySpacing.xs))
Text("编辑")
}
Spacer(modifier = Modifier.weight(1f))
TextButton(
onClick = onDelete,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(ComfySpacing.xs))
Text("删除")
OutlinedButton(
onClick = onSelect,
enabled = !isActive,
contentPadding = PaddingValues(
horizontal = ComfySpacing.md,
vertical = ComfySpacing.sm
)
) {
Icon(
imageVector = if (isActive) Icons.Default.Check else Icons.Default.RadioButtonUnchecked,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(ComfySpacing.xs))
Text(if (isActive) "已选择" else "选择")
}
OutlinedButton(
onClick = onEdit,
contentPadding = PaddingValues(
horizontal = ComfySpacing.md,
vertical = ComfySpacing.sm
)
) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(ComfySpacing.xs))
Text("编辑")
}
Spacer(modifier = Modifier.weight(1f))
TextButton(
onClick = onDelete,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(ComfySpacing.xs))
Text("删除")
}
}
}
}
@@ -9,18 +9,22 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -65,14 +69,18 @@ import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import moe.uni.comfyKmp.data.WorkflowEntity
import moe.uni.comfyKmp.data.extractPromptObject
import moe.uni.comfyKmp.data.loadCoverImageBytes
import moe.uni.comfyKmp.data.parsePromptNodes
import moe.uni.comfyKmp.di.LocalAppContainer
import moe.uni.comfyKmp.isFilePickerSupported
import moe.uni.comfyKmp.rememberFilePickerLauncher
import moe.uni.comfyKmp.storage.generateId
import moe.uni.comfyKmp.ui.components.ComfyDialog
import moe.uni.comfyKmp.ui.theme.AdaptiveLayoutConstants
import moe.uni.comfyKmp.ui.theme.ComfySpacing
import moe.uni.comfyKmp.ui.theme.comfyColors
import moe.uni.comfyKmp.ui.theme.rememberWindowSizeInfo
import org.jetbrains.compose.resources.decodeToImageBitmap
import kotlin.time.Clock
@@ -89,6 +97,7 @@ data class WorkflowListScreen(val serverId: String) : Screen {
}
var showImport by remember { mutableStateOf(false) }
var selectedWorkflowId by remember { mutableStateOf<String?>(null) }
if (showImport) {
WorkflowImportDialog(
@@ -102,97 +111,172 @@ data class WorkflowListScreen(val serverId: String) : Screen {
val comfyColors = MaterialTheme.comfyColors
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text("工作流")
Text(
text = "${model.workflows.size} 个工作流",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
navigationIcon = {
IconButton(onClick = { navigator.pop() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "返回"
)
}
},
actions = {
FilledTonalButton(
onClick = { showImport = true },
modifier = Modifier.padding(end = ComfySpacing.sm)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(ComfySpacing.xs))
Text("导入")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val windowSizeInfo = rememberWindowSizeInfo(maxWidth, maxHeight)
if (windowSizeInfo.shouldUseTwoPane) {
// 大屏幕:双面板布局
ExpandedWorkflowListLayout(
model = model,
navigator = navigator,
comfyColors = comfyColors,
selectedWorkflowId = selectedWorkflowId,
onWorkflowSelect = { selectedWorkflowId = it },
onImport = { showImport = true }
)
} else {
// 小屏幕:单面板布局
CompactWorkflowListLayout(
model = model,
navigator = navigator,
comfyColors = comfyColors,
onImport = { showImport = true }
)
},
containerColor = MaterialTheme.colorScheme.background
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(
Brush.verticalGradient(
colors = listOf(
comfyColors.gradientStart,
comfyColors.gradientEnd
)
)
)
) {
if (model.workflows.isEmpty()) {
EmptyWorkflowState(
onImport = { showImport = true },
modifier = Modifier.align(Alignment.Center)
)
} else {
LazyColumn(
contentPadding = PaddingValues(ComfySpacing.lg),
verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)
) {
itemsIndexed(
items = model.workflows,
key = { _, workflow -> workflow.id }
) { index, workflow ->
AnimatedVisibility(
visible = true,
enter = fadeIn(tween(300, delayMillis = index * 50)) +
slideInVertically(
initialOffsetY = { it / 2 },
animationSpec = tween(300, delayMillis = index * 50)
)
) {
WorkflowCard(
workflow = workflow,
onRun = { navigator.push(WorkflowRunScreen(workflow.id)) },
onDelete = { model.deleteWorkflow(workflow.id) }
)
}
}
}
}
}
}
}
}
/**
* 小屏幕布局:单面板
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CompactWorkflowListLayout(
model: WorkflowListScreenModel,
navigator: cafe.adriel.voyager.navigator.Navigator,
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors,
onImport: () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text("工作流")
Text(
text = "${model.workflows.size} 个工作流",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
navigationIcon = {
IconButton(onClick = { navigator.pop() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回")
}
},
actions = {
FilledTonalButton(
onClick = onImport,
modifier = Modifier.padding(end = ComfySpacing.sm)
) {
Icon(Icons.Default.Add, null, Modifier.size(18.dp))
Spacer(Modifier.width(ComfySpacing.xs))
Text("导入")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
containerColor = MaterialTheme.colorScheme.background
) { padding ->
WorkflowListContent(
model = model,
comfyColors = comfyColors,
onImport = onImport,
onWorkflowClick = { navigator.push(WorkflowRunScreen(it)) },
modifier = Modifier.padding(padding)
)
}
}
/**
* 大屏幕布局:双面板
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ExpandedWorkflowListLayout(
model: WorkflowListScreenModel,
navigator: cafe.adriel.voyager.navigator.Navigator,
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors,
selectedWorkflowId: String?,
onWorkflowSelect: (String?) -> Unit,
onImport: () -> Unit
) {
val selectedWorkflow = model.workflows.find { it.id == selectedWorkflowId }
Row(modifier = Modifier.fillMaxSize()) {
// 左侧:工作流列表
Column(
modifier = Modifier
.widthIn(
min = AdaptiveLayoutConstants.listPaneMinWidth,
max = AdaptiveLayoutConstants.listPaneMaxWidth
)
.fillMaxHeight()
) {
TopAppBar(
title = {
Column {
Text("工作流")
Text(
"${model.workflows.size} 个工作流",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
navigationIcon = {
IconButton(onClick = { navigator.pop() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回")
}
},
actions = {
FilledTonalButton(
onClick = onImport,
modifier = Modifier.padding(end = ComfySpacing.sm)
) {
Icon(Icons.Default.Add, null, Modifier.size(18.dp))
Spacer(Modifier.width(ComfySpacing.xs))
Text("导入")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
WorkflowListContent(
model = model,
comfyColors = comfyColors,
onImport = onImport,
onWorkflowClick = { onWorkflowSelect(it) },
selectedWorkflowId = selectedWorkflowId,
modifier = Modifier.weight(1f)
)
}
// 右侧:详情面板
WorkflowDetailPanel(
workflow = selectedWorkflow,
comfyColors = comfyColors,
onRun = {
selectedWorkflow?.let { navigator.push(WorkflowRunScreen(it.id)) }
},
onDelete = {
selectedWorkflow?.let {
model.deleteWorkflow(it.id)
onWorkflowSelect(null)
}
},
modifier = Modifier.weight(1f).fillMaxHeight()
)
}
}
private class WorkflowListScreenModel(
private val serverId: String,
private val repository: moe.uni.comfyKmp.storage.WorkflowRepository
@@ -222,6 +306,318 @@ private class WorkflowListScreenModel(
}
}
/**
* 工作流列表内容(可复用)
*/
@Composable
private fun WorkflowListContent(
model: WorkflowListScreenModel,
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors,
onImport: () -> Unit,
onWorkflowClick: (String) -> Unit,
modifier: Modifier = Modifier,
selectedWorkflowId: String? = null
) {
Box(
modifier = modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
comfyColors.gradientStart,
comfyColors.gradientEnd
)
)
)
) {
if (model.workflows.isEmpty()) {
EmptyWorkflowState(
onImport = onImport,
modifier = Modifier.align(Alignment.Center)
)
} else {
LazyColumn(
contentPadding = PaddingValues(ComfySpacing.lg),
verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)
) {
itemsIndexed(
items = model.workflows,
key = { _, workflow -> workflow.id }
) { index, workflow ->
AnimatedVisibility(
visible = true,
enter = fadeIn(tween(300, delayMillis = index * 50)) +
slideInVertically(
initialOffsetY = { it / 2 },
animationSpec = tween(300, delayMillis = index * 50)
)
) {
WorkflowCard(
workflow = workflow,
isSelected = workflow.id == selectedWorkflowId,
onRun = { onWorkflowClick(workflow.id) },
onDelete = { model.deleteWorkflow(workflow.id) },
showActions = selectedWorkflowId == null
)
}
}
}
}
}
}
/**
* 大屏幕右侧详情面板
*/
@Composable
private fun WorkflowDetailPanel(
workflow: WorkflowEntity?,
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors,
onRun: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier
) {
// 解析节点列表
val nodes = remember(workflow?.json) {
workflow?.json?.let { json ->
try {
val prompt = extractPromptObject(json)
parsePromptNodes(prompt)
} catch (e: Exception) { emptyList() }
} ?: emptyList()
}
// 加载封面图片
val coverBitmap = remember(workflow?.coverImage) {
workflow?.coverImage?.let { cover ->
try {
loadCoverImageBytes(cover)?.decodeToImageBitmap()
} catch (e: Exception) { null }
}
}
Box(
modifier = modifier.background(comfyColors.cardBackground),
contentAlignment = Alignment.Center
) {
if (workflow == null) {
// 未选择工作流时的占位内容
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Assignment,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.outline
)
Spacer(Modifier.height(ComfySpacing.lg))
Text(
"选择一个工作流查看详情",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
// 显示工作流详情
Column(modifier = Modifier.fillMaxSize()) {
// 顶部信息区域
WorkflowDetailHeader(
workflow = workflow,
coverBitmap = coverBitmap,
nodeCount = nodes.size,
onRun = onRun,
onDelete = onDelete
)
// 节点列表
WorkflowNodeList(
nodes = nodes,
modifier = Modifier.weight(1f)
)
}
}
}
}
/**
* 工作流详情头部
*/
@Composable
private fun WorkflowDetailHeader(
workflow: WorkflowEntity,
coverBitmap: androidx.compose.ui.graphics.ImageBitmap?,
nodeCount: Int,
onRun: () -> Unit,
onDelete: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(ComfySpacing.lg),
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.lg),
verticalAlignment = Alignment.Top
) {
// 封面图片(小尺寸)
Box(
modifier = Modifier
.size(120.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
if (coverBitmap != null) {
Image(
bitmap = coverBitmap,
contentDescription = workflow.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
} else {
Icon(
Icons.Default.Image, null,
Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.5f)
)
}
}
// 信息区域
Column(modifier = Modifier.weight(1f)) {
Text(
text = workflow.name,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.height(ComfySpacing.xs))
Text(
text = "更新于 ${formatTimestamp(workflow.updatedAt)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(ComfySpacing.xs))
Text(
text = "$nodeCount 个节点",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(ComfySpacing.md))
// 操作按钮
Row(horizontalArrangement = Arrangement.spacedBy(ComfySpacing.sm)) {
Button(
onClick = onRun,
modifier = Modifier.height(36.dp),
contentPadding = PaddingValues(horizontal = ComfySpacing.lg)
) {
Text("运行", style = MaterialTheme.typography.labelMedium)
}
OutlinedButton(
onClick = onDelete,
modifier = Modifier.height(36.dp),
contentPadding = PaddingValues(horizontal = ComfySpacing.md),
colors = androidx.compose.material3.ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(Icons.Default.Delete, null, Modifier.size(16.dp))
}
}
}
}
}
/**
* 节点列表
*/
@Composable
private fun WorkflowNodeList(
nodes: List<moe.uni.comfyKmp.data.PromptNodeEntry>,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
// 标题
Text(
text = "节点列表",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = ComfySpacing.lg, vertical = ComfySpacing.sm)
)
if (nodes.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
"无法解析节点",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
contentPadding = PaddingValues(
start = ComfySpacing.lg,
end = ComfySpacing.lg,
bottom = ComfySpacing.lg
),
verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
) {
items(nodes, key = { it.id }) { node ->
NodeListItem(node = node)
}
}
}
}
}
@Composable
private fun NodeListItem(node: moe.uni.comfyKmp.data.PromptNodeEntry) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.padding(horizontal = ComfySpacing.md, vertical = ComfySpacing.sm),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// 节点类型
Text(
text = node.classType,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
// 节点 ID
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
.padding(horizontal = ComfySpacing.sm, vertical = 2.dp)
) {
Text(
text = "#${node.id}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun WorkflowImportDialog(
onDismiss: () -> Unit,
@@ -310,7 +706,9 @@ private fun WorkflowImportDialog(
private fun WorkflowCard(
workflow: WorkflowEntity,
onRun: () -> Unit,
onDelete: () -> Unit
onDelete: () -> Unit,
isSelected: Boolean = false,
showActions: Boolean = true
) {
val shape = RoundedCornerShape(16.dp)
val comfyColors = MaterialTheme.comfyColors
@@ -330,9 +728,16 @@ private fun WorkflowCard(
Box(
modifier = Modifier
.fillMaxWidth()
.shadow(4.dp, shape)
.shadow(if (isSelected) 8.dp else 4.dp, shape)
.clip(shape)
.background(comfyColors.cardBackground)
.then(
if (isSelected) {
Modifier.background(
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.1f)
)
} else Modifier
)
.clickable(onClick = onRun)
) {
Column(modifier = Modifier.fillMaxWidth()) {
@@ -394,7 +799,7 @@ private fun WorkflowCard(
) {
// Title and timestamp
Column {
Column(modifier = Modifier.weight(1f)) {
Text(
text = workflow.name,
style = MaterialTheme.typography.titleMedium,
@@ -409,18 +814,20 @@ private fun WorkflowCard(
)
}
FilledTonalIconButton(
onClick = onDelete,
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "删除",
modifier = Modifier.size(20.dp)
)
if (showActions) {
FilledTonalIconButton(
onClick = onDelete,
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "删除",
modifier = Modifier.size(20.dp)
)
}
}
}
}
@@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -19,6 +21,7 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -42,6 +45,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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
@@ -72,15 +76,18 @@ import kotlinx.serialization.json.jsonPrimitive
import moe.uni.comfyKmp.data.ComfyJson
import moe.uni.comfyKmp.data.ImageRef
import moe.uni.comfyKmp.data.NodeExecutionState
import moe.uni.comfyKmp.data.NodeImageRef
import moe.uni.comfyKmp.data.NodeStatus
import moe.uni.comfyKmp.data.PromptRequest
import moe.uni.comfyKmp.data.extractImagesFromHistory
import moe.uni.comfyKmp.data.extractImagesFromHistoryWithNodeId
import moe.uni.comfyKmp.data.extractPromptObject
import moe.uni.comfyKmp.data.parsePromptNodes
import moe.uni.comfyKmp.data.saveCoverImage
import moe.uni.comfyKmp.data.saveImageToGallery
import moe.uni.comfyKmp.data.updateNodeInput
import moe.uni.comfyKmp.di.LocalAppContainer
import moe.uni.comfyKmp.PickedImage
import moe.uni.comfyKmp.rememberImagePickerLauncher
import moe.uni.comfyKmp.network.ComfyApiClient
import moe.uni.comfyKmp.network.ComfyWebSocketClient
import moe.uni.comfyKmp.network.WsConnectionStatus
@@ -93,10 +100,15 @@ import moe.uni.comfyKmp.ui.components.ExecutionStatus
import moe.uni.comfyKmp.ui.components.ExecutionStatusBar
import moe.uni.comfyKmp.ui.components.GalleryImage
import moe.uni.comfyKmp.ui.components.ImageGallery
import moe.uni.comfyKmp.ui.components.ToastHost
import moe.uni.comfyKmp.ui.components.ToastState
import moe.uni.comfyKmp.ui.components.detectNodeType
import moe.uni.comfyKmp.ui.components.getModelFolder
import moe.uni.comfyKmp.ui.components.rememberToastState
import moe.uni.comfyKmp.ui.theme.ComfySpacing
import moe.uni.comfyKmp.ui.theme.WindowSizeInfo
import moe.uni.comfyKmp.ui.theme.comfyColors
import moe.uni.comfyKmp.ui.theme.rememberWindowSizeInfo
import org.jetbrains.compose.resources.decodeToImageBitmap
import kotlin.random.Random
import kotlin.time.Clock
@@ -107,13 +119,15 @@ data class WorkflowRunScreen(val workflowId: String) : Screen {
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val container = LocalAppContainer.current
val toastState = rememberToastState()
val model = rememberScreenModel {
WorkflowRunScreenModel(
workflowId = workflowId,
workflowRepository = container.workflowRepository,
apiClient = container.apiClient,
wsClient = container.wsClient,
serverRepository = container.serverRepository
serverRepository = container.serverRepository,
onToast = { toastState.show(it) }
)
}
@@ -121,129 +135,23 @@ data class WorkflowRunScreen(val workflowId: String) : Screen {
model.connect()
}
val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(
initialValue = SheetValue.PartiallyExpanded,
skipHiddenState = false
)
)
val comfyColors = MaterialTheme.comfyColors
val sheetPeekHeight = 120.dp
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val density = LocalDensity.current
val layoutHeightPx = with(density) { maxHeight.toPx() }
val peekHeightPx = with(density) { sheetPeekHeight.toPx() }
BottomSheetScaffold(
scaffoldState = scaffoldState,
sheetPeekHeight = sheetPeekHeight,
sheetContainerColor = comfyColors.cardBackground,
sheetContentColor = MaterialTheme.colorScheme.onSurface,
sheetShape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
sheetDragHandle = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(12.dp))
Box(
modifier = Modifier
.width(40.dp)
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "节点状态 (${model.nodeStates.size})",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
}
},
sheetContent = {
NodeListSheet(
nodes = model.orderedNodeStates,
randomSeedNodes = model.randomSeedNodes,
modelChoices = model.modelChoices,
loadingModels = model.loadingModels,
onInputChange = { nodeId, field, value -> model.updateNodeInput(nodeId, field, value) },
onRandomSeedToggle = { nodeId, enabled -> model.setRandomSeedMode(nodeId, enabled) },
onRandomizeSeed = { nodeId -> model.randomizeSeed(nodeId) },
onLoadModels = { nodeId -> model.loadModelsForNode(nodeId) },
modifier = Modifier.navigationBarsPadding()
ToastHost(state = toastState) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val windowSizeInfo = rememberWindowSizeInfo(maxWidth, maxHeight)
if (windowSizeInfo.shouldUseTwoPane) {
ExpandedWorkflowRunLayout(
model = model,
navigator = navigator,
comfyColors = comfyColors
)
},
topBar = {
WorkflowRunTopBar(
onBack = { navigator.pop() },
status = model.executionStatus,
statusText = model.statusText,
progress = model.progress,
progressText = model.progressText
)
},
containerColor = MaterialTheme.colorScheme.background
) { padding ->
val layoutDirection = LocalLayoutDirection.current
val startPadding = padding.calculateLeftPadding(layoutDirection)
val endPadding = padding.calculateRightPadding(layoutDirection)
val contentPadding = PaddingValues(
start = startPadding,
top = padding.calculateTopPadding(),
end = endPadding,
bottom = 0.dp
)
val sheetOffsetPx = runCatching {
scaffoldState.bottomSheetState.requireOffset()
}.getOrElse { (layoutHeightPx - peekHeightPx).coerceAtLeast(0f) }
val sheetHeightPx = (layoutHeightPx - sheetOffsetPx).coerceAtLeast(0f)
val sheetHeightOffset = with(density) { sheetHeightPx.toDp() }
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
comfyColors.gradientStart,
comfyColors.gradientEnd
)
)
)
) {
ImageGallery(
images = model.galleryImages,
onImageClick = { index ->
model.getPreviewImage(index)?.let { preview ->
navigator.push(
ImagePreviewScreen(
preview.filename,
preview.bytes,
onSave = { model.saveImage(index) },
onSetCover = { model.setCoverImage(index) }
)
)
}
},
onSaveClick = { index -> model.saveImage(index) },
modifier = Modifier
.fillMaxSize()
.padding(contentPadding)
)
WorkflowRunFab(
isRunning = model.running,
onStart = { model.runWorkflow() },
onInterrupt = { model.interrupt() },
modifier = Modifier
.align(Alignment.BottomEnd)
.navigationBarsPadding()
.padding(end = ComfySpacing.lg, bottom = ComfySpacing.lg)
.offset(y = -sheetHeightOffset)
} else {
CompactWorkflowRunLayout(
model = model,
navigator = navigator,
comfyColors = comfyColors
)
}
}
@@ -251,6 +159,321 @@ data class WorkflowRunScreen(val workflowId: String) : Screen {
}
}
/**
* 小屏幕布局:底部面板
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CompactWorkflowRunLayout(
model: WorkflowRunScreenModel,
navigator: cafe.adriel.voyager.navigator.Navigator,
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors
) {
val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(
initialValue = SheetValue.PartiallyExpanded,
skipHiddenState = false
)
)
val sheetPeekHeight = 120.dp
// Image picker state
var pendingImageNodeId by remember { mutableStateOf<String?>(null) }
val launchImagePicker = rememberImagePickerLauncher { pickedImage ->
pendingImageNodeId?.let { nodeId ->
if (pickedImage != null) {
model.onImageSelected(nodeId, pickedImage)
}
}
pendingImageNodeId = null
}
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val density = LocalDensity.current
val layoutHeightPx = with(density) { maxHeight.toPx() }
val peekHeightPx = with(density) { sheetPeekHeight.toPx() }
BottomSheetScaffold(
scaffoldState = scaffoldState,
sheetPeekHeight = sheetPeekHeight,
sheetContainerColor = comfyColors.cardBackground,
sheetContentColor = MaterialTheme.colorScheme.onSurface,
sheetShape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
sheetDragHandle = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(12.dp))
Box(
modifier = Modifier
.width(40.dp)
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "节点状态 (${model.nodeStates.size})",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
}
},
sheetContent = {
NodeListSheet(
nodes = model.orderedNodeStates,
randomSeedNodes = model.randomSeedNodes,
modelChoices = model.modelChoices,
loadingModels = model.loadingModels,
uploadingImageNodes = model.uploadingImageNodes,
uploadedImagePreviews = model.uploadedImagePreviews,
nodeOutputPreviews = model.nodeOutputPreviews,
isLoadingNodeOutputs = model.running,
onInputChange = { nodeId, field, value -> model.updateNodeInput(nodeId, field, value) },
onRandomSeedToggle = { nodeId, enabled -> model.setRandomSeedMode(nodeId, enabled) },
onRandomizeSeed = { nodeId -> model.randomizeSeed(nodeId) },
onLoadModels = { nodeId -> model.loadModelsForNode(nodeId) },
onSelectImage = { nodeId ->
pendingImageNodeId = nodeId
launchImagePicker()
},
modifier = Modifier.navigationBarsPadding()
)
},
topBar = {
WorkflowRunTopBar(
onBack = { navigator.pop() },
status = model.executionStatus,
statusText = model.statusText
)
},
containerColor = MaterialTheme.colorScheme.background
) { padding ->
val layoutDirection = LocalLayoutDirection.current
val startPadding = padding.calculateLeftPadding(layoutDirection)
val endPadding = padding.calculateRightPadding(layoutDirection)
val contentPadding = PaddingValues(
start = startPadding,
top = padding.calculateTopPadding(),
end = endPadding,
bottom = 0.dp
)
val sheetOffsetPx = runCatching {
scaffoldState.bottomSheetState.requireOffset()
}.getOrElse { (layoutHeightPx - peekHeightPx).coerceAtLeast(0f) }
val sheetHeightPx = (layoutHeightPx - sheetOffsetPx).coerceAtLeast(0f)
val sheetHeightOffset = with(density) { sheetHeightPx.toDp() }
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
comfyColors.gradientStart,
comfyColors.gradientEnd
)
)
)
) {
ImageGallery(
images = model.galleryImages,
onImageClick = { index ->
model.getPreviewImage(index)?.let { preview ->
navigator.push(
ImagePreviewScreen(
preview.filename,
preview.bytes,
onSave = { model.saveImage(index) },
onSetCover = { model.setCoverImage(index) }
)
)
}
},
onSaveClick = { index -> model.saveImage(index) },
modifier = Modifier
.fillMaxSize()
.padding(contentPadding)
)
WorkflowRunFab(
isRunning = model.running,
onStart = { model.runWorkflow() },
onInterrupt = { model.interrupt() },
modifier = Modifier
.align(Alignment.BottomEnd)
.navigationBarsPadding()
.padding(end = ComfySpacing.lg, bottom = ComfySpacing.lg)
.offset(y = -sheetHeightOffset)
)
}
}
}
}
/**
* 大屏幕布局:左右分栏
* 左侧:图片预览区域
* 右侧:节点列表
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ExpandedWorkflowRunLayout(
model: WorkflowRunScreenModel,
navigator: cafe.adriel.voyager.navigator.Navigator,
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors
) {
Column(modifier = Modifier.fillMaxSize()) {
// 顶部栏
WorkflowRunTopBar(
onBack = { navigator.pop() },
status = model.executionStatus,
statusText = model.statusText
)
// 主内容区:左右分栏
Row(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
comfyColors.gradientStart,
comfyColors.gradientEnd
)
)
)
) {
// 左侧:图片预览区域
Box(
modifier = Modifier
.weight(0.6f)
.fillMaxHeight()
) {
ImageGallery(
images = model.galleryImages,
onImageClick = { index ->
model.getPreviewImage(index)?.let { preview ->
navigator.push(
ImagePreviewScreen(
preview.filename,
preview.bytes,
onSave = { model.saveImage(index) },
onSetCover = { model.setCoverImage(index) }
)
)
}
},
onSaveClick = { index -> model.saveImage(index) },
modifier = Modifier
.fillMaxSize()
.padding(ComfySpacing.lg)
)
// FAB 放在左侧区域的右下角
WorkflowRunFab(
isRunning = model.running,
onStart = { model.runWorkflow() },
onInterrupt = { model.interrupt() },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(ComfySpacing.lg)
)
}
// 右侧:节点列表面板
ExpandedNodeListPanel(
model = model,
comfyColors = comfyColors,
modifier = Modifier
.widthIn(min = 320.dp, max = 420.dp)
.fillMaxHeight()
)
}
}
}
/**
* 大屏幕右侧节点列表面板
*/
@Composable
private fun ExpandedNodeListPanel(
model: WorkflowRunScreenModel,
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors,
modifier: Modifier = Modifier
) {
// Image picker state
var pendingImageNodeId by remember { mutableStateOf<String?>(null) }
val launchImagePicker = rememberImagePickerLauncher { pickedImage ->
pendingImageNodeId?.let { nodeId ->
if (pickedImage != null) {
model.onImageSelected(nodeId, pickedImage)
}
}
pendingImageNodeId = null
}
Column(
modifier = modifier
.background(comfyColors.cardBackground)
) {
// 面板标题
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ComfySpacing.lg, vertical = ComfySpacing.md),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "节点状态 (${model.nodeStates.size})",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
// 节点列表
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = ComfySpacing.lg,
end = ComfySpacing.lg,
bottom = ComfySpacing.xl
),
verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)
) {
items(model.orderedNodeStates, key = { it.nodeId }) { node ->
EditableNodeCard(
nodeId = node.nodeId,
classType = node.classType,
status = node.status,
inputs = node.inputs,
isRandomSeedEnabled = model.randomSeedNodes.contains(node.nodeId),
modelOptions = model.modelChoices[node.nodeId] ?: emptyList(),
isLoadingModels = model.loadingModels.contains(node.nodeId),
imagePreview = model.uploadedImagePreviews[node.nodeId],
isUploadingImage = model.uploadingImageNodes.contains(node.nodeId),
nodeOutputPreview = model.nodeOutputPreviews[node.nodeId],
isLoadingNodeOutput = model.running && model.nodeOutputPreviews[node.nodeId] == null,
onInputChange = { field, value ->
model.updateNodeInput(node.nodeId, field, value)
},
onRandomSeedToggle = { enabled ->
model.setRandomSeedMode(node.nodeId, enabled)
},
onRandomizeSeed = { model.randomizeSeed(node.nodeId) },
onLoadModels = { model.loadModelsForNode(node.nodeId) },
onSelectImage = {
pendingImageNodeId = node.nodeId
launchImagePicker()
}
)
}
}
}
}
private data class PreviewImage(
val filename: String,
val bytes: ByteArray
@@ -261,9 +484,7 @@ private data class PreviewImage(
private fun WorkflowRunTopBar(
onBack: () -> Unit,
status: ExecutionStatus,
statusText: String,
progress: Float?,
progressText: String?
statusText: String
) {
Column(
modifier = Modifier
@@ -287,9 +508,7 @@ private fun WorkflowRunTopBar(
ExecutionStatusBar(
status = status,
statusText = statusText,
progress = progress,
progressText = progressText
statusText = statusText
)
}
}
@@ -345,10 +564,15 @@ private fun NodeListSheet(
randomSeedNodes: Set<String>,
modelChoices: Map<String, List<String>>,
loadingModels: Set<String>,
uploadingImageNodes: Set<String>,
uploadedImagePreviews: Map<String, ImageBitmap>,
nodeOutputPreviews: Map<String, ImageBitmap>,
isLoadingNodeOutputs: Boolean,
onInputChange: (nodeId: String, field: String, value: JsonElement) -> Unit,
onRandomSeedToggle: (nodeId: String, enabled: Boolean) -> Unit,
onRandomizeSeed: (nodeId: String) -> Unit,
onLoadModels: (nodeId: String) -> Unit,
onSelectImage: (nodeId: String) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
@@ -369,10 +593,15 @@ private fun NodeListSheet(
isRandomSeedEnabled = randomSeedNodes.contains(node.nodeId),
modelOptions = modelChoices[node.nodeId] ?: emptyList(),
isLoadingModels = loadingModels.contains(node.nodeId),
imagePreview = uploadedImagePreviews[node.nodeId],
isUploadingImage = uploadingImageNodes.contains(node.nodeId),
nodeOutputPreview = nodeOutputPreviews[node.nodeId],
isLoadingNodeOutput = isLoadingNodeOutputs && nodeOutputPreviews[node.nodeId] == null,
onInputChange = { field, value -> onInputChange(node.nodeId, field, value) },
onRandomSeedToggle = { enabled -> onRandomSeedToggle(node.nodeId, enabled) },
onRandomizeSeed = { onRandomizeSeed(node.nodeId) },
onLoadModels = { onLoadModels(node.nodeId) }
onLoadModels = { onLoadModels(node.nodeId) },
onSelectImage = { onSelectImage(node.nodeId) }
)
}
}
@@ -383,7 +612,8 @@ private class WorkflowRunScreenModel(
private val workflowRepository: WorkflowRepository,
private val apiClient: ComfyApiClient,
private val wsClient: ComfyWebSocketClient,
private val serverRepository: ServerRepository
private val serverRepository: ServerRepository,
private val onToast: (String) -> Unit = {}
) : ScreenModel {
private var workflow = workflowRepository.getWorkflow(workflowId)
private val server = workflow?.let { serverRepository.getServers().firstOrNull { s -> s.id == it.serverId } }
@@ -419,8 +649,18 @@ private class WorkflowRunScreenModel(
var loadingModels by mutableStateOf<Set<String>>(emptySet())
private set
// Image upload state
var uploadingImageNodes by mutableStateOf<Set<String>>(emptySet())
private set
var uploadedImagePreviews by mutableStateOf<Map<String, ImageBitmap>>(emptyMap())
private set
// Node output preview images (for PreviewImage nodes)
var nodeOutputPreviews by mutableStateOf<Map<String, ImageBitmap>>(emptyMap())
private set
private var promptId by mutableStateOf<String?>(null)
private var imageRefs by mutableStateOf<List<ImageRef>>(emptyList())
private var nodeImageRefs by mutableStateOf<List<NodeImageRef>>(emptyList())
private var imageBitmaps by mutableStateOf<Map<String, ImageBitmap>>(emptyMap())
private var imageBytes by mutableStateOf<Map<String, ByteArray>>(emptyMap())
private var isLoadingImages by mutableStateOf(false)
@@ -429,12 +669,23 @@ private class WorkflowRunScreenModel(
private var wsStateJob: Job? = null
val galleryImages: List<GalleryImage>
get() = imageRefs.map { ref ->
GalleryImage(
filename = ref.filename,
bitmap = imageBitmaps[ref.filename],
isLoading = isLoadingImages && !imageBitmaps.containsKey(ref.filename)
)
get() {
// Filter out PreviewImage nodes, only show SaveImage outputs in gallery
val previewNodeIds = nodeStates
.filter { detectNodeType(it.classType) == EditableNodeType.PREVIEW_IMAGE }
.map { it.nodeId }
.toSet()
return nodeImageRefs
.filter { it.nodeId !in previewNodeIds }
.map { nodeRef ->
val ref = nodeRef.imageRef
GalleryImage(
filename = ref.filename,
bitmap = imageBitmaps[ref.filename],
isLoading = isLoadingImages && !imageBitmaps.containsKey(ref.filename)
)
}
}
val orderedNodeStates: List<NodeExecutionState>
@@ -456,7 +707,8 @@ private class WorkflowRunScreenModel(
}
fun getPreviewImage(index: Int): PreviewImage? {
val ref = imageRefs.getOrNull(index) ?: return null
val galleryRefs = getGalleryImageRefs()
val ref = galleryRefs.getOrNull(index) ?: return null
val bytes = imageBytes[ref.filename] ?: return null
return PreviewImage(ref.filename, bytes)
}
@@ -529,6 +781,30 @@ private class WorkflowRunScreenModel(
}.map { it.nodeId }
modelNodeIds.forEach { loadModelsForNode(it) }
}
fun onImageSelected(nodeId: String, pickedImage: PickedImage) {
screenModelScope.launch {
uploadingImageNodes = uploadingImageNodes + nodeId
try {
val response = apiClient.uploadImage(
baseUrl = baseUrl,
filename = pickedImage.filename,
bytes = pickedImage.bytes,
mimeType = pickedImage.mimeType
)
updateNodeInput(nodeId, "image", JsonPrimitive(response.name))
val bitmap = pickedImage.bytes.decodeToImageBitmap()
uploadedImagePreviews = uploadedImagePreviews + (nodeId to bitmap)
} catch (e: Exception) {
onToast("图片上传失败: ${e.message}")
} finally {
uploadingImageNodes = uploadingImageNodes - nodeId
}
}
}
private fun scheduleSave() {
saveJob?.cancel()
@@ -769,9 +1045,9 @@ private class WorkflowRunScreenModel(
screenModelScope.launch {
try {
val history = apiClient.getHistory(baseUrl, id)
val newRefs = extractImagesFromHistory(history)
if (newRefs.isNotEmpty() && newRefs != imageRefs) {
imageRefs = newRefs
val newRefs = extractImagesFromHistoryWithNodeId(history)
if (newRefs.isNotEmpty() && newRefs != nodeImageRefs) {
nodeImageRefs = newRefs
loadImages()
}
} catch (e: Exception) {
@@ -782,7 +1058,15 @@ private class WorkflowRunScreenModel(
private fun loadImages() {
isLoadingImages = true
imageRefs.forEach { ref ->
// Get PreviewImage node IDs
val previewNodeIds = nodeStates
.filter { detectNodeType(it.classType) == EditableNodeType.PREVIEW_IMAGE }
.map { it.nodeId }
.toSet()
nodeImageRefs.forEach { nodeRef ->
val ref = nodeRef.imageRef
if (!imageBitmaps.containsKey(ref.filename)) {
screenModelScope.launch {
try {
@@ -791,11 +1075,16 @@ private class WorkflowRunScreenModel(
imageBytes = imageBytes + (ref.filename to bytes)
val bitmap = bytes.decodeToImageBitmap()
imageBitmaps = imageBitmaps + (ref.filename to bitmap)
// If this is a PreviewImage node, update nodeOutputPreviews
if (nodeRef.nodeId in previewNodeIds) {
nodeOutputPreviews = nodeOutputPreviews + (nodeRef.nodeId to bitmap)
}
} catch (e: Exception) {
// Image loading failed
} finally {
// Check if all images are loaded
if (imageBitmaps.size >= imageRefs.size) {
if (imageBitmaps.size >= nodeImageRefs.size) {
isLoadingImages = false
}
}
@@ -803,26 +1092,28 @@ private class WorkflowRunScreenModel(
}
}
// If all images are already cached
if (imageRefs.all { imageBitmaps.containsKey(it.filename) }) {
if (nodeImageRefs.all { imageBitmaps.containsKey(it.imageRef.filename) }) {
isLoadingImages = false
}
}
fun saveImage(index: Int) {
val ref = imageRefs.getOrNull(index) ?: return
val galleryRefs = getGalleryImageRefs()
val ref = galleryRefs.getOrNull(index) ?: return
val bytes = imageBytes[ref.filename] ?: return
screenModelScope.launch {
try {
val location = saveImageToGallery(bytes, ref.filename)
statusText = "已保存: $location"
onToast("已保存: $location")
} catch (e: Exception) {
statusText = "保存失败: ${e.message}"
onToast("保存失败: ${e.message}")
}
}
}
fun setCoverImage(index: Int) {
val ref = imageRefs.getOrNull(index) ?: return
val galleryRefs = getGalleryImageRefs()
val ref = galleryRefs.getOrNull(index) ?: return
val bytes = imageBytes[ref.filename] ?: return
val current = workflow ?: return
@@ -834,7 +1125,17 @@ private class WorkflowRunScreenModel(
)
workflowRepository.upsertWorkflow(updated)
workflow = updated
statusText = "已设置为封面"
onToast("已设置为封面")
}
private fun getGalleryImageRefs(): List<ImageRef> {
val previewNodeIds = nodeStates
.filter { detectNodeType(it.classType) == EditableNodeType.PREVIEW_IMAGE }
.map { it.nodeId }
.toSet()
return nodeImageRefs
.filter { it.nodeId !in previewNodeIds }
.map { it.imageRef }
}
private fun updateNodeStatus(nodeId: String, status: NodeStatus) {
@@ -0,0 +1,117 @@
package moe.uni.comfyKmp.ui.theme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Material 3 Window Width Size Classes
*
* - Compact: < 600dp (手机竖屏)
* - Medium: 600dp - 840dp (小平板、折叠屏、手机横屏)
* - Expanded: > 840dp (大平板、桌面)
*/
enum class WindowWidthSizeClass {
Compact,
Medium,
Expanded
}
/**
* Material 3 Window Height Size Classes
*/
enum class WindowHeightSizeClass {
Compact,
Medium,
Expanded
}
/**
* 窗口尺寸信息
*/
data class WindowSizeInfo(
val widthSizeClass: WindowWidthSizeClass,
val heightSizeClass: WindowHeightSizeClass,
val widthDp: Dp,
val heightDp: Dp
) {
val isCompact: Boolean get() = widthSizeClass == WindowWidthSizeClass.Compact
val isMedium: Boolean get() = widthSizeClass == WindowWidthSizeClass.Medium
val isExpanded: Boolean get() = widthSizeClass == WindowWidthSizeClass.Expanded
/** 是否应该使用双面板布局 */
val shouldUseTwoPane: Boolean get() = widthSizeClass == WindowWidthSizeClass.Expanded
/** 是否应该使用网格布局 */
val shouldUseGrid: Boolean get() = widthSizeClass != WindowWidthSizeClass.Compact
}
/**
* 根据宽度计算 Window Width Size Class
*/
fun calculateWidthSizeClass(widthDp: Dp): WindowWidthSizeClass {
return when {
widthDp < 600.dp -> WindowWidthSizeClass.Compact
widthDp < 840.dp -> WindowWidthSizeClass.Medium
else -> WindowWidthSizeClass.Expanded
}
}
/**
* 根据高度计算 Window Height Size Class
*/
fun calculateHeightSizeClass(heightDp: Dp): WindowHeightSizeClass {
return when {
heightDp < 480.dp -> WindowHeightSizeClass.Compact
heightDp < 900.dp -> WindowHeightSizeClass.Medium
else -> WindowHeightSizeClass.Expanded
}
}
/**
* 计算窗口尺寸信息
*
* 需要在 BoxWithConstraints 内部使用,传入 maxWidth 和 maxHeight
*/
@Composable
fun rememberWindowSizeInfo(maxWidth: Dp, maxHeight: Dp): WindowSizeInfo {
return remember(maxWidth, maxHeight) {
WindowSizeInfo(
widthSizeClass = calculateWidthSizeClass(maxWidth),
heightSizeClass = calculateHeightSizeClass(maxHeight),
widthDp = maxWidth,
heightDp = maxHeight
)
}
}
/**
* 响应式布局常量
*/
object AdaptiveLayoutConstants {
/** 列表面板最小宽度 */
val listPaneMinWidth = 300.dp
/** 列表面板最大宽度 */
val listPaneMaxWidth = 400.dp
/** 列表面板推荐宽度 */
val listPanePreferredWidth = 360.dp
/** 详情面板最小宽度 */
val detailPaneMinWidth = 400.dp
/** 卡片最大宽度 */
val cardMaxWidth = 480.dp
/** 对话框最大宽度 */
val dialogMaxWidth = 560.dp
/** 网格项最小宽度 */
val gridItemMinWidth = 280.dp
/** 网格项最大宽度 */
val gridItemMaxWidth = 400.dp
}
@@ -0,0 +1,99 @@
package moe.uni.comfyKmp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.refTo
import platform.Foundation.NSData
import platform.Foundation.NSDate
import platform.Foundation.timeIntervalSince1970
import platform.PhotosUI.PHPickerConfiguration
import platform.PhotosUI.PHPickerFilter
import platform.PhotosUI.PHPickerResult
import platform.PhotosUI.PHPickerViewController
import platform.PhotosUI.PHPickerViewControllerDelegateProtocol
import platform.UIKit.UIApplication
import platform.UIKit.UIViewController
import platform.UIKit.UIWindow
import platform.UniformTypeIdentifiers.UTTypeImage
import platform.darwin.NSObject
import platform.posix.memcpy
@Composable
actual fun rememberImagePickerLauncher(
onResult: (PickedImage?) -> Unit
): () -> Unit {
val onResultState = rememberUpdatedState(onResult)
val controller = remember {
IosImagePickerController { image ->
onResultState.value(image)
}
}
return { controller.launch() }
}
private class IosImagePickerController(
private val onResult: (PickedImage?) -> Unit
) : NSObject(), PHPickerViewControllerDelegateProtocol {
fun launch() {
val presenter = currentPresenter()
if (presenter == null) {
onResult(null)
return
}
val config = PHPickerConfiguration().apply {
filter = PHPickerFilter.imagesFilter
selectionLimit = 1
}
val picker = PHPickerViewController(configuration = config)
picker.delegate = this
presenter.presentViewController(picker, animated = true, completion = null)
}
override fun picker(picker: PHPickerViewController, didFinishPicking: List<*>) {
picker.dismissViewControllerAnimated(true, completion = null)
val result = didFinishPicking.firstOrNull() as? PHPickerResult
if (result == null) {
onResult(null)
return
}
result.itemProvider.loadDataRepresentationForTypeIdentifier(
typeIdentifier = UTTypeImage.identifier
) { data, error ->
if (data != null && error == null) {
val bytes = data.toByteArray()
val timestamp = NSDate().timeIntervalSince1970.toLong()
val filename = "image_$timestamp.png"
onResult(PickedImage(filename, bytes, "image/png"))
} else {
onResult(null)
}
}
}
}
private fun currentPresenter(): UIViewController? {
val app = UIApplication.sharedApplication
val window = (app.keyWindow ?: app.windows.firstOrNull()) as? UIWindow
var controller = window?.rootViewController
while (controller?.presentedViewController != null) {
controller = controller.presentedViewController
}
return controller
}
@OptIn(ExperimentalForeignApi::class)
private fun NSData.toByteArray(): ByteArray {
val size = length.toInt()
val bytes = ByteArray(size)
if (size > 0) {
memcpy(bytes.refTo(0), this.bytes, length)
}
return bytes
}