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 + + + + \ 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> = account.countAll(ArticleStatus.UNREAD) + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + + val foldersWithCounts: StateFlow> = combine(folders, feedCounts) { folderList, counts -> + folderList.map { folder -> + folder.copy( + count = folder.feeds.sumOf { counts[it.id] ?: 0L }, + feeds = folder.feeds.map { feed -> + feed.copy(count = counts[feed.id] ?: 0L) + } + ) + } + }.stateIn(scope, SharingStarted.Eagerly, emptyList()) + + val feedsWithCounts: StateFlow> = combine(feeds, feedCounts) { feedList, counts -> + feedList.map { feed -> + feed.copy(count = counts[feed.id] ?: 0L) + } + }.stateIn(scope, SharingStarted.Eagerly, emptyList()) + + val allUnreadCount: StateFlow = account.countAllByStatus(ArticleStatus.UNREAD) + .stateIn(scope, SharingStarted.Eagerly, 0L) + + fun selectFilter(newFilter: ArticleFilter) { + _filter.value = newFilter + _selectedArticle.value = null + currentOffset = 0 + loadArticles() + } + + fun selectStatus(status: ArticleStatus) { + _filter.value = _filter.value.withStatus(status) + loadArticles() + } + + fun selectArticle(article: Article) { + _selectedArticle.value = article + if (!article.read) { + scope.launch { + account.markRead(article.id) + reloadSelectedArticle() + loadArticles() + } + } + } + + fun selectNextArticle() { + val list = _articles.value + val current = _selectedArticle.value + if (list.isEmpty()) return + + if (current == null) { + selectArticle(list.first()) + return + } + + val index = list.indexOfFirst { it.id == current.id } + if (index >= 0 && index < list.lastIndex) { + selectArticle(list[index + 1]) + } + } + + fun selectPreviousArticle() { + val list = _articles.value + val current = _selectedArticle.value ?: return + + val index = list.indexOfFirst { it.id == current.id } + if (index > 0) { + selectArticle(list[index - 1]) + } + } + + fun clearSelection() { + _selectedArticle.value = null + } + + fun toggleRead() { + val article = _selectedArticle.value ?: return + scope.launch { + if (article.read) { + account.markUnread(article.id) + } else { + account.markRead(article.id) + } + reloadSelectedArticle() + loadArticles() + } + } + + fun toggleStar() { + val article = _selectedArticle.value ?: return + scope.launch { + if (article.starred) { + account.removeStar(article.id) + } else { + account.addStar(article.id) + } + reloadSelectedArticle() + loadArticles() + } + } + + fun markAllRead() { + scope.launch { + val articleIDs = withContext(Dispatchers.IO) { + account.unreadArticleIDs( + filter = _filter.value, + range = MarkRead.All, + sortOrder = SortOrder.NEWEST_FIRST, + query = null, + ) + } + if (articleIDs.isNotEmpty()) { + account.markAllRead(articleIDs) + loadArticles() + } + } + } + + fun refresh() { + if (_refreshing.value) return + + scope.launch { + _refreshing.value = true + try { + withContext(Dispatchers.IO) { + account.refresh(_filter.value) + } + loadArticles() + } finally { + _refreshing.value = false + } + } + } + + fun loadArticles() { + currentOffset = 0 + scope.launch { + val result = fetchPage(offset = 0) + _articles.value = result + _canLoadMore.value = result.size.toLong() == PAGE_SIZE + } + } + + fun loadMore() { + if (!_canLoadMore.value) return + + scope.launch { + val nextOffset = currentOffset + PAGE_SIZE + val result = fetchPage(offset = nextOffset) + if (result.isNotEmpty()) { + currentOffset = nextOffset + _articles.value = _articles.value + result + _canLoadMore.value = result.size.toLong() == PAGE_SIZE + } else { + _canLoadMore.value = false + } + } + } + + private suspend fun fetchPage(offset: Long): List
{ + return withContext(Dispatchers.IO) { + val currentFilter = _filter.value + val since = OffsetDateTime.MIN + + when (currentFilter) { + is ArticleFilter.Articles -> articleRecords.byStatus.all( + status = currentFilter.status, + limit = PAGE_SIZE, + offset = offset, + sortOrder = SortOrder.NEWEST_FIRST, + ) + is ArticleFilter.Feeds -> articleRecords.byFeed.all( + feedIDs = listOf(currentFilter.feedID), + status = currentFilter.status, + since = since, + limit = PAGE_SIZE, + offset = offset, + sortOrder = SortOrder.NEWEST_FIRST, + priority = FeedPriority.FEED, + ) + is ArticleFilter.Folders -> { + val feedIDs = account.database.taggingsQueries + .findFeedIDs(folderTitle = currentFilter.folderTitle) + .executeAsList() + articleRecords.byFeed.all( + feedIDs = feedIDs, + status = currentFilter.status, + since = since, + limit = PAGE_SIZE, + offset = offset, + sortOrder = SortOrder.NEWEST_FIRST, + priority = FeedPriority.CATEGORY, + ) + } + is ArticleFilter.SavedSearches -> articleRecords.bySavedSearch.all( + savedSearchID = currentFilter.savedSearchID, + status = currentFilter.status, + since = since, + limit = PAGE_SIZE, + offset = offset, + sortOrder = SortOrder.NEWEST_FIRST, + ) + is ArticleFilter.Today -> articleRecords.byToday.all( + status = currentFilter.status, + since = null, + limit = PAGE_SIZE, + offset = offset, + sortOrder = SortOrder.NEWEST_FIRST, + ) + }.executeAsList() + } + } + + companion object { + private const val PAGE_SIZE = 100L + } + + private suspend fun reloadSelectedArticle() { + val current = _selectedArticle.value ?: return + _selectedArticle.value = withContext(Dispatchers.IO) { + account.findArticle(current.id) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a70b6512e..0c1d5f225 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -92,3 +92,4 @@ zoomable-image-coil = { module = "me.saket.telephoto:zoomable-image-coil3", vers [plugins] jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +jetbrains-compose = { id = "org.jetbrains.compose", version = "1.10.3" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 211f744fc..b0055cf1f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,3 +29,4 @@ include(":minifluxclient") include(":rssparser") include(":readerclient") include(":bench") +include(":desktop")