diff options
Diffstat (limited to 'app/src/main/java/org/terst/feedreader/ui')
7 files changed, 904 insertions, 0 deletions
diff --git a/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt b/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt new file mode 100644 index 0000000..1e053af --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt @@ -0,0 +1,152 @@ +package org.terst.feedreader.ui + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +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.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.platform.LocalContext +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.Request +import org.terst.feedreader.data.AppDatabase +import org.terst.feedreader.data.FeedEntity +import org.terst.feedreader.data.FeedParser +import org.terst.feedreader.data.Net +import org.terst.feedreader.opml.Opml +import org.terst.feedreader.work.FeedFetchWorker + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddFeedScreen(onDone: () -> Unit) { + val context = LocalContext.current + val db = remember { AppDatabase.get(context) } + val scope = rememberCoroutineScope() + + var url by remember { mutableStateOf("") } + var status by remember { mutableStateOf<String?>(null) } + var isBusy by remember { mutableStateOf(false) } + + val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + isBusy = true + scope.launch { + val imported = withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(uri)?.use { stream -> Opml.parse(stream) } ?: emptyList() + } + for (feed in imported) { + db.feedDao().upsert(FeedEntity(id = feed.xmlUrl, title = feed.title, siteUrl = feed.htmlUrl)) + } + FeedFetchWorker.fetchNow(context) + status = "Imported ${imported.size} feed(s)" + isBusy = false + } + } + + val exportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/x-opml")) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch { + withContext(Dispatchers.IO) { + val feeds = db.feedDao().getAll() + context.contentResolver.openOutputStream(uri)?.use { out -> Opml.write(feeds, out) } + } + status = "Exported subscriptions" + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Add Feed") }, + navigationIcon = { + IconButton(onClick = onDone) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + LazyColumn(modifier = Modifier.fillMaxWidth().padding(padding)) { + item { + Column(modifier = Modifier.padding(16.dp)) { + OutlinedTextField( + value = url, + onValueChange = { url = it }, + label = { Text("Feed URL") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(4.dp)) + Button( + enabled = url.isNotBlank() && !isBusy, + onClick = { + isBusy = true + val feedUrl = url.trim() + scope.launch { + status = addFeedByUrl(context, db, feedUrl) + if (status?.startsWith("Added") == true) url = "" + isBusy = false + } + }, + modifier = Modifier.fillMaxWidth() + ) { Text("Add") } + + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton(onClick = { importLauncher.launch("*/*") }, modifier = Modifier.fillMaxWidth()) { + Text("Import OPML") + } + Spacer(modifier = Modifier.height(4.dp)) + OutlinedButton(onClick = { exportLauncher.launch("subscriptions.opml") }, modifier = Modifier.fillMaxWidth()) { + Text("Export OPML") + } + + status?.let { + Spacer(modifier = Modifier.height(8.dp)) + Text(it) + } + } + } + } + } +} + +private suspend fun addFeedByUrl(context: android.content.Context, db: AppDatabase, feedUrl: String): String = + withContext(Dispatchers.IO) { + try { + val response = Net.client.newCall(Request.Builder().url(feedUrl).build()).execute() + response.use { + if (!it.isSuccessful) return@withContext "Couldn't fetch feed: HTTP ${it.code}" + val body = it.body ?: return@withContext "Empty response" + val parsed = FeedParser.parse(body.byteStream()) + db.feedDao().upsert( + FeedEntity(id = feedUrl, title = parsed.title.ifBlank { feedUrl }, siteUrl = parsed.siteUrl) + ) + } + FeedFetchWorker.fetchNow(context) + "Added" + } catch (e: Exception) { + "Couldn't add feed: ${e.message}" + } + } diff --git a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt new file mode 100644 index 0000000..d9f3d8a --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt @@ -0,0 +1,109 @@ +package org.terst.feedreader.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextDecoration +import kotlinx.coroutines.flow.combine +import org.terst.feedreader.data.AppDatabase +import org.terst.feedreader.data.ArticleEntity +import org.terst.feedreader.data.FilterResult +import org.terst.feedreader.data.FilterRuleEngine +import java.text.DateFormat +import java.util.Date + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ArticleListScreen( + feedId: String, + onOpenArticle: (String) -> Unit, + onBack: () -> Unit +) { + val context = LocalContext.current + val db = remember { AppDatabase.get(context) } + + val articleFlow = remember(feedId) { + when (feedId) { + UNREAD_FEED_ID -> db.articleDao().observeUnread() + SAVED_FEED_ID -> db.articleDao().observeSaved() + else -> db.articleDao().observeByFeed(feedId) + } + } + val visibleArticles by remember(feedId) { + combine(articleFlow, db.filterRuleDao().observeAll()) { articles, rules -> + articles.map { it to FilterRuleEngine.evaluate(it, rules) } + .filter { (_, result) -> result != FilterResult.MUTED } + } + }.collectAsState(initial = emptyList()) + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + when (feedId) { + UNREAD_FEED_ID -> "Unread" + SAVED_FEED_ID -> "Saved" + else -> "Articles" + } + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) { + items(visibleArticles, key = { it.first.id }) { (article, result) -> + ArticleRow( + article = article, + highlighted = result == FilterResult.HIGHLIGHTED, + onClick = { onOpenArticle(article.id) } + ) + HorizontalDivider() + } + } + } +} + +@Composable +private fun ArticleRow(article: ArticleEntity, highlighted: Boolean, onClick: () -> Unit) { + val dateText = article.publishedAt?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(it)) } + ListItem( + headlineContent = { + Text( + article.title, + textDecoration = if (article.isRead) TextDecoration.LineThrough else null + ) + }, + supportingContent = { if (dateText != null) Text(dateText) }, + modifier = Modifier + .fillMaxWidth() + .background(if (highlighted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface) + .clickable { onClick() } + ) +} diff --git a/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt new file mode 100644 index 0000000..85c8d3c --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt @@ -0,0 +1,123 @@ +package org.terst.feedreader.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import kotlinx.coroutines.flow.combine +import org.terst.feedreader.data.AppDatabase +import org.terst.feedreader.data.FeedEntity +import org.terst.feedreader.work.FeedFetchWorker + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FeedListScreen( + onOpenFeed: (String) -> Unit, + onOpenUnread: () -> Unit, + onOpenSaved: () -> Unit, + onAddFeed: () -> Unit, + onSettings: () -> Unit +) { + val context = LocalContext.current + val db = remember { AppDatabase.get(context) } + + val feeds by remember { + combine(db.feedDao().observeAll(), db.articleDao().observeUnreadCounts()) { feeds, counts -> + val countsByFeed = counts.associate { it.feedId to it.count } + feeds.map { it to (countsByFeed[it.id] ?: 0) } + } + }.collectAsState(initial = emptyList()) + + val totalUnread = feeds.sumOf { it.second } + var menuExpanded by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Feeds") }, + actions = { + IconButton(onClick = { FeedFetchWorker.fetchNow(context) }) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh all") + } + IconButton(onClick = onSettings) { + Icon(Icons.Default.Settings, contentDescription = "Settings") + } + IconButton(onClick = { menuExpanded = true }) { + Icon(Icons.Default.MoreVert, contentDescription = "More") + } + DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { + DropdownMenuItem( + text = { Text("Import OPML") }, + onClick = { menuExpanded = false; onAddFeed() } + ) + } + } + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = onAddFeed) { + Icon(Icons.Default.Add, contentDescription = "Add feed") + } + } + ) { padding -> + LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) { + item { + ListItem( + headlineContent = { Text("Unread") }, + trailingContent = { Text(totalUnread.toString()) }, + modifier = Modifier.fillMaxWidth().clickable { onOpenUnread() } + ) + HorizontalDivider() + ListItem( + headlineContent = { Text("Saved") }, + modifier = Modifier.fillMaxWidth().clickable { onOpenSaved() } + ) + HorizontalDivider() + } + items(feeds, key = { it.first.id }) { (feed, unreadCount) -> + FeedRow(feed = feed, unreadCount = unreadCount, onClick = { onOpenFeed(feed.id) }) + HorizontalDivider() + } + } + } +} + +@Composable +private fun FeedRow(feed: FeedEntity, unreadCount: Int, onClick: () -> Unit) { + ListItem( + headlineContent = { Text(feed.title.ifBlank { feed.id }) }, + supportingContent = { + if (feed.lastError != null) { + Text("Last fetch failed: ${feed.lastError}") + } + }, + trailingContent = { if (unreadCount > 0) Text(unreadCount.toString()) }, + modifier = Modifier.fillMaxWidth().clickable { onClick() } + ) +} diff --git a/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt b/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt new file mode 100644 index 0000000..345bede --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt @@ -0,0 +1,79 @@ +package org.terst.feedreader.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import org.terst.feedreader.work.FeedFetchWorker +import java.net.URLDecoder +import java.net.URLEncoder + +const val UNREAD_FEED_ID = "__unread__" +const val SAVED_FEED_ID = "__saved__" + +fun encodeNavArg(value: String): String = URLEncoder.encode(value, "UTF-8") +fun decodeNavArg(value: String): String = URLDecoder.decode(value, "UTF-8") + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + FeedFetchWorker.schedulePeriodic(applicationContext) + + setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + FeedReaderApp() + } + } + } + } +} + +@Composable +fun FeedReaderApp() { + val navController = rememberNavController() + NavHost(navController = navController, startDestination = "feeds") { + composable("feeds") { + FeedListScreen( + onOpenFeed = { feedId -> navController.navigate("articles/${encodeNavArg(feedId)}") }, + onOpenUnread = { navController.navigate("articles/${encodeNavArg(UNREAD_FEED_ID)}") }, + onOpenSaved = { navController.navigate("articles/${encodeNavArg(SAVED_FEED_ID)}") }, + onAddFeed = { navController.navigate("addFeed") }, + onSettings = { navController.navigate("settings") } + ) + } + composable( + "articles/{feedId}", + arguments = listOf(navArgument("feedId") { type = NavType.StringType }) + ) { backStackEntry -> + val feedId = decodeNavArg(backStackEntry.arguments?.getString("feedId") ?: UNREAD_FEED_ID) + ArticleListScreen( + feedId = feedId, + onOpenArticle = { articleId -> navController.navigate("reader/${encodeNavArg(articleId)}") }, + onBack = { navController.popBackStack() } + ) + } + composable( + "reader/{articleId}", + arguments = listOf(navArgument("articleId") { type = NavType.StringType }) + ) { backStackEntry -> + val articleId = decodeNavArg(backStackEntry.arguments?.getString("articleId") ?: "") + ReaderScreen(articleId = articleId, onBack = { navController.popBackStack() }) + } + composable("addFeed") { + AddFeedScreen(onDone = { navController.popBackStack() }) + } + composable("settings") { + SettingsScreen(onBack = { navController.popBackStack() }) + } + } +} diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt new file mode 100644 index 0000000..8b320eb --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt @@ -0,0 +1,166 @@ +package org.terst.feedreader.ui + +import android.content.Intent +import android.net.Uri +import android.webkit.WebView +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.BookmarkBorder +import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import kotlinx.coroutines.launch +import org.terst.feedreader.data.AppDatabase +import org.terst.feedreader.data.ArticleEntity +import org.terst.feedreader.data.Net +import org.terst.feedreader.data.Settings +import org.terst.feedreader.readability.ReaderExtractor +import org.terst.feedreader.stash.StashClient +import org.terst.feedreader.stash.StashSaveResult + +// Below this length (of the plain feed-provided HTML), assume it's a teaser/summary, +// not the real article body, and fall back to on-device Readability extraction. +private const val THIN_CONTENT_THRESHOLD = 500 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReaderScreen(articleId: String, onBack: () -> Unit) { + val context = LocalContext.current + val db = remember { AppDatabase.get(context) } + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + var article by remember { mutableStateOf<ArticleEntity?>(null) } + var displayContent by remember { mutableStateOf<String?>(null) } + var isExtracting by remember { mutableStateOf(false) } + var isSaving by remember { mutableStateOf(false) } + + LaunchedEffect(articleId) { + val loaded = db.articleDao().getById(articleId) ?: return@LaunchedEffect + article = loaded + db.articleDao().setRead(articleId, true) + + val feedContent = loaded.feedContentHtml + val plainLength = feedContent?.replace(Regex("<[^>]*>"), "")?.trim()?.length ?: 0 + + displayContent = when { + loaded.extractedContent != null -> loaded.extractedContent + plainLength >= THIN_CONTENT_THRESHOLD -> feedContent + else -> { + isExtracting = true + val extracted = ReaderExtractor.extract(context, loaded.link) + isExtracting = false + val content = extracted?.content + if (content != null) { + db.articleDao().setExtractedContent(articleId, content) + content + } else { + feedContent ?: "<p>Couldn't load article content. Try opening in browser.</p>" + } + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(article?.title.orEmpty(), maxLines = 1) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + val currentArticle = article + IconButton(onClick = { + val a = currentArticle ?: return@IconButton + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(a.link)) + context.startActivity(browserIntent) + }) { + Icon(Icons.Default.OpenInBrowser, contentDescription = "Open in browser") + } + IconButton( + enabled = !isSaving, + onClick = { + val a = currentArticle ?: return@IconButton + isSaving = true + scope.launch { + val settings = Settings(context) + val client = StashClient(Net.client, settings.stashBaseUrlNow(), settings.stashApiKeyNow()) + when (val result = client.saveArticle(a.link, a.title)) { + is StashSaveResult.Saved -> { + db.articleDao().setSaved(a.id, true) + db.articleDao().setStashArtifactId(a.id, result.artifact.id) + snackbarHostState.showSnackbar("Saved to Stash") + } + is StashSaveResult.AlreadySaved -> { + db.articleDao().setSaved(a.id, true) + db.articleDao().setStashArtifactId(a.id, result.existingId) + snackbarHostState.showSnackbar("Already in Stash") + } + is StashSaveResult.Failed -> + snackbarHostState.showSnackbar("Save failed: ${result.message}") + } + isSaving = false + } + } + ) { + Icon( + if (currentArticle?.isSaved == true) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, + contentDescription = "Save to Stash" + ) + } + } + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { padding -> + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + val content = displayContent + val baseUrl = article?.link + if (content != null) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + WebView(ctx).apply { + settings.javaScriptEnabled = false + loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null) + } + }, + update = { webView -> webView.loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null) } + ) + } else if (isExtracting) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } + } +} + +private fun wrapHtml(bodyContent: String): String = """ + <html><head><meta name="viewport" content="width=device-width, initial-scale=1"> + <style>body { font-family: sans-serif; font-size: 18px; line-height: 1.5; padding: 16px; } + img { max-width: 100%; height: auto; }</style></head> + <body>$bodyContent</body></html> +""".trimIndent() diff --git a/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt b/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt new file mode 100644 index 0000000..a5d3f9c --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt @@ -0,0 +1,162 @@ +package org.terst.feedreader.ui + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.platform.LocalContext +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.terst.feedreader.data.AppDatabase +import org.terst.feedreader.data.FilterAction +import org.terst.feedreader.data.FilterRuleEntity +import org.terst.feedreader.data.Settings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(onBack: () -> Unit) { + val context = LocalContext.current + val db = remember { AppDatabase.get(context) } + val settings = remember { Settings(context) } + val scope = rememberCoroutineScope() + + var stashUrl by remember { mutableStateOf("") } + var stashKey by remember { mutableStateOf("") } + LaunchedEffect(Unit) { + stashUrl = settings.stashBaseUrlNow() + stashKey = settings.stashApiKeyNow() + } + + var newRulePattern by remember { mutableStateOf("") } + var newRuleAction by remember { mutableStateOf(FilterAction.MUTE) } + var actionMenuExpanded by remember { mutableStateOf(false) } + val rules by db.filterRuleDao().observeAll().collectAsState(initial = emptyList()) + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Settings") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + LazyColumn(modifier = Modifier.fillMaxWidth().padding(padding)) { + item { + Column(modifier = Modifier.padding(16.dp)) { + Text("Stash", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = stashUrl, + onValueChange = { stashUrl = it }, + label = { Text("Base URL") }, + placeholder = { Text("https://stash.example.com") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = stashKey, + onValueChange = { stashKey = it }, + label = { Text("API key") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { scope.launch { settings.setStash(stashUrl, stashKey) } }, + modifier = Modifier.fillMaxWidth() + ) { Text("Save") } + } + HorizontalDivider() + } + + item { + Column(modifier = Modifier.padding(16.dp)) { + Text("Filter rules", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = newRulePattern, + onValueChange = { newRulePattern = it }, + label = { Text("Keyword") }, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(8.dp)) + Box { + Button(onClick = { actionMenuExpanded = true }) { + Text(if (newRuleAction == FilterAction.MUTE) "Mute" else "Highlight") + } + DropdownMenu(expanded = actionMenuExpanded, onDismissRequest = { actionMenuExpanded = false }) { + DropdownMenuItem(text = { Text("Mute") }, onClick = { newRuleAction = FilterAction.MUTE; actionMenuExpanded = false }) + DropdownMenuItem(text = { Text("Highlight") }, onClick = { newRuleAction = FilterAction.HIGHLIGHT; actionMenuExpanded = false }) + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + Button( + enabled = newRulePattern.isNotBlank(), + onClick = { + scope.launch { + db.filterRuleDao().insert( + FilterRuleEntity( + pattern = newRulePattern.trim(), + action = newRuleAction, + createdAt = System.currentTimeMillis() + ) + ) + newRulePattern = "" + } + }, + modifier = Modifier.fillMaxWidth() + ) { Text("Add rule") } + } + } + + items(rules, key = { it.id }) { rule -> + ListItem( + headlineContent = { Text(rule.pattern) }, + supportingContent = { Text(if (rule.action == FilterAction.MUTE) "Mute" else "Highlight") }, + trailingContent = { + IconButton(onClick = { scope.launch { db.filterRuleDao().delete(rule.id) } }) { + Icon(Icons.Default.Delete, contentDescription = "Delete rule") + } + } + ) + HorizontalDivider() + } + } + } +} diff --git a/app/src/main/java/org/terst/feedreader/ui/ShareReceiverActivity.kt b/app/src/main/java/org/terst/feedreader/ui/ShareReceiverActivity.kt new file mode 100644 index 0000000..656bebc --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/ui/ShareReceiverActivity.kt @@ -0,0 +1,113 @@ +package org.terst.feedreader.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.platform.LocalContext +import androidx.compose.ui.unit.dp +import org.terst.feedreader.data.AppDatabase +import org.terst.feedreader.data.ArticleEntity +import org.terst.feedreader.data.Net +import org.terst.feedreader.data.Settings +import org.terst.feedreader.readability.ReaderExtractor +import org.terst.feedreader.stash.StashClient +import org.terst.feedreader.stash.StashSaveResult +import java.security.MessageDigest + +private val URL_REGEX = Regex("""https?://\S+""") + +/** + * Share-sheet target: a URL shared from any other app lands here. Extracts + * the article on-device (so it's readable offline immediately, independent + * of Stash), stores it locally as a standalone saved article (feedId=null, + * not tied to any subscription), and best-effort archives it to Stash. + */ +class ShareReceiverActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val sharedText = intent?.getStringExtra(android.content.Intent.EXTRA_TEXT).orEmpty() + val url = URL_REGEX.find(sharedText)?.value + + setContent { + MaterialTheme { + ShareReceiverContent(url = url, onDone = { finish() }) + } + } + } +} + +@Composable +private fun ShareReceiverContent(url: String?, onDone: () -> Unit) { + val context = LocalContext.current + var status by remember { mutableStateOf(if (url == null) "No link found in shared content" else "Saving…") } + var done by remember { mutableStateOf(url == null) } + + LaunchedEffect(url) { + if (url == null) return@LaunchedEffect + val db = AppDatabase.get(context) + + val extracted = ReaderExtractor.extract(context, url) + val id = MessageDigest.getInstance("SHA-256").digest(url.toByteArray()).joinToString("") { "%02x".format(it) } + db.articleDao().upsert( + ArticleEntity( + id = id, + feedId = null, + title = extracted?.title ?: url, + link = url, + extractedContent = extracted?.content, + isSaved = true, + createdAt = System.currentTimeMillis() + ) + ) + + val settings = Settings(context) + val client = StashClient(Net.client, settings.stashBaseUrlNow(), settings.stashApiKeyNow()) + status = when (val result = client.saveArticle(url, extracted?.title)) { + is StashSaveResult.Saved -> { + db.articleDao().setStashArtifactId(id, result.artifact.id) + "Saved for offline reading and archived to Stash" + } + is StashSaveResult.AlreadySaved -> { + db.articleDao().setStashArtifactId(id, result.existingId) + "Saved for offline reading (already in Stash)" + } + is StashSaveResult.Failed -> + "Saved for offline reading. Stash archive failed: ${result.message}" + } + done = true + } + + Column(modifier = Modifier.padding(24.dp)) { + Card(modifier = Modifier.padding(8.dp)) { + Column(modifier = Modifier.padding(20.dp)) { + Text("Feeds", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + Text(status) + if (!done) { + Spacer(modifier = Modifier.height(8.dp)) + CircularProgressIndicator() + } else { + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = onDone) { Text("Done") } + } + } + } + } +} |
