feat(desktop): add App Drawer with categorized screen launcher
Replace AddColumnDialog with full-screen App Drawer overlay (Cmd+K). Screens organized by category (Social, Long-Form, Discovery, Identity, Play) with instant search, keyboard navigation (arrows + Enter), and open-column indicators in Deck mode. Works in both Single Pane and Deck layout modes. - Add AppDrawer.kt with ScreenCategory, LAUNCHABLE_SCREENS registry - Add SinglePaneState for hoisted single-pane navigation - Wire Cmd+K shortcut, redirect Cmd+T to drawer - Add "More" button to SinglePaneLayout nav rail - Delete AddColumnDialog.kt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -88,12 +88,13 @@ import com.vitorpamplona.amethyst.desktop.ui.LoginScreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
||||
import com.vitorpamplona.amethyst.desktop.ui.auth.ForceLogoutDialog
|
||||
import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.AddColumnDialog
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawer
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckColumnType
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckLayout
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckSidebar
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneState
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
|
||||
@@ -192,7 +193,7 @@ fun main() {
|
||||
val deckState = remember { DeckState(deckScope).also { it.load() } }
|
||||
val accountManager = remember { AccountManager.create() }
|
||||
val accountState by accountManager.accountState.collectAsState()
|
||||
var showAddColumnDialog by remember { mutableStateOf(false) }
|
||||
var showAppDrawer by remember { mutableStateOf(false) }
|
||||
|
||||
// Tor state at Window level — survives key() app rebuild
|
||||
var torSettings by remember {
|
||||
@@ -299,6 +300,17 @@ fun main() {
|
||||
)
|
||||
}
|
||||
Menu("View") {
|
||||
Item(
|
||||
"App Drawer",
|
||||
shortcut =
|
||||
if (isMacOS) {
|
||||
KeyShortcut(Key.K, meta = true)
|
||||
} else {
|
||||
KeyShortcut(Key.K, ctrl = true)
|
||||
},
|
||||
onClick = { showAppDrawer = !showAppDrawer },
|
||||
)
|
||||
Separator()
|
||||
Item(
|
||||
if (layoutMode == LayoutMode.DECK) "\u2713 Deck Layout" else "Deck Layout",
|
||||
shortcut =
|
||||
@@ -323,7 +335,7 @@ fun main() {
|
||||
} else {
|
||||
KeyShortcut(Key.T, ctrl = true)
|
||||
},
|
||||
onClick = { showAddColumnDialog = true },
|
||||
onClick = { showAppDrawer = true },
|
||||
)
|
||||
Item(
|
||||
"Close Column",
|
||||
@@ -435,7 +447,7 @@ fun main() {
|
||||
deckState = deckState,
|
||||
accountManager = accountManager,
|
||||
showComposeDialog = showComposeDialog,
|
||||
showAddColumnDialog = showAddColumnDialog,
|
||||
showAppDrawer = showAppDrawer,
|
||||
onShowComposeDialog = { showComposeDialog = true },
|
||||
onShowReplyDialog = { event ->
|
||||
replyToNote = event
|
||||
@@ -445,8 +457,8 @@ fun main() {
|
||||
showComposeDialog = false
|
||||
replyToNote = null
|
||||
},
|
||||
onDismissAddColumnDialog = { showAddColumnDialog = false },
|
||||
onShowAddColumnDialog = { showAddColumnDialog = true },
|
||||
onDismissAppDrawer = { showAppDrawer = false },
|
||||
onShowAppDrawer = { showAppDrawer = true },
|
||||
replyToNote = replyToNote,
|
||||
onRestartApp = { appRestartKey++ },
|
||||
torManager = torManager,
|
||||
@@ -466,12 +478,12 @@ fun App(
|
||||
deckState: DeckState,
|
||||
accountManager: AccountManager,
|
||||
showComposeDialog: Boolean,
|
||||
showAddColumnDialog: Boolean,
|
||||
showAppDrawer: Boolean,
|
||||
onShowComposeDialog: () -> Unit,
|
||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||
onDismissComposeDialog: () -> Unit,
|
||||
onDismissAddColumnDialog: () -> Unit,
|
||||
onShowAddColumnDialog: () -> Unit,
|
||||
onDismissAppDrawer: () -> Unit,
|
||||
onShowAppDrawer: () -> Unit,
|
||||
replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?,
|
||||
onRestartApp: () -> Unit = {},
|
||||
torManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager,
|
||||
@@ -479,6 +491,8 @@ fun App(
|
||||
externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow<Int>,
|
||||
initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings,
|
||||
) {
|
||||
val singlePaneState = remember { SinglePaneState() }
|
||||
|
||||
// Always reload from prefs — after key() rebuild, prefs have the latest saved settings
|
||||
var torSettings by remember {
|
||||
mutableStateOf(
|
||||
@@ -683,6 +697,7 @@ fun App(
|
||||
MainContent(
|
||||
layoutMode = layoutMode,
|
||||
deckState = deckState,
|
||||
singlePaneState = singlePaneState,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
@@ -693,7 +708,7 @@ fun App(
|
||||
torStatus = currentTorStatus,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onShowAddColumnDialog = onShowAddColumnDialog,
|
||||
onShowAppDrawer = onShowAppDrawer,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -707,14 +722,32 @@ fun App(
|
||||
)
|
||||
}
|
||||
|
||||
// Add column dialog
|
||||
if (showAddColumnDialog) {
|
||||
AddColumnDialog(
|
||||
onDismiss = onDismissAddColumnDialog,
|
||||
onAdd = { type ->
|
||||
deckState.addColumn(type)
|
||||
onDismissAddColumnDialog()
|
||||
// App Drawer overlay
|
||||
if (showAppDrawer) {
|
||||
val openColumns by deckState.columns.collectAsState()
|
||||
AppDrawer(
|
||||
openColumnTypes =
|
||||
if (layoutMode == LayoutMode.DECK) {
|
||||
openColumns.map { it.type.typeKey() }.toSet()
|
||||
} else {
|
||||
emptySet()
|
||||
},
|
||||
onSelectScreen = { type ->
|
||||
when (layoutMode) {
|
||||
LayoutMode.DECK -> {
|
||||
if (deckState.hasColumnOfType(type)) {
|
||||
deckState.focusExistingColumn(type)
|
||||
} else {
|
||||
deckState.addColumn(type)
|
||||
}
|
||||
}
|
||||
|
||||
LayoutMode.SINGLE_PANE -> {
|
||||
singlePaneState.navigate(type)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = onDismissAppDrawer,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -736,6 +769,7 @@ fun App(
|
||||
fun MainContent(
|
||||
layoutMode: LayoutMode,
|
||||
deckState: DeckState,
|
||||
singlePaneState: SinglePaneState,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
localCache: DesktopLocalCache,
|
||||
accountManager: AccountManager,
|
||||
@@ -746,7 +780,7 @@ fun MainContent(
|
||||
torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus,
|
||||
onShowComposeDialog: () -> Unit,
|
||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||
onShowAddColumnDialog: () -> Unit,
|
||||
onShowAppDrawer: () -> Unit,
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -893,6 +927,8 @@ fun MainContent(
|
||||
highlightStore = highlightStore,
|
||||
draftStore = draftStore,
|
||||
appScope = appScope,
|
||||
singlePaneState = singlePaneState,
|
||||
onOpenAppDrawer = onShowAppDrawer,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
@@ -906,7 +942,7 @@ fun MainContent(
|
||||
LayoutMode.DECK -> {
|
||||
if (!isImmersive) {
|
||||
DeckSidebar(
|
||||
onAddColumn = onShowAddColumnDialog,
|
||||
onAddColumn = onShowAppDrawer,
|
||||
onOpenSettings = {
|
||||
if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
|
||||
deckState.focusExistingColumn(DeckColumnType.Settings)
|
||||
|
||||
-165
@@ -1,165 +0,0 @@
|
||||
/*
|
||||
* 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.deck
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
private val COLUMN_OPTIONS =
|
||||
listOf(
|
||||
DeckColumnType.HomeFeed,
|
||||
DeckColumnType.Notifications,
|
||||
DeckColumnType.Messages,
|
||||
DeckColumnType.Search,
|
||||
DeckColumnType.Reads,
|
||||
DeckColumnType.Drafts,
|
||||
DeckColumnType.MyHighlights,
|
||||
DeckColumnType.Bookmarks,
|
||||
DeckColumnType.GlobalFeed,
|
||||
DeckColumnType.MyProfile,
|
||||
DeckColumnType.Chess,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AddColumnDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onAdd: (DeckColumnType) -> Unit,
|
||||
) {
|
||||
var hashtagInput by remember { mutableStateOf("") }
|
||||
var showHashtagInput by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Add Column") },
|
||||
text = {
|
||||
Column {
|
||||
if (showHashtagInput) {
|
||||
Text(
|
||||
"Enter hashtag:",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = hashtagInput,
|
||||
onValueChange = { hashtagInput = it.removePrefix("#") },
|
||||
label = { Text("Hashtag") },
|
||||
placeholder = { Text("bitcoin") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
COLUMN_OPTIONS.forEach { type ->
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onAdd(type) }
|
||||
.padding(vertical = 10.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = type.icon(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
type.title(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { showHashtagInput = true }
|
||||
.padding(vertical = 10.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = DeckColumnType.Hashtag("").icon(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
"Hashtag...",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
if (showHashtagInput) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (hashtagInput.isNotBlank()) {
|
||||
onAdd(DeckColumnType.Hashtag(hashtagInput.trim()))
|
||||
}
|
||||
},
|
||||
enabled = hashtagInput.isNotBlank(),
|
||||
) {
|
||||
Text("Add")
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
if (showHashtagInput) {
|
||||
showHashtagInput = false
|
||||
} else {
|
||||
onDismiss()
|
||||
}
|
||||
}) {
|
||||
Text(if (showHashtagInput) "Back" else "Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
/*
|
||||
* 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.deck
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Article
|
||||
import androidx.compose.material.icons.filled.Explore
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.SportsEsports
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
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.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.onPointerEvent
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
// -- Screen categories --
|
||||
|
||||
enum class ScreenCategory(
|
||||
val title: String,
|
||||
val icon: ImageVector,
|
||||
) {
|
||||
SOCIAL("Social", Icons.Default.Groups),
|
||||
LONG_FORM("Long-Form", Icons.AutoMirrored.Filled.Article),
|
||||
DISCOVERY("Discovery", Icons.Default.Explore),
|
||||
IDENTITY("Identity", Icons.Default.Person),
|
||||
PLAY("Play", Icons.Default.SportsEsports),
|
||||
}
|
||||
|
||||
// -- Extensions on DeckColumnType --
|
||||
|
||||
fun DeckColumnType.category(): ScreenCategory =
|
||||
when (this) {
|
||||
DeckColumnType.HomeFeed,
|
||||
DeckColumnType.Notifications,
|
||||
DeckColumnType.Messages,
|
||||
DeckColumnType.GlobalFeed,
|
||||
-> ScreenCategory.SOCIAL
|
||||
|
||||
DeckColumnType.Reads,
|
||||
DeckColumnType.Drafts,
|
||||
is DeckColumnType.Editor,
|
||||
DeckColumnType.MyHighlights,
|
||||
-> ScreenCategory.LONG_FORM
|
||||
|
||||
DeckColumnType.Search,
|
||||
is DeckColumnType.Hashtag,
|
||||
DeckColumnType.Bookmarks,
|
||||
-> ScreenCategory.DISCOVERY
|
||||
|
||||
DeckColumnType.MyProfile,
|
||||
DeckColumnType.Settings,
|
||||
-> ScreenCategory.IDENTITY
|
||||
|
||||
DeckColumnType.Chess -> ScreenCategory.PLAY
|
||||
|
||||
// Deep-link types — not in LAUNCHABLE_SCREENS but need a category for exhaustiveness
|
||||
is DeckColumnType.Profile,
|
||||
is DeckColumnType.Thread,
|
||||
is DeckColumnType.Article,
|
||||
-> ScreenCategory.SOCIAL
|
||||
}
|
||||
|
||||
fun DeckColumnType.requiresInput(): Boolean =
|
||||
when (this) {
|
||||
is DeckColumnType.Hashtag -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
val LAUNCHABLE_SCREENS: List<DeckColumnType> =
|
||||
listOf(
|
||||
DeckColumnType.HomeFeed,
|
||||
DeckColumnType.Notifications,
|
||||
DeckColumnType.Messages,
|
||||
DeckColumnType.GlobalFeed,
|
||||
DeckColumnType.Reads,
|
||||
DeckColumnType.Drafts,
|
||||
DeckColumnType.Editor(),
|
||||
DeckColumnType.MyHighlights,
|
||||
DeckColumnType.Search,
|
||||
DeckColumnType.Hashtag(""),
|
||||
DeckColumnType.Bookmarks,
|
||||
DeckColumnType.MyProfile,
|
||||
DeckColumnType.Settings,
|
||||
DeckColumnType.Chess,
|
||||
)
|
||||
|
||||
// -- State --
|
||||
|
||||
@Stable
|
||||
private class AppDrawerState {
|
||||
var searchQuery by mutableStateOf("")
|
||||
var selectedIndex by mutableStateOf(0)
|
||||
var hashtagInput by mutableStateOf("")
|
||||
var awaitingHashtag by mutableStateOf(false)
|
||||
private var consumed by mutableStateOf(false)
|
||||
|
||||
val filteredScreens: List<DeckColumnType> by derivedStateOf {
|
||||
if (searchQuery.isBlank()) {
|
||||
LAUNCHABLE_SCREENS
|
||||
} else {
|
||||
LAUNCHABLE_SCREENS.filter {
|
||||
it.title().contains(searchQuery, ignoreCase = true) ||
|
||||
it.category().title.contains(searchQuery, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val groupedScreens: Map<ScreenCategory, List<DeckColumnType>> by derivedStateOf {
|
||||
filteredScreens.groupBy { it.category() }.filterValues { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun moveSelection(delta: Int) {
|
||||
val size = filteredScreens.size
|
||||
if (size > 0) selectedIndex = (selectedIndex + delta).coerceIn(0, size - 1)
|
||||
}
|
||||
|
||||
fun select(
|
||||
screen: DeckColumnType,
|
||||
onSelectScreen: (DeckColumnType) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
if (consumed) return
|
||||
if (screen.requiresInput()) {
|
||||
awaitingHashtag = true
|
||||
hashtagInput = ""
|
||||
} else {
|
||||
consumed = true
|
||||
onSelectScreen(screen)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmHashtag(
|
||||
onSelectScreen: (DeckColumnType) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
if (consumed || hashtagInput.isBlank()) return
|
||||
consumed = true
|
||||
onSelectScreen(DeckColumnType.Hashtag(hashtagInput.removePrefix("#").trim()))
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// -- Composables --
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun AppDrawer(
|
||||
openColumnTypes: Set<String>,
|
||||
onSelectScreen: (DeckColumnType) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val state = remember { AppDrawerState() }
|
||||
val searchFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(50)
|
||||
searchFocusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
when (event.key) {
|
||||
Key.Escape -> {
|
||||
if (state.awaitingHashtag) {
|
||||
state.awaitingHashtag = false
|
||||
} else {
|
||||
onDismiss()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionDown -> {
|
||||
state.moveSelection(1)
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionUp -> {
|
||||
state.moveSelection(-1)
|
||||
true
|
||||
}
|
||||
|
||||
Key.Enter -> {
|
||||
if (state.awaitingHashtag) {
|
||||
state.confirmHashtag(onSelectScreen, onDismiss)
|
||||
} else {
|
||||
state.filteredScreens.getOrNull(state.selectedIndex)?.let {
|
||||
state.select(it, onSelectScreen, onDismiss)
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> {
|
||||
false
|
||||
}
|
||||
}
|
||||
}.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
) { onDismiss() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(0.55f)
|
||||
.fillMaxHeight(0.7f)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
) { /* consume click — prevent propagation to scrim */ },
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
tonalElevation = 8.dp,
|
||||
) {
|
||||
Column {
|
||||
TextField(
|
||||
value = state.searchQuery,
|
||||
onValueChange = {
|
||||
state.searchQuery = it
|
||||
state.selectedIndex = 0
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(searchFocusRequester),
|
||||
placeholder = { Text("Search screens...") },
|
||||
singleLine = true,
|
||||
leadingIcon = { Icon(Icons.Default.Search, "Search") },
|
||||
)
|
||||
|
||||
if (state.awaitingHashtag) {
|
||||
HashtagInputSection(state, onSelectScreen, onDismiss)
|
||||
} else {
|
||||
DrawerGrid(state, openColumnTypes, onSelectScreen, onDismiss)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun DrawerGrid(
|
||||
state: AppDrawerState,
|
||||
openColumnTypes: Set<String>,
|
||||
onSelectScreen: (DeckColumnType) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(state.selectedIndex) {
|
||||
val approxItem = (state.selectedIndex / 4).coerceAtLeast(0)
|
||||
if (approxItem < listState.layoutInfo.totalItemsCount) {
|
||||
listState.animateScrollToItem(approxItem)
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
) {
|
||||
var globalIndex = 0
|
||||
state.groupedScreens.forEach { (category, screens) ->
|
||||
stickyHeader(key = "header-${category.name}") {
|
||||
CategoryHeader(category)
|
||||
}
|
||||
val startIndex = globalIndex
|
||||
item(key = "grid-${category.name}") {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
screens.forEachIndexed { localIdx, screen ->
|
||||
DrawerScreenCard(
|
||||
type = screen,
|
||||
isSelected = (startIndex + localIdx) == state.selectedIndex,
|
||||
isOpen = openColumnTypes.contains(screen.typeKey()),
|
||||
onClick = { state.select(screen, onSelectScreen, onDismiss) },
|
||||
onHover = { state.selectedIndex = startIndex + localIdx },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
globalIndex += screens.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun DrawerScreenCard(
|
||||
type: DeckColumnType,
|
||||
isSelected: Boolean,
|
||||
isOpen: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onHover: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(80.dp)
|
||||
.clickable(onClick = onClick)
|
||||
.onPointerEvent(PointerEventType.Enter) { onHover() },
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
tonalElevation = if (isSelected) 8.dp else 2.dp,
|
||||
color =
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
},
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
type.icon(),
|
||||
contentDescription = type.title(),
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
type.title(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
if (isOpen) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp)
|
||||
.size(6.dp)
|
||||
.background(MaterialTheme.colorScheme.primary, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoryHeader(category: ScreenCategory) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(category.icon, category.title, Modifier.size(16.dp))
|
||||
Text(
|
||||
category.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HashtagInputSection(
|
||||
state: AppDrawerState,
|
||||
onSelectScreen: (DeckColumnType) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val hashtagFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(50)
|
||||
hashtagFocusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text("Enter hashtag:", style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = state.hashtagInput,
|
||||
onValueChange = { state.hashtagInput = it.removePrefix("#") },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(hashtagFocusRequester),
|
||||
placeholder = { Text("bitcoin, nostr...") },
|
||||
singleLine = true,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(onClick = { state.awaitingHashtag = false }) { Text("Back") }
|
||||
Button(
|
||||
onClick = { state.confirmHashtag(onSelectScreen, onDismiss) },
|
||||
enabled = state.hashtagInput.isNotBlank(),
|
||||
) {
|
||||
Text("Open")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-5
@@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Article
|
||||
import androidx.compose.material.icons.filled.Apps
|
||||
import androidx.compose.material.icons.filled.Bookmark
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
@@ -49,9 +50,7 @@ import androidx.compose.material3.VerticalDivider
|
||||
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.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -106,6 +105,8 @@ fun SinglePaneLayout(
|
||||
highlightStore: DesktopHighlightStore,
|
||||
draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore,
|
||||
appScope: CoroutineScope,
|
||||
singlePaneState: SinglePaneState,
|
||||
onOpenAppDrawer: () -> Unit,
|
||||
onShowComposeDialog: () -> Unit,
|
||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||
onZapFeedback: (ZapFeedback) -> Unit,
|
||||
@@ -114,7 +115,7 @@ fun SinglePaneLayout(
|
||||
lastRelayEventAt: Long? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var currentColumnType by remember { mutableStateOf<DeckColumnType>(DeckColumnType.HomeFeed) }
|
||||
val currentColumnType by singlePaneState.currentScreen.collectAsState()
|
||||
val navState = remember { ColumnNavigationState() }
|
||||
val navStack by navState.stack.collectAsState()
|
||||
val currentOverlay = navStack.lastOrNull()
|
||||
@@ -131,7 +132,7 @@ fun SinglePaneLayout(
|
||||
NavigationRailItem(
|
||||
selected = currentColumnType == item.type && navStack.isEmpty(),
|
||||
onClick = {
|
||||
currentColumnType = item.type
|
||||
singlePaneState.navigate(item.type)
|
||||
navState.clear()
|
||||
},
|
||||
icon = {
|
||||
@@ -152,6 +153,25 @@ fun SinglePaneLayout(
|
||||
)
|
||||
}
|
||||
|
||||
NavigationRailItem(
|
||||
selected = false,
|
||||
onClick = onOpenAppDrawer,
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Apps,
|
||||
contentDescription = "App Drawer",
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
"More",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
// Relay health — shows elapsed time since last event (hidden when <30s)
|
||||
@@ -171,7 +191,7 @@ fun SinglePaneLayout(
|
||||
TorStatusIndicator(
|
||||
status = torState.status,
|
||||
onClick = {
|
||||
currentColumnType = DeckColumnType.Settings
|
||||
singlePaneState.navigate(DeckColumnType.Settings)
|
||||
navState.clear()
|
||||
},
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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.deck
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Holds current screen for single-pane mode. Mirrors DeckState pattern.
|
||||
*/
|
||||
class SinglePaneState {
|
||||
private val _currentScreen = MutableStateFlow<DeckColumnType>(DeckColumnType.HomeFeed)
|
||||
val currentScreen: StateFlow<DeckColumnType> = _currentScreen.asStateFlow()
|
||||
|
||||
fun navigate(type: DeckColumnType) {
|
||||
_currentScreen.value = type
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user