diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ShimmerPlaceholder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ShimmerPlaceholder.kt new file mode 100644 index 0000000000..0be7a93ab2 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ShimmerPlaceholder.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.unit.dp + +@Composable +fun ShimmerPlaceholder(modifier: Modifier = Modifier) { + val transition = rememberInfiniteTransition(label = "shimmer") + val translateAnim by transition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1200, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "shimmerTranslate", + ) + + val shimmerColors = + listOf( + MaterialTheme.colorScheme.surfaceContainerHigh, + MaterialTheme.colorScheme.surfaceContainer, + MaterialTheme.colorScheme.surfaceContainerHigh, + ) + + val brush = + Brush.linearGradient( + colors = shimmerColors, + start = Offset(translateAnim - 200f, translateAnim - 200f), + end = Offset(translateAnim, translateAnim), + ) + + Box(modifier.background(brush, MaterialTheme.shapes.small)) +} + +@Composable +fun NoteCardSkeleton(modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ShimmerPlaceholder(Modifier.size(32.dp).clip(CircleShape)) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + ShimmerPlaceholder(Modifier.width(120.dp).height(12.dp)) + ShimmerPlaceholder(Modifier.width(60.dp).height(10.dp)) + } + } + ShimmerPlaceholder(Modifier.fillMaxWidth().height(14.dp)) + ShimmerPlaceholder(Modifier.fillMaxWidth(0.7f).height(14.dp)) + ShimmerPlaceholder(Modifier.fillMaxWidth().height(180.dp)) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt index 9f79b1d4f5..0c0a65da72 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt @@ -23,6 +23,10 @@ package com.vitorpamplona.amethyst.commons.ui.theme import androidx.compose.ui.graphics.Color // Primary brand colors +val AmethystBlue = Color(0xFF0096FF) +val AmethystBlueDark = Color(0xFF4DB8FF) +val AmethystPurple = Color(0xFF9A82DB) + val Primary50 = Color(red = 127, green = 103, blue = 190) val Primary60 = Color(red = 154, green = 130, blue = 219) val Primary70 = Color(red = 182, green = 157, blue = 248) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt index 00d8e26fab..92d5b123be 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -105,4 +105,12 @@ object DesktopPreferences { val preferredBlossomServer: String get() = blossomServers.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER + + private const val KEY_SIDEBAR_COLLAPSED = "sidebar_collapsed" + + var sidebarCollapsed: Boolean + get() = prefs.getBoolean(KEY_SIDEBAR_COLLAPSED, false) + set(value) { + prefs.putBoolean(KEY_SIDEBAR_COLLAPSED, value) + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index fef81a722b..294609c1d1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1381,6 +1381,10 @@ fun MainContent( val allAccountsState by accountManager.allAccounts.collectAsState() var showAddAccountDialog by remember { mutableStateOf(false) } + val deckColumns by deckState.columns.collectAsState() + val focusedIdx by deckState.focusedColumnIndex.collectAsState() + val activeColumnType = deckColumns.getOrNull(focusedIdx)?.type + DeckSidebar( activeNpub = accountManager.currentAccount()?.npub, allAccounts = allAccountsState, @@ -1404,6 +1408,14 @@ fun MainContent( deckState.addColumn(DeckColumnType.Settings) } }, + onNavigate = { type -> + if (deckState.hasColumnOfType(type)) { + deckState.focusExistingColumn(type) + } else { + deckState.addColumn(type) + } + }, + activeColumnType = activeColumnType, onShowImportFollowListDialog = onShowImportFollowListDialog, signerConnectionState = signerConnectionState, lastPingTimeSec = lastPingTimeSec, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt index 6a8c3557bf..bf77e481fe 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt @@ -24,272 +24,92 @@ import androidx.compose.material3.ColorScheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import com.vitorpamplona.amethyst.commons.ui.theme.AmethystBlue +import com.vitorpamplona.amethyst.commons.ui.theme.AmethystBlueDark +import com.vitorpamplona.amethyst.commons.ui.theme.AmethystPurple /** - * Per-OS Material3 [ColorScheme]s tuned to match each platform's native surface - * tones. The OS accent (resolved via [PlatformAccent]) is plumbed in as `primary` - * so links, focus rings, and selection highlights match the rest of the user's - * system. - * - * Surface tones come from each OS's reference palettes: - * - macOS dark: NSWindowBackgroundColor / NSAlternateSelectedControlColor. - * - macOS light: NSColor systemBackgroundColor (#FFFFFF) / window background (#ECECEC). - * - GNOME dark: libadwaita `@window_bg_color` (#242424) / `@view_bg_color` (#1E1E1E). - * - GNOME light: libadwaita `@window_bg_color` (#FAFAFA) / `@view_bg_color` (#FFFFFF). - * - KDE Breeze dark/light defaults. - * - Windows 11 dark/light: WinUI mica reference values. + * Unified Amethyst brand [ColorScheme] — consistent visual identity across all + * platforms. Uses cyan/blue (#0096FF) as primary accent, with Amethyst purple + * as tertiary heritage color. */ object PlatformColorScheme { - fun resolve( - dark: Boolean, - accent: Color, - ): ColorScheme = - when (PlatformInfo.current) { - Platform.MACOS -> if (dark) macOSDark(accent) else macOSLight(accent) - Platform.GNOME -> if (dark) gnomeDark(accent) else gnomeLight(accent) - Platform.KDE -> if (dark) kdeDark(accent) else kdeLight(accent) - Platform.WINDOWS -> if (dark) windowsDark(accent) else windowsLight(accent) - else -> if (dark) genericDark(accent) else genericLight(accent) - } + fun resolve(dark: Boolean): ColorScheme = if (dark) amethystDark() else amethystLight() - // ── macOS ───────────────────────────────────────────────────────────────── + private fun amethystLight() = + lightColorScheme( + primary = AmethystBlue, + onPrimary = Color.White, + primaryContainer = AmethystBlue.copy(alpha = 0.12f).compositeOver(Color.White), + onPrimaryContainer = AmethystBlue, + secondary = Color(0xFF5E8FAD), + onSecondary = Color.White, + secondaryContainer = Color(0xFF5E8FAD).copy(alpha = 0.12f).compositeOver(Color.White), + onSecondaryContainer = Color(0xFF5E8FAD), + tertiary = AmethystPurple, + onTertiary = Color.White, + tertiaryContainer = AmethystPurple.copy(alpha = 0.12f).compositeOver(Color.White), + onTertiaryContainer = AmethystPurple, + background = Color(0xFFF2F2F7), + onBackground = Color(0xFF1C1C1E), + surface = Color.White, + onSurface = Color(0xFF1C1C1E), + surfaceVariant = Color(0xFFF0F0F5), + onSurfaceVariant = Color(0xFF6E6E73), + surfaceContainer = Color(0xFFF7F7FA), + surfaceContainerHigh = Color(0xFFEEEEF2), + surfaceContainerHighest = Color(0xFFE5E5EA), + surfaceContainerLow = Color(0xFFFAFAFC), + surfaceContainerLowest = Color.White, + surfaceDim = Color(0xFFE8E8ED), + surfaceBright = Color.White, + outline = Color(0xFFE0E0E0), + outlineVariant = Color(0xFFEBEBEB), + inverseSurface = Color(0xFF2C2C2E), + inverseOnSurface = Color(0xFFF2F2F7), + inversePrimary = AmethystBlueDark, + error = Color(0xFFBA1A1A), + onError = Color.White, + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF410002), + ) - private fun macOSDark(accent: Color) = + private fun amethystDark() = darkColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFF1E1E1E), - onBackground = Color(0xFFE5E5E5), + primary = AmethystBlueDark, + onPrimary = Color.White, + primaryContainer = AmethystBlueDark.copy(alpha = 0.16f).compositeOver(Color(0xFF1E1E1E)), + onPrimaryContainer = AmethystBlueDark, + secondary = Color(0xFF7EAEC8), + onSecondary = Color.White, + secondaryContainer = Color(0xFF7EAEC8).copy(alpha = 0.16f).compositeOver(Color(0xFF1E1E1E)), + onSecondaryContainer = Color(0xFF7EAEC8), + tertiary = Color(0xFFB6A0E0), + onTertiary = Color.White, + tertiaryContainer = Color(0xFFB6A0E0).copy(alpha = 0.16f).compositeOver(Color(0xFF1E1E1E)), + onTertiaryContainer = Color(0xFFB6A0E0), + background = Color(0xFF121212), + onBackground = Color(0xFFE5E5EA), surface = Color(0xFF1E1E1E), - onSurface = Color(0xFFE5E5E5), + onSurface = Color(0xFFE5E5EA), surfaceVariant = Color(0xFF2A2A2A), - onSurfaceVariant = Color(0xFFB8B8B8), + onSurfaceVariant = Color(0xFF9E9EA3), surfaceContainer = Color(0xFF252525), - surfaceContainerHigh = Color(0xFF2D2D2D), - surfaceContainerHighest = Color(0xFF353535), + surfaceContainerHigh = Color(0xFF2E2E2E), + surfaceContainerHighest = Color(0xFF383838), surfaceContainerLow = Color(0xFF1A1A1A), surfaceContainerLowest = Color(0xFF141414), surfaceDim = Color(0xFF1A1A1A), - surfaceBright = Color(0xFF353535), - outline = Color(0xFF6A6A6A), - outlineVariant = Color(0xFF3A3A3A), + surfaceBright = Color(0xFF383838), + outline = Color(0xFF3A3A3A), + outlineVariant = Color(0xFF2E2E2E), + inverseSurface = Color(0xFFE5E5EA), + inverseOnSurface = Color(0xFF1C1C1E), + inversePrimary = AmethystBlue, + error = Color(0xFFFFB4AB), + onError = Color(0xFF690005), + errorContainer = Color(0xFF93000A), + onErrorContainer = Color(0xFFFFDAD6), ) - - private fun macOSLight(accent: Color): ColorScheme { - // Apple's accent palette includes Yellow (#F8BA00) and Graphite (#8C8C8C) - // which have terrible contrast on any light surface. Darken high-luminance - // accents so the primary color stays readable without disabling the user's - // preference — the hue is preserved, just pulled toward a usable lightness. - val readable = darkenForLight(accent) - return lightColorScheme( - primary = readable, - onPrimary = onAccent(readable), - secondary = readable, - tertiary = readable, - // Apple's light-mode main content background is near-white (#F5F5F7 / - // Apple's secondarySystemBackgroundColor); the #ECECEC gray is only - // used behind the title bar chrome — which we map to surfaceContainer. - background = Color(0xFFF5F5F7), - onBackground = Color(0xFF1A1A1A), - surface = Color(0xFFFFFFFF), - onSurface = Color(0xFF1A1A1A), - surfaceVariant = Color(0xFFECECEC), - onSurfaceVariant = Color(0xFF555555), - surfaceContainer = Color(0xFFECECEC), - surfaceContainerHigh = Color(0xFFE5E5E5), - surfaceContainerHighest = Color(0xFFDDDDDD), - surfaceContainerLow = Color(0xFFF0F0F0), - surfaceContainerLowest = Color(0xFFFFFFFF), - outline = Color(0xFFB0B0B0), - outlineVariant = Color(0xFFD8D8D8), - ) - } - - /** - * Scales an accent color toward black until its relative luminance is at - * most [maxLuminance]. Preserves hue (all RGB channels scale by the same - * factor). Only applied on light surfaces — on dark surfaces the raw accent - * already has good contrast. - * - * 0.38 was picked empirically: it keeps a saturated blue visible without - * darkening it, while pulling yellow/graphite down to a readable amber/gray. - */ - private fun darkenForLight( - c: Color, - maxLuminance: Float = 0.38f, - ): Color { - val lum = 0.2126f * c.red + 0.7152f * c.green + 0.0722f * c.blue - if (lum <= maxLuminance) return c - val scale = maxLuminance / lum - return Color( - red = (c.red * scale).coerceIn(0f, 1f), - green = (c.green * scale).coerceIn(0f, 1f), - blue = (c.blue * scale).coerceIn(0f, 1f), - alpha = c.alpha, - ) - } - - // ── GNOME (libadwaita) ──────────────────────────────────────────────────── - - private fun gnomeDark(accent: Color) = - darkColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFF242424), - onBackground = Color(0xFFFFFFFF), - surface = Color(0xFF1E1E1E), - onSurface = Color(0xFFFFFFFF), - surfaceVariant = Color(0xFF323232), - onSurfaceVariant = Color(0xFFCCCCCC), - surfaceContainer = Color(0xFF2C2C2C), - surfaceContainerHigh = Color(0xFF383838), - surfaceContainerHighest = Color(0xFF424242), - surfaceContainerLow = Color(0xFF222222), - surfaceContainerLowest = Color(0xFF1A1A1A), - outline = Color(0xFF5E5E5E), - outlineVariant = Color(0xFF3A3A3A), - ) - - private fun gnomeLight(accent: Color) = - lightColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFFFAFAFA), - onBackground = Color(0xFF1A1A1A), - surface = Color(0xFFFFFFFF), - onSurface = Color(0xFF1A1A1A), - surfaceVariant = Color(0xFFF0F0F0), - onSurfaceVariant = Color(0xFF5E5E5E), - surfaceContainer = Color(0xFFF4F4F4), - surfaceContainerHigh = Color(0xFFEDEDED), - surfaceContainerHighest = Color(0xFFE5E5E5), - surfaceContainerLow = Color(0xFFF9F9F9), - surfaceContainerLowest = Color(0xFFFFFFFF), - outline = Color(0xFFB0B0B0), - outlineVariant = Color(0xFFD4D4D4), - ) - - // ── KDE Plasma (Breeze) ─────────────────────────────────────────────────── - - private fun kdeDark(accent: Color) = - darkColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFF1B1E20), - onBackground = Color(0xFFFCFCFC), - surface = Color(0xFF232629), - onSurface = Color(0xFFFCFCFC), - surfaceVariant = Color(0xFF2A2E32), - onSurfaceVariant = Color(0xFFBDC3C7), - surfaceContainer = Color(0xFF272A2E), - surfaceContainerHigh = Color(0xFF31353A), - surfaceContainerHighest = Color(0xFF3B4045), - surfaceContainerLow = Color(0xFF1F2225), - surfaceContainerLowest = Color(0xFF18191B), - outline = Color(0xFF4D5258), - outlineVariant = Color(0xFF34383C), - ) - - private fun kdeLight(accent: Color) = - lightColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFFEFF0F1), - onBackground = Color(0xFF232629), - surface = Color(0xFFFCFCFC), - onSurface = Color(0xFF232629), - surfaceVariant = Color(0xFFE5E9EC), - onSurfaceVariant = Color(0xFF4D4D4D), - surfaceContainer = Color(0xFFF2F3F4), - surfaceContainerHigh = Color(0xFFEAECEE), - surfaceContainerHighest = Color(0xFFE0E3E5), - surfaceContainerLow = Color(0xFFF7F8F9), - surfaceContainerLowest = Color(0xFFFFFFFF), - outline = Color(0xFFBABEC2), - outlineVariant = Color(0xFFD9DCDF), - ) - - // ── Windows 11 (WinUI 3 / Mica) ─────────────────────────────────────────── - - private fun windowsDark(accent: Color) = - darkColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFF202020), - onBackground = Color(0xFFFFFFFF), - surface = Color(0xFF2B2B2B), - onSurface = Color(0xFFFFFFFF), - surfaceVariant = Color(0xFF323232), - onSurfaceVariant = Color(0xFFCCCCCC), - surfaceContainer = Color(0xFF272727), - surfaceContainerHigh = Color(0xFF323232), - surfaceContainerHighest = Color(0xFF3D3D3D), - surfaceContainerLow = Color(0xFF1F1F1F), - surfaceContainerLowest = Color(0xFF1A1A1A), - outline = Color(0xFF5E5E5E), - outlineVariant = Color(0xFF3A3A3A), - ) - - private fun windowsLight(accent: Color) = - lightColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFFF3F3F3), - onBackground = Color(0xFF1A1A1A), - surface = Color(0xFFFBFBFB), - onSurface = Color(0xFF1A1A1A), - surfaceVariant = Color(0xFFEDEDED), - onSurfaceVariant = Color(0xFF555555), - surfaceContainer = Color(0xFFF6F6F6), - surfaceContainerHigh = Color(0xFFEDEDED), - surfaceContainerHighest = Color(0xFFE5E5E5), - surfaceContainerLow = Color(0xFFFAFAFA), - surfaceContainerLowest = Color(0xFFFFFFFF), - outline = Color(0xFFB0B0B0), - outlineVariant = Color(0xFFD8D8D8), - ) - - // ── Generic (other Linux DEs / Unknown) ────────────────────────────────── - - private fun genericDark(accent: Color) = - darkColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFF1E1E1E), - surface = Color(0xFF1E1E1E), - surfaceVariant = Color(0xFF2A2A2A), - ) - - private fun genericLight(accent: Color) = - lightColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - ) - - /** - * Picks readable text color (white or black) on top of the given accent based on - * its perceived luminance (Rec. 709 weights). - */ - private fun onAccent(accent: Color): Color { - val luminance = 0.2126f * accent.red + 0.7152f * accent.green + 0.0722f * accent.blue - return if (luminance > 0.55f) Color.Black else Color.White - } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformShapes.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformShapes.kt index fbd1831404..e2935bec2d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformShapes.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformShapes.kt @@ -25,67 +25,15 @@ import androidx.compose.material3.Shapes import androidx.compose.ui.unit.dp /** - * Per-OS Material3 [Shapes] tuned to match each platform's native rounding language. - * Material's defaults (4 / 4 / 0 dp) read as Android — these values match what users - * see in their OS's first-party apps. - * - * - macOS (Sonoma+): ~10 / 12 / 16 dp continuous-style corners. - * - GNOME (libadwaita): 9 / 12 / 16 dp — adw_dialog / adw_card baseline. - * - KDE (Breeze): 6 / 8 / 12 dp — Breeze prefers tighter rounding than libadwaita. - * - Windows (WinUI 3): 4 / 8 / 8 dp — WinUI's mica surfaces use modest rounding. + * Unified Amethyst brand [Shapes] — consistent rounding across all platforms. */ object PlatformShapes { - val current: Shapes by lazy { - when (PlatformInfo.current) { - Platform.MACOS -> { - Shapes( - extraSmall = RoundedCornerShape(6.dp), - small = RoundedCornerShape(8.dp), - medium = RoundedCornerShape(10.dp), - large = RoundedCornerShape(14.dp), - extraLarge = RoundedCornerShape(20.dp), - ) - } - - Platform.GNOME -> { - Shapes( - extraSmall = RoundedCornerShape(6.dp), - small = RoundedCornerShape(9.dp), - medium = RoundedCornerShape(12.dp), - large = RoundedCornerShape(16.dp), - extraLarge = RoundedCornerShape(24.dp), - ) - } - - Platform.KDE -> { - Shapes( - extraSmall = RoundedCornerShape(4.dp), - small = RoundedCornerShape(6.dp), - medium = RoundedCornerShape(8.dp), - large = RoundedCornerShape(12.dp), - extraLarge = RoundedCornerShape(16.dp), - ) - } - - Platform.WINDOWS -> { - Shapes( - extraSmall = RoundedCornerShape(4.dp), - small = RoundedCornerShape(4.dp), - medium = RoundedCornerShape(8.dp), - large = RoundedCornerShape(8.dp), - extraLarge = RoundedCornerShape(12.dp), - ) - } - - Platform.LINUX_OTHER, Platform.UNKNOWN -> { - Shapes( - extraSmall = RoundedCornerShape(6.dp), - small = RoundedCornerShape(8.dp), - medium = RoundedCornerShape(10.dp), - large = RoundedCornerShape(14.dp), - extraLarge = RoundedCornerShape(20.dp), - ) - } - } - } + val current: Shapes = + Shapes( + extraSmall = RoundedCornerShape(6.dp), + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(12.dp), + large = RoundedCornerShape(16.dp), + extraLarge = RoundedCornerShape(24.dp), + ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt index 739d0ee0a6..3b508d089c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt @@ -22,10 +22,14 @@ package com.vitorpamplona.amethyst.desktop.platform import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.FrameWindowScope +import com.vitorpamplona.amethyst.desktop.ui.theme.AmethystSpacing +import com.vitorpamplona.amethyst.desktop.ui.theme.LocalIsDarkTheme +import com.vitorpamplona.amethyst.desktop.ui.theme.LocalSpacing /** * Wraps content in a [MaterialTheme] tuned for the host OS: native fonts, accent @@ -41,14 +45,18 @@ fun PlatformMaterialTheme( isDark: Boolean, content: @Composable () -> Unit, ) { - val accent = remember { PlatformAccent.systemAccent() } - val colorScheme = remember(isDark, accent) { PlatformColorScheme.resolve(isDark, accent) } - MaterialTheme( - colorScheme = colorScheme, - typography = PlatformTypography.current, - shapes = PlatformShapes.current, - content = content, - ) + val colorScheme = remember(isDark) { PlatformColorScheme.resolve(isDark) } + CompositionLocalProvider( + LocalSpacing provides AmethystSpacing(), + LocalIsDarkTheme provides isDark, + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = PlatformTypography.current, + shapes = PlatformShapes.current, + content = content, + ) + } } /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTypography.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTypography.kt index f11e028393..97e6195241 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTypography.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTypography.kt @@ -40,19 +40,7 @@ object PlatformTypography { val current: Typography by lazy { build(PlatformFonts.ui) } private fun build(family: FontFamily): Typography { - // Per-OS letter spacing offset applied to display/headline styles. - val tightening: TextUnit = - when (PlatformInfo.current) { - Platform.MACOS -> (-0.4).sp - - // SF Pro is set tight at large sizes - Platform.GNOME -> (-0.2).sp - - // Adwaita Sans is mildly tight - Platform.KDE, Platform.WINDOWS -> 0.sp - - else -> 0.sp - } + val tightening: TextUnit = (-0.3).sp fun ts( size: Int, @@ -68,9 +56,9 @@ object PlatformTypography { ) return Typography( - displayLarge = ts(57, 64, FontWeight.Normal, tightening), - displayMedium = ts(45, 52, FontWeight.Normal, tightening), - displaySmall = ts(36, 44, FontWeight.Normal, tightening), + displayLarge = ts(57, 64, FontWeight.Light, tightening), + displayMedium = ts(45, 52, FontWeight.Light, tightening), + displaySmall = ts(36, 44, FontWeight.Light, tightening), headlineLarge = ts(32, 40, FontWeight.SemiBold, tightening), headlineMedium = ts(28, 36, FontWeight.SemiBold, tightening), headlineSmall = ts(24, 32, FontWeight.SemiBold, tightening), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt index 5ca2760bf1..28a0a761a8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -33,10 +34,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -148,14 +149,15 @@ private fun DraftCard( onClick: () -> Unit, onDelete: () -> Unit, ) { - Card( + OutlinedCard( onClick = onClick, modifier = Modifier.fillMaxWidth(), colors = - CardDefaults.cardColors( + CardDefaults.outlinedCardColors( containerColor = MaterialTheme.colorScheme.surface, ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.medium, ) { Row( modifier = Modifier.padding(16.dp).fillMaxWidth(), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index 0706f93fb6..2d5d569a22 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -32,12 +33,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -96,14 +97,15 @@ fun LongFormCard( val authorName = author.toBestDisplayName() val publishedAt = event.publishedAt() ?: event.createdAt - Card( + OutlinedCard( onClick = onClick, modifier = Modifier.fillMaxWidth(), colors = - CardDefaults.cardColors( + CardDefaults.outlinedCardColors( containerColor = MaterialTheme.colorScheme.surface, ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.medium, ) { Column(modifier = Modifier.padding(16.dp)) { // Title diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index cd6a13e876..a7c5742eac 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -48,6 +49,7 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text @@ -1139,13 +1141,14 @@ private fun PublishedHighlightCard( val articleAddress = highlight.inPostAddress() val articleTitle = articleAddress?.let { "Article" } ?: "Unknown source" - Card( + OutlinedCard( modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), colors = - CardDefaults.cardColors( + CardDefaults.outlinedCardColors( containerColor = MaterialTheme.colorScheme.surface, ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.medium, ) { Column(modifier = Modifier.padding(16.dp)) { // Quoted highlight text diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt index d1e8cf9a74..629b5fa127 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt @@ -56,13 +56,13 @@ fun ColumnHeader( modifier = modifier .fillMaxWidth() - .height(40.dp) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .height(48.dp) + .background(MaterialTheme.colorScheme.surfaceContainer) .pointerInput(Unit) { detectTapGestures( onDoubleTap = { onDoubleClick() }, ) - }.padding(horizontal = 8.dp), + }.padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { if (hasBackStack) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt index 8ce5c543e5..96058bcf88 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon @@ -51,6 +52,8 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscription import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce import java.awt.Cursor @Composable @@ -80,14 +83,23 @@ fun DeckLayout( val availableWidthDp = with(density) { constraints.maxWidth.toDp().value } deckState.setAvailableWidth(availableWidthDp) - // Auto-fit columns on first composition or when available width changes significantly - LaunchedEffect(availableWidthDp, columns.size) { - val dividers = (columns.size - 1) * DeckState.DIVIDER_WIDTH - val totalColumnWidth = columns.sumOf { it.width.toDouble() }.toFloat() - val diff = kotlin.math.abs(totalColumnWidth + dividers - availableWidthDp) - if (diff > 20f && columns.isNotEmpty()) { - deckState.fitColumnsToWidth(availableWidthDp) - } + // Auto-fit columns when available width changes (debounced to avoid per-frame + // recomposition during sidebar expand/collapse animation) + @OptIn(FlowPreview::class) + LaunchedEffect(Unit) { + snapshotFlow { availableWidthDp to columns.size } + .debounce(300) + .collect { (width, size) -> + val dividers = (size - 1) * DeckState.DIVIDER_WIDTH + val totalColumnWidth = + deckState.columns.value + .sumOf { it.width.toDouble() } + .toFloat() + val diff = kotlin.math.abs(totalColumnWidth + dividers - width) + if (diff > 20f && size > 0) { + deckState.fitColumnsToWidth(width) + } + } } Row( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index 6cea5f0013..078e6b8a7f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -20,32 +20,88 @@ */ package com.vitorpamplona.amethyst.desktop.ui.deck +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material3.IconButton +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState +import com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinition import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.account.AccountInfo import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar +import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.platform.titleBarInsetTop import com.vitorpamplona.amethyst.desktop.ui.account.AccountSwitcherDropdown import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import kotlinx.collections.immutable.ImmutableList +private val EXPANDED_WIDTH = 240.dp +private val COLLAPSED_WIDTH = 56.dp + +/** + * Data class for a sidebar navigation item. + */ +private data class NavItem( + val type: DeckColumnType, + val label: String, + val icon: MaterialSymbol, +) + +private val NAV_ITEMS = + listOf( + NavItem(DeckColumnType.HomeFeed, "Home", MaterialSymbols.Home), + NavItem(DeckColumnType.Search, "Search", MaterialSymbols.Search), + NavItem(DeckColumnType.Messages, "Messages", MaterialSymbols.Mail), + NavItem(DeckColumnType.Wallet, "Wallet", MaterialSymbols.AccountBalanceWallet), + NavItem(DeckColumnType.Bookmarks, "Bookmarks", MaterialSymbols.Bookmark), + NavItem(DeckColumnType.Settings, "Settings", MaterialSymbols.Settings), + ) + +@OptIn(ExperimentalComposeUiApi::class) @Composable fun DeckSidebar( activeNpub: String?, @@ -56,67 +112,415 @@ fun DeckSidebar( onRemoveAccount: (String) -> Unit, onAddColumn: () -> Unit, onOpenSettings: () -> Unit, + onNavigate: (DeckColumnType) -> Unit, + activeColumnType: DeckColumnType?, onShowImportFollowListDialog: () -> Unit = {}, signerConnectionState: SignerConnectionState, lastPingTimeSec: Long?, torStatus: TorServiceStatus, modifier: Modifier = Modifier, ) { + var expanded by remember { mutableStateOf(!DesktopPreferences.sidebarCollapsed) } + + val animatedWidth by animateDpAsState( + targetValue = if (expanded) EXPANDED_WIDTH else COLLAPSED_WIDTH, + animationSpec = tween(300, easing = FastOutSlowInEasing), + ) + + // Observe metadata to recompose when display names/pictures load + @Suppress("UNUSED_VARIABLE") + val metadataVersion by localCache?.metadataVersion?.collectAsState() + ?: remember { mutableStateOf(0L) } + + // Resolve user info for avatar header + val pubkeyHex = remember(activeNpub) { activeNpub?.let { decodePublicKeyAsHexOrNull(it) } } + val user = + remember(pubkeyHex, metadataVersion) { + pubkeyHex?.let { localCache?.getUserIfExists(it) } + } + val displayName = + remember(user, metadataVersion) { + user?.let { + val name = it.toBestDisplayName() + name.takeIf { n -> n != it.pubkeyDisplayHex() } + } + } + val avatarUrl = remember(user, metadataVersion) { user?.profilePicture() } + + // Custom feeds from repository + val feedRepo = LocalFeedRepository.current + val allFeeds by feedRepo.feeds.collectAsState() + Column( modifier = modifier - .width(56.dp) + .width(animatedWidth) .fillMaxHeight() - .background(MaterialTheme.colorScheme.surfaceContainer) + .clipToBounds() + .background(MaterialTheme.colorScheme.surface) .padding(top = 8.dp + titleBarInsetTop, bottom = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top, ) { - AccountSwitcherDropdown( + // -- Avatar + Account header -- + SidebarAccountHeader( activeNpub = activeNpub, allAccounts = allAccounts, localCache = localCache, + displayName = displayName, + avatarUrl = avatarUrl, + pubkeyHex = pubkeyHex, + expanded = expanded, onSwitchAccount = onSwitchAccount, onAddAccount = onAddAccount, onRemoveAccount = onRemoveAccount, ) - Spacer(Modifier.size(16.dp)) + Spacer(Modifier.height(8.dp)) + HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp)) + Spacer(Modifier.height(8.dp)) - IconButton(onClick = onAddColumn) { - Icon( - MaterialSymbols.Add, - contentDescription = "Add Column", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + // -- Nav items (scrollable) -- + Column( + modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()), + ) { + NAV_ITEMS.forEach { item -> + val isActive = activeColumnType?.typeKey() == item.type.typeKey() + SidebarNavItem( + icon = item.icon, + label = item.label, + isActive = isActive, + expanded = expanded, + onClick = { onNavigate(item.type) }, + ) + } + + // -- Custom feeds section -- + if (allFeeds.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp)) + Spacer(Modifier.height(8.dp)) + + AnimatedVisibility( + visible = expanded, + enter = fadeIn(tween(200, delayMillis = 100)), + exit = fadeOut(tween(100)), + ) { + Text( + text = "FEEDS", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + + allFeeds.forEach { feed -> + val isActive = + activeColumnType is DeckColumnType.CustomFeed && + activeColumnType.feedId == feed.id + SidebarFeedItem( + feed = feed, + isActive = isActive, + expanded = expanded, + onClick = { + onNavigate( + DeckColumnType.CustomFeed( + feedId = feed.id, + feedName = feed.name, + feedEmoji = feed.emoji, + ), + ) + }, + ) + } + } } - IconButton(onClick = onShowImportFollowListDialog) { - Icon( - MaterialSymbols.PersonAdd, - contentDescription = "Import Follow List", - tint = MaterialTheme.colorScheme.onSurfaceVariant, + // -- Bottom section: indicators + collapse toggle -- + HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp)) + Spacer(Modifier.height(4.dp)) + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + BunkerHeartbeatIndicator( + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, ) + Spacer(Modifier.width(4.dp)) + TorStatusIndicator(status = torStatus, onClick = onOpenSettings) } - Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(4.dp)) - BunkerHeartbeatIndicator( - signerConnectionState = signerConnectionState, - lastPingTimeSec = lastPingTimeSec, + // Collapse toggle + SidebarNavItem( + icon = if (expanded) MaterialSymbols.AutoMirrored.KeyboardArrowLeft else MaterialSymbols.ChevronRight, + label = if (expanded) "Collapse" else "Expand", + isActive = false, + expanded = expanded, + onClick = { + expanded = !expanded + DesktopPreferences.sidebarCollapsed = !expanded + }, ) + } +} - Spacer(Modifier.size(4.dp)) - TorStatusIndicator(status = torStatus, onClick = onOpenSettings) +@Composable +private fun SidebarAccountHeader( + activeNpub: String?, + allAccounts: ImmutableList, + localCache: DesktopLocalCache?, + displayName: String?, + avatarUrl: String?, + pubkeyHex: String?, + expanded: Boolean, + onSwitchAccount: (String) -> Unit, + onAddAccount: () -> Unit, + onRemoveAccount: (String) -> Unit, +) { + if (!expanded) { + // Collapsed: just the account switcher dropdown (icon-only) + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + AccountSwitcherDropdown( + activeNpub = activeNpub, + allAccounts = allAccounts, + localCache = localCache, + onSwitchAccount = onSwitchAccount, + onAddAccount = onAddAccount, + onRemoveAccount = onRemoveAccount, + ) + } + } else { + // Expanded: avatar + name + npub, tappable to open account switcher + Box(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Avatar + if (pubkeyHex != null) { + UserAvatar( + userHex = pubkeyHex, + pictureUrl = avatarUrl, + size = 40.dp, + contentDescription = "Account avatar", + ) + } else { + Box( + modifier = + Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Icon( + MaterialSymbols.Person, + contentDescription = "Account", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + } + } - Spacer(Modifier.size(4.dp)) + Spacer(Modifier.width(10.dp)) - IconButton(onClick = onOpenSettings) { - Icon( - MaterialSymbols.Settings, - contentDescription = "Settings", - tint = MaterialTheme.colorScheme.onSurfaceVariant, + Column(modifier = Modifier.weight(1f)) { + Text( + text = displayName ?: "Account", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (activeNpub != null) { + Text( + text = npubShortForSidebar(activeNpub), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + // Overlay the dropdown so clicking opens it + AccountSwitcherDropdown( + activeNpub = activeNpub, + allAccounts = allAccounts, + localCache = localCache, + onSwitchAccount = onSwitchAccount, + onAddAccount = onAddAccount, + onRemoveAccount = onRemoveAccount, + modifier = Modifier.matchParentSize(), ) } } } + +/** + * A single navigation item row with icon + optional label. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun SidebarNavItem( + icon: MaterialSymbol, + label: String, + isActive: Boolean, + expanded: Boolean, + onClick: () -> Unit, +) { + var isHovered by remember { mutableStateOf(false) } + + val backgroundColor = + when { + isActive -> MaterialTheme.colorScheme.primaryContainer + isHovered -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + else -> MaterialTheme.colorScheme.surface + } + + val iconTint = + if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + val textColor = + if (isActive) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp) + .clip(RoundedCornerShape(8.dp)) + .background(backgroundColor) + .clickable(onClick = onClick) + .onPointerEvent(PointerEventType.Enter) { isHovered = true } + .onPointerEvent(PointerEventType.Exit) { isHovered = false } + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + icon, + contentDescription = if (!expanded) label else null, + tint = iconTint, + modifier = Modifier.size(24.dp), + ) + + AnimatedVisibility( + visible = expanded, + enter = fadeIn(tween(200, delayMillis = 100)), + exit = fadeOut(tween(100)), + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 12.dp), + ) + } + } +} + +/** + * A custom feed item in the sidebar. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun SidebarFeedItem( + feed: FeedDefinition, + isActive: Boolean, + expanded: Boolean, + onClick: () -> Unit, +) { + var isHovered by remember { mutableStateOf(false) } + + val backgroundColor = + when { + isActive -> MaterialTheme.colorScheme.primaryContainer + isHovered -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + else -> MaterialTheme.colorScheme.surface + } + + val iconTint = + if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + val textColor = + if (isActive) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp) + .clip(RoundedCornerShape(8.dp)) + .background(backgroundColor) + .clickable(onClick = onClick) + .onPointerEvent(PointerEventType.Enter) { isHovered = true } + .onPointerEvent(PointerEventType.Exit) { isHovered = false } + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (feed.emoji.isNotEmpty() && expanded) { + Text( + text = feed.emoji, + modifier = Modifier.size(24.dp), + style = MaterialTheme.typography.titleMedium, + ) + } else { + Icon( + MaterialSymbols.AutoMirrored.Feed, + contentDescription = if (!expanded) feed.name else null, + tint = iconTint, + modifier = Modifier.size(24.dp), + ) + } + + AnimatedVisibility( + visible = expanded, + enter = fadeIn(tween(200, delayMillis = 100)), + exit = fadeOut(tween(100)), + ) { + Text( + text = feed.name.ifEmpty { "Feed" }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 12.dp), + ) + } + } +} + +private fun npubShortForSidebar(npub: String): String { + if (npub.length <= 20) return npub + return "${npub.take(10)}...${npub.takeLast(6)}" +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt index 8fbc168f55..7cae7fa2eb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.ui.highlights +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -34,10 +35,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -175,13 +176,14 @@ private fun HighlightCard( highlight: HighlightData, onDelete: () -> Unit, ) { - Card( + OutlinedCard( modifier = Modifier.fillMaxWidth(), colors = - CardDefaults.cardColors( + CardDefaults.outlinedCardColors( containerColor = MaterialTheme.colorScheme.surface, ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.medium, ) { Row( modifier = Modifier.padding(16.dp).fillMaxWidth(), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 7b152971ec..0e0a9a4031 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.ui.note +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -36,6 +37,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -147,10 +149,15 @@ fun NoteCard( // the NoteActionsRow have their own clickables that consume the click before // it reaches the Card's handler, so tapping an action still fires only that // action. - val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) - val cardElevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + val outlineVariant = MaterialTheme.colorScheme.outlineVariant + val cardBorder = + remember(outlineVariant) { + BorderStroke(1.dp, outlineVariant) + } + val cardColors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface) + val cardShape = MaterialTheme.shapes.medium val cardBody: @Composable ColumnScope.() -> Unit = { - Column(modifier = Modifier.padding(12.dp)) { + Column(modifier = Modifier.padding(16.dp)) { Column { Row( modifier = Modifier.fillMaxWidth(), @@ -302,18 +309,20 @@ fun NoteCard( } if (onClick != null) { - Card( + OutlinedCard( onClick = onClick, modifier = modifier.fillMaxWidth(), colors = cardColors, - elevation = cardElevation, + border = cardBorder, + shape = cardShape, content = cardBody, ) } else { - Card( + OutlinedCard( modifier = modifier.fillMaxWidth(), colors = cardColors, - elevation = cardElevation, + border = cardBorder, + shape = cardShape, content = cardBody, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/AmethystSpacing.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/AmethystSpacing.kt new file mode 100644 index 0000000000..edb375602d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/AmethystSpacing.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Immutable +data class AmethystSpacing( + val xxs: Dp = 2.dp, + val xs: Dp = 4.dp, + val sm: Dp = 8.dp, + val md: Dp = 12.dp, + val lg: Dp = 16.dp, + val xl: Dp = 24.dp, + val xxl: Dp = 32.dp, + val cardPadding: Dp = 16.dp, + val cardGap: Dp = 8.dp, + val sidebarExpandedWidth: Dp = 240.dp, + val sidebarCollapsedWidth: Dp = 56.dp, + val columnHeaderHeight: Dp = 48.dp, +) + +val LocalSpacing = staticCompositionLocalOf { AmethystSpacing() } + +val LocalIsDarkTheme = staticCompositionLocalOf { false } + +val MaterialTheme.spacing: AmethystSpacing + @Composable @ReadOnlyComposable + get() = LocalSpacing.current diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/HoverModifiers.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/HoverModifiers.kt new file mode 100644 index 0000000000..eb6858691a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/HoverModifiers.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent + +/** + * Adds a subtle background highlight on hover. The state read is deferred to + * the draw phase via [drawBehind], so hover enter/exit only invalidates draw — + * no recomposition of the host composable. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun Modifier.hoverHighlight(hoverColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)): Modifier { + val color = remember { mutableStateOf(Color.Transparent) } + return this + .onPointerEvent(PointerEventType.Enter) { color.value = hoverColor } + .onPointerEvent(PointerEventType.Exit) { color.value = Color.Transparent } + .drawBehind { drawRect(color.value) } +}