From 342a1a370de958d4ea6155b433210d5d8495a809 Mon Sep 17 00:00:00 2001 From: AnranYus Date: Mon, 6 Oct 2025 14:01:49 +0800 Subject: [PATCH] fix: bugs --- composeApp/build.gradle.kts | 6 +- .../data/settings/KeyValueStore.android.kt | 1 + .../ui/importer/FileImportFab.android.kt | 1 + .../comfyuikmp/util/ImageDecode.android.kt | 1 + .../res/xml/network_security_config.xml | 1 + .../comfyuikmp/data/model/ComfyModels.kt | 2 +- .../comfyuikmp/data/model/HistoryParsers.kt | 4 +- .../comfy/comfyuikmp/data/network/ComfyApi.kt | 3 + .../comfy/comfyuikmp/data/network/ComfyWs.kt | 81 ++++--- .../data/repository/ComfyRepository.kt | 31 +-- .../data/repository/SettingsRepository.kt | 1 + .../comfyuikmp/data/settings/KeyValueStore.kt | 1 + .../moe/uni/comfy/comfyuikmp/ui/AppRoot.kt | 206 ++++++++---------- .../uni/comfy/comfyuikmp/ui/AppViewModel.kt | 134 ++++++++++++ .../comfyuikmp/ui/editor/EditorScreen.kt | 157 ------------- .../comfyuikmp/ui/gallery/GalleryScreen.kt | 67 ++++-- .../comfyuikmp/ui/generate/GenerateScreen.kt | 202 +++++++++++++++++ .../comfy/comfyuikmp/ui/home/HomeScreen.kt | 14 +- .../comfyuikmp/ui/importer/FileImportFab.kt | 1 + .../comfyuikmp/ui/importer/FilePicker.kt | 1 + .../uni/comfy/comfyuikmp/ui/navigation/Nav.kt | 39 +++- .../comfyuikmp/ui/settings/SettingsScreen.kt | 1 + .../comfy/comfyuikmp/util/ViewModelFactory.kt | 11 + .../data/settings/KeyValueStore.ios.kt | 1 + .../ui/importer/FileImportFab.ios.kt | 1 + .../comfy/comfyuikmp/util/ImageDecode.ios.kt | 1 + gradle/libs.versions.toml | 3 + iosApp/iosApp.xcodeproj/project.pbxproj | 198 ++++++++--------- iosApp/iosApp/Info.plist | 22 +- 29 files changed, 727 insertions(+), 465 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/AppViewModel.kt delete mode 100644 composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/editor/EditorScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/generate/GenerateScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/util/ViewModelFactory.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index c37cd3c..9a14a92 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -31,6 +31,7 @@ kotlin { implementation(compose.preview) implementation(libs.androidx.activity.compose) implementation(libs.ktor.client.okhttp) + implementation("io.ktor:ktor-client-android:3.3.0") } commonMain.dependencies { implementation(compose.runtime) @@ -47,13 +48,16 @@ kotlin { implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.kotlinx.serialization.json) implementation(libs.ktor.client.websockets) + api(libs.androidx.lifecycle.viewmodel) + implementation(compose.materialIconsExtended) implementation("co.touchlab:kermit:2.0.4") //Add latest version - implementation("br.com.devsrsouza.compose.icons:font-awesome:1.1.1") + implementation("io.coil-kt.coil3:coil-compose:3.3.0") } commonTest.dependencies { implementation(libs.kotlin.test) } iosMain.dependencies { + implementation("io.ktor:ktor-client-darwin:3.3.0") implementation(libs.ktor.client.darwin) } } diff --git a/composeApp/src/androidMain/kotlin/moe/uni/comfy/comfyuikmp/data/settings/KeyValueStore.android.kt b/composeApp/src/androidMain/kotlin/moe/uni/comfy/comfyuikmp/data/settings/KeyValueStore.android.kt index 59b4595..98dfbc2 100644 --- a/composeApp/src/androidMain/kotlin/moe/uni/comfy/comfyuikmp/data/settings/KeyValueStore.android.kt +++ b/composeApp/src/androidMain/kotlin/moe/uni/comfy/comfyuikmp/data/settings/KeyValueStore.android.kt @@ -21,3 +21,4 @@ actual object KeyValueStore { } + diff --git a/composeApp/src/androidMain/kotlin/moe/uni/comfy/comfyuikmp/ui/importer/FileImportFab.android.kt b/composeApp/src/androidMain/kotlin/moe/uni/comfy/comfyuikmp/ui/importer/FileImportFab.android.kt index 845555d..cfe1bf2 100644 --- a/composeApp/src/androidMain/kotlin/moe/uni/comfy/comfyuikmp/ui/importer/FileImportFab.android.kt +++ b/composeApp/src/androidMain/kotlin/moe/uni/comfy/comfyuikmp/ui/importer/FileImportFab.android.kt @@ -17,3 +17,4 @@ actual fun FileImportFab(onImported: (ComfyPrompt) -> Unit, onError: (String) -> } + diff --git a/composeApp/src/androidMain/kotlin/moe/uni/comfy/comfyuikmp/util/ImageDecode.android.kt b/composeApp/src/androidMain/kotlin/moe/uni/comfy/comfyuikmp/util/ImageDecode.android.kt index f7aa33d..314b788 100644 --- a/composeApp/src/androidMain/kotlin/moe/uni/comfy/comfyuikmp/util/ImageDecode.android.kt +++ b/composeApp/src/androidMain/kotlin/moe/uni/comfy/comfyuikmp/util/ImageDecode.android.kt @@ -8,3 +8,4 @@ actual fun decodeImage(bytes: ByteArray): ImageBitmap? = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() + diff --git a/composeApp/src/androidMain/res/xml/network_security_config.xml b/composeApp/src/androidMain/res/xml/network_security_config.xml index 9b13264..905a4db 100644 --- a/composeApp/src/androidMain/res/xml/network_security_config.xml +++ b/composeApp/src/androidMain/res/xml/network_security_config.xml @@ -4,3 +4,4 @@ + diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/model/ComfyModels.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/model/ComfyModels.kt index 3a42fe4..09a3440 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/model/ComfyModels.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/model/ComfyModels.kt @@ -10,7 +10,7 @@ import kotlinx.serialization.json.JsonElement @Serializable data class ComfyNode( @SerialName("class_type") val classType: String, - val inputs: Map = emptyMap(), + var inputs: Map = emptyMap(), @SerialName("_meta") val meta: ComfyNodeMeta? = null ) diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/model/HistoryParsers.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/model/HistoryParsers.kt index 104692f..55305eb 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/model/HistoryParsers.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/model/HistoryParsers.kt @@ -27,8 +27,8 @@ fun extractImageRefsFromHistoryElement(root: JsonElement): List { is JsonObject -> { // 命中 images 字段 element["images"]?.let { imagesEl -> - val arr: JsonArray? = imagesEl as? JsonArray ?: imagesEl.jsonArray - arr?.forEach { imgEl -> + val arr: JsonArray = imagesEl as? JsonArray ?: imagesEl.jsonArray + arr.forEach { imgEl -> val obj: JsonObject? = imgEl as? JsonObject ?: runCatching { imgEl.jsonObject }.getOrNull() if (obj != null) { val filename = runCatching { obj["filename"]?.jsonPrimitive?.content }.getOrNull() diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/network/ComfyApi.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/network/ComfyApi.kt index bf2372f..27be5aa 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/network/ComfyApi.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/network/ComfyApi.kt @@ -35,6 +35,9 @@ class ComfyApi(private val baseUrl: String, private val httpClient: HttpClient) suspend fun getHistory(promptId: String): Map = httpClient.get("$baseUrl/history/$promptId").body() + suspend fun getHistory(size: Int): Map = + httpClient.get("$baseUrl/history?max_items=${size}").body() + suspend fun fetchImage( filename: String, subfolder: String? = null, diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/network/ComfyWs.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/network/ComfyWs.kt index 776ad6b..68c36f3 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/network/ComfyWs.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/network/ComfyWs.kt @@ -4,6 +4,7 @@ import io.ktor.client.HttpClient import io.ktor.client.plugins.websocket.webSocket import io.ktor.websocket.Frame import io.ktor.websocket.readText +import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.yield import kotlinx.serialization.json.Json @@ -17,7 +18,7 @@ import kotlin.coroutines.cancellation.CancellationException data class ProgressUpdate( val fraction: Float?, - val message: String? + val message: String?, ) class ComfyWsClient(private val client: HttpClient) { @@ -33,6 +34,10 @@ class ComfyWsClient(private val client: HttpClient) { return "$wsBase/ws?clientId=$clientId" } + fun close(){ + client.close() + } + suspend fun listen( baseUrl: String, clientId: String, @@ -40,38 +45,58 @@ class ComfyWsClient(private val client: HttpClient) { onImages: (List) -> Unit = {} ) { val url = toWsUrl(baseUrl, clientId) - client.webSocket(urlString = url) { + var attempt = 0 + while (true) { try { - for (frame in incoming) { - val text = (frame as? Frame.Text)?.readText() ?: continue - val root = runCatching { json.parseToJsonElement(text) }.getOrNull() ?: continue - val obj: JsonObject = root as? JsonObject ?: continue - val type = obj["type"]?.jsonPrimitive?.content - when (type) { - "progress" -> { - val data = obj["data"]?.jsonObject - val value = data?.get("value")?.jsonPrimitive?.content?.toFloatOrNull() - val max = data?.get("max")?.jsonPrimitive?.content?.toFloatOrNull() - val frac = if (value != null && max != null && max > 0f) value / max else null - onUpdate(ProgressUpdate(frac, "生成中 ${(value ?: 0f).toInt()}/${(max ?: 0f).toInt()}")) - } - "executing" -> { - val node = obj["data"]?.jsonObject?.get("node")?.jsonPrimitive?.content - onUpdate(ProgressUpdate(null, "执行节点: ${node ?: ""}")) - } - "executed" -> { - val data: JsonElement = obj["data"] ?: continue - val refs = extractImageRefsFromHistoryElement(data) - if (refs.isNotEmpty()) onImages(refs) - } - else -> { - // ignore other types + client.webSocket(urlString = url) { + // reset backoff on successful connect + attempt = 0 + try { + for (frame in incoming) { + val text = (frame as? Frame.Text)?.readText() ?: continue + val root = runCatching { json.parseToJsonElement(text) }.getOrNull() ?: continue + val obj: JsonObject = root as? JsonObject ?: continue + val type = obj["type"]?.jsonPrimitive?.content + when (type) { + "progress" -> { + val data = obj["data"]?.jsonObject + val value = data?.get("value")?.jsonPrimitive?.content?.toFloatOrNull() + val max = data?.get("max")?.jsonPrimitive?.content?.toFloatOrNull() + val frac = if (value != null && max != null && max > 0f) value / max else null + onUpdate(ProgressUpdate(frac, "生成中 ${(value ?: 0f).toInt()}/${(max ?: 0f).toInt()}")) + } + "executing" -> { + val node = obj["data"]?.jsonObject?.get("node")?.jsonPrimitive?.content + if (node != null && node != "null") { + onUpdate(ProgressUpdate(null, "执行节点: ${node}")) + } + } + "executed" -> { + onUpdate(ProgressUpdate(null, "success")) + val data: JsonElement = obj["data"] ?: continue + val refs = extractImageRefsFromHistoryElement(data) + if (refs.isNotEmpty()) onImages(refs) + } + else -> { + // ignore other types + } + } + if (!this@webSocket.isActive) break + yield() } + } catch (e: CancellationException) { + throw e } - if (!this@webSocket.isActive) break - yield() } } catch (_: CancellationException) { + break + } catch (_: Throwable) { + // 连接错误或读写错误:指数退避重连 + val backoffSeconds = (1 shl attempt).coerceAtMost(32) + onUpdate(ProgressUpdate(null, "连接断开,${backoffSeconds}s 后重连…")) + attempt = (attempt + 1).coerceAtMost(5) + delay(backoffSeconds * 1000L) + continue } } } diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/repository/ComfyRepository.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/repository/ComfyRepository.kt index 54ad938..7b4d437 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/repository/ComfyRepository.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/repository/ComfyRepository.kt @@ -1,15 +1,10 @@ package moe.uni.comfy.comfyuikmp.data.repository -import co.touchlab.kermit.Logger import io.ktor.client.HttpClient import moe.uni.comfy.comfyuikmp.data.model.ComfyPrompt -import moe.uni.comfy.comfyuikmp.data.model.ImageRef -import moe.uni.comfy.comfyuikmp.data.model.extractImageRefsFromHistoryElement import moe.uni.comfy.comfyuikmp.data.network.ComfyApi import moe.uni.comfy.comfyuikmp.data.network.createDefaultHttpClient -import kotlinx.serialization.json.JsonElement -import kotlinx.coroutines.delay -import kotlinx.coroutines.withTimeout + class ComfyRepository( private val settingsRepository: SettingsRepository, @@ -17,7 +12,7 @@ class ComfyRepository( ) { private fun apiOrNull(): ComfyApi? { val base = settingsRepository.getBaseUrl() ?: return null - return ComfyApi(baseUrl = base.trimEnd('/'), httpClient = httpClient) + return ComfyApi(baseUrl = base, httpClient = httpClient) } suspend fun submit(prompt: ComfyPrompt, clientId: String? = null) = @@ -26,31 +21,11 @@ class ComfyRepository( suspend fun queue() = apiOrNull()?.getQueue() suspend fun history(promptId: String) = apiOrNull()?.getHistory(promptId) + suspend fun history(size: Int) = apiOrNull()?.getHistory(size) suspend fun image(filename: String, subfolder: String? = null, type: String? = null) = apiOrNull()?.fetchImage(filename, subfolder, type) - /** - * 提交并轮询直到历史中出现图片引用或超时。 - */ - suspend fun runAndWaitImages(prompt: ComfyPrompt, clientId: String? = null, timeoutMs: Long = 120_000): List { - val api = apiOrNull() ?: return emptyList() - val submit = api.submitPrompt(prompt, clientId) - Logger.i { "Submit result: $submit." } - return withTimeout(timeoutMs) { - while (true) { - val hist: Map = api.getHistory(submit.promptId) - Logger.i { "History result: $hist." } - val merged = hist.values.firstOrNull() - if (merged != null) { - val refs = extractImageRefsFromHistoryElement(merged) - if (refs.isNotEmpty()) return@withTimeout refs - } - delay(1000) - } - emptyList() - } - } } diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/repository/SettingsRepository.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/repository/SettingsRepository.kt index 263efa0..86b1bc9 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/repository/SettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/repository/SettingsRepository.kt @@ -9,3 +9,4 @@ class SettingsRepository { } + diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/settings/KeyValueStore.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/settings/KeyValueStore.kt index cadce03..617b248 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/settings/KeyValueStore.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/data/settings/KeyValueStore.kt @@ -11,3 +11,4 @@ expect object KeyValueStore { } + diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/AppRoot.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/AppRoot.kt index 6717e60..fb9cdcb 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/AppRoot.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/AppRoot.kt @@ -1,158 +1,140 @@ package moe.uni.comfy.comfyuikmp.ui -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.foundation.clickable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.History +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import moe.uni.comfy.comfyuikmp.ui.navigation.Dest -import moe.uni.comfy.comfyuikmp.ui.settings.SettingsScreen -import moe.uni.comfy.comfyuikmp.ui.importer.ImportScreen -import moe.uni.comfy.comfyuikmp.ui.editor.EditorScreen -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue +import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.launch -import moe.uni.comfy.comfyuikmp.data.model.ComfyPrompt -import moe.uni.comfy.comfyuikmp.ui.gallery.GalleryScreen -import moe.uni.comfy.comfyuikmp.util.decodeImage -import androidx.compose.ui.graphics.ImageBitmap +import kotlinx.serialization.json.JsonPrimitive import moe.uni.comfy.comfyuikmp.di.AppGraph -import androidx.compose.material3.ExtendedFloatingActionButton -import co.touchlab.kermit.Logger +import moe.uni.comfy.comfyuikmp.ui.gallery.GalleryScreen +import moe.uni.comfy.comfyuikmp.ui.generate.GenerateScreen import moe.uni.comfy.comfyuikmp.ui.home.HomeScreen -import moe.uni.comfy.comfyuikmp.data.repository.WorkflowItem -import moe.uni.comfy.comfyuikmp.data.network.ComfyWsClient -import moe.uni.comfy.comfyuikmp.data.network.ProgressUpdate -import moe.uni.comfy.comfyuikmp.data.network.createDefaultHttpClient +import moe.uni.comfy.comfyuikmp.ui.importer.ImportScreen +import moe.uni.comfy.comfyuikmp.ui.navigation.Dest +import moe.uni.comfy.comfyuikmp.ui.navigation.rememberNavController +import moe.uni.comfy.comfyuikmp.ui.settings.SettingsScreen +import moe.uni.comfy.comfyuikmp.util.mainViewModelFactory +import kotlin.random.Random +import kotlin.time.Clock +import kotlin.time.ExperimentalTime -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class) @Composable -fun AppRoot() { +fun AppRoot(viewModel: AppViewModel = viewModel(factory = mainViewModelFactory)) { val hasSaved = remember { AppGraph.workflows.list().isNotEmpty() } - val current: MutableState = remember { mutableStateOf(if (hasSaved) Dest.Home else Dest.Settings) } + val startDest = if (hasSaved) Dest.Home else Dest.Settings + val navController = rememberNavController(startDest) MaterialTheme { val snackbar = remember { SnackbarHostState() } val scope = rememberCoroutineScope() - var promptState by remember { mutableStateOf(null) } - var images by remember { mutableStateOf>(emptyList()) } - var currentWorkflowId by remember { mutableStateOf(null) } - var currentWorkflowName by remember { mutableStateOf(null) } - var progress by remember { mutableStateOf(null) } - val wsClient = remember { ComfyWsClient(createDefaultHttpClient()) } + val uiState by viewModel.uiState.collectAsState() Scaffold( snackbarHost = { SnackbarHost(hostState = snackbar) }, topBar = { - TopAppBar(title = { Text(text = when (current.value) { - Dest.Home -> "工作流" - Dest.Settings -> "设置" - Dest.Import -> "导入工作流" - Dest.Editor -> "编辑工作流" - Dest.Gallery -> "结果" - }) }) + val current = navController.current.value + TopAppBar( + title = { + Text( + text = when (current) { + Dest.Home -> "工作流" + Dest.Settings -> "设置" + Dest.Import -> "导入工作流" + Dest.Generate -> "生成" + Dest.Gallery -> "历史" + } + ) + }, + navigationIcon = { + if (current == Dest.Import || current == Dest.Generate || current == Dest.Gallery) { + IconButton(onClick = { navController.pop() }) { + Icon(Icons.Filled.ArrowBack, contentDescription = "返回") + } + } + }, + actions = { + if (current == Dest.Generate) { + Icon( + Icons.Filled.History, + contentDescription = null, + modifier = Modifier.clickable { navController.navigate(Dest.Gallery) } + ) + } + } + ) }, floatingActionButton = { - if (current.value == Dest.Editor) { + if (navController.current.value == Dest.Generate) { ExtendedFloatingActionButton( text = { Text("生成") }, icon = {}, onClick = { - val p = promptState ?: return@ExtendedFloatingActionButton - scope.launch { - val repo = AppGraph.comfy - snackbar.showSnackbar("提交中…") - val clientId = (currentWorkflowId ?: "client") - scope.launch { - runCatching { - wsClient.listen( - AppGraph.settings.getBaseUrl() ?: "", - clientId, - onUpdate = { progress = it }, - onImages = { refs -> - scope.launch { - val imgs = refs.mapNotNull { ref -> - val bytes = repo.image(ref.filename, ref.subfolder, ref.type) - bytes?.let { decodeImage(it) } - } - images = imgs - progress = null - } + uiState.prompt.let { p -> + scope.launch { + if (uiState.useRandom) { + p.values.find { it.classType == "KSampler" }?.let { + val inputs = it.inputs.toMutableMap() + inputs["seed"] = JsonPrimitive(Random.nextInt(0, Int.MAX_VALUE)) + it.inputs = inputs } - ) + } + snackbar.showSnackbar("提交中…") + viewModel.submitTask() } } - runCatching { repo.submit(p, clientId) }.onFailure { e -> - snackbar.showSnackbar(e.message ?: "运行失败") - } } - } ) } } ) { padding -> - when (current.value) { + when (navController.current.value) { Dest.Home -> HomeScreen( contentPadding = padding, onPick = { item -> - currentWorkflowId = item.id - currentWorkflowName = item.name - promptState = item.prompt - current.value = Dest.Editor + viewModel.setWorkflow(item.id,item.name,item.prompt) + navController.navigate(Dest.Generate) }, - onCreate = { current.value = Dest.Import } + onCreate = { navController.navigate(Dest.Import) } ) - Dest.Settings -> SettingsScreen(padding){ - current.value = Dest.Import + + Dest.Settings -> SettingsScreen(padding) { + navController.navigate(Dest.Import) } + Dest.Import -> ImportScreen( contentPadding = padding, onImported = { val name = it.values.firstOrNull()?.meta?.title ?: "工作流" - val id = name + "_" + it.hashCode() - AppGraph.workflows.saveOrUpdate( - WorkflowItem( - id = id, - name = name, - prompt = it - ) - ) - currentWorkflowId = id - currentWorkflowName = name - promptState = it - current.value = Dest.Editor + val id = it.hashCode().toString() + Clock.System.now().toEpochMilliseconds() + viewModel.setWorkflow(id,name,it) + navController.navigate(Dest.Generate) }, onError = { msg -> scope.launch { snackbar.showSnackbar(msg) } } ) - Dest.Editor -> EditorScreen( - contentPadding = padding, - prompt = promptState ?: emptyMap(), - latestImage = images.firstOrNull(), - progress = progress, - onPromptChange = { - promptState = it - val name = currentWorkflowName ?: it.values.firstOrNull()?.meta?.title ?: "工作流" - val id = currentWorkflowId ?: (name + "_" + it.hashCode()).also { nid -> currentWorkflowId = nid } - currentWorkflowName = name - AppGraph.workflows.saveOrUpdate( - WorkflowItem( - id = id, - name = name, - prompt = it - ) - ) - } - ) - Dest.Gallery -> GalleryScreen(contentPadding = padding, images = images) - else -> Text("占位页面:" + current.value.name, modifier = Modifier.fillMaxSize()) + + Dest.Generate -> { + GenerateScreen( + contentPadding = padding, + prompt = uiState.prompt, + latestImage = uiState.image, + progress = uiState.progress, + useRandom = uiState.useRandom, + onPromptChange = { + viewModel.updatePrompt(it) + }, + onRandomSwitchChange = { + viewModel.useRandomSeed(it) + + } + ) + } + + Dest.Gallery -> GalleryScreen(contentPadding = padding,viewModel) } } } diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/AppViewModel.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/AppViewModel.kt new file mode 100644 index 0000000..b3ab11d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/AppViewModel.kt @@ -0,0 +1,134 @@ +package moe.uni.comfy.comfyuikmp.ui + +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.ImageBitmap +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import moe.uni.comfy.comfyuikmp.data.model.ComfyPrompt +import moe.uni.comfy.comfyuikmp.data.model.ImageRef +import moe.uni.comfy.comfyuikmp.data.model.extractImageRefsFromHistoryElement +import moe.uni.comfy.comfyuikmp.data.network.ComfyWsClient +import moe.uni.comfy.comfyuikmp.data.network.ProgressUpdate +import moe.uni.comfy.comfyuikmp.data.network.createDefaultHttpClient +import moe.uni.comfy.comfyuikmp.data.repository.WorkflowItem +import moe.uni.comfy.comfyuikmp.di.AppGraph +import moe.uni.comfy.comfyuikmp.util.decodeImage + +class AppViewModel : ViewModel() { + private val _uiState = MutableStateFlow(AppUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + val wsClient = ComfyWsClient(createDefaultHttpClient()) + private val wsScope = CoroutineScope(Dispatchers.IO + Job()) + private var wsJob: Job? = null + private val _historyImages = MutableStateFlow>(emptyList()) + val historyImages: StateFlow> = _historyImages.asStateFlow() + + init { + viewModelScope.launch { + var oldId = "" + uiState.collectLatest { + if (it.workflowId != oldId && it.workflowId != "") { + oldId = it.workflowId + startListening() + } + } + } + } + + fun startListening() { + wsJob?.cancel() + wsJob = wsScope.launch { + Logger.i{"Listening:${AppGraph.settings.getBaseUrl() + _uiState.value.workflowId}"} + wsClient.listen(AppGraph.settings.getBaseUrl() ?: "", _uiState.value.workflowId, onUpdate = { + _uiState.value = _uiState.value.copy(progress = it) + }, onImages = { + wsScope.launch { + it.map { ref -> + _uiState.value = _uiState.value.copy(image = getImage(ref)) + } + } + }) + } + } + + override fun onCleared() { + super.onCleared() + wsJob?.cancel() + wsClient.close() + } + + suspend fun getImage(meta: ImageRef): ImageBitmap? { + AppGraph.comfy.image(meta.filename, meta.subfolder, meta.type)?.let { + return decodeImage(it) + } + return null + } + + fun getHistoryImages(size:Int){ + viewModelScope.launch { + val map = AppGraph.comfy.history(size) ?: return@launch + val refs = map.values.flatMap { element -> + extractImageRefsFromHistoryElement(element) + } + _historyImages.value = refs.distinctBy { it.filename }.asReversed() + Logger.i { "History images retrieved:${_historyImages.value.size}" } + } + } + + fun setWorkflow(id: String,name: String,prompt: ComfyPrompt) { + _uiState.value = _uiState.value.copy(workflowId = id, workflowName = name,prompt = prompt) + viewModelScope.launch { + AppGraph.workflows.saveOrUpdate( + WorkflowItem( + id = id, + name = name, + prompt = prompt + ) + ) + } + } + + fun useRandomSeed(boolean: Boolean){ + _uiState.value = _uiState.value.copy(useRandom = boolean) + } + + fun updatePrompt(prompt: ComfyPrompt){ + _uiState.value = _uiState.value.copy(prompt = prompt) + viewModelScope.launch { + AppGraph.workflows.saveOrUpdate( + WorkflowItem( + id = _uiState.value.workflowId, + name = _uiState.value.workflowName, + prompt = prompt + ) + ) + } + } + + fun submitTask(){ + viewModelScope.launch { + AppGraph.comfy.submit(_uiState.value.prompt,_uiState.value.workflowId) + } + } + +} + +@Stable +data class AppUiState( + val workflowId: String = "", + val workflowName: String = "", + val image: ImageBitmap? = null, + val progress:ProgressUpdate = ProgressUpdate(0f, ""), + val prompt: ComfyPrompt = emptyMap(), + val useRandom: Boolean = false +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/editor/EditorScreen.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/editor/EditorScreen.kt deleted file mode 100644 index 0472075..0000000 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/editor/EditorScreen.kt +++ /dev/null @@ -1,157 +0,0 @@ -package moe.uni.comfy.comfyuikmp.ui.editor - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.BottomSheetScaffold -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.Button -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.ui.draw.clip -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.booleanOrNull -import kotlinx.serialization.json.doubleOrNull -import kotlinx.serialization.json.intOrNull -import kotlin.random.Random -import moe.uni.comfy.comfyuikmp.data.model.ComfyPrompt -import moe.uni.comfy.comfyuikmp.data.model.updateNodeStringInput -import moe.uni.comfy.comfyuikmp.data.model.updateNodePrimitiveInput -import moe.uni.comfy.comfyuikmp.di.AppGraph -import moe.uni.comfy.comfyuikmp.data.network.ProgressUpdate - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun EditorScreen( - contentPadding: PaddingValues, - prompt: ComfyPrompt, - latestImage: ImageBitmap?, - progress: ProgressUpdate? = null, - onPromptChange: (ComfyPrompt) -> Unit, -) { - BottomSheetScaffold( - sheetPeekHeight = 56.dp, - sheetContent = { - LazyColumn(modifier = Modifier.padding(16.dp)) { - items(prompt.keys.toList()) { nodeId -> - val node = prompt[nodeId] - val title = node?.meta?.title ?: nodeId - Text(title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp)) - node?.inputs?.forEach { (key, value: JsonElement) -> - if (value is JsonPrimitive) { - when { - value.isString -> { - var field by remember(nodeId, key) { mutableStateOf(value.content) } - OutlinedTextField( - value = field, - onValueChange = { - field = it - onPromptChange(updateNodeStringInput(prompt, nodeId, key, it)) - }, - label = { Text(key) }, - modifier = Modifier - .padding(top = 8.dp) - .fillMaxWidth() - ) - } - value.booleanOrNull != null -> { - var checked by remember(nodeId, key) { mutableStateOf(value.booleanOrNull == true) } - androidx.compose.material3.Switch( - checked = checked, - onCheckedChange = { - checked = it - onPromptChange(updateNodePrimitiveInput(prompt, nodeId, key, JsonPrimitive(it))) - }, - ) - } - value.intOrNull != null || value.doubleOrNull != null -> { - var text by remember(nodeId, key) { mutableStateOf(value.toString()) } - OutlinedTextField( - value = text, - onValueChange = { - text = it - it.toIntOrNull()?.let { num -> - onPromptChange(updateNodePrimitiveInput(prompt, nodeId, key, JsonPrimitive(num))) - } ?: it.toDoubleOrNull()?.let { d -> - onPromptChange(updateNodePrimitiveInput(prompt, nodeId, key, JsonPrimitive(d))) - } - }, - label = { Text(key) }, - modifier = Modifier - .padding(top = 8.dp) - .fillMaxWidth() - ) - if (key.equals("seed", ignoreCase = true)) { - Row(modifier = Modifier.padding(top = 8.dp)) { - Button(onClick = { - val rnd = Random.nextInt(0, Int.MAX_VALUE) - text = rnd.toString() - onPromptChange(updateNodePrimitiveInput(prompt, nodeId, key, JsonPrimitive(rnd))) - }) { Text("随机") } - Spacer(Modifier.width(8.dp)) - Text("自动生成随机种子", style = MaterialTheme.typography.bodySmall) - } - } - } - else -> { - OutlinedTextField( - value = value.toString(), - onValueChange = {}, - readOnly = true, - label = { Text(key) }, - modifier = Modifier - .padding(top = 8.dp) - .fillMaxWidth() - ) - } - } - } else { - OutlinedTextField( - value = value.toString(), - onValueChange = {}, - readOnly = true, - label = { Text(key) }, - modifier = Modifier - .padding(top = 8.dp) - .fillMaxWidth() - ) - } - } - } - } - } - ) { innerPadding -> - Column(modifier = Modifier.padding(contentPadding).padding(innerPadding).padding(16.dp)) { - val p = progress - if (p != null) { - LinearProgressIndicator(progress = { p.fraction ?: 0f }, modifier = Modifier.fillMaxWidth()) - if (p.message != null) Text(p.message, style = MaterialTheme.typography.bodySmall) - } - if (latestImage != null) { - Image(bitmap = latestImage, contentDescription = null, modifier = Modifier.fillMaxSize().clip( - RoundedCornerShape(15.dp) - )) - } else { - Text("暂无生成结果", style = MaterialTheme.typography.bodyMedium) - } - } - } -} - - diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/gallery/GalleryScreen.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/gallery/GalleryScreen.kt index 37ceaa2..26b26e5 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/gallery/GalleryScreen.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/gallery/GalleryScreen.kt @@ -1,38 +1,67 @@ package moe.uni.comfy.comfyuikmp.ui.gallery import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import moe.uni.comfy.comfyuikmp.di.AppGraph +import moe.uni.comfy.comfyuikmp.ui.AppViewModel @OptIn(ExperimentalFoundationApi::class) @Composable -fun GalleryScreen(contentPadding: PaddingValues, images: List) { - if (images.isEmpty()) { +fun GalleryScreen(contentPadding: PaddingValues, viewModel: AppViewModel) { + val history by viewModel.historyImages.collectAsState() + + // 初次进入时拉取最新历史 + LaunchedEffect(Unit) { + viewModel.getHistoryImages(64) + } + + if (history.isEmpty()) { Text("暂无图片", modifier = Modifier.padding(contentPadding).padding(16.dp)) return } - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 160.dp), - contentPadding = contentPadding - ) { - items(images) { img -> - Image( - bitmap = img, - contentDescription = null, - modifier = Modifier - .padding(6.dp) - .aspectRatio(1f) - ) + val base = AppGraph.settings.getBaseUrl() ?: "" + + LazyColumn(contentPadding = contentPadding, modifier = Modifier.padding(horizontal = 16.dp)) { + items(history, key = { it.filename }) { img -> + val url = buildString { + append(base) + append("/view?filename=") + append(img.filename) + img.subfolder?.let { + append("&subfolder=") + append(it) + } + img.type?.let { + append("&type=") + append(it) + } + } + + Card(modifier = Modifier.fillMaxWidth()) { + AsyncImage( + model = url, + contentDescription = null, + modifier = Modifier.fillMaxWidth(), + ) + } + Spacer(Modifier.height(16.dp)) + } } } diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/generate/GenerateScreen.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/generate/GenerateScreen.kt new file mode 100644 index 0000000..e009eda --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/generate/GenerateScreen.kt @@ -0,0 +1,202 @@ +package moe.uni.comfy.comfyuikmp.ui.generate + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.ui.Alignment +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import moe.uni.comfy.comfyuikmp.data.model.ComfyPrompt +import moe.uni.comfy.comfyuikmp.data.model.updateNodeStringInput +import moe.uni.comfy.comfyuikmp.data.model.updateNodePrimitiveInput +import moe.uni.comfy.comfyuikmp.data.network.ProgressUpdate + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GenerateScreen( + contentPadding: PaddingValues, + prompt: ComfyPrompt, + latestImage: ImageBitmap?, + progress: ProgressUpdate? = null, + useRandom: Boolean, + onPromptChange: (ComfyPrompt) -> Unit, + onRandomSwitchChange: (Boolean) -> Unit, +) { + val state = rememberBottomSheetScaffoldState() + Scaffold(modifier = Modifier.padding(contentPadding)) { + BottomSheetScaffold( + sheetPeekHeight = 56.dp, + scaffoldState = state, + sheetContent = { + LazyColumn(modifier = Modifier.padding(16.dp)) { + items(prompt.keys.toList()) { nodeId -> + val node = prompt[nodeId] + val title = node?.meta?.title ?: nodeId + Text( + title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp) + ) + node?.inputs?.forEach { (key, value: JsonElement) -> + var inputEnable by remember { mutableStateOf(true) } + if (value is JsonPrimitive) { + when { + value.isString -> { + var field by remember(nodeId, key) { mutableStateOf(value.content) } + OutlinedTextField( + enabled = inputEnable, + value = field, + onValueChange = { + field = it + onPromptChange(updateNodeStringInput(prompt, nodeId, key, it)) + }, + label = { Text(key) }, + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth() + ) + } + + value.booleanOrNull != null -> { + var checked by remember( + nodeId, + key + ) { mutableStateOf(value.booleanOrNull == true) } + Switch( + enabled = inputEnable, + checked = checked, + onCheckedChange = { + checked = it + onPromptChange( + updateNodePrimitiveInput( + prompt, + nodeId, + key, + JsonPrimitive(it) + ) + ) + }, + ) + } + + value.intOrNull != null || value.doubleOrNull != null -> { + var text by remember(nodeId, key) { mutableStateOf(value.toString()) } + OutlinedTextField( + enabled = inputEnable, + value = text, + onValueChange = { + text = it + it.toIntOrNull()?.let { num -> + onPromptChange( + updateNodePrimitiveInput( + prompt, + nodeId, + key, + JsonPrimitive(num) + ) + ) + } ?: it.toDoubleOrNull()?.let { d -> + onPromptChange( + updateNodePrimitiveInput( + prompt, + nodeId, + key, + JsonPrimitive(d) + ) + ) + } + }, + label = { Text(key) }, + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth() + ) + + //KSampler + if (node.classType == "KSampler" && key.equals("seed", ignoreCase = true)) { + Row(modifier = Modifier.padding(top = 8.dp)) { + Spacer(Modifier.width(8.dp)) + Switch(useRandom, onCheckedChange = { + inputEnable = !it + onRandomSwitchChange(it) + }) + Text("自动生成随机种子", style = MaterialTheme.typography.bodySmall) + } + } + } + + else -> { + OutlinedTextField( + enabled = inputEnable, + value = value.toString(), + onValueChange = {}, + readOnly = true, + label = { Text(key) }, + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth() + ) + } + } + } else { + OutlinedTextField( + value = value.toString(), + onValueChange = {}, + readOnly = true, + label = { Text(key) }, + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth() + ) + } + } + } + } + }, + ) { innerPadding -> + Box(modifier = Modifier.padding(innerPadding)) { + if (progress != null && progress.fraction != 0f && progress.message != "success") { + LinearProgressIndicator(progress = { progress.fraction ?: 0f }, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) + if (progress.message != null) Text(progress.message, style = MaterialTheme.typography.bodySmall, modifier = Modifier.align( + Alignment.Center)) + } + + if (latestImage != null) { + Image( + bitmap = latestImage, + contentDescription = null, + modifier = Modifier.fillMaxWidth().padding(top = 32.dp) + ) + } + } + } + } + + +} + + diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/home/HomeScreen.kt index 8225894..e7fe74c 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/home/HomeScreen.kt @@ -8,6 +8,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -25,12 +28,7 @@ import androidx.compose.material3.Icon import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue -import compose.icons.FontAwesomeIcons -import compose.icons.fontawesomeicons.Brands -import compose.icons.fontawesomeicons.Regular -import compose.icons.fontawesomeicons.brands.FontAwesome -import compose.icons.fontawesomeicons.regular.Edit -import compose.icons.fontawesomeicons.regular.TrashAlt + @Composable fun HomeScreen( @@ -58,9 +56,9 @@ fun HomeScreen( ) { Text(w.name, modifier = Modifier.padding(16.dp), style = MaterialTheme.typography.titleMedium) Row(modifier = Modifier.padding(horizontal = 8.dp)) { - IconButton(onClick = { renaming = w; newName = w.name }) { Icon(FontAwesomeIcons.Regular.Edit, contentDescription = null, + IconButton(onClick = { renaming = w; newName = w.name }) { Icon(Icons.Filled.Edit, contentDescription = null, Modifier.size(24.dp)) } - IconButton(onClick = { AppGraph.workflows.delete(w.id) }) { Icon(FontAwesomeIcons.Regular.TrashAlt, contentDescription = null, + IconButton(onClick = { AppGraph.workflows.delete(w.id) }) { Icon(Icons.Filled.Delete, contentDescription = null, Modifier.size(24.dp)) } } } diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/importer/FileImportFab.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/importer/FileImportFab.kt index 996f998..7ce4c99 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/importer/FileImportFab.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/importer/FileImportFab.kt @@ -7,3 +7,4 @@ import moe.uni.comfy.comfyuikmp.data.model.ComfyPrompt expect fun FileImportFab(onImported: (ComfyPrompt) -> Unit, onError: (String) -> Unit) + diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/importer/FilePicker.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/importer/FilePicker.kt index 3751beb..7008f35 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/importer/FilePicker.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/importer/FilePicker.kt @@ -6,3 +6,4 @@ import androidx.compose.runtime.Composable expect fun rememberJsonFilePicker(onPicked: (name: String, content: String) -> Unit): () -> Unit + diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/navigation/Nav.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/navigation/Nav.kt index 2091d29..7da9bad 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/navigation/Nav.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/navigation/Nav.kt @@ -1,11 +1,48 @@ package moe.uni.comfy.comfyuikmp.ui.navigation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember + enum class Dest { Home, Settings, Import, - Editor, + Generate, Gallery } +@Stable +class NavController(startDestination: Dest) { + private val backstack = mutableStateListOf(startDestination) + + val current: State = derivedStateOf { backstack.last() } + + val canPop: Boolean + get() = backstack.size > 1 + + fun navigate(dest: Dest) { + if (backstack.isEmpty() || backstack.last() != dest) { + backstack.add(dest) + } + } + + fun pop(): Boolean { + if (canPop) { + backstack.removeAt(backstack.lastIndex) + return true + } + return false + } +} + +@Composable +fun rememberNavController(startDestination: Dest): NavController { + return remember(startDestination) { NavController(startDestination) } +} + diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/settings/SettingsScreen.kt index 20cfb3a..20f3652 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/ui/settings/SettingsScreen.kt @@ -33,3 +33,4 @@ fun SettingsScreen(contentPadding: PaddingValues, onSave: () -> Unit) { } + diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/util/ViewModelFactory.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/util/ViewModelFactory.kt new file mode 100644 index 0000000..9222e26 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfy/comfyuikmp/util/ViewModelFactory.kt @@ -0,0 +1,11 @@ +package moe.uni.comfy.comfyuikmp.util + +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import moe.uni.comfy.comfyuikmp.ui.AppViewModel + +val mainViewModelFactory = viewModelFactory { + initializer { + AppViewModel() + } +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/moe/uni/comfy/comfyuikmp/data/settings/KeyValueStore.ios.kt b/composeApp/src/iosMain/kotlin/moe/uni/comfy/comfyuikmp/data/settings/KeyValueStore.ios.kt index 6a98426..6ca84e6 100644 --- a/composeApp/src/iosMain/kotlin/moe/uni/comfy/comfyuikmp/data/settings/KeyValueStore.ios.kt +++ b/composeApp/src/iosMain/kotlin/moe/uni/comfy/comfyuikmp/data/settings/KeyValueStore.ios.kt @@ -16,3 +16,4 @@ actual object KeyValueStore { } + diff --git a/composeApp/src/iosMain/kotlin/moe/uni/comfy/comfyuikmp/ui/importer/FileImportFab.ios.kt b/composeApp/src/iosMain/kotlin/moe/uni/comfy/comfyuikmp/ui/importer/FileImportFab.ios.kt index 845555d..cfe1bf2 100644 --- a/composeApp/src/iosMain/kotlin/moe/uni/comfy/comfyuikmp/ui/importer/FileImportFab.ios.kt +++ b/composeApp/src/iosMain/kotlin/moe/uni/comfy/comfyuikmp/ui/importer/FileImportFab.ios.kt @@ -17,3 +17,4 @@ actual fun FileImportFab(onImported: (ComfyPrompt) -> Unit, onError: (String) -> } + diff --git a/composeApp/src/iosMain/kotlin/moe/uni/comfy/comfyuikmp/util/ImageDecode.ios.kt b/composeApp/src/iosMain/kotlin/moe/uni/comfy/comfyuikmp/util/ImageDecode.ios.kt index fe108ec..5022d1b 100644 --- a/composeApp/src/iosMain/kotlin/moe/uni/comfy/comfyuikmp/util/ImageDecode.ios.kt +++ b/composeApp/src/iosMain/kotlin/moe/uni/comfy/comfyuikmp/util/ImageDecode.ios.kt @@ -8,3 +8,4 @@ actual fun decodeImage(bytes: ByteArray): ImageBitmap? = runCatching { Image.makeFromEncoded(bytes).toComposeImageBitmap() }.getOrNull() + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b942e3c..1f86c03 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,8 @@ junit = "4.13.2" kotlin = "2.2.20" ktor = "3.0.0" serialization = "1.7.3" +androidx-viewmodel = "2.9.4" + [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -34,6 +36,7 @@ ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "kto ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } +androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-viewmodel" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 5ce20c3..5c9f892 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -167,6 +167,94 @@ /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ + 1421DB7F95CDAE57944E10C9 /* 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 = NLHG3FFX4L; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Comfy; + 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", + ); + PRODUCT_BUNDLE_IDENTIFIER = moe.uni.comfy.comfyuikmp; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 35E05C7FC47D3052DBB14A86 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = CF9414654D7F1C0CBABF3105 /* 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; + }; B418F86C9121F76AC29E0C68 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = CF9414654D7F1C0CBABF3105 /* Configuration */; @@ -232,92 +320,6 @@ }; name = Debug; }; - 35E05C7FC47D3052DBB14A86 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReferenceAnchor = CF9414654D7F1C0CBABF3105 /* 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; - }; - 1421DB7F95CDAE57944E10C9 /* 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; - }; CF145A26F4A0096FF05CC5ED /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -327,10 +329,11 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; + DEVELOPMENT_TEAM = NLHG3FFX4L; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Comfy; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -340,6 +343,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + PRODUCT_BUNDLE_IDENTIFIER = moe.uni.comfy.comfyuikmp; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -349,15 +353,6 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - CE072D131AB21E31A22C0C65 /* Build configuration list for PBXProject "iosApp" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - B418F86C9121F76AC29E0C68 /* Debug */, - 35E05C7FC47D3052DBB14A86 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; C5F0489CAF21F1B9DDCDE9EC /* Build configuration list for PBXNativeTarget "iosApp" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -367,7 +362,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + CE072D131AB21E31A22C0C65 /* Build configuration list for PBXProject "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B418F86C9121F76AC29E0C68 /* Debug */, + 35E05C7FC47D3052DBB14A86 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = D3A0FA2F57232E5614C0AC86 /* Project object */; -} \ No newline at end of file +} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 65e9740..c7e6844 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -1,15 +1,15 @@ - - CADisableMinimumFrameDurationOnPhone - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - NSAllowsLocalNetworking - - - + + CADisableMinimumFrameDurationOnPhone + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + +