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/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/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, +) 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..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 @@ -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 @@ -57,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) @@ -185,17 +183,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, ) } }, @@ -215,66 +213,72 @@ 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 = { - IconButton( - onClick = { - appState.showAiDiscoverDialog = true - }, - ) { - Icon( - Icons.Filled.AutoAwesome, - contentDescription = "AI Discover", + 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, ) - } - BatchActionBar( - hasActiveDownloads = hasActive, - hasPausedDownloads = hasPaused, - hasCompletedDownloads = hasCompleted, - onPauseAll = { appState.pauseAll() }, - onResumeAll = { appState.resumeAll() }, - onClearCompleted = { - appState.clearCompleted() - }, - ) - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = - MaterialTheme.colorScheme.surface, - ), - ) + }, + 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) { - 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 +289,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 +334,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/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/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..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,30 @@ 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.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.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 androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -27,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 @@ -34,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 @@ -41,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 } @@ -59,217 +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 = - MaterialTheme.colorScheme.surfaceContainer, - disabledContainerColor = - MaterialTheme.colorScheme.surfaceContainer - ), - 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 = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - 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 = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme - .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)) - IconButton( - 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 - ) - } - } + 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 - } + onScheduled = { subPanel = ExpandedSubPanel.None }, ) - ExpandedPanel.Settings -> TaskSettingsPanel( - task = task, - ) - 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/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..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,6 +1,7 @@ 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 @@ -15,185 +16,200 @@ 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 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 = 200.dp +private val SIDEBAR_WIDTH = 220.dp @Composable 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(MaterialTheme.colorScheme.surfaceContainerLow) - .padding(vertical = 12.dp), + .background(colors.surfaceVariant), ) { - // Add download button - FloatingActionButton( - onClick = onAddClick, + // 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(), - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - shape = RoundedCornerShape(12.dp), + .fillMaxWidth() + .height(48.dp) + .padding(start = 78.dp, end = 12.dp), + contentAlignment = Alignment.CenterStart, ) { - 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, - ) - } + Wordmark() } - Spacer(modifier = Modifier.height(16.dp)) - HorizontalDivider( - modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.outlineVariant, + // Connection pill (clickable → instance selector). + InstancePill( + activeInstance = activeInstance, + connectionState = connectionState, + onClick = onInstanceClick, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) - Spacer(modifier = 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, - ) - ) + Spacer(Modifier.height(12.dp)) - // Navigation items + // Queue group. + SectionLabel("Queue") 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, ) } + + Spacer(Modifier.weight(1f)) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = colors.outlineVariant, + ) } } @Composable -private fun SidebarItem( - icon: ImageVector, - label: String, - count: Int, - selected: Boolean, +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 bgColor = if (selected) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) - } else { - MaterialTheme.colorScheme.surfaceContainerLow + 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 contentColor = if (selected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant + 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( - modifier = Modifier + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 1.dp) - .clip(RoundedCornerShape(8.dp)) - .background(bgColor) + .clip(RoundedCornerShape(6.dp)) + .background(colors.background) + .border(1.dp, colors.outline, RoundedCornerShape(6.dp)) .clickable(onClick = onClick) - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + .padding(horizontal = 10.dp, vertical = 6.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), + Box( + modifier = Modifier + .size(7.dp) + .clip(CircleShape) + .background(dotColor) + .border(3.dp, dotColor.copy(alpha = 0.18f), CircleShape), ) - 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, - ) - } + 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, + ) } } -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 - } +@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 + 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) } } } 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), + ) + } +}