From db2b08953388a2ad9a116b8b7b87757a7be1b03d Mon Sep 17 00:00:00 2001 From: Shinokuni Date: Sun, 20 Aug 2023 17:31:35 +0200 Subject: [PATCH] Display folders and feeds in Drawer --- .../readrops/app/compose/ComposeAppModule.kt | 5 +- .../app/compose/timelime/TimelineTab.kt | 14 +- .../app/compose/timelime/TimelineViewModel.kt | 45 +++++- .../compose/timelime/drawer/DrawerFeedItem.kt | 59 ++++++++ .../timelime/drawer/DrawerFolderItem.kt | 140 ++++++++++++++++++ .../timelime/{ => drawer}/TimelineDrawer.kt | 77 +++++++++- .../app/compose/util/theme/Spacing.kt | 5 +- 7 files changed, 331 insertions(+), 14 deletions(-) create mode 100644 appcompose/src/main/java/com/readrops/app/compose/timelime/drawer/DrawerFeedItem.kt create mode 100644 appcompose/src/main/java/com/readrops/app/compose/timelime/drawer/DrawerFolderItem.kt rename appcompose/src/main/java/com/readrops/app/compose/timelime/{ => drawer}/TimelineDrawer.kt (52%) diff --git a/appcompose/src/main/java/com/readrops/app/compose/ComposeAppModule.kt b/appcompose/src/main/java/com/readrops/app/compose/ComposeAppModule.kt index 4741a29e..b51dba3a 100644 --- a/appcompose/src/main/java/com/readrops/app/compose/ComposeAppModule.kt +++ b/appcompose/src/main/java/com/readrops/app/compose/ComposeAppModule.kt @@ -4,6 +4,7 @@ import com.readrops.app.compose.account.AccountViewModel import com.readrops.app.compose.account.selection.AccountSelectionViewModel import com.readrops.app.compose.feeds.FeedViewModel import com.readrops.app.compose.repositories.BaseRepository +import com.readrops.app.compose.repositories.GetFoldersWithFeeds import com.readrops.app.compose.repositories.LocalRSSRepository import com.readrops.app.compose.timelime.TimelineViewModel import com.readrops.db.entities.account.Account @@ -12,7 +13,7 @@ import org.koin.dsl.module val composeAppModule = module { - viewModel { TimelineViewModel(get()) } + viewModel { TimelineViewModel(get(), get()) } viewModel { FeedViewModel(get()) } @@ -20,6 +21,8 @@ val composeAppModule = module { viewModel { AccountViewModel(get()) } + single { GetFoldersWithFeeds(get()) } + // repositories factory { (account: Account) -> diff --git a/appcompose/src/main/java/com/readrops/app/compose/timelime/TimelineTab.kt b/appcompose/src/main/java/com/readrops/app/compose/timelime/TimelineTab.kt index 65ffe134..51418e29 100644 --- a/appcompose/src/main/java/com/readrops/app/compose/timelime/TimelineTab.kt +++ b/appcompose/src/main/java/com/readrops/app/compose/timelime/TimelineTab.kt @@ -39,6 +39,7 @@ import com.google.accompanist.swiperefresh.SwipeRefresh import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import com.readrops.app.compose.R import com.readrops.app.compose.item.ItemScreen +import com.readrops.app.compose.timelime.drawer.TimelineDrawer import com.readrops.app.compose.util.theme.spacing import kotlinx.coroutines.launch import org.koin.androidx.compose.getViewModel @@ -78,6 +79,7 @@ object TimelineTab : Tab { } ) + val closeDrawer = { scope.launch { drawerState.close() } } ModalNavigationDrawer( drawerState = drawerState, drawerContent = { @@ -85,9 +87,15 @@ object TimelineTab : Tab { viewModel = viewModel, onClickDefaultItem = { viewModel.updateDrawerDefaultItem(it) - scope.launch { - drawerState.close() - } + closeDrawer() + }, + onFolderClick = { + viewModel.updateDrawerFolderSelection(it) + closeDrawer() + }, + onFeedClick = { + viewModel.updateDrawerFeedSelection(it) + closeDrawer() } ) } diff --git a/appcompose/src/main/java/com/readrops/app/compose/timelime/TimelineViewModel.kt b/appcompose/src/main/java/com/readrops/app/compose/timelime/TimelineViewModel.kt index 917505d2..2bb97d8f 100644 --- a/appcompose/src/main/java/com/readrops/app/compose/timelime/TimelineViewModel.kt +++ b/appcompose/src/main/java/com/readrops/app/compose/timelime/TimelineViewModel.kt @@ -3,6 +3,8 @@ package com.readrops.app.compose.timelime import androidx.compose.runtime.Immutable import androidx.lifecycle.viewModelScope import com.readrops.app.compose.base.TabViewModel +import com.readrops.app.compose.repositories.GetFoldersWithFeeds +import com.readrops.app.compose.timelime.drawer.DrawerDefaultItemsSelection import com.readrops.db.Database import com.readrops.db.entities.Feed import com.readrops.db.entities.Folder @@ -11,6 +13,8 @@ import com.readrops.db.queries.ItemsQueryBuilder import com.readrops.db.queries.QueryFilters import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch @@ -21,6 +25,7 @@ import kotlinx.coroutines.launch class TimelineViewModel( private val database: Database, + private val getFoldersWithFeeds: GetFoldersWithFeeds, private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) : TabViewModel(database) { @@ -38,11 +43,23 @@ class TimelineViewModel( accountEvent.consumeAsFlow().collectLatest { account -> val query = ItemsQueryBuilder.buildItemsQuery(QueryFilters(accountId = account.id)) - database.newItemDao().selectAll(query) - .catch { _timelineState.value = TimelineState.Error(Exception(it)) } - .collect { - _timelineState.value = TimelineState.Loaded(it) + val items = async { + database.newItemDao().selectAll(query) + .catch { _timelineState.value = TimelineState.Error(Exception(it)) } + .collect { + _timelineState.value = TimelineState.Loaded(it) + } + } + + val drawer = async { + _drawerState.update { + it.copy( + foldersAndFeeds = getFoldersWithFeeds.get(account.id) + ) } + } + + awaitAll(items, drawer) } } } @@ -59,7 +76,21 @@ class TimelineViewModel( } fun updateDrawerDefaultItem(selection: DrawerDefaultItemsSelection) { - _drawerState.update { it.copy(selection = selection) } + _drawerState.update { + it.copy( + selection = selection, + selectedFolderId = 0, + selectedFeedId = 0, + ) + } + } + + fun updateDrawerFolderSelection(folderId: Int) { + _drawerState.update { it.copy(selectedFolderId = folderId, selectedFeedId = 0) } + } + + fun updateDrawerFeedSelection(feedId: Int) { + _drawerState.update { it.copy(selectedFeedId = feedId, selectedFolderId = 0) } } } @@ -76,8 +107,8 @@ sealed class TimelineState { @Immutable data class DrawerState( val selection: DrawerDefaultItemsSelection = DrawerDefaultItemsSelection.ARTICLES, - val folderSelection: Int = 0, - val feedSelection: Int = 0, + val selectedFolderId: Int = 0, + val selectedFeedId: Int = 0, val foldersAndFeeds: Map> = emptyMap() ) diff --git a/appcompose/src/main/java/com/readrops/app/compose/timelime/drawer/DrawerFeedItem.kt b/appcompose/src/main/java/com/readrops/app/compose/timelime/drawer/DrawerFeedItem.kt new file mode 100644 index 00000000..5c937c63 --- /dev/null +++ b/appcompose/src/main/java/com/readrops/app/compose/timelime/drawer/DrawerFeedItem.kt @@ -0,0 +1,59 @@ +package com.readrops.app.compose.timelime.drawer + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.readrops.app.compose.util.theme.DrawerSpacing + +@Composable +fun DrawerFeedItem( + label: @Composable () -> Unit, + icon: @Composable () -> Unit, + badge: @Composable () -> Unit, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val colors = NavigationDrawerItemDefaults.colors() + + Surface( + selected = selected, + onClick = onClick, + color = colors.containerColor(selected = selected).value, + shape = CircleShape, + modifier = modifier + .height(36.dp) + .fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp, end = 24.dp) + ) { + val iconColor = colors.iconColor(selected).value + CompositionLocalProvider(LocalContentColor provides iconColor, content = icon) + + DrawerSpacing() + + Box(Modifier.weight(1f)) { + val labelColor = colors.textColor(selected).value + CompositionLocalProvider(LocalContentColor provides labelColor, content = label) + } + + DrawerSpacing() + + val badgeColor = colors.badgeColor(selected).value + CompositionLocalProvider(LocalContentColor provides badgeColor, content = badge) + } + } +} \ No newline at end of file diff --git a/appcompose/src/main/java/com/readrops/app/compose/timelime/drawer/DrawerFolderItem.kt b/appcompose/src/main/java/com/readrops/app/compose/timelime/drawer/DrawerFolderItem.kt new file mode 100644 index 00000000..ec4f101e --- /dev/null +++ b/appcompose/src/main/java/com/readrops/app/compose/timelime/drawer/DrawerFolderItem.kt @@ -0,0 +1,140 @@ +package com.readrops.app.compose.timelime.drawer + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.readrops.app.compose.R +import com.readrops.app.compose.util.theme.DrawerSpacing +import com.readrops.db.entities.Feed + +@Composable +fun DrawerFolderItem( + label: @Composable () -> Unit, + icon: @Composable () -> Unit, + badge: @Composable () -> Unit, + selected: Boolean, + onClick: () -> Unit, + feeds: List, + selectedFeed: Int, + onFeedClick: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val colors = NavigationDrawerItemDefaults.colors() + + var isExpanded by remember { mutableStateOf(false) } + val rotationState by animateFloatAsState( + targetValue = if (isExpanded) 180f else 0f, + label = "drawer item arrow rotation" + ) + + Column( + modifier = Modifier.animateContentSize( + animationSpec = tween( + durationMillis = 300, + easing = LinearOutSlowInEasing, + ) + ) + ) { + Surface( + selected = selected, + onClick = onClick, + color = colors.containerColor(selected = selected).value, + shape = CircleShape, + modifier = modifier + .height(56.dp) + .fillMaxWidth() + .animateContentSize( + animationSpec = tween( + durationMillis = 300, + easing = LinearOutSlowInEasing, + ) + ) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp, end = 24.dp) + ) { + val iconColor = colors.iconColor(selected).value + CompositionLocalProvider(LocalContentColor provides iconColor, content = icon) + + DrawerSpacing() + + Box(Modifier.weight(1f)) { + val labelColor = colors.textColor(selected).value + CompositionLocalProvider(LocalContentColor provides labelColor, content = label) + } + + DrawerSpacing() + + val badgeColor = colors.badgeColor(selected).value + CompositionLocalProvider(LocalContentColor provides badgeColor, content = badge) + + DrawerSpacing() + + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + modifier = Modifier + .clickable { isExpanded = isExpanded.not() } + .rotate(rotationState), + ) + } + } + + if (isExpanded && feeds.isNotEmpty()) { + for (feed in feeds) { + DrawerFeedItem( + label = { + Text( + text = feed.name!!, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + icon = { + AsyncImage( + model = feed.iconUrl, + contentDescription = feed.name, + placeholder = painterResource(id = R.drawable.ic_folder_grey), + modifier = Modifier.size(24.dp) + ) + }, + badge = { Text(feed.unreadCount.toString()) }, + selected = feed.id == selectedFeed, + onClick = { onFeedClick(feed.id) }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) + ) + } + } + } +} \ No newline at end of file diff --git a/appcompose/src/main/java/com/readrops/app/compose/timelime/TimelineDrawer.kt b/appcompose/src/main/java/com/readrops/app/compose/timelime/drawer/TimelineDrawer.kt similarity index 52% rename from appcompose/src/main/java/com/readrops/app/compose/timelime/TimelineDrawer.kt rename to appcompose/src/main/java/com/readrops/app/compose/timelime/drawer/TimelineDrawer.kt index 7889a5a9..6c7e73fa 100644 --- a/appcompose/src/main/java/com/readrops/app/compose/timelime/TimelineDrawer.kt +++ b/appcompose/src/main/java/com/readrops/app/compose/timelime/drawer/TimelineDrawer.kt @@ -1,8 +1,12 @@ -package com.readrops.app.compose.timelime +package com.readrops.app.compose.timelime.drawer +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Star import androidx.compose.material3.Divider @@ -17,8 +21,11 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage import com.readrops.app.compose.R +import com.readrops.app.compose.timelime.TimelineViewModel import com.readrops.app.compose.util.theme.spacing enum class DrawerDefaultItemsSelection { @@ -32,10 +39,17 @@ enum class DrawerDefaultItemsSelection { fun TimelineDrawer( viewModel: TimelineViewModel, onClickDefaultItem: (DrawerDefaultItemsSelection) -> Unit, + onFolderClick: (Int) -> Unit, + onFeedClick: (Int) -> Unit, ) { val state by viewModel.drawerState.collectAsState() + val scrollState = rememberScrollState() - ModalDrawerSheet { + ModalDrawerSheet( + modifier = Modifier + .fillMaxHeight() + .verticalScroll(scrollState) + ) { Spacer(modifier = Modifier.size(MaterialTheme.spacing.drawerSpacing)) DrawerDefaultItems( @@ -44,6 +58,65 @@ fun TimelineDrawer( ) DrawerDivider() + + Column { + for (folderEntry in state.foldersAndFeeds) { + val folder = folderEntry.key + + if (folder != null) { + DrawerFolderItem( + label = { + Text( + text = folder.name!!, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + icon = { + Icon( + painterResource(id = R.drawable.ic_folder_grey), + contentDescription = null + ) + }, + badge = { + Text(folderEntry.value.sumOf { it.unreadCount }.toString()) + }, + selected = state.selectedFolderId == folder.id, + onClick = { onFolderClick(folder.id) }, + feeds = folderEntry.value, + selectedFeed = state.selectedFeedId, + onFeedClick = { onFeedClick(it) }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) + ) + } else { + val feeds = folderEntry.value + + for (feed in feeds) { + DrawerFeedItem( + label = { + Text( + text = feed.name!!, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + icon = { + AsyncImage( + model = feed.iconUrl, + contentDescription = feed.name, + placeholder = painterResource(id = R.drawable.ic_folder_grey), + modifier = Modifier.size(24.dp) + ) + }, + badge = { Text(feed.unreadCount.toString()) }, + selected = feed.id == state.selectedFeedId, + onClick = { onFeedClick(feed.id) }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) + ) + } + } + } + } } } diff --git a/appcompose/src/main/java/com/readrops/app/compose/util/theme/Spacing.kt b/appcompose/src/main/java/com/readrops/app/compose/util/theme/Spacing.kt index 67f2e448..f83def6e 100644 --- a/appcompose/src/main/java/com/readrops/app/compose/util/theme/Spacing.kt +++ b/appcompose/src/main/java/com/readrops/app/compose/util/theme/Spacing.kt @@ -39,4 +39,7 @@ fun MediumSpacer() = Spacer(Modifier.size(MaterialTheme.spacing.mediumSpacing)) fun LargeSpacer() = Spacer(Modifier.size(MaterialTheme.spacing.largeSpacing)) @Composable -fun VeryLargeSpacer() = Spacer(Modifier.size(MaterialTheme.spacing.veryLargeSpacing)) \ No newline at end of file +fun VeryLargeSpacer() = Spacer(Modifier.size(MaterialTheme.spacing.veryLargeSpacing)) + +@Composable +fun DrawerSpacing() = Spacer(Modifier.size(MaterialTheme.spacing.drawerSpacing)) \ No newline at end of file