Compare commits
2 Commits
e5a779e48a
...
c0e04852a7
| Author | SHA1 | Date | |
|---|---|---|---|
| c0e04852a7 | |||
| 0c3ef2f6b4 |
@@ -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("删除")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+507
-100
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+450
-149
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user