diff options
Diffstat (limited to 'app/src/main/java/org/terst/feedreader/ui')
4 files changed, 98 insertions, 11 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 index 1e053af..5d19c90 100644 --- a/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt @@ -57,11 +57,16 @@ fun AddFeedScreen(onDone: () -> Unit) { val imported = withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri)?.use { stream -> Opml.parse(stream) } ?: emptyList() } - for (feed in imported) { + // A feed cross-listed under multiple OPML category folders (common when + // exported from a reader with folders/labels) appears once per folder here -- + // dedupe by feed URL before reporting a count, or "108 feeds" would be reported + // as some larger number even though upsert already collapses them to one row each. + val uniqueFeeds = imported.distinctBy { it.xmlUrl } + for (feed in uniqueFeeds) { db.feedDao().upsert(FeedEntity(id = feed.xmlUrl, title = feed.title, siteUrl = feed.htmlUrl)) } FeedFetchWorker.fetchNow(context) - status = "Imported ${imported.size} feed(s)" + status = "Imported ${uniqueFeeds.size} feed(s)" isBusy = false } } diff --git a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt index d9f3d8a..22130f9 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt @@ -5,10 +5,13 @@ 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.layout.size 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.filled.ArrowBack +import androidx.compose.material.icons.filled.List import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -23,8 +26,12 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage import kotlinx.coroutines.flow.combine import org.terst.feedreader.data.AppDatabase import org.terst.feedreader.data.ArticleEntity @@ -38,7 +45,8 @@ import java.util.Date fun ArticleListScreen( feedId: String, onOpenArticle: (String) -> Unit, - onBack: () -> Unit + onBack: () -> Unit, + onOpenFeedList: () -> Unit ) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } @@ -70,8 +78,16 @@ fun ArticleListScreen( ) }, navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.Default.ArrowBack, contentDescription = "Back") + // Unread is the app's landing screen (root of the back stack), so there's + // nothing to pop back to -- show a way forward to the feed list instead. + if (feedId == UNREAD_FEED_ID) { + IconButton(onClick = onOpenFeedList) { + Icon(Icons.Default.List, contentDescription = "Feeds") + } + } else { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } } } ) @@ -94,6 +110,19 @@ fun ArticleListScreen( private fun ArticleRow(article: ArticleEntity, highlighted: Boolean, onClick: () -> Unit) { val dateText = article.publishedAt?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(it)) } ListItem( + // Passed as null (not an empty/conditional lambda) when there's no image, so + // ListItem's own layout collapses the leading slot entirely for text-only rows + // instead of reserving blank space for every row regardless of content. + leadingContent = article.imageUrl?.let { url -> + { + AsyncImage( + model = url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(56.dp).clip(RoundedCornerShape(6.dp)) + ) + } + }, headlineContent = { Text( article.title, diff --git a/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt index 85c8d3c..a4852c6 100644 --- a/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt @@ -1,16 +1,21 @@ package org.terst.feedreader.ui import androidx.compose.foundation.clickable +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.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.Add +import androidx.compose.material.icons.filled.Delete 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.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -21,16 +26,21 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton 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.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.unit.dp import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch import org.terst.feedreader.data.AppDatabase import org.terst.feedreader.data.FeedEntity import org.terst.feedreader.work.FeedFetchWorker @@ -46,6 +56,7 @@ fun FeedListScreen( ) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } + val scope = rememberCoroutineScope() val feeds by remember { combine(db.feedDao().observeAll(), db.articleDao().observeUnreadCounts()) { feeds, counts -> @@ -56,6 +67,27 @@ fun FeedListScreen( val totalUnread = feeds.sumOf { it.second } var menuExpanded by remember { mutableStateOf(false) } + var pendingDelete by remember { mutableStateOf<FeedEntity?>(null) } + + pendingDelete?.let { feed -> + AlertDialog( + onDismissRequest = { pendingDelete = null }, + title = { Text("Unsubscribe from ${feed.title.ifBlank { feed.id }}?") }, + text = { Text("Removes its articles. Anything you've saved is kept.") }, + confirmButton = { + TextButton(onClick = { + scope.launch { + db.articleDao().deleteUnsavedByFeed(feed.id) + db.feedDao().delete(feed.id) + } + pendingDelete = null + }) { Text("Unsubscribe") } + }, + dismissButton = { + TextButton(onClick = { pendingDelete = null }) { Text("Cancel") } + } + ) + } Scaffold( topBar = { @@ -101,7 +133,12 @@ fun FeedListScreen( HorizontalDivider() } items(feeds, key = { it.first.id }) { (feed, unreadCount) -> - FeedRow(feed = feed, unreadCount = unreadCount, onClick = { onOpenFeed(feed.id) }) + FeedRow( + feed = feed, + unreadCount = unreadCount, + onClick = { onOpenFeed(feed.id) }, + onDelete = { pendingDelete = feed } + ) HorizontalDivider() } } @@ -109,7 +146,7 @@ fun FeedListScreen( } @Composable -private fun FeedRow(feed: FeedEntity, unreadCount: Int, onClick: () -> Unit) { +private fun FeedRow(feed: FeedEntity, unreadCount: Int, onClick: () -> Unit, onDelete: () -> Unit) { ListItem( headlineContent = { Text(feed.title.ifBlank { feed.id }) }, supportingContent = { @@ -117,7 +154,17 @@ private fun FeedRow(feed: FeedEntity, unreadCount: Int, onClick: () -> Unit) { Text("Last fetch failed: ${feed.lastError}") } }, - trailingContent = { if (unreadCount > 0) Text(unreadCount.toString()) }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + if (unreadCount > 0) { + Text(unreadCount.toString()) + Spacer(modifier = Modifier.width(8.dp)) + } + IconButton(onClick = onDelete) { + Icon(Icons.Default.Delete, contentDescription = "Unsubscribe") + } + } + }, 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 index 345bede..c080d89 100644 --- a/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt +++ b/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt @@ -6,6 +6,7 @@ import androidx.activity.compose.setContent import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.navigation.NavType @@ -29,7 +30,8 @@ class MainActivity : ComponentActivity() { FeedFetchWorker.schedulePeriodic(applicationContext) setContent { - MaterialTheme { + // Dark by default -- per user request, not following system light/dark for now. + MaterialTheme(colorScheme = darkColorScheme()) { Surface(modifier = Modifier.fillMaxSize()) { FeedReaderApp() } @@ -41,7 +43,10 @@ class MainActivity : ComponentActivity() { @Composable fun FeedReaderApp() { val navController = rememberNavController() - NavHost(navController = navController, startDestination = "feeds") { + // Unread is the landing screen (per user request) -- it has no "back" affordance of its + // own since it's the root of the back stack; ArticleListScreen instead shows a way to + // reach the feed list from there (a forward navigation, not a pop). + NavHost(navController = navController, startDestination = "articles/${encodeNavArg(UNREAD_FEED_ID)}") { composable("feeds") { FeedListScreen( onOpenFeed = { feedId -> navController.navigate("articles/${encodeNavArg(feedId)}") }, @@ -59,7 +64,8 @@ fun FeedReaderApp() { ArticleListScreen( feedId = feedId, onOpenArticle = { articleId -> navController.navigate("reader/${encodeNavArg(articleId)}") }, - onBack = { navController.popBackStack() } + onBack = { navController.popBackStack() }, + onOpenFeedList = { navController.navigate("feeds") } ) } composable( |
