diff --git a/uicomponent-compose/core/src/main/java/io/github/droidkaigi/feeder/core/UnidirectionalViewModel.kt b/uicomponent-compose/core/src/main/java/io/github/droidkaigi/feeder/core/UnidirectionalViewModel.kt index 07202c882..1016ea60f 100644 --- a/uicomponent-compose/core/src/main/java/io/github/droidkaigi/feeder/core/UnidirectionalViewModel.kt +++ b/uicomponent-compose/core/src/main/java/io/github/droidkaigi/feeder/core/UnidirectionalViewModel.kt @@ -28,6 +28,18 @@ inline fun use( ) } +@Composable +inline fun UnidirectionalViewModel<*, *, STATE>.state(): STATE { + val state by state.collectAsState() + return state +} + +inline fun UnidirectionalViewModel.dispatcher(): (EVENT) -> Unit { + return { event -> + event(event) + } +} + interface UnidirectionalViewModel { val state: StateFlow val effect: Flow diff --git a/uicomponent-compose/feed/src/main/java/io/github/droidkaigi/feeder/feed/FeedScreen.kt b/uicomponent-compose/feed/src/main/java/io/github/droidkaigi/feeder/feed/FeedScreen.kt index 0d7aa5dda..a35eb7a90 100644 --- a/uicomponent-compose/feed/src/main/java/io/github/droidkaigi/feeder/feed/FeedScreen.kt +++ b/uicomponent-compose/feed/src/main/java/io/github/droidkaigi/feeder/feed/FeedScreen.kt @@ -11,10 +11,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.BackdropScaffold import androidx.compose.material.BackdropScaffoldState -import androidx.compose.material.BackdropValue import androidx.compose.material.Divider import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon @@ -22,25 +20,20 @@ import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.ScrollableTabRow import androidx.compose.material.SnackbarHost -import androidx.compose.material.SnackbarResult import androidx.compose.material.Surface import androidx.compose.material.Tab import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.material.primarySurface -import androidx.compose.material.rememberBackdropScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf 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.graphics.Color import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.semantics @@ -64,10 +57,8 @@ import io.github.droidkaigi.feeder.Filters import io.github.droidkaigi.feeder.Theme import io.github.droidkaigi.feeder.core.R as CoreR import io.github.droidkaigi.feeder.core.TabIndicator -import io.github.droidkaigi.feeder.core.getReadableMessage import io.github.droidkaigi.feeder.core.theme.AppThemeWithBackground import io.github.droidkaigi.feeder.core.theme.greenDroid -import io.github.droidkaigi.feeder.core.use import io.github.droidkaigi.feeder.core.util.collectInLaunchedEffect import kotlin.reflect.KClass import kotlinx.coroutines.launch @@ -97,77 +88,39 @@ sealed class FeedTab(val name: String, val routePath: String) { @OptIn(ExperimentalPagerApi::class, ExperimentalMaterialApi::class) @Composable fun FeedScreen( - selectedTab: FeedTab, + feedScreenState: FeedScreenState = rememberFeedScreenState(), + pagerState: PagerState, onSelectedTab: (FeedTab) -> Unit, onNavigationIconClick: () -> Unit, onDroidKaigi2021ArticleClick: () -> Unit, - isDroidKaigiEnd: MutableState, + isDroidKaigiEnd: Boolean, onDetailClick: (FeedItem) -> Unit, ) { - val scaffoldState = rememberBackdropScaffoldState(BackdropValue.Concealed) - val pagerState = rememberPagerState( - initialPage = FeedTab.values().indexOf(selectedTab) - ) - - val ( - state, - effectFlow, - dispatch, - ) = use(feedViewModel()) - - val ( - fmPlayerState, - fmPlayerEffectFlow, - fmPlayerDispatch, - ) = use(fmPlayerViewModel()) - val context = LocalContext.current - - effectFlow.collectInLaunchedEffect { effect -> + feedScreenState.effect.collectInLaunchedEffect { effect -> when (effect) { is FeedViewModel.Effect.ErrorMessage -> { - when ( - scaffoldState.snackbarHostState.showSnackbar( - message = effect.appError.getReadableMessage(context), - actionLabel = "Reload", - ) - ) { - SnackbarResult.ActionPerformed -> { - dispatch(FeedViewModel.Event.ReloadContent) - } - SnackbarResult.Dismissed -> { - } - } + feedScreenState.onErrorMessage(effect) } } } - val tabLazyListStates = FeedTab.values() - .map { it to rememberLazyListState() } - .toMap() + val uiState = feedScreenState.uiState FeedScreen( - scaffoldState = scaffoldState, + scaffoldState = feedScreenState.scaffoldState, pagerState = pagerState, - tabLazyListStates = tabLazyListStates, - feedContents = state.filteredFeedContents, - fmPlayerState = fmPlayerState, - filters = state.filters, + tabLazyListStates = feedScreenState.tabLazyListStates, + feedContents = uiState.filteredFeedContents, + fmPlayerState = feedScreenState.fmPlayerUiState, + filters = uiState.filters, onSelectTab = onSelectedTab, onNavigationIconClick = onNavigationIconClick, - onFavoriteChange = { - dispatch(FeedViewModel.Event.ToggleFavorite(feedItem = it)) - }, + onFavoriteChange = feedScreenState::onFavoriteChange, onFavoriteFilterChanged = { - dispatch( - FeedViewModel.Event.ChangeFavoriteFilter( - filters = state.filters.copy(filterFavorite = it) - ) - ) + feedScreenState.onFavoriteFilterChange(uiState.filters, it) }, onClickFeed = onDetailClick, - onClickPlayPodcastButton = { - fmPlayerDispatch(FmPlayerViewModel.Event.ChangePlayerState(it.podcastLink)) - }, + onClickPlayPodcastButton = feedScreenState::onPotcastPlayButtonClick, onClickDroidKaigi2021Article = onDroidKaigi2021ArticleClick, isDroidKaigiEnd = isDroidKaigiEnd ) @@ -192,7 +145,7 @@ private fun FeedScreen( onClickFeed: (FeedItem) -> Unit, onClickPlayPodcastButton: (FeedItem.Podcast) -> Unit, onClickDroidKaigi2021Article: () -> Unit, - isDroidKaigiEnd: MutableState, + isDroidKaigiEnd: Boolean, ) { val density = LocalDensity.current BackdropScaffold( @@ -303,7 +256,7 @@ private fun FeedList( onClickArticleItem: () -> Unit, listState: LazyListState, isFilterState: Boolean, - isDroidKaigiEnd: MutableState, + isDroidKaigiEnd: Boolean, ) { val isHome = feedTab is FeedTab.Home Surface( @@ -332,14 +285,14 @@ private fun FeedList( if (isHome && index == 0) { if (isFilterState) { FilterItemCountRow(feedContents.size.toString()) - } else if (!isDroidKaigiEnd.value) { + } else if (!isDroidKaigiEnd) { DroidKaigi2021ArticleItem( onClick = onClickArticleItem, shouldPadding = isFilterState, ) } } - if (isDroidKaigiEnd.value && isHome && index == 0) { + if (isDroidKaigiEnd && isHome && index == 0) { FirstFeedItem( feedItem = feedItem, favorited = favorited, @@ -477,11 +430,13 @@ fun PreviewFeedScreen() { provideFmPlayerViewModelFactory { fakeFmPlayerViewModel() } ) { FeedScreen( - selectedTab = FeedTab.Home, + pagerState = rememberPagerState( + initialPage = FeedTab.values().indexOf(FeedTab.Home) + ), onSelectedTab = {}, onNavigationIconClick = {}, onDroidKaigi2021ArticleClick = {}, - isDroidKaigiEnd = remember { mutableStateOf(false) }, + isDroidKaigiEnd = false, ) { feedItem: FeedItem -> } } @@ -499,11 +454,13 @@ fun PreviewDarkFeedScreen() { provideFmPlayerViewModelFactory { fakeFmPlayerViewModel() } ) { FeedScreen( - selectedTab = FeedTab.Home, + pagerState = rememberPagerState( + initialPage = FeedTab.values().indexOf(FeedTab.Home) + ), onSelectedTab = {}, onNavigationIconClick = {}, onDroidKaigi2021ArticleClick = {}, - isDroidKaigiEnd = remember { mutableStateOf(false) }, + isDroidKaigiEnd = false, ) { feedItem: FeedItem -> } } @@ -519,11 +476,13 @@ fun PreviewFeedScreenWhenDroidKaigiEnd() { provideFmPlayerViewModelFactory { fakeFmPlayerViewModel() } ) { FeedScreen( - selectedTab = FeedTab.Home, + pagerState = rememberPagerState( + initialPage = FeedTab.values().indexOf(FeedTab.Home) + ), onSelectedTab = {}, onNavigationIconClick = {}, onDroidKaigi2021ArticleClick = {}, - isDroidKaigiEnd = remember { mutableStateOf(true) }, + isDroidKaigiEnd = true, ) { feedItem: FeedItem -> } } @@ -541,11 +500,13 @@ fun PreviewDarkFeedScreenWhenDroidKaigiEnd() { provideFmPlayerViewModelFactory { fakeFmPlayerViewModel() } ) { FeedScreen( - selectedTab = FeedTab.Home, + pagerState = rememberPagerState( + initialPage = FeedTab.values().indexOf(FeedTab.Home) + ), onSelectedTab = {}, onNavigationIconClick = {}, onDroidKaigi2021ArticleClick = {}, - isDroidKaigiEnd = remember { mutableStateOf(true) }, + isDroidKaigiEnd = true, ) { feedItem: FeedItem -> } } @@ -561,11 +522,13 @@ fun PreviewFeedScreenWithStartBlog() { provideFmPlayerViewModelFactory { fakeFmPlayerViewModel() } ) { FeedScreen( - selectedTab = FeedTab.FilteredFeed.Blog, + pagerState = rememberPagerState( + initialPage = FeedTab.values().indexOf(FeedTab.FilteredFeed.Blog) + ), onSelectedTab = {}, onNavigationIconClick = {}, onDroidKaigi2021ArticleClick = {}, - isDroidKaigiEnd = remember { mutableStateOf(false) }, + isDroidKaigiEnd = false, ) { feedItem: FeedItem -> } } diff --git a/uicomponent-compose/feed/src/main/java/io/github/droidkaigi/feeder/feed/FeedScreenState.kt b/uicomponent-compose/feed/src/main/java/io/github/droidkaigi/feeder/feed/FeedScreenState.kt new file mode 100644 index 000000000..c424fd3be --- /dev/null +++ b/uicomponent-compose/feed/src/main/java/io/github/droidkaigi/feeder/feed/FeedScreenState.kt @@ -0,0 +1,82 @@ +package io.github.droidkaigi.feeder.feed + +import android.content.Context +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.BackdropScaffoldState +import androidx.compose.material.BackdropValue +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.SnackbarResult +import androidx.compose.material.rememberBackdropScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import io.github.droidkaigi.feeder.FeedItem +import io.github.droidkaigi.feeder.Filters +import io.github.droidkaigi.feeder.core.dispatcher +import io.github.droidkaigi.feeder.core.getReadableMessage +import io.github.droidkaigi.feeder.core.state +import kotlinx.coroutines.flow.Flow + +@OptIn(ExperimentalMaterialApi::class) +class FeedScreenState( + private val feedViewModel: FeedViewModel, + private val fmPlayerViewModel: FmPlayerViewModel, + val scaffoldState: BackdropScaffoldState, + val context: Context, +) { + val uiState: FeedViewModel.State @Composable get() = feedViewModel.state() + private val dispatcher: (FeedViewModel.Event) -> Unit get() = feedViewModel.dispatcher() + val effect: Flow get() = feedViewModel.effect + + val fmPlayerUiState: FmPlayerViewModel.State @Composable get() = fmPlayerViewModel.state() + private val fmPlayerDispatcher: (FmPlayerViewModel.Event) -> Unit + get() = fmPlayerViewModel.dispatcher() + + val tabLazyListStates + @Composable get() = FeedTab.values() + .map { it to rememberLazyListState() } + .toMap() + + suspend fun onErrorMessage(effect: FeedViewModel.Effect.ErrorMessage) { + when ( + scaffoldState.snackbarHostState.showSnackbar( + message = effect.appError.getReadableMessage(context), + actionLabel = "Reload", + ) + ) { + SnackbarResult.ActionPerformed -> { + dispatcher(FeedViewModel.Event.ReloadContent) + } + SnackbarResult.Dismissed -> { + } + } + } + + fun onFavoriteChange(feedItem: FeedItem) { + dispatcher(FeedViewModel.Event.ToggleFavorite(feedItem = feedItem)) + } + + fun onFavoriteFilterChange(currentFilters: Filters, isFavoriteFiltered: Boolean) { + dispatcher( + FeedViewModel.Event.ChangeFavoriteFilter( + filters = currentFilters.copy(filterFavorite = isFavoriteFiltered) + + ) + ) + } + + fun onPotcastPlayButtonClick(podcast: FeedItem.Podcast) { + fmPlayerDispatcher(FmPlayerViewModel.Event.ChangePlayerState(podcast.podcastLink)) + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun rememberFeedScreenState( + feedViewModel: FeedViewModel = feedViewModel(), + fmPlayerViewModel: FmPlayerViewModel = fmPlayerViewModel(), + scaffoldState: BackdropScaffoldState = rememberBackdropScaffoldState(BackdropValue.Concealed), + context: Context = LocalContext.current, +): FeedScreenState = remember { + FeedScreenState(feedViewModel, fmPlayerViewModel, scaffoldState, context) +} diff --git a/uicomponent-compose/main/build.gradle b/uicomponent-compose/main/build.gradle index e40aa530d..c8bd1c2d5 100644 --- a/uicomponent-compose/main/build.gradle +++ b/uicomponent-compose/main/build.gradle @@ -50,6 +50,8 @@ dependencies { implementation Dep.Accompanist.insets implementation Dep.Accompanist.systemuicontroller + implementation Dep.Accompanist.pager + implementation (Dep.Coroutines.core) { version { strictly Versions.coroutines diff --git a/uicomponent-compose/main/src/main/java/io/github/droidkaigi/feeder/AppContent.kt b/uicomponent-compose/main/src/main/java/io/github/droidkaigi/feeder/AppContent.kt index e00a81c71..e77d49d83 100644 --- a/uicomponent-compose/main/src/main/java/io/github/droidkaigi/feeder/AppContent.kt +++ b/uicomponent-compose/main/src/main/java/io/github/droidkaigi/feeder/AppContent.kt @@ -22,6 +22,8 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navArgument import androidx.navigation.navDeepLink +import com.google.accompanist.pager.ExperimentalPagerApi +import com.google.accompanist.pager.rememberPagerState import com.google.accompanist.systemuicontroller.rememberSystemUiController import io.github.droidkaigi.feeder.core.R as CoreR import io.github.droidkaigi.feeder.core.navigation.chromeCustomTabs @@ -44,6 +46,7 @@ private const val TIMETABLE_DETAIL_PATH = "timetable/detail/" private val drawerOpenedStatusBarColor = Color.Black.copy(alpha = 0.48f) +@OptIn(ExperimentalPagerApi::class) @Composable fun AppContent( modifier: Modifier = Modifier, @@ -104,9 +107,12 @@ fun AppContent( ) val selectedTab = FeedTab.ofRoutePath(routePath.value) drawerContentState.onSelectDrawerContent(selectedTab) + val pagerState = rememberPagerState( + initialPage = FeedTab.values().indexOf(selectedTab) + ) FeedScreen( onNavigationIconClick = onNavigationIconClick, - selectedTab = selectedTab, + pagerState = pagerState, onSelectedTab = { feedTab -> // We don't use navigation component transitions here for animation. routePath.value = feedTab.routePath @@ -118,7 +124,7 @@ fun AppContent( onDroidKaigi2021ArticleClick = { actions.onSelectDrawerItem(DrawerContents.TIMETABLE) }, - isDroidKaigiEnd = remember { mutableStateOf(DroidKaigi2021.isArticleEnd()) }, + isDroidKaigiEnd = DroidKaigi2021.isArticleEnd(), ) } composable(