Init
CI / android (push) Failing after 7m49s

This commit is contained in:
anran
2026-01-30 02:49:28 +08:00
commit 4cd28f7274
82 changed files with 6276 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
name: CI
on:
push:
pull_request:
jobs:
android:
runs-on: ubuntu-latest
env:
GRADLE_USER_HOME: ${{ github.workspace }}/.gradle
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
cache: gradle
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Configure SDK path for CI
run: |
echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties
- name: Install Android SDK packages
run: |
yes | sdkmanager --licenses
sdkmanager "platforms;android-36" "platform-tools"
- name: Build (Android)
run: ./gradlew :composeApp:assembleDebug :composeApp:testDebugUnitTest --no-daemon --stacktrace
+19
View File
@@ -0,0 +1,19 @@
*.iml
.kotlin
.gradle
**/build/
xcuserdata
!src/**/build/
local.properties
.idea
.DS_Store
captures
.externalNativeBuild
.cxx
*.xcodeproj/*
!*.xcodeproj/project.pbxproj
!*.xcodeproj/xcshareddata/
!*.xcodeproj/project.xcworkspace/
!*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings
node_modules/
+36
View File
@@ -0,0 +1,36 @@
This is a Kotlin Multiplatform project targeting Android, iOS.
* [/composeApp](./composeApp/src) is for code that will be shared across your Compose Multiplatform applications.
It contains several subfolders:
- [commonMain](./composeApp/src/commonMain/kotlin) is for code thats common for all targets.
- Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name.
For example, if you want to use Apples CoreCrypto for the iOS part of your Kotlin app,
the [iosMain](./composeApp/src/iosMain/kotlin) folder would be the right place for such calls.
Similarly, if you want to edit the Desktop (JVM) specific part, the [jvmMain](./composeApp/src/jvmMain/kotlin)
folder is the appropriate location.
* [/iosApp](./iosApp/iosApp) contains iOS applications. Even if youre sharing your UI with Compose Multiplatform,
you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.
### Build and Run Android Application
To build and run the development version of the Android app, use the run configuration from the run widget
in your IDEs toolbar or build it directly from the terminal:
- on macOS/Linux
```shell
./gradlew :composeApp:assembleDebug
```
- on Windows
```shell
.\gradlew.bat :composeApp:assembleDebug
```
### Build and Run iOS Application
To build and run the development version of the iOS app, use the run configuration from the run widget
in your IDEs toolbar or open the [/iosApp](./iosApp) directory in Xcode and run it from there.
---
Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)…
+9
View File
@@ -0,0 +1,9 @@
plugins {
// this is necessary to avoid the plugins to be loaded multiple times
// in each subproject's classloader
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.androidLibrary) apply false
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
}
+97
View File
@@ -0,0 +1,97 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
}
kotlin {
androidTarget {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
listOf(
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
}
sourceSets {
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose)
}
commonMain.dependencies {
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(compose.materialIconsExtended)
implementation(libs.compose.ui)
implementation(libs.compose.components.resources)
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.kotlinx.serialization.json)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.client.websockets)
implementation(libs.multiplatform.settings.no.arg)
implementation(libs.voyager.navigator)
implementation(libs.voyager.screenmodel)
implementation(libs.voyager.transitions)
implementation(libs.okio)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
}
}
android {
namespace = "moe.uni.comfy_kmp"
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
applicationId = "moe.uni.comfy_kmp"
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = "1.0"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
dependencies {
debugImplementation(libs.compose.uiTooling)
}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:exported="true"
android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,12 @@
package moe.uni.comfy_kmp
import android.content.Context
object AndroidAppContext {
lateinit var appContext: Context
private set
fun init(context: Context) {
appContext = context.applicationContext
}
}
@@ -0,0 +1,46 @@
package moe.uni.comfy_kmp
import android.content.Context
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import java.io.BufferedReader
import java.io.InputStreamReader
@Composable
actual fun rememberFilePickerLauncher(
onResult: (String?) -> Unit
): () -> Unit {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
if (uri != null) {
val content = readTextFromUri(context, uri)
onResult(content)
} else {
onResult(null)
}
}
return {
launcher.launch(arrayOf("application/json", "text/plain", "*/*"))
}
}
actual fun isFilePickerSupported(): Boolean = true
private fun readTextFromUri(context: Context, uri: Uri): String? {
return try {
context.contentResolver.openInputStream(uri)?.use { inputStream ->
BufferedReader(InputStreamReader(inputStream)).use { reader ->
reader.readText()
}
}
} catch (e: Exception) {
e.printStackTrace()
null
}
}
@@ -0,0 +1,26 @@
package moe.uni.comfy_kmp
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
AndroidAppContext.init(this)
setContent {
App()
}
}
}
@Preview
@Composable
fun AppAndroidPreview() {
App()
}
@@ -0,0 +1,9 @@
package moe.uni.comfy_kmp
import android.os.Build
class AndroidPlatform : Platform {
override val name: String = "Android ${Build.VERSION.SDK_INT}"
}
actual fun getPlatform(): Platform = AndroidPlatform()
@@ -0,0 +1,36 @@
package moe.uni.comfy_kmp
import android.app.Activity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
@Composable
actual fun SystemBarsEffect(hidden: Boolean) {
val view = LocalView.current
DisposableEffect(hidden) {
val activity = view.context as? Activity
val window = activity?.window
val controller = window?.let { WindowCompat.getInsetsController(it, it.decorView) }
val previousBehavior = controller?.systemBarsBehavior
if (hidden) {
controller?.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller?.hide(WindowInsetsCompat.Type.systemBars())
} else {
controller?.show(WindowInsetsCompat.Type.systemBars())
}
onDispose {
controller?.show(WindowInsetsCompat.Type.systemBars())
if (previousBehavior != null) {
controller.systemBarsBehavior = previousBehavior
}
}
}
}
@@ -0,0 +1,33 @@
package moe.uni.comfy_kmp.data
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import java.io.File
import kotlin.math.max
import moe.uni.comfy_kmp.AndroidAppContext
actual fun saveCoverImage(bytes: ByteArray, filenameHint: String): String {
val safeName = filenameHint.replace(Regex("[^A-Za-z0-9._-]"), "_")
val file = File(AndroidAppContext.appContext.cacheDir, safeName)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
?: throw IllegalArgumentException("无法解码封面图片")
val scaled = scaleDownBitmap(bitmap, 1024)
file.outputStream().use { stream ->
scaled.compress(Bitmap.CompressFormat.JPEG, 80, stream)
}
if (scaled !== bitmap) {
scaled.recycle()
}
return file.absolutePath
}
private fun scaleDownBitmap(bitmap: Bitmap, maxSize: Int): Bitmap {
val width = bitmap.width
val height = bitmap.height
val largest = max(width, height)
if (largest <= maxSize) return bitmap
val scale = maxSize.toFloat() / largest.toFloat()
val targetWidth = (width * scale).toInt().coerceAtLeast(1)
val targetHeight = (height * scale).toInt().coerceAtLeast(1)
return Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true)
}
@@ -0,0 +1,8 @@
package moe.uni.comfy_kmp.network
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
actual fun createHttpClient(): HttpClient = HttpClient(OkHttp) {
installComfyPlugins()
}
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0"/>
<item
android:color="#00000000"
android:offset="1.0"/>
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000"/>
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/>
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">comfy_kmp</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
@@ -0,0 +1,44 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="450dp"
android:height="450dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M56.25,18V46L32,60 7.75,46V18L32,4Z"
android:fillColor="#6075f2"/>
<path
android:pathData="m41.5,26.5v11L32,43V60L56.25,46V18Z"
android:fillColor="#6b57ff"/>
<path
android:pathData="m32,43 l-9.5,-5.5v-11L7.75,18V46L32,60Z">
<aapt:attr name="android:fillColor">
<gradient
android:centerX="23.131"
android:centerY="18.441"
android:gradientRadius="42.132"
android:type="radial">
<item android:offset="0" android:color="#FF5383EC"/>
<item android:offset="0.867" android:color="#FF7F52FF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M22.5,26.5 L32,21 41.5,26.5 56.25,18 32,4 7.75,18Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="44.172"
android:startY="4.377"
android:endX="17.973"
android:endY="34.035"
android:type="linear">
<item android:offset="0" android:color="#FF33C3FF"/>
<item android:offset="0.878" android:color="#FF5383EC"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="m32,21 l9.526,5.5v11L32,43 22.474,37.5v-11z"
android:fillColor="#000000"/>
</vector>
@@ -0,0 +1,23 @@
package moe.uni.comfy_kmp
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.tooling.preview.Preview
import cafe.adriel.voyager.navigator.Navigator
import moe.uni.comfy_kmp.di.AppContainer
import moe.uni.comfy_kmp.di.LocalAppContainer
import moe.uni.comfy_kmp.ui.screens.ServerScreen
import moe.uni.comfy_kmp.ui.theme.ComfyTheme
@Composable
@Preview
fun App() {
val container = remember { AppContainer() }
CompositionLocalProvider(LocalAppContainer provides container) {
ComfyTheme(darkTheme = isSystemInDarkTheme()) {
Navigator(ServerScreen())
}
}
}
@@ -0,0 +1,18 @@
package moe.uni.comfy_kmp
import androidx.compose.runtime.Composable
/**
* Remembers a file picker launcher that can be used to select JSON files.
* @param onResult Callback with the file content as String, or null if cancelled/failed
* @return A function to launch the file picker
*/
@Composable
expect fun rememberFilePickerLauncher(
onResult: (String?) -> Unit
): () -> Unit
/**
* Returns whether file picking is supported on the current platform
*/
expect fun isFilePickerSupported(): Boolean
@@ -0,0 +1,9 @@
package moe.uni.comfy_kmp
class Greeting {
private val platform = getPlatform()
fun greet(): String {
return "Hello, ${platform.name}!"
}
}
@@ -0,0 +1,7 @@
package moe.uni.comfy_kmp
interface Platform {
val name: String
}
expect fun getPlatform(): Platform
@@ -0,0 +1,6 @@
package moe.uni.comfy_kmp
import androidx.compose.runtime.Composable
@Composable
expect fun SystemBarsEffect(hidden: Boolean)
@@ -0,0 +1,16 @@
package moe.uni.comfy_kmp.data
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
val ComfyJson = Json {
ignoreUnknownKeys = true
isLenient = true
prettyPrint = false
}
fun parseJsonObject(raw: String): JsonObject = ComfyJson.parseToJsonElement(raw).jsonObject
fun jsonOrNull(raw: String): JsonElement? = runCatching { ComfyJson.parseToJsonElement(raw) }.getOrNull()
@@ -0,0 +1,24 @@
package moe.uni.comfy_kmp.data
import okio.FileSystem
import okio.Path.Companion.toPath
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
expect fun saveCoverImage(bytes: ByteArray, filenameHint: String): String
@OptIn(ExperimentalEncodingApi::class)
fun loadCoverImageBytes(coverImage: String): ByteArray? {
val cleaned = coverImage.removePrefix("file://")
val path = cleaned.toPath()
if (FileSystem.SYSTEM.exists(path)) {
return FileSystem.SYSTEM.read(path) {
readByteArray()
}
}
return try {
Base64.decode(coverImage)
} catch (_: IllegalArgumentException) {
null
}
}
@@ -0,0 +1,13 @@
package moe.uni.comfy_kmp.data
import okio.FileSystem
fun saveToTemp(bytes: ByteArray, filename: String): String {
val dir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY
val safeName = filename.replace(Regex("[^A-Za-z0-9._-]"), "_")
val path = dir / safeName
FileSystem.SYSTEM.write(path) {
write(bytes)
}
return path.toString()
}
@@ -0,0 +1,26 @@
package moe.uni.comfy_kmp.data
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
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>()
val root = history as? JsonObject ?: return emptyList()
root.values.forEach { promptEntry ->
val outputs = promptEntry.jsonObject["outputs"]?.jsonObject ?: return@forEach
outputs.values.forEach { 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))
}
}
}
return result
}
@@ -0,0 +1,90 @@
package moe.uni.comfy_kmp.data
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
@Serializable
data class ServerConfig(
val id: String,
val name: String,
val baseUrl: String,
val isDefault: Boolean = false
)
@Serializable
data class WorkflowEntity(
val id: String,
val name: String,
val json: String,
val serverId: String,
val updatedAt: Long,
val coverImage: String? = null
)
@Serializable
data class PromptNode(
val class_type: String,
val inputs: JsonObject = JsonObject(emptyMap())
)
@Serializable
data class PromptRequest(
val prompt: JsonObject,
val client_id: String? = null
)
@Serializable
data class PromptResponse(
val prompt_id: String? = null,
val number: Int? = null,
val error: String? = null,
val node_errors: JsonObject? = null
)
@Serializable
data class ImageRef(
val filename: String,
val subfolder: String? = null,
val type: String? = null
)
@Serializable
data class WsMessage(
val type: String,
val data: JsonObject? = null
)
@Serializable
data class SimpleStatus(
val status: String,
val message: String? = null
)
@Serializable
data class QueueSnapshot(
val queue_running: JsonArray? = null,
val queue_pending: JsonArray? = null
)
data class PromptNodeField(
val name: String,
val type: String,
val defaultValue: JsonElement? = null,
val optional: Boolean = false
)
enum class NodeStatus {
PENDING,
RUNNING,
COMPLETED,
ERROR
}
data class NodeExecutionState(
val nodeId: String,
val classType: String,
val status: NodeStatus = NodeStatus.PENDING,
val inputs: Map<String, JsonElement> = emptyMap()
)
@@ -0,0 +1,117 @@
package moe.uni.comfy_kmp.data
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
data class PromptNodeEntry(
val id: String,
val classType: String,
val inputs: JsonObject
)
/**
* Extracts the prompt object from various JSON formats:
* 1. ComfyUI web export format: {"prompt": {...}, "workflow": {...}}
* 2. Direct prompt object: {"1": {"class_type": "...", "inputs": {...}}, ...}
* 3. API format: {"prompt": {...}}
*/
fun extractPromptObject(raw: String): JsonObject {
if (raw.isBlank()) return JsonObject(emptyMap())
return try {
val root = parseJsonObject(raw)
// Check if it's a web export format with "prompt" key
val prompt = root["prompt"]?.jsonObject
if (prompt != null) {
return prompt
}
// Check if root looks like a prompt object (has nodes with class_type)
val hasNodes = root.any { (_, value) ->
try {
val obj = value.jsonObject
obj.containsKey("class_type") || obj.containsKey("inputs")
} catch (e: Exception) {
false
}
}
if (hasNodes) {
return root
}
// Return empty if nothing valid found
JsonObject(emptyMap())
} catch (e: Exception) {
JsonObject(emptyMap())
}
}
fun parsePromptNodes(prompt: JsonObject): List<PromptNodeEntry> {
return prompt.mapNotNull { (id, nodeElement) ->
try {
val nodeObj = nodeElement.jsonObject
val classType = nodeObj["class_type"]?.jsonPrimitive?.content
if (classType == null) {
// Skip entries that don't look like nodes
null
} else {
val inputs = nodeObj["inputs"]?.jsonObject ?: JsonObject(emptyMap())
PromptNodeEntry(id = id, classType = classType, inputs = inputs)
}
} catch (e: Exception) {
null
}
}.sortedBy { it.id.toIntOrNull() ?: Int.MAX_VALUE }
}
fun updateNodeInput(
prompt: JsonObject,
nodeId: String,
field: String,
value: JsonElement
): JsonObject {
val nodeObj = prompt[nodeId]?.jsonObject ?: return prompt
val currentInputs = nodeObj["inputs"]?.jsonObject ?: JsonObject(emptyMap())
val newInputs = JsonObject(currentInputs.toMutableMap().apply { put(field, value) })
val newNode = JsonObject(nodeObj.toMutableMap().apply { put("inputs", newInputs) })
return JsonObject(prompt.toMutableMap().apply { put(nodeId, newNode) })
}
fun guessInputType(element: JsonElement?): String {
return when (element) {
null -> "string"
is JsonPrimitive -> {
when {
element.isString -> "string"
element.booleanOrNull != null -> "boolean"
element.longOrNull != null || element.doubleOrNull != null -> "number"
else -> "string"
}
}
is JsonArray -> "array"
is JsonObject -> "object"
else -> "string"
}
}
/**
* Validates if a JSON string contains a valid ComfyUI workflow/prompt
*/
fun isValidWorkflowJson(raw: String): Boolean {
if (raw.isBlank()) return false
return try {
val prompt = extractPromptObject(raw)
prompt.isNotEmpty() && parsePromptNodes(prompt).isNotEmpty()
} catch (e: Exception) {
false
}
}
@@ -0,0 +1,7 @@
package moe.uni.comfy_kmp.di
import androidx.compose.runtime.compositionLocalOf
val LocalAppContainer = compositionLocalOf<AppContainer> {
error("AppContainer not provided")
}
@@ -0,0 +1,19 @@
package moe.uni.comfy_kmp.di
import moe.uni.comfy_kmp.network.ComfyApiClient
import moe.uni.comfy_kmp.network.ComfyWebSocketClient
import moe.uni.comfy_kmp.network.createHttpClient
import moe.uni.comfy_kmp.storage.ServerRepository
import moe.uni.comfy_kmp.storage.SettingsStorage
import moe.uni.comfy_kmp.storage.WorkflowRepository
class AppContainer(
private val storage: SettingsStorage = SettingsStorage()
) {
private val httpClient = createHttpClient()
val serverRepository = ServerRepository(storage)
val workflowRepository = WorkflowRepository(storage)
val apiClient = ComfyApiClient(httpClient)
val wsClient = ComfyWebSocketClient(httpClient)
}
@@ -0,0 +1,72 @@
package moe.uni.comfy_kmp.network
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.delete
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.contentType
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import moe.uni.comfy_kmp.data.PromptRequest
import moe.uni.comfy_kmp.data.PromptResponse
class ComfyApiClient(
private val httpClient: HttpClient
) {
suspend fun getObjectInfo(baseUrl: String): JsonObject {
return httpClient.get("${baseUrl.trimEnd('/')}/object_info").body()
}
suspend fun getObjectInfo(baseUrl: String, nodeClass: String): JsonObject {
return httpClient.get("${baseUrl.trimEnd('/')}/object_info/$nodeClass").body()
}
suspend fun getModels(baseUrl: String): JsonElement {
return httpClient.get("${baseUrl.trimEnd('/')}/models").body()
}
suspend fun getModels(baseUrl: String, folder: String): JsonElement {
return httpClient.get("${baseUrl.trimEnd('/')}/models/$folder").body()
}
suspend fun getQueue(baseUrl: String): JsonElement {
return httpClient.get("${baseUrl.trimEnd('/')}/queue").body()
}
suspend fun getHistory(baseUrl: String): JsonElement {
return httpClient.get("${baseUrl.trimEnd('/')}/history").body()
}
suspend fun getHistory(baseUrl: String, promptId: String): JsonElement {
return httpClient.get("${baseUrl.trimEnd('/')}/history/$promptId").body()
}
suspend fun postPrompt(baseUrl: String, request: PromptRequest): PromptResponse {
return httpClient.post("${baseUrl.trimEnd('/')}/prompt") {
contentType(ContentType.Application.Json)
setBody(request)
}.body()
}
suspend fun interrupt(baseUrl: String): JsonElement {
return httpClient.post("${baseUrl.trimEnd('/')}/interrupt").body()
}
suspend fun clearHistory(baseUrl: String): JsonElement {
return httpClient.post("${baseUrl.trimEnd('/')}/history") {
contentType(ContentType.Application.Json)
setBody(mapOf("clear" to true))
}.body()
}
suspend fun deleteHistoryItem(baseUrl: String, promptId: String): JsonElement {
return httpClient.delete("${baseUrl.trimEnd('/')}/history/$promptId").body()
}
suspend fun getBytes(url: String): ByteArray {
return httpClient.get(url).body()
}
}
@@ -0,0 +1,76 @@
package moe.uni.comfy_kmp.network
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.close
import io.ktor.websocket.readText
import io.ktor.utils.io.errors.IOException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import moe.uni.comfy_kmp.data.ComfyJson
import moe.uni.comfy_kmp.data.WsMessage
class ComfyWebSocketClient(
private val httpClient: HttpClient
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _events = MutableSharedFlow<WsMessage>(extraBufferCapacity = 64)
val events: SharedFlow<WsMessage> = _events
private var session: WebSocketSession? = null
fun connect(baseUrl: String, clientId: String) {
if (session != null) return
val wsUrl = buildWsUrl(baseUrl, clientId)
scope.launch {
try {
httpClient.webSocket(wsUrl) {
session = this
try {
for (frame in incoming) {
if (frame is Frame.Text) {
val element = ComfyJson.parseToJsonElement(frame.readText())
val obj = element.jsonObject
val type = obj["type"]?.jsonPrimitive?.content ?: "unknown"
val data = obj["data"] as? JsonObject
_events.tryEmit(WsMessage(type = type, data = data))
}
}
} catch (_: IOException) {
// 后台/断网时底层 socket 关闭会抛异常,这里忽略即可
} finally {
session = null
}
}
} catch (_: IOException) {
session = null
}
}
}
suspend fun disconnect() {
session?.close()
session = null
}
fun close() {
scope.cancel()
}
private fun buildWsUrl(baseUrl: String, clientId: String): String {
val trimmed = baseUrl.trimEnd('/')
val scheme = if (trimmed.startsWith("https://")) "wss://" else "ws://"
val host = trimmed.removePrefix("https://").removePrefix("http://")
return "${scheme}${host}/ws?clientId=$clientId"
}
}
@@ -0,0 +1,17 @@
package moe.uni.comfy_kmp.network
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.serialization.kotlinx.json.json
import moe.uni.comfy_kmp.data.ComfyJson
internal fun HttpClientConfig<*>.installComfyPlugins() {
install(ContentNegotiation) {
json(ComfyJson)
}
install(WebSockets)
}
expect fun createHttpClient(): HttpClient
@@ -0,0 +1,76 @@
package moe.uni.comfy_kmp.storage
import moe.uni.comfy_kmp.data.ServerConfig
import moe.uni.comfy_kmp.data.WorkflowEntity
import kotlin.random.Random
import kotlin.time.Clock
class ServerRepository(
private val storage: SettingsStorage
) {
fun getServers(): List<ServerConfig> = storage.getServers()
fun getActiveServer(): ServerConfig? {
val servers = storage.getServers()
val activeId = storage.getActiveServerId()
return servers.firstOrNull { it.id == activeId } ?: servers.firstOrNull { it.isDefault }
}
fun setActiveServer(serverId: String?) {
storage.setActiveServerId(serverId)
}
fun upsertServer(server: ServerConfig) {
val servers = storage.getServers().toMutableList()
val index = servers.indexOfFirst { it.id == server.id }
if (index >= 0) {
servers[index] = server
} else {
servers.add(server)
}
storage.saveServers(servers)
}
fun deleteServer(serverId: String) {
val servers = storage.getServers().filterNot { it.id == serverId }
storage.saveServers(servers)
val active = storage.getActiveServerId()
if (active == serverId) {
storage.setActiveServerId(servers.firstOrNull()?.id)
}
}
}
class WorkflowRepository(
private val storage: SettingsStorage
) {
fun getWorkflows(serverId: String): List<WorkflowEntity> {
return storage.getWorkflows().filter { it.serverId == serverId }
}
fun getWorkflow(workflowId: String): WorkflowEntity? {
return storage.getWorkflows().firstOrNull { it.id == workflowId }
}
fun upsertWorkflow(workflow: WorkflowEntity) {
val items = storage.getWorkflows().toMutableList()
val index = items.indexOfFirst { it.id == workflow.id }
if (index >= 0) {
items[index] = workflow
} else {
items.add(workflow)
}
storage.saveWorkflows(items)
}
fun deleteWorkflow(workflowId: String) {
val items = storage.getWorkflows().filterNot { it.id == workflowId }
storage.saveWorkflows(items)
}
}
fun generateId(prefix: String): String {
val seed = Clock.System.now().epochSeconds
val rand = Random.nextInt(1000, 9999)
return "${prefix}_${seed}_$rand"
}
@@ -0,0 +1,76 @@
package moe.uni.comfy_kmp.storage
import com.russhwolf.settings.Settings
import moe.uni.comfy_kmp.data.ComfyJson
import moe.uni.comfy_kmp.data.ServerConfig
import moe.uni.comfy_kmp.data.WorkflowEntity
import moe.uni.comfy_kmp.data.saveCoverImage
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
import okio.FileSystem
import okio.Path.Companion.toPath
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.time.Clock
class SettingsStorage(
private val settings: Settings = Settings()
) {
private val serversKey = "servers"
private val workflowsKey = "workflows"
private val activeServerKey = "active_server_id"
fun getServers(): List<ServerConfig> {
val raw = settings.getStringOrNull(serversKey) ?: return emptyList()
return ComfyJson.decodeFromString(ListSerializer(ServerConfig.serializer()), raw)
}
fun saveServers(servers: List<ServerConfig>) {
settings.putString(serversKey, ComfyJson.encodeToString(servers))
}
fun getActiveServerId(): String? = settings.getStringOrNull(activeServerKey)
fun setActiveServerId(serverId: String?) {
if (serverId == null) {
settings.remove(activeServerKey)
} else {
settings.putString(activeServerKey, serverId)
}
}
fun getWorkflows(): List<WorkflowEntity> {
val raw = settings.getStringOrNull(workflowsKey) ?: return emptyList()
val items = ComfyJson.decodeFromString(ListSerializer(WorkflowEntity.serializer()), raw)
val migrated = migrateCoverImages(items)
if (migrated !== items) {
saveWorkflows(migrated)
}
return migrated
}
fun saveWorkflows(workflows: List<WorkflowEntity>) {
settings.putString(workflowsKey, ComfyJson.encodeToString(workflows))
}
@OptIn(ExperimentalEncodingApi::class)
private fun migrateCoverImages(items: List<WorkflowEntity>): List<WorkflowEntity> {
var changed = false
val updated = items.map { workflow ->
val cover = workflow.coverImage ?: return@map workflow
val path = cover.removePrefix("file://").toPath()
if (FileSystem.SYSTEM.exists(path)) return@map workflow
val bytes = try {
Base64.decode(cover)
} catch (_: IllegalArgumentException) {
return@map workflow
}
val filename = "cover_${workflow.id}_${Clock.System.now().toEpochMilliseconds()}.jpg"
val savedPath = saveCoverImage(bytes, filename)
changed = true
workflow.copy(coverImage = savedPath)
}
return if (changed) updated else items
}
}
@@ -0,0 +1,186 @@
package moe.uni.comfy_kmp.ui.components
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.ColumnScope
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.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.comfy_kmp.ui.theme.ComfySpacing
import moe.uni.comfy_kmp.ui.theme.comfyColors
@Composable
fun ComfyDialog(
onDismissRequest: () -> Unit,
title: String,
confirmText: String = "确认",
dismissText: String = "取消",
onConfirm: () -> Unit,
confirmEnabled: Boolean = true,
showDismiss: Boolean = true,
properties: DialogProperties = DialogProperties(),
content: @Composable ColumnScope.() -> Unit
) {
Dialog(
onDismissRequest = onDismissRequest,
properties = properties
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(28.dp)),
color = MaterialTheme.comfyColors.cardBackground,
tonalElevation = 6.dp,
shadowElevation = 8.dp
) {
Column(
modifier = Modifier.padding(ComfySpacing.xl)
) {
// Title
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(ComfySpacing.lg))
// Content
content()
Spacer(modifier = Modifier.height(ComfySpacing.xl))
// Actions
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
if (showDismiss) {
TextButton(onClick = onDismissRequest) {
Text(dismissText)
}
Spacer(modifier = Modifier.width(ComfySpacing.sm))
}
Button(
onClick = onConfirm,
enabled = confirmEnabled,
shape = RoundedCornerShape(12.dp)
) {
Text(confirmText)
}
}
}
}
}
}
@Composable
fun ComfyAlertDialog(
onDismissRequest: () -> Unit,
title: String,
message: String,
confirmText: String = "确认",
dismissText: String = "取消",
onConfirm: () -> Unit,
showDismiss: Boolean = true,
isDestructive: Boolean = false
) {
Dialog(onDismissRequest = onDismissRequest) {
Surface(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(28.dp)),
color = MaterialTheme.comfyColors.cardBackground,
tonalElevation = 6.dp,
shadowElevation = 8.dp
) {
Column(
modifier = Modifier.padding(ComfySpacing.xl)
) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(ComfySpacing.md))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(ComfySpacing.xl))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
if (showDismiss) {
TextButton(onClick = onDismissRequest) {
Text(dismissText)
}
Spacer(modifier = Modifier.width(ComfySpacing.sm))
}
Button(
onClick = onConfirm,
shape = RoundedCornerShape(12.dp),
colors = if (isDestructive) {
androidx.compose.material3.ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
} else {
androidx.compose.material3.ButtonDefaults.buttonColors()
}
) {
Text(confirmText)
}
}
}
}
}
}
@Composable
fun ComfyInputDialog(
onDismissRequest: () -> Unit,
title: String,
confirmText: String = "确认",
dismissText: String = "取消",
onConfirm: () -> Unit,
confirmEnabled: Boolean = true,
content: @Composable ColumnScope.() -> Unit
) {
ComfyDialog(
onDismissRequest = onDismissRequest,
title = title,
confirmText = confirmText,
dismissText = dismissText,
onConfirm = onConfirm,
confirmEnabled = confirmEnabled,
content = content
)
}
@@ -0,0 +1,122 @@
package moe.uni.comfy_kmp.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import moe.uni.comfy_kmp.ui.theme.ComfySpacing
import moe.uni.comfy_kmp.ui.theme.comfyColors
@Composable
fun GlassCard(
modifier: Modifier = Modifier,
cornerRadius: Dp = 16.dp,
onClick: (() -> Unit)? = null,
isSelected: Boolean = false,
content: @Composable BoxScope.() -> Unit
) {
val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState()
val isPressed by interactionSource.collectIsPressedAsState()
val elevation by animateDpAsState(
targetValue = when {
isPressed -> 2.dp
isHovered -> 8.dp
isSelected -> 6.dp
else -> 4.dp
},
animationSpec = tween(150)
)
val borderColor by animateColorAsState(
targetValue = when {
isSelected -> MaterialTheme.colorScheme.primary
isHovered -> MaterialTheme.colorScheme.outline
else -> MaterialTheme.comfyColors.cardBorder
},
animationSpec = tween(150)
)
val shape = RoundedCornerShape(cornerRadius)
Box(
modifier = modifier
.shadow(
elevation = elevation,
shape = shape,
ambientColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
)
.clip(shape)
.background(MaterialTheme.comfyColors.cardBackground)
.border(
width = if (isSelected) 2.dp else 1.dp,
color = borderColor,
shape = shape
)
.then(
if (onClick != null) {
Modifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = onClick
)
} else Modifier
)
.padding(ComfySpacing.lg),
content = content
)
}
@Composable
fun GlassCardCompact(
modifier: Modifier = Modifier,
cornerRadius: Dp = 12.dp,
backgroundColor: Color = MaterialTheme.comfyColors.cardBackground,
onClick: (() -> Unit)? = null,
content: @Composable BoxScope.() -> Unit
) {
val shape = RoundedCornerShape(cornerRadius)
Box(
modifier = modifier
.shadow(
elevation = 2.dp,
shape = shape
)
.clip(shape)
.background(backgroundColor)
.border(
width = 1.dp,
color = MaterialTheme.comfyColors.cardBorder,
shape = shape
)
.then(
if (onClick != null) {
Modifier.clickable(onClick = onClick)
} else Modifier
)
.padding(ComfySpacing.md),
content = content
)
}
@@ -0,0 +1,257 @@
package moe.uni.comfy_kmp.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.unit.dp
import moe.uni.comfy_kmp.ui.theme.ComfySpacing
import moe.uni.comfy_kmp.ui.theme.comfyColors
data class GalleryImage(
val filename: String,
val bitmap: ImageBitmap? = null,
val isLoading: Boolean = true
)
@Composable
fun ImageGallery(
images: List<GalleryImage>,
modifier: Modifier = Modifier,
onImageClick: ((Int) -> Unit)? = null,
onSaveClick: ((Int) -> Unit)? = null,
onSetCoverClick: ((Int) -> Unit)? = null
) {
Box(modifier = modifier.fillMaxSize()) {
if (images.isEmpty()) {
EmptyGalleryState(modifier = Modifier.align(Alignment.Center))
} else {
LazyColumn(
contentPadding = PaddingValues(ComfySpacing.lg),
verticalArrangement = Arrangement.spacedBy(ComfySpacing.md),
modifier = Modifier.fillMaxSize()
) {
itemsIndexed(images) { index, image ->
GalleryImageItem(
image = image,
onClick = {
onImageClick?.invoke(index)
},
onSave = onSaveClick?.let { { it(index) } },
onSetCover = onSetCoverClick?.let { { it(index) } }
)
}
}
}
}
}
@Composable
private fun GalleryImageItem(
image: GalleryImage,
onClick: () -> Unit,
onSave: (() -> Unit)?,
onSetCover: (() -> Unit)?
) {
val shape = RoundedCornerShape(12.dp)
val aspectRatio = image.bitmap?.let { it.width.toFloat() / it.height.toFloat() } ?: 1f
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.shadow(4.dp, shape)
.clip(shape)
.background(MaterialTheme.comfyColors.cardBackground)
.clickable(onClick = onClick)
) {
when {
image.bitmap != null -> {
Image(
bitmap = image.bitmap,
contentDescription = image.filename,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.then(
if (aspectRatio.isFinite() && aspectRatio > 0f) {
Modifier.aspectRatio(aspectRatio)
} else Modifier
)
)
// Filename badge at bottom
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.5f))
.padding(ComfySpacing.sm)
) {
Text(
text = image.filename,
style = MaterialTheme.typography.labelSmall,
color = Color.White,
maxLines = 1
)
}
}
image.isLoading -> {
ShimmerPlaceholder(modifier = Modifier.fillMaxSize())
}
else -> {
WaitingPlaceholder(modifier = Modifier.fillMaxSize())
}
}
}
// Action buttons
if (image.bitmap != null && onSetCover != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
FilledTonalButton(
onClick = onSetCover,
contentPadding = PaddingValues(horizontal = ComfySpacing.md, vertical = ComfySpacing.sm)
) {
Icon(
imageVector = Icons.Default.PhotoLibrary,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(ComfySpacing.xs))
Text("设为封面")
}
}
}
}
}
@Composable
private fun EmptyGalleryState(modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(ComfySpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)
) {
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Image,
contentDescription = null,
modifier = Modifier.size(36.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = "暂无生成结果",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "点击「开始执行」运行工作流",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun ShimmerPlaceholder(modifier: Modifier = Modifier) {
val comfyColors = MaterialTheme.comfyColors
Box(
modifier = modifier.background(comfyColors.shimmerBase),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "加载中...",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun WaitingPlaceholder(modifier: Modifier = Modifier) {
val comfyColors = MaterialTheme.comfyColors
Box(
modifier = modifier.background(comfyColors.shimmerBase),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.HourglassEmpty,
contentDescription = "等待中",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun ErrorPlaceholder(modifier: Modifier = Modifier) {
Box(
modifier = modifier.background(MaterialTheme.colorScheme.errorContainer),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.ErrorOutline,
contentDescription = "加载失败",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
@@ -0,0 +1,793 @@
package moe.uni.comfy_kmp.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
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.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.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.Refresh
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
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.longOrNull
import moe.uni.comfy_kmp.data.NodeStatus
import moe.uni.comfy_kmp.ui.theme.ComfySpacing
import moe.uni.comfy_kmp.ui.theme.comfyColors
import kotlin.math.roundToInt
@Composable
fun NodeCard(
nodeId: String,
classType: String,
status: NodeStatus,
inputs: Map<String, JsonElement> = emptyMap(),
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null
) {
val comfyColors = MaterialTheme.comfyColors
val statusColor by animateColorAsState(
targetValue = when (status) {
NodeStatus.RUNNING -> comfyColors.nodeRunning
NodeStatus.COMPLETED -> comfyColors.nodeCompleted
NodeStatus.PENDING -> comfyColors.nodePending
NodeStatus.ERROR -> MaterialTheme.colorScheme.error
},
animationSpec = tween(300)
)
val cardAlpha by animateFloatAsState(
targetValue = if (status == NodeStatus.PENDING) 0.7f else 1f,
animationSpec = tween(200)
)
val shape = RoundedCornerShape(12.dp)
Box(
modifier = modifier
.fillMaxWidth()
.alpha(cardAlpha)
.shadow(
elevation = if (status == NodeStatus.RUNNING) 6.dp else 2.dp,
shape = shape,
ambientColor = statusColor.copy(alpha = 0.2f),
spotColor = statusColor.copy(alpha = 0.25f)
)
.clip(shape)
.background(comfyColors.cardBackground)
.border(
width = if (status == NodeStatus.RUNNING) 2.dp else 1.dp,
color = if (status == NodeStatus.RUNNING) statusColor else comfyColors.cardBorder,
shape = shape
)
.then(
if (onClick != null) Modifier.clickable(onClick = onClick)
else Modifier
)
.padding(ComfySpacing.md)
) {
Column(
verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
) {
// Header row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
// Status indicator
StatusDot(status = status, color = statusColor)
// Class type
Text(
text = classType,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
// Node ID badge
Box(
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = "#$nodeId",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Key inputs preview
val previewInputs = inputs.entries
.filter { (_, value) -> value is JsonPrimitive }
.take(3)
if (previewInputs.isNotEmpty()) {
Column(
verticalArrangement = Arrangement.spacedBy(ComfySpacing.xs)
) {
previewInputs.forEach { (key, value) ->
InputPreviewRow(key = key, value = value)
}
}
}
}
}
}
@Composable
private fun StatusDot(
status: NodeStatus,
color: Color
) {
val dotSize = when (status) {
NodeStatus.RUNNING -> 10.dp
else -> 8.dp
}
Box(
modifier = Modifier
.size(dotSize)
.clip(CircleShape)
.background(color)
.then(
if (status == NodeStatus.RUNNING) {
Modifier.border(2.dp, color.copy(alpha = 0.3f), CircleShape)
} else Modifier
)
)
}
@Composable
private fun InputPreviewRow(
key: String,
value: JsonElement
) {
Row(
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.sm),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "$key:",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = formatValue(value),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
}
}
private fun formatValue(value: JsonElement): String {
return when (value) {
is JsonPrimitive -> {
val content = value.contentOrNull ?: value.toString()
if (content.length > 50) content.take(47) + "..." else content
}
else -> value.toString().take(50)
}
}
// Node type detection helpers
enum class EditableNodeType {
KSAMPLER,
CHECKPOINT_LOADER,
VAE_LOADER,
LORA_LOADER,
CLIP_TEXT_ENCODE,
OTHER
}
fun detectNodeType(classType: String): EditableNodeType {
val normalized = classType.lowercase()
return when {
normalized.contains("ksampler") -> EditableNodeType.KSAMPLER
normalized.contains("checkpointloader") ||
normalized.contains("checkpoint_loader") ||
normalized == "load checkpoint" -> EditableNodeType.CHECKPOINT_LOADER
normalized.contains("vaeloader") ||
normalized.contains("vae_loader") ||
normalized == "load vae" -> EditableNodeType.VAE_LOADER
normalized.contains("loraloader") ||
normalized.contains("lora_loader") ||
normalized == "load lora" -> EditableNodeType.LORA_LOADER
normalized.contains("cliptextencode") ||
normalized.contains("clip_text_encode") ||
normalized == "clip text encode" -> EditableNodeType.CLIP_TEXT_ENCODE
else -> EditableNodeType.OTHER
}
}
fun getModelFolder(nodeType: EditableNodeType): String? {
return when (nodeType) {
EditableNodeType.CHECKPOINT_LOADER -> "checkpoints"
EditableNodeType.VAE_LOADER -> "vae"
EditableNodeType.LORA_LOADER -> "loras"
else -> null
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditableNodeCard(
nodeId: String,
classType: String,
status: NodeStatus,
inputs: Map<String, JsonElement>,
isRandomSeedEnabled: Boolean = false,
modelOptions: List<String> = emptyList(),
isLoadingModels: Boolean = false,
modifier: Modifier = Modifier,
onInputChange: (field: String, value: JsonElement) -> Unit = { _, _ -> },
onRandomSeedToggle: (enabled: Boolean) -> Unit = {},
onRandomizeSeed: () -> Unit = {},
onLoadModels: () -> Unit = {}
) {
val comfyColors = MaterialTheme.comfyColors
val nodeType = remember(classType) { detectNodeType(classType) }
var isExpanded by remember { mutableStateOf(nodeType != EditableNodeType.OTHER) }
val statusColor by animateColorAsState(
targetValue = when (status) {
NodeStatus.RUNNING -> comfyColors.nodeRunning
NodeStatus.COMPLETED -> comfyColors.nodeCompleted
NodeStatus.PENDING -> comfyColors.nodePending
NodeStatus.ERROR -> MaterialTheme.colorScheme.error
},
animationSpec = tween(300)
)
val cardAlpha by animateFloatAsState(
targetValue = if (status == NodeStatus.PENDING) 0.85f else 1f,
animationSpec = tween(200)
)
val shape = RoundedCornerShape(16.dp)
Box(
modifier = modifier
.fillMaxWidth()
.alpha(cardAlpha)
.shadow(
elevation = if (status == NodeStatus.RUNNING) 8.dp else 3.dp,
shape = shape,
ambientColor = statusColor.copy(alpha = 0.15f),
spotColor = statusColor.copy(alpha = 0.2f)
)
.clip(shape)
.background(comfyColors.cardBackground)
.border(
width = if (status == NodeStatus.RUNNING) 2.dp else 1.dp,
color = if (status == NodeStatus.RUNNING) statusColor else comfyColors.cardBorder,
shape = shape
)
.clickable { isExpanded = !isExpanded }
.padding(ComfySpacing.lg)
) {
Column(
verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)
) {
// Header row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
StatusDot(status = status, color = statusColor)
Column {
Text(
text = classType,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
if (nodeType != EditableNodeType.OTHER) {
Text(
text = when (nodeType) {
EditableNodeType.KSAMPLER -> "采样器"
EditableNodeType.CHECKPOINT_LOADER -> "模型"
EditableNodeType.VAE_LOADER -> "VAE"
EditableNodeType.LORA_LOADER -> "LoRA"
EditableNodeType.CLIP_TEXT_ENCODE -> "提示词"
else -> ""
},
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = "#$nodeId",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Icon(
imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (isExpanded) "收起" else "展开",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Editable content
AnimatedVisibility(
visible = isExpanded,
enter = expandVertically(tween(200)),
exit = shrinkVertically(tween(200))
) {
Column(
verticalArrangement = Arrangement.spacedBy(ComfySpacing.md),
modifier = Modifier.padding(top = ComfySpacing.sm)
) {
when (nodeType) {
EditableNodeType.KSAMPLER -> {
KSamplerEditor(
inputs = inputs,
isRandomSeedEnabled = isRandomSeedEnabled,
onInputChange = onInputChange,
onRandomSeedToggle = onRandomSeedToggle,
onRandomizeSeed = onRandomizeSeed
)
}
EditableNodeType.CHECKPOINT_LOADER,
EditableNodeType.VAE_LOADER,
EditableNodeType.LORA_LOADER -> {
ModelSelector(
nodeType = nodeType,
inputs = inputs,
modelOptions = modelOptions,
isLoading = isLoadingModels,
onInputChange = onInputChange,
onLoadModels = onLoadModels
)
}
EditableNodeType.CLIP_TEXT_ENCODE -> {
PromptEditor(
inputs = inputs,
onInputChange = onInputChange
)
}
EditableNodeType.OTHER -> {
// Show read-only inputs for other node types
val previewInputs = inputs.entries
.filter { (_, value) -> value is JsonPrimitive }
.take(5)
previewInputs.forEach { (key, value) ->
InputPreviewRow(key = key, value = value)
}
}
}
}
}
// Collapsed preview for non-expanded state
if (!isExpanded && nodeType == EditableNodeType.OTHER) {
val previewInputs = inputs.entries
.filter { (_, value) -> value is JsonPrimitive }
.take(2)
if (previewInputs.isNotEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.xs)) {
previewInputs.forEach { (key, value) ->
InputPreviewRow(key = key, value = value)
}
}
}
}
}
}
}
@Composable
private fun KSamplerEditor(
inputs: Map<String, JsonElement>,
isRandomSeedEnabled: Boolean,
onInputChange: (String, JsonElement) -> Unit,
onRandomSeedToggle: (Boolean) -> Unit,
onRandomizeSeed: () -> Unit
) {
val seedValue = inputs["seed"]?.let {
(it as? JsonPrimitive)?.longOrNull?.toString()
} ?: ""
val stepsValue = inputs["steps"]?.let {
(it as? JsonPrimitive)?.contentOrNull
} ?: ""
val cfgValue = inputs["cfg"]?.let {
(it as? JsonPrimitive)?.contentOrNull
} ?: ""
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)) {
// Seed with random mode
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Seed",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "随机",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Switch(
checked = isRandomSeedEnabled,
onCheckedChange = onRandomSeedToggle
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = seedValue,
onValueChange = { newValue ->
newValue.toLongOrNull()?.let {
onInputChange("seed", JsonPrimitive(it))
}
},
modifier = Modifier.weight(1f),
enabled = !isRandomSeedEnabled,
singleLine = true,
shape = RoundedCornerShape(12.dp),
placeholder = { Text("输入 seed") }
)
FilledTonalIconButton(
onClick = onRandomizeSeed
) {
Icon(
imageVector = Icons.Default.Casino,
contentDescription = "随机生成",
modifier = Modifier.size(20.dp)
)
}
}
}
// Steps
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.md)
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(ComfySpacing.xs)
) {
Text(
text = "Steps",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
OutlinedTextField(
value = stepsValue,
onValueChange = { newValue ->
newValue.toIntOrNull()?.let {
onInputChange("steps", JsonPrimitive(it))
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(ComfySpacing.xs)
) {
Text(
text = "CFG",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
OutlinedTextField(
value = cfgValue,
onValueChange = { newValue ->
newValue.toDoubleOrNull()?.let {
onInputChange("cfg", JsonPrimitive(it))
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ModelSelector(
nodeType: EditableNodeType,
inputs: Map<String, JsonElement>,
modelOptions: List<String>,
isLoading: Boolean,
onInputChange: (String, JsonElement) -> Unit,
onLoadModels: () -> Unit
) {
val fieldName = when (nodeType) {
EditableNodeType.CHECKPOINT_LOADER -> "ckpt_name"
EditableNodeType.VAE_LOADER -> "vae_name"
EditableNodeType.LORA_LOADER -> "lora_name"
else -> return
}
val currentValue = inputs[fieldName]?.let {
(it as? JsonPrimitive)?.contentOrNull
} ?: ""
var expanded by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)) {
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)) {
Text(
text = when (nodeType) {
EditableNodeType.CHECKPOINT_LOADER -> "模型"
EditableNodeType.VAE_LOADER -> "VAE"
EditableNodeType.LORA_LOADER -> "LoRA"
else -> "选择"
},
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (modelOptions.isEmpty() && !isLoading) {
Row(
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = currentValue.ifEmpty { "未选择" },
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
FilledTonalButton(
onClick = onLoadModels,
contentPadding = PaddingValues(horizontal = ComfySpacing.md)
) {
Text("加载列表")
}
}
} else if (isLoading) {
Text(
text = "加载中...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it }
) {
OutlinedTextField(
value = currentValue,
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
shape = RoundedCornerShape(12.dp),
singleLine = true
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
modelOptions.forEach { option ->
DropdownMenuItem(
text = {
Text(
option,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
onClick = {
onInputChange(fieldName, JsonPrimitive(option))
expanded = false
}
)
}
}
}
}
}
if (nodeType == EditableNodeType.LORA_LOADER) {
val strengthModel = inputs["strength_model"]?.let {
(it as? JsonPrimitive)?.contentOrNull?.toFloatOrNull()
} ?: 1.0f
val strengthClip = inputs["strength_clip"]?.let {
(it as? JsonPrimitive)?.contentOrNull?.toFloatOrNull()
} ?: 1.0f
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Model 强度",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = formatTwoDecimals(strengthModel),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
}
Slider(
value = strengthModel,
onValueChange = { newValue ->
onInputChange("strength_model", JsonPrimitive(newValue.toDouble()))
},
valueRange = 0f..2f,
modifier = Modifier.fillMaxWidth()
)
}
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "CLIP 强度",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = formatTwoDecimals(strengthClip),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
}
Slider(
value = strengthClip,
onValueChange = { newValue ->
onInputChange("strength_clip", JsonPrimitive(newValue.toDouble()))
},
valueRange = 0f..2f,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
private fun formatTwoDecimals(value: Float): String {
val scaled = (value * 100).roundToInt()
val whole = scaled / 100
val fraction = kotlin.math.abs(scaled % 100)
return "$whole.${fraction.toString().padStart(2, '0')}"
}
@Composable
private fun PromptEditor(
inputs: Map<String, JsonElement>,
onInputChange: (String, JsonElement) -> Unit
) {
val textValue = inputs["text"]?.let {
(it as? JsonPrimitive)?.contentOrNull
} ?: ""
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)) {
Text(
text = "提示词",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
OutlinedTextField(
value = textValue,
onValueChange = { newValue ->
onInputChange("text", JsonPrimitive(newValue))
},
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
shape = RoundedCornerShape(12.dp),
placeholder = { Text("输入提示词...") }
)
}
}
@@ -0,0 +1,203 @@
package moe.uni.comfy_kmp.ui.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
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.layout.width
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
import androidx.compose.runtime.getValue
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.comfy_kmp.ui.theme.ComfySpacing
import moe.uni.comfy_kmp.ui.theme.comfyColors
enum class ExecutionStatus {
IDLE,
CONNECTING,
RUNNING,
COMPLETED,
ERROR
}
@Composable
fun ExecutionStatusBar(
status: ExecutionStatus,
statusText: String,
progress: Float? = null,
progressText: String? = null,
modifier: Modifier = Modifier
) {
val comfyColors = MaterialTheme.comfyColors
val statusColor by animateColorAsState(
targetValue = when (status) {
ExecutionStatus.IDLE -> comfyColors.nodePending
ExecutionStatus.CONNECTING -> MaterialTheme.colorScheme.primary
ExecutionStatus.RUNNING -> comfyColors.nodeRunning
ExecutionStatus.COMPLETED -> comfyColors.nodeCompleted
ExecutionStatus.ERROR -> MaterialTheme.colorScheme.error
},
animationSpec = tween(300)
)
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = ComfySpacing.lg, vertical = ComfySpacing.md),
verticalArrangement = 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
)
}
}
// 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
)
}
}
}
}
@Composable
private fun StatusIndicator(
status: ExecutionStatus,
color: androidx.compose.ui.graphics.Color
) {
val infiniteTransition = rememberInfiniteTransition()
val pulseAlpha by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 0.4f,
animationSpec = infiniteRepeatable(
animation = tween(800, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
)
)
Box(
modifier = Modifier
.size(10.dp)
.alpha(if (status == ExecutionStatus.RUNNING) pulseAlpha else 1f)
.clip(CircleShape)
.background(color)
)
}
@Composable
fun CompactStatusChip(
status: ExecutionStatus,
text: String,
modifier: Modifier = Modifier
) {
val comfyColors = MaterialTheme.comfyColors
val backgroundColor by animateColorAsState(
targetValue = when (status) {
ExecutionStatus.IDLE -> MaterialTheme.colorScheme.surfaceVariant
ExecutionStatus.CONNECTING -> MaterialTheme.colorScheme.primaryContainer
ExecutionStatus.RUNNING -> comfyColors.warningContainer
ExecutionStatus.COMPLETED -> comfyColors.successContainer
ExecutionStatus.ERROR -> MaterialTheme.colorScheme.errorContainer
},
animationSpec = tween(200)
)
val textColor by animateColorAsState(
targetValue = when (status) {
ExecutionStatus.IDLE -> MaterialTheme.colorScheme.onSurfaceVariant
ExecutionStatus.CONNECTING -> MaterialTheme.colorScheme.onPrimaryContainer
ExecutionStatus.RUNNING -> comfyColors.onWarning
ExecutionStatus.COMPLETED -> comfyColors.onSuccess
ExecutionStatus.ERROR -> MaterialTheme.colorScheme.onErrorContainer
},
animationSpec = tween(200)
)
Box(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(backgroundColor)
.padding(horizontal = ComfySpacing.md, vertical = ComfySpacing.sm)
) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
color = textColor
)
}
}
@@ -0,0 +1,122 @@
package moe.uni.comfy_kmp.ui.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import moe.uni.comfy_kmp.SystemBarsEffect
import moe.uni.comfy_kmp.ui.theme.ComfySpacing
import org.jetbrains.compose.resources.decodeToImageBitmap
data class ImagePreviewScreen(
val filename: String,
val bytes: ByteArray,
val onSave: (() -> Unit)? = null
) : Screen {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val bitmap = remember(bytes) { bytes.decodeToImageBitmap() }
SystemBarsEffect(hidden = true)
var scale by remember { mutableStateOf(1f) }
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.pointerInput(Unit) {
detectTapGestures(onTap = { navigator.pop() })
}
) {
Image(
bitmap = bitmap,
contentDescription = filename,
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offsetX,
translationY = offsetY
)
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
scale = (scale * zoom).coerceIn(0.5f, 5f)
offsetX += pan.x
offsetY += pan.y
}
}
)
FilledTonalIconButton(
onClick = { navigator.pop() },
modifier = Modifier
.align(Alignment.TopStart)
.padding(ComfySpacing.lg)
.size(48.dp),
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = Color.Black.copy(alpha = 0.5f),
contentColor = Color.White
)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "返回"
)
}
if (onSave != null) {
FilledTonalIconButton(
onClick = onSave,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(ComfySpacing.lg)
.size(48.dp),
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
) {
Icon(
imageVector = Icons.Default.Save,
contentDescription = "保存"
)
}
}
}
}
}
@@ -0,0 +1,513 @@
@file:OptIn(ExperimentalMaterial3Api::class)
package moe.uni.comfy_kmp.ui.screens
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
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.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.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.RadioButtonChecked
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.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.graphics.Brush
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import moe.uni.comfy_kmp.data.ServerConfig
import moe.uni.comfy_kmp.di.LocalAppContainer
import moe.uni.comfy_kmp.storage.generateId
import moe.uni.comfy_kmp.ui.components.ComfyDialog
import moe.uni.comfy_kmp.ui.components.GlassCard
import moe.uni.comfy_kmp.ui.theme.ComfySpacing
import moe.uni.comfy_kmp.ui.theme.comfyColors
class ServerScreen : Screen {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val container = LocalAppContainer.current
val model = rememberScreenModel { ServerScreenModel(container.serverRepository) }
var showDialog by remember { mutableStateOf(false) }
var editingServer by remember { mutableStateOf<ServerConfig?>(null) }
if (showDialog) {
ServerEditDialog(
initial = editingServer,
onDismiss = { showDialog = false },
onSave = { server ->
model.upsert(server)
showDialog = false
editingServer = null
}
)
}
val comfyColors = MaterialTheme.comfyColors
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(
"Comfy KMP",
style = MaterialTheme.typography.headlineSmall
)
Text(
text = "ComfyUI 移动客户端",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
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
}
)
},
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) }
)
}
}
}
}
}
}
}
}
}
private class ServerScreenModel(
private val repository: moe.uni.comfy_kmp.storage.ServerRepository
) : ScreenModel {
var servers by mutableStateOf(repository.getServers())
private set
var activeServerId by mutableStateOf(repository.getActiveServer()?.id)
private set
fun upsert(server: ServerConfig) {
repository.upsertServer(server)
servers = repository.getServers()
if (server.isDefault) {
repository.setActiveServer(server.id)
activeServerId = server.id
}
}
fun delete(serverId: String) {
repository.deleteServer(serverId)
servers = repository.getServers()
activeServerId = repository.getActiveServer()?.id
}
fun setActive(serverId: String) {
repository.setActiveServer(serverId)
activeServerId = serverId
}
}
@Composable
private fun ServerEditDialog(
initial: ServerConfig?,
onDismiss: () -> Unit,
onSave: (ServerConfig) -> Unit
) {
var name by remember { mutableStateOf(initial?.name ?: "") }
var baseUrl by remember { mutableStateOf(initial?.baseUrl ?: "") }
var isDefault by remember { mutableStateOf(initial?.isDefault ?: false) }
ComfyDialog(
onDismissRequest = onDismiss,
title = if (initial == null) "添加服务器" else "编辑服务器",
confirmText = "保存",
dismissText = "取消",
confirmEnabled = baseUrl.isNotBlank(),
onConfirm = {
val id = initial?.id ?: generateId("server")
onSave(
ServerConfig(
id = id,
name = name.ifBlank { "未命名服务器" },
baseUrl = baseUrl.trim(),
isDefault = isDefault
)
)
}
) {
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.lg)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("名称") },
placeholder = { Text("我的服务器") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
OutlinedTextField(
value = baseUrl,
onValueChange = { baseUrl = it },
label = { Text("服务器地址") },
placeholder = { Text("http://192.168.1.100:8188") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
) {
Checkbox(
checked = isDefault,
onCheckedChange = { isDefault = it }
)
Text(
"设为默认服务器",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
@Composable
private fun ServerCard(
server: ServerConfig,
isActive: Boolean,
onSelect: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit
) {
GlassCard(
modifier = Modifier.fillMaxWidth(),
isSelected = isActive,
onClick = onSelect
) {
Column(
verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.md),
verticalAlignment = Alignment.CenterVertically
) {
// Status indicator
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(
if (isActive) MaterialTheme.comfyColors.success
else MaterialTheme.colorScheme.outline
)
)
Column {
Text(
text = server.name,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = server.baseUrl,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
if (server.isDefault) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(horizontal = ComfySpacing.sm, vertical = ComfySpacing.xs)
) {
Text(
"默认",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
// Actions
Row(
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
) {
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("删除")
}
}
}
}
}
@Composable
private fun EmptyServerState(
onAdd: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Dns,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Spacer(modifier = Modifier.height(ComfySpacing.xl))
Text(
text = "欢迎使用 Comfy KMP",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(ComfySpacing.sm))
Text(
text = "添加一个 ComfyUI 服务器开始使用",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(ComfySpacing.xl))
Button(
onClick = onAdd,
contentPadding = PaddingValues(
horizontal = ComfySpacing.xl,
vertical = ComfySpacing.md
)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(ComfySpacing.sm))
Text("添加服务器")
}
}
}
@@ -0,0 +1,489 @@
package moe.uni.comfy_kmp.ui.screens
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.Image
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.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.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.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.automirrored.filled.Assignment
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.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.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import moe.uni.comfy_kmp.data.WorkflowEntity
import moe.uni.comfy_kmp.data.loadCoverImageBytes
import moe.uni.comfy_kmp.di.LocalAppContainer
import moe.uni.comfy_kmp.isFilePickerSupported
import moe.uni.comfy_kmp.rememberFilePickerLauncher
import moe.uni.comfy_kmp.storage.generateId
import moe.uni.comfy_kmp.ui.components.ComfyDialog
import moe.uni.comfy_kmp.ui.theme.ComfySpacing
import moe.uni.comfy_kmp.ui.theme.comfyColors
import org.jetbrains.compose.resources.decodeToImageBitmap
import kotlin.time.Clock
data class WorkflowListScreen(val serverId: String) : Screen {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val container = LocalAppContainer.current
val model = rememberScreenModel { WorkflowListScreenModel(serverId, container.workflowRepository) }
var showImport by remember { mutableStateOf(false) }
if (showImport) {
WorkflowImportDialog(
onDismiss = { showImport = false },
onSave = { name, json ->
model.addWorkflow(name, json)
showImport = false
}
)
}
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
)
)
},
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) }
)
}
}
}
}
}
}
}
}
private class WorkflowListScreenModel(
private val serverId: String,
private val repository: moe.uni.comfy_kmp.storage.WorkflowRepository
) : ScreenModel {
var workflows by mutableStateOf(repository.getWorkflows(serverId))
private set
fun addWorkflow(name: String, json: String) {
val workflow = WorkflowEntity(
id = generateId("workflow"),
name = name.ifBlank { "未命名工作流" },
json = json,
serverId = serverId,
updatedAt = Clock.System.now().epochSeconds
)
repository.upsertWorkflow(workflow)
workflows = repository.getWorkflows(serverId)
}
fun deleteWorkflow(id: String) {
repository.deleteWorkflow(id)
workflows = repository.getWorkflows(serverId)
}
}
@Composable
private fun WorkflowImportDialog(
onDismiss: () -> Unit,
onSave: (String, String) -> Unit
) {
var name by remember { mutableStateOf("") }
var json by remember { mutableStateOf("") }
// File picker for Android
val filePickerSupported = isFilePickerSupported()
val launchFilePicker = rememberFilePickerLauncher { content ->
if (content != null) {
json = content
if (name.isBlank()) {
name = "导入的工作流"
}
}
}
ComfyDialog(
onDismissRequest = onDismiss,
title = "导入工作流",
confirmText = "导入",
dismissText = "取消",
confirmEnabled = json.isNotBlank(),
onConfirm = { onSave(name, json) }
) {
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.lg)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("名称") },
placeholder = { Text("输入工作流名称") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"JSON 内容",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (filePickerSupported) {
OutlinedButton(
onClick = { launchFilePicker() },
contentPadding = PaddingValues(
horizontal = ComfySpacing.md,
vertical = ComfySpacing.sm
)
) {
Icon(
imageVector = Icons.Default.FolderOpen,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(ComfySpacing.xs))
Text("选择文件")
}
}
}
OutlinedTextField(
value = json,
onValueChange = { json = it },
placeholder = { Text("粘贴 JSON 或选择本地文件...") },
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
minLines = 6,
shape = RoundedCornerShape(12.dp)
)
}
}
}
}
@Composable
private fun WorkflowCard(
workflow: WorkflowEntity,
onRun: () -> Unit,
onDelete: () -> Unit
) {
val shape = RoundedCornerShape(16.dp)
val comfyColors = MaterialTheme.comfyColors
// Decode cover image if available
val coverBitmap = remember(workflow.coverImage) {
workflow.coverImage?.let { cover ->
try {
val bytes = loadCoverImageBytes(cover)
bytes?.decodeToImageBitmap()
} catch (e: Exception) {
null
}
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(0.85f)
.shadow(4.dp, shape)
.clip(shape)
.background(comfyColors.cardBackground)
.clickable(onClick = onRun)
) {
Column(modifier = Modifier.fillMaxSize()) {
// Cover image area (3/4)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(4f)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
if (coverBitmap != null) {
Image(
bitmap = coverBitmap,
contentDescription = workflow.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
} else {
// Placeholder
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
) {
Icon(
imageVector = Icons.Default.Image,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Text(
text = "暂无封面",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
}
}
}
}
// Info and actions area (1/4)
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(comfyColors.cardBackground)
.padding(ComfySpacing.md),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally
) {
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Title and timestamp
Column {
Text(
text = workflow.name,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = formatTimestamp(workflow.updatedAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
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)
)
}
}
}
}
}
}
@Composable
private fun EmptyWorkflowState(
onImport: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.padding(ComfySpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(ComfySpacing.lg)
) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Assignment,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
Text(
text = "暂无工作流",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "导入 ComfyUI 工作流 JSON 开始使用",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(ComfySpacing.md))
Button(onClick = onImport) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(ComfySpacing.sm))
Text("导入工作流")
}
}
}
private fun formatTimestamp(timestamp: Long): String {
val now = Clock.System.now().epochSeconds
val diff = now - timestamp
return when {
diff < 60 -> "刚刚"
diff < 3600 -> "${diff / 60} 分钟前"
diff < 86400 -> "${diff / 3600} 小时前"
diff < 604800 -> "${diff / 86400} 天前"
else -> "${diff / 604800} 周前"
}
}
@@ -0,0 +1,787 @@
package moe.uni.comfy_kmp.ui.screens
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
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.PaddingValues
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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SheetValue
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.graphics.Brush
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import moe.uni.comfy_kmp.data.ComfyJson
import moe.uni.comfy_kmp.data.ImageRef
import moe.uni.comfy_kmp.data.NodeExecutionState
import moe.uni.comfy_kmp.data.NodeStatus
import moe.uni.comfy_kmp.data.PromptRequest
import moe.uni.comfy_kmp.data.extractImagesFromHistory
import moe.uni.comfy_kmp.data.extractPromptObject
import moe.uni.comfy_kmp.data.parsePromptNodes
import moe.uni.comfy_kmp.data.saveCoverImage
import moe.uni.comfy_kmp.data.saveToTemp
import moe.uni.comfy_kmp.data.updateNodeInput
import moe.uni.comfy_kmp.di.LocalAppContainer
import moe.uni.comfy_kmp.network.ComfyApiClient
import moe.uni.comfy_kmp.network.ComfyWebSocketClient
import moe.uni.comfy_kmp.storage.ServerRepository
import moe.uni.comfy_kmp.storage.WorkflowRepository
import moe.uni.comfy_kmp.storage.generateId
import moe.uni.comfy_kmp.ui.components.EditableNodeCard
import moe.uni.comfy_kmp.ui.components.ExecutionStatus
import moe.uni.comfy_kmp.ui.components.ExecutionStatusBar
import moe.uni.comfy_kmp.ui.components.GalleryImage
import moe.uni.comfy_kmp.ui.components.ImageGallery
import moe.uni.comfy_kmp.ui.components.detectNodeType
import moe.uni.comfy_kmp.ui.components.getModelFolder
import moe.uni.comfy_kmp.ui.theme.ComfySpacing
import moe.uni.comfy_kmp.ui.theme.comfyColors
import org.jetbrains.compose.resources.decodeToImageBitmap
import kotlin.random.Random
import kotlin.time.Clock
data class WorkflowRunScreen(val workflowId: String) : Screen {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val container = LocalAppContainer.current
val model = rememberScreenModel {
WorkflowRunScreenModel(
workflowId = workflowId,
workflowRepository = container.workflowRepository,
apiClient = container.apiClient,
wsClient = container.wsClient,
serverRepository = container.serverRepository
)
}
LaunchedEffect(Unit) {
model.connect()
}
val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(
initialValue = SheetValue.PartiallyExpanded,
skipHiddenState = false
)
)
val comfyColors = MaterialTheme.comfyColors
BottomSheetScaffold(
scaffoldState = scaffoldState,
sheetPeekHeight = 120.dp,
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()
)
},
topBar = {
WorkflowRunTopBar(
onBack = { navigator.pop() },
status = model.executionStatus,
statusText = model.statusText,
progress = model.progress,
progressText = model.progressText,
isRunning = model.running,
onStart = { model.runWorkflow() },
onInterrupt = { model.interrupt() }
)
},
containerColor = MaterialTheme.colorScheme.background
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.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) }
)
)
}
},
onSaveClick = { index -> model.saveImage(index) },
onSetCoverClick = { index -> model.setCoverImage(index) },
modifier = Modifier.fillMaxSize()
)
}
}
}
}
private data class PreviewImage(
val filename: String,
val bytes: ByteArray
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WorkflowRunTopBar(
onBack: () -> Unit,
status: ExecutionStatus,
statusText: String,
progress: Float?,
progressText: String?,
isRunning: Boolean,
onStart: () -> Unit,
onInterrupt: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
) {
TopAppBar(
title = { Text("工作流执行") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "返回"
)
}
},
actions = {
AnimatedContent(
targetState = isRunning,
transitionSpec = { fadeIn() togetherWith fadeOut() }
) { running ->
if (running) {
OutlinedButton(
onClick = onInterrupt,
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
modifier = Modifier.padding(end = ComfySpacing.sm)
) {
Icon(
imageVector = Icons.Default.Stop,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(ComfySpacing.xs))
Text("中断")
}
} else {
FilledTonalButton(
onClick = onStart,
modifier = Modifier.padding(end = ComfySpacing.sm)
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(ComfySpacing.xs))
Text("开始执行")
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
ExecutionStatusBar(
status = status,
statusText = statusText,
progress = progress,
progressText = progressText
)
}
}
@Composable
private fun NodeListSheet(
nodes: List<NodeExecutionState>,
randomSeedNodes: Set<String>,
modelChoices: Map<String, List<String>>,
loadingModels: Set<String>,
onInputChange: (nodeId: String, field: String, value: JsonElement) -> Unit,
onRandomSeedToggle: (nodeId: String, enabled: Boolean) -> Unit,
onRandomizeSeed: (nodeId: String) -> Unit,
onLoadModels: (nodeId: String) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(
start = ComfySpacing.lg,
end = ComfySpacing.lg,
bottom = ComfySpacing.xl
),
verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)
) {
items(nodes, key = { it.nodeId }) { node ->
EditableNodeCard(
nodeId = node.nodeId,
classType = node.classType,
status = node.status,
inputs = node.inputs,
isRandomSeedEnabled = randomSeedNodes.contains(node.nodeId),
modelOptions = modelChoices[node.nodeId] ?: emptyList(),
isLoadingModels = loadingModels.contains(node.nodeId),
onInputChange = { field, value -> onInputChange(node.nodeId, field, value) },
onRandomSeedToggle = { enabled -> onRandomSeedToggle(node.nodeId, enabled) },
onRandomizeSeed = { onRandomizeSeed(node.nodeId) },
onLoadModels = { onLoadModels(node.nodeId) }
)
}
}
}
private class WorkflowRunScreenModel(
private val workflowId: String,
private val workflowRepository: WorkflowRepository,
private val apiClient: ComfyApiClient,
private val wsClient: ComfyWebSocketClient,
private val serverRepository: ServerRepository
) : ScreenModel {
private var workflow = workflowRepository.getWorkflow(workflowId)
private val server = workflow?.let { serverRepository.getServers().firstOrNull { s -> s.id == it.serverId } }
private val baseUrl: String = server?.baseUrl.orEmpty()
private val clientId = generateId("client")
// Editable prompt object
private var promptObject by mutableStateOf(
workflow?.json?.let { extractPromptObject(it) } ?: JsonObject(emptyMap())
)
var executionStatus by mutableStateOf(ExecutionStatus.IDLE)
private set
var statusText by mutableStateOf("准备就绪")
private set
var progress by mutableStateOf<Float?>(null)
private set
var progressText by mutableStateOf<String?>(null)
private set
var running by mutableStateOf(false)
private set
var nodeStates by mutableStateOf(buildNodeStates())
private set
private val initialNodeOrder = nodeStates.map { it.nodeId }
var executionOrder by mutableStateOf<List<String>>(emptyList())
private set
// Editing state
var randomSeedNodes by mutableStateOf<Set<String>>(emptySet())
private set
var modelChoices by mutableStateOf<Map<String, List<String>>>(emptyMap())
private set
var loadingModels by mutableStateOf<Set<String>>(emptySet())
private set
private var promptId by mutableStateOf<String?>(null)
private var imageRefs by mutableStateOf<List<ImageRef>>(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)
private var saveJob: Job? = null
val galleryImages: List<GalleryImage>
get() = imageRefs.map { ref ->
GalleryImage(
filename = ref.filename,
bitmap = imageBitmaps[ref.filename],
isLoading = isLoadingImages && !imageBitmaps.containsKey(ref.filename)
)
}
val orderedNodeStates: List<NodeExecutionState>
get() {
val orderIndex = executionOrder.withIndex().associate { it.value to it.index }
val fallbackIndex = initialNodeOrder.withIndex().associate { it.value to it.index }
return nodeStates.sortedWith(
compareBy<NodeExecutionState> { orderIndex[it.nodeId] ?: Int.MAX_VALUE }
.thenBy { fallbackIndex[it.nodeId] ?: Int.MAX_VALUE }
)
}
init {
preloadModelLists()
}
fun getPreviewImage(index: Int): PreviewImage? {
val ref = imageRefs.getOrNull(index) ?: return null
val bytes = imageBytes[ref.filename] ?: return null
return PreviewImage(ref.filename, bytes)
}
private fun buildNodeStates(): List<NodeExecutionState> {
val parsedNodes = parsePromptNodes(promptObject)
return parsedNodes.map { node ->
NodeExecutionState(
nodeId = node.id,
classType = node.classType,
status = NodeStatus.PENDING,
inputs = node.inputs.toMap()
)
}
}
fun updateNodeInput(nodeId: String, field: String, value: JsonElement) {
promptObject = updateNodeInput(promptObject, nodeId, field, value)
// Update node states
nodeStates = nodeStates.map { state ->
if (state.nodeId == nodeId) {
state.copy(inputs = state.inputs.toMutableMap().apply { put(field, value) })
} else state
}
scheduleSave()
}
fun setRandomSeedMode(nodeId: String, enabled: Boolean) {
randomSeedNodes = if (enabled) {
randomSeedNodes + nodeId
} else {
randomSeedNodes - nodeId
}
}
fun randomizeSeed(nodeId: String) {
val newSeed = Random.nextLong(0, Long.MAX_VALUE)
updateNodeInput(nodeId, "seed", JsonPrimitive(newSeed))
}
fun loadModelsForNode(nodeId: String) {
val node = nodeStates.find { it.nodeId == nodeId } ?: return
val nodeType = detectNodeType(node.classType)
val folder = getModelFolder(nodeType) ?: return
if (modelChoices.containsKey(nodeId) || loadingModels.contains(nodeId)) return
if (baseUrl.isBlank()) return
loadingModels = loadingModels + nodeId
screenModelScope.launch {
try {
val result = apiClient.getModels(baseUrl, folder)
val list = result as? JsonArray
val names = list?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
modelChoices = modelChoices + (nodeId to names)
} catch (e: Exception) {
statusText = "加载模型失败: ${e.message}"
} finally {
loadingModels = loadingModels - nodeId
}
}
}
private fun preloadModelLists() {
val modelNodeIds = nodeStates.filter { node ->
getModelFolder(detectNodeType(node.classType)) != null
}.map { it.nodeId }
modelNodeIds.forEach { loadModelsForNode(it) }
}
private fun scheduleSave() {
saveJob?.cancel()
saveJob = screenModelScope.launch {
delay(350)
saveWorkflow()
}
}
private suspend fun saveWorkflow() {
val current = workflow ?: return
val snapshot = promptObject
val newJson = withContext(Dispatchers.Default) {
ComfyJson.encodeToString(JsonObject.serializer(), snapshot)
}
val updated = current.copy(
json = newJson,
updatedAt = Clock.System.now().toEpochMilliseconds()
)
workflowRepository.upsertWorkflow(updated)
workflow = updated
}
fun connect() {
if (baseUrl.isBlank()) {
executionStatus = ExecutionStatus.ERROR
statusText = "未设置服务器"
return
}
executionStatus = ExecutionStatus.CONNECTING
statusText = "正在连接..."
screenModelScope.launch {
wsClient.disconnect()
wsClient.connect(baseUrl, clientId)
wsClient.events.collectLatest { msg ->
handleWebSocketMessage(msg.type, msg.data)
}
}
}
private fun handleWebSocketMessage(type: String, data: JsonObject?) {
when (type) {
"status" -> {
if (!running) {
executionStatus = ExecutionStatus.IDLE
statusText = "已连接"
}
}
"execution_start" -> {
running = true
executionStatus = ExecutionStatus.RUNNING
statusText = "开始执行"
executionOrder = emptyList()
resetNodeStatuses()
}
"execution_cached" -> {
val nodeIds = extractCachedNodes(data)
if (nodeIds.isNotEmpty()) {
val newIds = nodeIds.filterNot { executionOrder.contains(it) }
if (newIds.isNotEmpty()) {
executionOrder = executionOrder + newIds
}
}
nodeIds.forEach { nodeId ->
updateNodeStatus(nodeId, NodeStatus.COMPLETED)
}
}
"executing" -> {
val nodeId = data?.get("node")?.jsonPrimitive?.content
?.takeIf { it.isNotBlank() && it != "null" }
if (nodeId != null) {
if (!executionOrder.contains(nodeId)) {
executionOrder = executionOrder + nodeId
}
// Mark previous running nodes as completed
nodeStates = nodeStates.map { state ->
if (state.status == NodeStatus.RUNNING) {
state.copy(status = NodeStatus.COMPLETED)
} else state
}
updateNodeStatus(nodeId, NodeStatus.RUNNING)
statusText = "执行: ${getNodeClassType(nodeId)}"
} else {
// nodeId is null means execution finished
running = false
executionStatus = ExecutionStatus.COMPLETED
statusText = "执行完成"
markAllCompleted()
// Delay a bit to ensure history is available
screenModelScope.launch {
delay(500)
fetchResults()
}
}
}
"progress" -> {
val value = data?.get("value")?.jsonPrimitive?.content?.toFloatOrNull() ?: 0f
val max = data?.get("max")?.jsonPrimitive?.content?.toFloatOrNull() ?: 1f
progress = if (max > 0) value / max else null
progressText = "${value.toInt()} / ${max.toInt()}"
}
"executed" -> {
val nodeId = data?.get("node")?.jsonPrimitive?.content
if (nodeId != null) {
updateNodeStatus(nodeId, NodeStatus.COMPLETED)
}
// Try to fetch results on each executed event (for intermediate results)
screenModelScope.launch {
fetchResults()
}
}
"execution_error" -> {
running = false
executionStatus = ExecutionStatus.ERROR
val errorMsg = data?.get("exception_message")?.jsonPrimitive?.content
statusText = "执行出错: ${errorMsg ?: "未知错误"}"
val nodeId = data?.get("node_id")?.jsonPrimitive?.content
if (nodeId != null) {
updateNodeStatus(nodeId, NodeStatus.ERROR)
}
}
}
}
fun runWorkflow() {
// Check if prompt is empty
if (promptObject.isEmpty()) {
executionStatus = ExecutionStatus.ERROR
statusText = "工作流为空或格式错误"
return
}
// Generate random seeds for nodes with random mode enabled
randomSeedNodes.forEach { nodeId ->
randomizeSeed(nodeId)
}
resetNodeStatuses()
progress = null
progressText = null
screenModelScope.launch {
executionStatus = ExecutionStatus.RUNNING
statusText = "提交中..."
try {
val response = apiClient.postPrompt(baseUrl, PromptRequest(promptObject, clientId))
if (response.error != null) {
executionStatus = ExecutionStatus.ERROR
statusText = "错误: ${response.error}"
running = false
} else if (response.node_errors != null && response.node_errors.isNotEmpty()) {
executionStatus = ExecutionStatus.ERROR
val firstError = response.node_errors.entries.firstOrNull()
statusText = "节点错误: ${firstError?.key}"
running = false
} else {
promptId = response.prompt_id
running = true
statusText = "已提交: ${response.prompt_id?.take(8)}..."
}
} catch (e: Exception) {
executionStatus = ExecutionStatus.ERROR
statusText = "提交失败: ${e.message}"
running = false
}
}
}
fun interrupt() {
if (baseUrl.isBlank()) return
screenModelScope.launch {
try {
apiClient.interrupt(baseUrl)
running = false
executionStatus = ExecutionStatus.IDLE
statusText = "已中断"
} catch (e: Exception) {
statusText = "中断失败: ${e.message}"
}
}
}
private fun fetchResults() {
val id = promptId ?: return
screenModelScope.launch {
try {
val history = apiClient.getHistory(baseUrl, id)
val newRefs = extractImagesFromHistory(history)
if (newRefs.isNotEmpty() && newRefs != imageRefs) {
imageRefs = newRefs
loadImages()
}
} catch (e: Exception) {
// Silently ignore - history might not be ready yet
}
}
}
private fun loadImages() {
isLoadingImages = true
imageRefs.forEach { ref ->
if (!imageBitmaps.containsKey(ref.filename)) {
screenModelScope.launch {
try {
val url = buildViewUrl(ref)
val bytes = apiClient.getBytes(url)
imageBytes = imageBytes + (ref.filename to bytes)
val bitmap = bytes.decodeToImageBitmap()
imageBitmaps = imageBitmaps + (ref.filename to bitmap)
} catch (e: Exception) {
// Image loading failed
} finally {
// Check if all images are loaded
if (imageBitmaps.size >= imageRefs.size) {
isLoadingImages = false
}
}
}
}
}
// If all images are already cached
if (imageRefs.all { imageBitmaps.containsKey(it.filename) }) {
isLoadingImages = false
}
}
fun saveImage(index: Int) {
val ref = imageRefs.getOrNull(index) ?: return
val bytes = imageBytes[ref.filename] ?: return
screenModelScope.launch {
try {
val path = saveToTemp(bytes, ref.filename)
statusText = "已保存: $path"
} catch (e: Exception) {
statusText = "保存失败: ${e.message}"
}
}
}
fun setCoverImage(index: Int) {
val ref = imageRefs.getOrNull(index) ?: return
val bytes = imageBytes[ref.filename] ?: return
val current = workflow ?: return
val filename = "cover_${current.id}_${Clock.System.now().toEpochMilliseconds()}.jpg"
val path = saveCoverImage(bytes, filename)
val updated = current.copy(
coverImage = path,
updatedAt = Clock.System.now().toEpochMilliseconds()
)
workflowRepository.upsertWorkflow(updated)
workflow = updated
statusText = "已设置为封面"
}
private fun updateNodeStatus(nodeId: String, status: NodeStatus) {
nodeStates = nodeStates.map { state ->
if (state.nodeId == nodeId) state.copy(status = status)
else state
}
}
private fun resetNodeStatuses() {
nodeStates = nodeStates.map { it.copy(status = NodeStatus.PENDING) }
}
private fun markAllCompleted() {
nodeStates = nodeStates.map { state ->
if (state.status == NodeStatus.RUNNING || state.status == NodeStatus.PENDING) {
state.copy(status = NodeStatus.COMPLETED)
} else state
}
}
private fun getNodeClassType(nodeId: String): String {
return nodeStates.find { it.nodeId == nodeId }?.classType ?: nodeId
}
private fun extractCachedNodes(data: JsonObject?): List<String> {
// Extract node IDs from execution_cached event
val nodes = data?.get("nodes")
return if (nodes is JsonArray) {
nodes.mapNotNull { it.jsonPrimitive.content }
} else {
emptyList()
}
}
private fun buildViewUrl(ref: ImageRef): String {
val params = buildString {
append("filename=").append(ref.filename)
if (!ref.subfolder.isNullOrBlank()) append("&subfolder=").append(ref.subfolder)
if (!ref.type.isNullOrBlank()) append("&type=").append(ref.type)
}
return "${baseUrl.trimEnd('/')}/view?$params"
}
}
@@ -0,0 +1,314 @@
package moe.uni.comfy_kmp.ui.theme
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
// Light Mode Colors
private val LightPrimary = Color(0xFF0EA5E9) // 电光蓝
private val LightOnPrimary = Color(0xFFFFFFFF)
private val LightPrimaryContainer = Color(0xFFE0F2FE)
private val LightOnPrimaryContainer = Color(0xFF0C4A6E)
private val LightSecondary = Color(0xFF6366F1) // 靛蓝
private val LightOnSecondary = Color.White
private val LightSecondaryContainer = Color(0xFFE0E7FF)
private val LightOnSecondaryContainer = Color(0xFF3730A3)
private val LightTertiary = Color(0xFF10B981) // 翡翠绿
private val LightOnTertiary = Color.White
private val LightTertiaryContainer = Color(0xFFD1FAE5)
private val LightOnTertiaryContainer = Color(0xFF065F46)
private val LightBackground = Color(0xFFF5F5F7)
private val LightOnBackground = Color(0xFF1A1A1A)
private val LightSurface = Color(0xFFFFFFFF)
private val LightOnSurface = Color(0xFF1A1A1A)
private val LightSurfaceVariant = Color(0xFFE8E8ED)
private val LightOnSurfaceVariant = Color(0xFF4A4A52)
private val LightOutline = Color(0xFFD1D5DB)
private val LightOutlineVariant = Color(0xFFE5E7EB)
private val LightError = Color(0xFFEF4444)
private val LightOnError = Color.White
// Dark Mode Colors
private val DarkPrimary = Color(0xFF38BDF8) // 电光蓝
private val DarkOnPrimary = Color(0xFF0C1929)
private val DarkPrimaryContainer = Color(0xFF0369A1)
private val DarkOnPrimaryContainer = Color(0xFFBAE6FD)
private val DarkSecondary = Color(0xFFA78BFA) // 紫罗兰
private val DarkOnSecondary = Color(0xFF1E1B4B)
private val DarkSecondaryContainer = Color(0xFF4C1D95)
private val DarkOnSecondaryContainer = Color(0xFFEDE9FE)
private val DarkTertiary = Color(0xFF34D399) // 翡翠绿
private val DarkOnTertiary = Color(0xFF052E16)
private val DarkTertiaryContainer = Color(0xFF065F46)
private val DarkOnTertiaryContainer = Color(0xFFA7F3D0)
private val DarkBackground = Color(0xFF0D0D12)
private val DarkOnBackground = Color(0xFFE8E8ED)
private val DarkSurface = Color(0xFF1A1A24)
private val DarkOnSurface = Color(0xFFE8E8ED)
private val DarkSurfaceVariant = Color(0xFF2A2A36)
private val DarkOnSurfaceVariant = Color(0xFFA1A1AA)
private val DarkOutline = Color(0xFF3F3F46)
private val DarkOutlineVariant = Color(0xFF27272A)
private val DarkError = Color(0xFFF87171)
private val DarkOnError = Color(0xFF1A0A0A)
private val LightColors = lightColorScheme(
primary = LightPrimary,
onPrimary = LightOnPrimary,
primaryContainer = LightPrimaryContainer,
onPrimaryContainer = LightOnPrimaryContainer,
secondary = LightSecondary,
onSecondary = LightOnSecondary,
secondaryContainer = LightSecondaryContainer,
onSecondaryContainer = LightOnSecondaryContainer,
tertiary = LightTertiary,
onTertiary = LightOnTertiary,
tertiaryContainer = LightTertiaryContainer,
onTertiaryContainer = LightOnTertiaryContainer,
background = LightBackground,
onBackground = LightOnBackground,
surface = LightSurface,
onSurface = LightOnSurface,
surfaceVariant = LightSurfaceVariant,
onSurfaceVariant = LightOnSurfaceVariant,
outline = LightOutline,
outlineVariant = LightOutlineVariant,
error = LightError,
onError = LightOnError
)
private val DarkColors = darkColorScheme(
primary = DarkPrimary,
onPrimary = DarkOnPrimary,
primaryContainer = DarkPrimaryContainer,
onPrimaryContainer = DarkOnPrimaryContainer,
secondary = DarkSecondary,
onSecondary = DarkOnSecondary,
secondaryContainer = DarkSecondaryContainer,
onSecondaryContainer = DarkOnSecondaryContainer,
tertiary = DarkTertiary,
onTertiary = DarkOnTertiary,
tertiaryContainer = DarkTertiaryContainer,
onTertiaryContainer = DarkOnTertiaryContainer,
background = DarkBackground,
onBackground = DarkOnBackground,
surface = DarkSurface,
onSurface = DarkOnSurface,
surfaceVariant = DarkSurfaceVariant,
onSurfaceVariant = DarkOnSurfaceVariant,
outline = DarkOutline,
outlineVariant = DarkOutlineVariant,
error = DarkError,
onError = DarkOnError
)
private val ComfyTypography = Typography(
displayLarge = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
),
displayMedium = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 45.sp,
lineHeight = 52.sp
),
displaySmall = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 36.sp,
lineHeight = 44.sp
),
headlineLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 32.sp,
lineHeight = 40.sp
),
headlineMedium = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 28.sp,
lineHeight = 36.sp
),
headlineSmall = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 32.sp
),
titleLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp
),
titleMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp
),
titleSmall = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
bodySmall = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp
),
labelLarge = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
labelMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
),
labelSmall = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)
private val ComfyShapes = Shapes(
extraSmall = RoundedCornerShape(4.dp),
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp),
extraLarge = RoundedCornerShape(24.dp)
)
// Extended colors for custom UI elements
@Immutable
data class ComfyExtendedColors(
val success: Color,
val onSuccess: Color,
val successContainer: Color,
val warning: Color,
val onWarning: Color,
val warningContainer: Color,
val cardBackground: Color,
val cardBorder: Color,
val shimmerBase: Color,
val shimmerHighlight: Color,
val gradientStart: Color,
val gradientEnd: Color,
val nodeRunning: Color,
val nodeCompleted: Color,
val nodePending: Color
)
private val LightExtendedColors = ComfyExtendedColors(
success = Color(0xFF10B981),
onSuccess = Color.White,
successContainer = Color(0xFFD1FAE5),
warning = Color(0xFFF59E0B),
onWarning = Color(0xFF1A1A1A),
warningContainer = Color(0xFFFEF3C7),
cardBackground = Color.White,
cardBorder = Color(0xFFE5E7EB),
shimmerBase = Color(0xFFE5E7EB),
shimmerHighlight = Color(0xFFF3F4F6),
gradientStart = Color(0xFFF5F5F7),
gradientEnd = Color(0xFFE8E8ED),
nodeRunning = Color(0xFF0EA5E9),
nodeCompleted = Color(0xFF10B981),
nodePending = Color(0xFF9CA3AF)
)
private val DarkExtendedColors = ComfyExtendedColors(
success = Color(0xFF34D399),
onSuccess = Color(0xFF052E16),
successContainer = Color(0xFF065F46),
warning = Color(0xFFFBBF24),
onWarning = Color(0xFF1A1A1A),
warningContainer = Color(0xFF78350F),
cardBackground = Color(0xFF1F1F2A),
cardBorder = Color(0xFF3F3F46),
shimmerBase = Color(0xFF27272A),
shimmerHighlight = Color(0xFF3F3F46),
gradientStart = Color(0xFF0D0D12),
gradientEnd = Color(0xFF1A1A24),
nodeRunning = Color(0xFF38BDF8),
nodeCompleted = Color(0xFF34D399),
nodePending = Color(0xFF6B7280)
)
val LocalComfyColors = staticCompositionLocalOf { LightExtendedColors }
// Animation easings
object ComfyEasing {
val emphasized = CubicBezierEasing(0.2f, 0f, 0f, 1f)
val emphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
val emphasizedAccelerate = CubicBezierEasing(0.3f, 0f, 0.8f, 0.15f)
val standard = CubicBezierEasing(0.2f, 0f, 0f, 1f)
val standardDecelerate = CubicBezierEasing(0f, 0f, 0f, 1f)
val standardAccelerate = CubicBezierEasing(0.3f, 0f, 1f, 1f)
}
// Spacing constants
object ComfySpacing {
val xs = 4.dp
val sm = 8.dp
val md = 12.dp
val lg = 16.dp
val xl = 24.dp
val xxl = 32.dp
}
@Composable
fun ComfyTheme(
darkTheme: Boolean,
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColors else LightColors
val extendedColors = if (darkTheme) DarkExtendedColors else LightExtendedColors
CompositionLocalProvider(LocalComfyColors provides extendedColors) {
MaterialTheme(
colorScheme = colorScheme,
typography = ComfyTypography,
shapes = ComfyShapes,
content = content
)
}
}
// Helper extension to access extended colors
val MaterialTheme.comfyColors: ComfyExtendedColors
@Composable
get() = LocalComfyColors.current
@@ -0,0 +1,89 @@
package moe.uni.comfy_kmp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import platform.Foundation.NSURL
import platform.UIKit.UIApplication
import platform.UIKit.UIDocumentPickerDelegateProtocol
import platform.UIKit.UIDocumentPickerMode
import platform.UIKit.UIDocumentPickerViewController
import platform.UIKit.UIViewController
import platform.UIKit.UIWindow
import platform.darwin.NSObject
import okio.FileSystem
import okio.Path.Companion.toPath
@Composable
actual fun rememberFilePickerLauncher(
onResult: (String?) -> Unit
): () -> Unit {
val onResultState = rememberUpdatedState(onResult)
val controller = remember {
IosFilePickerController { content ->
onResultState.value(content)
}
}
return { controller.launch() }
}
actual fun isFilePickerSupported(): Boolean = true
private class IosFilePickerController(
private val onResult: (String?) -> Unit
) : NSObject(), UIDocumentPickerDelegateProtocol {
private var activePicker: UIDocumentPickerViewController? = null
fun launch() {
val presenter = currentPresenter()
if (presenter == null) {
onResult(null)
return
}
val picker = UIDocumentPickerViewController(
documentTypes = listOf("public.json", "public.plain-text", "public.data"),
inMode = UIDocumentPickerMode.UIDocumentPickerModeImport
)
picker.delegate = this
picker.allowsMultipleSelection = false
activePicker = picker
presenter.presentViewController(picker, animated = true, completion = null)
}
override fun documentPicker(
controller: UIDocumentPickerViewController,
didPickDocumentsAtURLs: List<*>
) {
val url = didPickDocumentsAtURLs.firstOrNull() as? NSURL
onResult(readText(url))
activePicker = null
}
override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) {
onResult(null)
activePicker = 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
}
private fun readText(url: NSURL?): String? {
if (url == null) return null
val path = url.path ?: return null
return try {
FileSystem.SYSTEM.read(path.toPath()) {
readUtf8()
}
} catch (_: Throwable) {
null
}
}
@@ -0,0 +1,5 @@
package moe.uni.comfy_kmp
import androidx.compose.ui.window.ComposeUIViewController
fun MainViewController() = ComposeUIViewController { App() }
@@ -0,0 +1,9 @@
package moe.uni.comfy_kmp
import platform.UIKit.UIDevice
class IOSPlatform : Platform {
override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
}
actual fun getPlatform(): Platform = IOSPlatform()
@@ -0,0 +1,32 @@
package moe.uni.comfy_kmp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import platform.Foundation.NSNotificationCenter
import platform.Foundation.NSUserDefaults
private const val STATUS_BAR_NOTIFICATION = "ComfyStatusBarHiddenChanged"
private const val STATUS_BAR_KEY = "comfyStatusBarHidden"
@Composable
actual fun SystemBarsEffect(hidden: Boolean) {
DisposableEffect(hidden) {
val previous = isStatusBarHidden()
setStatusBarHidden(hidden)
onDispose {
setStatusBarHidden(previous)
}
}
}
private fun isStatusBarHidden(): Boolean {
return NSUserDefaults.standardUserDefaults.boolForKey(STATUS_BAR_KEY)
}
private fun setStatusBarHidden(hidden: Boolean) {
NSUserDefaults.standardUserDefaults.setBool(hidden, forKey = STATUS_BAR_KEY)
NSNotificationCenter.defaultCenter.postNotificationName(
aName = STATUS_BAR_NOTIFICATION,
`object` = null
)
}
@@ -0,0 +1,56 @@
@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
package moe.uni.comfy_kmp.data
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.convert
import kotlinx.cinterop.usePinned
import okio.FileSystem
import okio.Path.Companion.toPath
import platform.Foundation.NSCachesDirectory
import platform.Foundation.NSData
import platform.Foundation.NSFileManager
import platform.Foundation.NSUserDomainMask
import platform.Foundation.URLByAppendingPathComponent
import platform.Foundation.dataWithBytes
import platform.UIKit.UIImage
import platform.UIKit.UIImageJPEGRepresentation
import platform.posix.memcpy
actual fun saveCoverImage(bytes: ByteArray, filenameHint: String): String {
val safeName = filenameHint.replace(Regex("[^A-Za-z0-9._-]"), "_")
val cacheDir = NSFileManager.defaultManager.URLForDirectory(
directory = NSCachesDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = true,
error = null
) ?: throw IllegalStateException("无法获取缓存目录")
val fileUrl = cacheDir.URLByAppendingPathComponent(safeName)
?: throw IllegalStateException("无法获取封面路径")
val data = bytes.toNSData()
val image = UIImage(data = data) ?: throw IllegalArgumentException("无法解码封面图片")
val jpegData = UIImageJPEGRepresentation(image, 0.8) ?: throw IllegalArgumentException("无法压缩封面图片")
val outputPath = fileUrl.path ?: throw IllegalStateException("无法获取封面路径")
val bytesToWrite = jpegData.toByteArray()
FileSystem.SYSTEM.write(outputPath.toPath()) {
write(bytesToWrite)
}
return outputPath
}
private fun ByteArray.toNSData(): NSData {
return usePinned { pinned ->
NSData.dataWithBytes(pinned.addressOf(0), size.toULong())
}
}
private fun NSData.toByteArray(): ByteArray {
val size = length.toInt()
if (size == 0) return ByteArray(0)
val result = ByteArray(size)
result.usePinned { pinned ->
memcpy(pinned.addressOf(0), bytes, size.convert())
}
return result
}
@@ -0,0 +1,8 @@
package moe.uni.comfy_kmp.network
import io.ktor.client.HttpClient
import io.ktor.client.engine.darwin.Darwin
actual fun createHttpClient(): HttpClient = HttpClient(Darwin) {
installComfyPlugins()
}
+10
View File
@@ -0,0 +1,10 @@
#Kotlin
kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx3072M
#Gradle
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
org.gradle.configuration-cache=true
org.gradle.caching=true
#Android
android.nonTransitiveRClass=true
android.useAndroidX=true
+59
View File
@@ -0,0 +1,59 @@
[versions]
agp = "8.11.2"
android-compileSdk = "36"
android-minSdk = "24"
android-targetSdk = "36"
androidx-activity = "1.12.2"
androidx-appcompat = "1.7.1"
androidx-core = "1.17.0"
androidx-espresso = "3.7.0"
androidx-lifecycle = "2.9.6"
androidx-testExt = "1.3.0"
composeMultiplatform = "1.10.0"
junit = "4.13.2"
kotlin = "2.3.0"
material3 = "1.10.0-alpha05"
ktor = "2.3.12"
kotlinx-serialization = "1.7.3"
multiplatform-settings = "1.1.1"
voyager = "1.0.1"
okio = "3.9.1"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
junit = { module = "junit:junit", version.ref = "junit" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" }
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
multiplatform-settings-no-arg = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "multiplatform-settings" }
voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager" }
voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" }
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+7
View File
@@ -0,0 +1,7 @@
TEAM_ID=
PRODUCT_NAME=comfy_kmp
PRODUCT_BUNDLE_IDENTIFIER=moe.uni.comfy_kmp.comfy_kmp$(TEAM_ID)
CURRENT_PROJECT_VERSION=1
MARKETING_VERSION=1.0
+373
View File
@@ -0,0 +1,373 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXFileReference section */
338A2ACE269EA4A9809609C8 /* comfy_kmp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = comfy_kmp.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
4FFC36D9AC666E480D025333 /* Exceptions for "iosApp" folder in "iosApp" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = F7DE0DB0650264F349CDBB7E /* iosApp */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
0D6065CBB220FB3CFF7205E4 /* iosApp */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
4FFC36D9AC666E480D025333 /* Exceptions for "iosApp" folder in "iosApp" target */,
);
path = iosApp;
sourceTree = "<group>";
};
6DFFEFC5344B9751F9B09211 /* Configuration */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Configuration;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
22D2A6B08D61E3651CC4EDC4 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
13628CF6F1A0D57C704ED6C0 = {
isa = PBXGroup;
children = (
6DFFEFC5344B9751F9B09211 /* Configuration */,
0D6065CBB220FB3CFF7205E4 /* iosApp */,
657B04DAB4CB0FD124755A63 /* Products */,
);
sourceTree = "<group>";
};
657B04DAB4CB0FD124755A63 /* Products */ = {
isa = PBXGroup;
children = (
338A2ACE269EA4A9809609C8 /* comfy_kmp.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
F7DE0DB0650264F349CDBB7E /* iosApp */ = {
isa = PBXNativeTarget;
buildConfigurationList = 365A766E3A8766A34A980614 /* Build configuration list for PBXNativeTarget "iosApp" */;
buildPhases = (
966925882A719CA71FDFC892 /* Compile Kotlin Framework */,
892832C9664E2C142667EEE1 /* Sources */,
22D2A6B08D61E3651CC4EDC4 /* Frameworks */,
30FCA98B94CE41079FC49A79 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
0D6065CBB220FB3CFF7205E4 /* iosApp */,
);
name = iosApp;
packageProductDependencies = (
);
productName = iosApp;
productReference = 338A2ACE269EA4A9809609C8 /* comfy_kmp.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
04FFB4DE676F9A629B413B44 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1620;
LastUpgradeCheck = 1620;
TargetAttributes = {
F7DE0DB0650264F349CDBB7E = {
CreatedOnToolsVersion = 16.2;
};
};
};
buildConfigurationList = 9EF34B5F98F775D07666BB98 /* Build configuration list for PBXProject "iosApp" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 13628CF6F1A0D57C704ED6C0;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 657B04DAB4CB0FD124755A63 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
F7DE0DB0650264F349CDBB7E /* iosApp */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
30FCA98B94CE41079FC49A79 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
966925882A719CA71FDFC892 /* Compile Kotlin Framework */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Compile Kotlin Framework";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
892832C9664E2C142667EEE1 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
BD38431153976DCA04A5BCBF /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = 6DFFEFC5344B9751F9B09211 /* Configuration */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
220243254E599B629BB97E0D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = 6DFFEFC5344B9751F9B09211 /* Configuration */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
790F64FF50A458DCD9C8B7D3 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = "${TEAM_ID}";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iosApp/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
C41926DAA27AEF778FA38896 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = "${TEAM_ID}";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iosApp/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
9EF34B5F98F775D07666BB98 /* Build configuration list for PBXProject "iosApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (
BD38431153976DCA04A5BCBF /* Debug */,
220243254E599B629BB97E0D /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
365A766E3A8766A34A980614 /* Build configuration list for PBXNativeTarget "iosApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (
790F64FF50A458DCD9C8B7D3 /* Debug */,
C41926DAA27AEF778FA38896 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 04FFB4DE676F9A629B413B44 /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,11 @@
{
"colors": [
{
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,36 @@
{
"images": [
{
"filename": "app-icon-1024.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "tinted"
}
],
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
+31
View File
@@ -0,0 +1,31 @@
import UIKit
import SwiftUI
import ComposeApp
struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
MainViewControllerKt.MainViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
}
}
struct ContentView: View {
@State private var statusBarHidden = false
var body: some View {
ComposeView()
.ignoresSafeArea()
.statusBarHidden(statusBarHidden)
.onReceive(NotificationCenter.default.publisher(for: .comfyStatusBarHiddenChanged)) { _ in
statusBarHidden = UserDefaults.standard.bool(forKey: "comfyStatusBarHidden")
}
}
}
extension Notification.Name {
static let comfyStatusBarHiddenChanged = Notification.Name("ComfyStatusBarHiddenChanged")
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
+10
View File
@@ -0,0 +1,10 @@
import SwiftUI
@main
struct iOSApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
+31
View File
@@ -0,0 +1,31 @@
rootProject.name = "comfy_kmp"
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
pluginManagement {
repositories {
google {
mavenContent {
includeGroupAndSubgroups("androidx")
includeGroupAndSubgroups("com.android")
includeGroupAndSubgroups("com.google")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google {
mavenContent {
includeGroupAndSubgroups("androidx")
includeGroupAndSubgroups("com.android")
includeGroupAndSubgroups("com.google")
}
}
mavenCentral()
}
}
include(":composeApp")