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.Article import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.outlined.Article 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.MaterialTheme 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.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.data.setReaderModeEnabled import org.terst.feedreader.work.FeedFetchWorker @OptIn(ExperimentalMaterial3Api::class) @Composable fun FeedListScreen( onOpenFeed: (String) -> Unit, onOpenUnread: () -> Unit, onOpenOlder: () -> Unit, onOpenSaved: () -> Unit, onAddFeed: () -> Unit, onSettings: () -> Unit ) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } val scope = rememberCoroutineScope() val cutoff = remember { System.currentTimeMillis() - RECENT_WINDOW_MS } 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 recentUnreadCount by remember { db.articleDao().observeUnreadCount(cutoff) }.collectAsState(initial = 0) val olderUnreadCount by remember { db.articleDao().observeOlderUnreadCount(cutoff) }.collectAsState(initial = 0) val savedCount by remember { db.articleDao().observeSavedCount() }.collectAsState(initial = 0) var menuExpanded by remember { mutableStateOf(false) } var pendingDelete by remember { mutableStateOf(null) } // Folders start collapsed -- that's the whole point of grouping once there are enough // feeds/folders that the flat list was worth organizing in the first place. Session-only // (not persisted): which folders were open isn't something worth remembering across app // restarts, per the app's "simple design" scope. var expandedFolders by remember { mutableStateOf(setOf()) } val snackbarHostState = remember { SnackbarHostState() } pendingDelete?.let { feed -> ConfirmDialog( title = "Unsubscribe from ${feed.title.ifBlank { feed.id }}?", text = "Removes its articles. Anything you've saved is kept.", confirmLabel = "Unsubscribe", onConfirm = { scope.launch { db.articleDao().deleteUnsavedByFeed(feed.id) db.feedDao().delete(feed.id) } pendingDelete = null }, onDismiss = { pendingDelete = null } ) } 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") } }, snackbarHost = { SnackbarHost(snackbarHostState) } ) { padding -> LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) { item { ListItem( headlineContent = { Text("Unread") }, trailingContent = { Text(recentUnreadCount.toString()) }, modifier = Modifier.fillMaxWidth().clickable { onOpenUnread() } ) HorizontalDivider() // The rest of the unread backlog, older than RECENT_WINDOW_MS -- kept // out of the main Unread view so a large backlog doesn't bury what's new. ListItem( headlineContent = { Text("Older") }, trailingContent = { Text(olderUnreadCount.toString()) }, modifier = Modifier.fillMaxWidth().clickable { onOpenOlder() } ) HorizontalDivider() ListItem( headlineContent = { Text("Saved") }, trailingContent = { Text(savedCount.toString()) }, modifier = Modifier.fillMaxWidth().clickable { onOpenSaved() } ) SectionHeader("Feeds") } val failedFeeds = feeds.filter { it.first.lastError != null } val okFeeds = feeds.filter { it.first.lastError == null } val groupedByFolder = okFeeds.groupBy { it.first.folder } val folderNames = groupedByFolder.keys.filterNotNull().sortedBy { it.lowercase() } val ungrouped = groupedByFolder[null].orEmpty() fun feedRowItems(rows: List>) { items(rows, key = { it.first.id }) { (feed, unreadCount) -> FeedRow( feed = feed, unreadCount = unreadCount, onClick = { onOpenFeed(feed.id) }, onDelete = { pendingDelete = feed }, onToggleReaderMode = { val newEnabled = !feed.readerModeEnabled scope.launch { val message = db.feedDao().setReaderModeEnabled(feed, newEnabled) snackbarHostState.showSnackbar(message) } } ) HorizontalDivider() } } if (failedFeeds.isNotEmpty()) { item { SectionHeader("Last fetch failed") } feedRowItems(failedFeeds) } for (folder in folderNames) { val isExpanded = folder in expandedFolders val folderFeeds = groupedByFolder.getValue(folder) item { FolderHeader( title = folder, expanded = isExpanded, unreadCount = folderFeeds.sumOf { it.second }, onClick = { expandedFolders = if (isExpanded) expandedFolders - folder else expandedFolders + folder } ) } if (isExpanded) feedRowItems(folderFeeds) } if (ungrouped.isNotEmpty()) { if (folderNames.isNotEmpty() || failedFeeds.isNotEmpty()) item { SectionHeader("Other") } feedRowItems(ungrouped) } } } } @Composable private fun SectionHeader(title: String) { Text( title, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp) ) } @Composable private fun FolderHeader(title: String, expanded: Boolean, unreadCount: Int, onClick: () -> Unit) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 8.dp, vertical = 4.dp) ) { Icon( if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, contentDescription = if (expanded) "Collapse" else "Expand", tint = MaterialTheme.colorScheme.primary ) Text( title, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f).padding(horizontal = 8.dp, vertical = 4.dp) ) // A folder collapsed hides its per-feed unread badges too -- show the total here so // collapsing doesn't also hide "is there anything new in here." if (unreadCount > 0) { Text(unreadCount.toString(), color = MaterialTheme.colorScheme.primary) Spacer(modifier = Modifier.width(8.dp)) } } } @Composable private fun FeedRow( feed: FeedEntity, unreadCount: Int, onClick: () -> Unit, onDelete: () -> Unit, onToggleReaderMode: () -> Unit ) { ListItem( headlineContent = { Text(feed.title.ifBlank { feed.id }) }, supportingContent = { if (feed.lastError != null) { Text("Last fetch failed: ${feed.lastError}") } }, trailingContent = { Row(verticalAlignment = Alignment.CenterVertically) { if (unreadCount > 0) { Text(unreadCount.toString()) Spacer(modifier = Modifier.width(8.dp)) } IconButton(onClick = onToggleReaderMode) { Icon( if (feed.readerModeEnabled) Icons.Filled.Article else Icons.Outlined.Article, contentDescription = if (feed.readerModeEnabled) "Reader mode enabled for this feed -- tap to disable" else "Reader mode disabled for this feed -- tap to enable" ) } IconButton(onClick = onDelete) { Icon(Icons.Default.Delete, contentDescription = "Unsubscribe") } } }, modifier = Modifier.fillMaxWidth().clickable { onClick() } ) }