diff --git a/build.gradle.kts b/build.gradle.kts
index aa3ab61de..cfe72c449 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -8,4 +8,5 @@ plugins {
id("com.google.gms.google-services") version "4.4.3" apply false
id("com.google.firebase.crashlytics") version "3.0.6" apply false
alias(libs.plugins.compose.compiler) apply false
+ alias(libs.plugins.jetbrains.compose) apply false
}
diff --git a/desktop/.gitignore b/desktop/.gitignore
new file mode 100644
index 000000000..a606c7c4b
--- /dev/null
+++ b/desktop/.gitignore
@@ -0,0 +1 @@
+.idea/caches/
diff --git a/desktop/.idea/AndroidProjectSystem.xml b/desktop/.idea/AndroidProjectSystem.xml
new file mode 100644
index 000000000..4a53bee8c
--- /dev/null
+++ b/desktop/.idea/AndroidProjectSystem.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/desktop/.idea/gradle.xml b/desktop/.idea/gradle.xml
new file mode 100644
index 000000000..b8382370e
--- /dev/null
+++ b/desktop/.idea/gradle.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/desktop/.idea/migrations.xml b/desktop/.idea/migrations.xml
new file mode 100644
index 000000000..f8051a6f9
--- /dev/null
+++ b/desktop/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/desktop/.idea/misc.xml b/desktop/.idea/misc.xml
new file mode 100644
index 000000000..3aec57f90
--- /dev/null
+++ b/desktop/.idea/misc.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/desktop/.idea/runConfigurations.xml b/desktop/.idea/runConfigurations.xml
new file mode 100644
index 000000000..16660f1d8
--- /dev/null
+++ b/desktop/.idea/runConfigurations.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/desktop/.idea/vcs.xml b/desktop/.idea/vcs.xml
new file mode 100644
index 000000000..6c0b86358
--- /dev/null
+++ b/desktop/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/desktop/.idea/workspace.xml b/desktop/.idea/workspace.xml
new file mode 100644
index 000000000..8ebc665cf
--- /dev/null
+++ b/desktop/.idea/workspace.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1774758048123
+
+
+ 1774758048123
+
+
+
+
\ No newline at end of file
diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts
new file mode 100644
index 000000000..300154a6f
--- /dev/null
+++ b/desktop/build.gradle.kts
@@ -0,0 +1,38 @@
+import org.jetbrains.compose.desktop.application.dsl.TargetFormat
+
+plugins {
+ alias(libs.plugins.jetbrains.kotlin.jvm)
+ alias(libs.plugins.jetbrains.compose)
+ alias(libs.plugins.compose.compiler)
+}
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+}
+
+dependencies {
+ implementation(project(":capy"))
+ implementation(compose.desktop.currentOs)
+ implementation(compose.material3)
+ implementation(libs.sqldelight.sqlite.driver)
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.jsoup)
+ implementation(compose.materialIconsExtended)
+}
+
+compose.desktop {
+ application {
+ mainClass = "com.jocmp.capyreader.desktop.MainKt"
+
+ nativeDistributions {
+ targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
+ packageName = "Capy Reader"
+ packageVersion = "1.0.0"
+
+ macOS {
+ bundleID = "com.jocmp.capyreader.desktop"
+ }
+ }
+ }
+}
diff --git a/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/AddFeedDialog.kt b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/AddFeedDialog.kt
new file mode 100644
index 000000000..af3e44fb7
--- /dev/null
+++ b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/AddFeedDialog.kt
@@ -0,0 +1,193 @@
+package com.jocmp.capyreader.desktop
+
+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.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import com.jocmp.capy.accounts.AddFeedResult
+import com.jocmp.capy.accounts.FeedOption
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+@Composable
+fun AddFeedDialog(
+ state: ReaderState,
+ onDismiss: () -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+ var url by remember { mutableStateOf("") }
+ var loading by remember { mutableStateOf(false) }
+ var error by remember { mutableStateOf(null) }
+ var choices by remember { mutableStateOf?>(null) }
+
+ Dialog(onDismissRequest = onDismiss) {
+ Surface(
+ shape = MaterialTheme.shapes.large,
+ tonalElevation = 6.dp,
+ ) {
+ Column(
+ modifier = Modifier.padding(24.dp).fillMaxWidth(),
+ ) {
+ Text(
+ text = if (choices != null) "Choose a feed" else "Add Feed",
+ style = MaterialTheme.typography.titleLarge,
+ )
+
+ Spacer(Modifier.height(16.dp))
+
+ if (choices != null) {
+ LazyColumn {
+ items(choices.orEmpty()) { option ->
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ loading = true
+ error = null
+ scope.launch {
+ val result = withContext(Dispatchers.IO) {
+ state.account.addFeed(url = option.feedURL)
+ }
+ loading = false
+ when (result) {
+ is AddFeedResult.Success -> {
+ state.loadArticles()
+ onDismiss()
+ }
+ is AddFeedResult.Failure -> {
+ error = feedErrorMessage(result.error)
+ }
+ is AddFeedResult.MultipleChoices -> {
+ choices = result.choices
+ }
+ }
+ }
+ },
+ ) {
+ Column(modifier = Modifier.padding(vertical = 8.dp)) {
+ Text(
+ text = option.title,
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ Text(
+ text = option.feedURL,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ HorizontalDivider()
+ }
+ }
+ } else {
+ OutlinedTextField(
+ value = url,
+ onValueChange = { url = it },
+ label = { Text("Feed or site URL") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+
+ error?.let {
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = it,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+
+ Spacer(Modifier.height(16.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (choices != null) {
+ TextButton(onClick = {
+ choices = null
+ error = null
+ }) {
+ Text("Back")
+ }
+ }
+
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+
+ if (choices == null) {
+ if (loading) {
+ CircularProgressIndicator()
+ } else {
+ Button(
+ onClick = {
+ if (url.isBlank()) return@Button
+ loading = true
+ error = null
+
+ scope.launch {
+ val result = withContext(Dispatchers.IO) {
+ state.account.addFeed(url = url)
+ }
+ loading = false
+ when (result) {
+ is AddFeedResult.Success -> {
+ state.loadArticles()
+ onDismiss()
+ }
+ is AddFeedResult.MultipleChoices -> {
+ choices = result.choices
+ }
+ is AddFeedResult.Failure -> {
+ error = feedErrorMessage(result.error)
+ }
+ }
+ }
+ },
+ ) {
+ Text("Add")
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun feedErrorMessage(error: AddFeedResult.Error): String {
+ return when (error) {
+ is AddFeedResult.Error.FeedNotFound -> "No feed found at that URL"
+ is AddFeedResult.Error.ConnectionError -> "Could not connect to server"
+ is AddFeedResult.Error.NetworkError -> "Network error"
+ is AddFeedResult.Error.SaveFailure -> "Failed to save feed"
+ }
+}
diff --git a/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/AppState.kt b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/AppState.kt
new file mode 100644
index 000000000..14ce2bfa3
--- /dev/null
+++ b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/AppState.kt
@@ -0,0 +1,46 @@
+package com.jocmp.capyreader.desktop
+
+import com.jocmp.capy.Account
+import com.jocmp.capy.AccountManager
+import com.jocmp.capy.accounts.FaviconPolicy
+import java.io.File
+
+class AppState(private val dataDir: File) {
+ private val accountsDir = File(dataDir, "accounts").apply { mkdirs() }
+ private val cacheDir = File(dataDir, "cache").apply { mkdirs() }
+ private val dbDir = File(dataDir, "db")
+ private val prefsDir = File(dataDir, "prefs")
+ private val accountIDFile = File(dataDir, "account_id")
+
+ private val databaseProvider = FileDatabaseProvider(dbDir)
+ private val preferenceStoreProvider = FilePreferenceStoreProvider(prefsDir)
+
+ val manager = AccountManager(
+ rootFolder = accountsDir.toURI(),
+ cacheDirectory = cacheDir.toURI(),
+ databaseProvider = databaseProvider,
+ preferenceStoreProvider = preferenceStoreProvider,
+ faviconPolicy = FaviconPolicy { true },
+ userAgent = "CapyReaderDesktop/1.0",
+ acceptLanguage = "en-US",
+ )
+
+ fun savedAccountID(): String? {
+ if (!accountIDFile.exists()) return null
+ val id = accountIDFile.readText().trim()
+ return id.ifBlank { null }
+ }
+
+ fun saveAccountID(id: String) {
+ accountIDFile.writeText(id)
+ }
+
+ fun clearAccountID() {
+ accountIDFile.delete()
+ }
+
+ fun loadAccount(): Account? {
+ val id = savedAccountID() ?: return null
+ return manager.findByID(id)
+ }
+}
diff --git a/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/ArticleDetailPane.kt b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/ArticleDetailPane.kt
new file mode 100644
index 000000000..994a3ef32
--- /dev/null
+++ b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/ArticleDetailPane.kt
@@ -0,0 +1,267 @@
+package com.jocmp.capyreader.desktop
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.OpenInNew
+import androidx.compose.material.icons.outlined.Circle
+import androidx.compose.material.icons.rounded.Circle
+import androidx.compose.material.icons.rounded.Share
+import androidx.compose.material.icons.rounded.Star
+import androidx.compose.material.icons.rounded.StarOutline
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.awt.SwingPanel
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.jocmp.capy.Article
+import java.awt.Desktop
+import java.net.URI
+import java.time.format.DateTimeFormatter
+import javax.swing.JEditorPane
+import javax.swing.JScrollPane
+import javax.swing.event.HyperlinkEvent
+
+private val dateFormatter = DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy 'at' h:mm a")
+
+@Composable
+fun ArticleDetailPane(
+ state: ReaderState,
+ modifier: Modifier = Modifier,
+) {
+ val article by state.selectedArticle.collectAsDesktopState()
+
+ Surface(modifier = modifier.fillMaxSize()) {
+ val current = article
+ if (current == null) {
+ EmptyDetail()
+ } else {
+ ArticleDetail(
+ article = current,
+ onToggleRead = { state.toggleRead() },
+ onToggleStar = { state.toggleStar() },
+ )
+ }
+ }
+}
+
+@Composable
+private fun EmptyDetail() {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "Select an article to read",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
+@Composable
+private fun ArticleDetail(
+ article: Article,
+ onToggleRead: () -> Unit,
+ onToggleStar: () -> Unit,
+) {
+ val isDark = isSystemInDarkTheme()
+ val bgColor = MaterialTheme.colorScheme.surface
+ val textColor = MaterialTheme.colorScheme.onSurface
+ val mutedColor = MaterialTheme.colorScheme.onSurfaceVariant
+ val linkColor = MaterialTheme.colorScheme.primary
+
+ Column(modifier = Modifier.fillMaxSize()) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(start = 8.dp),
+ ) {
+ Text(
+ text = article.feedName,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ article.author?.let { author ->
+ Text(
+ text = " \u00B7 $author",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+
+ Row {
+ IconButton(onClick = onToggleRead) {
+ Icon(
+ imageVector = if (article.read) Icons.Outlined.Circle else Icons.Rounded.Circle,
+ contentDescription = if (article.read) "Mark unread" else "Mark read",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ IconButton(onClick = onToggleStar) {
+ Icon(
+ imageVector = if (article.starred) Icons.Rounded.Star else Icons.Rounded.StarOutline,
+ contentDescription = if (article.starred) "Unstar" else "Star",
+ tint = if (article.starred) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ article.url?.let { url ->
+ IconButton(onClick = { openInBrowser(url.toString()) }) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.OpenInNew,
+ contentDescription = "Open in browser",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+
+ HorizontalDivider()
+
+ key(article.id) {
+ val htmlContent = buildArticleHtml(
+ article = article,
+ bgHex = bgColor.toHex(),
+ textHex = textColor.toHex(),
+ mutedHex = mutedColor.toHex(),
+ linkHex = linkColor.toHex(),
+ )
+
+ SwingPanel(
+ modifier = Modifier.fillMaxSize(),
+ factory = {
+ val editorPane = JEditorPane().apply {
+ contentType = "text/html"
+ isEditable = false
+ putClientProperty(JEditorPane.HONOR_DISPLAY_PROPERTIES, true)
+
+ addHyperlinkListener { event ->
+ if (event.eventType == HyperlinkEvent.EventType.ACTIVATED) {
+ openInBrowser(event.url.toString())
+ }
+ }
+
+ text = htmlContent
+ caretPosition = 0
+ }
+
+ val bgAwt = java.awt.Color(bgColor.toArgb())
+ editorPane.background = bgAwt
+
+ JScrollPane(editorPane).apply {
+ border = null
+ background = bgAwt
+ viewport.background = bgAwt
+ }
+ },
+ )
+ }
+ }
+}
+
+private fun buildArticleHtml(
+ article: Article,
+ bgHex: String,
+ textHex: String,
+ mutedHex: String,
+ linkHex: String,
+): String {
+ val date = article.publishedAt.format(dateFormatter)
+
+ return """
+
+
+
+
+
+ ${escapeHtmlEntities(article.title)}
+ ${date}
+
+ ${article.content}
+
+
+ """.trimIndent()
+}
+
+private fun escapeHtmlEntities(text: String): String {
+ return text
+ .replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+}
+
+private fun androidx.compose.ui.graphics.Color.toHex(): String {
+ val argb = toArgb()
+ return String.format("#%06X", argb and 0xFFFFFF)
+}
+
+private fun openInBrowser(url: String) {
+ try {
+ if (Desktop.isDesktopSupported()) {
+ Desktop.getDesktop().browse(URI(url))
+ }
+ } catch (_: Exception) {
+ // Silently fail
+ }
+}
diff --git a/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/ArticleListPane.kt b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/ArticleListPane.kt
new file mode 100644
index 000000000..4d857e2ce
--- /dev/null
+++ b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/ArticleListPane.kt
@@ -0,0 +1,250 @@
+package com.jocmp.capyreader.desktop
+
+import androidx.compose.foundation.ContextMenuArea
+import androidx.compose.foundation.ContextMenuItem
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Circle
+import androidx.compose.material.icons.rounded.Menu
+import androidx.compose.material.icons.rounded.Star
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.jocmp.capy.Article
+import kotlinx.coroutines.launch
+import java.time.format.DateTimeFormatter
+
+private val timeFormatter = DateTimeFormatter.ofPattern("MMM d")
+
+@Composable
+fun ArticleListPane(
+ state: ReaderState,
+ width: Dp,
+ sidebarCollapsed: Boolean = false,
+ onToggleSidebar: () -> Unit = {},
+ modifier: Modifier = Modifier,
+) {
+ val articles by state.articles.collectAsDesktopState()
+ val selectedArticle by state.selectedArticle.collectAsDesktopState()
+ val canLoadMore by state.canLoadMore.collectAsDesktopState()
+ val listState = rememberLazyListState()
+ val scope = rememberCoroutineScope()
+
+ val shouldLoadMore by remember {
+ derivedStateOf {
+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
+ val totalItems = listState.layoutInfo.totalItemsCount
+ canLoadMore && lastVisible >= totalItems - 10
+ }
+ }
+
+ LaunchedEffect(shouldLoadMore) {
+ if (shouldLoadMore) {
+ state.loadMore()
+ }
+ }
+
+ Surface(
+ modifier = modifier.fillMaxHeight().width(width),
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ Column {
+ if (sidebarCollapsed) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(onClick = onToggleSidebar, modifier = Modifier.size(36.dp)) {
+ Icon(Icons.Rounded.Menu, contentDescription = "Show sidebar", modifier = Modifier.size(20.dp))
+ }
+ }
+ HorizontalDivider()
+ }
+
+ if (articles.isEmpty()) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "No articles",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ } else {
+ LazyColumn(state = listState, modifier = Modifier.weight(1f)) {
+ items(articles, key = { it.id }) { article ->
+ ContextMenuArea(
+ items = {
+ buildList {
+ add(ContextMenuItem(
+ label = if (article.read) "Mark as Unread" else "Mark as Read",
+ ) {
+ scope.launch {
+ if (article.read) {
+ state.account.markUnread(article.id)
+ } else {
+ state.account.markRead(article.id)
+ }
+ state.loadArticles()
+ }
+ })
+ add(ContextMenuItem(
+ label = if (article.starred) "Unstar" else "Star",
+ ) {
+ scope.launch {
+ if (article.starred) {
+ state.account.removeStar(article.id)
+ } else {
+ state.account.addStar(article.id)
+ }
+ state.loadArticles()
+ }
+ })
+ article.url?.let { url ->
+ add(ContextMenuItem("Open in Browser") {
+ try {
+ java.awt.Desktop.getDesktop().browse(java.net.URI(url.toString()))
+ } catch (_: Exception) {}
+ })
+ }
+ }
+ },
+ ) {
+ ArticleRow(
+ article = article,
+ selected = selectedArticle?.id == article.id,
+ onClick = { state.selectArticle(article) },
+ )
+ }
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ArticleRow(
+ article: Article,
+ selected: Boolean,
+ onClick: () -> Unit,
+) {
+ val bgColor = when {
+ selected -> MaterialTheme.colorScheme.secondaryContainer
+ !article.read -> MaterialTheme.colorScheme.surface
+ else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.15f)
+ }
+ val textAlpha = if (article.read && !selected) 0.65f else 1f
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(bgColor)
+ .clickable { onClick() }
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ if (!article.read) {
+ Icon(
+ imageVector = Icons.Rounded.Circle,
+ contentDescription = "Unread",
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(8.dp).padding(top = 6.dp),
+ )
+ } else {
+ Box(modifier = Modifier.size(8.dp))
+ }
+
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = article.feedName,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary.copy(alpha = textAlpha),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (article.starred) {
+ Icon(
+ imageVector = Icons.Rounded.Star,
+ contentDescription = "Starred",
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(12.dp),
+ )
+ }
+ Text(
+ text = article.publishedAt.format(timeFormatter),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = textAlpha),
+ )
+ }
+ }
+
+ Text(
+ text = article.title,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = if (!article.read) FontWeight.SemiBold else FontWeight.Normal,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = textAlpha),
+ )
+
+ if (article.summary.isNotBlank()) {
+ Text(
+ text = article.summary,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = textAlpha * 0.8f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+}
diff --git a/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/FeedSidebar.kt b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/FeedSidebar.kt
new file mode 100644
index 000000000..370805994
--- /dev/null
+++ b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/FeedSidebar.kt
@@ -0,0 +1,297 @@
+package com.jocmp.capyreader.desktop
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.Notes
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.rounded.Add
+import androidx.compose.material.icons.rounded.ArrowDropDown
+import androidx.compose.material.icons.rounded.Circle
+import androidx.compose.material.icons.rounded.Refresh
+import androidx.compose.material.icons.rounded.RssFeed
+import androidx.compose.material.icons.automirrored.rounded.Logout
+import androidx.compose.material.icons.rounded.Star
+import androidx.compose.material.icons.rounded.Today
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateMapOf
+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.draw.clip
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.jocmp.capy.ArticleFilter
+import com.jocmp.capy.ArticleStatus
+import com.jocmp.capy.Feed
+import com.jocmp.capy.Folder
+
+@Composable
+fun FeedSidebar(
+ state: ReaderState,
+ width: Dp,
+ onSignOut: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val filter by state.filter.collectAsDesktopState()
+ val folders by state.foldersWithCounts.collectAsDesktopState()
+ val feeds by state.feedsWithCounts.collectAsDesktopState()
+ val allUnread by state.allUnreadCount.collectAsDesktopState()
+ val refreshing by state.refreshing.collectAsDesktopState()
+ var showAddFeed by remember { mutableStateOf(false) }
+ val expandedFolders = remember { mutableStateMapOf() }
+
+ Surface(
+ modifier = modifier.fillMaxHeight().width(width),
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
+ ) {
+ Column {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 10.dp, bottom = 6.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = "Capy Reader",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Row {
+ IconButton(onClick = { showAddFeed = true }, modifier = Modifier.size(32.dp)) {
+ Icon(Icons.Rounded.Add, contentDescription = "Add feed", modifier = Modifier.size(18.dp))
+ }
+ IconButton(onClick = { state.markAllRead() }, modifier = Modifier.size(32.dp)) {
+ Icon(Icons.Filled.CheckCircle, contentDescription = "Mark all read", modifier = Modifier.size(18.dp))
+ }
+ IconButton(onClick = { state.refresh() }, enabled = !refreshing, modifier = Modifier.size(32.dp)) {
+ Icon(Icons.Rounded.Refresh, contentDescription = "Refresh", modifier = Modifier.size(18.dp))
+ }
+ }
+ }
+
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth().weight(1f),
+ ) {
+ item {
+ SidebarItem(
+ label = "All Articles",
+ icon = Icons.AutoMirrored.Rounded.Notes,
+ count = allUnread,
+ selected = filter.hasArticlesSelected() && filter.status == ArticleStatus.ALL,
+ onClick = { state.selectFilter(ArticleFilter.Articles(ArticleStatus.ALL)) },
+ )
+ }
+
+ item {
+ SidebarItem(
+ label = "Unread",
+ icon = Icons.Rounded.Circle,
+ count = allUnread,
+ selected = filter.hasArticlesSelected() && filter.status == ArticleStatus.UNREAD,
+ onClick = { state.selectFilter(ArticleFilter.Articles(ArticleStatus.UNREAD)) },
+ )
+ }
+
+ item {
+ SidebarItem(
+ label = "Starred",
+ icon = Icons.Rounded.Star,
+ count = 0,
+ selected = filter.hasArticlesSelected() && filter.status == ArticleStatus.STARRED,
+ onClick = { state.selectFilter(ArticleFilter.Articles(ArticleStatus.STARRED)) },
+ )
+ }
+
+ if (folders.isNotEmpty() || feeds.isNotEmpty()) {
+ item {
+ Spacer(Modifier.height(4.dp))
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp))
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = "FEEDS",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ letterSpacing = 1.sp,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
+ )
+ }
+ }
+
+ items(folders, key = { "folder:${it.title}" }) { folder ->
+ val expanded = expandedFolders[folder.title] ?: folder.expanded
+ SidebarItem(
+ label = folder.title,
+ icon = Icons.Rounded.ArrowDropDown,
+ iconRotation = if (expanded) 0f else -90f,
+ count = folder.count,
+ selected = filter.isFolderSelected(folder),
+ onClick = {
+ state.selectFilter(
+ ArticleFilter.Folders(
+ folderTitle = folder.title,
+ folderStatus = filter.status,
+ )
+ )
+ },
+ onIconClick = { expandedFolders[folder.title] = !expanded },
+ )
+
+ if (expanded) {
+ folder.feeds.forEach { feed ->
+ SidebarItem(
+ label = feed.title,
+ icon = Icons.Rounded.RssFeed,
+ count = feed.count,
+ selected = filter.isFeedSelected(feed),
+ indent = 1,
+ onClick = {
+ state.selectFilter(
+ ArticleFilter.Feeds(
+ feedID = feed.id,
+ folderTitle = folder.title,
+ feedStatus = filter.status,
+ )
+ )
+ },
+ )
+ }
+ }
+ }
+
+ items(feeds, key = { "feed:${it.id}" }) { feed ->
+ SidebarItem(
+ label = feed.title,
+ icon = Icons.Rounded.RssFeed,
+ count = feed.count,
+ selected = filter.isFeedSelected(feed),
+ onClick = {
+ state.selectFilter(
+ ArticleFilter.Feeds(
+ feedID = feed.id,
+ folderTitle = null,
+ feedStatus = filter.status,
+ )
+ )
+ },
+ )
+ }
+ }
+
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp))
+
+ SidebarItem(
+ label = "Sign Out",
+ icon = Icons.AutoMirrored.Rounded.Logout,
+ count = 0,
+ selected = false,
+ onClick = onSignOut,
+ )
+
+ Spacer(Modifier.height(8.dp))
+
+ if (showAddFeed) {
+ AddFeedDialog(
+ state = state,
+ onDismiss = { showAddFeed = false },
+ )
+ }
+
+ }
+ }
+}
+
+@Composable
+private fun SidebarItem(
+ label: String,
+ icon: ImageVector,
+ count: Long,
+ selected: Boolean,
+ indent: Int = 0,
+ iconRotation: Float = 0f,
+ onClick: () -> Unit,
+ onIconClick: (() -> Unit)? = null,
+) {
+ val shape = RoundedCornerShape(6.dp)
+ val bgColor = if (selected) {
+ MaterialTheme.colorScheme.secondaryContainer
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0f)
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = (8 + indent * 16).dp, end = 8.dp, top = 1.dp, bottom = 1.dp)
+ .clip(shape)
+ .background(bgColor)
+ .clickable { onClick() }
+ .padding(horizontal = 8.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier
+ .size(16.dp)
+ .rotate(iconRotation)
+ .then(if (onIconClick != null) Modifier.clickable { onIconClick() } else Modifier),
+ tint = if (selected) {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ color = if (selected) {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ )
+
+ if (count > 0) {
+ Text(
+ text = count.toString(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
diff --git a/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/FileDatabaseProvider.kt b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/FileDatabaseProvider.kt
new file mode 100644
index 000000000..9dcf7d1f4
--- /dev/null
+++ b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/FileDatabaseProvider.kt
@@ -0,0 +1,32 @@
+package com.jocmp.capyreader.desktop
+
+import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
+import com.jocmp.capy.DatabaseProvider
+import com.jocmp.capy.db.Database
+import java.io.File
+
+class FileDatabaseProvider(private val dbDir: File) : DatabaseProvider {
+ init {
+ dbDir.mkdirs()
+ }
+
+ override fun build(accountID: String): Database {
+ val dbFile = File(dbDir, "articles_${accountID}.db")
+ val isNew = !dbFile.exists()
+
+ val driver = JdbcSqliteDriver("jdbc:sqlite:${dbFile.absolutePath}")
+
+ if (isNew) {
+ Database.Schema.create(driver)
+ }
+
+ driver.execute(null, "PRAGMA journal_mode = WAL", 0)
+ driver.execute(null, "PRAGMA synchronous = NORMAL", 0)
+
+ return Database(driver)
+ }
+
+ override fun delete(accountID: String) {
+ File(dbDir, "articles_${accountID}.db").delete()
+ }
+}
diff --git a/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/FilePreferenceStore.kt b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/FilePreferenceStore.kt
new file mode 100644
index 000000000..a1687741d
--- /dev/null
+++ b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/FilePreferenceStore.kt
@@ -0,0 +1,133 @@
+package com.jocmp.capyreader.desktop
+
+import com.jocmp.capy.preferences.Preference
+import com.jocmp.capy.preferences.PreferenceStore
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import java.io.File
+import java.util.Properties
+
+class FilePreferenceStore(private val file: File) : PreferenceStore {
+ private val props = Properties()
+ private val keyFlow = MutableStateFlow(null)
+
+ init {
+ if (file.exists()) {
+ file.inputStream().use { props.load(it) }
+ }
+ }
+
+ private fun flush() {
+ file.parentFile?.mkdirs()
+ file.outputStream().use { props.store(it, null) }
+ }
+
+ override fun getString(key: String, defaultValue: String): Preference {
+ return FilePref(key, defaultValue, keyFlow,
+ get = { props.getProperty(key, defaultValue) },
+ set = { props.setProperty(key, it); flush() },
+ del = { props.remove(key); flush() },
+ has = { props.containsKey(key) },
+ )
+ }
+
+ override fun getLong(key: String, defaultValue: Long): Preference {
+ return FilePref(key, defaultValue, keyFlow,
+ get = { props.getProperty(key)?.toLongOrNull() ?: defaultValue },
+ set = { props.setProperty(key, it.toString()); flush() },
+ del = { props.remove(key); flush() },
+ has = { props.containsKey(key) },
+ )
+ }
+
+ override fun getInt(key: String, defaultValue: Int): Preference {
+ return FilePref(key, defaultValue, keyFlow,
+ get = { props.getProperty(key)?.toIntOrNull() ?: defaultValue },
+ set = { props.setProperty(key, it.toString()); flush() },
+ del = { props.remove(key); flush() },
+ has = { props.containsKey(key) },
+ )
+ }
+
+ override fun getFloat(key: String, defaultValue: Float): Preference {
+ return FilePref(key, defaultValue, keyFlow,
+ get = { props.getProperty(key)?.toFloatOrNull() ?: defaultValue },
+ set = { props.setProperty(key, it.toString()); flush() },
+ del = { props.remove(key); flush() },
+ has = { props.containsKey(key) },
+ )
+ }
+
+ override fun getBoolean(key: String, defaultValue: Boolean): Preference {
+ return FilePref(key, defaultValue, keyFlow,
+ get = { props.getProperty(key)?.toBooleanStrictOrNull() ?: defaultValue },
+ set = { props.setProperty(key, it.toString()); flush() },
+ del = { props.remove(key); flush() },
+ has = { props.containsKey(key) },
+ )
+ }
+
+ override fun getStringSet(key: String, defaultValue: Set): Preference> {
+ return FilePref(key, defaultValue, keyFlow,
+ get = {
+ val raw = props.getProperty(key) ?: return@FilePref defaultValue
+ if (raw.isEmpty()) emptySet() else raw.split("\u001F").toSet()
+ },
+ set = { props.setProperty(key, it.joinToString("\u001F")); flush() },
+ del = { props.remove(key); flush() },
+ has = { props.containsKey(key) },
+ )
+ }
+
+ override fun getObject(
+ key: String,
+ defaultValue: T,
+ serializer: (T) -> String,
+ deserializer: (String) -> T,
+ ): Preference {
+ return FilePref(key, defaultValue, keyFlow,
+ get = {
+ val raw = props.getProperty(key) ?: return@FilePref defaultValue
+ try { deserializer(raw) } catch (_: Exception) { defaultValue }
+ },
+ set = { props.setProperty(key, serializer(it)); flush() },
+ del = { props.remove(key); flush() },
+ has = { props.containsKey(key) },
+ )
+ }
+
+ override fun clearAll() {
+ props.clear()
+ flush()
+ }
+}
+
+private class FilePref(
+ private val key: String,
+ private val default: T,
+ private val keyFlow: MutableStateFlow,
+ private val get: () -> T,
+ private val set: (T) -> Unit,
+ private val del: () -> Unit,
+ private val has: () -> Boolean,
+) : Preference {
+ override fun key() = key
+ override fun get() = get.invoke()
+ override fun set(value: T) { set.invoke(value); keyFlow.value = key }
+ override fun isSet() = has()
+ override fun delete() { del(); keyFlow.value = key }
+ override fun defaultValue() = default
+
+ override fun changes(): Flow {
+ return keyFlow.map { get() }
+ }
+
+ override fun stateIn(scope: CoroutineScope): StateFlow {
+ return changes().stateIn(scope, SharingStarted.Eagerly, get())
+ }
+}
diff --git a/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/FilePreferenceStoreProvider.kt b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/FilePreferenceStoreProvider.kt
new file mode 100644
index 000000000..47f20893a
--- /dev/null
+++ b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/FilePreferenceStoreProvider.kt
@@ -0,0 +1,20 @@
+package com.jocmp.capyreader.desktop
+
+import com.jocmp.capy.AccountPreferences
+import com.jocmp.capy.PreferenceStoreProvider
+import java.io.File
+
+class FilePreferenceStoreProvider(private val prefsDir: File) : PreferenceStoreProvider {
+ init {
+ prefsDir.mkdirs()
+ }
+
+ override fun build(accountID: String): AccountPreferences {
+ val file = File(prefsDir, "${accountID}.properties")
+ return AccountPreferences(FilePreferenceStore(file))
+ }
+
+ override fun delete(accountID: String) {
+ File(prefsDir, "${accountID}.properties").delete()
+ }
+}
diff --git a/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/FlowExt.kt b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/FlowExt.kt
new file mode 100644
index 000000000..bd1ecc52f
--- /dev/null
+++ b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/FlowExt.kt
@@ -0,0 +1,11 @@
+package com.jocmp.capyreader.desktop
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.collectAsState
+import kotlinx.coroutines.flow.StateFlow
+
+@Composable
+fun StateFlow.collectAsDesktopState(): State {
+ return collectAsState()
+}
diff --git a/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/LoginScreen.kt b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/LoginScreen.kt
new file mode 100644
index 000000000..81d5b8a78
--- /dev/null
+++ b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/LoginScreen.kt
@@ -0,0 +1,208 @@
+package com.jocmp.capyreader.desktop
+
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ExposedDropdownMenuAnchorType
+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.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import com.jocmp.capy.accounts.Credentials
+import com.jocmp.capy.accounts.Source
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun LoginScreen(
+ appState: AppState,
+ onAccountCreated: () -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+ var selectedSource by remember { mutableStateOf(Source.LOCAL) }
+ var username by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+ var url by remember { mutableStateOf("") }
+ var loading by remember { mutableStateOf(false) }
+ var error by remember { mutableStateOf(null) }
+ var sourceExpanded by remember { mutableStateOf(false) }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ text = "Capy Reader",
+ style = MaterialTheme.typography.headlineLarge,
+ )
+
+ Spacer(Modifier.height(8.dp))
+
+ Text(
+ text = "Add an account to get started",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Spacer(Modifier.height(24.dp))
+
+ Column(
+ modifier = Modifier.width(400.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ ExposedDropdownMenuBox(
+ expanded = sourceExpanded,
+ onExpandedChange = { sourceExpanded = it },
+ ) {
+ OutlinedTextField(
+ value = sourceLabel(selectedSource),
+ onValueChange = {},
+ readOnly = true,
+ label = { Text("Account type") },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = sourceExpanded) },
+ modifier = Modifier.fillMaxWidth().menuAnchor(type = ExposedDropdownMenuAnchorType.PrimaryNotEditable),
+ )
+ ExposedDropdownMenu(
+ expanded = sourceExpanded,
+ onDismissRequest = { sourceExpanded = false },
+ ) {
+ Source.entries.forEach { source ->
+ DropdownMenuItem(
+ text = { Text(sourceLabel(source)) },
+ onClick = {
+ selectedSource = source
+ sourceExpanded = false
+ },
+ )
+ }
+ }
+ }
+
+ if (selectedSource.hasCustomURL) {
+ OutlinedTextField(
+ value = url,
+ onValueChange = { url = it },
+ label = { Text("Server URL") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+
+ if (selectedSource != Source.LOCAL && selectedSource.requiresUsername) {
+ OutlinedTextField(
+ value = username,
+ onValueChange = { username = it },
+ label = { Text("Username") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+
+ if (selectedSource != Source.LOCAL) {
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text(if (selectedSource == Source.MINIFLUX_TOKEN) "API Token" else "Password") },
+ singleLine = true,
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+
+ error?.let {
+ Text(
+ text = it,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ if (loading) {
+ CircularProgressIndicator()
+ } else {
+ Button(
+ onClick = {
+ loading = true
+ error = null
+
+ scope.launch {
+ try {
+ val accountID = if (selectedSource == Source.LOCAL) {
+ appState.manager.createAccount(source = Source.LOCAL)
+ } else {
+ val credentials = withContext(Dispatchers.IO) {
+ Credentials.from(
+ source = selectedSource,
+ username = username,
+ password = password,
+ url = url,
+ ).verify().getOrThrow()
+ }
+
+ appState.manager.createAccount(
+ username = credentials.username,
+ password = credentials.secret,
+ url = credentials.url,
+ source = credentials.source,
+ )
+ }
+
+ appState.saveAccountID(accountID)
+ onAccountCreated()
+ } catch (e: Exception) {
+ error = e.message ?: "Login failed"
+ } finally {
+ loading = false
+ }
+ }
+ },
+ ) {
+ Text(if (selectedSource == Source.LOCAL) "Create" else "Sign In")
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun sourceLabel(source: Source): String {
+ return when (source) {
+ Source.LOCAL -> "Local (no sync)"
+ Source.FEEDBIN -> "Feedbin"
+ Source.FRESHRSS -> "FreshRSS"
+ Source.MINIFLUX -> "Miniflux"
+ Source.MINIFLUX_TOKEN -> "Miniflux (API Token)"
+ Source.READER -> "Google Reader API"
+ }
+}
diff --git a/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/Main.kt b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/Main.kt
new file mode 100644
index 000000000..b79ca7c66
--- /dev/null
+++ b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/Main.kt
@@ -0,0 +1,140 @@
+package com.jocmp.capyreader.desktop
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Typography
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.platform.Font
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.application
+import androidx.compose.ui.window.rememberWindowState
+import java.io.File
+import javax.swing.UIManager
+
+private val LightColors = lightColorScheme(
+ primary = Color(0xFF3B6837),
+ onPrimary = Color.White,
+ primaryContainer = Color(0xFFBCF0B4),
+ onPrimaryContainer = Color(0xFF002200),
+ secondary = Color(0xFF526350),
+ secondaryContainer = Color(0xFFD5E8CF),
+ surface = Color(0xFFFCFDF7),
+ surfaceVariant = Color(0xFFDEE5D9),
+ background = Color(0xFFFCFDF7),
+ onSurface = Color(0xFF1A1C19),
+ onSurfaceVariant = Color(0xFF434846),
+ outline = Color(0xFF737873),
+)
+
+private val DarkColors = darkColorScheme(
+ primary = Color(0xFFA1D49A),
+ onPrimary = Color(0xFF0A390E),
+ primaryContainer = Color(0xFF235022),
+ onPrimaryContainer = Color(0xFFBCF0B4),
+ secondary = Color(0xFFB9CCB4),
+ secondaryContainer = Color(0xFF3A4B39),
+ surface = Color(0xFF1A1C19),
+ surfaceVariant = Color(0xFF434846),
+ background = Color(0xFF1A1C19),
+ onSurface = Color(0xFFE2E3DD),
+ onSurfaceVariant = Color(0xFFC2C9BD),
+ outline = Color(0xFF8C9389),
+)
+
+fun main() {
+ UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName())
+
+ application {
+ val dataDir = File(System.getProperty("user.home"), ".capyreader").apply { mkdirs() }
+ val appState = remember { AppState(dataDir) }
+
+ Window(
+ onCloseRequest = ::exitApplication,
+ title = "Capy Reader",
+ state = rememberWindowState(width = 1200.dp, height = 800.dp),
+ ) {
+ val colorScheme = if (isSystemInDarkTheme()) DarkColors else LightColors
+ val typography = platformTypography()
+
+ MaterialTheme(colorScheme = colorScheme, typography = typography) {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ var account by remember { mutableStateOf(appState.loadAccount()) }
+
+ val currentAccount = account
+ if (currentAccount == null) {
+ LoginScreen(
+ appState = appState,
+ onAccountCreated = {
+ account = appState.loadAccount()
+ },
+ )
+ } else {
+ val scope = rememberCoroutineScope()
+ val readerState = remember(currentAccount.id) {
+ ReaderState(account = currentAccount, scope = scope)
+ }
+ ReaderScreen(
+ state = readerState,
+ onSignOut = {
+ val id = appState.savedAccountID()
+ if (id != null) {
+ appState.manager.removeAccount(id)
+ appState.clearAccountID()
+ }
+ account = null
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun platformTypography(): Typography {
+ val fontFamily = platformFontFamily()
+ val defaults = Typography()
+
+ return Typography(
+ displayLarge = defaults.displayLarge.copy(fontFamily = fontFamily),
+ displayMedium = defaults.displayMedium.copy(fontFamily = fontFamily),
+ displaySmall = defaults.displaySmall.copy(fontFamily = fontFamily),
+ headlineLarge = defaults.headlineLarge.copy(fontFamily = fontFamily),
+ headlineMedium = defaults.headlineMedium.copy(fontFamily = fontFamily),
+ headlineSmall = defaults.headlineSmall.copy(fontFamily = fontFamily),
+ titleLarge = defaults.titleLarge.copy(fontFamily = fontFamily),
+ titleMedium = defaults.titleMedium.copy(fontFamily = fontFamily),
+ titleSmall = defaults.titleSmall.copy(fontFamily = fontFamily),
+ bodyLarge = defaults.bodyLarge.copy(fontFamily = fontFamily),
+ bodyMedium = defaults.bodyMedium.copy(fontFamily = fontFamily),
+ bodySmall = defaults.bodySmall.copy(fontFamily = fontFamily),
+ labelLarge = defaults.labelLarge.copy(fontFamily = fontFamily),
+ labelMedium = defaults.labelMedium.copy(fontFamily = fontFamily),
+ labelSmall = defaults.labelSmall.copy(fontFamily = fontFamily),
+ )
+}
+
+@Composable
+private fun platformFontFamily(): FontFamily {
+ val os = System.getProperty("os.name").orEmpty().lowercase()
+
+ return when {
+ os.contains("win") -> FontFamily(Font("Segoe UI"))
+ os.contains("mac") -> FontFamily(Font(".AppleSystemUIFont"))
+ else -> FontFamily.Default
+ }
+}
diff --git a/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/PaneDivider.kt b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/PaneDivider.kt
new file mode 100644
index 000000000..470535bb4
--- /dev/null
+++ b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/PaneDivider.kt
@@ -0,0 +1,34 @@
+package com.jocmp.capyreader.desktop
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+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.unit.dp
+import java.awt.Cursor
+
+@Composable
+fun PaneDivider(
+ onDrag: (deltaX: Float) -> Unit,
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxHeight()
+ .width(4.dp)
+ .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f))
+ .pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR)))
+ .pointerInput(Unit) {
+ detectDragGestures { change, dragAmount ->
+ change.consume()
+ onDrag(dragAmount.x)
+ }
+ },
+ )
+}
diff --git a/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/ReaderScreen.kt b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/ReaderScreen.kt
new file mode 100644
index 000000000..c0097f35a
--- /dev/null
+++ b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/ReaderScreen.kt
@@ -0,0 +1,158 @@
+package com.jocmp.capyreader.desktop
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandHorizontally
+import androidx.compose.animation.shrinkHorizontally
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.isCtrlPressed
+import androidx.compose.ui.input.key.isMetaPressed
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+import java.awt.Desktop
+import java.net.URI
+
+private val COMPACT_BREAKPOINT = 700.dp
+
+@Composable
+fun ReaderScreen(
+ state: ReaderState,
+ onSignOut: () -> Unit,
+) {
+ val focusRequester = remember { FocusRequester() }
+ val isMac = remember { System.getProperty("os.name").orEmpty().lowercase().contains("mac") }
+ val density = LocalDensity.current
+
+ var sidebarWidth by remember { mutableStateOf(240.dp) }
+ var sidebarCollapsed by remember { mutableStateOf(false) }
+ var listWidth by remember { mutableStateOf(320.dp) }
+
+ val selectedArticle by state.selectedArticle.collectAsDesktopState()
+
+ LaunchedEffect(Unit) {
+ state.loadArticles()
+ focusRequester.requestFocus()
+ }
+
+ BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
+ val containerWidth = maxWidth
+ val isCompact by remember(containerWidth) {
+ derivedStateOf { containerWidth < COMPACT_BREAKPOINT }
+ }
+ val showDetail = !isCompact || selectedArticle != null
+ val showList = !isCompact || selectedArticle == null
+
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .focusRequester(focusRequester)
+ .focusable()
+ .onPreviewKeyEvent { event ->
+ if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
+ val mod = if (isMac) event.isMetaPressed else event.isCtrlPressed
+
+ when {
+ event.key == Key.J || event.key == Key.DirectionDown -> {
+ state.selectNextArticle(); true
+ }
+ event.key == Key.K || event.key == Key.DirectionUp -> {
+ state.selectPreviousArticle(); true
+ }
+ mod && event.key == Key.R -> {
+ state.refresh(); true
+ }
+ event.key == Key.S -> {
+ state.toggleStar(); true
+ }
+ event.key == Key.M -> {
+ state.toggleRead(); true
+ }
+ mod && event.key == Key.A -> {
+ state.markAllRead(); true
+ }
+ // Cmd/Ctrl+[ or Backspace — back to list in compact mode
+ (mod && event.key == Key.LeftBracket) || (isCompact && event.key == Key.Backspace) -> {
+ state.clearSelection(); true
+ }
+ // Cmd/Ctrl+B — toggle sidebar
+ mod && event.key == Key.B -> {
+ sidebarCollapsed = !sidebarCollapsed; true
+ }
+ event.key == Key.V -> {
+ state.selectedArticle.value?.url?.let {
+ try { Desktop.getDesktop().browse(URI(it.toString())) } catch (_: Exception) {}
+ }
+ true
+ }
+ event.key == Key.Escape -> {
+ if (isCompact && selectedArticle != null) {
+ state.clearSelection()
+ } else {
+ sidebarCollapsed = !sidebarCollapsed
+ }
+ true
+ }
+ else -> false
+ }
+ },
+ ) {
+ AnimatedVisibility(
+ visible = !sidebarCollapsed,
+ enter = expandHorizontally(),
+ exit = shrinkHorizontally(),
+ ) {
+ Row {
+ FeedSidebar(state = state, width = sidebarWidth, onSignOut = onSignOut)
+ PaneDivider { deltaX ->
+ with(density) {
+ val newWidth = sidebarWidth + deltaX.toDp()
+ sidebarWidth = newWidth.coerceIn(160.dp, 400.dp)
+ }
+ }
+ }
+ }
+
+ if (showList) {
+ ArticleListPane(
+ state = state,
+ width = if (isCompact) containerWidth else listWidth,
+ sidebarCollapsed = sidebarCollapsed,
+ onToggleSidebar = { sidebarCollapsed = !sidebarCollapsed },
+ )
+
+ if (!isCompact) {
+ PaneDivider { deltaX ->
+ with(density) {
+ val newWidth = listWidth + deltaX.toDp()
+ listWidth = newWidth.coerceIn(200.dp, 600.dp)
+ }
+ }
+ }
+ }
+
+ if (showDetail) {
+ ArticleDetailPane(
+ state = state,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ }
+}
diff --git a/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/ReaderState.kt b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/ReaderState.kt
new file mode 100644
index 000000000..fee2f275c
--- /dev/null
+++ b/desktop/src/main/kotlin/com/jocmp/capyreader/desktop/ReaderState.kt
@@ -0,0 +1,282 @@
+package com.jocmp.capyreader.desktop
+
+import com.jocmp.capy.Account
+import com.jocmp.capy.Article
+import com.jocmp.capy.ArticleFilter
+import com.jocmp.capy.ArticleStatus
+import com.jocmp.capy.Feed
+import com.jocmp.capy.FeedPriority
+import com.jocmp.capy.Folder
+import com.jocmp.capy.MarkRead
+import com.jocmp.capy.SavedSearch
+import com.jocmp.capy.articles.SortOrder
+import com.jocmp.capy.persistence.ArticleRecords
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.time.OffsetDateTime
+
+class ReaderState(
+ val account: Account,
+ private val scope: CoroutineScope,
+) {
+ private val articleRecords = ArticleRecords(account.database)
+
+ private val _filter = MutableStateFlow(ArticleFilter.default())
+ val filter: StateFlow = _filter.asStateFlow()
+
+ private val _articles = MutableStateFlow>(emptyList())
+ val articles: StateFlow> = _articles.asStateFlow()
+
+ private val _canLoadMore = MutableStateFlow(false)
+ val canLoadMore: StateFlow = _canLoadMore.asStateFlow()
+
+ private var currentOffset = 0L
+
+ private val _selectedArticle = MutableStateFlow(null)
+ val selectedArticle: StateFlow = _selectedArticle.asStateFlow()
+
+ private val _refreshing = MutableStateFlow(false)
+ val refreshing: StateFlow = _refreshing.asStateFlow()
+
+ val feeds: StateFlow> = account.feeds
+ .stateIn(scope, SharingStarted.Eagerly, emptyList())
+
+ val folders: StateFlow> = account.folders
+ .stateIn(scope, SharingStarted.Eagerly, emptyList())
+
+ val savedSearches: StateFlow> = account.savedSearches
+ .stateIn(scope, SharingStarted.Eagerly, emptyList())
+
+ private val feedCounts: StateFlow