Merge pull request #1760 from nrobi144/feat/desktop-deck-layout
feat(desktop): multi-column deck layout with single-pane toggle
This commit is contained in:
+14
@@ -35,6 +35,8 @@ object DesktopPreferences {
|
|||||||
|
|
||||||
private const val KEY_FEED_MODE = "feed_mode"
|
private const val KEY_FEED_MODE = "feed_mode"
|
||||||
private const val KEY_LAST_SCREEN = "last_screen"
|
private const val KEY_LAST_SCREEN = "last_screen"
|
||||||
|
private const val KEY_DECK_COLUMNS = "deck_columns"
|
||||||
|
private const val KEY_LAYOUT_MODE = "layout_mode"
|
||||||
|
|
||||||
var feedMode: FeedMode
|
var feedMode: FeedMode
|
||||||
get() {
|
get() {
|
||||||
@@ -54,4 +56,16 @@ object DesktopPreferences {
|
|||||||
set(value) {
|
set(value) {
|
||||||
prefs.put(KEY_LAST_SCREEN, value)
|
prefs.put(KEY_LAST_SCREEN, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var deckColumns: String
|
||||||
|
get() = prefs.get(KEY_DECK_COLUMNS, "")
|
||||||
|
set(value) {
|
||||||
|
prefs.put(KEY_DECK_COLUMNS, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
var layoutMode: String
|
||||||
|
get() = prefs.get(KEY_LAYOUT_MODE, "SINGLE_PANE")
|
||||||
|
set(value) {
|
||||||
|
prefs.put(KEY_LAYOUT_MODE, value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,32 +25,20 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Article
|
|
||||||
import androidx.compose.material.icons.filled.Email
|
|
||||||
import androidx.compose.material.icons.filled.Extension
|
|
||||||
import androidx.compose.material.icons.filled.Home
|
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
|
||||||
import androidx.compose.material.icons.filled.Person
|
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.Search
|
|
||||||
import androidx.compose.material.icons.filled.Settings
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.NavigationRail
|
|
||||||
import androidx.compose.material3.NavigationRailItem
|
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
@@ -82,23 +70,20 @@ import androidx.compose.ui.window.rememberWindowState
|
|||||||
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
||||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
import com.vitorpamplona.amethyst.desktop.chess.ChessScreen
|
|
||||||
import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
|
import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
|
||||||
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
|
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen
|
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog
|
import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
|
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.LoginScreen
|
import com.vitorpamplona.amethyst.desktop.ui.LoginScreen
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen
|
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.ReadsScreen
|
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.SearchScreen
|
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen
|
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen
|
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen
|
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
|
import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.deck.AddColumnDialog
|
||||||
|
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.profile.ProfileInfoCard
|
import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
|
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
|
||||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||||
@@ -110,8 +95,13 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
||||||
|
|
||||||
|
enum class LayoutMode {
|
||||||
|
SINGLE_PANE,
|
||||||
|
DECK,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Desktop navigation state - extends AppScreen with dynamic destinations.
|
* Desktop navigation state — used for in-column navigation (drill-down).
|
||||||
*/
|
*/
|
||||||
sealed class DesktopScreen {
|
sealed class DesktopScreen {
|
||||||
object Feed : DesktopScreen()
|
object Feed : DesktopScreen()
|
||||||
@@ -151,7 +141,18 @@ fun main() =
|
|||||||
)
|
)
|
||||||
var showComposeDialog by remember { mutableStateOf(false) }
|
var showComposeDialog by remember { mutableStateOf(false) }
|
||||||
var replyToNote by remember { mutableStateOf<com.vitorpamplona.quartz.nip01Core.core.Event?>(null) }
|
var replyToNote by remember { mutableStateOf<com.vitorpamplona.quartz.nip01Core.core.Event?>(null) }
|
||||||
var currentScreen by remember { mutableStateOf<DesktopScreen>(DesktopScreen.Feed) }
|
val deckScope = rememberCoroutineScope()
|
||||||
|
val deckState = remember { DeckState(deckScope).also { it.load() } }
|
||||||
|
var showAddColumnDialog by remember { mutableStateOf(false) }
|
||||||
|
var layoutMode by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
try {
|
||||||
|
LayoutMode.valueOf(DesktopPreferences.layoutMode)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LayoutMode.SINGLE_PANE
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Window(
|
Window(
|
||||||
onCloseRequest = ::exitApplication,
|
onCloseRequest = ::exitApplication,
|
||||||
@@ -179,7 +180,13 @@ fun main() =
|
|||||||
} else {
|
} else {
|
||||||
KeyShortcut(Key.Comma, ctrl = true)
|
KeyShortcut(Key.Comma, ctrl = true)
|
||||||
},
|
},
|
||||||
onClick = { currentScreen = DesktopScreen.Settings },
|
onClick = {
|
||||||
|
if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
|
||||||
|
deckState.focusExistingColumn(DeckColumnType.Settings)
|
||||||
|
} else {
|
||||||
|
deckState.addColumn(DeckColumnType.Settings)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
Separator()
|
Separator()
|
||||||
Item(
|
Item(
|
||||||
@@ -216,9 +223,121 @@ fun main() =
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Menu("View") {
|
Menu("View") {
|
||||||
Item("Feed", onClick = { })
|
Item(
|
||||||
Item("Messages", onClick = { })
|
if (layoutMode == LayoutMode.DECK) "\u2713 Deck Layout" else "Deck Layout",
|
||||||
Item("Notifications", onClick = { })
|
shortcut =
|
||||||
|
if (isMacOS) {
|
||||||
|
KeyShortcut(Key.D, meta = true, shift = true)
|
||||||
|
} else {
|
||||||
|
KeyShortcut(Key.D, ctrl = true, shift = true)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
layoutMode =
|
||||||
|
if (layoutMode == LayoutMode.DECK) LayoutMode.SINGLE_PANE else LayoutMode.DECK
|
||||||
|
DesktopPreferences.layoutMode = layoutMode.name
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (layoutMode == LayoutMode.DECK) {
|
||||||
|
Separator()
|
||||||
|
Item(
|
||||||
|
"Add Column",
|
||||||
|
shortcut =
|
||||||
|
if (isMacOS) {
|
||||||
|
KeyShortcut(Key.T, meta = true)
|
||||||
|
} else {
|
||||||
|
KeyShortcut(Key.T, ctrl = true)
|
||||||
|
},
|
||||||
|
onClick = { showAddColumnDialog = true },
|
||||||
|
)
|
||||||
|
Item(
|
||||||
|
"Close Column",
|
||||||
|
shortcut =
|
||||||
|
if (isMacOS) {
|
||||||
|
KeyShortcut(Key.W, meta = true)
|
||||||
|
} else {
|
||||||
|
KeyShortcut(Key.W, ctrl = true)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
val cols = deckState.columns.value
|
||||||
|
val idx = deckState.focusedColumnIndex.value
|
||||||
|
if (cols.size > 1 && idx in cols.indices) {
|
||||||
|
deckState.removeColumn(cols[idx].id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Item(
|
||||||
|
"Move Column Left",
|
||||||
|
shortcut =
|
||||||
|
if (isMacOS) {
|
||||||
|
KeyShortcut(Key.DirectionLeft, meta = true, shift = true)
|
||||||
|
} else {
|
||||||
|
KeyShortcut(Key.DirectionLeft, ctrl = true, shift = true)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
val idx = deckState.focusedColumnIndex.value
|
||||||
|
if (idx > 0) {
|
||||||
|
deckState.moveColumn(idx, idx - 1)
|
||||||
|
deckState.focusColumn(idx - 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Item(
|
||||||
|
"Move Column Right",
|
||||||
|
shortcut =
|
||||||
|
if (isMacOS) {
|
||||||
|
KeyShortcut(Key.DirectionRight, meta = true, shift = true)
|
||||||
|
} else {
|
||||||
|
KeyShortcut(Key.DirectionRight, ctrl = true, shift = true)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
val idx = deckState.focusedColumnIndex.value
|
||||||
|
val size = deckState.columns.value.size
|
||||||
|
if (idx < size - 1) {
|
||||||
|
deckState.moveColumn(idx, idx + 1)
|
||||||
|
deckState.focusColumn(idx + 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Separator()
|
||||||
|
// Focus column by index (Cmd/Ctrl+1..9)
|
||||||
|
val columnKeys =
|
||||||
|
listOf(
|
||||||
|
Key.One,
|
||||||
|
Key.Two,
|
||||||
|
Key.Three,
|
||||||
|
Key.Four,
|
||||||
|
Key.Five,
|
||||||
|
Key.Six,
|
||||||
|
Key.Seven,
|
||||||
|
Key.Eight,
|
||||||
|
Key.Nine,
|
||||||
|
)
|
||||||
|
val columnCount = deckState.columns.value.size
|
||||||
|
columnKeys.take(columnCount).forEachIndexed { i, key ->
|
||||||
|
Item(
|
||||||
|
"Column ${i + 1}",
|
||||||
|
shortcut =
|
||||||
|
if (isMacOS) {
|
||||||
|
KeyShortcut(key, meta = true)
|
||||||
|
} else {
|
||||||
|
KeyShortcut(key, ctrl = true)
|
||||||
|
},
|
||||||
|
onClick = { deckState.focusColumn(i) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Separator()
|
||||||
|
Menu("Add Column...") {
|
||||||
|
Item("Home Feed", onClick = { deckState.addColumn(DeckColumnType.HomeFeed) })
|
||||||
|
Item("Notifications", onClick = { deckState.addColumn(DeckColumnType.Notifications) })
|
||||||
|
Item("Messages", onClick = { deckState.addColumn(DeckColumnType.Messages) })
|
||||||
|
Item("Search", onClick = { deckState.addColumn(DeckColumnType.Search) })
|
||||||
|
Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) })
|
||||||
|
Item("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) })
|
||||||
|
Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) })
|
||||||
|
Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) })
|
||||||
|
Item("Chess", onClick = { deckState.addColumn(DeckColumnType.Chess) })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Menu("Help") {
|
Menu("Help") {
|
||||||
Item("About Amethyst", onClick = { })
|
Item("About Amethyst", onClick = { })
|
||||||
@@ -227,9 +346,10 @@ fun main() =
|
|||||||
}
|
}
|
||||||
|
|
||||||
App(
|
App(
|
||||||
currentScreen = currentScreen,
|
layoutMode = layoutMode,
|
||||||
onScreenChange = { currentScreen = it },
|
deckState = deckState,
|
||||||
showComposeDialog = showComposeDialog,
|
showComposeDialog = showComposeDialog,
|
||||||
|
showAddColumnDialog = showAddColumnDialog,
|
||||||
onShowComposeDialog = { showComposeDialog = true },
|
onShowComposeDialog = { showComposeDialog = true },
|
||||||
onShowReplyDialog = { event ->
|
onShowReplyDialog = { event ->
|
||||||
replyToNote = event
|
replyToNote = event
|
||||||
@@ -239,6 +359,8 @@ fun main() =
|
|||||||
showComposeDialog = false
|
showComposeDialog = false
|
||||||
replyToNote = null
|
replyToNote = null
|
||||||
},
|
},
|
||||||
|
onDismissAddColumnDialog = { showAddColumnDialog = false },
|
||||||
|
onShowAddColumnDialog = { showAddColumnDialog = true },
|
||||||
replyToNote = replyToNote,
|
replyToNote = replyToNote,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -246,12 +368,15 @@ fun main() =
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun App(
|
fun App(
|
||||||
currentScreen: DesktopScreen,
|
layoutMode: LayoutMode,
|
||||||
onScreenChange: (DesktopScreen) -> Unit,
|
deckState: DeckState,
|
||||||
showComposeDialog: Boolean,
|
showComposeDialog: Boolean,
|
||||||
|
showAddColumnDialog: Boolean,
|
||||||
onShowComposeDialog: () -> Unit,
|
onShowComposeDialog: () -> Unit,
|
||||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||||
onDismissComposeDialog: () -> Unit,
|
onDismissComposeDialog: () -> Unit,
|
||||||
|
onDismissAddColumnDialog: () -> Unit,
|
||||||
|
onShowAddColumnDialog: () -> Unit,
|
||||||
replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?,
|
replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?,
|
||||||
) {
|
) {
|
||||||
val relayManager = remember { DesktopRelayConnectionManager() }
|
val relayManager = remember { DesktopRelayConnectionManager() }
|
||||||
@@ -301,7 +426,7 @@ fun App(
|
|||||||
is AccountState.LoggedOut -> {
|
is AccountState.LoggedOut -> {
|
||||||
LoginScreen(
|
LoginScreen(
|
||||||
accountManager = accountManager,
|
accountManager = accountManager,
|
||||||
onLoginSuccess = { onScreenChange(DesktopScreen.Feed) },
|
onLoginSuccess = { },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,16 +440,18 @@ fun App(
|
|||||||
}
|
}
|
||||||
|
|
||||||
MainContent(
|
MainContent(
|
||||||
currentScreen = currentScreen,
|
layoutMode = layoutMode,
|
||||||
onScreenChange = onScreenChange,
|
deckState = deckState,
|
||||||
relayManager = relayManager,
|
relayManager = relayManager,
|
||||||
localCache = localCache,
|
localCache = localCache,
|
||||||
accountManager = accountManager,
|
accountManager = accountManager,
|
||||||
account = account,
|
account = account,
|
||||||
nwcConnection = nwcConnection,
|
nwcConnection = nwcConnection,
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
appScope = scope,
|
||||||
onShowComposeDialog = onShowComposeDialog,
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
onShowReplyDialog = onShowReplyDialog,
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
|
onShowAddColumnDialog = onShowAddColumnDialog,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Compose dialog
|
// Compose dialog
|
||||||
@@ -336,6 +463,17 @@ fun App(
|
|||||||
replyTo = replyToNote,
|
replyTo = replyToNote,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add column dialog
|
||||||
|
if (showAddColumnDialog) {
|
||||||
|
AddColumnDialog(
|
||||||
|
onDismiss = onDismissAddColumnDialog,
|
||||||
|
onAdd = { type ->
|
||||||
|
deckState.addColumn(type)
|
||||||
|
onDismissAddColumnDialog()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,16 +482,18 @@ fun App(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainContent(
|
fun MainContent(
|
||||||
currentScreen: DesktopScreen,
|
layoutMode: LayoutMode,
|
||||||
onScreenChange: (DesktopScreen) -> Unit,
|
deckState: DeckState,
|
||||||
relayManager: DesktopRelayConnectionManager,
|
relayManager: DesktopRelayConnectionManager,
|
||||||
localCache: DesktopLocalCache,
|
localCache: DesktopLocalCache,
|
||||||
accountManager: AccountManager,
|
accountManager: AccountManager,
|
||||||
account: AccountState.LoggedIn,
|
account: AccountState.LoggedIn,
|
||||||
nwcConnection: Nip47WalletConnect.Nip47URINorm?,
|
nwcConnection: Nip47WalletConnect.Nip47URINorm?,
|
||||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||||
|
appScope: CoroutineScope,
|
||||||
onShowComposeDialog: () -> Unit,
|
onShowComposeDialog: () -> Unit,
|
||||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||||
|
onShowAddColumnDialog: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -465,234 +605,51 @@ fun MainContent(
|
|||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
Row(Modifier.fillMaxSize()) {
|
Row(Modifier.fillMaxSize()) {
|
||||||
// Sidebar Navigation
|
when (layoutMode) {
|
||||||
NavigationRail(
|
LayoutMode.SINGLE_PANE -> {
|
||||||
modifier = Modifier.width(80.dp).fillMaxHeight(),
|
SinglePaneLayout(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
relayManager = relayManager,
|
||||||
) {
|
localCache = localCache,
|
||||||
Spacer(Modifier.height(16.dp))
|
accountManager = accountManager,
|
||||||
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
appScope = appScope,
|
||||||
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
NavigationRailItem(
|
LayoutMode.DECK -> {
|
||||||
icon = { Icon(Icons.Default.Home, contentDescription = "Feed") },
|
DeckSidebar(
|
||||||
label = { Text("Feed") },
|
onAddColumn = onShowAddColumnDialog,
|
||||||
selected = currentScreen == DesktopScreen.Feed,
|
onOpenSettings = {
|
||||||
onClick = { onScreenChange(DesktopScreen.Feed) },
|
if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
|
||||||
)
|
deckState.focusExistingColumn(DeckColumnType.Settings)
|
||||||
|
} else {
|
||||||
|
deckState.addColumn(DeckColumnType.Settings)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
NavigationRailItem(
|
VerticalDivider()
|
||||||
icon = { Icon(Icons.AutoMirrored.Filled.Article, contentDescription = "Reads") },
|
|
||||||
label = { Text("Reads") },
|
|
||||||
selected = currentScreen == DesktopScreen.Reads,
|
|
||||||
onClick = { onScreenChange(DesktopScreen.Reads) },
|
|
||||||
)
|
|
||||||
|
|
||||||
NavigationRailItem(
|
DeckLayout(
|
||||||
icon = { Icon(Icons.Default.Search, contentDescription = "Search") },
|
deckState = deckState,
|
||||||
label = { Text("Search") },
|
relayManager = relayManager,
|
||||||
selected = currentScreen == DesktopScreen.Search,
|
localCache = localCache,
|
||||||
onClick = { onScreenChange(DesktopScreen.Search) },
|
accountManager = accountManager,
|
||||||
)
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
NavigationRailItem(
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
icon = { Icon(com.vitorpamplona.amethyst.commons.icons.Bookmark, contentDescription = "Bookmarks") },
|
appScope = appScope,
|
||||||
label = { Text("Bookmarks") },
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
selected = currentScreen == DesktopScreen.Bookmarks,
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
onClick = { onScreenChange(DesktopScreen.Bookmarks) },
|
onZapFeedback = onZapFeedback,
|
||||||
)
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
NavigationRailItem(
|
|
||||||
icon = { Icon(Icons.Default.Email, contentDescription = "Messages") },
|
|
||||||
label = { Text("DMs") },
|
|
||||||
selected = currentScreen == DesktopScreen.Messages,
|
|
||||||
onClick = { onScreenChange(DesktopScreen.Messages) },
|
|
||||||
)
|
|
||||||
|
|
||||||
NavigationRailItem(
|
|
||||||
icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") },
|
|
||||||
label = { Text("Alerts") },
|
|
||||||
selected = currentScreen == DesktopScreen.Notifications,
|
|
||||||
onClick = { onScreenChange(DesktopScreen.Notifications) },
|
|
||||||
)
|
|
||||||
|
|
||||||
NavigationRailItem(
|
|
||||||
icon = { Icon(Icons.Default.Extension, contentDescription = "Chess") },
|
|
||||||
label = { Text("Chess") },
|
|
||||||
selected = currentScreen == DesktopScreen.Chess,
|
|
||||||
onClick = { onScreenChange(DesktopScreen.Chess) },
|
|
||||||
)
|
|
||||||
|
|
||||||
NavigationRailItem(
|
|
||||||
icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
|
|
||||||
label = { Text("Profile") },
|
|
||||||
selected = currentScreen == DesktopScreen.MyProfile || currentScreen is DesktopScreen.UserProfile,
|
|
||||||
onClick = { onScreenChange(DesktopScreen.MyProfile) },
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.weight(1f))
|
|
||||||
|
|
||||||
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
|
|
||||||
|
|
||||||
NavigationRailItem(
|
|
||||||
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
|
|
||||||
label = { Text("Settings") },
|
|
||||||
selected = currentScreen == DesktopScreen.Settings,
|
|
||||||
onClick = { onScreenChange(DesktopScreen.Settings) },
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
VerticalDivider()
|
|
||||||
|
|
||||||
// Main Content
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp),
|
|
||||||
) {
|
|
||||||
when (currentScreen) {
|
|
||||||
DesktopScreen.Feed -> {
|
|
||||||
FeedScreen(
|
|
||||||
relayManager = relayManager,
|
|
||||||
localCache = localCache,
|
|
||||||
account = account,
|
|
||||||
nwcConnection = nwcConnection,
|
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
|
||||||
onCompose = onShowComposeDialog,
|
|
||||||
onNavigateToProfile = { pubKeyHex ->
|
|
||||||
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
|
|
||||||
},
|
|
||||||
onNavigateToThread = { noteId ->
|
|
||||||
onScreenChange(DesktopScreen.Thread(noteId))
|
|
||||||
},
|
|
||||||
onZapFeedback = onZapFeedback,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
DesktopScreen.Reads -> {
|
|
||||||
ReadsScreen(
|
|
||||||
relayManager = relayManager,
|
|
||||||
localCache = localCache,
|
|
||||||
account = account,
|
|
||||||
onNavigateToProfile = { pubKeyHex ->
|
|
||||||
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
|
|
||||||
},
|
|
||||||
onNavigateToArticle = { noteId ->
|
|
||||||
onScreenChange(DesktopScreen.Thread(noteId))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
DesktopScreen.Search -> {
|
|
||||||
SearchScreen(
|
|
||||||
localCache = localCache,
|
|
||||||
relayManager = relayManager,
|
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
|
||||||
onNavigateToProfile = { pubKeyHex ->
|
|
||||||
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
|
|
||||||
},
|
|
||||||
onNavigateToThread = { noteId ->
|
|
||||||
onScreenChange(DesktopScreen.Thread(noteId))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
DesktopScreen.Bookmarks -> {
|
|
||||||
BookmarksScreen(
|
|
||||||
relayManager = relayManager,
|
|
||||||
localCache = localCache,
|
|
||||||
account = account,
|
|
||||||
nwcConnection = nwcConnection,
|
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
|
||||||
onNavigateToProfile = { pubKeyHex ->
|
|
||||||
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
|
|
||||||
},
|
|
||||||
onNavigateToThread = { noteId ->
|
|
||||||
onScreenChange(DesktopScreen.Thread(noteId))
|
|
||||||
},
|
|
||||||
onZapFeedback = onZapFeedback,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
DesktopScreen.Messages -> {
|
|
||||||
DesktopMessagesScreen(
|
|
||||||
account = iAccount,
|
|
||||||
cacheProvider = localCache,
|
|
||||||
relayManager = relayManager,
|
|
||||||
localCache = localCache,
|
|
||||||
onNavigateToProfile = { pubKeyHex ->
|
|
||||||
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
DesktopScreen.Notifications -> {
|
|
||||||
NotificationsScreen(relayManager, account, subscriptionsCoordinator)
|
|
||||||
}
|
|
||||||
|
|
||||||
DesktopScreen.Chess -> {
|
|
||||||
ChessScreen(
|
|
||||||
relayManager = relayManager,
|
|
||||||
account = account,
|
|
||||||
onBack = { onScreenChange(DesktopScreen.Feed) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
DesktopScreen.MyProfile -> {
|
|
||||||
UserProfileScreen(
|
|
||||||
pubKeyHex = account.pubKeyHex,
|
|
||||||
relayManager = relayManager,
|
|
||||||
localCache = localCache,
|
|
||||||
account = account,
|
|
||||||
nwcConnection = nwcConnection,
|
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
|
||||||
onBack = { onScreenChange(DesktopScreen.Feed) },
|
|
||||||
onCompose = onShowComposeDialog,
|
|
||||||
onNavigateToProfile = { pubKeyHex ->
|
|
||||||
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
|
|
||||||
},
|
|
||||||
onZapFeedback = onZapFeedback,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is DesktopScreen.UserProfile -> {
|
|
||||||
UserProfileScreen(
|
|
||||||
pubKeyHex = currentScreen.pubKeyHex,
|
|
||||||
relayManager = relayManager,
|
|
||||||
localCache = localCache,
|
|
||||||
account = account,
|
|
||||||
nwcConnection = nwcConnection,
|
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
|
||||||
onBack = { onScreenChange(DesktopScreen.Feed) },
|
|
||||||
onCompose = onShowComposeDialog,
|
|
||||||
onNavigateToProfile = { pubKeyHex ->
|
|
||||||
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
|
|
||||||
},
|
|
||||||
onZapFeedback = onZapFeedback,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is DesktopScreen.Thread -> {
|
|
||||||
ThreadScreen(
|
|
||||||
noteId = currentScreen.noteId,
|
|
||||||
relayManager = relayManager,
|
|
||||||
localCache = localCache,
|
|
||||||
account = account,
|
|
||||||
nwcConnection = nwcConnection,
|
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
|
||||||
onBack = { onScreenChange(DesktopScreen.Feed) },
|
|
||||||
onNavigateToProfile = { pubKeyHex ->
|
|
||||||
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
|
|
||||||
},
|
|
||||||
onNavigateToThread = { noteId ->
|
|
||||||
onScreenChange(DesktopScreen.Thread(noteId))
|
|
||||||
},
|
|
||||||
onZapFeedback = onZapFeedback,
|
|
||||||
onReply = onShowReplyDialog,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
DesktopScreen.Settings -> {
|
|
||||||
RelaySettingsScreen(relayManager, account, accountManager)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+196
-172
@@ -39,6 +39,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
@@ -90,6 +92,7 @@ fun ChessScreen(
|
|||||||
relayManager: DesktopRelayConnectionManager,
|
relayManager: DesktopRelayConnectionManager,
|
||||||
account: AccountState.LoggedIn,
|
account: AccountState.LoggedIn,
|
||||||
onBack: () -> Unit = {},
|
onBack: () -> Unit = {},
|
||||||
|
compactMode: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val viewModel =
|
val viewModel =
|
||||||
@@ -274,6 +277,7 @@ fun ChessScreen(
|
|||||||
},
|
},
|
||||||
onResign = { viewModel.resign(gameState.startEventId) },
|
onResign = { viewModel.resign(gameState.startEventId) },
|
||||||
isSpectatorOverride = isSpectating,
|
isSpectatorOverride = isSpectating,
|
||||||
|
compactMode = compactMode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -593,6 +597,7 @@ private fun DesktopChessGameLayout(
|
|||||||
onMoveMade: (from: String, to: String, san: String) -> Unit,
|
onMoveMade: (from: String, to: String, san: String) -> Unit,
|
||||||
onResign: () -> Unit,
|
onResign: () -> Unit,
|
||||||
isSpectatorOverride: Boolean? = null,
|
isSpectatorOverride: Boolean? = null,
|
||||||
|
compactMode: Boolean = false,
|
||||||
) {
|
) {
|
||||||
// Collect state flows to trigger recomposition on changes
|
// Collect state flows to trigger recomposition on changes
|
||||||
val currentPosition by gameState.currentPosition.collectAsState()
|
val currentPosition by gameState.currentPosition.collectAsState()
|
||||||
@@ -604,12 +609,13 @@ private fun DesktopChessGameLayout(
|
|||||||
val opponentPubkey = gameState.opponentPubkey
|
val opponentPubkey = gameState.opponentPubkey
|
||||||
val isSpectator = isSpectatorOverride ?: gameState.isSpectator
|
val isSpectator = isSpectatorOverride ?: gameState.isSpectator
|
||||||
|
|
||||||
|
var showInfoPanel by remember { mutableStateOf(!compactMode) }
|
||||||
|
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||||
) {
|
) {
|
||||||
// Calculate board size based on available space
|
// Calculate board size based on available space
|
||||||
// Leave room for the info panel (300dp + 24dp spacing)
|
val infoPanelWidth = if (showInfoPanel) 300.dp + 24.dp else 0.dp
|
||||||
val infoPanelWidth = 300.dp + 24.dp
|
|
||||||
val availableWidth = maxWidth - infoPanelWidth
|
val availableWidth = maxWidth - infoPanelWidth
|
||||||
val availableHeight = maxHeight
|
val availableHeight = maxHeight
|
||||||
// Board should fit within available space, maintaining square aspect ratio
|
// Board should fit within available space, maintaining square aspect ratio
|
||||||
@@ -617,11 +623,11 @@ private fun DesktopChessGameLayout(
|
|||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
horizontalArrangement = Arrangement.spacedBy(if (showInfoPanel) 24.dp else 0.dp),
|
||||||
) {
|
) {
|
||||||
// Left side: Chess board
|
// Left side: Chess board + toggle
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxHeight(),
|
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
InteractiveChessBoard(
|
InteractiveChessBoard(
|
||||||
@@ -633,188 +639,206 @@ private fun DesktopChessGameLayout(
|
|||||||
positionVersion = moveHistory.size,
|
positionVersion = moveHistory.size,
|
||||||
onMoveMade = onMoveMade,
|
onMoveMade = onMoveMade,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Toggle button anchored to top-end of board area
|
||||||
|
IconButton(
|
||||||
|
onClick = { showInfoPanel = !showInfoPanel },
|
||||||
|
modifier = Modifier.align(Alignment.TopEnd),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (showInfoPanel) {
|
||||||
|
Icons.AutoMirrored.Filled.KeyboardArrowRight
|
||||||
|
} else {
|
||||||
|
Icons.AutoMirrored.Filled.KeyboardArrowLeft
|
||||||
|
},
|
||||||
|
contentDescription = if (showInfoPanel) "Hide info panel" else "Show info panel",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right side: Game info, moves, controls
|
// Right side: Game info, moves, controls (collapsible)
|
||||||
Column(
|
if (showInfoPanel) {
|
||||||
modifier =
|
Column(
|
||||||
Modifier
|
modifier =
|
||||||
.width(300.dp)
|
Modifier
|
||||||
.fillMaxHeight()
|
.width(300.dp)
|
||||||
.verticalScroll(rememberScrollState()),
|
.fillMaxHeight()
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
.verticalScroll(rememberScrollState()),
|
||||||
) {
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
// Extract human-readable game name
|
|
||||||
val gameName =
|
|
||||||
remember(startEventId) {
|
|
||||||
ChessGameNameGenerator.extractDisplayName(startEventId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Game info card
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
) {
|
||||||
Column(
|
// Extract human-readable game name
|
||||||
modifier = Modifier.padding(16.dp),
|
val gameName =
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
remember(startEventId) {
|
||||||
) {
|
ChessGameNameGenerator.extractDisplayName(startEventId)
|
||||||
// Show readable game name if available
|
|
||||||
if (gameName != null) {
|
|
||||||
Text(
|
|
||||||
gameName,
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
"Game Info",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
// Game info card
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
Card(
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
UserAvatar(
|
// Show readable game name if available
|
||||||
userHex = opponentPubkey,
|
if (gameName != null) {
|
||||||
pictureUrl = opponentPicture,
|
Text(
|
||||||
size = 48.dp,
|
gameName,
|
||||||
)
|
style = MaterialTheme.typography.titleLarge,
|
||||||
Column {
|
fontWeight = FontWeight.Bold,
|
||||||
if (isSpectator) {
|
color = MaterialTheme.colorScheme.primary,
|
||||||
Text(
|
)
|
||||||
"Spectating",
|
} else {
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
Text(
|
||||||
fontWeight = FontWeight.Bold,
|
"Game Info",
|
||||||
color = MaterialTheme.colorScheme.tertiary,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
fontWeight = FontWeight.Bold,
|
||||||
} else {
|
)
|
||||||
Text(
|
}
|
||||||
"vs $opponentName",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
Row(
|
||||||
)
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
Text(
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
"You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}",
|
) {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
UserAvatar(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
userHex = opponentPubkey,
|
||||||
)
|
pictureUrl = opponentPicture,
|
||||||
|
size = 48.dp,
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
if (isSpectator) {
|
||||||
|
Text(
|
||||||
|
"Spectating",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.tertiary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
"vs $opponentName",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use currentPosition to derive turn (triggers recomposition on move)
|
||||||
|
val currentTurn = currentPosition.activeColor
|
||||||
|
|
||||||
|
if (isSpectator) {
|
||||||
|
Text(
|
||||||
|
"${if (currentTurn == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}'s turn",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val isYourTurn = currentTurn == playerColor
|
||||||
|
Text(
|
||||||
|
if (isYourTurn) "Your turn" else "Opponent's turn",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
color =
|
||||||
|
if (isYourTurn) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move history card
|
||||||
|
if (moveHistory.isNotEmpty()) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Move History",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Format moves as numbered pairs
|
||||||
|
val moveText =
|
||||||
|
moveHistory
|
||||||
|
.chunked(2)
|
||||||
|
.mapIndexed { index, pair ->
|
||||||
|
"${index + 1}. ${pair.joinToString(" ")}"
|
||||||
|
}.joinToString("\n")
|
||||||
|
|
||||||
|
Text(
|
||||||
|
moveText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game controls card (only for participants, not spectators)
|
||||||
|
if (!isSpectator) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Actions",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onResign,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Resign")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
// Use currentPosition to derive turn (triggers recomposition on move)
|
// Spectator info card
|
||||||
val currentTurn = currentPosition.activeColor
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
if (isSpectator) {
|
colors =
|
||||||
Text(
|
CardDefaults.cardColors(
|
||||||
"${if (currentTurn == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}'s turn",
|
containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
val isYourTurn = currentTurn == playerColor
|
|
||||||
Text(
|
|
||||||
if (isYourTurn) "Your turn" else "Opponent's turn",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal,
|
|
||||||
color =
|
|
||||||
if (isYourTurn) {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move history card
|
|
||||||
if (moveHistory.isNotEmpty()) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Row(
|
||||||
"Move History",
|
modifier = Modifier.padding(16.dp),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
fontWeight = FontWeight.Bold,
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
)
|
|
||||||
|
|
||||||
// Format moves as numbered pairs
|
|
||||||
val moveText =
|
|
||||||
moveHistory
|
|
||||||
.chunked(2)
|
|
||||||
.mapIndexed { index, pair ->
|
|
||||||
"${index + 1}. ${pair.joinToString(" ")}"
|
|
||||||
}.joinToString("\n")
|
|
||||||
|
|
||||||
Text(
|
|
||||||
moveText,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Game controls card (only for participants, not spectators)
|
|
||||||
if (!isSpectator) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Actions",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = onResign,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
) {
|
||||||
Text("Resign")
|
Icon(Icons.Default.Visibility, contentDescription = null)
|
||||||
|
Text(
|
||||||
|
"Watching game - spectator mode",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Spectator info card
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors =
|
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f),
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Visibility, contentDescription = null)
|
|
||||||
Text(
|
|
||||||
"Watching game - spectator mode",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Game ID (small footer)
|
// Game ID (small footer)
|
||||||
Text(
|
Text(
|
||||||
"Game: ${startEventId.take(16)}...",
|
"Game: ${startEventId.take(16)}...",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.desktop.ui
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
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.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -149,6 +151,7 @@ fun FeedScreen(
|
|||||||
account: AccountState.LoggedIn? = null,
|
account: AccountState.LoggedIn? = null,
|
||||||
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
|
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
|
||||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||||
|
initialFeedMode: FeedMode? = null,
|
||||||
onCompose: () -> Unit = {},
|
onCompose: () -> Unit = {},
|
||||||
onNavigateToProfile: (String) -> Unit = {},
|
onNavigateToProfile: (String) -> Unit = {},
|
||||||
onNavigateToThread: (String) -> Unit = {},
|
onNavigateToThread: (String) -> Unit = {},
|
||||||
@@ -168,7 +171,7 @@ fun FeedScreen(
|
|||||||
}
|
}
|
||||||
val events by eventState.items.collectAsState()
|
val events by eventState.items.collectAsState()
|
||||||
var replyToEvent by remember { mutableStateOf<Event?>(null) }
|
var replyToEvent by remember { mutableStateOf<Event?>(null) }
|
||||||
var feedMode by remember { mutableStateOf(DesktopPreferences.feedMode) }
|
var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) }
|
||||||
var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) }
|
var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||||
var zapsByEvent by remember { mutableStateOf<Map<String, List<ZapReceipt>>>(emptyMap()) }
|
var zapsByEvent by remember { mutableStateOf<Map<String, List<ZapReceipt>>>(emptyMap()) }
|
||||||
// Track reaction event IDs per target event to deduplicate
|
// Track reaction event IDs per target event to deduplicate
|
||||||
@@ -475,16 +478,17 @@ fun FeedScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Header with compose button
|
// Header with compose button — wraps on narrow columns
|
||||||
Row(
|
FlowRow(
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
Row(
|
FlowRow(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.desktop.ui
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
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.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -259,16 +261,17 @@ fun ReadsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Header
|
// Header — wraps on narrow columns
|
||||||
Row(
|
FlowRow(
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
Row(
|
FlowRow(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ fun SearchScreen(
|
|||||||
localCache: DesktopLocalCache,
|
localCache: DesktopLocalCache,
|
||||||
relayManager: DesktopRelayConnectionManager,
|
relayManager: DesktopRelayConnectionManager,
|
||||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||||
|
initialQuery: String = "",
|
||||||
onNavigateToProfile: (String) -> Unit,
|
onNavigateToProfile: (String) -> Unit,
|
||||||
onNavigateToThread: (String) -> Unit,
|
onNavigateToThread: (String) -> Unit,
|
||||||
onNavigateToHashtag: (String) -> Unit = {},
|
onNavigateToHashtag: (String) -> Unit = {},
|
||||||
@@ -86,6 +87,13 @@ fun SearchScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val searchState = remember { SearchBarState(localCache, scope) }
|
val searchState = remember { SearchBarState(localCache, scope) }
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
// Pre-fill initial query (e.g., hashtag column)
|
||||||
|
LaunchedEffect(initialQuery) {
|
||||||
|
if (initialQuery.isNotBlank()) {
|
||||||
|
searchState.updateSearchText(initialQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||||
|
|
||||||
// Collect state from SearchBarState
|
// Collect state from SearchBarState
|
||||||
|
|||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
/*
|
||||||
|
* 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.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")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
/*
|
||||||
|
* 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.gestures.detectTapGestures
|
||||||
|
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.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Article
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Email
|
||||||
|
import androidx.compose.material.icons.filled.Extension
|
||||||
|
import androidx.compose.material.icons.filled.Home
|
||||||
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.Public
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Tag
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ColumnHeader(
|
||||||
|
column: DeckColumn,
|
||||||
|
canClose: Boolean,
|
||||||
|
hasBackStack: Boolean,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onDoubleClick: () -> Unit = {},
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(40.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(
|
||||||
|
onDoubleTap = { onDoubleClick() },
|
||||||
|
)
|
||||||
|
}.padding(horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (hasBackStack) {
|
||||||
|
IconButton(onClick = onBack, modifier = Modifier.size(28.dp)) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Back",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = column.type.icon(),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = column.type.title(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (canClose) {
|
||||||
|
IconButton(onClick = onClose, modifier = Modifier.size(28.dp)) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = "Close column",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun DeckColumnType.icon(): ImageVector =
|
||||||
|
when (this) {
|
||||||
|
DeckColumnType.HomeFeed -> Icons.Default.Home
|
||||||
|
DeckColumnType.Notifications -> Icons.Default.Notifications
|
||||||
|
DeckColumnType.Messages -> Icons.Default.Email
|
||||||
|
DeckColumnType.Search -> Icons.Default.Search
|
||||||
|
DeckColumnType.Reads -> Icons.AutoMirrored.Filled.Article
|
||||||
|
DeckColumnType.Bookmarks -> com.vitorpamplona.amethyst.commons.icons.Bookmark
|
||||||
|
DeckColumnType.GlobalFeed -> Icons.Default.Public
|
||||||
|
DeckColumnType.MyProfile -> Icons.Default.Person
|
||||||
|
DeckColumnType.Chess -> Icons.Default.Extension
|
||||||
|
DeckColumnType.Settings -> Icons.Default.Settings
|
||||||
|
is DeckColumnType.Profile -> Icons.Default.Person
|
||||||
|
is DeckColumnType.Thread -> Icons.AutoMirrored.Filled.Article
|
||||||
|
is DeckColumnType.Hashtag -> Icons.Default.Tag
|
||||||
|
}
|
||||||
+390
@@ -0,0 +1,390 @@
|
|||||||
|
/*
|
||||||
|
* 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.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.desktop.DesktopScreen
|
||||||
|
import com.vitorpamplona.amethyst.desktop.RelaySettingsScreen
|
||||||
|
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
||||||
|
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||||
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
|
import com.vitorpamplona.amethyst.desktop.chess.ChessScreen
|
||||||
|
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
|
||||||
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
|
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||||
|
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.ReadsScreen
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.SearchScreen
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
|
||||||
|
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
class ColumnNavigationState {
|
||||||
|
private val _stack = MutableStateFlow<List<DesktopScreen>>(emptyList())
|
||||||
|
val stack: kotlinx.coroutines.flow.StateFlow<List<DesktopScreen>> = _stack.asStateFlow()
|
||||||
|
|
||||||
|
fun push(screen: DesktopScreen) {
|
||||||
|
_stack.value = _stack.value + screen
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pop(): Boolean {
|
||||||
|
if (_stack.value.isEmpty()) return false
|
||||||
|
_stack.value = _stack.value.dropLast(1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
_stack.value = emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeckColumnContainer(
|
||||||
|
column: DeckColumn,
|
||||||
|
canClose: Boolean,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onDoubleClickHeader: () -> Unit = {},
|
||||||
|
relayManager: DesktopRelayConnectionManager,
|
||||||
|
localCache: DesktopLocalCache,
|
||||||
|
accountManager: AccountManager,
|
||||||
|
account: AccountState.LoggedIn,
|
||||||
|
nwcConnection: Nip47URINorm?,
|
||||||
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||||
|
appScope: CoroutineScope,
|
||||||
|
onShowComposeDialog: () -> Unit,
|
||||||
|
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||||
|
onZapFeedback: (ZapFeedback) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val navState = remember(column.id) { ColumnNavigationState() }
|
||||||
|
val navStack by navState.stack.collectAsState()
|
||||||
|
val currentOverlay = navStack.lastOrNull()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.width(column.width.dp)
|
||||||
|
.fillMaxHeight(),
|
||||||
|
) {
|
||||||
|
ColumnHeader(
|
||||||
|
column = column,
|
||||||
|
canClose = canClose,
|
||||||
|
hasBackStack = navStack.isNotEmpty(),
|
||||||
|
onBack = { navState.pop() },
|
||||||
|
onClose = onClose,
|
||||||
|
onDoubleClick = onDoubleClickHeader,
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(12.dp),
|
||||||
|
) {
|
||||||
|
if (currentOverlay != null) {
|
||||||
|
OverlayContent(
|
||||||
|
screen = currentOverlay,
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
||||||
|
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||||
|
onBack = { navState.pop() },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
RootContent(
|
||||||
|
columnType = column.type,
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
accountManager = accountManager,
|
||||||
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
appScope = appScope,
|
||||||
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
||||||
|
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun RootContent(
|
||||||
|
columnType: DeckColumnType,
|
||||||
|
relayManager: DesktopRelayConnectionManager,
|
||||||
|
localCache: DesktopLocalCache,
|
||||||
|
accountManager: AccountManager,
|
||||||
|
account: AccountState.LoggedIn,
|
||||||
|
nwcConnection: Nip47URINorm?,
|
||||||
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||||
|
appScope: CoroutineScope,
|
||||||
|
onShowComposeDialog: () -> Unit,
|
||||||
|
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||||
|
onZapFeedback: (ZapFeedback) -> Unit,
|
||||||
|
onNavigateToProfile: (String) -> Unit,
|
||||||
|
onNavigateToThread: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
when (columnType) {
|
||||||
|
DeckColumnType.HomeFeed -> {
|
||||||
|
FeedScreen(
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
initialFeedMode = FeedMode.FOLLOWING,
|
||||||
|
onCompose = onShowComposeDialog,
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onNavigateToThread = onNavigateToThread,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeckColumnType.Notifications -> {
|
||||||
|
NotificationsScreen(relayManager, account, subscriptionsCoordinator)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeckColumnType.Messages -> {
|
||||||
|
val dmSendTracker =
|
||||||
|
remember(relayManager) {
|
||||||
|
DmSendTracker(relayManager.client)
|
||||||
|
}
|
||||||
|
val iAccount =
|
||||||
|
remember(account, localCache, relayManager, dmSendTracker) {
|
||||||
|
DesktopIAccount(account, localCache, relayManager, dmSendTracker, appScope)
|
||||||
|
}
|
||||||
|
DesktopMessagesScreen(
|
||||||
|
account = iAccount,
|
||||||
|
cacheProvider = localCache,
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeckColumnType.Search -> {
|
||||||
|
SearchScreen(
|
||||||
|
localCache = localCache,
|
||||||
|
relayManager = relayManager,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onNavigateToThread = onNavigateToThread,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeckColumnType.Reads -> {
|
||||||
|
ReadsScreen(
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
account = account,
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onNavigateToArticle = onNavigateToThread,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeckColumnType.Bookmarks -> {
|
||||||
|
BookmarksScreen(
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onNavigateToThread = onNavigateToThread,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeckColumnType.GlobalFeed -> {
|
||||||
|
FeedScreen(
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
initialFeedMode = FeedMode.GLOBAL,
|
||||||
|
onCompose = onShowComposeDialog,
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onNavigateToThread = onNavigateToThread,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeckColumnType.MyProfile -> {
|
||||||
|
UserProfileScreen(
|
||||||
|
pubKeyHex = account.pubKeyHex,
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
onBack = {},
|
||||||
|
onCompose = onShowComposeDialog,
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeckColumnType.Chess -> {
|
||||||
|
ChessScreen(
|
||||||
|
relayManager = relayManager,
|
||||||
|
account = account,
|
||||||
|
onBack = {},
|
||||||
|
compactMode = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeckColumnType.Settings -> {
|
||||||
|
RelaySettingsScreen(relayManager, account, accountManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
is DeckColumnType.Profile -> {
|
||||||
|
UserProfileScreen(
|
||||||
|
pubKeyHex = columnType.pubKeyHex,
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
onBack = {},
|
||||||
|
onCompose = onShowComposeDialog,
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is DeckColumnType.Thread -> {
|
||||||
|
ThreadScreen(
|
||||||
|
noteId = columnType.noteId,
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
onBack = {},
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onNavigateToThread = onNavigateToThread,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
onReply = onShowReplyDialog,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is DeckColumnType.Hashtag -> {
|
||||||
|
SearchScreen(
|
||||||
|
localCache = localCache,
|
||||||
|
relayManager = relayManager,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
initialQuery = "#${columnType.tag}",
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onNavigateToThread = onNavigateToThread,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun OverlayContent(
|
||||||
|
screen: DesktopScreen,
|
||||||
|
relayManager: DesktopRelayConnectionManager,
|
||||||
|
localCache: DesktopLocalCache,
|
||||||
|
account: AccountState.LoggedIn,
|
||||||
|
nwcConnection: Nip47URINorm?,
|
||||||
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||||
|
onShowComposeDialog: () -> Unit,
|
||||||
|
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||||
|
onZapFeedback: (ZapFeedback) -> Unit,
|
||||||
|
onNavigateToProfile: (String) -> Unit,
|
||||||
|
onNavigateToThread: (String) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
when (screen) {
|
||||||
|
is DesktopScreen.UserProfile -> {
|
||||||
|
UserProfileScreen(
|
||||||
|
pubKeyHex = screen.pubKeyHex,
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
onBack = onBack,
|
||||||
|
onCompose = onShowComposeDialog,
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is DesktopScreen.Thread -> {
|
||||||
|
ThreadScreen(
|
||||||
|
noteId = screen.noteId,
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
onBack = onBack,
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onNavigateToThread = onNavigateToThread,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
onReply = onShowReplyDialog,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
androidx.compose.material3.Text(
|
||||||
|
"Unsupported screen type",
|
||||||
|
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
|
||||||
|
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
* 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 java.util.UUID
|
||||||
|
|
||||||
|
sealed class DeckColumnType {
|
||||||
|
object HomeFeed : DeckColumnType()
|
||||||
|
|
||||||
|
object Notifications : DeckColumnType()
|
||||||
|
|
||||||
|
object Messages : DeckColumnType()
|
||||||
|
|
||||||
|
object Search : DeckColumnType()
|
||||||
|
|
||||||
|
object Reads : DeckColumnType()
|
||||||
|
|
||||||
|
object Bookmarks : DeckColumnType()
|
||||||
|
|
||||||
|
object GlobalFeed : DeckColumnType()
|
||||||
|
|
||||||
|
object MyProfile : DeckColumnType()
|
||||||
|
|
||||||
|
object Chess : DeckColumnType()
|
||||||
|
|
||||||
|
object Settings : DeckColumnType()
|
||||||
|
|
||||||
|
data class Profile(
|
||||||
|
val pubKeyHex: String,
|
||||||
|
) : DeckColumnType()
|
||||||
|
|
||||||
|
data class Thread(
|
||||||
|
val noteId: String,
|
||||||
|
) : DeckColumnType()
|
||||||
|
|
||||||
|
data class Hashtag(
|
||||||
|
val tag: String,
|
||||||
|
) : DeckColumnType()
|
||||||
|
|
||||||
|
fun title(): String =
|
||||||
|
when (this) {
|
||||||
|
HomeFeed -> "Home"
|
||||||
|
Notifications -> "Notifications"
|
||||||
|
Messages -> "Messages"
|
||||||
|
Search -> "Search"
|
||||||
|
Reads -> "Reads"
|
||||||
|
Bookmarks -> "Bookmarks"
|
||||||
|
GlobalFeed -> "Global"
|
||||||
|
MyProfile -> "Profile"
|
||||||
|
Chess -> "Chess"
|
||||||
|
Settings -> "Settings"
|
||||||
|
is Profile -> "Profile"
|
||||||
|
is Thread -> "Thread"
|
||||||
|
is Hashtag -> "#$tag"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun typeKey(): String =
|
||||||
|
when (this) {
|
||||||
|
HomeFeed -> "home"
|
||||||
|
Notifications -> "notifications"
|
||||||
|
Messages -> "messages"
|
||||||
|
Search -> "search"
|
||||||
|
Reads -> "reads"
|
||||||
|
Bookmarks -> "bookmarks"
|
||||||
|
GlobalFeed -> "global"
|
||||||
|
MyProfile -> "my_profile"
|
||||||
|
Chess -> "chess"
|
||||||
|
Settings -> "settings"
|
||||||
|
is Profile -> "profile"
|
||||||
|
is Thread -> "thread"
|
||||||
|
is Hashtag -> "hashtag"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DeckColumn(
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
val type: DeckColumnType,
|
||||||
|
val width: Float = 400f,
|
||||||
|
)
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
/*
|
||||||
|
* 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.gestures.detectDragGestures
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.VerticalDivider
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.pointer.PointerIcon
|
||||||
|
import androidx.compose.ui.input.pointer.pointerHoverIcon
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
||||||
|
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||||
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
|
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
||||||
|
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import java.awt.Cursor
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeckLayout(
|
||||||
|
deckState: DeckState,
|
||||||
|
relayManager: DesktopRelayConnectionManager,
|
||||||
|
localCache: DesktopLocalCache,
|
||||||
|
accountManager: AccountManager,
|
||||||
|
account: AccountState.LoggedIn,
|
||||||
|
nwcConnection: Nip47URINorm?,
|
||||||
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||||
|
appScope: CoroutineScope,
|
||||||
|
onShowComposeDialog: () -> Unit,
|
||||||
|
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||||
|
onZapFeedback: (ZapFeedback) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val columns by deckState.columns.collectAsState()
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.horizontalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
columns.forEachIndexed { index, column ->
|
||||||
|
if (index > 0) {
|
||||||
|
DraggableDivider(
|
||||||
|
onDrag = { delta ->
|
||||||
|
val leftCol = columns[index - 1]
|
||||||
|
val rightCol = columns[index]
|
||||||
|
deckState.resizePair(leftCol.id, rightCol.id, delta, availableWidthDp)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeckColumnContainer(
|
||||||
|
column = column,
|
||||||
|
canClose = columns.size > 1,
|
||||||
|
onClose = { deckState.removeColumn(column.id) },
|
||||||
|
onDoubleClickHeader = { deckState.expandColumn(column.id, availableWidthDp) },
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
accountManager = accountManager,
|
||||||
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
appScope = appScope,
|
||||||
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DraggableDivider(onDrag: (Float) -> Unit) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.width(12.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR)))
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectDragGestures { change, dragAmount ->
|
||||||
|
change.consume()
|
||||||
|
onDrag(dragAmount.x / density)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = androidx.compose.ui.Alignment.Center,
|
||||||
|
) {
|
||||||
|
VerticalDivider(
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* 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.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeckSidebar(
|
||||||
|
onAddColumn: () -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.width(48.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Top,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"A",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.size(16.dp))
|
||||||
|
|
||||||
|
IconButton(onClick = onAddColumn) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Add,
|
||||||
|
contentDescription = "Add Column",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
|
IconButton(onClick = onOpenSettings) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Settings,
|
||||||
|
contentDescription = "Settings",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+295
@@ -0,0 +1,295 @@
|
|||||||
|
/*
|
||||||
|
* 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 com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
|
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class DeckState(
|
||||||
|
private val saveScope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
private val _columns = MutableStateFlow(DEFAULT_COLUMNS)
|
||||||
|
val columns: StateFlow<List<DeckColumn>> = _columns.asStateFlow()
|
||||||
|
|
||||||
|
private val _focusedColumnIndex = MutableStateFlow(0)
|
||||||
|
val focusedColumnIndex: StateFlow<Int> = _focusedColumnIndex.asStateFlow()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var lastKnownWidth: Float = 0f
|
||||||
|
|
||||||
|
private var saveJob: Job? = null
|
||||||
|
|
||||||
|
fun setAvailableWidth(width: Float) {
|
||||||
|
lastKnownWidth = width
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addColumn(
|
||||||
|
type: DeckColumnType,
|
||||||
|
afterIndex: Int? = null,
|
||||||
|
) {
|
||||||
|
val col = DeckColumn(type = type)
|
||||||
|
_columns.update { current ->
|
||||||
|
if (afterIndex != null && afterIndex < current.size) {
|
||||||
|
current.toMutableList().apply { add(afterIndex + 1, col) }
|
||||||
|
} else {
|
||||||
|
current + col
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Auto-fit all columns to available width when known
|
||||||
|
val width = lastKnownWidth
|
||||||
|
if (width > 0f) {
|
||||||
|
fitColumnsToWidth(width)
|
||||||
|
} else {
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasColumnOfType(type: DeckColumnType): Boolean = _columns.value.any { it.type == type }
|
||||||
|
|
||||||
|
fun focusExistingColumn(type: DeckColumnType) {
|
||||||
|
val idx = _columns.value.indexOfFirst { it.type == type }
|
||||||
|
if (idx >= 0) focusColumn(idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeColumn(id: String) {
|
||||||
|
_columns.update { current ->
|
||||||
|
if (current.size <= 1) return
|
||||||
|
val removed = current.find { it.id == id } ?: return
|
||||||
|
val remaining = current.filter { it.id != id }
|
||||||
|
val extra = removed.width / remaining.size
|
||||||
|
val result =
|
||||||
|
remaining.map {
|
||||||
|
it.copy(width = (it.width + extra).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH))
|
||||||
|
}
|
||||||
|
// Fix gaps: if total is less than available, redistribute remainder
|
||||||
|
val width = lastKnownWidth
|
||||||
|
if (width > 0f) {
|
||||||
|
val dividers = (result.size - 1) * DIVIDER_WIDTH
|
||||||
|
val totalUsed = result.sumOf { it.width.toDouble() }.toFloat()
|
||||||
|
val deficit = width - dividers - totalUsed
|
||||||
|
if (deficit > 1f) {
|
||||||
|
val perColumn = deficit / result.size
|
||||||
|
return@update result.map {
|
||||||
|
it.copy(width = (it.width + perColumn).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
_focusedColumnIndex.update { idx ->
|
||||||
|
idx.coerceAtMost(_columns.value.size - 1)
|
||||||
|
}
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun moveColumn(
|
||||||
|
fromIndex: Int,
|
||||||
|
toIndex: Int,
|
||||||
|
) {
|
||||||
|
_columns.update { current ->
|
||||||
|
if (fromIndex !in current.indices || toIndex !in current.indices) return
|
||||||
|
current.toMutableList().apply {
|
||||||
|
val item = removeAt(fromIndex)
|
||||||
|
add(toIndex, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateColumnWidth(
|
||||||
|
id: String,
|
||||||
|
width: Float,
|
||||||
|
) {
|
||||||
|
_columns.update { current ->
|
||||||
|
current.map {
|
||||||
|
if (it.id == id) it.copy(width = width.coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) else it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun expandColumn(
|
||||||
|
id: String,
|
||||||
|
availableWidth: Float,
|
||||||
|
) {
|
||||||
|
_columns.update { cols ->
|
||||||
|
if (cols.find { it.id == id } == null) return
|
||||||
|
val othersMin = (cols.size - 1) * MIN_COLUMN_WIDTH
|
||||||
|
val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH
|
||||||
|
val maxForTarget = (availableWidth - othersMin - dividerWidth).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
|
||||||
|
cols.map {
|
||||||
|
if (it.id == id) {
|
||||||
|
it.copy(width = maxForTarget)
|
||||||
|
} else {
|
||||||
|
it.copy(width = MIN_COLUMN_WIDTH)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resizePair(
|
||||||
|
leftId: String,
|
||||||
|
rightId: String,
|
||||||
|
delta: Float,
|
||||||
|
availableWidth: Float,
|
||||||
|
) {
|
||||||
|
_columns.update { cols ->
|
||||||
|
val left = cols.find { it.id == leftId } ?: return
|
||||||
|
val right = cols.find { it.id == rightId } ?: return
|
||||||
|
val otherWidth = cols.filter { it.id != leftId && it.id != rightId }.sumOf { it.width.toDouble() }.toFloat()
|
||||||
|
val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH
|
||||||
|
val maxPairWidth = availableWidth - otherWidth - dividerWidth
|
||||||
|
var newLeft = (left.width + delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
|
||||||
|
var newRight = (right.width - delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
|
||||||
|
if (newLeft + newRight > maxPairWidth) {
|
||||||
|
if (delta > 0) {
|
||||||
|
newLeft = (maxPairWidth - newRight).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
|
||||||
|
} else {
|
||||||
|
newRight = (maxPairWidth - newLeft).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cols.map {
|
||||||
|
when (it.id) {
|
||||||
|
leftId -> it.copy(width = newLeft)
|
||||||
|
rightId -> it.copy(width = newRight)
|
||||||
|
else -> it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fitColumnsToWidth(availableWidth: Float) {
|
||||||
|
_columns.update { cols ->
|
||||||
|
if (cols.isEmpty()) return
|
||||||
|
val dividers = (cols.size - 1) * DIVIDER_WIDTH
|
||||||
|
val usable = availableWidth - dividers
|
||||||
|
val perColumn = (usable / cols.size).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
|
||||||
|
cols.map { it.copy(width = perColumn) }
|
||||||
|
}
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun focusColumn(index: Int) {
|
||||||
|
if (index in _columns.value.indices) {
|
||||||
|
_focusedColumnIndex.value = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleSave() {
|
||||||
|
saveJob?.cancel()
|
||||||
|
saveJob =
|
||||||
|
saveScope.launch {
|
||||||
|
delay(SAVE_DEBOUNCE_MS)
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save() {
|
||||||
|
try {
|
||||||
|
val data =
|
||||||
|
_columns.value.map { col ->
|
||||||
|
mapOf(
|
||||||
|
"id" to col.id,
|
||||||
|
"type" to col.type.typeKey(),
|
||||||
|
"width" to col.width,
|
||||||
|
"param" to
|
||||||
|
when (col.type) {
|
||||||
|
is DeckColumnType.Profile -> col.type.pubKeyHex
|
||||||
|
is DeckColumnType.Thread -> col.type.noteId
|
||||||
|
is DeckColumnType.Hashtag -> col.type.tag
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DesktopPreferences.deckColumns = mapper.writeValueAsString(data)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("DeckState: failed to save columns: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
try {
|
||||||
|
val json = DesktopPreferences.deckColumns
|
||||||
|
if (json.isBlank()) return
|
||||||
|
val data: List<Map<String, Any?>> = mapper.readValue(json)
|
||||||
|
val loaded =
|
||||||
|
data.mapNotNull { entry ->
|
||||||
|
val type = parseColumnType(entry) ?: return@mapNotNull null
|
||||||
|
val id = entry["id"] as? String ?: return@mapNotNull null
|
||||||
|
val width = (entry["width"] as? Number)?.toFloat() ?: 400f
|
||||||
|
DeckColumn(id = id, type = type, width = width.coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH))
|
||||||
|
}
|
||||||
|
if (loaded.isNotEmpty()) {
|
||||||
|
_columns.value = loaded
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("DeckState: failed to load columns: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val MIN_COLUMN_WIDTH = 300f
|
||||||
|
const val MAX_COLUMN_WIDTH = 800f
|
||||||
|
const val DIVIDER_WIDTH = 12f
|
||||||
|
private const val SAVE_DEBOUNCE_MS = 500L
|
||||||
|
|
||||||
|
val DEFAULT_COLUMNS =
|
||||||
|
listOf(
|
||||||
|
DeckColumn(id = "default-home", type = DeckColumnType.HomeFeed),
|
||||||
|
DeckColumn(id = "default-notifications", type = DeckColumnType.Notifications),
|
||||||
|
DeckColumn(id = "default-messages", type = DeckColumnType.Messages),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val mapper = jacksonObjectMapper()
|
||||||
|
|
||||||
|
private fun parseColumnType(entry: Map<String, Any?>): DeckColumnType? {
|
||||||
|
val typeKey = entry["type"] as? String ?: return null
|
||||||
|
val param = entry["param"] as? String
|
||||||
|
return when (typeKey) {
|
||||||
|
"home" -> DeckColumnType.HomeFeed
|
||||||
|
"notifications" -> DeckColumnType.Notifications
|
||||||
|
"messages" -> DeckColumnType.Messages
|
||||||
|
"search" -> DeckColumnType.Search
|
||||||
|
"reads" -> DeckColumnType.Reads
|
||||||
|
"bookmarks" -> DeckColumnType.Bookmarks
|
||||||
|
"global" -> DeckColumnType.GlobalFeed
|
||||||
|
"my_profile" -> DeckColumnType.MyProfile
|
||||||
|
"chess" -> DeckColumnType.Chess
|
||||||
|
"settings" -> DeckColumnType.Settings
|
||||||
|
"profile" -> param?.let { DeckColumnType.Profile(it) }
|
||||||
|
"thread" -> param?.let { DeckColumnType.Thread(it) }
|
||||||
|
"hashtag" -> param?.let { DeckColumnType.Hashtag(it) }
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+233
@@ -0,0 +1,233 @@
|
|||||||
|
/*
|
||||||
|
* 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.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Article
|
||||||
|
import androidx.compose.material.icons.filled.Bookmark
|
||||||
|
import androidx.compose.material.icons.filled.Email
|
||||||
|
import androidx.compose.material.icons.filled.Extension
|
||||||
|
import androidx.compose.material.icons.filled.Home
|
||||||
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.NavigationRail
|
||||||
|
import androidx.compose.material3.NavigationRailItem
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
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.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.desktop.DesktopScreen
|
||||||
|
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
||||||
|
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||||
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
|
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
||||||
|
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
||||||
|
private data class NavItem(
|
||||||
|
val type: DeckColumnType,
|
||||||
|
val icon: ImageVector,
|
||||||
|
val label: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val navItems =
|
||||||
|
listOf(
|
||||||
|
NavItem(DeckColumnType.HomeFeed, Icons.Default.Home, "Home"),
|
||||||
|
NavItem(DeckColumnType.Reads, Icons.AutoMirrored.Filled.Article, "Reads"),
|
||||||
|
NavItem(DeckColumnType.Search, Icons.Default.Search, "Search"),
|
||||||
|
NavItem(DeckColumnType.Bookmarks, Icons.Default.Bookmark, "Bookmarks"),
|
||||||
|
NavItem(DeckColumnType.Messages, Icons.Default.Email, "Messages"),
|
||||||
|
NavItem(DeckColumnType.Notifications, Icons.Default.Notifications, "Notifications"),
|
||||||
|
NavItem(DeckColumnType.MyProfile, Icons.Default.Person, "Profile"),
|
||||||
|
NavItem(DeckColumnType.Chess, Icons.Default.Extension, "Chess"),
|
||||||
|
NavItem(DeckColumnType.Settings, Icons.Default.Settings, "Settings"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SinglePaneLayout(
|
||||||
|
relayManager: DesktopRelayConnectionManager,
|
||||||
|
localCache: DesktopLocalCache,
|
||||||
|
accountManager: AccountManager,
|
||||||
|
account: AccountState.LoggedIn,
|
||||||
|
nwcConnection: Nip47URINorm?,
|
||||||
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||||
|
appScope: CoroutineScope,
|
||||||
|
onShowComposeDialog: () -> Unit,
|
||||||
|
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||||
|
onZapFeedback: (ZapFeedback) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var currentColumnType by remember { mutableStateOf<DeckColumnType>(DeckColumnType.HomeFeed) }
|
||||||
|
val navState = remember { ColumnNavigationState() }
|
||||||
|
val navStack by navState.stack.collectAsState()
|
||||||
|
val currentOverlay = navStack.lastOrNull()
|
||||||
|
|
||||||
|
Row(modifier = modifier.fillMaxSize()) {
|
||||||
|
NavigationRail(
|
||||||
|
modifier = Modifier.width(80.dp).fillMaxHeight(),
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
) {
|
||||||
|
navItems.forEach { item ->
|
||||||
|
NavigationRailItem(
|
||||||
|
selected = currentColumnType == item.type && navStack.isEmpty(),
|
||||||
|
onClick = {
|
||||||
|
currentColumnType = item.type
|
||||||
|
navState.clear()
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
item.icon,
|
||||||
|
contentDescription = item.label,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
item.label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalDivider()
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||||
|
// Show header with back button when navigated into overlay
|
||||||
|
if (navStack.isNotEmpty()) {
|
||||||
|
SinglePaneHeader(
|
||||||
|
title =
|
||||||
|
when (currentOverlay) {
|
||||||
|
is DesktopScreen.UserProfile -> "Profile"
|
||||||
|
is DesktopScreen.Thread -> "Thread"
|
||||||
|
else -> currentColumnType.title()
|
||||||
|
},
|
||||||
|
onBack = { navState.pop() },
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(12.dp),
|
||||||
|
) {
|
||||||
|
if (currentOverlay != null) {
|
||||||
|
OverlayContent(
|
||||||
|
screen = currentOverlay,
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
||||||
|
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||||
|
onBack = { navState.pop() },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
RootContent(
|
||||||
|
columnType = currentColumnType,
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
accountManager = accountManager,
|
||||||
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
appScope = appScope,
|
||||||
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
||||||
|
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SinglePaneHeader(
|
||||||
|
title: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(40.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBack, modifier = Modifier.size(28.dp)) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Back",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user