diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/components/AdaptiveLayout.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/components/AdaptiveLayout.kt new file mode 100644 index 0000000..689d465 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/components/AdaptiveLayout.kt @@ -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) +} diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/components/ComfyDialog.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/components/ComfyDialog.kt index 508c4ad..0ca933d 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/components/ComfyDialog.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/components/ComfyDialog.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/components/StatusBar.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/components/StatusBar.kt index 6959922..9eb095e 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/components/StatusBar.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/components/StatusBar.kt @@ -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 + ) } } } diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/components/Toast.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/components/Toast.kt new file mode 100644 index 0000000..eb2bca1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/components/Toast.kt @@ -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(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 + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/screens/ServerScreen.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/screens/ServerScreen.kt index 8241f16..05269d3 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/screens/ServerScreen.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/screens/ServerScreen.kt @@ -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(null) } + var selectedServerId by remember { mutableStateOf(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("删除") + } } } } diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/screens/WorkflowListScreen.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/screens/WorkflowListScreen.kt index fc57263..17f786e 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/screens/WorkflowListScreen.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/screens/WorkflowListScreen.kt @@ -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(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, + 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) + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/screens/WorkflowRunScreen.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/screens/WorkflowRunScreen.kt index bc2fb5b..483fadf 100644 --- a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/screens/WorkflowRunScreen.kt +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/screens/WorkflowRunScreen.kt @@ -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) { diff --git a/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/theme/WindowSize.kt b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/theme/WindowSize.kt new file mode 100644 index 0000000..279fab5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/uni/comfyKmp/ui/theme/WindowSize.kt @@ -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 +}