@@ -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")
|
||||||