@@ -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
|
||||
@@ -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/
|
||||
@@ -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 that’s 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 Apple’s 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 you’re 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 IDE’s 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 IDE’s 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)…
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
@@ -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" "$@"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct iOSApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||