From b33858785fcef1e2fd5a5241184372db0c6c625a Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Sat, 30 Dec 2023 20:52:55 -0600 Subject: [PATCH] Add filter UI Non-functioning, but selection state works --- .../ui/accounts/AccountViewModel.kt | 9 ++- .../ui/articles/ArticleFilterNavigationBar.kt | 67 +++++++++++++++++++ .../basilreader/ui/articles/ArticleList.kt | 67 ++++++++++++++----- .../ui/articles/ArticleNavigation.kt | 2 - .../ui/articles/ArticleScaffold.kt | 45 ++++++++++++- .../basilreader/ui/articles/ArticleScreen.kt | 8 ++- app/src/main/res/drawable/notes.xml | 5 ++ app/src/main/res/drawable/unread.xml | 9 +++ app/src/main/res/values/strings.xml | 3 + .../java/com/jocmp/basil/ArticleFilter.kt | 12 +++- .../java/com/jocmp/basil/ArticleFilterTest.kt | 17 +++++ 11 files changed, 217 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleFilterNavigationBar.kt create mode 100644 app/src/main/res/drawable/notes.xml create mode 100644 app/src/main/res/drawable/unread.xml create mode 100644 basil/src/test/java/com/jocmp/basil/ArticleFilterTest.kt diff --git a/app/src/main/java/com/jocmp/basilreader/ui/accounts/AccountViewModel.kt b/app/src/main/java/com/jocmp/basilreader/ui/accounts/AccountViewModel.kt index d6f1d468..ba1af281 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/accounts/AccountViewModel.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/accounts/AccountViewModel.kt @@ -65,8 +65,15 @@ class AccountViewModel( val article: Article? get() = articleState.value + val filterStatus: ArticleFilter.Status + get() = filter.value.status + fun articles(): Pager? = pager.value + fun selectStatus(status: ArticleFilter.Status) { + filter.value = filter.value.withStatus(status = status) + } + fun selectFeed(feedID: String, onComplete: () -> Unit) { val feed = account?.findFeed(feedID) ?: return val feedFilter = ArticleFilter.Feeds(feed = feed, status = filter.value.status) @@ -84,7 +91,7 @@ class AccountViewModel( suspend fun refreshFeed() { when (val currentFilter = filter.value) { is ArticleFilter.Feeds -> account?.refreshFeed(currentFilter.feed) - is ArticleFilter.Folders -> account?.refreshFeeds(currentFilter.folder.feeds) + is ArticleFilter.Folders -> account?.refreshFeeds(currentFilter.folder.feeds) is ArticleFilter.Articles -> account?.refreshAll() } } diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleFilterNavigationBar.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleFilterNavigationBar.kt new file mode 100644 index 00000000..beb5d58c --- /dev/null +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleFilterNavigationBar.kt @@ -0,0 +1,67 @@ +package com.jocmp.basilreader.ui.articles + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.jocmp.basil.ArticleFilter +import com.jocmp.basilreader.R + +@Composable +fun ArticleFilterNavigationBar( + selected: ArticleFilter.Status, + onSelect: (status: ArticleFilter.Status) -> Unit +) { + NavigationBar { + NavigationBarItem( + icon = { Icon(Icons.Filled.Favorite, contentDescription = null) }, + label = { Text(stringResource(id = R.string.article_filters_starred)) }, + selected = selected === ArticleFilter.Status.STARRED, + onClick = { onSelect(ArticleFilter.Status.STARRED) }, + alwaysShowLabel = false + ) + NavigationBarItem( + icon = { + Icon( + painter = painterResource(R.drawable.unread), + contentDescription = null + ) + }, + label = { Text(stringResource(R.string.article_filters_unread)) }, + selected = selected === ArticleFilter.Status.UNREAD, + onClick = { onSelect(ArticleFilter.Status.UNREAD) }, + alwaysShowLabel = false + ) + NavigationBarItem( + icon = { + Icon( + painter = painterResource(R.drawable.notes), + contentDescription = null + ) + }, + label = { Text(stringResource(R.string.article_filters_all)) }, + selected = selected === ArticleFilter.Status.ALL, + onClick = { onSelect(ArticleFilter.Status.ALL) }, + alwaysShowLabel = false + ) + } +} + +@Preview +@Composable +fun ArticleFilterNavigationBarPreview() { + ArticleFilterNavigationBar( + selected = ArticleFilter.Status.ALL, + onSelect = {} + ) +} diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleList.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleList.kt index a6aa67df..988d5634 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleList.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleList.kt @@ -3,13 +3,20 @@ package com.jocmp.basilreader.ui.articles import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf @@ -17,11 +24,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.paging.Pager import androidx.paging.compose.collectAsLazyPagingItems import com.jocmp.basil.Article +import com.jocmp.basil.ArticleFilter +import com.jocmp.basilreader.R import kotlinx.coroutines.launch import java.time.format.DateTimeFormatter @@ -31,6 +42,8 @@ fun ArticleList( pager: Pager, onRefresh: suspend () -> Unit, onSelect: suspend (articleID: String) -> Unit, + onStatusSelect: (status: ArticleFilter.Status) -> Unit, + selectedStatus: ArticleFilter.Status, ) { val composableScope = rememberCoroutineScope() val lazyPagingItems = pager.flow.collectAsLazyPagingItems() @@ -46,30 +59,48 @@ fun ArticleList( val state = rememberPullRefreshState(refreshing, ::refresh) - Box(Modifier.pullRefresh(state)) { - - LazyColumn(Modifier.fillMaxWidth()) { - items(count = lazyPagingItems.itemCount) { index -> - val item = lazyPagingItems[index] - Column( - modifier = Modifier - .padding(8.dp) - .clickable { - item?.let { - composableScope.launch { - onSelect(it.id) + Scaffold( + bottomBar = { + ArticleFilterNavigationBar( + selected = selectedStatus, + onSelect = onStatusSelect + ) + } + ) { innerPadding -> + Box( + Modifier + .padding(innerPadding) + .pullRefresh(state) + ) { + LazyColumn(Modifier.fillMaxWidth()) { + items(count = lazyPagingItems.itemCount) { index -> + val item = lazyPagingItems[index] + Box( + modifier = Modifier + .clickable { + item?.let { + composableScope.launch { + onSelect(it.id) + } } } + ) { + Column(Modifier.padding(8.dp)) { + item?.let { article -> + Text(article.title, fontSize = 20.sp) + Text(article.arrivedAt.format(DateTimeFormatter.BASIC_ISO_DATE)) + } } - ) { - item?.let { article -> - Text(article.title, fontSize = 20.sp) - Text(article.arrivedAt.format(DateTimeFormatter.BASIC_ISO_DATE)) } } } - } - PullRefreshIndicator(refreshing, state, Modifier.align(Alignment.TopCenter)) + PullRefreshIndicator(refreshing, state, Modifier.align(Alignment.TopCenter)) + } } } + +data class ArticleStatusNavigationItem( + val icon: Icons, + val label: String +) diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleNavigation.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleNavigation.kt index 69f9ad6c..3abde3c7 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleNavigation.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleNavigation.kt @@ -6,8 +6,6 @@ import androidx.navigation.compose.composable const val articlesRoute = "articles" -fun feedRoute(feedID: String) = "articles" - fun NavController.navigateToArticles() = navigate(articlesRoute) diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScaffold.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScaffold.kt index b4677046..cedadb3f 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScaffold.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScaffold.kt @@ -1,25 +1,35 @@ package com.jocmp.basilreader.ui.articles +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.ListDetailPaneScaffold import androidx.compose.material3.adaptive.ListDetailPaneScaffoldRole +import androidx.compose.material3.adaptive.PaneScaffoldDirective import androidx.compose.material3.adaptive.ThreePaneScaffoldState import androidx.compose.material3.adaptive.calculateListDetailPaneScaffoldState +import androidx.compose.material3.adaptive.calculateStandardPaneScaffoldDirective +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.jocmp.basilreader.ui.theme.BasilReaderTheme @OptIn(ExperimentalMaterial3AdaptiveApi::class) @@ -27,7 +37,8 @@ import com.jocmp.basilreader.ui.theme.BasilReaderTheme fun ArticleScaffold( drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed), listDetailState: ThreePaneScaffoldState = calculateListDetailPaneScaffoldState( - currentPaneDestination = ListDetailPaneScaffoldRole.List + currentPaneDestination = ListDetailPaneScaffoldRole.List, + scaffoldDirective = calculateArticleDirective() ), drawerPane: @Composable () -> Unit, listPane: @Composable () -> Unit, @@ -39,7 +50,7 @@ fun ArticleScaffold( ModalDrawerSheet { drawerPane() } - } + }, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -58,6 +69,28 @@ fun ArticleScaffold( } } +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun calculateArticleDirective(): PaneScaffoldDirective { + val calculated = calculateStandardPaneScaffoldDirective(currentWindowAdaptiveInfo()) + + return copyDirectiveWithoutPadding(calculated) +} + + + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +private fun copyDirectiveWithoutPadding(directive: PaneScaffoldDirective): PaneScaffoldDirective { + return PaneScaffoldDirective( + contentPadding = PaddingValues(0.dp), + maxHorizontalPartitions = directive.maxHorizontalPartitions, + horizontalPartitionSpacerSize = 0.dp, + maxVerticalPartitions = directive.maxVerticalPartitions, + verticalPartitionSpacerSize = directive.verticalPartitionSpacerSize, + excludedBounds = directive.excludedBounds + ) +} + @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Preview(device = Devices.FOLDABLE) @Composable @@ -68,7 +101,13 @@ fun ArticlesLayoutPreview() { Text("List here!") }, listPane = { - Text("Index list here...") + Surface( + Modifier + .background(Color.Cyan) + .fillMaxSize() + ) { + Text("Index list here...") + } }, detailPane = { Text("Detail!") diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScreen.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScreen.kt index 768e1fda..f42e5890 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScreen.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable +import com.jocmp.basil.ArticleFilter import com.jocmp.basilreader.ui.accounts.AccountViewModel import com.jocmp.basilreader.ui.components.EmptyView import kotlinx.coroutines.launch @@ -23,7 +24,10 @@ fun ArticleScreen( val drawerState = rememberDrawerState(DrawerValue.Closed) val coroutineScope = rememberCoroutineScope() val (destination, setDestination) = rememberSaveable { mutableStateOf(ListDetailPaneScaffoldRole.List) } - val scaffoldState = calculateListDetailPaneScaffoldState(currentPaneDestination = destination) + val scaffoldState = calculateListDetailPaneScaffoldState( + currentPaneDestination = destination, + scaffoldDirective = calculateArticleDirective() + ) val navigateToDetail = { setDestination(ListDetailPaneScaffoldRole.Detail) @@ -55,6 +59,8 @@ fun ArticleScreen( onRefresh = { viewModel.refreshFeed() }, + selectedStatus = viewModel.filterStatus, + onStatusSelect = { viewModel.selectStatus(it) }, onSelect = { viewModel.selectArticle(it) navigateToDetail() diff --git a/app/src/main/res/drawable/notes.xml b/app/src/main/res/drawable/notes.xml new file mode 100644 index 00000000..d9cdd33c --- /dev/null +++ b/app/src/main/res/drawable/notes.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/unread.xml b/app/src/main/res/drawable/unread.xml new file mode 100644 index 00000000..e7b84944 --- /dev/null +++ b/app/src/main/res/drawable/unread.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c10d3b1c..355c8669 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,4 +6,7 @@ Add Cancel Required + Starred + Unread + All diff --git a/basil/src/main/java/com/jocmp/basil/ArticleFilter.kt b/basil/src/main/java/com/jocmp/basil/ArticleFilter.kt index 91317036..ac5d9af1 100644 --- a/basil/src/main/java/com/jocmp/basil/ArticleFilter.kt +++ b/basil/src/main/java/com/jocmp/basil/ArticleFilter.kt @@ -3,10 +3,18 @@ package com.jocmp.basil sealed class ArticleFilter(open val status: Status) { enum class Status(value: String) { ALL("all"), - READ("read"), + UNREAD("unread"), STARRED("starred") } + fun withStatus(status: Status): ArticleFilter { + return when (this) { + is Articles -> copy(status = status) + is Feeds -> copy(status = status) + is Folders -> copy(status = status) + } + } + data class Articles(override val status: Status) : ArticleFilter(status) data class Feeds(val feed: Feed, override val status: Status) : ArticleFilter(status) @@ -14,6 +22,6 @@ sealed class ArticleFilter(open val status: Status) { data class Folders(val folder: Folder, override val status: Status) : ArticleFilter(status) companion object { - fun default() = ArticleFilter.Articles(status = Status.ALL) + fun default() = Articles(status = Status.ALL) } } diff --git a/basil/src/test/java/com/jocmp/basil/ArticleFilterTest.kt b/basil/src/test/java/com/jocmp/basil/ArticleFilterTest.kt new file mode 100644 index 00000000..14b1ea43 --- /dev/null +++ b/basil/src/test/java/com/jocmp/basil/ArticleFilterTest.kt @@ -0,0 +1,17 @@ +package com.jocmp.basil + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class ArticleFilterTest { + @Test + fun withStatus_copiesExistingFilter() { + val articles = ArticleFilter.default() + + val nextFilter = articles.withStatus(status = ArticleFilter.Status.STARRED) + + assertNotEquals(articles.status, nextFilter.status) + assertEquals(expected = ArticleFilter.Status.STARRED, actual = nextFilter.status) + } +}