diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 59cde4e6d..a217f095c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -157,6 +157,7 @@ dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) implementation(libs.okhttp.client) + implementation(libs.vico.compose.m3) implementation(libs.sqldelight.android.driver) implementation(libs.sqldelight.androidx.paging.extensions) implementation(libs.zoomable) diff --git a/app/src/main/java/com/capyreader/app/ui/settings/SettingsView.kt b/app/src/main/java/com/capyreader/app/ui/settings/SettingsView.kt index c9484f10b..e5c1ac0f4 100644 --- a/app/src/main/java/com/capyreader/app/ui/settings/SettingsView.kt +++ b/app/src/main/java/com/capyreader/app/ui/settings/SettingsView.kt @@ -9,7 +9,10 @@ import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaf import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -28,6 +31,8 @@ import com.capyreader.app.ui.settings.panels.GeneralSettingsPanel import com.capyreader.app.ui.settings.panels.GesturesSettingPanel import com.capyreader.app.ui.settings.panels.NotificationsSettingsPanel import com.capyreader.app.ui.settings.panels.SettingsPanel +import com.capyreader.app.ui.settings.panels.SubscriptionDetailView +import com.capyreader.app.ui.settings.panels.SubscriptionsSettingsPanel import com.capyreader.app.ui.settings.panels.UnreadBadgesSettingsPanel import com.capyreader.app.ui.settings.panels.SettingsViewModel import com.jocmp.capy.common.launchUI @@ -48,6 +53,7 @@ fun SettingsView( val currentPanel = navigator.currentDestination?.contentKey val feeds by viewModel.feeds.collectAsStateWithLifecycle(emptyList()) val savedSearches by viewModel.savedSearches.collectAsStateWithLifecycle(emptyList()) + var subscriptionDetailFeedId by rememberSaveable { mutableStateOf(null) } val navigateToPanel = { panel: SettingsPanel -> coroutineScope.launchUI { @@ -86,15 +92,37 @@ fun SettingsView( SettingsPanelScaffold( panel = currentPanel, onBack = { - navigateBack() + if (currentPanel == SettingsPanel.Subscriptions && subscriptionDetailFeedId != null) { + subscriptionDetailFeedId = null + } else { + navigateBack() + } }, ) { when (currentPanel) { - SettingsPanel.General -> GeneralSettingsPanel( - onNavigateToNotifications = { - navigateToPanel(SettingsPanel.Notifications) + SettingsPanel.General -> GeneralSettingsPanel() + + SettingsPanel.Subscriptions -> { + val detailFeedId = subscriptionDetailFeedId + if (detailFeedId != null) { + SubscriptionDetailView( + feedStats = viewModel.feedStats, + onLoadStats = { + viewModel.loadFeedStats(detailFeedId) + }, + ) + } else { + SubscriptionsSettingsPanel( + feeds = feeds, + onSelectAll = viewModel::selectAllFeedNotifications, + onSelectNone = viewModel::deselectAllFeedNotifications, + onToggleNotifications = viewModel::toggleNotifications, + onNavigateToDetail = { feedID -> + subscriptionDetailFeedId = feedID + }, + ) } - ) + } SettingsPanel.Notifications -> NotificationsSettingsPanel( onSelectNone = viewModel::deselectAllFeedNotifications, diff --git a/app/src/main/java/com/capyreader/app/ui/settings/panels/GeneralSettingsPanel.kt b/app/src/main/java/com/capyreader/app/ui/settings/panels/GeneralSettingsPanel.kt index 112b4c19b..e94579078 100644 --- a/app/src/main/java/com/capyreader/app/ui/settings/panels/GeneralSettingsPanel.kt +++ b/app/src/main/java/com/capyreader/app/ui/settings/panels/GeneralSettingsPanel.kt @@ -1,15 +1,6 @@ package com.capyreader.app.ui.settings.panels -import android.Manifest -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts.RequestPermission -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.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -18,10 +9,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.ListItemDefaults -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -29,9 +16,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -40,7 +25,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.capyreader.app.BuildConfig import com.capyreader.app.R import com.capyreader.app.common.RowItem -import com.capyreader.app.notifications.Notifications import com.capyreader.app.preferences.AfterReadAllBehavior import com.capyreader.app.preferences.HomePage import com.capyreader.app.refresher.RefreshInterval @@ -49,7 +33,6 @@ import com.capyreader.app.ui.components.FormSection import com.capyreader.app.ui.components.TextSwitch import com.capyreader.app.ui.fixtures.PreviewKoinApplication import com.capyreader.app.ui.settings.CrashReportingCheckbox -import com.capyreader.app.ui.components.LocalSnackbarHost import com.capyreader.app.ui.settings.PreferenceSelect import com.capyreader.app.ui.settings.filters.FilterKeywords import com.capyreader.app.ui.settings.filters.FiltersItem @@ -58,14 +41,12 @@ import com.capyreader.app.ui.theme.CapyTheme import com.jocmp.capy.accounts.AutoDelete import com.jocmp.capy.accounts.Source import com.jocmp.capy.articles.SortOrder -import com.jocmp.capy.common.launchUI import org.koin.androidx.compose.koinViewModel import java.lang.String.CASE_INSENSITIVE_ORDER @Composable fun GeneralSettingsPanel( viewModel: GeneralSettingsViewModel = koinViewModel(), - onNavigateToNotifications: () -> Unit, ) { val hasReadLaterFeed by viewModel.hasReadLaterFeed.collectAsStateWithLifecycle(initialValue = false) val keywords by viewModel.filterKeywords.collectAsStateWithLifecycle() @@ -81,7 +62,6 @@ fun GeneralSettingsPanel( ) { GeneralSettingsPanelView( source = viewModel.source, - onNavigateToNotifications = onNavigateToNotifications, refreshInterval = viewModel.refreshInterval, updateRefreshInterval = viewModel::updateRefreshInterval, canOpenLinksInternally = viewModel.canOpenLinksInternally, @@ -109,7 +89,6 @@ fun GeneralSettingsPanel( @Composable fun GeneralSettingsPanelView( source: Source, - onNavigateToNotifications: () -> Unit, onClearArticles: () -> Unit, refreshInterval: RefreshInterval, updateRefreshInterval: (RefreshInterval) -> Unit, @@ -163,10 +142,6 @@ fun GeneralSettingsPanelView( refreshInterval = refreshInterval, updateRefreshInterval = updateRefreshInterval, ) - NotificationsListItem( - onNavigate = onNavigateToNotifications, - refreshInterval = refreshInterval, - ) if (source == Source.LOCAL) { FiltersItem() } @@ -276,75 +251,6 @@ fun GeneralSettingsPanelView( } } -@Composable -fun NotificationsListItem( - onNavigate: () -> Unit, - refreshInterval: RefreshInterval, -) { - val defaultColors = ListItemDefaults.colors() - val enabled = refreshInterval.isPeriodic - val snackbar = LocalSnackbarHost.current - val scope = rememberCoroutineScope() - val context = LocalContext.current - - fun showPermissionFailureMessage() { - scope.launchUI { - snackbar.showSnackbar( - message = context.getString(R.string.notifications_permission_disabled_title), - actionLabel = context.getString(R.string.notifications_permissions_disabled_call_to_action), - duration = SnackbarDuration.Short - ).let { result -> - if (result == SnackbarResult.ActionPerformed) { - context.openAppSettings() - } - } - } - } - - val colors = ListItemDefaults.colors( - headlineColor = if (enabled) defaultColors.headlineColor else defaultColors.disabledHeadlineColor, - supportingColor = if (enabled) defaultColors.supportingTextColor else defaultColors.disabledHeadlineColor, - ) - - val permissions = rememberLauncherForActivityResult(RequestPermission()) { allowed -> - if (allowed) { - onNavigate() - } else { - showPermissionFailureMessage() - } - } - - Box( - Modifier.clickable( - enabled = enabled - ) { - if (Notifications.askForPermission) { - permissions.launch(Manifest.permission.POST_NOTIFICATIONS) - } else { - onNavigate() - } - } - ) { - ListItem( - colors = colors, - headlineContent = { - Text(stringResource(R.string.settings_panel_notifications_title)) - }, - supportingContent = { - if (!enabled) { - Text(stringResource(R.string.settings_enable_refresh_call_to_action)) - } - } - ) - } -} - -private fun Context.openAppSettings() { - startActivity(Intent().apply { - action = ACTION_APPLICATION_DETAILS_SETTINGS - data = Uri.fromParts("package", packageName, null) - }) -} @Preview @Composable @@ -362,7 +268,6 @@ private fun GeneralSettingsPanelPreview() { autoDelete = AutoDelete.WEEKLY, sortOrder = SortOrder.NEWEST_FIRST, updateSortOrder = {}, - onNavigateToNotifications = {}, markReadOnScroll = true, updateConfirmMarkAllRead = {}, updateMarkReadOnScroll = {}, diff --git a/app/src/main/java/com/capyreader/app/ui/settings/panels/SettingsPanel.kt b/app/src/main/java/com/capyreader/app/ui/settings/panels/SettingsPanel.kt index 7196724d4..36429897e 100644 --- a/app/src/main/java/com/capyreader/app/ui/settings/panels/SettingsPanel.kt +++ b/app/src/main/java/com/capyreader/app/ui/settings/panels/SettingsPanel.kt @@ -9,6 +9,7 @@ import androidx.compose.material.icons.rounded.Gesture import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.Palette +import androidx.compose.material.icons.rounded.RssFeed import androidx.compose.material.icons.rounded.Visibility import androidx.compose.ui.graphics.vector.ImageVector import com.capyreader.app.R @@ -22,6 +23,11 @@ sealed class SettingsPanel(@StringRes val title: Int) { override fun icon() = Icons.Rounded.Build } + @Parcelize + data object Subscriptions : SettingsPanel(title = R.string.settings_panel_subscriptions_title), Parcelable { + override fun icon() = Icons.Rounded.RssFeed + } + @Parcelize data object Notifications : SettingsPanel(title = R.string.settings_panel_notifications_title), Parcelable { override fun icon() = Icons.Rounded.Notifications @@ -66,6 +72,7 @@ sealed class SettingsPanel(@StringRes val title: Int) { val items: List get() = listOf( General, + Subscriptions, Display, Gestures, Account, diff --git a/app/src/main/java/com/capyreader/app/ui/settings/panels/SettingsViewModel.kt b/app/src/main/java/com/capyreader/app/ui/settings/panels/SettingsViewModel.kt index cf6f364bc..760946509 100644 --- a/app/src/main/java/com/capyreader/app/ui/settings/panels/SettingsViewModel.kt +++ b/app/src/main/java/com/capyreader/app/ui/settings/panels/SettingsViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope import com.capyreader.app.preferences.AppPreferences import com.capyreader.app.preferences.BadgeStyle import com.jocmp.capy.Account +import com.jocmp.capy.FeedStats import kotlinx.coroutines.launch class SettingsViewModel( @@ -18,6 +19,9 @@ class SettingsViewModel( val feeds = account.allFeeds val savedSearches = account.savedSearches + var feedStats by mutableStateOf(null) + private set + var badgeStyle by mutableStateOf(appPreferences.badgeStyle.get()) private set @@ -73,4 +77,10 @@ class SettingsViewModel( account.toggleAllUnreadBadges(enabled = false) } } + + fun loadFeedStats(feedID: String) { + viewModelScope.launch { + feedStats = account.feedStats(feedID) + } + } } diff --git a/app/src/main/java/com/capyreader/app/ui/settings/panels/SubscriptionDetailView.kt b/app/src/main/java/com/capyreader/app/ui/settings/panels/SubscriptionDetailView.kt new file mode 100644 index 000000000..9ece50b7a --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/settings/panels/SubscriptionDetailView.kt @@ -0,0 +1,267 @@ +package com.capyreader.app.ui.settings.panels + +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.capyreader.app.R +import com.capyreader.app.ui.articles.FaviconBadge +import com.capyreader.app.ui.articles.feeds.edit.EditFeedDialog +import com.jocmp.capy.DailyCount +import com.jocmp.capy.FeedStats +import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost +import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis +import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianValueFormatter +import com.patrykandpatrick.vico.compose.cartesian.data.columnSeries +import com.patrykandpatrick.vico.compose.cartesian.layer.ColumnCartesianLayer +import com.patrykandpatrick.vico.compose.cartesian.layer.rememberColumnCartesianLayer +import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart +import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState +import com.patrykandpatrick.vico.compose.common.Fill +import com.patrykandpatrick.vico.compose.common.ProvideVicoTheme +import com.patrykandpatrick.vico.compose.common.component.rememberLineComponent +import com.patrykandpatrick.vico.compose.m3.common.rememberM3VicoTheme +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +@Composable +fun SubscriptionDetailView( + feedStats: FeedStats?, + onLoadStats: () -> Unit, +) { + val (isEditOpen, setEditOpen) = rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(Unit) { + onLoadStats() + } + + if (feedStats == null) { + return + } + + val feed = feedStats.feed + + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + ) { + FeedHeader( + feed = feed, + onEditClick = { setEditOpen(true) }, + ) + + Spacer(Modifier.height(16.dp)) + + ActivityChart( + dailyCounts = feedStats.dailyCounts, + chartDays = feedStats.chartDays, + ) + + Spacer(Modifier.height(24.dp)) + + StatsSection(feedStats = feedStats) + + Spacer(Modifier.height(16.dp)) + } + + EditFeedDialog( + feed = feed, + isOpen = isEditOpen, + onDismiss = { setEditOpen(false) }, + ) +} + +@Composable +private fun FeedHeader( + feed: com.jocmp.capy.Feed, + onEditClick: () -> Unit, +) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.weight(1f), + ) { + FaviconBadge(url = feed.faviconURL, size = 24.dp) + Text( + text = feed.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + OutlinedButton(onClick = onEditClick) { + Text(stringResource(R.string.subscriptions_edit)) + } + } + } +} + +@Composable +private fun ActivityChart(dailyCounts: List, chartDays: Long) { + val modelProducer = remember { CartesianChartModelProducer() } + val chartColor = MaterialTheme.colorScheme.primary + val allDays = remember(dailyCounts, chartDays) { buildFullDayRange(dailyCounts, chartDays) } + + LaunchedEffect(dailyCounts) { + if (allDays.isNotEmpty()) { + modelProducer.runTransaction { + columnSeries { + series(allDays.map { it.count }) + } + } + } + } + + val bottomAxisFormatter = remember(allDays) { + CartesianValueFormatter { _, value, _ -> + val index = value.toInt() + if (index >= 0 && index < allDays.size) { + allDays[index].day.format(DateTimeFormatter.ofPattern("MMM d")) + } else { + "" + } + } + } + + ProvideVicoTheme(rememberM3VicoTheme()) { + CartesianChartHost( + chart = rememberCartesianChart( + rememberColumnCartesianLayer( + columnProvider = ColumnCartesianLayer.ColumnProvider.series( + rememberLineComponent( + fill = Fill(chartColor), + thickness = 4.dp, + ) + ), + ), + startAxis = VerticalAxis.rememberStart(), + bottomAxis = HorizontalAxis.rememberBottom( + valueFormatter = bottomAxisFormatter, + itemPlacer = remember(chartDays) { + val spacing = (chartDays / 3).coerceAtLeast(1) + HorizontalAxis.ItemPlacer.aligned(spacing = { spacing.toInt() }) + }, + ), + ), + modelProducer = modelProducer, + scrollState = rememberVicoScrollState(scrollEnabled = false), + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + ) + } +} + +@Composable +private fun StatsSection(feedStats: FeedStats) { + Text( + text = "Stats", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + + HorizontalDivider() + + StatRow( + label = stringResource(R.string.subscriptions_latest_article), + value = feedStats.latestArticleAt?.format( + DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG) + ) ?: stringResource(R.string.subscriptions_no_articles), + ) + + HorizontalDivider() + + StatRow( + label = stringResource(R.string.subscriptions_volume), + value = stringResource(R.string.subscriptions_articles_per_month, feedStats.volume), + ) + + HorizontalDivider() + + if (feedStats.feed.siteURL.isNotBlank()) { + StatRow( + label = stringResource(R.string.subscriptions_website), + value = feedStats.feed.siteURL, + ) + HorizontalDivider() + } + + StatRow( + label = stringResource(R.string.subscriptions_source), + value = feedStats.feed.feedURL, + ) + + HorizontalDivider() +} + +@Composable +private fun StatRow(label: String, value: String) { + ListItem( + headlineContent = { + Text( + label, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingContent = { + Text( + value, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) +} + +private fun buildFullDayRange(dailyCounts: List, chartDays: Long): List { + val end = LocalDate.now() + val start = end.minusDays(chartDays - 1) + val countMap = dailyCounts.associate { it.day to it.count } + + return (0L until chartDays).map { offset -> + val day = start.plusDays(offset) + DailyCount(day = day, count = countMap[day] ?: 0) + } +} diff --git a/app/src/main/java/com/capyreader/app/ui/settings/panels/SubscriptionsSettingsPanel.kt b/app/src/main/java/com/capyreader/app/ui/settings/panels/SubscriptionsSettingsPanel.kt new file mode 100644 index 000000000..d2d477ced --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/settings/panels/SubscriptionsSettingsPanel.kt @@ -0,0 +1,232 @@ +package com.capyreader.app.ui.settings.panels + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Canvas +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.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.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Checkbox +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.TextButton +import androidx.compose.material3.TriStateCheckbox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.capyreader.app.R +import com.capyreader.app.ui.articles.FaviconBadge +import com.jocmp.capy.Feed +import java.net.URI + +@Composable +fun SubscriptionsSettingsPanel( + feeds: List, + onSelectAll: () -> Unit, + onSelectNone: () -> Unit, + onToggleNotifications: (feedID: String, enabled: Boolean) -> Unit, + onNavigateToDetail: (feedID: String) -> Unit, +) { + val selectedIds = remember { mutableStateListOf() } + + val groupState = remember(selectedIds.size, feeds.size) { + when { + selectedIds.isEmpty() -> ToggleableState.Off + selectedIds.size == feeds.size -> ToggleableState.On + else -> ToggleableState.Indeterminate + } + } + + val onGroupClick: () -> Unit = { + when (groupState) { + ToggleableState.On -> selectedIds.clear() + else -> { + selectedIds.clear() + selectedIds.addAll(feeds.map { it.id }) + } + } + } + + val selectedFeeds = feeds.filter { it.id in selectedIds } + val allSelectedEnabled = selectedFeeds.isNotEmpty() && selectedFeeds.all { it.enableNotifications } + + Scaffold( + bottomBar = { + AnimatedVisibility( + visible = selectedIds.isNotEmpty(), + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + ) { + BottomAppBar { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.Center, + ) { + TextButton( + onClick = { + selectedIds.forEach { id -> + onToggleNotifications(id, !allSelectedEnabled) + } + selectedIds.clear() + } + ) { + Text( + if (allSelectedEnabled) { + stringResource(R.string.subscriptions_disable_notifications) + } else { + stringResource(R.string.subscriptions_enable_notifications) + } + ) + } + } + } + } + } + ) { padding -> + LazyColumn( + modifier = Modifier.padding(padding) + ) { + item(key = "select_all") { + SelectAllHeader( + groupState = groupState, + onClick = onGroupClick, + ) + } + items(feeds, key = { it.id }) { feed -> + SubscriptionRow( + feed = feed, + isSelected = feed.id in selectedIds, + onToggleSelected = { checked -> + if (checked) { + selectedIds.add(feed.id) + } else { + selectedIds.remove(feed.id) + } + }, + onNavigateToDetail = { onNavigateToDetail(feed.id) }, + ) + } + item { + Spacer(Modifier.height(16.dp)) + } + } + } +} + +@Composable +private fun SelectAllHeader( + groupState: ToggleableState, + onClick: () -> Unit, +) { + Box( + Modifier.clickable { onClick() } + ) { + ListItem( + headlineContent = { + val text = when (groupState) { + ToggleableState.On -> stringResource(R.string.settings_select_none) + else -> stringResource(R.string.settings_select_all) + } + Text(text, fontWeight = FontWeight.Medium) + }, + trailingContent = { + TriStateCheckbox( + state = groupState, + onClick = onClick + ) + } + ) + } +} + +@Composable +private fun SubscriptionRow( + feed: Feed, + isSelected: Boolean, + onToggleSelected: (Boolean) -> Unit, + onNavigateToDetail: () -> Unit, +) { + ListItem( + modifier = Modifier.clickable { onToggleSelected(!isSelected) }, + leadingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = isSelected, + onCheckedChange = onToggleSelected, + ) + Spacer(Modifier.width(4.dp)) + FaviconBadge(feed.faviconURL) + } + }, + headlineContent = { + Text( + feed.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + supportingContent = { + Text( + formatFeedURL(feed.feedURL), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingContent = { + IconButton(onClick = onNavigateToDetail) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + contentDescription = null, + ) + } + }, + ) +} + +private fun formatFeedURL(url: String): String { + return try { + val uri = URI(url) + val host = uri.host?.removePrefix("www.").orEmpty() + val path = uri.path?.trimStart('/')?.trimEnd('/').orEmpty() + if (path.isNotEmpty()) { + "$host \u203A ${path.replace("/", " \u203A ")}" + } else { + host + } + } catch (_: Exception) { + url + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bb810e261..8649cb98f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -214,6 +214,16 @@ General Display & Appearance Notifications + Subscriptions + Enable Notifications + Disable Notifications + Latest Article + Volume + Website + Source + %1$d articles / month + Edit + No articles Gestures Account About diff --git a/capy/src/main/java/com/jocmp/capy/Account.kt b/capy/src/main/java/com/jocmp/capy/Account.kt index c82f2b484..5d7a019f6 100644 --- a/capy/src/main/java/com/jocmp/capy/Account.kt +++ b/capy/src/main/java/com/jocmp/capy/Account.kt @@ -47,6 +47,9 @@ import kotlinx.coroutines.flow.map import okhttp3.OkHttpClient import java.io.InputStream import java.net.URI +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId import java.time.ZonedDateTime data class Account( @@ -442,6 +445,35 @@ data class Account( feedRecords.updateStickyFullContent(enabled = false, feedID = feedID) } + suspend fun feedStats(feedID: String): FeedStats? { + val feed = taggedFeeds.first().find { it.id == feedID } + ?: findFeed(feedID) + ?: return null + val autoDelete = preferences.autoDelete.get() + + val chartDays = autoDelete.chartDays() + val sinceDate = nowUTC().minusDays(chartDays) + val since = sinceDate.toEpochSecond() + + val dailyCounts = feedRecords.dailyArticleCounts(feedID, since) + + val thirtyDaysAgo = nowUTC().minusDays(30).toLocalDate() + val volume = dailyCounts.filter { it.day >= thirtyDaysAgo }.sumOf { it.count } + + val latestEpoch = feedRecords.latestArticleDate(feedID) + val latestArticleAt = latestEpoch?.let { + ZonedDateTime.ofInstant(Instant.ofEpochSecond(it), ZoneId.systemDefault()) + } + + return FeedStats( + feed = feed, + dailyCounts = dailyCounts, + chartDays = chartDays, + volume = volume, + latestArticleAt = latestArticleAt, + ) + } + suspend fun clearAllArticles() { articleRecords.deleteAllArticles() } @@ -501,3 +533,13 @@ private fun AutoDelete.cutoffDate(): ZonedDateTime? { AutoDelete.EVERY_THREE_MONTHS -> now.minusMonths(3) } } + +private fun AutoDelete.chartDays(): Long { + return when (this) { + AutoDelete.DISABLED -> 90 + AutoDelete.WEEKLY -> 7 + AutoDelete.EVERY_TWO_WEEKS -> 14 + AutoDelete.EVERY_MONTH -> 30 + AutoDelete.EVERY_THREE_MONTHS -> 90 + } +} diff --git a/capy/src/main/java/com/jocmp/capy/FeedStats.kt b/capy/src/main/java/com/jocmp/capy/FeedStats.kt new file mode 100644 index 000000000..6359fa7af --- /dev/null +++ b/capy/src/main/java/com/jocmp/capy/FeedStats.kt @@ -0,0 +1,14 @@ +package com.jocmp.capy + +import java.time.LocalDate +import java.time.ZonedDateTime + +data class FeedStats( + val feed: Feed, + val dailyCounts: List, + val chartDays: Long, + val volume: Int, + val latestArticleAt: ZonedDateTime?, +) + +data class DailyCount(val day: LocalDate, val count: Int) diff --git a/capy/src/main/java/com/jocmp/capy/persistence/FeedRecords.kt b/capy/src/main/java/com/jocmp/capy/persistence/FeedRecords.kt index 263154b04..068df9a3e 100644 --- a/capy/src/main/java/com/jocmp/capy/persistence/FeedRecords.kt +++ b/capy/src/main/java/com/jocmp/capy/persistence/FeedRecords.kt @@ -2,6 +2,7 @@ package com.jocmp.capy.persistence import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.coroutines.mapToList +import com.jocmp.capy.DailyCount import com.jocmp.capy.Feed import com.jocmp.capy.FeedPriority import com.jocmp.capy.Folder @@ -10,6 +11,7 @@ import com.jocmp.capy.db.Database import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull +import java.time.LocalDate internal class FeedRecords(private val database: Database) { suspend fun find(id: String): Feed? = withIOContext { @@ -101,6 +103,23 @@ internal class FeedRecords(private val database: Database) { database.feedsQueries.toggleAllShowUnreadBadge(enabled = enabled) } + suspend fun dailyArticleCounts(feedID: String, since: Long): List = withIOContext { + database.feedsQueries.dailyArticleCounts( + feedID = feedID, + since = since + ).executeAsList().mapNotNull { row -> + val day = row.day ?: return@mapNotNull null + DailyCount( + day = LocalDate.parse(day), + count = row.count.toInt() + ) + } + } + + suspend fun latestArticleDate(feedID: String): Long? = withIOContext { + database.feedsQueries.latestArticleDate(feedID).executeAsOneOrNull()?.MAX + } + suspend fun clearStickyFullContent() = withIOContext { database.feedsQueries.clearStickyFullContent() } diff --git a/capy/src/main/sqldelight/com/jocmp/capy/db/feeds.sq b/capy/src/main/sqldelight/com/jocmp/capy/db/feeds.sq index 411f95ae8..edf3980f3 100644 --- a/capy/src/main/sqldelight/com/jocmp/capy/db/feeds.sq +++ b/capy/src/main/sqldelight/com/jocmp/capy/db/feeds.sq @@ -108,6 +108,17 @@ UPDATE feeds SET show_unread_badge = :enabled WHERE id = :feedID; toggleAllShowUnreadBadge: UPDATE feeds SET show_unread_badge = :enabled; +dailyArticleCounts: +SELECT date(published_at, 'unixepoch') AS day, COUNT(*) AS count +FROM articles +WHERE feed_id = :feedID +AND published_at >= :since +GROUP BY day +ORDER BY day; + +latestArticleDate: +SELECT MAX(published_at) FROM articles WHERE feed_id = :feedID; + delete { DELETE FROM article_statuses WHERE article_statuses.article_id IN ( SELECT id diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a70b6512e..b4334aab3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ okhttp = "5.3.2" retrofit = "3.0.0" paging-compose = "3.4.1" sqldelight = "2.2.1" +vico = "3.1.0" zoomable = "0.18.0" [libraries] @@ -86,6 +87,7 @@ tests-mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } tests-mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } tests-mockk-mockk = { module = "io.mockk:mockk", version.ref = "mockk" } tests-okhttp-mock = { module = "com.github.gmazzo.okhttp.mock:mock-client", version = "2.1.0" } +vico-compose-m3 = { module = "com.patrykandpatrick.vico:compose-m3", version.ref = "vico" } zoomable = { module = "me.saket.telephoto:zoomable", version.ref = "zoomable" } zoomable-image-coil = { module = "me.saket.telephoto:zoomable-image-coil3", version.ref = "zoomable" }