mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
feat(desktop): visual personality overhaul — unified theme, sidebar, cards
Phase 1: Replace per-OS color schemes with unified Amethyst brand - Cyan/blue accent (#0096FF light, #4DB8FF dark) replacing OS-adaptive colors - Amethyst purple as tertiary heritage color - Unified shapes (8/12/16/24dp) replacing per-OS variants - Standardized typography weights (Light for display, SemiBold for headlines) - Letter spacing unified to -0.3sp Phase 2: Spacing system - AmethystSpacing CompositionLocal with design tokens - LocalIsDarkTheme for M3-compatible dark mode detection Phase 3: Sidebar redesign - 240dp wide sidebar with icon + text labels (was 56dp icon-only) - Animated collapse/expand with smooth width transition - Avatar + username at top with account switcher - Custom feeds section from FeedDefinitionRepository - Active item cyan pill indicator with hover effects - Collapse state persisted in Preferences - Debounced fitColumnsToWidth to prevent animation thrash Phase 4: Card refinement - OutlinedCard with 1dp border replacing 1dp shadow elevation - 16dp internal padding (was 12dp) - Converted NoteCard, ReadsScreen, DraftsScreen, MyHighlightsScreen, UserProfileScreen Phase 5: Column header restyling - 48dp height (was 40dp) with surfaceContainer background - 12dp horizontal padding (was 8dp) Phase 6: Polish - HoverModifiers.kt — shared hover highlight using onPointerEvent + drawBehind - ShimmerPlaceholder.kt — skeleton loading animation in commons/commonMain Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
16afed0f3f
commit
b431c1efab
+96
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+74
-254
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+9
-61
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
+16
-8
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+4
-16
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-3
@@ -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
|
||||
|
||||
+3
-3
@@ -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) {
|
||||
|
||||
+20
-8
@@ -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(
|
||||
|
||||
+434
-30
@@ -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<AccountInfo>,
|
||||
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)}"
|
||||
}
|
||||
|
||||
+6
-4
@@ -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(),
|
||||
|
||||
+16
-7
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+53
@@ -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
|
||||
+47
@@ -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) }
|
||||
}
|
||||
Reference in New Issue
Block a user