feat: dynamic layout

This commit is contained in:
anran
2026-02-01 00:12:46 +08:00
parent e5a779e48a
commit 0c3ef2f6b4
8 changed files with 1658 additions and 463 deletions
@@ -0,0 +1,117 @@
package moe.uni.comfyKmp.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import moe.uni.comfyKmp.ui.theme.AdaptiveLayoutConstants
import moe.uni.comfyKmp.ui.theme.WindowSizeInfo
import moe.uni.comfyKmp.ui.theme.rememberWindowSizeInfo
/**
* 自适应双面板布局
*
* - Compact: 仅显示 listPane 或 detailPane(根据 showDetail 决定)
* - Medium/Expanded: 左右并排显示两个面板
*
* @param showDetail 在 Compact 模式下是否显示详情面板
* @param listPaneWidth 列表面板宽度(仅在非 Compact 模式下生效)
* @param listPane 列表面板内容
* @param detailPane 详情面板内容
* @param emptyDetailPane 当没有选中项时显示的占位内容
*/
@Composable
fun AdaptiveTwoPaneLayout(
modifier: Modifier = Modifier,
showDetail: Boolean = false,
listPaneWidth: Dp = AdaptiveLayoutConstants.listPanePreferredWidth,
listPane: @Composable (WindowSizeInfo) -> Unit,
detailPane: @Composable (WindowSizeInfo) -> Unit,
emptyDetailPane: @Composable (WindowSizeInfo) -> Unit = {}
) {
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val windowSizeInfo = rememberWindowSizeInfo(maxWidth, maxHeight)
if (windowSizeInfo.shouldUseTwoPane) {
// Expanded: 双面板布局
Row(modifier = Modifier.fillMaxSize()) {
// 左侧列表面板
Box(
modifier = Modifier
.width(listPaneWidth.coerceIn(
AdaptiveLayoutConstants.listPaneMinWidth,
AdaptiveLayoutConstants.listPaneMaxWidth
))
.fillMaxHeight()
) {
listPane(windowSizeInfo)
}
// 右侧详情面板
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
) {
if (showDetail) {
detailPane(windowSizeInfo)
} else {
emptyDetailPane(windowSizeInfo)
}
}
}
} else {
// Compact/Medium: 单面板布局
if (showDetail) {
detailPane(windowSizeInfo)
} else {
listPane(windowSizeInfo)
}
}
}
}
/**
* 自适应内容容器
*
* 在大屏幕上限制内容最大宽度并居中显示
*/
@Composable
fun AdaptiveContentContainer(
modifier: Modifier = Modifier,
maxWidth: Dp = AdaptiveLayoutConstants.cardMaxWidth,
content: @Composable () -> Unit
) {
Box(
modifier = modifier,
contentAlignment = Alignment.TopCenter
) {
Box(modifier = Modifier.widthIn(max = maxWidth)) {
content()
}
}
}
/**
* 计算网格列数
*
* @param availableWidth 可用宽度
* @param minItemWidth 每项最小宽度
* @param maxColumns 最大列数
*/
fun calculateGridColumns(
availableWidth: Dp,
minItemWidth: Dp = AdaptiveLayoutConstants.gridItemMinWidth,
maxColumns: Int = 3
): Int {
val columns = (availableWidth / minItemWidth).toInt()
return columns.coerceIn(1, maxColumns)
}
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
@@ -22,6 +23,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import moe.uni.comfyKmp.ui.theme.AdaptiveLayoutConstants
import moe.uni.comfyKmp.ui.theme.ComfySpacing
import moe.uni.comfyKmp.ui.theme.comfyColors
@@ -43,6 +45,7 @@ fun ComfyDialog(
) {
Surface(
modifier = Modifier
.widthIn(max = AdaptiveLayoutConstants.dialogMaxWidth)
.fillMaxWidth()
.clip(RoundedCornerShape(28.dp)),
color = MaterialTheme.comfyColors.cardBackground,
@@ -105,6 +108,7 @@ fun ComfyAlertDialog(
Dialog(onDismissRequest = onDismissRequest) {
Surface(
modifier = Modifier
.widthIn(max = AdaptiveLayoutConstants.dialogMaxWidth)
.fillMaxWidth()
.clip(RoundedCornerShape(28.dp)),
color = MaterialTheme.comfyColors.cardBackground,
@@ -14,16 +14,12 @@ import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -32,7 +28,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.dp
import moe.uni.comfyKmp.ui.theme.ComfySpacing
import moe.uni.comfyKmp.ui.theme.comfyColors
@@ -49,8 +44,6 @@ enum class ExecutionStatus {
fun ExecutionStatusBar(
status: ExecutionStatus,
statusText: String,
progress: Float? = null,
progressText: String? = null,
modifier: Modifier = Modifier
) {
val comfyColors = MaterialTheme.comfyColors
@@ -66,13 +59,10 @@ fun ExecutionStatusBar(
animationSpec = tween(300)
)
Column(
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = ComfySpacing.lg, vertical = ComfySpacing.md),
verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
) {
@@ -92,44 +82,6 @@ fun ExecutionStatusBar(
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
)
}
}
}
}
@@ -0,0 +1,107 @@
package moe.uni.comfyKmp.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import moe.uni.comfyKmp.ui.theme.ComfySpacing
/**
* Toast 消息数据
*/
data class ToastMessage(
val message: String,
val duration: Long = 2000L
)
/**
* Toast 状态管理
*/
class ToastState {
var currentMessage by mutableStateOf<ToastMessage?>(null)
private set
fun show(message: String, duration: Long = 2000L) {
currentMessage = ToastMessage(message, duration)
}
fun dismiss() {
currentMessage = null
}
}
@Composable
fun rememberToastState(): ToastState {
return remember { ToastState() }
}
/**
* Toast 容器 - 包裹内容并在底部显示 Toast
*/
@Composable
fun ToastHost(
state: ToastState,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Box(modifier = modifier.fillMaxSize()) {
content()
val message = state.currentMessage
LaunchedEffect(message) {
if (message != null) {
delay(message.duration)
state.dismiss()
}
}
AnimatedVisibility(
visible = message != null,
enter = fadeIn() + slideInVertically { it },
exit = fadeOut() + slideOutVertically { it },
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 100.dp)
) {
message?.let {
ToastContent(message = it.message)
}
}
}
}
@Composable
private fun ToastContent(message: String) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(24.dp))
.background(MaterialTheme.colorScheme.inverseSurface)
.padding(horizontal = ComfySpacing.lg, vertical = ComfySpacing.md)
) {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.inverseOnSurface
)
}
}
@@ -9,16 +9,19 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
@@ -68,8 +71,10 @@ import moe.uni.comfyKmp.di.LocalAppContainer
import moe.uni.comfyKmp.storage.generateId
import moe.uni.comfyKmp.ui.components.ComfyDialog
import moe.uni.comfyKmp.ui.components.GlassCard
import moe.uni.comfyKmp.ui.theme.AdaptiveLayoutConstants
import moe.uni.comfyKmp.ui.theme.ComfySpacing
import moe.uni.comfyKmp.ui.theme.comfyColors
import moe.uni.comfyKmp.ui.theme.rememberWindowSizeInfo
class ServerScreen : Screen {
@Composable
@@ -80,6 +85,7 @@ class ServerScreen : Screen {
var showDialog by remember { mutableStateOf(false) }
var editingServer by remember { mutableStateOf<ServerConfig?>(null) }
var selectedServerId by remember { mutableStateOf<String?>(null) }
if (showDialog) {
ServerEditDialog(
@@ -95,6 +101,57 @@ class ServerScreen : Screen {
val comfyColors = MaterialTheme.comfyColors
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val windowSizeInfo = rememberWindowSizeInfo(maxWidth, maxHeight)
if (windowSizeInfo.shouldUseTwoPane) {
// 大屏幕:双面板布局
ExpandedServerLayout(
model = model,
navigator = navigator,
comfyColors = comfyColors,
selectedServerId = selectedServerId,
onServerSelect = { selectedServerId = it },
onAddServer = {
editingServer = null
showDialog = true
},
onEditServer = { server ->
editingServer = server
showDialog = true
}
)
} else {
// 小屏幕:单面板布局
CompactServerLayout(
model = model,
navigator = navigator,
comfyColors = comfyColors,
onAddServer = {
editingServer = null
showDialog = true
},
onEditServer = { server ->
editingServer = server
showDialog = true
}
)
}
}
}
}
/**
* 小屏幕布局:单面板
*/
@Composable
private fun CompactServerLayout(
model: ServerScreenModel,
navigator: cafe.adriel.voyager.navigator.Navigator,
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors,
onAddServer: () -> Unit,
onEditServer: (ServerConfig) -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
@@ -108,10 +165,7 @@ class ServerScreen : Screen {
},
actions = {
FilledTonalButton(
onClick = {
editingServer = null
showDialog = true
},
onClick = onAddServer,
modifier = Modifier.padding(end = ComfySpacing.sm)
) {
Icon(
@@ -158,65 +212,93 @@ class ServerScreen : Screen {
floatingActionButtonPosition = FabPosition.End,
containerColor = MaterialTheme.colorScheme.background
) { padding ->
Box(
ServerListContent(
model = model,
comfyColors = comfyColors,
onAddServer = onAddServer,
onEditServer = onEditServer,
modifier = Modifier.padding(padding)
)
}
}
/**
* 大屏幕布局:双面板
*/
@Composable
private fun ExpandedServerLayout(
model: ServerScreenModel,
navigator: cafe.adriel.voyager.navigator.Navigator,
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors,
selectedServerId: String?,
onServerSelect: (String?) -> Unit,
onAddServer: () -> Unit,
onEditServer: (ServerConfig) -> Unit
) {
val selectedServer = model.servers.find { it.id == selectedServerId }
Row(modifier = Modifier.fillMaxSize()) {
// 左侧:服务器列表
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(
Brush.verticalGradient(
colors = listOf(
comfyColors.gradientStart,
comfyColors.gradientEnd
)
)
.widthIn(
min = AdaptiveLayoutConstants.listPaneMinWidth,
max = AdaptiveLayoutConstants.listPaneMaxWidth
)
.fillMaxHeight()
) {
Column(modifier = Modifier.fillMaxSize()) {
if (model.servers.isEmpty()) {
EmptyServerState(
onAdd = {
editingServer = null
showDialog = true
// 顶部栏
TopAppBar(
title = {
Text("Comfy KMP", style = MaterialTheme.typography.headlineSmall)
},
modifier = Modifier
.weight(1f)
.fillMaxWidth()
)
} else {
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(ComfySpacing.lg),
verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)
actions = {
FilledTonalButton(
onClick = onAddServer,
modifier = Modifier.padding(end = ComfySpacing.sm)
) {
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
Icon(Icons.Default.Add, null, Modifier.size(18.dp))
Spacer(Modifier.width(ComfySpacing.xs))
Text("添加")
}
},
onDelete = { model.delete(server.id) }
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
// 服务器列表
ServerListContent(
model = model,
comfyColors = comfyColors,
onAddServer = onAddServer,
onEditServer = onEditServer,
selectedServerId = selectedServerId,
onServerClick = { onServerSelect(it) },
modifier = Modifier.weight(1f)
)
}
// 右侧:详情面板
ServerDetailPanel(
server = selectedServer,
isActive = selectedServer?.id == model.activeServerId,
comfyColors = comfyColors,
onSelect = { selectedServer?.let { model.setActive(it.id) } },
onEdit = { selectedServer?.let { onEditServer(it) } },
onDelete = {
selectedServer?.let {
model.delete(it.id)
onServerSelect(null)
}
}
}
}
}
}
},
onEnterWorkflow = {
val activeId = model.activeServerId ?: return@ServerDetailPanel
navigator.push(WorkflowListScreen(activeId))
},
canEnterWorkflow = model.activeServerId != null,
modifier = Modifier.weight(1f).fillMaxHeight()
)
}
}
@@ -249,6 +331,233 @@ private class ServerScreenModel(
}
}
/**
* 服务器列表内容(可复用)
*/
@Composable
private fun ServerListContent(
model: ServerScreenModel,
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors,
onAddServer: () -> Unit,
onEditServer: (ServerConfig) -> Unit,
modifier: Modifier = Modifier,
selectedServerId: String? = null,
onServerClick: ((String) -> Unit)? = null
) {
Box(
modifier = modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
comfyColors.gradientStart,
comfyColors.gradientEnd
)
)
)
) {
if (model.servers.isEmpty()) {
EmptyServerState(
onAdd = onAddServer,
modifier = Modifier.fillMaxSize()
)
} else {
LazyColumn(
contentPadding = PaddingValues(ComfySpacing.lg),
verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)
) {
itemsIndexed(
items = model.servers,
key = { _, server -> server.id }
) { index, server ->
AnimatedVisibility(
visible = true,
enter = fadeIn(tween(300, delayMillis = index * 50)) +
slideInVertically(
initialOffsetY = { it / 2 },
animationSpec = tween(300, delayMillis = index * 50)
)
) {
ServerCard(
server = server,
isActive = server.id == model.activeServerId,
isSelected = server.id == selectedServerId,
onSelect = {
model.setActive(server.id)
onServerClick?.invoke(server.id)
},
onEdit = { onEditServer(server) },
onDelete = { model.delete(server.id) },
showActions = onServerClick == null
)
}
}
}
}
}
}
/**
* 大屏幕右侧详情面板
*/
@Composable
private fun ServerDetailPanel(
server: ServerConfig?,
isActive: Boolean,
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors,
onSelect: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
onEnterWorkflow: () -> Unit,
canEnterWorkflow: Boolean,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.background(comfyColors.cardBackground)
.padding(ComfySpacing.xl),
contentAlignment = Alignment.Center
) {
if (server == null) {
// 未选择服务器时的占位内容
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Dns,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.outline
)
Spacer(Modifier.height(ComfySpacing.lg))
Text(
"选择一个服务器查看详情",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
// 显示服务器详情
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.weight(1f))
// 服务器图标
Box(
modifier = Modifier
.size(96.dp)
.clip(CircleShape)
.background(
if (isActive) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Dns,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = if (isActive) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(ComfySpacing.xl))
// 服务器名称
Text(
text = server.name,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.height(ComfySpacing.sm))
// 服务器地址
Text(
text = server.baseUrl,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (server.isDefault) {
Spacer(Modifier.height(ComfySpacing.md))
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(horizontal = ComfySpacing.md, vertical = ComfySpacing.sm)
) {
Text(
"默认服务器",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
Spacer(Modifier.height(ComfySpacing.xxl))
// 操作按钮
Row(horizontalArrangement = Arrangement.spacedBy(ComfySpacing.md)) {
OutlinedButton(
onClick = onSelect,
enabled = !isActive
) {
Icon(
if (isActive) Icons.Default.Check else Icons.Default.RadioButtonUnchecked,
null, Modifier.size(18.dp)
)
Spacer(Modifier.width(ComfySpacing.sm))
Text(if (isActive) "已选择" else "选择")
}
OutlinedButton(onClick = onEdit) {
Icon(Icons.Default.Edit, null, Modifier.size(18.dp))
Spacer(Modifier.width(ComfySpacing.sm))
Text("编辑")
}
OutlinedButton(
onClick = onDelete,
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(Icons.Default.Delete, null, Modifier.size(18.dp))
Spacer(Modifier.width(ComfySpacing.sm))
Text("删除")
}
}
Spacer(Modifier.weight(1f))
// 进入工作流按钮
ExtendedFloatingActionButton(
onClick = onEnterWorkflow,
icon = {
Icon(Icons.AutoMirrored.Filled.ArrowForward, null, Modifier.size(20.dp))
},
text = { Text("进入工作流") },
containerColor = if (canEnterWorkflow) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
contentColor = if (canEnterWorkflow) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
}
}
}
@Composable
private fun ServerEditDialog(
initial: ServerConfig?,
@@ -321,7 +630,9 @@ private fun ServerCard(
isActive: Boolean,
onSelect: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit
onDelete: () -> Unit,
isSelected: Boolean = false,
showActions: Boolean = true
) {
GlassCard(
modifier = Modifier.fillMaxWidth(),
@@ -386,7 +697,8 @@ private fun ServerCard(
}
}
// Actions
// Actions - 仅在小屏幕模式下显示
if (showActions) {
Row(
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
) {
@@ -443,6 +755,7 @@ private fun ServerCard(
}
}
}
}
@Composable
private fun EmptyServerState(
@@ -9,18 +9,22 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -65,14 +69,18 @@ import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import moe.uni.comfyKmp.data.WorkflowEntity
import moe.uni.comfyKmp.data.extractPromptObject
import moe.uni.comfyKmp.data.loadCoverImageBytes
import moe.uni.comfyKmp.data.parsePromptNodes
import moe.uni.comfyKmp.di.LocalAppContainer
import moe.uni.comfyKmp.isFilePickerSupported
import moe.uni.comfyKmp.rememberFilePickerLauncher
import moe.uni.comfyKmp.storage.generateId
import moe.uni.comfyKmp.ui.components.ComfyDialog
import moe.uni.comfyKmp.ui.theme.AdaptiveLayoutConstants
import moe.uni.comfyKmp.ui.theme.ComfySpacing
import moe.uni.comfyKmp.ui.theme.comfyColors
import moe.uni.comfyKmp.ui.theme.rememberWindowSizeInfo
import org.jetbrains.compose.resources.decodeToImageBitmap
import kotlin.time.Clock
@@ -89,6 +97,7 @@ data class WorkflowListScreen(val serverId: String) : Screen {
}
var showImport by remember { mutableStateOf(false) }
var selectedWorkflowId by remember { mutableStateOf<String?>(null) }
if (showImport) {
WorkflowImportDialog(
@@ -102,6 +111,43 @@ data class WorkflowListScreen(val serverId: String) : Screen {
val comfyColors = MaterialTheme.comfyColors
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val windowSizeInfo = rememberWindowSizeInfo(maxWidth, maxHeight)
if (windowSizeInfo.shouldUseTwoPane) {
// 大屏幕:双面板布局
ExpandedWorkflowListLayout(
model = model,
navigator = navigator,
comfyColors = comfyColors,
selectedWorkflowId = selectedWorkflowId,
onWorkflowSelect = { selectedWorkflowId = it },
onImport = { showImport = true }
)
} else {
// 小屏幕:单面板布局
CompactWorkflowListLayout(
model = model,
navigator = navigator,
comfyColors = comfyColors,
onImport = { showImport = true }
)
}
}
}
}
/**
* 小屏幕布局:单面板
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CompactWorkflowListLayout(
model: WorkflowListScreenModel,
navigator: cafe.adriel.voyager.navigator.Navigator,
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors,
onImport: () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
@@ -117,23 +163,16 @@ data class WorkflowListScreen(val serverId: String) : Screen {
},
navigationIcon = {
IconButton(onClick = { navigator.pop() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "返回"
)
Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回")
}
},
actions = {
FilledTonalButton(
onClick = { showImport = true },
onClick = onImport,
modifier = Modifier.padding(end = ComfySpacing.sm)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(ComfySpacing.xs))
Icon(Icons.Default.Add, null, Modifier.size(18.dp))
Spacer(Modifier.width(ComfySpacing.xs))
Text("导入")
}
},
@@ -144,52 +183,97 @@ data class WorkflowListScreen(val serverId: String) : Screen {
},
containerColor = MaterialTheme.colorScheme.background
) { padding ->
Box(
WorkflowListContent(
model = model,
comfyColors = comfyColors,
onImport = onImport,
onWorkflowClick = { navigator.push(WorkflowRunScreen(it)) },
modifier = Modifier.padding(padding)
)
}
}
/**
* 大屏幕布局:双面板
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ExpandedWorkflowListLayout(
model: WorkflowListScreenModel,
navigator: cafe.adriel.voyager.navigator.Navigator,
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors,
selectedWorkflowId: String?,
onWorkflowSelect: (String?) -> Unit,
onImport: () -> Unit
) {
val selectedWorkflow = model.workflows.find { it.id == selectedWorkflowId }
Row(modifier = Modifier.fillMaxSize()) {
// 左侧:工作流列表
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(
Brush.verticalGradient(
colors = listOf(
comfyColors.gradientStart,
comfyColors.gradientEnd
)
)
.widthIn(
min = AdaptiveLayoutConstants.listPaneMinWidth,
max = AdaptiveLayoutConstants.listPaneMaxWidth
)
.fillMaxHeight()
) {
if (model.workflows.isEmpty()) {
EmptyWorkflowState(
onImport = { showImport = true },
modifier = Modifier.align(Alignment.Center)
TopAppBar(
title = {
Column {
Text("工作流")
Text(
"${model.workflows.size} 个工作流",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
LazyColumn(
contentPadding = PaddingValues(ComfySpacing.lg),
verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)
}
},
navigationIcon = {
IconButton(onClick = { navigator.pop() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回")
}
},
actions = {
FilledTonalButton(
onClick = onImport,
modifier = Modifier.padding(end = ComfySpacing.sm)
) {
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)
Icon(Icons.Default.Add, null, Modifier.size(18.dp))
Spacer(Modifier.width(ComfySpacing.xs))
Text("导入")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
WorkflowCard(
workflow = workflow,
onRun = { navigator.push(WorkflowRunScreen(workflow.id)) },
onDelete = { model.deleteWorkflow(workflow.id) }
)
WorkflowListContent(
model = model,
comfyColors = comfyColors,
onImport = onImport,
onWorkflowClick = { onWorkflowSelect(it) },
selectedWorkflowId = selectedWorkflowId,
modifier = Modifier.weight(1f)
)
}
// 右侧:详情面板
WorkflowDetailPanel(
workflow = selectedWorkflow,
comfyColors = comfyColors,
onRun = {
selectedWorkflow?.let { navigator.push(WorkflowRunScreen(it.id)) }
},
onDelete = {
selectedWorkflow?.let {
model.deleteWorkflow(it.id)
onWorkflowSelect(null)
}
}
}
}
}
},
modifier = Modifier.weight(1f).fillMaxHeight()
)
}
}
@@ -222,6 +306,318 @@ private class WorkflowListScreenModel(
}
}
/**
* 工作流列表内容(可复用)
*/
@Composable
private fun WorkflowListContent(
model: WorkflowListScreenModel,
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors,
onImport: () -> Unit,
onWorkflowClick: (String) -> Unit,
modifier: Modifier = Modifier,
selectedWorkflowId: String? = null
) {
Box(
modifier = modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
comfyColors.gradientStart,
comfyColors.gradientEnd
)
)
)
) {
if (model.workflows.isEmpty()) {
EmptyWorkflowState(
onImport = onImport,
modifier = Modifier.align(Alignment.Center)
)
} else {
LazyColumn(
contentPadding = PaddingValues(ComfySpacing.lg),
verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)
) {
itemsIndexed(
items = model.workflows,
key = { _, workflow -> workflow.id }
) { index, workflow ->
AnimatedVisibility(
visible = true,
enter = fadeIn(tween(300, delayMillis = index * 50)) +
slideInVertically(
initialOffsetY = { it / 2 },
animationSpec = tween(300, delayMillis = index * 50)
)
) {
WorkflowCard(
workflow = workflow,
isSelected = workflow.id == selectedWorkflowId,
onRun = { onWorkflowClick(workflow.id) },
onDelete = { model.deleteWorkflow(workflow.id) },
showActions = selectedWorkflowId == null
)
}
}
}
}
}
}
/**
* 大屏幕右侧详情面板
*/
@Composable
private fun WorkflowDetailPanel(
workflow: WorkflowEntity?,
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors,
onRun: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier
) {
// 解析节点列表
val nodes = remember(workflow?.json) {
workflow?.json?.let { json ->
try {
val prompt = extractPromptObject(json)
parsePromptNodes(prompt)
} catch (e: Exception) { emptyList() }
} ?: emptyList()
}
// 加载封面图片
val coverBitmap = remember(workflow?.coverImage) {
workflow?.coverImage?.let { cover ->
try {
loadCoverImageBytes(cover)?.decodeToImageBitmap()
} catch (e: Exception) { null }
}
}
Box(
modifier = modifier.background(comfyColors.cardBackground),
contentAlignment = Alignment.Center
) {
if (workflow == null) {
// 未选择工作流时的占位内容
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Assignment,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.outline
)
Spacer(Modifier.height(ComfySpacing.lg))
Text(
"选择一个工作流查看详情",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
// 显示工作流详情
Column(modifier = Modifier.fillMaxSize()) {
// 顶部信息区域
WorkflowDetailHeader(
workflow = workflow,
coverBitmap = coverBitmap,
nodeCount = nodes.size,
onRun = onRun,
onDelete = onDelete
)
// 节点列表
WorkflowNodeList(
nodes = nodes,
modifier = Modifier.weight(1f)
)
}
}
}
}
/**
* 工作流详情头部
*/
@Composable
private fun WorkflowDetailHeader(
workflow: WorkflowEntity,
coverBitmap: androidx.compose.ui.graphics.ImageBitmap?,
nodeCount: Int,
onRun: () -> Unit,
onDelete: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(ComfySpacing.lg),
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.lg),
verticalAlignment = Alignment.Top
) {
// 封面图片(小尺寸)
Box(
modifier = Modifier
.size(120.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
if (coverBitmap != null) {
Image(
bitmap = coverBitmap,
contentDescription = workflow.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
} else {
Icon(
Icons.Default.Image, null,
Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.5f)
)
}
}
// 信息区域
Column(modifier = Modifier.weight(1f)) {
Text(
text = workflow.name,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.height(ComfySpacing.xs))
Text(
text = "更新于 ${formatTimestamp(workflow.updatedAt)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(ComfySpacing.xs))
Text(
text = "$nodeCount 个节点",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(ComfySpacing.md))
// 操作按钮
Row(horizontalArrangement = Arrangement.spacedBy(ComfySpacing.sm)) {
Button(
onClick = onRun,
modifier = Modifier.height(36.dp),
contentPadding = PaddingValues(horizontal = ComfySpacing.lg)
) {
Text("运行", style = MaterialTheme.typography.labelMedium)
}
OutlinedButton(
onClick = onDelete,
modifier = Modifier.height(36.dp),
contentPadding = PaddingValues(horizontal = ComfySpacing.md),
colors = androidx.compose.material3.ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(Icons.Default.Delete, null, Modifier.size(16.dp))
}
}
}
}
}
/**
* 节点列表
*/
@Composable
private fun WorkflowNodeList(
nodes: List<moe.uni.comfyKmp.data.PromptNodeEntry>,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
// 标题
Text(
text = "节点列表",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = ComfySpacing.lg, vertical = ComfySpacing.sm)
)
if (nodes.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
"无法解析节点",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
contentPadding = PaddingValues(
start = ComfySpacing.lg,
end = ComfySpacing.lg,
bottom = ComfySpacing.lg
),
verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
) {
items(nodes, key = { it.id }) { node ->
NodeListItem(node = node)
}
}
}
}
}
@Composable
private fun NodeListItem(node: moe.uni.comfyKmp.data.PromptNodeEntry) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.padding(horizontal = ComfySpacing.md, vertical = ComfySpacing.sm),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// 节点类型
Text(
text = node.classType,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
// 节点 ID
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
.padding(horizontal = ComfySpacing.sm, vertical = 2.dp)
) {
Text(
text = "#${node.id}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun WorkflowImportDialog(
onDismiss: () -> Unit,
@@ -310,7 +706,9 @@ private fun WorkflowImportDialog(
private fun WorkflowCard(
workflow: WorkflowEntity,
onRun: () -> Unit,
onDelete: () -> Unit
onDelete: () -> Unit,
isSelected: Boolean = false,
showActions: Boolean = true
) {
val shape = RoundedCornerShape(16.dp)
val comfyColors = MaterialTheme.comfyColors
@@ -330,9 +728,16 @@ private fun WorkflowCard(
Box(
modifier = Modifier
.fillMaxWidth()
.shadow(4.dp, shape)
.shadow(if (isSelected) 8.dp else 4.dp, shape)
.clip(shape)
.background(comfyColors.cardBackground)
.then(
if (isSelected) {
Modifier.background(
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.1f)
)
} else Modifier
)
.clickable(onClick = onRun)
) {
Column(modifier = Modifier.fillMaxWidth()) {
@@ -394,7 +799,7 @@ private fun WorkflowCard(
) {
// Title and timestamp
Column {
Column(modifier = Modifier.weight(1f)) {
Text(
text = workflow.name,
style = MaterialTheme.typography.titleMedium,
@@ -409,6 +814,7 @@ private fun WorkflowCard(
)
}
if (showActions) {
FilledTonalIconButton(
onClick = onDelete,
colors = IconButtonDefaults.filledTonalIconButtonColors(
@@ -427,6 +833,7 @@ private fun WorkflowCard(
}
}
}
}
@Composable
private fun EmptyWorkflowState(
@@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -19,6 +21,7 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -93,10 +96,15 @@ import moe.uni.comfyKmp.ui.components.ExecutionStatus
import moe.uni.comfyKmp.ui.components.ExecutionStatusBar
import moe.uni.comfyKmp.ui.components.GalleryImage
import moe.uni.comfyKmp.ui.components.ImageGallery
import moe.uni.comfyKmp.ui.components.ToastHost
import moe.uni.comfyKmp.ui.components.ToastState
import moe.uni.comfyKmp.ui.components.detectNodeType
import moe.uni.comfyKmp.ui.components.getModelFolder
import moe.uni.comfyKmp.ui.components.rememberToastState
import moe.uni.comfyKmp.ui.theme.ComfySpacing
import moe.uni.comfyKmp.ui.theme.WindowSizeInfo
import moe.uni.comfyKmp.ui.theme.comfyColors
import moe.uni.comfyKmp.ui.theme.rememberWindowSizeInfo
import org.jetbrains.compose.resources.decodeToImageBitmap
import kotlin.random.Random
import kotlin.time.Clock
@@ -107,13 +115,15 @@ data class WorkflowRunScreen(val workflowId: String) : Screen {
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val container = LocalAppContainer.current
val toastState = rememberToastState()
val model = rememberScreenModel {
WorkflowRunScreenModel(
workflowId = workflowId,
workflowRepository = container.workflowRepository,
apiClient = container.apiClient,
wsClient = container.wsClient,
serverRepository = container.serverRepository
serverRepository = container.serverRepository,
onToast = { toastState.show(it) }
)
}
@@ -121,14 +131,46 @@ data class WorkflowRunScreen(val workflowId: String) : Screen {
model.connect()
}
val comfyColors = MaterialTheme.comfyColors
ToastHost(state = toastState) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val windowSizeInfo = rememberWindowSizeInfo(maxWidth, maxHeight)
if (windowSizeInfo.shouldUseTwoPane) {
ExpandedWorkflowRunLayout(
model = model,
navigator = navigator,
comfyColors = comfyColors
)
} else {
CompactWorkflowRunLayout(
model = model,
navigator = navigator,
comfyColors = comfyColors
)
}
}
}
}
}
/**
* 小屏幕布局:底部面板
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CompactWorkflowRunLayout(
model: WorkflowRunScreenModel,
navigator: cafe.adriel.voyager.navigator.Navigator,
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors
) {
val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(
initialValue = SheetValue.PartiallyExpanded,
skipHiddenState = false
)
)
val comfyColors = MaterialTheme.comfyColors
val sheetPeekHeight = 120.dp
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
@@ -181,9 +223,7 @@ data class WorkflowRunScreen(val workflowId: String) : Screen {
WorkflowRunTopBar(
onBack = { navigator.pop() },
status = model.executionStatus,
statusText = model.statusText,
progress = model.progress,
progressText = model.progressText
statusText = model.statusText
)
},
containerColor = MaterialTheme.colorScheme.background
@@ -249,6 +289,147 @@ data class WorkflowRunScreen(val workflowId: String) : Screen {
}
}
}
/**
* 大屏幕布局:左右分栏
* 左侧:图片预览区域
* 右侧:节点列表
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ExpandedWorkflowRunLayout(
model: WorkflowRunScreenModel,
navigator: cafe.adriel.voyager.navigator.Navigator,
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors
) {
Column(modifier = Modifier.fillMaxSize()) {
// 顶部栏
WorkflowRunTopBar(
onBack = { navigator.pop() },
status = model.executionStatus,
statusText = model.statusText
)
// 主内容区:左右分栏
Row(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
comfyColors.gradientStart,
comfyColors.gradientEnd
)
)
)
) {
// 左侧:图片预览区域
Box(
modifier = Modifier
.weight(0.6f)
.fillMaxHeight()
) {
ImageGallery(
images = model.galleryImages,
onImageClick = { index ->
model.getPreviewImage(index)?.let { preview ->
navigator.push(
ImagePreviewScreen(
preview.filename,
preview.bytes,
onSave = { model.saveImage(index) },
onSetCover = { model.setCoverImage(index) }
)
)
}
},
onSaveClick = { index -> model.saveImage(index) },
modifier = Modifier
.fillMaxSize()
.padding(ComfySpacing.lg)
)
// FAB 放在左侧区域的右下角
WorkflowRunFab(
isRunning = model.running,
onStart = { model.runWorkflow() },
onInterrupt = { model.interrupt() },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(ComfySpacing.lg)
)
}
// 右侧:节点列表面板
ExpandedNodeListPanel(
model = model,
comfyColors = comfyColors,
modifier = Modifier
.widthIn(min = 320.dp, max = 420.dp)
.fillMaxHeight()
)
}
}
}
/**
* 大屏幕右侧节点列表面板
*/
@Composable
private fun ExpandedNodeListPanel(
model: WorkflowRunScreenModel,
comfyColors: moe.uni.comfyKmp.ui.theme.ComfyExtendedColors,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.background(comfyColors.cardBackground)
) {
// 面板标题
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ComfySpacing.lg, vertical = ComfySpacing.md),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "节点状态 (${model.nodeStates.size})",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
// 节点列表
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = ComfySpacing.lg,
end = ComfySpacing.lg,
bottom = ComfySpacing.xl
),
verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)
) {
items(model.orderedNodeStates, key = { it.nodeId }) { node ->
EditableNodeCard(
nodeId = node.nodeId,
classType = node.classType,
status = node.status,
inputs = node.inputs,
isRandomSeedEnabled = model.randomSeedNodes.contains(node.nodeId),
modelOptions = model.modelChoices[node.nodeId] ?: emptyList(),
isLoadingModels = model.loadingModels.contains(node.nodeId),
onInputChange = { field, value ->
model.updateNodeInput(node.nodeId, field, value)
},
onRandomSeedToggle = { enabled ->
model.setRandomSeedMode(node.nodeId, enabled)
},
onRandomizeSeed = { model.randomizeSeed(node.nodeId) },
onLoadModels = { model.loadModelsForNode(node.nodeId) }
)
}
}
}
}
private data class PreviewImage(
@@ -261,9 +442,7 @@ private data class PreviewImage(
private fun WorkflowRunTopBar(
onBack: () -> Unit,
status: ExecutionStatus,
statusText: String,
progress: Float?,
progressText: String?
statusText: String
) {
Column(
modifier = Modifier
@@ -287,9 +466,7 @@ private fun WorkflowRunTopBar(
ExecutionStatusBar(
status = status,
statusText = statusText,
progress = progress,
progressText = progressText
statusText = statusText
)
}
}
@@ -383,7 +560,8 @@ private class WorkflowRunScreenModel(
private val workflowRepository: WorkflowRepository,
private val apiClient: ComfyApiClient,
private val wsClient: ComfyWebSocketClient,
private val serverRepository: ServerRepository
private val serverRepository: ServerRepository,
private val onToast: (String) -> Unit = {}
) : ScreenModel {
private var workflow = workflowRepository.getWorkflow(workflowId)
private val server = workflow?.let { serverRepository.getServers().firstOrNull { s -> s.id == it.serverId } }
@@ -814,9 +992,9 @@ private class WorkflowRunScreenModel(
screenModelScope.launch {
try {
val location = saveImageToGallery(bytes, ref.filename)
statusText = "已保存: $location"
onToast("已保存: $location")
} catch (e: Exception) {
statusText = "保存失败: ${e.message}"
onToast("保存失败: ${e.message}")
}
}
}
@@ -834,7 +1012,7 @@ private class WorkflowRunScreenModel(
)
workflowRepository.upsertWorkflow(updated)
workflow = updated
statusText = "已设置为封面"
onToast("已设置为封面")
}
private fun updateNodeStatus(nodeId: String, status: NodeStatus) {
@@ -0,0 +1,117 @@
package moe.uni.comfyKmp.ui.theme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Material 3 Window Width Size Classes
*
* - Compact: < 600dp (手机竖屏)
* - Medium: 600dp - 840dp (小平板、折叠屏、手机横屏)
* - Expanded: > 840dp (大平板、桌面)
*/
enum class WindowWidthSizeClass {
Compact,
Medium,
Expanded
}
/**
* Material 3 Window Height Size Classes
*/
enum class WindowHeightSizeClass {
Compact,
Medium,
Expanded
}
/**
* 窗口尺寸信息
*/
data class WindowSizeInfo(
val widthSizeClass: WindowWidthSizeClass,
val heightSizeClass: WindowHeightSizeClass,
val widthDp: Dp,
val heightDp: Dp
) {
val isCompact: Boolean get() = widthSizeClass == WindowWidthSizeClass.Compact
val isMedium: Boolean get() = widthSizeClass == WindowWidthSizeClass.Medium
val isExpanded: Boolean get() = widthSizeClass == WindowWidthSizeClass.Expanded
/** 是否应该使用双面板布局 */
val shouldUseTwoPane: Boolean get() = widthSizeClass == WindowWidthSizeClass.Expanded
/** 是否应该使用网格布局 */
val shouldUseGrid: Boolean get() = widthSizeClass != WindowWidthSizeClass.Compact
}
/**
* 根据宽度计算 Window Width Size Class
*/
fun calculateWidthSizeClass(widthDp: Dp): WindowWidthSizeClass {
return when {
widthDp < 600.dp -> WindowWidthSizeClass.Compact
widthDp < 840.dp -> WindowWidthSizeClass.Medium
else -> WindowWidthSizeClass.Expanded
}
}
/**
* 根据高度计算 Window Height Size Class
*/
fun calculateHeightSizeClass(heightDp: Dp): WindowHeightSizeClass {
return when {
heightDp < 480.dp -> WindowHeightSizeClass.Compact
heightDp < 900.dp -> WindowHeightSizeClass.Medium
else -> WindowHeightSizeClass.Expanded
}
}
/**
* 计算窗口尺寸信息
*
* 需要在 BoxWithConstraints 内部使用,传入 maxWidth 和 maxHeight
*/
@Composable
fun rememberWindowSizeInfo(maxWidth: Dp, maxHeight: Dp): WindowSizeInfo {
return remember(maxWidth, maxHeight) {
WindowSizeInfo(
widthSizeClass = calculateWidthSizeClass(maxWidth),
heightSizeClass = calculateHeightSizeClass(maxHeight),
widthDp = maxWidth,
heightDp = maxHeight
)
}
}
/**
* 响应式布局常量
*/
object AdaptiveLayoutConstants {
/** 列表面板最小宽度 */
val listPaneMinWidth = 300.dp
/** 列表面板最大宽度 */
val listPaneMaxWidth = 400.dp
/** 列表面板推荐宽度 */
val listPanePreferredWidth = 360.dp
/** 详情面板最小宽度 */
val detailPaneMinWidth = 400.dp
/** 卡片最大宽度 */
val cardMaxWidth = 480.dp
/** 对话框最大宽度 */
val dialogMaxWidth = 560.dp
/** 网格项最小宽度 */
val gridItemMinWidth = 280.dp
/** 网格项最大宽度 */
val gridItemMaxWidth = 400.dp
}