feat: dynamic layout
This commit is contained in:
@@ -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,69 +59,28 @@ fun ExecutionStatusBar(
|
||||
animationSpec = tween(300)
|
||||
)
|
||||
|
||||
Column(
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ComfySpacing.lg, vertical = ComfySpacing.md),
|
||||
verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
|
||||
) {
|
||||
// Animated status indicator
|
||||
StatusIndicator(status = status, color = statusColor)
|
||||
|
||||
// Status text with animation
|
||||
AnimatedContent(
|
||||
targetState = statusText,
|
||||
transitionSpec = {
|
||||
fadeIn(tween(200)) togetherWith fadeOut(tween(200))
|
||||
}
|
||||
) { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Progress text
|
||||
progressText?.let { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
// Animated status indicator
|
||||
StatusIndicator(status = status, color = statusColor)
|
||||
|
||||
// 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
|
||||
)
|
||||
// Status text with animation
|
||||
AnimatedContent(
|
||||
targetState = statusText,
|
||||
transitionSpec = {
|
||||
fadeIn(tween(200)) togetherWith fadeOut(tween(200))
|
||||
}
|
||||
) { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,131 +101,207 @@ class ServerScreen : Screen {
|
||||
|
||||
val comfyColors = MaterialTheme.comfyColors
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
"Comfy KMP",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
}
|
||||
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
|
||||
},
|
||||
actions = {
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
editingServer = null
|
||||
showDialog = true
|
||||
},
|
||||
modifier = Modifier.padding(end = ComfySpacing.sm)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ComfySpacing.xs))
|
||||
Text("添加服务器")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
val canEnter = model.activeServerId != null
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = {
|
||||
val activeId = model.activeServerId ?: return@ExtendedFloatingActionButton
|
||||
navigator.push(WorkflowListScreen(activeId))
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
},
|
||||
text = { Text("进入工作流") },
|
||||
containerColor = if (canEnter) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
contentColor = if (canEnter) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
onEditServer = { server ->
|
||||
editingServer = server
|
||||
showDialog = true
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButtonPosition = FabPosition.End,
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
comfyColors.gradientStart,
|
||||
comfyColors.gradientEnd
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
if (model.servers.isEmpty()) {
|
||||
EmptyServerState(
|
||||
onAdd = {
|
||||
editingServer = null
|
||||
showDialog = true
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(ComfySpacing.lg),
|
||||
verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = model.servers,
|
||||
key = { _, server -> server.id }
|
||||
) { index, server ->
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter = fadeIn(tween(300, delayMillis = index * 50)) +
|
||||
slideInVertically(
|
||||
initialOffsetY = { it / 2 },
|
||||
animationSpec = tween(300, delayMillis = index * 50)
|
||||
)
|
||||
) {
|
||||
ServerCard(
|
||||
server = server,
|
||||
isActive = server.id == model.activeServerId,
|
||||
onSelect = { model.setActive(server.id) },
|
||||
onEdit = {
|
||||
editingServer = server
|
||||
showDialog = true
|
||||
},
|
||||
onDelete = { model.delete(server.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
"Comfy KMP",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
FilledTonalButton(
|
||||
onClick = onAddServer,
|
||||
modifier = Modifier.padding(end = ComfySpacing.sm)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ComfySpacing.xs))
|
||||
Text("添加服务器")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
val canEnter = model.activeServerId != null
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = {
|
||||
val activeId = model.activeServerId ?: return@ExtendedFloatingActionButton
|
||||
navigator.push(WorkflowListScreen(activeId))
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
},
|
||||
text = { Text("进入工作流") },
|
||||
containerColor = if (canEnter) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
contentColor = if (canEnter) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButtonPosition = FabPosition.End,
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
) { padding ->
|
||||
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
|
||||
.widthIn(
|
||||
min = AdaptiveLayoutConstants.listPaneMinWidth,
|
||||
max = AdaptiveLayoutConstants.listPaneMaxWidth
|
||||
)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
// 顶部栏
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text("Comfy KMP", style = MaterialTheme.typography.headlineSmall)
|
||||
},
|
||||
actions = {
|
||||
FilledTonalButton(
|
||||
onClick = onAddServer,
|
||||
modifier = Modifier.padding(end = ComfySpacing.sm)
|
||||
) {
|
||||
Icon(Icons.Default.Add, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(ComfySpacing.xs))
|
||||
Text("添加")
|
||||
}
|
||||
},
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class ServerScreenModel(
|
||||
private val repository: moe.uni.comfyKmp.storage.ServerRepository
|
||||
) : ScreenModel {
|
||||
@@ -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,58 +697,60 @@ private fun ServerCard(
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onSelect,
|
||||
enabled = !isActive,
|
||||
contentPadding = PaddingValues(
|
||||
horizontal = ComfySpacing.md,
|
||||
vertical = ComfySpacing.sm
|
||||
)
|
||||
// Actions - 仅在小屏幕模式下显示
|
||||
if (showActions) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isActive) Icons.Default.Check else Icons.Default.RadioButtonUnchecked,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ComfySpacing.xs))
|
||||
Text(if (isActive) "已选择" else "选择")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onEdit,
|
||||
contentPadding = PaddingValues(
|
||||
horizontal = ComfySpacing.md,
|
||||
vertical = ComfySpacing.sm
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Edit,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ComfySpacing.xs))
|
||||
Text("编辑")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
TextButton(
|
||||
onClick = onDelete,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ComfySpacing.xs))
|
||||
Text("删除")
|
||||
OutlinedButton(
|
||||
onClick = onSelect,
|
||||
enabled = !isActive,
|
||||
contentPadding = PaddingValues(
|
||||
horizontal = ComfySpacing.md,
|
||||
vertical = ComfySpacing.sm
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isActive) Icons.Default.Check else Icons.Default.RadioButtonUnchecked,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ComfySpacing.xs))
|
||||
Text(if (isActive) "已选择" else "选择")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onEdit,
|
||||
contentPadding = PaddingValues(
|
||||
horizontal = ComfySpacing.md,
|
||||
vertical = ComfySpacing.sm
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Edit,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ComfySpacing.xs))
|
||||
Text("编辑")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
TextButton(
|
||||
onClick = onDelete,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ComfySpacing.xs))
|
||||
Text("删除")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+507
-100
@@ -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,97 +111,172 @@ data class WorkflowListScreen(val serverId: String) : Screen {
|
||||
|
||||
val comfyColors = MaterialTheme.comfyColors
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text("工作流")
|
||||
Text(
|
||||
text = "${model.workflows.size} 个工作流",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.pop() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "返回"
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
FilledTonalButton(
|
||||
onClick = { showImport = true },
|
||||
modifier = Modifier.padding(end = ComfySpacing.sm)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ComfySpacing.xs))
|
||||
Text("导入")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
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 }
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
comfyColors.gradientStart,
|
||||
comfyColors.gradientEnd
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
if (model.workflows.isEmpty()) {
|
||||
EmptyWorkflowState(
|
||||
onImport = { showImport = true },
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(ComfySpacing.lg),
|
||||
verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = model.workflows,
|
||||
key = { _, workflow -> workflow.id }
|
||||
) { index, workflow ->
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter = fadeIn(tween(300, delayMillis = index * 50)) +
|
||||
slideInVertically(
|
||||
initialOffsetY = { it / 2 },
|
||||
animationSpec = tween(300, delayMillis = index * 50)
|
||||
)
|
||||
) {
|
||||
WorkflowCard(
|
||||
workflow = workflow,
|
||||
onRun = { navigator.push(WorkflowRunScreen(workflow.id)) },
|
||||
onDelete = { model.deleteWorkflow(workflow.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 小屏幕布局:单面板
|
||||
*/
|
||||
@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(
|
||||
title = {
|
||||
Column {
|
||||
Text("工作流")
|
||||
Text(
|
||||
text = "${model.workflows.size} 个工作流",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.pop() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
FilledTonalButton(
|
||||
onClick = onImport,
|
||||
modifier = Modifier.padding(end = ComfySpacing.sm)
|
||||
) {
|
||||
Icon(Icons.Default.Add, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(ComfySpacing.xs))
|
||||
Text("导入")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
) { padding ->
|
||||
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
|
||||
.widthIn(
|
||||
min = AdaptiveLayoutConstants.listPaneMinWidth,
|
||||
max = AdaptiveLayoutConstants.listPaneMaxWidth
|
||||
)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text("工作流")
|
||||
Text(
|
||||
"${model.workflows.size} 个工作流",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.pop() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
FilledTonalButton(
|
||||
onClick = onImport,
|
||||
modifier = Modifier.padding(end = ComfySpacing.sm)
|
||||
) {
|
||||
Icon(Icons.Default.Add, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(ComfySpacing.xs))
|
||||
Text("导入")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class WorkflowListScreenModel(
|
||||
private val serverId: String,
|
||||
private val repository: moe.uni.comfyKmp.storage.WorkflowRepository
|
||||
@@ -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,18 +814,20 @@ private fun WorkflowCard(
|
||||
)
|
||||
}
|
||||
|
||||
FilledTonalIconButton(
|
||||
onClick = onDelete,
|
||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "删除",
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
if (showActions) {
|
||||
FilledTonalIconButton(
|
||||
onClick = onDelete,
|
||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "删除",
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+309
-131
@@ -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,129 +131,23 @@ data class WorkflowRunScreen(val workflowId: String) : Screen {
|
||||
model.connect()
|
||||
}
|
||||
|
||||
val scaffoldState = rememberBottomSheetScaffoldState(
|
||||
bottomSheetState = rememberStandardBottomSheetState(
|
||||
initialValue = SheetValue.PartiallyExpanded,
|
||||
skipHiddenState = false
|
||||
)
|
||||
)
|
||||
|
||||
val comfyColors = MaterialTheme.comfyColors
|
||||
val sheetPeekHeight = 120.dp
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val density = LocalDensity.current
|
||||
val layoutHeightPx = with(density) { maxHeight.toPx() }
|
||||
val peekHeightPx = with(density) { sheetPeekHeight.toPx() }
|
||||
|
||||
BottomSheetScaffold(
|
||||
scaffoldState = scaffoldState,
|
||||
sheetPeekHeight = sheetPeekHeight,
|
||||
sheetContainerColor = comfyColors.cardBackground,
|
||||
sheetContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
sheetShape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
sheetDragHandle = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(40.dp)
|
||||
.height(4.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "节点状态 (${model.nodeStates.size})",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
},
|
||||
sheetContent = {
|
||||
NodeListSheet(
|
||||
nodes = model.orderedNodeStates,
|
||||
randomSeedNodes = model.randomSeedNodes,
|
||||
modelChoices = model.modelChoices,
|
||||
loadingModels = model.loadingModels,
|
||||
onInputChange = { nodeId, field, value -> model.updateNodeInput(nodeId, field, value) },
|
||||
onRandomSeedToggle = { nodeId, enabled -> model.setRandomSeedMode(nodeId, enabled) },
|
||||
onRandomizeSeed = { nodeId -> model.randomizeSeed(nodeId) },
|
||||
onLoadModels = { nodeId -> model.loadModelsForNode(nodeId) },
|
||||
modifier = Modifier.navigationBarsPadding()
|
||||
ToastHost(state = toastState) {
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val windowSizeInfo = rememberWindowSizeInfo(maxWidth, maxHeight)
|
||||
|
||||
if (windowSizeInfo.shouldUseTwoPane) {
|
||||
ExpandedWorkflowRunLayout(
|
||||
model = model,
|
||||
navigator = navigator,
|
||||
comfyColors = comfyColors
|
||||
)
|
||||
},
|
||||
topBar = {
|
||||
WorkflowRunTopBar(
|
||||
onBack = { navigator.pop() },
|
||||
status = model.executionStatus,
|
||||
statusText = model.statusText,
|
||||
progress = model.progress,
|
||||
progressText = model.progressText
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
) { padding ->
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val startPadding = padding.calculateLeftPadding(layoutDirection)
|
||||
val endPadding = padding.calculateRightPadding(layoutDirection)
|
||||
val contentPadding = PaddingValues(
|
||||
start = startPadding,
|
||||
top = padding.calculateTopPadding(),
|
||||
end = endPadding,
|
||||
bottom = 0.dp
|
||||
)
|
||||
val sheetOffsetPx = runCatching {
|
||||
scaffoldState.bottomSheetState.requireOffset()
|
||||
}.getOrElse { (layoutHeightPx - peekHeightPx).coerceAtLeast(0f) }
|
||||
val sheetHeightPx = (layoutHeightPx - sheetOffsetPx).coerceAtLeast(0f)
|
||||
val sheetHeightOffset = with(density) { sheetHeightPx.toDp() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
comfyColors.gradientStart,
|
||||
comfyColors.gradientEnd
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
ImageGallery(
|
||||
images = model.galleryImages,
|
||||
onImageClick = { index ->
|
||||
model.getPreviewImage(index)?.let { preview ->
|
||||
navigator.push(
|
||||
ImagePreviewScreen(
|
||||
preview.filename,
|
||||
preview.bytes,
|
||||
onSave = { model.saveImage(index) },
|
||||
onSetCover = { model.setCoverImage(index) }
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
onSaveClick = { index -> model.saveImage(index) },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(contentPadding)
|
||||
)
|
||||
|
||||
WorkflowRunFab(
|
||||
isRunning = model.running,
|
||||
onStart = { model.runWorkflow() },
|
||||
onInterrupt = { model.interrupt() },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.navigationBarsPadding()
|
||||
.padding(end = ComfySpacing.lg, bottom = ComfySpacing.lg)
|
||||
.offset(y = -sheetHeightOffset)
|
||||
} else {
|
||||
CompactWorkflowRunLayout(
|
||||
model = model,
|
||||
navigator = navigator,
|
||||
comfyColors = comfyColors
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -251,6 +155,283 @@ data class WorkflowRunScreen(val workflowId: String) : Screen {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 小屏幕布局:底部面板
|
||||
*/
|
||||
@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 sheetPeekHeight = 120.dp
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val density = LocalDensity.current
|
||||
val layoutHeightPx = with(density) { maxHeight.toPx() }
|
||||
val peekHeightPx = with(density) { sheetPeekHeight.toPx() }
|
||||
|
||||
BottomSheetScaffold(
|
||||
scaffoldState = scaffoldState,
|
||||
sheetPeekHeight = sheetPeekHeight,
|
||||
sheetContainerColor = comfyColors.cardBackground,
|
||||
sheetContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
sheetShape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
sheetDragHandle = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(40.dp)
|
||||
.height(4.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "节点状态 (${model.nodeStates.size})",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
},
|
||||
sheetContent = {
|
||||
NodeListSheet(
|
||||
nodes = model.orderedNodeStates,
|
||||
randomSeedNodes = model.randomSeedNodes,
|
||||
modelChoices = model.modelChoices,
|
||||
loadingModels = model.loadingModels,
|
||||
onInputChange = { nodeId, field, value -> model.updateNodeInput(nodeId, field, value) },
|
||||
onRandomSeedToggle = { nodeId, enabled -> model.setRandomSeedMode(nodeId, enabled) },
|
||||
onRandomizeSeed = { nodeId -> model.randomizeSeed(nodeId) },
|
||||
onLoadModels = { nodeId -> model.loadModelsForNode(nodeId) },
|
||||
modifier = Modifier.navigationBarsPadding()
|
||||
)
|
||||
},
|
||||
topBar = {
|
||||
WorkflowRunTopBar(
|
||||
onBack = { navigator.pop() },
|
||||
status = model.executionStatus,
|
||||
statusText = model.statusText
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
) { padding ->
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val startPadding = padding.calculateLeftPadding(layoutDirection)
|
||||
val endPadding = padding.calculateRightPadding(layoutDirection)
|
||||
val contentPadding = PaddingValues(
|
||||
start = startPadding,
|
||||
top = padding.calculateTopPadding(),
|
||||
end = endPadding,
|
||||
bottom = 0.dp
|
||||
)
|
||||
val sheetOffsetPx = runCatching {
|
||||
scaffoldState.bottomSheetState.requireOffset()
|
||||
}.getOrElse { (layoutHeightPx - peekHeightPx).coerceAtLeast(0f) }
|
||||
val sheetHeightPx = (layoutHeightPx - sheetOffsetPx).coerceAtLeast(0f)
|
||||
val sheetHeightOffset = with(density) { sheetHeightPx.toDp() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
comfyColors.gradientStart,
|
||||
comfyColors.gradientEnd
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
ImageGallery(
|
||||
images = model.galleryImages,
|
||||
onImageClick = { index ->
|
||||
model.getPreviewImage(index)?.let { preview ->
|
||||
navigator.push(
|
||||
ImagePreviewScreen(
|
||||
preview.filename,
|
||||
preview.bytes,
|
||||
onSave = { model.saveImage(index) },
|
||||
onSetCover = { model.setCoverImage(index) }
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
onSaveClick = { index -> model.saveImage(index) },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(contentPadding)
|
||||
)
|
||||
|
||||
WorkflowRunFab(
|
||||
isRunning = model.running,
|
||||
onStart = { model.runWorkflow() },
|
||||
onInterrupt = { model.interrupt() },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.navigationBarsPadding()
|
||||
.padding(end = ComfySpacing.lg, bottom = ComfySpacing.lg)
|
||||
.offset(y = -sheetHeightOffset)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 大屏幕布局:左右分栏
|
||||
* 左侧:图片预览区域
|
||||
* 右侧:节点列表
|
||||
*/
|
||||
@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(
|
||||
val filename: String,
|
||||
val bytes: ByteArray
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user