From 050540f7cebaf4d1e1356aba82852f719f3d94eb Mon Sep 17 00:00:00 2001 From: Lin Zhang Date: Thu, 7 May 2026 00:55:03 +0800 Subject: [PATCH 1/3] feat: add Ketch design system for Compose Multiplatform Ports the design system from the Ketch.html design package into the shared module: theme tokens (colors, typography, shapes, spacing, elevation, motion) plus icon set and core components (Button, IconButton, TextField, Card, Badge, ProgressBar, SidebarItem, SegmentBar, SegmentDetail, SpeedChart, DownloadRow). Compiles for Android, JVM, iOS-Simulator-Arm64, and WasmJs. --- .../ketch/app/components/KetchButton.kt | 109 ++++++++ .../app/components/KetchDownloadComponents.kt | 249 ++++++++++++++++++ .../ketch/app/components/KetchSurfaces.kt | 170 ++++++++++++ .../ketch/app/components/KetchTextField.kt | 71 +++++ .../com/linroid/ketch/app/icons/KetchIcon.kt | 117 ++++++++ .../ketch/app/icons/KetchIconRenderer.kt | 55 ++++ .../com/linroid/ketch/app/theme/Color.kt | 161 +++++------ .../linroid/ketch/app/theme/KetchColors.kt | 203 ++++++++++++++ .../linroid/ketch/app/theme/KetchElevation.kt | 75 ++++++ .../linroid/ketch/app/theme/KetchMotion.kt | 22 ++ .../linroid/ketch/app/theme/KetchShapes.kt | 27 ++ .../linroid/ketch/app/theme/KetchSpacing.kt | 22 ++ .../ketch/app/theme/KetchTypography.kt | 88 +++++++ .../com/linroid/ketch/app/theme/Theme.kt | 209 +++++++++------ 14 files changed, 1404 insertions(+), 174 deletions(-) create mode 100644 app/shared/src/commonMain/kotlin/com/linroid/ketch/app/components/KetchButton.kt create mode 100644 app/shared/src/commonMain/kotlin/com/linroid/ketch/app/components/KetchDownloadComponents.kt create mode 100644 app/shared/src/commonMain/kotlin/com/linroid/ketch/app/components/KetchSurfaces.kt create mode 100644 app/shared/src/commonMain/kotlin/com/linroid/ketch/app/components/KetchTextField.kt create mode 100644 app/shared/src/commonMain/kotlin/com/linroid/ketch/app/icons/KetchIcon.kt create mode 100644 app/shared/src/commonMain/kotlin/com/linroid/ketch/app/icons/KetchIconRenderer.kt create mode 100644 app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchColors.kt create mode 100644 app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchElevation.kt create mode 100644 app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchMotion.kt create mode 100644 app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchShapes.kt create mode 100644 app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchSpacing.kt create mode 100644 app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchTypography.kt diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/components/KetchButton.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/components/KetchButton.kt new file mode 100644 index 00000000..e6f4b21c --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/components/KetchButton.kt @@ -0,0 +1,109 @@ +package com.linroid.ketch.app.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.linroid.ketch.app.icons.KetchIcon +import com.linroid.ketch.app.icons.KetchIconImage +import com.linroid.ketch.app.theme.KetchTheme + +enum class KetchButtonVariant { Primary, Secondary, Ghost, Danger } +enum class KetchButtonSize { Small, Medium, Large } + +/** + * Ketch button. + * + * - [Primary] — filled accent, for the main action on a surface. + * - [Secondary] — outlined, for paired actions (Cancel next to Save). + * - [Ghost] — no border, no fill; for toolbar rows. + * - [Danger] — filled error color. + * + * Sizes: + * - [Small] 32dp tall, 12dp h-padding, 14dp icon. + * - [Medium] 36dp tall, 16dp h-padding, 16dp icon. Default. + * - [Large] 40dp tall, 20dp h-padding, 18dp icon. + */ +@Composable +fun KetchButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + variant: KetchButtonVariant = KetchButtonVariant.Primary, + size: KetchButtonSize = KetchButtonSize.Medium, + leadingIcon: KetchIcon? = null, + enabled: Boolean = true, +) { + val colors = KetchTheme.colors + val shape = RoundedCornerShape(8.dp) + + data class Style(val bg: Color, val fg: Color, val border: Color?) + val style = when (variant) { + KetchButtonVariant.Primary -> Style(colors.primary, Color.White, null) + KetchButtonVariant.Secondary -> Style(Color.Transparent, colors.onBackground, colors.outline) + KetchButtonVariant.Ghost -> Style(Color.Transparent, colors.onBackground, null) + KetchButtonVariant.Danger -> Style(colors.error, Color.White, null) + } + + val (minH, padH, iconSize) = when (size) { + KetchButtonSize.Small -> Triple(32.dp, 12.dp, 14.dp) + KetchButtonSize.Medium -> Triple(36.dp, 16.dp, 16.dp) + KetchButtonSize.Large -> Triple(40.dp, 20.dp, 18.dp) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(7.dp, Alignment.CenterHorizontally), + modifier = modifier + .defaultMinSize(minHeight = minH) + .clip(shape) + .background(if (enabled) style.bg else style.bg.copy(alpha = 0.4f)) + .let { if (style.border != null) it.border(1.dp, style.border, shape) else it } + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = padH, vertical = 0.dp), + ) { + if (leadingIcon != null) { + KetchIconImage(leadingIcon, size = iconSize, tint = style.fg) + } + Text(text = text, color = style.fg, style = KetchTheme.typography.labelLarge) + } +} + +/** Square icon-only button — toolbar-style, transparent until hover. */ +@Composable +fun KetchIconButton( + icon: KetchIcon, + onClick: () -> Unit, + modifier: Modifier = Modifier, + size: KetchButtonSize = KetchButtonSize.Medium, + enabled: Boolean = true, + tint: Color = KetchTheme.colors.onSurfaceVariant, +) { + val side = when (size) { + KetchButtonSize.Small -> 28.dp + KetchButtonSize.Medium -> 32.dp + KetchButtonSize.Large -> 36.dp + } + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .size(side) + .clip(RoundedCornerShape(7.dp)) + .clickable(enabled = enabled, onClick = onClick), + ) { + KetchIconImage(icon = icon, size = side - 12.dp, tint = tint) + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/components/KetchDownloadComponents.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/components/KetchDownloadComponents.kt new file mode 100644 index 00000000..db6b4b33 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/components/KetchDownloadComponents.kt @@ -0,0 +1,249 @@ +package com.linroid.ketch.app.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +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.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.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.linroid.ketch.app.theme.KetchTheme +import kotlin.math.max + +/** + * Segmented progress bar — Ketch's signature visualization. + * + * Each parallel HTTP range is shown as one stripe, all stacked into a single + * progress track. The accent hue cycles through the 8-color [segments] palette + * so adjacent stripes stay distinguishable even on long bars. + * + * @param progress per-segment fraction in [0, 1]; size determines segment count. + * @param widths per-segment relative width; defaults to equal split. + * @param showSeams draw 1px gaps between segments (matches the JS mock). + */ +@Composable +fun KetchSegmentBar( + progress: List, + modifier: Modifier = Modifier, + widths: List? = null, + height: Dp = 10.dp, + trackColor: Color = KetchTheme.colors.outlineVariant, + showSeams: Boolean = true, +) { + val palette = KetchTheme.colors.segments + val n = progress.size + if (n == 0) return + val ws = widths ?: List(n) { 1f / n } + val seamColor = KetchTheme.colors.background + + Row( + modifier = modifier + .fillMaxWidth() + .height(height) + .clip(RoundedCornerShape(height / 2)) + .background(trackColor), + ) { + progress.forEachIndexed { i, p -> + val w = ws.getOrElse(i) { 1f / n } + Box(Modifier.weight(max(w, 0.0001f)).fillMaxHeight()) { + Box( + Modifier + .fillMaxWidth(p.coerceIn(0f, 1f)) + .fillMaxHeight() + .background(palette[i % palette.size]), + ) + } + if (showSeams && i < n - 1) { + Box(Modifier.width(1.dp).fillMaxHeight().background(seamColor)) + } + } + } +} + +/** + * Detailed segment view — N rows, each a mini bar with byte offset, percent, + * and a health dot. Used in the download row's expanded panel. + * + * `health` is in [0, 1]; values below 0.6 dim the fill and overlay a striped + * warning pattern (rendered here as a flat warning tint to keep the renderer + * portable across platforms). + */ +@Composable +fun KetchSegmentDetail( + progress: List, + health: List, + modifier: Modifier = Modifier, + compact: Boolean = false, +) { + val colors = KetchTheme.colors + val type = KetchTheme.typography + val rowH = if (compact) 14.dp else 20.dp + val barH = if (compact) 6.dp else 8.dp + + Column(modifier = modifier.fillMaxWidth()) { + progress.forEachIndexed { i, p -> + val h = health.getOrElse(i) { 1f } + val color = colors.segments[i % colors.segments.size] + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().height(rowH), + ) { + Text( + text = "#${i + 1}", + style = type.monoXSmall.copy(color = colors.onSurfaceDim), + modifier = Modifier.width(22.dp), + ) + Spacer(Modifier.width(8.dp)) + Box( + Modifier + .weight(1f) + .height(barH) + .clip(RoundedCornerShape(2.dp)) + .background(colors.outlineVariant), + ) { + Box( + Modifier + .fillMaxWidth(p.coerceIn(0f, 1f)) + .fillMaxHeight() + .background(if (h < 0.6f) color.copy(alpha = 0.4f) else color), + ) + if (h < 0.6f) { + Box( + Modifier + .fillMaxSize() + .background(colors.warning.copy(alpha = 0.18f)), + ) + } + } + if (!compact) { + Spacer(Modifier.width(8.dp)) + Text( + text = "${(p * 100).toInt()}%", + style = type.monoXSmall.copy(color = colors.onSurfaceDim), + modifier = Modifier.width(38.dp), + ) + } + Spacer(Modifier.width(8.dp)) + HealthDot(value = h, size = if (compact) 5.dp else 6.dp) + } + if (i < progress.size - 1) Spacer(Modifier.height(if (compact) 3.dp else 5.dp)) + } + } +} + +@Composable +private fun HealthDot(value: Float, size: Dp = 6.dp) { + val colors = KetchTheme.colors + val c = when { + value > 0.8f -> colors.success + value > 0.5f -> colors.warning + else -> colors.error + } + Box( + modifier = Modifier + .size(size) + .clip(RoundedCornerShape(50)) + .background(c), + ) +} + +/** + * Speed sparkline — area + 1.5dp top stroke. Normalises against the sample max + * so the line always fills the canvas; pass [normalize] = false if the caller + * already scaled the values into [0, 1]. + */ +@Composable +fun KetchSpeedChart( + samples: List, + modifier: Modifier = Modifier, + height: Dp = 80.dp, + lineColor: Color = KetchTheme.colors.primary, + normalize: Boolean = true, +) { + Canvas(modifier = modifier.fillMaxWidth().height(height)) { + if (samples.size < 2) return@Canvas + val max = if (normalize) samples.max().coerceAtLeast(0.0001f) else 1f + val w = size.width + val h = size.height + val step = w / (samples.size - 1) + val line = Path() + val fill = Path() + fill.moveTo(0f, h) + samples.forEachIndexed { i, v -> + val x = i * step + val y = h - (v / max).coerceIn(0f, 1f) * (h - 4f) - 2f + if (i == 0) line.moveTo(x, y) else line.lineTo(x, y) + fill.lineTo(x, y) + } + fill.lineTo(w, h) + fill.close() + + drawPath(fill, color = lineColor.copy(alpha = 0.18f)) + drawPath( + path = line, + color = lineColor, + style = Stroke(width = 1.5f, cap = StrokeCap.Round), + ) + } +} + +/** + * Single row in the download queue — DS skeleton. + * + * Wrap in a clickable container if you want the row to expand on tap; the row + * itself only renders the presentational layer (file name, single thin + * progress track, primary metric). + */ +@Composable +fun KetchDownloadRow( + name: String, + progress: Float, + primaryMetric: String, + modifier: Modifier = Modifier, + trackColor: Color = KetchTheme.colors.outlineVariant, + fillColor: Color = KetchTheme.colors.primary, +) { + val colors = KetchTheme.colors + val type = KetchTheme.typography + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = name, + style = type.bodyLarge.copy(color = colors.onBackground), + modifier = Modifier.weight(1f), + ) + Text( + text = primaryMetric, + style = type.monoSmall.copy(color = colors.onSurfaceVariant), + ) + } + Spacer(Modifier.height(6.dp)) + KetchProgressBar( + progress = progress, + trackColor = trackColor, + fillColor = fillColor, + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/components/KetchSurfaces.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/components/KetchSurfaces.kt new file mode 100644 index 00000000..9bf530f9 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/components/KetchSurfaces.kt @@ -0,0 +1,170 @@ +package com.linroid.ketch.app.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.linroid.ketch.app.icons.KetchIcon +import com.linroid.ketch.app.icons.KetchIconImage +import com.linroid.ketch.app.theme.KetchTheme + +/** Surface container — 10dp card with 1dp hairline border. */ +@Composable +fun KetchCard( + modifier: Modifier = Modifier, + padding: Dp = 16.dp, + content: @Composable () -> Unit, +) { + val shape = RoundedCornerShape(10.dp) + Box( + modifier = modifier + .clip(shape) + .background(KetchTheme.colors.surface) + .border(1.dp, KetchTheme.colors.outline, shape) + .padding(padding), + ) { content() } +} + +enum class KetchBadgeTone { Neutral, Success, Warning, Danger, Accent } + +/** Small chip for statuses, counts, eyebrows. */ +@Composable +fun KetchBadge( + text: String, + tone: KetchBadgeTone = KetchBadgeTone.Neutral, + modifier: Modifier = Modifier, +) { + val colors = KetchTheme.colors + val (bg, fg) = when (tone) { + KetchBadgeTone.Neutral -> colors.outlineVariant to colors.onSurfaceDim + KetchBadgeTone.Success -> colors.success.copy(alpha = 0.12f) to colors.success + KetchBadgeTone.Warning -> colors.warning.copy(alpha = 0.16f) to colors.warning + KetchBadgeTone.Danger -> colors.error.copy(alpha = 0.14f) to colors.error + KetchBadgeTone.Accent -> colors.primaryContainer to colors.onPrimaryContainer + } + Box( + modifier = modifier + .clip(RoundedCornerShape(3.dp)) + .background(bg) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text(text, style = KetchTheme.typography.monoXSmall.copy(color = fg)) + } +} + +/** Linear progress bar — 3dp tall, 2dp radius. */ +@Composable +fun KetchProgressBar( + progress: Float, + modifier: Modifier = Modifier, + trackColor: Color = KetchTheme.colors.outlineVariant, + fillColor: Color = KetchTheme.colors.primary, +) { + val shape = RoundedCornerShape(2.dp) + Box( + modifier = modifier + .fillMaxWidth() + .height(3.dp) + .clip(shape) + .background(trackColor), + ) { + Box( + Modifier + .fillMaxWidth(progress.coerceIn(0f, 1f)) + .height(3.dp) + .background(fillColor), + ) + } +} + +/** + * Sidebar list item. + * + * Selected state stacks four cues: elevated card background, hairline border, + * a 3dp accent rail on the leading edge, accent-tinted icon, and a heavier + * label weight. Unselected items are flat-on-panel and switch to a neutral + * hover background, so selected and hover stay visually distinct. + */ +@Composable +fun KetchSidebarItem( + label: String, + icon: KetchIcon, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + count: Int? = null, +) { + val colors = KetchTheme.colors + val type = KetchTheme.typography + val shape = RoundedCornerShape(8.dp) + + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp) + .height(38.dp) + .clip(shape) + .background(if (selected) colors.surface else Color.Transparent) + .let { if (selected) it.border(1.dp, colors.outline, shape) else it } + .clickable(onClick = onClick), + ) { + if (selected) { + Box( + Modifier + .padding(vertical = 9.dp) + .width(3.dp) + .fillMaxHeight() + .clip(RoundedCornerShape(2.dp)) + .background(colors.primary), + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp), + ) { + KetchIconImage( + icon = icon, + size = 17.dp, + tint = if (selected) colors.primary else colors.onSurfaceVariant, + ) + Text( + text = label, + style = type.bodyLarge.copy( + color = if (selected) colors.onBackground else colors.onSurfaceVariant, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + ), + modifier = Modifier.weight(1f), + ) + if (count != null) { + Spacer(Modifier.width(4.dp)) + Text( + text = count.toString(), + style = type.monoXSmall.copy( + color = if (selected) colors.onSurfaceVariant else colors.onSurfaceDim, + ), + ) + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/components/KetchTextField.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/components/KetchTextField.kt new file mode 100644 index 00000000..1cd4adda --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/components/KetchTextField.kt @@ -0,0 +1,71 @@ +package com.linroid.ketch.app.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import com.linroid.ketch.app.icons.KetchIcon +import com.linroid.ketch.app.icons.KetchIconImage +import com.linroid.ketch.app.theme.KetchTheme + +/** + * Ketch single-line text field. 36dp tall, 8dp radius, 1dp border. + * Cursor uses the accent color. + */ +@Composable +fun KetchTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + placeholder: String = "", + leadingIcon: KetchIcon? = null, + mono: Boolean = false, + enabled: Boolean = true, +) { + val colors = KetchTheme.colors + val shape = RoundedCornerShape(8.dp) + val textStyle: TextStyle = + (if (mono) KetchTheme.typography.monoSmall else KetchTheme.typography.bodyMedium) + .copy(color = colors.onBackground) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .defaultMinSize(minHeight = 36.dp) + .clip(shape) + .background(colors.surface) + .border(1.dp, colors.outline, shape) + .padding(horizontal = 12.dp), + ) { + if (leadingIcon != null) { + KetchIconImage(leadingIcon, size = 14.dp, tint = colors.onSurfaceDim) + } + BasicTextField( + value = value, + onValueChange = onValueChange, + enabled = enabled, + textStyle = textStyle, + cursorBrush = SolidColor(colors.primary), + modifier = Modifier.defaultMinSize(minWidth = 120.dp), + decorationBox = { inner -> + if (value.isEmpty() && placeholder.isNotEmpty()) { + Text(placeholder, style = textStyle.copy(color = colors.onSurfaceDim)) + } + inner() + }, + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/icons/KetchIcon.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/icons/KetchIcon.kt new file mode 100644 index 00000000..039ba8e2 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/icons/KetchIcon.kt @@ -0,0 +1,117 @@ +package com.linroid.ketch.app.icons + +import androidx.compose.runtime.Immutable + +/** + * Ketch icon set. + * + * Every icon is authored against a 20×20 viewport with a 1.7px outlined stroke + * (round caps + joins). The same path strings used by the JS mocks are kept + * verbatim so there is no pixel drift between the web design and Compose. + * + * Render with [KetchIconImage]. + */ +@Immutable +enum class KetchIcon(internal val data: IconData) { + // Generic + Plus(IconData.strokes("M10 4v12", "M4 10h12")), + Close(IconData.strokes("M5 5l10 10", "M15 5L5 15")), + Check(IconData.strokes("M4 10l4 4 8-8")), + Chevron(IconData.strokes("M7 5l5 5-5 5")), + ChevronDown(IconData.strokes("M5 8l5 5 5-5")), + Search(IconData.paths( + stroke = listOf("M9 3a6 6 0 100 12A6 6 0 009 3z", "M13.5 13.5l3 3"), + )), + Filter(IconData.strokes("M3 5h14", "M6 10h8", "M9 15h2")), + Link(IconData.strokes( + "M8 12l4-4", "M7 13l-2-2a3 3 0 014-4l1 1", "M13 7l2 2a3 3 0 01-4 4l-1-1", + )), + Folder(IconData.strokes( + "M3 7a2 2 0 012-2h3l2 2h5a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2V7z", + )), + Settings(IconData.paths( + stroke = listOf( + "M10 7.5a2.5 2.5 0 100 5 2.5 2.5 0 000-5z", + "M10 2v2M10 16v2M2 10h2M16 10h2", + "M4.2 4.2l1.4 1.4M14.4 14.4l1.4 1.4M4.2 15.8l1.4-1.4M14.4 5.6l1.4-1.4", + ), + )), + + // Playback / actions + Play(IconData.fills("M6 4l10 6-10 6V4z")), + Pause(IconData.fills("M5 4h3v12H5z", "M12 4h3v12h-3z")), + Stop(IconData.fills("M5 5h10v10H5z")), + Retry(IconData.strokes( + "M16 10a6 6 0 11-6-6 6 6 0 014.5 2", "M17 3v4h-4", + )), + More(IconData.fills( + "M10 5.5a1.3 1.3 0 100-2.6 1.3 1.3 0 000 2.6z", + "M10 11.3a1.3 1.3 0 100-2.6 1.3 1.3 0 000 2.6z", + "M10 17.1a1.3 1.3 0 100-2.6 1.3 1.3 0 000 2.6z", + )), + Trash(IconData.strokes("M4 6h12", "M8 6V4h4v2", "M6 6l1 10h6l1-10")), + + // Sidebar nav + All(IconData.strokes("M4 6h12", "M4 10h12", "M4 14h8")), + Active(IconData.paths( + stroke = listOf( + "M10 7a3 3 0 100 6 3 3 0 000-6z", + "M10 3v2M10 15v2M3 10h2M15 10h2M5 5l1.4 1.4M13.6 13.6L15 15M5 15l1.4-1.4M13.6 6.4L15 5", + ), + )), + Queued(IconData.paths( + stroke = listOf("M10 3a7 7 0 100 14 7 7 0 000-14z", "M10 6v4l3 2"), + )), + Scheduled(IconData.paths( + stroke = listOf( + "M3.5 5h13a1.5 1.5 0 011.5 1.5v9a1.5 1.5 0 01-1.5 1.5h-13A1.5 1.5 0 012 15.5v-9A1.5 1.5 0 013.5 5z", + "M2 8.5h16M7 3v3M13 3v3", + ), + )), + Done(IconData.paths( + stroke = listOf("M10 3a7 7 0 100 14 7 7 0 000-14z", "M7 10l2 2 4-4"), + )), + Failed(IconData.paths( + stroke = listOf("M10 3a7 7 0 100 14 7 7 0 000-14z", "M7.5 7.5l5 5", "M12.5 7.5l-5 5"), + )), + + // AI + infra + Ai(IconData.strokes("M10 3l1.8 4.5L16 9l-4.2 1.5L10 15l-1.8-4.5L4 9l4.2-1.5L10 3z")), + Speed(IconData.strokes("M3 14a7 7 0 0114 0", "M10 14l3-4")), + Server(IconData.paths( + stroke = listOf( + "M3 4h14a1 1 0 011 1v3a1 1 0 01-1 1H3a1 1 0 01-1-1V5a1 1 0 011-1z", + "M3 11h14a1 1 0 011 1v3a1 1 0 01-1 1H3a1 1 0 01-1-1v-3a1 1 0 011-1z", + ), + fill = listOf( + "M6 6.5a0.7 0.7 0 100-1.4 0.7 0.7 0 000 1.4z", + "M6 13.5a0.7 0.7 0 100-1.4 0.7 0.7 0 000 1.4z", + ), + )), + Local(IconData.strokes( + "M3.5 4h13a1.5 1.5 0 011.5 1.5v7a1.5 1.5 0 01-1.5 1.5h-13A1.5 1.5 0 012 12.5v-7A1.5 1.5 0 013.5 4z", + "M7 17h6", "M8 14v3", "M12 14v3", + )), + Remote(IconData.strokes( + "M10 3a7 7 0 100 14 7 7 0 000-14z", + "M3 10h14", + "M10 3c3 4 3 10 0 14", + "M10 3c-3 4-3 10 0 14", + )), +} + +/** Raw path data for an icon. */ +@Immutable +internal data class IconData( + /** Paths rendered with a stroke (outlined). */ + val strokes: List = emptyList(), + /** Paths rendered with a fill. */ + val fills: List = emptyList(), +) { + companion object { + fun strokes(vararg d: String) = IconData(strokes = d.toList()) + fun fills(vararg d: String) = IconData(fills = d.toList()) + fun paths(stroke: List = emptyList(), fill: List = emptyList()) = + IconData(strokes = stroke, fills = fill) + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/icons/KetchIconRenderer.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/icons/KetchIconRenderer.kt new file mode 100644 index 00000000..ce91ace5 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/icons/KetchIconRenderer.kt @@ -0,0 +1,55 @@ +package com.linroid.ketch.app.icons + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.vector.PathParser +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.linroid.ketch.app.theme.KetchTheme + +/** + * Renders a [KetchIcon] at [size] in [tint]. + * + * Uses Compose's built-in [PathParser] so the JS mock's path strings are + * reusable verbatim. Stroke width scales with the requested size to preserve + * the design's optical weight (1.7px at the 20×20 author viewport). + */ +@Composable +fun KetchIconImage( + icon: KetchIcon, + size: Dp = 20.dp, + tint: Color = KetchTheme.colors.onBackground, +) { + val data = icon.data + Canvas(modifier = Modifier.size(size)) { + val s = this.size.width / 20f + // Author stroke is 1.7px in the 20-unit viewport; we draw post-scale so + // dividing by `s` keeps the effective stroke weight constant on screen. + scale(scaleX = s, scaleY = s, pivot = Offset.Zero) { + data.strokes.forEach { d -> + val path = PathParser().parsePathString(d).toPath() + drawPath( + path = path, + color = tint, + style = Stroke( + width = 1.7f, + cap = StrokeCap.Round, + join = StrokeJoin.Round, + ), + ) + } + data.fills.forEach { d -> + val path = PathParser().parsePathString(d).toPath() + drawPath(path = path, color = tint) + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/Color.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/Color.kt index ee6b92d3..80c4ff76 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/Color.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/Color.kt @@ -3,77 +3,72 @@ package com.linroid.ketch.app.theme import androidx.compose.ui.graphics.Color import com.linroid.ketch.api.DownloadState -// Surface palette (neutral dark) -val KetchBackground = Color(0xFF101010) -val KetchSurface = Color(0xFF1A1A1A) -val KetchSurfaceVariant = Color(0xFF252525) -val KetchSurfaceContainer = Color(0xFF1F1F1F) -val KetchSurfaceContainerHigh = Color(0xFF2A2A2A) -val KetchOnSurface = Color(0xFFE8E8E8) -val KetchOnSurfaceVariant = Color(0xFF999999) -val KetchOutline = Color(0xFF4A4A4A) -val KetchOutlineVariant = Color(0xFF303030) +// Dark scheme — neutral surfaces (hue 250, low chroma) + Signal blue accent. +val KetchBackground = KetchPalette.Dark.bg +val KetchSurface = KetchPalette.Dark.bgElev +val KetchSurfaceVariant = KetchPalette.Dark.panel +val KetchSurfaceContainer = KetchPalette.Dark.panel +val KetchSurfaceContainerHigh = KetchPalette.Dark.bgHover +val KetchOnSurface = KetchPalette.Dark.text +val KetchOnSurfaceVariant = KetchPalette.Dark.textSec +val KetchOutline = KetchPalette.Dark.line +val KetchOutlineVariant = KetchPalette.Dark.lineSoft -// Primary (teal — from logo) -val KetchPrimary = Color(0xFF00BCD4) -val KetchPrimaryContainer = Color(0xFF003840) -val KetchOnPrimary = Color(0xFF000000) -val KetchOnPrimaryContainer = Color(0xFFB2EBF2) +// Primary — Signal blue (default accent) +val KetchPrimary = KetchPalette.SignalDark.primary +val KetchPrimaryContainer = KetchPalette.SignalDark.container +val KetchOnPrimary = Color(0xFFFFFFFF) +val KetchOnPrimaryContainer = KetchPalette.SignalDark.onContainer -// Secondary (deep teal — from logo hull) -val KetchSecondary = Color(0xFF0097A7) -val KetchSecondaryContainer = Color(0xFF002E33) +// Secondary — reuse accent container for tonal surface roles +val KetchSecondary = KetchPalette.SignalDark.onContainer +val KetchSecondaryContainer = KetchPalette.SignalDark.container val KetchOnSecondary = Color(0xFF000000) -val KetchOnSecondaryContainer = Color(0xFF80DEEA) +val KetchOnSecondaryContainer = KetchPalette.SignalDark.onContainer -// Tertiary (success/green) -val KetchTertiary = Color(0xFF66BB6A) -val KetchTertiaryContainer = Color(0xFF1B3A2B) -val KetchOnTertiary = Color(0xFF0F1419) -val KetchOnTertiaryContainer = Color(0xFFA5D6A7) +// Tertiary — success/green +val KetchTertiary = KetchPalette.Dark.success +val KetchTertiaryContainer = Color(0xFF003F17) +val KetchOnTertiary = Color(0xFFFFFFFF) +val KetchOnTertiaryContainer = Color(0xFF6FD087) -// Error (red) -val KetchError = Color(0xFFEF5350) +// Error — danger +val KetchError = KetchPalette.Dark.danger val KetchErrorContainer = Color(0xFF3A1B1B) -val KetchOnError = Color(0xFF0F1419) -val KetchOnErrorContainer = Color(0xFFEF9A9A) +val KetchOnError = Color(0xFFFFFFFF) +val KetchOnErrorContainer = Color(0xFFF3A2A2) -// Light theme surface palette (neutral light) -val KetchLightBackground = Color(0xFFFAFAFA) -val KetchLightSurface = Color(0xFFFFFFFF) -val KetchLightSurfaceVariant = Color(0xFFE8E8E8) -val KetchLightSurfaceContainer = Color(0xFFF2F2F2) -val KetchLightSurfaceContainerHigh = Color(0xFFE8E8E8) -val KetchLightOnSurface = Color(0xFF1A1A1A) -val KetchLightOnSurfaceVariant = Color(0xFF555555) -val KetchLightOutline = Color(0xFF999999) -val KetchLightOutlineVariant = Color(0xFFCCCCCC) +// Light scheme — neutral surfaces + Signal blue accent. +val KetchLightBackground = KetchPalette.Light.bg +val KetchLightSurface = KetchPalette.Light.bgElev +val KetchLightSurfaceVariant = KetchPalette.Light.panel +val KetchLightSurfaceContainer = KetchPalette.Light.panel +val KetchLightSurfaceContainerHigh = KetchPalette.Light.bgHover +val KetchLightOnSurface = KetchPalette.Light.text +val KetchLightOnSurfaceVariant = KetchPalette.Light.textSec +val KetchLightOutline = KetchPalette.Light.line +val KetchLightOutlineVariant = KetchPalette.Light.lineSoft -// Light primary (teal — from logo, darker for readability) -val KetchLightPrimary = Color(0xFF00838F) -val KetchLightPrimaryContainer = Color(0xFFB2EBF2) +val KetchLightPrimary = KetchPalette.SignalLight.primary +val KetchLightPrimaryContainer = KetchPalette.SignalLight.container val KetchLightOnPrimary = Color(0xFFFFFFFF) -val KetchLightOnPrimaryContainer = Color(0xFF006064) +val KetchLightOnPrimaryContainer = KetchPalette.SignalLight.onContainer -// Light secondary (deep teal) -val KetchLightSecondary = Color(0xFF00695C) -val KetchLightSecondaryContainer = Color(0xFFB2DFDB) +val KetchLightSecondary = KetchPalette.SignalLight.onContainer +val KetchLightSecondaryContainer = KetchPalette.SignalLight.container val KetchLightOnSecondary = Color(0xFFFFFFFF) -val KetchLightOnSecondaryContainer = Color(0xFF004D40) +val KetchLightOnSecondaryContainer = KetchPalette.SignalLight.onContainer -// Light tertiary -val KetchLightTertiary = Color(0xFF2E7D32) -val KetchLightTertiaryContainer = Color(0xFFA5D6A7) +val KetchLightTertiary = KetchPalette.Light.success +val KetchLightTertiaryContainer = Color(0xFFD9F3DD) val KetchLightOnTertiary = Color(0xFFFFFFFF) -val KetchLightOnTertiaryContainer = Color(0xFF1B5E20) +val KetchLightOnTertiaryContainer = Color(0xFF007717) -// Light error -val KetchLightError = Color(0xFFC62828) -val KetchLightErrorContainer = Color(0xFFEF9A9A) +val KetchLightError = KetchPalette.Light.danger +val KetchLightErrorContainer = Color(0xFFFCE4E5) val KetchLightOnError = Color(0xFFFFFFFF) -val KetchLightOnErrorContainer = Color(0xFF8B0000) +val KetchLightOnErrorContainer = Color(0xFF7E1F20) -// State-specific color pairs data class StateColorPair( val foreground: Color, val background: Color, @@ -102,49 +97,21 @@ data class DownloadStateColors( } val DarkStateColors = DownloadStateColors( - downloading = StateColorPair( - Color(0xFF00BCD4), Color(0xFF003840) - ), - queued = StateColorPair( - Color(0xFF90A4AE), Color(0xFF2A2D35) - ), - scheduled = StateColorPair( - Color(0xFF90A4AE), Color(0xFF2A2D35) - ), - paused = StateColorPair( - Color(0xFFFFB74D), Color(0xFF3A2E1B) - ), - completed = StateColorPair( - Color(0xFF66BB6A), Color(0xFF1B3A2B) - ), - failed = StateColorPair( - Color(0xFFEF5350), Color(0xFF3A1B1B) - ), - canceled = StateColorPair( - Color(0xFF78909C), Color(0xFF2A2D35) - ), + downloading = StateColorPair(KetchPalette.SignalDark.primary, KetchPalette.SignalDark.container), + queued = StateColorPair(KetchPalette.Dark.textSec, KetchPalette.Dark.bgHover), + scheduled = StateColorPair(KetchPalette.Dark.textSec, KetchPalette.Dark.bgHover), + paused = StateColorPair(KetchPalette.Dark.warning, Color(0xFF3A2E1B)), + completed = StateColorPair(KetchPalette.Dark.success, Color(0xFF163A22)), + failed = StateColorPair(KetchPalette.Dark.danger, Color(0xFF3A1B1B)), + canceled = StateColorPair(KetchPalette.Dark.textDim, KetchPalette.Dark.bgHover), ) val LightStateColors = DownloadStateColors( - downloading = StateColorPair( - Color(0xFF00838F), Color(0xFFE0F7FA) - ), - queued = StateColorPair( - Color(0xFF546E7A), Color(0xFFECEFF1) - ), - scheduled = StateColorPair( - Color(0xFF546E7A), Color(0xFFECEFF1) - ), - paused = StateColorPair( - Color(0xFFEF6C00), Color(0xFFFFF3E0) - ), - completed = StateColorPair( - Color(0xFF2E7D32), Color(0xFFE8F5E9) - ), - failed = StateColorPair( - Color(0xFFC62828), Color(0xFFFFEBEE) - ), - canceled = StateColorPair( - Color(0xFF78909C), Color(0xFFECEFF1) - ), + downloading = StateColorPair(KetchPalette.SignalLight.primary, KetchPalette.SignalLight.container), + queued = StateColorPair(KetchPalette.Light.textSec, KetchPalette.Light.bgHover), + scheduled = StateColorPair(KetchPalette.Light.textSec, KetchPalette.Light.bgHover), + paused = StateColorPair(KetchPalette.Light.warning, Color(0xFFFFF0D6)), + completed = StateColorPair(KetchPalette.Light.success, Color(0xFFDCF3E1)), + failed = StateColorPair(KetchPalette.Light.danger, Color(0xFFFCE4E5)), + canceled = StateColorPair(KetchPalette.Light.textDim, KetchPalette.Light.bgHover), ) diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchColors.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchColors.kt new file mode 100644 index 00000000..ee4fde5c --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchColors.kt @@ -0,0 +1,203 @@ +package com.linroid.ketch.app.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + +@Immutable +data class KetchColors( + // Surfaces + val background: Color, + val surface: Color, + val surfaceVariant: Color, + val surfaceHover: Color, + + // Borders + val outline: Color, + val outlineVariant: Color, + + // Content + val onBackground: Color, + val onSurfaceVariant: Color, + val onSurfaceDim: Color, + + // Accent + val primary: Color, + val primaryContainer: Color, + val onPrimaryContainer: Color, + + // Status + val success: Color, + val warning: Color, + val error: Color, + + // Eight-color palette for segment visualization + val segments: List, + + val isDark: Boolean, +) { + // Descriptive aliases used throughout the mocks + val bg: Color get() = background + val bgElev: Color get() = surface + val panel: Color get() = surfaceVariant + val bgHover: Color get() = surfaceHover + val line: Color get() = outline + val lineSoft: Color get() = outlineVariant + val text: Color get() = onBackground + val textSec: Color get() = onSurfaceVariant + val textDim: Color get() = onSurfaceDim + val accent: Color get() = primary + val accentSoft: Color get() = primaryContainer + val accentText: Color get() = onPrimaryContainer +} + +enum class KetchAccent(val displayName: String) { + Signal("Signal"), + Harbor("Harbor"), + Fathom("Fathom"), + Beacon("Beacon"), +} + +internal object KetchPalette { + + object Dark { + val bg = Color(0xFF101214) + val bgElev = Color(0xFF16191B) + val bgHover = Color(0xFF1F2225) + val panel = Color(0xFF1B1D20) + val line = Color(0xFF2B2E32) + val lineSoft = Color(0xFF222427) + val text = Color(0xFFF0F2F4) + val textSec = Color(0xFFA1A5A9) + val textDim = Color(0xFF6E7276) + val success = Color(0xFF54B66E) + val warning = Color(0xFFE6AC3D) + val danger = Color(0xFFF05F5A) + } + + object Light { + val bg = Color(0xFFF9FAFB) + val bgElev = Color(0xFFFFFFFF) + val bgHover = Color(0xFFEDEFF0) + val panel = Color(0xFFF5F7F9) + val line = Color(0xFFDFE1E4) + val lineSoft = Color(0xFFEAEBED) + val text = Color(0xFF13161A) + val textSec = Color(0xFF51565B) + val textDim = Color(0xFF83878B) + val success = Color(0xFF2A904B) + val warning = Color(0xFFD58300) + val danger = Color(0xFFDE3B3D) + } + + data class AccentTriple(val primary: Color, val container: Color, val onContainer: Color) + + val SignalLight = AccentTriple(Color(0xFF0076D8), Color(0xFFD8EEFF), Color(0xFF005DBD)) + val SignalDark = AccentTriple(Color(0xFF319CFC), Color(0xFF01345E), Color(0xFF6DBDFF)) + val HarborLight = AccentTriple(Color(0xFF00909E), Color(0xFFCDF4F6), Color(0xFF007785)) + val HarborDark = AccentTriple(Color(0xFF00B5C1), Color(0xFF003F45), Color(0xFF00D1DA)) + val FathomLight = AccentTriple(Color(0xFF008F32), Color(0xFFD9F3DD), Color(0xFF007717)) + val FathomDark = AccentTriple(Color(0xFF2EB45C), Color(0xFF003F17), Color(0xFF6FD087)) + val BeaconLight = AccentTriple(Color(0xFFBF4C00), Color(0xFFFFE5D2), Color(0xFFA43200)) + val BeaconDark = AccentTriple(Color(0xFFE57600), Color(0xFF542300), Color(0xFFFB9D59)) + + val SignalSegmentsLight = listOf( + Color(0xFF007CDF), Color(0xFF4697E4), Color(0xFF005EB3), Color(0xFF67AAED), + Color(0xFF004E95), Color(0xFF85BCF5), Color(0xFF00437F), Color(0xFF9DC9F7), + ) + val SignalSegmentsDark = listOf( + Color(0xFF42A3FD), Color(0xFF4393E1), Color(0xFF3C7EBE), Color(0xFF6DB0F4), + Color(0xFF116BB5), Color(0xFF8CC3FC), Color(0xFF125A98), Color(0xFF90BCE9), + ) + val HarborSegmentsLight = listOf( + Color(0xFF0096A4), Color(0xFF00AAB4), Color(0xFF007581), Color(0xFF14BBC2), + Color(0xFF00616B), Color(0xFF5DCBD1), Color(0xFF00535B), Color(0xFF83D4D8), + ) + val HarborSegmentsDark = listOf( + Color(0xFF00BAC5), Color(0xFF00A7B1), Color(0xFF008E96), Color(0xFF24C1C9), + Color(0xFF007F88), Color(0xFF64D1D7), Color(0xFF006A72), Color(0xFF76C7CC), + ) + val FathomSegmentsLight = listOf( + Color(0xFF009639), Color(0xFF47AA62), Color(0xFF007424), Color(0xFF69BA7C), + Color(0xFF00601C), Color(0xFF88CA95), Color(0xFF00531B), Color(0xFF9FD3A9), + ) + val FathomSegmentsDark = listOf( + Color(0xFF43B966), Color(0xFF43A65F), Color(0xFF3D8E53), Color(0xFF6FC082), + Color(0xFF0A7E3A), Color(0xFF8ED09C), Color(0xFF0F6A31), Color(0xFF93C69D), + ) + val BeaconSegmentsLight = listOf( + Color(0xFFC65300), Color(0xFFD27830), Color(0xFF9D3A00), Color(0xFFDE8F57), + Color(0xFF832F00), Color(0xFFE9A679), Color(0xFF702A00), Color(0xFFEDB793), + ) + val BeaconSegmentsDark = listOf( + Color(0xFFE87F25), Color(0xFFCF752D), Color(0xFFB0652A), Color(0xFFE5955D), + Color(0xFFA34D00), Color(0xFFF0AD7F), Color(0xFF894100), Color(0xFFE0AA86), + ) +} + +fun lightKetchColors(accent: KetchAccent = KetchAccent.Signal): KetchColors { + val a = when (accent) { + KetchAccent.Signal -> KetchPalette.SignalLight + KetchAccent.Harbor -> KetchPalette.HarborLight + KetchAccent.Fathom -> KetchPalette.FathomLight + KetchAccent.Beacon -> KetchPalette.BeaconLight + } + val segs = when (accent) { + KetchAccent.Signal -> KetchPalette.SignalSegmentsLight + KetchAccent.Harbor -> KetchPalette.HarborSegmentsLight + KetchAccent.Fathom -> KetchPalette.FathomSegmentsLight + KetchAccent.Beacon -> KetchPalette.BeaconSegmentsLight + } + return KetchColors( + background = KetchPalette.Light.bg, + surface = KetchPalette.Light.bgElev, + surfaceVariant = KetchPalette.Light.panel, + surfaceHover = KetchPalette.Light.bgHover, + outline = KetchPalette.Light.line, + outlineVariant = KetchPalette.Light.lineSoft, + onBackground = KetchPalette.Light.text, + onSurfaceVariant = KetchPalette.Light.textSec, + onSurfaceDim = KetchPalette.Light.textDim, + primary = a.primary, + primaryContainer = a.container, + onPrimaryContainer = a.onContainer, + success = KetchPalette.Light.success, + warning = KetchPalette.Light.warning, + error = KetchPalette.Light.danger, + segments = segs, + isDark = false, + ) +} + +fun darkKetchColors(accent: KetchAccent = KetchAccent.Signal): KetchColors { + val a = when (accent) { + KetchAccent.Signal -> KetchPalette.SignalDark + KetchAccent.Harbor -> KetchPalette.HarborDark + KetchAccent.Fathom -> KetchPalette.FathomDark + KetchAccent.Beacon -> KetchPalette.BeaconDark + } + val segs = when (accent) { + KetchAccent.Signal -> KetchPalette.SignalSegmentsDark + KetchAccent.Harbor -> KetchPalette.HarborSegmentsDark + KetchAccent.Fathom -> KetchPalette.FathomSegmentsDark + KetchAccent.Beacon -> KetchPalette.BeaconSegmentsDark + } + return KetchColors( + background = KetchPalette.Dark.bg, + surface = KetchPalette.Dark.bgElev, + surfaceVariant = KetchPalette.Dark.panel, + surfaceHover = KetchPalette.Dark.bgHover, + outline = KetchPalette.Dark.line, + outlineVariant = KetchPalette.Dark.lineSoft, + onBackground = KetchPalette.Dark.text, + onSurfaceVariant = KetchPalette.Dark.textSec, + onSurfaceDim = KetchPalette.Dark.textDim, + primary = a.primary, + primaryContainer = a.container, + onPrimaryContainer = a.onContainer, + success = KetchPalette.Dark.success, + warning = KetchPalette.Dark.warning, + error = KetchPalette.Dark.danger, + segments = segs, + isDark = true, + ) +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchElevation.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchElevation.kt new file mode 100644 index 00000000..0d0b1c3e --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchElevation.kt @@ -0,0 +1,75 @@ +package com.linroid.ketch.app.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Immutable +data class ShadowLayer( + val offsetX: Dp, + val offsetY: Dp, + val blur: Dp, + val spread: Dp, + val color: Color, +) + +@Immutable +data class KetchElevation( + val level0: List = emptyList(), + val level1: List, + val level2: List, + val level3: List, + val level4: List, + val level5: List, +) { + val button: List get() = level1 + val card: List get() = level2 + val popover: List get() = level3 + val dialog: List get() = level4 + val window: List get() = level5 +} + +fun lightKetchElevation(): KetchElevation = KetchElevation( + level1 = listOf( + ShadowLayer(0.dp, 1.dp, 2.dp, 0.dp, Color(0x14000000)), + ShadowLayer(0.dp, 0.dp, 0.dp, (-0.5).dp, Color(0x14000000)), + ), + level2 = listOf( + ShadowLayer(0.dp, 2.dp, 4.dp, 0.dp, Color(0x0F000000)), + ShadowLayer(0.dp, 4.dp, 12.dp, 0.dp, Color(0x0A000000)), + ), + level3 = listOf( + ShadowLayer(0.dp, 4.dp, 8.dp, 0.dp, Color(0x14000000)), + ShadowLayer(0.dp, 8.dp, 24.dp, 0.dp, Color(0x0F000000)), + ), + level4 = listOf( + ShadowLayer(0.dp, 12.dp, 16.dp, 0.dp, Color(0x1F000000)), + ShadowLayer(0.dp, 24.dp, 48.dp, 0.dp, Color(0x14000000)), + ), + level5 = listOf( + ShadowLayer(0.dp, 1.dp, 2.dp, 0.dp, Color(0x0A000000)), + ShadowLayer(0.dp, 20.dp, 50.dp, 0.dp, Color(0x24000000)), + ), +) + +fun darkKetchElevation(): KetchElevation = KetchElevation( + level1 = listOf( + ShadowLayer(0.dp, 1.dp, 2.dp, 0.dp, Color(0x40000000)), + ), + level2 = listOf( + ShadowLayer(0.dp, 2.dp, 4.dp, 0.dp, Color(0x33000000)), + ShadowLayer(0.dp, 4.dp, 12.dp, 0.dp, Color(0x26000000)), + ), + level3 = listOf( + ShadowLayer(0.dp, 4.dp, 8.dp, 0.dp, Color(0x40000000)), + ShadowLayer(0.dp, 8.dp, 24.dp, 0.dp, Color(0x33000000)), + ), + level4 = listOf( + ShadowLayer(0.dp, 12.dp, 16.dp, 0.dp, Color(0x59000000)), + ShadowLayer(0.dp, 24.dp, 48.dp, 0.dp, Color(0x40000000)), + ), + level5 = listOf( + ShadowLayer(0.dp, 20.dp, 50.dp, 0.dp, Color(0x66000000)), + ), +) diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchMotion.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchMotion.kt new file mode 100644 index 00000000..c434972a --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchMotion.kt @@ -0,0 +1,22 @@ +package com.linroid.ketch.app.theme + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.Easing +import androidx.compose.runtime.Immutable + +@Immutable +data class KetchMotion( + val durationMicro: Int = 80, + val durationShort: Int = 120, + val durationMedium: Int = 200, + val durationLong: Int = 320, + val durationExtra: Int = 480, + + val easeStandard: Easing = CubicBezierEasing(0.20f, 0.00f, 0.00f, 1.00f), + val easeEmphasized: Easing = CubicBezierEasing(0.20f, 0.00f, 0.00f, 1.00f), + val easeDecelerate: Easing = CubicBezierEasing(0.00f, 0.00f, 0.00f, 1.00f), + val easeAccelerate: Easing = CubicBezierEasing(0.30f, 0.00f, 1.00f, 1.00f), + val easeLinear: Easing = Easing { it }, +) + +fun ketchMotion(): KetchMotion = KetchMotion() diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchShapes.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchShapes.kt new file mode 100644 index 00000000..46ecc6e1 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchShapes.kt @@ -0,0 +1,27 @@ +package com.linroid.ketch.app.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp + +@Immutable +data class KetchShapes( + val xs: Shape = RoundedCornerShape(3.dp), + val sm: Shape = RoundedCornerShape(6.dp), + val md: Shape = RoundedCornerShape(8.dp), + val lg: Shape = RoundedCornerShape(10.dp), + val xl: Shape = RoundedCornerShape(14.dp), + val round: Shape = RoundedCornerShape(percent = 50), + + // Semantic aliases + val button: Shape = RoundedCornerShape(8.dp), + val textField: Shape = RoundedCornerShape(8.dp), + val card: Shape = RoundedCornerShape(10.dp), + val sidebarItem: Shape = RoundedCornerShape(8.dp), + val badge: Shape = RoundedCornerShape(3.dp), + val progressBar: Shape = RoundedCornerShape(2.dp), + val dialog: Shape = RoundedCornerShape(12.dp), +) + +fun ketchShapes(): KetchShapes = KetchShapes() diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchSpacing.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchSpacing.kt new file mode 100644 index 00000000..614a6b8d --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchSpacing.kt @@ -0,0 +1,22 @@ +package com.linroid.ketch.app.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Immutable +data class KetchSpacing( + val xxs: Dp = 2.dp, + val xs: Dp = 4.dp, + val sm: Dp = 6.dp, + val md: Dp = 8.dp, + val lg: Dp = 12.dp, + val xl: Dp = 16.dp, + val xxl: Dp = 20.dp, + val xxxl: Dp = 24.dp, + val x4l: Dp = 32.dp, + val x5l: Dp = 40.dp, + val x6l: Dp = 64.dp, +) + +fun ketchSpacing(): KetchSpacing = KetchSpacing() diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchTypography.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchTypography.kt new file mode 100644 index 00000000..9549ae24 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/KetchTypography.kt @@ -0,0 +1,88 @@ +package com.linroid.ketch.app.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +@Immutable +data class KetchTypography( + // Display / section headers + val displayLarge: TextStyle, + val displayMedium: TextStyle, + val displaySmall: TextStyle, + + // Body + val bodyLarge: TextStyle, + val bodyMedium: TextStyle, + val bodySmall: TextStyle, + + // Labels + val labelLarge: TextStyle, + val labelMedium: TextStyle, + val labelSmall: TextStyle, + + // Monospace — for sizes / speeds / URLs + val monoMedium: TextStyle, + val monoSmall: TextStyle, + val monoXSmall: TextStyle, +) + +// Platform-safe defaults. Wire in bundled Inter + JetBrains Mono resources later. +val KetchSans: FontFamily = FontFamily.SansSerif +val KetchMono: FontFamily = FontFamily.Monospace + +fun ketchTypography( + sans: FontFamily = KetchSans, + mono: FontFamily = KetchMono, +): KetchTypography = KetchTypography( + displayLarge = TextStyle( + fontFamily = sans, fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, lineHeight = 30.sp, letterSpacing = (-0.3).sp, + ), + displayMedium = TextStyle( + fontFamily = sans, fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, lineHeight = 26.sp, letterSpacing = (-0.25).sp, + ), + displaySmall = TextStyle( + fontFamily = sans, fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, lineHeight = 24.sp, letterSpacing = (-0.2).sp, + ), + bodyLarge = TextStyle( + fontFamily = sans, fontWeight = FontWeight.Normal, + fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = (-0.1).sp, + ), + bodyMedium = TextStyle( + fontFamily = sans, fontWeight = FontWeight.Normal, + fontSize = 13.sp, lineHeight = 18.sp, letterSpacing = 0.sp, + ), + bodySmall = TextStyle( + fontFamily = sans, fontWeight = FontWeight.Normal, + fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.sp, + ), + labelLarge = TextStyle( + fontFamily = sans, fontWeight = FontWeight.Medium, + fontSize = 13.sp, lineHeight = 16.sp, letterSpacing = 0.sp, + ), + labelMedium = TextStyle( + fontFamily = sans, fontWeight = FontWeight.Medium, + fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.sp, + ), + labelSmall = TextStyle( + fontFamily = sans, fontWeight = FontWeight.Medium, + fontSize = 11.sp, lineHeight = 14.sp, letterSpacing = 0.6.sp, + ), + monoMedium = TextStyle( + fontFamily = mono, fontWeight = FontWeight.Medium, + fontSize = 13.sp, lineHeight = 18.sp, letterSpacing = (-0.2).sp, + ), + monoSmall = TextStyle( + fontFamily = mono, fontWeight = FontWeight.Normal, + fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.sp, + ), + monoXSmall = TextStyle( + fontFamily = mono, fontWeight = FontWeight.Medium, + fontSize = 11.sp, lineHeight = 14.sp, letterSpacing = 0.3.sp, + ), +) diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/Theme.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/Theme.kt index 2117f659..e51d4061 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/Theme.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/theme/Theme.kt @@ -1,101 +1,156 @@ package com.linroid.ketch.app.theme import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color -val LocalDownloadStateColors = - staticCompositionLocalOf { DarkStateColors } - -private val KetchDarkColorScheme = darkColorScheme( - primary = KetchPrimary, - onPrimary = KetchOnPrimary, - primaryContainer = KetchPrimaryContainer, - onPrimaryContainer = KetchOnPrimaryContainer, - secondary = KetchSecondary, - onSecondary = KetchOnSecondary, - secondaryContainer = KetchSecondaryContainer, - onSecondaryContainer = KetchOnSecondaryContainer, - tertiary = KetchTertiary, - onTertiary = KetchOnTertiary, - tertiaryContainer = KetchTertiaryContainer, - onTertiaryContainer = KetchOnTertiaryContainer, - error = KetchError, - onError = KetchOnError, - errorContainer = KetchErrorContainer, - onErrorContainer = KetchOnErrorContainer, - background = KetchBackground, - onBackground = KetchOnSurface, - surface = KetchSurface, - onSurface = KetchOnSurface, - surfaceVariant = KetchSurfaceVariant, - onSurfaceVariant = KetchOnSurfaceVariant, - surfaceContainerLowest = KetchBackground, - surfaceContainerLow = KetchSurface, - surfaceContainer = KetchSurfaceContainer, - surfaceContainerHigh = KetchSurfaceContainerHigh, - surfaceContainerHighest = KetchSurfaceVariant, - outline = KetchOutline, - outlineVariant = KetchOutlineVariant, -) +val LocalDownloadStateColors = staticCompositionLocalOf { DarkStateColors } -private val KetchLightColorScheme = lightColorScheme( - primary = KetchLightPrimary, - onPrimary = KetchLightOnPrimary, - primaryContainer = KetchLightPrimaryContainer, - onPrimaryContainer = KetchLightOnPrimaryContainer, - secondary = KetchLightSecondary, - onSecondary = KetchLightOnSecondary, - secondaryContainer = KetchLightSecondaryContainer, - onSecondaryContainer = KetchLightOnSecondaryContainer, - tertiary = KetchLightTertiary, - onTertiary = KetchLightOnTertiary, - tertiaryContainer = KetchLightTertiaryContainer, - onTertiaryContainer = KetchLightOnTertiaryContainer, - error = KetchLightError, - onError = KetchLightOnError, - errorContainer = KetchLightErrorContainer, - onErrorContainer = KetchLightOnErrorContainer, - background = KetchLightBackground, - onBackground = KetchLightOnSurface, - surface = KetchLightSurface, - onSurface = KetchLightOnSurface, - surfaceVariant = KetchLightSurfaceVariant, - onSurfaceVariant = KetchLightOnSurfaceVariant, - surfaceContainerLowest = KetchLightSurface, - surfaceContainerLow = KetchLightBackground, - surfaceContainer = KetchLightSurfaceContainer, - surfaceContainerHigh = KetchLightSurfaceContainerHigh, - surfaceContainerHighest = KetchLightSurfaceVariant, - outline = KetchLightOutline, - outlineVariant = KetchLightOutlineVariant, -) +val LocalKetchColors = staticCompositionLocalOf { + error("KetchColors not provided. Wrap your UI in KetchTheme { … }.") +} + +val LocalKetchTypography = staticCompositionLocalOf { + error("KetchTypography not provided. Wrap your UI in KetchTheme { … }.") +} + +val LocalKetchShapes = staticCompositionLocalOf { + error("KetchShapes not provided. Wrap your UI in KetchTheme { … }.") +} + +val LocalKetchSpacing = staticCompositionLocalOf { + error("KetchSpacing not provided. Wrap your UI in KetchTheme { … }.") +} + +val LocalKetchElevation = staticCompositionLocalOf { + error("KetchElevation not provided. Wrap your UI in KetchTheme { … }.") +} + +val LocalKetchMotion = staticCompositionLocalOf { + error("KetchMotion not provided. Wrap your UI in KetchTheme { … }.") +} @Composable fun KetchTheme( darkTheme: Boolean = isSystemInDarkTheme(), + accent: KetchAccent = KetchAccent.Signal, + colors: KetchColors = if (darkTheme) darkKetchColors(accent) else lightKetchColors(accent), + typography: KetchTypography = ketchTypography(), + shapes: KetchShapes = ketchShapes(), + spacing: KetchSpacing = ketchSpacing(), + elevation: KetchElevation = if (darkTheme) darkKetchElevation() else lightKetchElevation(), + motion: KetchMotion = ketchMotion(), content: @Composable () -> Unit, ) { - val colorScheme = if (darkTheme) { - KetchDarkColorScheme - } else { - KetchLightColorScheme - } - val stateColors = if (darkTheme) { - DarkStateColors - } else { - LightStateColors - } + val stateColors = if (darkTheme) DarkStateColors else LightStateColors CompositionLocalProvider( - LocalDownloadStateColors provides stateColors + LocalDownloadStateColors provides stateColors, + LocalKetchColors provides colors, + LocalKetchTypography provides typography, + LocalKetchShapes provides shapes, + LocalKetchSpacing provides spacing, + LocalKetchElevation provides elevation, + LocalKetchMotion provides motion, ) { MaterialTheme( - colorScheme = colorScheme, + colorScheme = colors.toMaterialColorScheme(darkTheme), + typography = typography.toMaterialTypography(), + shapes = shapes.toMaterialShapes(), content = content, ) } } + +object KetchTheme { + val colors: KetchColors + @Composable @ReadOnlyComposable + get() = LocalKetchColors.current + + val typography: KetchTypography + @Composable @ReadOnlyComposable + get() = LocalKetchTypography.current + + val shapes: KetchShapes + @Composable @ReadOnlyComposable + get() = LocalKetchShapes.current + + val spacing: KetchSpacing + @Composable @ReadOnlyComposable + get() = LocalKetchSpacing.current + + val elevation: KetchElevation + @Composable @ReadOnlyComposable + get() = LocalKetchElevation.current + + val motion: KetchMotion + @Composable @ReadOnlyComposable + get() = LocalKetchMotion.current +} + +internal fun KetchColors.toMaterialColorScheme(dark: Boolean): ColorScheme { + val base = if (dark) darkColorScheme() else lightColorScheme() + return base.copy( + background = background, + onBackground = onBackground, + surface = surface, + onSurface = onBackground, + surfaceVariant = surfaceVariant, + onSurfaceVariant = onSurfaceVariant, + surfaceContainerLowest = background, + surfaceContainerLow = surface, + surfaceContainer = surfaceVariant, + surfaceContainerHigh = surfaceHover, + surfaceContainerHighest = surfaceHover, + outline = outline, + outlineVariant = outlineVariant, + primary = primary, + onPrimary = Color.White, + primaryContainer = primaryContainer, + onPrimaryContainer = onPrimaryContainer, + secondary = onPrimaryContainer, + onSecondary = Color.White, + secondaryContainer = primaryContainer, + onSecondaryContainer = onPrimaryContainer, + tertiary = success, + onTertiary = Color.White, + error = error, + onError = Color.White, + errorContainer = error.copy(alpha = 0.12f), + onErrorContainer = error, + ) +} + +internal fun KetchTypography.toMaterialTypography(): Typography = Typography( + displayLarge = displayLarge, + displayMedium = displayMedium, + displaySmall = displaySmall, + headlineLarge = displayMedium, + headlineMedium = displaySmall, + headlineSmall = displaySmall, + titleLarge = displaySmall, + titleMedium = labelLarge, + titleSmall = labelMedium, + bodyLarge = bodyLarge, + bodyMedium = bodyMedium, + bodySmall = bodySmall, + labelLarge = labelLarge, + labelMedium = labelMedium, + labelSmall = labelSmall, +) + +internal fun KetchShapes.toMaterialShapes(): Shapes = Shapes( + extraSmall = xs as androidx.compose.foundation.shape.CornerBasedShape, + small = sm as androidx.compose.foundation.shape.CornerBasedShape, + medium = md as androidx.compose.foundation.shape.CornerBasedShape, + large = lg as androidx.compose.foundation.shape.CornerBasedShape, + extraLarge = xl as androidx.compose.foundation.shape.CornerBasedShape, +) From 75eb47617b3b3b101d955d16ba3329194ac79bb5 Mon Sep 17 00:00:00 2001 From: Lin Zhang Date: Thu, 7 May 2026 01:04:54 +0800 Subject: [PATCH 2/3] feat: wire screens to Ketch design system Replaces Material 3 primitives across the shared UI with KetchButton, KetchIconButton, KetchSidebarItem, KetchProgressBar, KetchBadge, KetchCard, and KetchIconImage so screens render in the design system's visual language (3dp accent rail on selected sidebar items, monospaced mono-styled metadata, neutral-tinted icon buttons). Touched: AppShell, SidebarNavigation, SpeedStatusBar, BatchActionBar, DownloadList + DownloadListItem + ProgressSection + TaskActionButtons, PriorityBadge, StatusIndicator, AddRemoteServerDialog, AddDownloadDialog, AiDiscoverDialog, EmbeddedServerControls. Compiles clean for Android, JVM, iOS-Simulator-Arm64, and WasmJs. --- .../com/linroid/ketch/app/ui/AppShell.kt | 93 ++++------ .../ketch/app/ui/common/PriorityBadge.kt | 48 +---- .../ketch/app/ui/common/StatusIndicator.kt | 49 ++--- .../ketch/app/ui/dialog/AddDownloadDialog.kt | 55 +++--- .../app/ui/dialog/AddRemoteServerDialog.kt | 48 ++--- .../ketch/app/ui/dialog/AiDiscoverDialog.kt | 30 ++-- .../app/ui/dialog/EmbeddedServerControls.kt | 67 +++---- .../linroid/ketch/app/ui/list/DownloadList.kt | 55 +++--- .../ketch/app/ui/list/DownloadListItem.kt | 42 ++--- .../ketch/app/ui/list/ProgressSection.kt | 87 ++++----- .../ketch/app/ui/list/TaskActionButtons.kt | 82 +++------ .../ketch/app/ui/sidebar/SidebarNavigation.kt | 168 +++--------------- .../ketch/app/ui/sidebar/SpeedStatusBar.kt | 44 ++--- .../ketch/app/ui/toolbar/BatchActionBar.kt | 33 +--- 14 files changed, 283 insertions(+), 618 deletions(-) diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/AppShell.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/AppShell.kt index f5a3e787..32194cf3 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/AppShell.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/AppShell.kt @@ -8,21 +8,11 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.AutoAwesome -import androidx.compose.material3.IconButton import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.VerticalDivider @@ -30,6 +20,13 @@ import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType +import com.linroid.ketch.app.components.KetchButton +import com.linroid.ketch.app.components.KetchCard +import com.linroid.ketch.app.components.KetchIconButton +import com.linroid.ketch.app.components.KetchButtonSize +import com.linroid.ketch.app.icons.KetchIcon +import com.linroid.ketch.app.icons.KetchIconImage +import com.linroid.ketch.app.theme.KetchTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -185,17 +182,17 @@ fun AppShell( Badge { Text(count.toString()) } }, ) { - Icon( - imageVector = filterIcon(filter), - contentDescription = filter.label, - modifier = Modifier.size(24.dp), + KetchIconImage( + icon = filterIcon(filter), + size = 24.dp, + tint = KetchTheme.colors.onSurfaceVariant, ) } } else { - Icon( - imageVector = filterIcon(filter), - contentDescription = filter.label, - modifier = Modifier.size(24.dp), + KetchIconImage( + icon = filterIcon(filter), + size = 24.dp, + tint = KetchTheme.colors.onSurfaceVariant, ) } }, @@ -237,16 +234,12 @@ fun AppShell( ) }, actions = { - IconButton( + KetchIconButton( + icon = KetchIcon.Ai, onClick = { appState.showAiDiscoverDialog = true }, - ) { - Icon( - Icons.Filled.AutoAwesome, - contentDescription = "AI Discover", - ) - } + ) BatchActionBar( hasActiveDownloads = hasActive, hasPausedDownloads = hasPaused, @@ -266,15 +259,11 @@ fun AppShell( // Error banner if (appState.errorMessage != null) { - Card( - colors = CardDefaults.cardColors( - containerColor = - MaterialTheme.colorScheme - .errorContainer, - ), + KetchCard( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), + padding = 0.dp, ) { Row( modifier = Modifier.padding(16.dp), @@ -285,19 +274,18 @@ fun AppShell( ) { Text( text = appState.errorMessage ?: "", - style = - MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme - .onErrorContainer, + style = KetchTheme.typography.bodySmall, + color = KetchTheme.colors.error, modifier = Modifier.weight(1f), ) - TextButton( - onClick = { - appState.dismissError() - }, - ) { - Text("Dismiss") - } + KetchButton( + text = "Dismiss", + onClick = { appState.dismissError() }, + variant = + com.linroid.ketch.app.components + .KetchButtonVariant.Ghost, + size = KetchButtonSize.Small, + ) } } } @@ -331,25 +319,18 @@ fun AppShell( ) } - // FAB for Compact/Medium layouts (sidebar has its - // own "New Task" button on Expanded) + // FAB-style primary action for Compact/Medium layouts. + // (Sidebar has its own "New Task" button on Expanded.) if (!isExpanded) { - FloatingActionButton( + KetchButton( + text = "New Task", onClick = { appState.requestAddDownload() }, + leadingIcon = KetchIcon.Plus, + size = KetchButtonSize.Large, modifier = Modifier .align(Alignment.BottomEnd) .padding(end = 16.dp, bottom = 72.dp), - containerColor = - MaterialTheme.colorScheme.primary, - contentColor = - MaterialTheme.colorScheme.onPrimary, - shape = RoundedCornerShape(16.dp), - ) { - Icon( - Icons.Filled.Add, - contentDescription = "New Task", - ) - } + ) } } } diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/common/PriorityBadge.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/common/PriorityBadge.kt index 5580b4a8..7dd914b5 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/common/PriorityBadge.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/common/PriorityBadge.kt @@ -1,50 +1,18 @@ package com.linroid.ketch.app.ui.common -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import com.linroid.ketch.api.DownloadPriority +import com.linroid.ketch.app.components.KetchBadge +import com.linroid.ketch.app.components.KetchBadgeTone import com.linroid.ketch.app.util.priorityLabel @Composable fun PriorityBadge(priority: DownloadPriority) { - val color = when (priority) { - DownloadPriority.LOW -> - MaterialTheme.colorScheme.surfaceVariant - DownloadPriority.NORMAL -> - MaterialTheme.colorScheme.secondaryContainer - DownloadPriority.HIGH -> - MaterialTheme.colorScheme.tertiaryContainer - DownloadPriority.URGENT -> - MaterialTheme.colorScheme.errorContainer - } - val textColor = when (priority) { - DownloadPriority.LOW -> - MaterialTheme.colorScheme.onSurfaceVariant - DownloadPriority.NORMAL -> - MaterialTheme.colorScheme.onSecondaryContainer - DownloadPriority.HIGH -> - MaterialTheme.colorScheme.onTertiaryContainer - DownloadPriority.URGENT -> - MaterialTheme.colorScheme.onErrorContainer - } - Box( - modifier = Modifier - .background( - color = color, - shape = MaterialTheme.shapes.small, - ) - .padding(horizontal = 6.dp, vertical = 2.dp), - ) { - Text( - text = priorityLabel(priority), - style = MaterialTheme.typography.labelSmall, - color = textColor, - ) + val tone = when (priority) { + DownloadPriority.LOW -> KetchBadgeTone.Neutral + DownloadPriority.NORMAL -> KetchBadgeTone.Accent + DownloadPriority.HIGH -> KetchBadgeTone.Warning + DownloadPriority.URGENT -> KetchBadgeTone.Danger } + KetchBadge(text = priorityLabel(priority), tone = tone) } diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/common/StatusIndicator.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/common/StatusIndicator.kt index a7f1b9c1..16f6317e 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/common/StatusIndicator.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/common/StatusIndicator.kt @@ -4,22 +4,14 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDownward -import androidx.compose.material.icons.filled.Cancel -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.ErrorOutline -import androidx.compose.material.icons.filled.Inbox -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.Schedule -import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import com.linroid.ketch.api.DownloadState +import com.linroid.ketch.app.icons.KetchIcon +import com.linroid.ketch.app.icons.KetchIconImage import com.linroid.ketch.app.theme.LocalDownloadStateColors @Composable @@ -38,35 +30,16 @@ fun StatusIndicator( .background(colors.background), contentAlignment = Alignment.Center, ) { - Icon( - imageVector = icon, - contentDescription = stateLabel(state), - tint = colors.foreground, - modifier = Modifier.size(20.dp), - ) + KetchIconImage(icon = icon, size = 20.dp, tint = colors.foreground) } } -private fun stateIcon(state: DownloadState): ImageVector { - return when (state) { - is DownloadState.Downloading -> Icons.Filled.ArrowDownward - is DownloadState.Queued -> Icons.Filled.Inbox - is DownloadState.Scheduled -> Icons.Filled.Schedule - is DownloadState.Paused -> Icons.Filled.Pause - is DownloadState.Completed -> Icons.Filled.CheckCircle - is DownloadState.Failed -> Icons.Filled.ErrorOutline - is DownloadState.Canceled -> Icons.Filled.Cancel - } -} - -private fun stateLabel(state: DownloadState): String { - return when (state) { - is DownloadState.Downloading -> "Downloading" - is DownloadState.Queued -> "Queued" - is DownloadState.Scheduled -> "Scheduled" - is DownloadState.Paused -> "Paused" - is DownloadState.Completed -> "Completed" - is DownloadState.Failed -> "Failed" - is DownloadState.Canceled -> "Canceled" - } +private fun stateIcon(state: DownloadState): KetchIcon = when (state) { + is DownloadState.Downloading -> KetchIcon.Active + is DownloadState.Queued -> KetchIcon.Queued + is DownloadState.Scheduled -> KetchIcon.Scheduled + is DownloadState.Paused -> KetchIcon.Pause + is DownloadState.Completed -> KetchIcon.Done + is DownloadState.Failed -> KetchIcon.Failed + is DownloadState.Canceled -> KetchIcon.Close } diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/AddDownloadDialog.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/AddDownloadDialog.kt index c7f50e90..469a6b39 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/AddDownloadDialog.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/AddDownloadDialog.kt @@ -27,7 +27,9 @@ import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.WarningAmber import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button +import com.linroid.ketch.app.components.KetchButton +import com.linroid.ketch.app.components.KetchButtonSize +import com.linroid.ketch.app.components.KetchButtonVariant import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -36,7 +38,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -408,7 +409,8 @@ fun AddDownloadDialog( confirmButton = { val hasMultipleFiles = resolved != null && resolved.files.size > 1 - Button( + KetchButton( + text = "Download", onClick = { val downloadUrl = buildResolveUrl() if (downloadUrl.isNotEmpty()) { @@ -426,19 +428,17 @@ fun AddDownloadDialog( }, enabled = url.isNotBlank() && (!hasMultipleFiles || selectedFileIds.isNotEmpty()), - ) { - Text("Download") - } + ) }, dismissButton = { - TextButton( + KetchButton( + text = "Cancel", onClick = { onResetResolve() onDismiss() - } - ) { - Text("Cancel") - } + }, + variant = KetchButtonVariant.Ghost, + ) } ) } @@ -680,13 +680,12 @@ private fun CredentialFields( PasswordVisualTransformation(), ) } - Button( + KetchButton( + text = "Retry with credentials", onClick = onRetry, enabled = username.isNotBlank(), modifier = Modifier.align(Alignment.End), - ) { - Text("Retry with credentials") - } + ) } } @@ -723,26 +722,20 @@ private fun FileSelector( horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - TextButton( + KetchButton( + text = "All", onClick = onSelectAll, enabled = selectedIds.size < files.size, - ) { - Text( - text = "All", - style = - MaterialTheme.typography.labelSmall, - ) - } - TextButton( + variant = KetchButtonVariant.Ghost, + size = KetchButtonSize.Small, + ) + KetchButton( + text = "None", onClick = onDeselectAll, enabled = selectedIds.isNotEmpty(), - ) { - Text( - text = "None", - style = - MaterialTheme.typography.labelSmall, - ) - } + variant = KetchButtonVariant.Ghost, + size = KetchButtonSize.Small, + ) } } diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/AddRemoteServerDialog.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/AddRemoteServerDialog.kt index e5dcd9d3..9a5e20ba 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/AddRemoteServerDialog.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/AddRemoteServerDialog.kt @@ -12,16 +12,14 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Dns import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import com.linroid.ketch.app.components.KetchButton +import com.linroid.ketch.app.components.KetchButtonVariant import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -115,28 +113,19 @@ fun AddRemoteServerDialog( placeholder = { Text("Optional") }, ) if (discovering) { - OutlinedButton( + KetchButton( + text = "Stop", onClick = onStopDiscovery, + variant = KetchButtonVariant.Secondary, modifier = Modifier.fillMaxWidth(), - ) { - CircularProgressIndicator( - modifier = Modifier - .padding(end = 8.dp) - .size(16.dp), - strokeWidth = 2.dp, - ) - Text("Stop") - } + ) } else { - Button( - onClick = { - onDiscover(port.toIntOrNull() ?: 8642) - }, + KetchButton( + text = "Discover on LAN", + onClick = { onDiscover(port.toIntOrNull() ?: 8642) }, enabled = isValidPort, modifier = Modifier.fillMaxWidth(), - ) { - Text("Discover on LAN") - } + ) } if (discoveryState is DiscoveryState.Error) { Text( @@ -198,23 +187,24 @@ fun AddRemoteServerDialog( } }, confirmButton = { - Button( + KetchButton( + text = if (authRequired) "Connect" else "Add", onClick = { onAdd( host.trim(), port.toIntOrNull() ?: 8642, - token.trim().ifBlank { null } + token.trim().ifBlank { null }, ) }, enabled = isValidHost && isValidPort, - ) { - Text(if (authRequired) "Connect" else "Add") - } + ) }, dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } + KetchButton( + text = "Cancel", + onClick = onDismiss, + variant = KetchButtonVariant.Ghost, + ) } ) } diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/AiDiscoverDialog.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/AiDiscoverDialog.kt index 62d5d64a..5a18676e 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/AiDiscoverDialog.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/AiDiscoverDialog.kt @@ -11,14 +11,14 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button +import com.linroid.ketch.app.components.KetchButton +import com.linroid.ketch.app.components.KetchButtonVariant import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -164,31 +164,29 @@ fun AiDiscoverDialog( confirmButton = { val results = state as? AiDiscoverState.Results if (results != null && selected.isNotEmpty()) { - Button( + KetchButton( + text = "Download ${selected.size} selected", onClick = { val selectedCandidates = - results.candidates.filter { - it.url in selected - } + results.candidates.filter { it.url in selected } onDownloadSelected(selectedCandidates) }, - ) { - Text("Download ${selected.size} selected") - } + ) } else { - Button( + KetchButton( + text = "Discover", onClick = { onDiscover(query, sites) }, enabled = query.isNotBlank() && state !is AiDiscoverState.Loading, - ) { - Text("Discover") - } + ) } }, dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } + KetchButton( + text = "Cancel", + onClick = onDismiss, + variant = KetchButtonVariant.Ghost, + ) }, ) } diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/EmbeddedServerControls.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/EmbeddedServerControls.kt index b28a7836..7c094e32 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/EmbeddedServerControls.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/EmbeddedServerControls.kt @@ -1,25 +1,20 @@ package com.linroid.ketch.app.ui.dialog import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Computer -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.linroid.ketch.app.components.KetchButton +import com.linroid.ketch.app.components.KetchButtonSize +import com.linroid.ketch.app.components.KetchButtonVariant +import com.linroid.ketch.app.components.KetchIconButton +import com.linroid.ketch.app.icons.KetchIcon import com.linroid.ketch.app.instance.ServerState +import com.linroid.ketch.app.theme.KetchTheme @Composable fun EmbeddedServerControls( @@ -32,52 +27,30 @@ fun EmbeddedServerControls( Row( modifier = Modifier.padding(top = 4.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = - Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( text = "Server on :${serverState.port}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, + style = KetchTheme.typography.labelSmall, + color = KetchTheme.colors.primary, ) - FilledTonalIconButton( + KetchIconButton( + icon = KetchIcon.Close, onClick = onStopServer, - modifier = Modifier.size(24.dp), - colors = IconButtonDefaults - .filledTonalIconButtonColors( - containerColor = MaterialTheme.colorScheme - .errorContainer, - contentColor = MaterialTheme.colorScheme - .onErrorContainer - ) - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = "Stop server", - modifier = Modifier.size(14.dp), - ) - } + size = KetchButtonSize.Small, + tint = KetchTheme.colors.error, + ) } } is ServerState.Stopped -> { - TextButton( + KetchButton( + text = "Start Server", onClick = { onStartServer(8642, null) }, + leadingIcon = KetchIcon.Local, + variant = KetchButtonVariant.Ghost, + size = KetchButtonSize.Small, modifier = Modifier.padding(top = 2.dp), - contentPadding = PaddingValues( - horizontal = 8.dp, vertical = 0.dp, - ) - ) { - Icon( - imageVector = Icons.Filled.Computer, - contentDescription = null, - modifier = Modifier.size(14.dp), - ) - Spacer(Modifier.size(4.dp)) - Text( - text = "Start Server", - style = MaterialTheme.typography.labelSmall, - ) - } + ) } } } diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/DownloadList.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/DownloadList.kt index a6479687..f3a52afe 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/DownloadList.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/DownloadList.kt @@ -10,12 +10,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.CloudDownload -import androidx.compose.material.icons.outlined.FilterList -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -24,7 +18,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.linroid.ketch.api.DownloadTask +import com.linroid.ketch.app.components.KetchButton +import com.linroid.ketch.app.icons.KetchIcon +import com.linroid.ketch.app.icons.KetchIconImage import com.linroid.ketch.app.state.StatusFilter +import com.linroid.ketch.app.theme.KetchTheme import kotlinx.coroutines.CoroutineScope @Composable @@ -88,30 +86,29 @@ private fun EmptyState( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Icon( - imageVector = Icons.Outlined.CloudDownload, - contentDescription = null, - modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.primary - .copy(alpha = 0.6f), + KetchIconImage( + icon = KetchIcon.Active, + size = 64.dp, + tint = KetchTheme.colors.primary.copy(alpha = 0.6f), ) Spacer(modifier = Modifier.height(8.dp)) Text( text = "No downloads yet", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, + style = KetchTheme.typography.displaySmall.copy(fontWeight = FontWeight.SemiBold), + color = KetchTheme.colors.onBackground, ) Text( text = "Click \"New Task\" to start downloading", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = KetchTheme.typography.bodyMedium, + color = KetchTheme.colors.onSurfaceVariant, textAlign = TextAlign.Center, ) Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = onAddClick) { - Text("New Task") - } + KetchButton( + text = "New Task", + onClick = onAddClick, + leadingIcon = KetchIcon.Plus, + ) } } } @@ -129,23 +126,21 @@ private fun EmptyFilterState( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Icon( - imageVector = Icons.Outlined.FilterList, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - .copy(alpha = 0.4f), + KetchIconImage( + icon = KetchIcon.Filter, + size = 48.dp, + tint = KetchTheme.colors.onSurfaceVariant.copy(alpha = 0.4f), ) Spacer(modifier = Modifier.height(4.dp)) Text( text = "No ${filter.label.lowercase()} downloads", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = KetchTheme.typography.bodyLarge, + color = KetchTheme.colors.onSurfaceVariant, ) Text( text = "Try a different category", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.outline, + style = KetchTheme.typography.bodySmall, + color = KetchTheme.colors.onSurfaceDim, ) } } diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/DownloadListItem.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/DownloadListItem.kt index 0a5aba21..8d980e69 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/DownloadListItem.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/DownloadListItem.kt @@ -11,14 +11,13 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import com.linroid.ketch.app.components.KetchIconButton +import com.linroid.ketch.app.components.KetchButtonSize +import com.linroid.ketch.app.icons.KetchIcon +import com.linroid.ketch.app.theme.KetchTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -82,10 +81,8 @@ fun DownloadListItem( }, enabled = isDownloading || isPaused, colors = CardDefaults.cardColors( - containerColor = - MaterialTheme.colorScheme.surfaceContainer, - disabledContainerColor = - MaterialTheme.colorScheme.surfaceContainer + containerColor = KetchTheme.colors.surface, + disabledContainerColor = KetchTheme.colors.surface, ), modifier = modifier.fillMaxWidth(), ) { @@ -114,9 +111,10 @@ fun DownloadListItem( ) { Text( text = fileName, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, + style = KetchTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = KetchTheme.colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight( @@ -131,9 +129,8 @@ fun DownloadListItem( } Text( text = task.request.url, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme - .onSurfaceVariant, + style = KetchTheme.typography.monoSmall, + color = KetchTheme.colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -227,18 +224,11 @@ fun DownloadListItem( ) } Spacer(modifier = Modifier.weight(1f)) - IconButton( + KetchIconButton( + icon = KetchIcon.Trash, onClick = { scope.launch { task.remove() } }, - modifier = Modifier.size(32.dp), - ) { - Icon( - imageVector = Icons.Outlined.DeleteOutline, - contentDescription = "Remove", - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme - .onSurfaceVariant - ) - } + size = KetchButtonSize.Medium, + ) } if (showToggles) { diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/ProgressSection.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/ProgressSection.kt index 7c115a10..3f3e83b5 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/ProgressSection.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/ProgressSection.kt @@ -3,14 +3,14 @@ package com.linroid.ketch.app.ui.list import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import com.linroid.ketch.api.DownloadState import com.linroid.ketch.api.SpeedLimit +import com.linroid.ketch.app.components.KetchProgressBar +import com.linroid.ketch.app.theme.KetchTheme import com.linroid.ketch.app.theme.LocalDownloadStateColors import com.linroid.ketch.app.util.formatBytes import com.linroid.ketch.app.util.formatEta @@ -21,17 +21,18 @@ fun ProgressSection( speedLimit: SpeedLimit, ) { val stateColors = LocalDownloadStateColors.current + val type = KetchTheme.typography + val colors = KetchTheme.colors when (state) { is DownloadState.Downloading -> { val progress = state.progress val pct = (progress.percent * 100).coerceIn(0f, 100f) - val colors = stateColors.downloading - LinearProgressIndicator( - progress = { progress.percent }, + val active = stateColors.downloading + KetchProgressBar( + progress = progress.percent, modifier = Modifier.fillMaxWidth(), - color = colors.foreground, - trackColor = MaterialTheme.colorScheme.surfaceVariant, + fillColor = active.foreground, ) Row( modifier = Modifier.fillMaxWidth(), @@ -40,93 +41,83 @@ fun ProgressSection( Text( text = buildString { append("${pct.toInt()}%") - append( - " \u00b7 ${formatBytes(progress.downloadedBytes)}" - ) + append(" · ${formatBytes(progress.downloadedBytes)}") append(" / ${formatBytes(progress.totalBytes)}") }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = type.monoSmall, + color = colors.onSurfaceVariant, ) Text( text = buildString { if (progress.bytesPerSecond > 0) { - append( - "${formatBytes(progress.bytesPerSecond)}/s" - ) + append("${formatBytes(progress.bytesPerSecond)}/s") if (progress.totalBytes > 0) { - val remaining = progress.totalBytes - - progress.downloadedBytes - val eta = - remaining / progress.bytesPerSecond + val remaining = progress.totalBytes - progress.downloadedBytes + val eta = remaining / progress.bytesPerSecond val etaStr = formatEta(eta) if (etaStr.isNotEmpty()) { - append(" \u00b7 $etaStr") + append(" · $etaStr") } } } if (!speedLimit.isUnlimited) { - append(" (limit: " + formatBytes(speedLimit.bytesPerSecond) + "/s)") + append(" (limit: ${formatBytes(speedLimit.bytesPerSecond)}/s)") } }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = type.monoSmall, + color = colors.onSurfaceVariant, ) } } is DownloadState.Paused -> { val progress = state.progress - val colors = stateColors.paused + val pausedColors = stateColors.paused if (progress.totalBytes > 0) { - val pct = - (progress.percent * 100).coerceIn(0f, 100f) - LinearProgressIndicator( - progress = { progress.percent }, + val pct = (progress.percent * 100).coerceIn(0f, 100f) + KetchProgressBar( + progress = progress.percent, modifier = Modifier.fillMaxWidth(), - color = colors.foreground, - trackColor = - MaterialTheme.colorScheme.surfaceVariant + fillColor = pausedColors.foreground, ) Text( - text = "Paused \u00b7 ${pct.toInt()}%" + - " \u00b7 " + formatBytes(progress.downloadedBytes) + - " / ${formatBytes(progress.totalBytes)}", - style = MaterialTheme.typography.bodySmall, - color = colors.foreground, + text = "Paused · ${pct.toInt()}% · " + + "${formatBytes(progress.downloadedBytes)} / ${formatBytes(progress.totalBytes)}", + style = type.bodySmall, + color = pausedColors.foreground, ) } else { Text( text = "Paused", - style = MaterialTheme.typography.bodySmall, - color = colors.foreground, + style = type.bodySmall, + color = pausedColors.foreground, ) } } is DownloadState.Queued -> { Text( - text = "Queued \u2014 waiting for download slot\u2026", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = "Queued — waiting for download slot…", + style = type.bodySmall, + color = colors.onSurfaceVariant, ) } is DownloadState.Scheduled -> { Text( - text = "Scheduled \u2014 waiting for start time\u2026", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = "Scheduled — waiting for start time…", + style = type.bodySmall, + color = colors.onSurfaceVariant, ) } is DownloadState.Completed -> { Text( text = "Download complete", - style = MaterialTheme.typography.bodySmall, + style = type.bodySmall, color = stateColors.completed.foreground, ) } is DownloadState.Failed -> { Text( text = "Failed: ${state.error.message}", - style = MaterialTheme.typography.bodySmall, + style = type.bodySmall, color = stateColors.failed.foreground, maxLines = 2, overflow = TextOverflow.Ellipsis, @@ -135,8 +126,8 @@ fun ProgressSection( is DownloadState.Canceled -> { Text( text = "Canceled", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = type.bodySmall, + color = colors.onSurfaceVariant, ) } } diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/TaskActionButtons.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/TaskActionButtons.kt index 700ec31d..47e41bdd 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/TaskActionButtons.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/TaskActionButtons.kt @@ -2,22 +2,18 @@ package com.linroid.ketch.app.ui.list import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.window.core.layout.WindowSizeClass import com.linroid.ketch.api.DownloadState +import com.linroid.ketch.app.components.KetchButtonSize +import com.linroid.ketch.app.components.KetchIconButton +import com.linroid.ketch.app.icons.KetchIcon +import com.linroid.ketch.app.theme.KetchTheme @Composable fun TaskActionButtons( @@ -28,6 +24,11 @@ fun TaskActionButtons( onRetry: () -> Unit, modifier: Modifier = Modifier, ) { + val isCompact = !currentWindowAdaptiveInfo().windowSizeClass + .isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) + val size = if (isCompact) KetchButtonSize.Large else KetchButtonSize.Medium + val colors = KetchTheme.colors + Row( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(2.dp), @@ -35,41 +36,16 @@ fun TaskActionButtons( ) { when (state) { is DownloadState.Downloading -> { - ActionIcon( - icon = Icons.Filled.Pause, - description = "Pause", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - onClick = onPause, - ) - ActionIcon( - icon = Icons.Filled.Close, - description = "Cancel", - tint = MaterialTheme.colorScheme.error, - onClick = onCancel, - ) + Action(KetchIcon.Pause, colors.onSurfaceVariant, size, onPause) + Action(KetchIcon.Close, colors.error, size, onCancel) } is DownloadState.Paused -> { - ActionIcon( - icon = Icons.Filled.PlayArrow, - description = "Resume", - tint = MaterialTheme.colorScheme.primary, - onClick = onResume, - ) - ActionIcon( - icon = Icons.Filled.Close, - description = "Cancel", - tint = MaterialTheme.colorScheme.error, - onClick = onCancel, - ) + Action(KetchIcon.Play, colors.primary, size, onResume) + Action(KetchIcon.Close, colors.error, size, onCancel) } is DownloadState.Failed, is DownloadState.Canceled -> { - ActionIcon( - icon = Icons.Filled.Refresh, - description = "Retry", - tint = MaterialTheme.colorScheme.primary, - onClick = onRetry, - ) + Action(KetchIcon.Retry, colors.primary, size, onRetry) } is DownloadState.Completed, is DownloadState.Scheduled, @@ -79,29 +55,11 @@ fun TaskActionButtons( } @Composable -private fun ActionIcon( - icon: androidx.compose.ui.graphics.vector.ImageVector, - description: String, - tint: androidx.compose.ui.graphics.Color, +private fun Action( + icon: KetchIcon, + tint: Color, + size: KetchButtonSize, onClick: () -> Unit, ) { - val windowSizeClass = - currentWindowAdaptiveInfo().windowSizeClass - val isCompact = !windowSizeClass - .isWidthAtLeastBreakpoint( - WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND - ) - val buttonSize = if (isCompact) 48.dp else 32.dp - val iconSize = if (isCompact) 22.dp else 18.dp - IconButton( - onClick = onClick, - modifier = Modifier.size(buttonSize), - ) { - Icon( - imageVector = icon, - contentDescription = description, - modifier = Modifier.size(iconSize), - tint = tint, - ) - } + KetchIconButton(icon = icon, onClick = onClick, size = size, tint = tint) } diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/sidebar/SidebarNavigation.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/sidebar/SidebarNavigation.kt index c97a968c..e08a3b19 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/sidebar/SidebarNavigation.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/sidebar/SidebarNavigation.kt @@ -1,42 +1,26 @@ package com.linroid.ketch.app.ui.sidebar import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowDownward -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.ErrorOutline -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import com.linroid.ketch.app.components.KetchButton +import com.linroid.ketch.app.components.KetchSidebarItem +import com.linroid.ketch.app.icons.KetchIcon import com.linroid.ketch.app.state.StatusFilter +import com.linroid.ketch.app.theme.KetchTheme -private val SIDEBAR_WIDTH = 200.dp +private val SIDEBAR_WIDTH = 220.dp @Composable fun SidebarNavigation( @@ -46,154 +30,54 @@ fun SidebarNavigation( onAddClick: () -> Unit, modifier: Modifier = Modifier, ) { + val colors = KetchTheme.colors Column( modifier = modifier .width(SIDEBAR_WIDTH) .fillMaxHeight() - .background(MaterialTheme.colorScheme.surfaceContainerLow) + .background(colors.surfaceVariant) .padding(vertical = 12.dp), ) { - // Add download button - FloatingActionButton( + KetchButton( + text = "New Task", onClick = onAddClick, + leadingIcon = KetchIcon.Plus, modifier = Modifier .padding(horizontal = 16.dp) .fillMaxWidth(), - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - shape = RoundedCornerShape(12.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - Icons.Filled.Add, - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - Text( - text = "New Task", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - ) - } - } + ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(Modifier.height(16.dp)) HorizontalDivider( modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.outlineVariant, + color = colors.outlineVariant, ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(Modifier.height(8.dp)) - // Category label Text( text = "TASKS", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding( - horizontal = 20.dp, vertical = 8.dp, - ) + style = KetchTheme.typography.labelSmall.copy(fontWeight = FontWeight.SemiBold), + color = colors.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), ) - // Navigation items StatusFilter.entries.forEach { filter -> val count = taskCounts[filter] ?: 0 - SidebarItem( - icon = filterIcon(filter), + KetchSidebarItem( label = filter.label, - count = count, + icon = filterIcon(filter), selected = selectedFilter == filter, onClick = { onFilterSelect(filter) }, + count = if (count > 0) count else null, ) } } } -@Composable -private fun SidebarItem( - icon: ImageVector, - label: String, - count: Int, - selected: Boolean, - onClick: () -> Unit, -) { - val bgColor = if (selected) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) - } else { - MaterialTheme.colorScheme.surfaceContainerLow - } - val contentColor = if (selected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 1.dp) - .clip(RoundedCornerShape(8.dp)) - .background(bgColor) - .clickable(onClick = onClick) - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - imageVector = icon, - contentDescription = label, - modifier = Modifier.size(20.dp), - tint = contentColor, - ) - Text( - text = label, - style = MaterialTheme.typography.bodyMedium, - color = if (selected) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - fontWeight = if (selected) { - FontWeight.SemiBold - } else { - FontWeight.Normal - }, - modifier = Modifier.weight(1f), - ) - if (count > 0) { - Box( - modifier = Modifier - .background( - color = if (selected) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) - } else { - MaterialTheme.colorScheme.surfaceContainerHigh - }, - shape = CircleShape, - ) - .padding(horizontal = 8.dp, vertical = 2.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = count.toString(), - style = MaterialTheme.typography.labelSmall, - color = contentColor, - fontWeight = FontWeight.SemiBold, - ) - } - } - } -} - -internal fun filterIcon(filter: StatusFilter): ImageVector { - return when (filter) { - StatusFilter.All -> Icons.Filled.Folder - StatusFilter.Downloading -> Icons.Filled.ArrowDownward - StatusFilter.Paused -> Icons.Filled.Pause - StatusFilter.Completed -> Icons.Filled.CheckCircle - StatusFilter.Failed -> Icons.Filled.ErrorOutline - } +internal fun filterIcon(filter: StatusFilter): KetchIcon = when (filter) { + StatusFilter.All -> KetchIcon.All + StatusFilter.Downloading -> KetchIcon.Active + StatusFilter.Paused -> KetchIcon.Pause + StatusFilter.Completed -> KetchIcon.Done + StatusFilter.Failed -> KetchIcon.Failed } diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/sidebar/SpeedStatusBar.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/sidebar/SpeedStatusBar.kt index 20071f15..3a49f9b9 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/sidebar/SpeedStatusBar.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/sidebar/SpeedStatusBar.kt @@ -6,15 +6,13 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDownward -import androidx.compose.material.icons.filled.Speed import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import com.linroid.ketch.app.icons.KetchIcon +import com.linroid.ketch.app.icons.KetchIconImage +import com.linroid.ketch.app.theme.KetchTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -34,12 +32,10 @@ fun SpeedStatusBar( modifier: Modifier = Modifier, ) { Surface( - color = MaterialTheme.colorScheme.surfaceContainerLow, + color = KetchTheme.colors.surfaceVariant, modifier = modifier.fillMaxWidth(), ) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant, - ) + HorizontalDivider(color = KetchTheme.colors.outlineVariant) val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass val isCompact = !windowSizeClass @@ -65,8 +61,8 @@ fun SpeedStatusBar( } Text( text = instanceLabel ?: "Not connected", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = KetchTheme.typography.labelSmall, + color = KetchTheme.colors.onSurfaceVariant, ) } // Right side: speed info @@ -79,16 +75,15 @@ fun SpeedStatusBar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - Icon( - Icons.Filled.ArrowDownward, - contentDescription = "Download speed", - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.primary, + KetchIconImage( + icon = KetchIcon.Active, + size = 14.dp, + tint = KetchTheme.colors.primary, ) Text( text = "${formatBytes(totalSpeed)}/s", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, + style = KetchTheme.typography.monoXSmall, + color = KetchTheme.colors.primary, ) } } @@ -96,11 +91,10 @@ fun SpeedStatusBar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - Icon( - Icons.Filled.Speed, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, + KetchIconImage( + icon = KetchIcon.Speed, + size = 14.dp, + tint = KetchTheme.colors.onSurfaceVariant, ) Text( text = if (activeDownloads > 0) { @@ -108,8 +102,8 @@ fun SpeedStatusBar( } else { "Idle" }, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = KetchTheme.typography.labelSmall, + color = KetchTheme.colors.onSurfaceVariant, ) } } diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/toolbar/BatchActionBar.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/toolbar/BatchActionBar.kt index 2e73b9ff..3a1191cd 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/toolbar/BatchActionBar.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/toolbar/BatchActionBar.kt @@ -2,17 +2,12 @@ package com.linroid.ketch.app.ui.toolbar import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CleaningServices -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.linroid.ketch.app.components.KetchIconButton +import com.linroid.ketch.app.icons.KetchIcon @Composable fun BatchActionBar( @@ -30,31 +25,13 @@ fun BatchActionBar( horizontalArrangement = Arrangement.spacedBy(2.dp), ) { if (hasActiveDownloads) { - IconButton(onClick = onPauseAll) { - Icon( - Icons.Filled.Pause, - contentDescription = "Pause all", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + KetchIconButton(icon = KetchIcon.Pause, onClick = onPauseAll) } if (hasPausedDownloads) { - IconButton(onClick = onResumeAll) { - Icon( - Icons.Filled.PlayArrow, - contentDescription = "Resume all", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + KetchIconButton(icon = KetchIcon.Play, onClick = onResumeAll) } if (hasCompletedDownloads) { - IconButton(onClick = onClearCompleted) { - Icon( - Icons.Filled.CleaningServices, - contentDescription = "Clear completed", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + KetchIconButton(icon = KetchIcon.Trash, onClick = onClearCompleted) } } } From b2ba964d0fe591526fe1cf89f6a4be194dcf3edf Mon Sep 17 00:00:00 2001 From: Lin Zhang Date: Thu, 7 May 2026 01:54:21 +0800 Subject: [PATCH 3/3] feat: implement desktop hero design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructures the desktop shell to match the design exploration: - DownloadListItem: chip + name + thin progress + mono metric + status pill + contextual action, all on one 56dp row. Click expands. - DownloadExpandedPanel: per-segment progress (KetchSegmentDetail driven by DownloadTask.segments), 30-sample speed sparkline (KetchSpeedChart), and metadata grid (URL, priority, destination, error). - KetchToolbar: title + bandwidth readout (live MB/s with cap meter) + search hint pill (⌘K) + AI button + batch actions + primary Add Download. Wired in for expanded layouts; TopAppBar still used on compact/medium. - SidebarNavigation: Ketch wordmark header with macOS traffic-light insets + clickable connection pill (local/remote, hostname, status dot) → instance selector. New Task moves to the toolbar to mirror the design. - KetchFileTypeChip: small accent-tinted file-extension tile. Compiles clean for Android, JVM, iOS-Simulator-Arm64, WasmJs. --- .../ketch/app/components/KetchFileTypeChip.kt | 59 ++ .../com/linroid/ketch/app/ui/AppShell.kt | 91 ++-- .../app/ui/list/DownloadExpandedPanel.kt | 164 ++++++ .../ketch/app/ui/list/DownloadListItem.kt | 509 +++++++++++------- .../ketch/app/ui/sidebar/SidebarNavigation.kt | 176 +++++- .../ketch/app/ui/toolbar/KetchToolbar.kt | 198 +++++++ 6 files changed, 946 insertions(+), 251 deletions(-) create mode 100644 app/shared/src/commonMain/kotlin/com/linroid/ketch/app/components/KetchFileTypeChip.kt create mode 100644 app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/DownloadExpandedPanel.kt create mode 100644 app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/toolbar/KetchToolbar.kt diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/components/KetchFileTypeChip.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/components/KetchFileTypeChip.kt new file mode 100644 index 00000000..695de162 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/components/KetchFileTypeChip.kt @@ -0,0 +1,59 @@ +package com.linroid.ketch.app.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.linroid.ketch.app.theme.KetchTheme + +/** + * Small file-type tile rendered to the left of a download row. Uses the + * accent-segment palette so different extensions remain visually distinct + * even on long lists. + */ +@Composable +fun KetchFileTypeChip( + fileName: String, + modifier: Modifier = Modifier, + size: Dp = 26.dp, +) { + val ext = fileName.substringAfterLast('.', "").lowercase() + val palette = KetchTheme.colors.segments + val color: Color = when (ext) { + "iso" -> palette[0] + "zip", "7z", "rar" -> palette[3] + "xz", "gz", "tar", "bz2" -> palette[2] + "parquet", "csv", "json" -> palette[4] + "safetensors", "ckpt", "bin", "pt", "h5" -> palette[5] + "mp4", "mkv", "webm", "mov", "avi" -> palette[1] + "mp3", "flac", "wav", "ogg", "m4a" -> palette[6] + "pdf", "epub", "djvu" -> palette[7] + else -> KetchTheme.colors.onSurfaceDim + } + val label = ext.take(3).ifBlank { "·" } + + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(size) + .clip(RoundedCornerShape(6.dp)) + .background(color.copy(alpha = 0.13f)), + ) { + Text( + text = label.uppercase(), + style = KetchTheme.typography.monoXSmall.copy( + color = color, + fontWeight = FontWeight.Bold, + ), + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/AppShell.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/AppShell.kt index 32194cf3..402e83a7 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/AppShell.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/AppShell.kt @@ -54,6 +54,7 @@ import com.linroid.ketch.app.ui.sidebar.SidebarNavigation import com.linroid.ketch.app.ui.sidebar.SpeedStatusBar import com.linroid.ketch.app.ui.sidebar.filterIcon import com.linroid.ketch.app.ui.toolbar.BatchActionBar +import com.linroid.ketch.app.ui.toolbar.KetchToolbar import com.linroid.ketch.app.ui.toolbar.countTasksByFilter @OptIn(ExperimentalMaterial3Api::class) @@ -212,50 +213,64 @@ fun AppShell( onFilterSelect = { selected -> appState.statusFilter = selected }, - onAddClick = { - appState.requestAddDownload() + activeInstance = activeInstance, + connectionState = connectionState, + onInstanceClick = { + appState.showInstanceSelector = true }, ) - VerticalDivider( - color = - MaterialTheme.colorScheme.outlineVariant, - ) } // Content area Column(modifier = Modifier.weight(1f)) { - TopAppBar( - title = { - Text( - text = appState.statusFilter.label, - style = - MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - }, - actions = { - KetchIconButton( - icon = KetchIcon.Ai, - onClick = { - appState.showAiDiscoverDialog = true - }, - ) - BatchActionBar( - hasActiveDownloads = hasActive, - hasPausedDownloads = hasPaused, - hasCompletedDownloads = hasCompleted, - onPauseAll = { appState.pauseAll() }, - onResumeAll = { appState.resumeAll() }, - onClearCompleted = { - appState.clearCompleted() - }, - ) - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = - MaterialTheme.colorScheme.surface, - ), - ) + if (isExpanded) { + KetchToolbar( + title = appState.statusFilter.label, + bandwidthBytesPerSec = totalSpeed, + globalCapBytesPerSec = null, + hasActiveDownloads = hasActive, + hasPausedDownloads = hasPaused, + hasCompletedDownloads = hasCompleted, + onPauseAll = { appState.pauseAll() }, + onResumeAll = { appState.resumeAll() }, + onClearCompleted = { appState.clearCompleted() }, + onAiDiscoverClick = { + appState.showAiDiscoverDialog = true + }, + onAddClick = { appState.requestAddDownload() }, + ) + } else { + TopAppBar( + title = { + Text( + text = appState.statusFilter.label, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + }, + actions = { + KetchIconButton( + icon = KetchIcon.Ai, + onClick = { + appState.showAiDiscoverDialog = true + }, + ) + BatchActionBar( + hasActiveDownloads = hasActive, + hasPausedDownloads = hasPaused, + hasCompletedDownloads = hasCompleted, + onPauseAll = { appState.pauseAll() }, + onResumeAll = { appState.resumeAll() }, + onClearCompleted = { + appState.clearCompleted() + }, + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + } // Error banner if (appState.errorMessage != null) { diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/DownloadExpandedPanel.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/DownloadExpandedPanel.kt new file mode 100644 index 00000000..e6c1ec75 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/DownloadExpandedPanel.kt @@ -0,0 +1,164 @@ +package com.linroid.ketch.app.ui.list + +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.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.linroid.ketch.api.DownloadState +import com.linroid.ketch.api.DownloadTask +import com.linroid.ketch.api.Segment +import com.linroid.ketch.app.components.KetchSegmentDetail +import com.linroid.ketch.app.components.KetchSpeedChart +import com.linroid.ketch.app.theme.KetchTheme +import com.linroid.ketch.app.util.priorityLabel +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * Detail panel revealed when a download row is expanded. Two columns: + * - per-segment progress bars (from [DownloadTask.segments]) + * - rolling 30-sample speed sparkline + metadata grid + */ +@Composable +fun DownloadExpandedPanel( + state: DownloadState, + segments: List, + task: DownloadTask, +) { + val colors = KetchTheme.colors + val type = KetchTheme.typography + + val history = remember(task.taskId) { mutableStateListOf() } + DisposableEffect(task.taskId) { + val scope = MainScope() + task.state + .onEach { s -> + val bps = (s as? DownloadState.Downloading)?.progress?.bytesPerSecond ?: 0L + history.add(bps.toFloat()) + if (history.size > 30) history.removeAt(0) + } + .launchIn(scope) + onDispose { scope.cancel() } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(colors.surfaceVariant) + .padding(start = 54.dp, end = 16.dp, top = 12.dp, bottom = 12.dp), + horizontalArrangement = Arrangement.spacedBy(28.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + SectionEyebrow( + text = if (segments.isEmpty()) "Segments" + else "Segments · ${segments.size} parallel connections", + ) + Spacer(Modifier.height(8.dp)) + if (segments.isEmpty()) { + Text( + text = "No active segments yet.", + style = type.bodySmall, + color = colors.onSurfaceDim, + ) + } else { + val progress = segments.map(::segmentProgress) + KetchSegmentDetail( + progress = progress, + health = List(segments.size) { 1f }, + ) + } + } + + Column(modifier = Modifier.widthIn(min = 280.dp, max = 360.dp)) { + SectionEyebrow(text = "Speed · last 30s") + Spacer(Modifier.height(8.dp)) + if (history.size >= 2) { + KetchSpeedChart(samples = history.toList(), height = 70.dp) + } else { + Box(Modifier.height(70.dp).fillMaxWidth()) + } + Spacer(Modifier.height(12.dp)) + MetadataGrid(task = task, state = state) + } + } +} + +@Composable +private fun SectionEyebrow(text: String) { + Text( + text = text.uppercase(), + style = KetchTheme.typography.labelSmall.copy( + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.6.sp, + ), + color = KetchTheme.colors.onSurfaceDim, + ) +} + +@Composable +private fun MetadataGrid(task: DownloadTask, state: DownloadState) { + val colors = KetchTheme.colors + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + MetaRow("URL", task.request.url, valueColor = colors.onPrimaryContainer) + MetaRow( + "Priority", + "P${task.request.priority.ordinal} · ${priorityLabel(task.request.priority)}", + ) + val dest = task.request.destination?.value + if (!dest.isNullOrBlank()) MetaRow("Saved to", dest) + if (state is DownloadState.Failed) { + MetaRow("Error", state.error.message.orEmpty(), valueColor = colors.error) + } + } +} + +@Composable +private fun MetaRow( + label: String, + value: String, + valueColor: Color = KetchTheme.colors.onSurfaceVariant, +) { + val type = KetchTheme.typography + Row(verticalAlignment = Alignment.Top) { + Text( + text = label, + style = type.monoXSmall, + color = KetchTheme.colors.onSurfaceDim, + modifier = Modifier.width(70.dp), + ) + Text( + text = value, + style = type.monoSmall, + color = valueColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private fun segmentProgress(s: Segment): Float { + val total = s.totalBytes + if (total <= 0L) return 0f + return (s.downloadedBytes.toFloat() / total).coerceIn(0f, 1f) +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/DownloadListItem.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/DownloadListItem.kt index 8d980e69..d1e16e1b 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/DownloadListItem.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/list/DownloadListItem.kt @@ -1,23 +1,31 @@ package com.linroid.ketch.app.ui.list import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.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.material3.Card -import androidx.compose.material3.CardDefaults +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text -import com.linroid.ketch.app.components.KetchIconButton -import com.linroid.ketch.app.components.KetchButtonSize -import com.linroid.ketch.app.icons.KetchIcon -import com.linroid.ketch.app.theme.KetchTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -26,6 +34,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -33,6 +43,12 @@ import com.linroid.ketch.api.DownloadPriority import com.linroid.ketch.api.DownloadState import com.linroid.ketch.api.DownloadTask import com.linroid.ketch.api.isName +import com.linroid.ketch.app.components.KetchFileTypeChip +import com.linroid.ketch.app.components.KetchProgressBar +import com.linroid.ketch.app.icons.KetchIcon +import com.linroid.ketch.app.theme.KetchTheme +import com.linroid.ketch.app.theme.LocalDownloadStateColors +import com.linroid.ketch.app.theme.StateColorPair import com.linroid.ketch.app.ui.common.PriorityBadge import com.linroid.ketch.app.ui.common.PriorityIcon import com.linroid.ketch.app.ui.common.PriorityPanel @@ -40,14 +56,15 @@ import com.linroid.ketch.app.ui.common.ScheduleIcon import com.linroid.ketch.app.ui.common.SchedulePanel import com.linroid.ketch.app.ui.common.SpeedLimitIcon import com.linroid.ketch.app.ui.common.SpeedLimitPanel -import com.linroid.ketch.app.ui.common.StatusIndicator import com.linroid.ketch.app.ui.common.TaskSettingsIcon import com.linroid.ketch.app.ui.common.TaskSettingsPanel import com.linroid.ketch.app.util.extractFilename +import com.linroid.ketch.app.util.formatBytes +import com.linroid.ketch.app.util.formatEta import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -private enum class ExpandedPanel { +private enum class ExpandedSubPanel { None, SpeedLimit, Priority, Schedule, Settings } @@ -58,208 +75,318 @@ fun DownloadListItem( modifier: Modifier = Modifier, ) { val state by task.state.collectAsState() + val segments by task.segments.collectAsState() val dest = task.request.destination - val fileName = when { - dest != null && dest.isName() -> dest.value - dest != null -> extractFilename(dest.value) - .ifBlank { null } - else -> null - } ?: extractFilename(task.request.url).ifBlank { "download" } - val isDownloading = state is DownloadState.Downloading - val isPaused = state is DownloadState.Paused - val showToggles = isDownloading || isPaused || - state is DownloadState.Queued || - state is DownloadState.Scheduled - var expanded by remember { mutableStateOf(ExpandedPanel.None) } + val fileName = remember(task.taskId, dest, task.request.url) { + val raw = when { + dest != null && dest.isName() -> dest.value + dest != null -> extractFilename(dest.value).ifBlank { null } + else -> null + } + raw ?: extractFilename(task.request.url).ifBlank { "download" } + } - Card( - onClick = { - scope.launch { - if (isDownloading) task.pause() - else task.resume() - } - }, - enabled = isDownloading || isPaused, - colors = CardDefaults.cardColors( - containerColor = KetchTheme.colors.surface, - disabledContainerColor = KetchTheme.colors.surface, - ), - modifier = modifier.fillMaxWidth(), + var expanded by remember { mutableStateOf(false) } + var subPanel by remember { mutableStateOf(ExpandedSubPanel.None) } + + val colors = KetchTheme.colors + val type = KetchTheme.typography + val stateColors = LocalDownloadStateColors.current.forState(state) + val borderColor = if (expanded) colors.outline else colors.outlineVariant + + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(if (expanded) colors.surface else colors.surface) + .border(1.dp, borderColor, RoundedCornerShape(10.dp)) + .clickable { expanded = !expanded }, ) { - Column( - modifier = Modifier.padding( - horizontal = 16.dp, vertical = 14.dp, - ), - verticalArrangement = Arrangement.spacedBy(10.dp), + DownloadRow( + fileName = fileName, + state = state, + stateColors = stateColors, + task = task, + scope = scope, + ) + + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), ) { - // Header: status icon + file info + actions - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = - Arrangement.spacedBy(12.dp) - ) { - StatusIndicator(state) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = - Arrangement.spacedBy(4.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = - Arrangement.spacedBy(8.dp) - ) { - Text( - text = fileName, - style = KetchTheme.typography.bodyLarge.copy( - fontWeight = FontWeight.SemiBold, - ), - color = KetchTheme.colors.onBackground, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight( - 1f, fill = false, - ) - ) - if (task.request.priority != - DownloadPriority.NORMAL - ) { - PriorityBadge(task.request.priority) - } - } - Text( - text = task.request.url, - style = KetchTheme.typography.monoSmall, - color = KetchTheme.colors.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - TaskActionButtons( + Column { + DownloadExpandedPanel( state = state, - onPause = { - scope.launch { task.pause() } - }, - onResume = { - scope.launch { task.resume() } - }, - onCancel = { - scope.launch { task.cancel() } - }, - onRetry = { - scope.launch { task.resume() } - } + segments = segments, + task = task, ) - } - - // State-specific content - ProgressSection( - state = state, - speedLimit = task.request.speedLimit, - ) - // Toggle icon row + remove button - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = - Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (showToggles) { - SpeedLimitIcon( - active = - !task.request.speedLimit.isUnlimited, - selected = - expanded == ExpandedPanel.SpeedLimit, - onClick = { - expanded = if (expanded == - ExpandedPanel.SpeedLimit - ) { - ExpandedPanel.None - } else { - ExpandedPanel.SpeedLimit - } - } - ) - PriorityIcon( - active = task.request.priority != - DownloadPriority.NORMAL, - selected = - expanded == ExpandedPanel.Priority, - onClick = { - expanded = if (expanded == - ExpandedPanel.Priority - ) { - ExpandedPanel.None - } else { - ExpandedPanel.Priority - } - } - ) - ScheduleIcon( - selected = - expanded == ExpandedPanel.Schedule, - onClick = { - expanded = if (expanded == - ExpandedPanel.Schedule - ) { - ExpandedPanel.None - } else { - ExpandedPanel.Schedule - } - } - ) - TaskSettingsIcon( - selected = - expanded == ExpandedPanel.Settings, - onClick = { - expanded = if (expanded == - ExpandedPanel.Settings - ) { - ExpandedPanel.None - } else { - ExpandedPanel.Settings - } - } - ) - } - Spacer(modifier = Modifier.weight(1f)) - KetchIconButton( - icon = KetchIcon.Trash, - onClick = { scope.launch { task.remove() } }, - size = KetchButtonSize.Medium, + ExpandedSettingsRow( + task = task, + scope = scope, + subPanel = subPanel, + onSubPanelChange = { subPanel = it }, ) - } - if (showToggles) { - // Expanded panel below icons AnimatedContent( - targetState = expanded, + targetState = subPanel, transitionSpec = { - expandVertically() togetherWith shrinkVertically() - } + (expandVertically() + fadeIn()) togetherWith + (shrinkVertically() + fadeOut()) + }, + label = "sub-panel", ) { panel -> when (panel) { - ExpandedPanel.SpeedLimit -> SpeedLimitPanel( - task = task, scope = scope, - ) - ExpandedPanel.Priority -> PriorityPanel( - task = task, scope = scope, - ) - ExpandedPanel.Schedule -> SchedulePanel( + ExpandedSubPanel.SpeedLimit -> SpeedLimitPanel(task, scope) + ExpandedSubPanel.Priority -> PriorityPanel(task, scope) + ExpandedSubPanel.Schedule -> SchedulePanel( task = task, scope = scope, - onScheduled = { - expanded = ExpandedPanel.None - } - ) - ExpandedPanel.Settings -> TaskSettingsPanel( - task = task, + onScheduled = { subPanel = ExpandedSubPanel.None }, ) - ExpandedPanel.None -> {} + ExpandedSubPanel.Settings -> TaskSettingsPanel(task) + ExpandedSubPanel.None -> {} } } } } } } + +@Composable +private fun DownloadRow( + fileName: String, + state: DownloadState, + stateColors: StateColorPair, + task: DownloadTask, + scope: CoroutineScope, +) { + val colors = KetchTheme.colors + val type = KetchTheme.typography + val progress = stateProgress(state) + val animatedPct by animateFloatAsState(progress, tween(400), label = "row-progress") + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 16.dp), + ) { + KetchFileTypeChip(fileName) + + // Name + thin progress + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.weight(1f), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = fileName, + style = type.bodyLarge.copy(fontWeight = FontWeight.Medium), + color = colors.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + if (task.request.priority != DownloadPriority.NORMAL) { + Spacer(Modifier.width(8.dp)) + PriorityBadge(task.request.priority) + } + } + KetchProgressBar( + progress = animatedPct, + fillColor = if (state is DownloadState.Completed) Color.Transparent + else stateColors.foreground, + ) + } + + // Primary metric (mono) + PrimaryMetric(state = state) + + // Status pill + StatusPill(state = state, foreground = stateColors.foreground) + + // Single contextual action + ContextualAction(state = state, task = task, scope = scope) + } +} + +@Composable +private fun PrimaryMetric(state: DownloadState) { + val colors = KetchTheme.colors + val type = KetchTheme.typography + val text = when (state) { + is DownloadState.Downloading -> { + val p = state.progress + val speed = if (p.bytesPerSecond > 0) "${formatBytes(p.bytesPerSecond)}/s" else "--" + val eta = if (p.bytesPerSecond > 0 && p.totalBytes > 0) { + val remaining = (p.totalBytes - p.downloadedBytes).coerceAtLeast(0) + formatEta(remaining / p.bytesPerSecond) + } else "" + if (eta.isNotEmpty()) "$speed · $eta" else speed + } + is DownloadState.Paused -> { + val p = state.progress + if (p.totalBytes > 0) "${formatBytes(p.downloadedBytes)} / ${formatBytes(p.totalBytes)}" + else "Paused" + } + is DownloadState.Queued -> "Queued" + is DownloadState.Scheduled -> "Scheduled" + is DownloadState.Completed -> "" + is DownloadState.Failed -> "Failed" + is DownloadState.Canceled -> "Canceled" + } + Text( + text = text, + style = type.monoSmall, + color = colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 160.dp), + ) +} + +@Composable +private fun StatusPill(state: DownloadState, foreground: Color) { + val colors = KetchTheme.colors + val type = KetchTheme.typography + + val (label, isLive) = when (state) { + is DownloadState.Downloading -> { + val pct = (state.progress.percent * 100).coerceIn(0f, 100f) + "${pct.toInt()}%" to true + } + is DownloadState.Paused -> "Paused" to false + is DownloadState.Queued -> "Queued" to false + is DownloadState.Scheduled -> "Scheduled" to false + is DownloadState.Completed -> "Done" to false + is DownloadState.Failed -> "Failed" to false + is DownloadState.Canceled -> "Canceled" to false + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.widthIn(min = 88.dp), + ) { + Box( + modifier = Modifier + .size(6.dp) + .clip(CircleShape) + .background(foreground) + .let { + if (isLive) it.border( + width = 3.dp, + color = foreground.copy(alpha = 0.18f), + shape = CircleShape, + ) else it + }, + ) + Text( + text = label, + style = type.labelMedium.copy(fontWeight = FontWeight.Medium), + color = foreground, + maxLines = 1, + ) + } +} + +@Composable +private fun ContextualAction( + state: DownloadState, + task: DownloadTask, + scope: CoroutineScope, +) { + val colors = KetchTheme.colors + Box(modifier = Modifier.size(32.dp), contentAlignment = Alignment.Center) { + when (state) { + is DownloadState.Downloading -> RowAction(KetchIcon.Pause, colors.onSurfaceVariant) { + scope.launch { task.pause() } + } + is DownloadState.Paused -> RowAction(KetchIcon.Play, colors.primary) { + scope.launch { task.resume() } + } + is DownloadState.Queued -> RowAction(KetchIcon.More, colors.onSurfaceVariant) {} + is DownloadState.Scheduled -> RowAction(KetchIcon.Scheduled, colors.warning) {} + is DownloadState.Completed -> RowAction(KetchIcon.Folder, colors.onSurfaceVariant) {} + is DownloadState.Failed, + is DownloadState.Canceled -> RowAction(KetchIcon.Retry, colors.primary) { + scope.launch { task.resume() } + } + } + } +} + +@Composable +private fun RowAction(icon: KetchIcon, tint: Color, onClick: () -> Unit) { + Box( + modifier = Modifier + .size(30.dp) + .clip(RoundedCornerShape(7.dp)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + com.linroid.ketch.app.icons.KetchIconImage(icon = icon, size = 16.dp, tint = tint) + } +} + +@Composable +private fun ExpandedSettingsRow( + task: DownloadTask, + scope: CoroutineScope, + subPanel: ExpandedSubPanel, + onSubPanelChange: (ExpandedSubPanel) -> Unit, +) { + val state by task.state.collectAsState() + val canConfigure = state is DownloadState.Downloading || + state is DownloadState.Paused || + state is DownloadState.Queued || + state is DownloadState.Scheduled + if (!canConfigure) return + + fun toggle(target: ExpandedSubPanel) { + onSubPanelChange(if (subPanel == target) ExpandedSubPanel.None else target) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SpeedLimitIcon( + active = !task.request.speedLimit.isUnlimited, + selected = subPanel == ExpandedSubPanel.SpeedLimit, + onClick = { toggle(ExpandedSubPanel.SpeedLimit) }, + ) + PriorityIcon( + active = task.request.priority != DownloadPriority.NORMAL, + selected = subPanel == ExpandedSubPanel.Priority, + onClick = { toggle(ExpandedSubPanel.Priority) }, + ) + ScheduleIcon( + selected = subPanel == ExpandedSubPanel.Schedule, + onClick = { toggle(ExpandedSubPanel.Schedule) }, + ) + TaskSettingsIcon( + selected = subPanel == ExpandedSubPanel.Settings, + onClick = { toggle(ExpandedSubPanel.Settings) }, + ) + Spacer(Modifier.weight(1f)) + com.linroid.ketch.app.components.KetchIconButton( + icon = KetchIcon.Trash, + onClick = { scope.launch { task.remove() } }, + ) + } +} + +private fun stateProgress(state: DownloadState): Float = when (state) { + is DownloadState.Downloading -> state.progress.percent + is DownloadState.Paused -> state.progress.percent + is DownloadState.Completed -> 1f + else -> 0f +} diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/sidebar/SidebarNavigation.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/sidebar/SidebarNavigation.kt index e08a3b19..82e55f1f 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/sidebar/SidebarNavigation.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/sidebar/SidebarNavigation.kt @@ -1,24 +1,39 @@ package com.linroid.ketch.app.ui.sidebar import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.linroid.ketch.app.components.KetchButton +import androidx.compose.ui.unit.sp import com.linroid.ketch.app.components.KetchSidebarItem import com.linroid.ketch.app.icons.KetchIcon +import com.linroid.ketch.app.icons.KetchIconImage +import com.linroid.ketch.app.instance.EmbeddedInstance +import com.linroid.ketch.app.instance.InstanceEntry +import com.linroid.ketch.app.instance.RemoteInstance import com.linroid.ketch.app.state.StatusFilter import com.linroid.ketch.app.theme.KetchTheme +import com.linroid.ketch.remote.ConnectionState private val SIDEBAR_WIDTH = 220.dp @@ -27,40 +42,44 @@ fun SidebarNavigation( selectedFilter: StatusFilter, taskCounts: Map, onFilterSelect: (StatusFilter) -> Unit, - onAddClick: () -> Unit, + activeInstance: InstanceEntry?, + connectionState: ConnectionState?, + onInstanceClick: () -> Unit, modifier: Modifier = Modifier, ) { val colors = KetchTheme.colors + Column( modifier = modifier .width(SIDEBAR_WIDTH) .fillMaxHeight() - .background(colors.surfaceVariant) - .padding(vertical = 12.dp), + .background(colors.surfaceVariant), ) { - KetchButton( - text = "New Task", - onClick = onAddClick, - leadingIcon = KetchIcon.Plus, + // Brand header — keeps the macOS traffic-light insets that desktop windows + // inject. Padding-left is generous so the wordmark clears the lights even + // when the host doesn't insert one. + Box( modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - ) + .fillMaxWidth() + .height(48.dp) + .padding(start = 78.dp, end = 12.dp), + contentAlignment = Alignment.CenterStart, + ) { + Wordmark() + } - Spacer(Modifier.height(16.dp)) - HorizontalDivider( - modifier = Modifier.padding(horizontal = 16.dp), - color = colors.outlineVariant, + // Connection pill (clickable → instance selector). + InstancePill( + activeInstance = activeInstance, + connectionState = connectionState, + onClick = onInstanceClick, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) - Spacer(Modifier.height(8.dp)) - Text( - text = "TASKS", - style = KetchTheme.typography.labelSmall.copy(fontWeight = FontWeight.SemiBold), - color = colors.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), - ) + Spacer(Modifier.height(12.dp)) + // Queue group. + SectionLabel("Queue") StatusFilter.entries.forEach { filter -> val count = taskCounts[filter] ?: 0 KetchSidebarItem( @@ -71,9 +90,122 @@ fun SidebarNavigation( count = if (count > 0) count else null, ) } + + Spacer(Modifier.weight(1f)) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = colors.outlineVariant, + ) } } +@Composable +private fun Wordmark() { + val colors = KetchTheme.colors + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(20.dp) + .clip(RoundedCornerShape(6.dp)) + .background(colors.primary), + ) { + Text( + text = "K", + style = KetchTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), + color = androidx.compose.ui.graphics.Color.White, + ) + } + Text( + text = "Ketch", + style = KetchTheme.typography.displaySmall.copy( + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.3).sp, + ), + color = colors.onBackground, + ) + } +} + +@Composable +private fun InstancePill( + activeInstance: InstanceEntry?, + connectionState: ConnectionState?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val colors = KetchTheme.colors + val type = KetchTheme.typography + val (kindLabel, addressLabel) = when (activeInstance) { + is RemoteInstance -> "Remote daemon" to "${activeInstance.host}:${activeInstance.port}" + is EmbeddedInstance -> "Local daemon" to (activeInstance.label.ifBlank { "in-process" }) + else -> "Not connected" to "Tap to add a daemon" + } + val dotColor = when (connectionState) { + is ConnectionState.Connected -> colors.success + is ConnectionState.Connecting -> colors.warning + is ConnectionState.Disconnected -> colors.error + is ConnectionState.Unauthorized -> colors.error + null -> if (activeInstance is EmbeddedInstance) colors.success else colors.onSurfaceDim + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(colors.background) + .border(1.dp, colors.outline, RoundedCornerShape(6.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) { + Box( + modifier = Modifier + .size(7.dp) + .clip(CircleShape) + .background(dotColor) + .border(3.dp, dotColor.copy(alpha = 0.18f), CircleShape), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = kindLabel, + style = type.labelSmall, + color = colors.onSurfaceDim, + ) + Text( + text = addressLabel, + style = type.bodyMedium.copy(fontWeight = FontWeight.Medium), + color = colors.onBackground, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + ) + } + KetchIconImage( + icon = KetchIcon.ChevronDown, + size = 12.dp, + tint = colors.onSurfaceDim, + ) + } +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text.uppercase(), + style = KetchTheme.typography.labelSmall.copy( + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.6.sp, + ), + color = KetchTheme.colors.onSurfaceDim, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + ) +} + internal fun filterIcon(filter: StatusFilter): KetchIcon = when (filter) { StatusFilter.All -> KetchIcon.All StatusFilter.Downloading -> KetchIcon.Active diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/toolbar/KetchToolbar.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/toolbar/KetchToolbar.kt new file mode 100644 index 00000000..9078db4d --- /dev/null +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/toolbar/KetchToolbar.kt @@ -0,0 +1,198 @@ +package com.linroid.ketch.app.ui.toolbar + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.fillMaxHeight +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.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.linroid.ketch.app.components.KetchButton +import com.linroid.ketch.app.components.KetchIconButton +import com.linroid.ketch.app.icons.KetchIcon +import com.linroid.ketch.app.icons.KetchIconImage +import com.linroid.ketch.app.theme.KetchTheme +import com.linroid.ketch.app.util.formatBytes + +/** + * Top toolbar for the desktop hero view. + * + * Layout (left → right): + * - View title + * - Bandwidth readout (live MB/s + horizontal cap meter) + * - Search hint pill + * - AI discovery icon button + * - Batch action buttons (pause/resume/clear all) + * - Primary "Add download" button + * + * 60dp tall, no bottom border — relies on tonal contrast vs. the body. + */ +@Composable +fun KetchToolbar( + title: String, + bandwidthBytesPerSec: Long, + globalCapBytesPerSec: Long?, + hasActiveDownloads: Boolean, + hasPausedDownloads: Boolean, + hasCompletedDownloads: Boolean, + onPauseAll: () -> Unit, + onResumeAll: () -> Unit, + onClearCompleted: () -> Unit, + onAiDiscoverClick: () -> Unit, + onAddClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val colors = KetchTheme.colors + val type = KetchTheme.typography + + Row( + modifier = modifier + .fillMaxWidth() + .height(60.dp) + .background(colors.surface) + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + text = title, + style = type.displayMedium.copy(fontWeight = FontWeight.SemiBold), + color = colors.onBackground, + ) + + Spacer(Modifier.weight(1f)) + + BandwidthReadout( + bandwidthBytesPerSec = bandwidthBytesPerSec, + globalCapBytesPerSec = globalCapBytesPerSec, + ) + + SearchHint() + + KetchIconButton(icon = KetchIcon.Ai, onClick = onAiDiscoverClick) + + BatchActionBar( + hasActiveDownloads = hasActiveDownloads, + hasPausedDownloads = hasPausedDownloads, + hasCompletedDownloads = hasCompletedDownloads, + onPauseAll = onPauseAll, + onResumeAll = onResumeAll, + onClearCompleted = onClearCompleted, + ) + + KetchButton( + text = "Add download", + onClick = onAddClick, + leadingIcon = KetchIcon.Plus, + ) + } +} + +@Composable +private fun BandwidthReadout( + bandwidthBytesPerSec: Long, + globalCapBytesPerSec: Long?, +) { + val colors = KetchTheme.colors + val type = KetchTheme.typography + val capLabel = globalCapBytesPerSec?.let { "/ ${formatBytes(it)}/s" } ?: "/ ∞" + val capFraction = if (globalCapBytesPerSec != null && globalCapBytesPerSec > 0) { + (bandwidthBytesPerSec.toFloat() / globalCapBytesPerSec).coerceIn(0f, 1f) + } else { + 0f + } + val nearCap = capFraction > 0.9f + val fillColor = if (nearCap) colors.warning else colors.primary + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier + .height(36.dp) + .clip(RoundedCornerShape(8.dp)) + .background(colors.background) + .border(1.dp, colors.outline, RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp), + ) { + KetchIconImage(icon = KetchIcon.Speed, size = 13.dp, tint = colors.onSurfaceVariant) + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "${formatBytes(bandwidthBytesPerSec)}/s", + style = type.monoSmall.copy(fontWeight = FontWeight.SemiBold), + color = colors.onBackground, + ) + Spacer(Modifier.width(4.dp)) + Text( + text = capLabel, + style = type.monoXSmall, + color = colors.onSurfaceDim, + ) + } + Box( + Modifier + .width(110.dp) + .height(3.dp) + .clip(RoundedCornerShape(2.dp)) + .background(colors.outlineVariant), + ) { + if (capFraction > 0f) { + Box( + Modifier + .fillMaxWidth(capFraction) + .fillMaxHeight() + .background(fillColor), + ) + } + } + } + } +} + +@Composable +private fun SearchHint() { + val colors = KetchTheme.colors + val type = KetchTheme.typography + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .height(36.dp) + .widthIn(min = 220.dp) + .clip(RoundedCornerShape(8.dp)) + .background(colors.background) + .border(1.dp, colors.outline, RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp), + ) { + KetchIconImage(icon = KetchIcon.Search, size = 14.dp, tint = colors.onSurfaceDim) + Text( + text = "Search downloads…", + style = type.bodyMedium, + color = colors.onSurfaceDim, + modifier = Modifier.weight(1f), + ) + Text( + text = "⌘K", + style = type.monoXSmall, + color = colors.onSurfaceDim, + modifier = Modifier + .clip(RoundedCornerShape(3.dp)) + .background(colors.outlineVariant) + .padding(horizontal = 4.dp, vertical = 1.dp), + ) + } +}