From 162ab70483f8f94a574c327684b329b32257d7b3 Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Fri, 30 Dec 2022 10:51:17 +0000 Subject: [PATCH 01/13] Add skeleton library module --- app/build.gradle.kts | 1 + app/src/main/java/app/tivi/AppNavigation.kt | 41 +++ app/src/main/java/app/tivi/home/Home.kt | 12 + .../resources/src/main/res/values/strings.xml | 2 + .../observers/ObservePagedLibraryShows.kt | 44 +++ settings.gradle.kts | 1 + ui/library/build.gradle.kts | 64 ++++ .../java/app/tivi/home/library/Library.kt | 303 ++++++++++++++++++ .../app/tivi/home/library/LibraryViewModel.kt | 187 +++++++++++ .../app/tivi/home/library/LibraryViewState.kt | 39 +++ 10 files changed, 694 insertions(+) create mode 100644 domain/src/main/java/app/tivi/domain/observers/ObservePagedLibraryShows.kt create mode 100644 ui/library/build.gradle.kts create mode 100644 ui/library/src/main/java/app/tivi/home/library/Library.kt create mode 100644 ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt create mode 100644 ui/library/src/main/java/app/tivi/home/library/LibraryViewState.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6a6e0dde12..02b0d48caf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -169,6 +169,7 @@ dependencies { implementation(projects.ui.discover) implementation(projects.ui.showdetails) implementation(projects.ui.episodedetails) + implementation(projects.ui.library) implementation(projects.ui.followed) implementation(projects.ui.watched) implementation(projects.ui.popular) diff --git a/app/src/main/java/app/tivi/AppNavigation.kt b/app/src/main/java/app/tivi/AppNavigation.kt index ead57dbbed..e129b93a7b 100644 --- a/app/src/main/java/app/tivi/AppNavigation.kt +++ b/app/src/main/java/app/tivi/AppNavigation.kt @@ -38,6 +38,7 @@ import app.tivi.account.AccountUi import app.tivi.episodedetails.EpisodeDetails import app.tivi.home.discover.Discover import app.tivi.home.followed.Followed +import app.tivi.home.library.Library import app.tivi.home.popular.PopularShows import app.tivi.home.recommended.RecommendedShows import app.tivi.home.search.Search @@ -54,6 +55,7 @@ internal sealed class Screen(val route: String) { object Discover : Screen("discover") object Following : Screen("following") object Watched : Screen("watched") + object Library : Screen("library") object Search : Screen("search") } @@ -65,6 +67,7 @@ private sealed class LeafScreen( object Discover : LeafScreen("discover") object Following : LeafScreen("following") object Trending : LeafScreen("trending") + object Library : LeafScreen("library") object Popular : LeafScreen("popular") object ShowDetails : LeafScreen("show/{showId}") { @@ -114,6 +117,7 @@ internal fun AppNavigation( modifier = modifier, ) { addDiscoverTopLevel(navController, onOpenSettings) + addLibraryTopLevel(navController, onOpenSettings) addFollowingTopLevel(navController, onOpenSettings) addWatchedTopLevel(navController, onOpenSettings) addSearchTopLevel(navController, onOpenSettings) @@ -157,6 +161,23 @@ private fun NavGraphBuilder.addFollowingTopLevel( } } +@ExperimentalAnimationApi +private fun NavGraphBuilder.addLibraryTopLevel( + navController: NavController, + openSettings: () -> Unit, +) { + navigation( + route = Screen.Library.route, + startDestination = LeafScreen.Library.createRoute(Screen.Library), + ) { + addLibrary(navController, Screen.Library) + addAccount(Screen.Following, openSettings) + addShowDetails(navController, Screen.Following) + addShowSeasons(navController, Screen.Following) + addEpisodeDetails(navController, Screen.Following) + } +} + @ExperimentalAnimationApi private fun NavGraphBuilder.addWatchedTopLevel( navController: NavController, @@ -251,6 +272,26 @@ private fun NavGraphBuilder.addFollowedShows( } } +@ExperimentalAnimationApi +private fun NavGraphBuilder.addLibrary( + navController: NavController, + root: Screen, +) { + composable( + route = LeafScreen.Library.createRoute(root), + debugLabel = "Library()", + ) { + Library( + openShowDetails = { showId -> + navController.navigate(LeafScreen.ShowDetails.createRoute(root, showId)) + }, + openUser = { + navController.navigate(LeafScreen.Account.createRoute(root)) + }, + ) + } +} + @ExperimentalAnimationApi private fun NavGraphBuilder.addWatchedShows( navController: NavController, diff --git a/app/src/main/java/app/tivi/home/Home.kt b/app/src/main/java/app/tivi/home/Home.kt index 97d0ea1776..32760f3d2a 100644 --- a/app/src/main/java/app/tivi/home/Home.kt +++ b/app/src/main/java/app/tivi/home/Home.kt @@ -39,8 +39,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Weekend +import androidx.compose.material.icons.outlined.VideoLibrary import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.Weekend import androidx.compose.material3.Divider @@ -204,6 +206,9 @@ private fun NavController.currentScreenAsState(): State { destination.hierarchy.any { it.route == Screen.Discover.route } -> { selectedItem.value = Screen.Discover } + destination.hierarchy.any { it.route == Screen.Library.route } -> { + selectedItem.value = Screen.Library + } destination.hierarchy.any { it.route == Screen.Watched.route } -> { selectedItem.value = Screen.Watched } @@ -328,6 +333,13 @@ private val HomeNavigationItems = listOf( iconImageVector = Icons.Outlined.Weekend, selectedImageVector = Icons.Default.Weekend, ), + HomeNavigationItem.ImageVectorIcon( + screen = Screen.Library, + labelResId = UiR.string.library_title, + contentDescriptionResId = UiR.string.cd_library_title, + iconImageVector = Icons.Outlined.VideoLibrary, + selectedImageVector = Icons.Default.VideoLibrary, + ), HomeNavigationItem.ImageVectorIcon( screen = Screen.Following, labelResId = UiR.string.following_shows_title, diff --git a/common/ui/resources/src/main/res/values/strings.xml b/common/ui/resources/src/main/res/values/strings.xml index 65dcba6661..dd6c89b312 100644 --- a/common/ui/resources/src/main/res/values/strings.xml +++ b/common/ui/resources/src/main/res/values/strings.xml @@ -47,6 +47,8 @@ @string/following_shows_title Watched @string/watched_shows_title + Library + @string/library_title Login Refresh credentials diff --git a/domain/src/main/java/app/tivi/domain/observers/ObservePagedLibraryShows.kt b/domain/src/main/java/app/tivi/domain/observers/ObservePagedLibraryShows.kt new file mode 100644 index 0000000000..3c444c155f --- /dev/null +++ b/domain/src/main/java/app/tivi/domain/observers/ObservePagedLibraryShows.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.tivi.domain.observers + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import app.tivi.data.entities.SortOption +import app.tivi.data.repositories.followedshows.FollowedShowsRepository +import app.tivi.data.resultentities.FollowedShowEntryWithShow +import app.tivi.domain.PagingInteractor +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +class ObservePagedLibraryShows @Inject constructor( + private val followedShowsRepository: FollowedShowsRepository, +) : PagingInteractor() { + + override fun createObservable( + params: Parameters, + ): Flow> = Pager(config = params.pagingConfig) { + followedShowsRepository.observeFollowedShows(params.sort, params.filter) + }.flow + + data class Parameters( + val filter: String? = null, + val sort: SortOption, + override val pagingConfig: PagingConfig, + ) : PagingInteractor.Parameters +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 8d4f26e841..792eccedfd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -63,6 +63,7 @@ include( ":ui:search", ":ui:showseasons", ":ui:settings", + ":ui:library", ":ui:account", ":app", ":benchmark", diff --git a/ui/library/build.gradle.kts b/ui/library/build.gradle.kts new file mode 100644 index 0000000000..f4c74e0419 --- /dev/null +++ b/ui/library/build.gradle.kts @@ -0,0 +1,64 @@ +/* + * Copyright 2018 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.hilt) +} + +android { + namespace = "app.tivi.home.shows" + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.composecompiler.get() + } +} + +dependencies { + implementation(projects.base) + implementation(projects.domain) + implementation(projects.common.ui.compose) + implementation(projects.common.ui.view) + + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + + implementation(libs.androidx.paging.runtime) + implementation(libs.androidx.paging.compose) + + implementation(libs.androidx.core) + + implementation(libs.compose.foundation.foundation) + implementation(libs.compose.foundation.layout) + implementation(libs.compose.material.material) + implementation(libs.compose.material.iconsext) + implementation(libs.compose.material3) + implementation(libs.compose.animation.animation) + implementation(libs.compose.ui.tooling) + + implementation(libs.coil.compose) + + implementation(libs.hilt.compose) + implementation(libs.hilt.library) + kapt(libs.hilt.compiler) +} diff --git a/ui/library/src/main/java/app/tivi/home/library/Library.kt b/ui/library/src/main/java/app/tivi/home/library/Library.kt new file mode 100644 index 0000000000..1b7651ed09 --- /dev/null +++ b/ui/library/src/main/java/app/tivi/home/library/Library.kt @@ -0,0 +1,303 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalMaterialApi::class) + +package app.tivi.home.library + +import androidx.compose.foundation.ExperimentalFoundationApi +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material.DismissValue +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.SwipeToDismiss +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.rememberDismissState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import app.tivi.common.compose.Layout +import app.tivi.common.compose.LocalTiviTextCreator +import app.tivi.common.compose.bodyWidth +import app.tivi.common.compose.fullSpanItem +import app.tivi.common.compose.items +import app.tivi.common.compose.ui.FilterSortPanel +import app.tivi.common.compose.ui.PosterCard +import app.tivi.common.compose.ui.TiviStandardAppBar +import app.tivi.common.compose.ui.plus +import app.tivi.common.ui.resources.R as UiR +import app.tivi.data.entities.ShowTmdbImage +import app.tivi.data.entities.SortOption +import app.tivi.data.entities.TiviShow +import app.tivi.data.resultentities.FollowedShowEntryWithShow +import app.tivi.trakt.TraktAuthState + +@Composable +fun Library( + openShowDetails: (showId: Long) -> Unit, + openUser: () -> Unit, +) { + Library( + viewModel = hiltViewModel(), + openShowDetails = openShowDetails, + openUser = openUser, + ) +} + +@Composable +internal fun Library( + viewModel: LibraryViewModel, + openShowDetails: (showId: Long) -> Unit, + openUser: () -> Unit, +) { + val viewState by viewModel.state.collectAsState() + val pagingItems = viewModel.pagedList.collectAsLazyPagingItems() + + Library( + state = viewState, + list = pagingItems, + openShowDetails = openShowDetails, + onMessageShown = viewModel::clearMessage, + openUser = openUser, + refresh = viewModel::refresh, + onFilterChanged = viewModel::setFilter, + onSortSelected = viewModel::setSort, + ) +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) +@Composable +internal fun Library( + state: LibraryViewState, + list: LazyPagingItems, + openShowDetails: (showId: Long) -> Unit, + onMessageShown: (id: Long) -> Unit, + refresh: () -> Unit, + openUser: () -> Unit, + onFilterChanged: (String) -> Unit, + onSortSelected: (SortOption) -> Unit, +) { + val snackbarHostState = remember { SnackbarHostState() } + + val dismissSnackbarState = rememberDismissState { value -> + when { + value != DismissValue.Default -> { + snackbarHostState.currentSnackbarData?.dismiss() + true + } + + else -> false + } + } + + state.message?.let { message -> + LaunchedEffect(message) { + snackbarHostState.showSnackbar(message.message) + // Notify the view model that the message has been dismissed + onMessageShown(message.id) + } + } + + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + + Scaffold( + topBar = { + TiviStandardAppBar( + title = stringResource(UiR.string.library_title), + loggedIn = state.authState == TraktAuthState.LOGGED_IN, + user = state.user, + scrollBehavior = scrollBehavior, + refreshing = state.isLoading, + onRefreshActionClick = refresh, + onUserActionClick = openUser, + modifier = Modifier + .fillMaxWidth(), + ) + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) { data -> + SwipeToDismiss( + state = dismissSnackbarState, + background = {}, + dismissContent = { Snackbar(snackbarData = data) }, + modifier = Modifier + .padding(horizontal = Layout.bodyMargin) + .fillMaxWidth(), + ) + } + }, + modifier = Modifier.fillMaxSize(), + ) { paddingValues -> + val refreshState = rememberPullRefreshState( + refreshing = state.isLoading, + onRefresh = refresh, + ) + Box(modifier = Modifier.pullRefresh(state = refreshState)) { + val columns = Layout.columns + val bodyMargin = Layout.bodyMargin + val gutter = Layout.gutter + + LazyVerticalGrid( + columns = GridCells.Fixed(columns / 4), + contentPadding = paddingValues + PaddingValues( + horizontal = (bodyMargin - 8.dp).coerceAtLeast(0.dp), + vertical = (gutter - 8.dp).coerceAtLeast(0.dp), + ), + // We minus 8.dp off the grid padding, as we use content padding on the items below + horizontalArrangement = Arrangement.spacedBy((gutter - 8.dp).coerceAtLeast(0.dp)), + verticalArrangement = Arrangement.spacedBy((gutter - 8.dp).coerceAtLeast(0.dp)), + modifier = Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + .bodyWidth() + .fillMaxHeight(), + ) { + fullSpanItem { + FilterSortPanel( + filterHint = stringResource(UiR.string.filter_shows, list.itemCount), + onFilterChanged = onFilterChanged, + sortOptions = state.availableSorts, + currentSortOption = state.sort, + onSortSelected = onSortSelected, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + ) + } + + items( + items = list, + key = { it.show.id }, + ) { entry -> + if (entry != null) { + FollowedShowItem( + show = entry.show, + poster = entry.poster, + watchedEpisodeCount = entry.stats?.watchedEpisodeCount ?: 0, + totalEpisodeCount = entry.stats?.episodeCount ?: 0, + onClick = { openShowDetails(entry.show.id) }, + contentPadding = PaddingValues(8.dp), + modifier = Modifier + .animateItemPlacement() + .fillMaxWidth(), + ) + } + } + } + + PullRefreshIndicator( + refreshing = state.isLoading, + state = refreshState, + modifier = Modifier.align(Alignment.TopCenter).padding(paddingValues), + scale = true, + ) + } + } +} + +@Composable +private fun FollowedShowItem( + show: TiviShow, + poster: ShowTmdbImage?, + watchedEpisodeCount: Int, + totalEpisodeCount: Int, + onClick: () -> Unit, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + Row( + modifier + .clip(MaterialTheme.shapes.medium) + .clickable(onClick = onClick) + .padding(contentPadding), + ) { + PosterCard( + show = show, + poster = poster, + modifier = Modifier + .fillMaxWidth(0.2f) // 20% of the width + .aspectRatio(2 / 3f), + ) + + Spacer(Modifier.width(16.dp)) + + Column { + val textCreator = LocalTiviTextCreator.current + + Text( + text = textCreator.showTitle(show = show).toString(), + style = MaterialTheme.typography.titleMedium, + ) + + Spacer(Modifier.height(4.dp)) + + LinearProgressIndicator( + progress = when { + totalEpisodeCount > 0 -> watchedEpisodeCount / totalEpisodeCount.toFloat() + else -> 0f + }, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(4.dp)) + + Text( + text = textCreator.followedShowEpisodeWatchStatus( + episodeCount = totalEpisodeCount, + watchedEpisodeCount = watchedEpisodeCount, + ).toString(), + style = MaterialTheme.typography.bodySmall, + ) + + Spacer(Modifier.height(8.dp)) + } + } +} diff --git a/ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt b/ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt new file mode 100644 index 0000000000..62435ae538 --- /dev/null +++ b/ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt @@ -0,0 +1,187 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.tivi.home.library + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.cachedIn +import app.tivi.api.UiMessageManager +import app.tivi.data.entities.RefreshType +import app.tivi.data.entities.SortOption +import app.tivi.data.resultentities.FollowedShowEntryWithShow +import app.tivi.domain.executeSync +import app.tivi.domain.interactors.ChangeShowFollowStatus +import app.tivi.domain.interactors.GetTraktAuthState +import app.tivi.domain.interactors.UpdateFollowedShows +import app.tivi.domain.observers.ObservePagedLibraryShows +import app.tivi.domain.observers.ObserveTraktAuthState +import app.tivi.domain.observers.ObserveUserDetails +import app.tivi.extensions.combine +import app.tivi.trakt.TraktAuthState +import app.tivi.util.Logger +import app.tivi.util.ObservableLoadingCounter +import app.tivi.util.collectStatus +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel +internal class LibraryViewModel @Inject constructor( + private val updateFollowedShows: UpdateFollowedShows, + private val observePagedLibraryShows: ObservePagedLibraryShows, + private val observeTraktAuthState: ObserveTraktAuthState, + private val changeShowFollowStatus: ChangeShowFollowStatus, + observeUserDetails: ObserveUserDetails, + private val getTraktAuthState: GetTraktAuthState, + private val logger: Logger, +) : ViewModel() { + private val loadingState = ObservableLoadingCounter() + private val uiMessageManager = UiMessageManager() + + val pagedList: Flow> = + observePagedLibraryShows.flow.cachedIn(viewModelScope) + + private val availableSorts = listOf( + // SortOption.SUPER_SORT, + SortOption.LAST_WATCHED, + SortOption.ALPHABETICAL, + SortOption.DATE_ADDED, + ) + + private val filter = MutableStateFlow(null) + private val sort = MutableStateFlow(SortOption.LAST_WATCHED) + + private val includeWatchedShows = MutableStateFlow(true) + private val includeFollowedShows = MutableStateFlow(true) + + val state: StateFlow = combine( + loadingState.observable, + observeTraktAuthState.flow, + observeUserDetails.flow, + filter, + sort, + uiMessageManager.message, + includeWatchedShows, + includeFollowedShows, + ) { loading, authState, user, filter, sort, message, includeWatchedShows, includeFollowedShows -> + LibraryViewState( + user = user, + authState = authState, + isLoading = loading, + filter = filter, + filterActive = !filter.isNullOrEmpty(), + availableSorts = availableSorts, + sort = sort, + message = message, + watchedShowsIncluded = includeWatchedShows, + followedShowsIncluded = includeFollowedShows, + ) + }.stateIn( + scope = viewModelScope, + started = WhileSubscribed(), + initialValue = LibraryViewState.Empty, + ) + + init { + observeTraktAuthState(Unit) + observeUserDetails(ObserveUserDetails.Params("me")) + + // When the filter and sort options change, update the data source + filter + .onEach { updateDataSource() } + .launchIn(viewModelScope) + + sort + .onEach { updateDataSource() } + .launchIn(viewModelScope) + + includeFollowedShows + .onEach { updateDataSource() } + .launchIn(viewModelScope) + + includeWatchedShows + .onEach { updateDataSource() } + .launchIn(viewModelScope) + + // When the user logs in, refresh... + observeTraktAuthState.flow + .filter { it == TraktAuthState.LOGGED_IN } + .onEach { refresh(false) } + .launchIn(viewModelScope) + } + + private fun updateDataSource() { + observePagedLibraryShows( + ObservePagedLibraryShows.Parameters( + sort = sort.value, + filter = filter.value, + pagingConfig = PAGING_CONFIG, + ), + ) + } + + fun refresh(fromUser: Boolean = true) { + viewModelScope.launch { + if (getTraktAuthState.executeSync() == TraktAuthState.LOGGED_IN) { + refreshFollowed(fromUser) + } + } + } + + fun setFilter(filter: String?) { + viewModelScope.launch { + this@LibraryViewModel.filter.emit(filter) + } + } + + fun setSort(sort: SortOption) { + viewModelScope.launch { + this@LibraryViewModel.sort.emit(sort) + } + } + + private fun refreshFollowed(fromInteraction: Boolean) { + viewModelScope.launch { + updateFollowedShows( + UpdateFollowedShows.Params(fromInteraction, RefreshType.QUICK), + ).collectStatus(loadingState, logger, uiMessageManager) + } + } + + fun clearMessage(id: Long) { + viewModelScope.launch { + uiMessageManager.clearMessage(id) + } + } + + companion object { + private val PAGING_CONFIG = PagingConfig( + pageSize = 16, + initialLoadSize = 32, + ) + } +} diff --git a/ui/library/src/main/java/app/tivi/home/library/LibraryViewState.kt b/ui/library/src/main/java/app/tivi/home/library/LibraryViewState.kt new file mode 100644 index 0000000000..0ae203529d --- /dev/null +++ b/ui/library/src/main/java/app/tivi/home/library/LibraryViewState.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.tivi.home.library + +import app.tivi.api.UiMessage +import app.tivi.data.entities.SortOption +import app.tivi.data.entities.TraktUser +import app.tivi.trakt.TraktAuthState + +internal data class LibraryViewState( + val user: TraktUser? = null, + val authState: TraktAuthState = TraktAuthState.LOGGED_OUT, + val isLoading: Boolean = false, + val filterActive: Boolean = false, + val filter: String? = null, + val availableSorts: List = emptyList(), + val sort: SortOption = SortOption.LAST_WATCHED, + val message: UiMessage? = null, + val followedShowsIncluded: Boolean = false, + val watchedShowsIncluded: Boolean = false, +) { + companion object { + val Empty = LibraryViewState() + } +} From 0e5d2a8a9de2c3b08346c8af7c5bc8872db75955 Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Fri, 30 Dec 2022 11:39:16 +0000 Subject: [PATCH 02/13] Initial working data stream for library --- app/src/main/java/app/tivi/AppNavigation.kt | 8 +- .../main/java/app/tivi/data/DatabaseInject.kt | 3 + .../main/java/app/tivi/data/TiviDatabase.kt | 2 + .../app/tivi/data/daos/LibraryShowsDao.kt | 60 +++++++++++++ .../tivi/data/resultentities/LibraryShow.kt | 86 +++++++++++++++++++ .../observers/ObservePagedLibraryShows.kt | 16 ++-- .../java/app/tivi/home/library/Library.kt | 4 +- .../app/tivi/home/library/LibraryViewModel.kt | 6 +- 8 files changed, 168 insertions(+), 17 deletions(-) create mode 100644 data/src/main/java/app/tivi/data/daos/LibraryShowsDao.kt create mode 100644 data/src/main/java/app/tivi/data/resultentities/LibraryShow.kt diff --git a/app/src/main/java/app/tivi/AppNavigation.kt b/app/src/main/java/app/tivi/AppNavigation.kt index e129b93a7b..e174819f34 100644 --- a/app/src/main/java/app/tivi/AppNavigation.kt +++ b/app/src/main/java/app/tivi/AppNavigation.kt @@ -171,10 +171,10 @@ private fun NavGraphBuilder.addLibraryTopLevel( startDestination = LeafScreen.Library.createRoute(Screen.Library), ) { addLibrary(navController, Screen.Library) - addAccount(Screen.Following, openSettings) - addShowDetails(navController, Screen.Following) - addShowSeasons(navController, Screen.Following) - addEpisodeDetails(navController, Screen.Following) + addAccount(Screen.Library, openSettings) + addShowDetails(navController, Screen.Library) + addShowSeasons(navController, Screen.Library) + addEpisodeDetails(navController, Screen.Library) } } diff --git a/data-android/src/main/java/app/tivi/data/DatabaseInject.kt b/data-android/src/main/java/app/tivi/data/DatabaseInject.kt index b8fdd21130..e16c33764d 100644 --- a/data-android/src/main/java/app/tivi/data/DatabaseInject.kt +++ b/data-android/src/main/java/app/tivi/data/DatabaseInject.kt @@ -88,6 +88,9 @@ object DatabaseDaoModule { @Provides fun provideRecommendedShowsDao(db: TiviDatabase) = db.recommendedShowsDao() + + @Provides + fun provideLibraryShowsDao(db: TiviDatabase) = db.libraryShowsDao() } @InstallIn(SingletonComponent::class) diff --git a/data/src/main/java/app/tivi/data/TiviDatabase.kt b/data/src/main/java/app/tivi/data/TiviDatabase.kt index ebceb18b02..3844c09e24 100644 --- a/data/src/main/java/app/tivi/data/TiviDatabase.kt +++ b/data/src/main/java/app/tivi/data/TiviDatabase.kt @@ -20,6 +20,7 @@ import app.tivi.data.daos.EpisodeWatchEntryDao import app.tivi.data.daos.EpisodesDao import app.tivi.data.daos.FollowedShowsDao import app.tivi.data.daos.LastRequestDao +import app.tivi.data.daos.LibraryShowsDao import app.tivi.data.daos.PopularDao import app.tivi.data.daos.RecommendedDao import app.tivi.data.daos.RelatedShowsDao @@ -46,4 +47,5 @@ interface TiviDatabase { fun episodeWatchesDao(): EpisodeWatchEntryDao fun lastRequestDao(): LastRequestDao fun recommendedShowsDao(): RecommendedDao + fun libraryShowsDao(): LibraryShowsDao } diff --git a/data/src/main/java/app/tivi/data/daos/LibraryShowsDao.kt b/data/src/main/java/app/tivi/data/daos/LibraryShowsDao.kt new file mode 100644 index 0000000000..3a135cc6b7 --- /dev/null +++ b/data/src/main/java/app/tivi/data/daos/LibraryShowsDao.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2017 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.tivi.data.daos + +import androidx.paging.PagingSource +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Transaction +import app.tivi.data.entities.SortOption +import app.tivi.data.entities.TiviShow +import app.tivi.data.resultentities.LibraryShow + +@Dao +abstract class LibraryShowsDao : EntityDao() { + + @Suppress("UNUSED_PARAMETER", "UNUSED_VARIABLE") + fun observeForPaging( + sort: SortOption, + filter: String?, + ): PagingSource { + val filtered = !filter.isNullOrEmpty() + return pagedListLastWatched() + } + + @Transaction + @Query(QUERY_LAST_WATCHED) + internal abstract fun pagedListLastWatched(): PagingSource + + companion object { + private const val QUERY_LAST_WATCHED = """ + SELECT shows.* FROM shows + LEFT JOIN myshows_entries ON shows.id = myshows_entries.show_id + LEFT JOIN watched_entries ON shows.id = watched_entries.show_id + LEFT JOIN seasons AS s ON shows.id = s.show_id + LEFT JOIN episodes AS eps ON eps.season_id = s.id + LEFT JOIN episode_watch_entries as ew ON ew.episode_id = eps.id + WHERE watched_entries.id IS NOT NULL OR myshows_entries.id IS NOT NULL + GROUP BY shows.id + ORDER BY + CASE WHEN MAX(datetime(watched_entries.last_watched)) IS NULL + OR MAX(datetime(ew.watched_at)) > MAX(datetime(watched_entries.last_watched)) + THEN MAX(datetime(ew.watched_at)) ELSE MAX(datetime(watched_entries.last_watched)) END + DESC + """ + } +} diff --git a/data/src/main/java/app/tivi/data/resultentities/LibraryShow.kt b/data/src/main/java/app/tivi/data/resultentities/LibraryShow.kt new file mode 100644 index 0000000000..031c1214d5 --- /dev/null +++ b/data/src/main/java/app/tivi/data/resultentities/LibraryShow.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.tivi.data.resultentities + +import androidx.room.Embedded +import androidx.room.Ignore +import androidx.room.Relation +import app.tivi.data.entities.FollowedShowEntry +import app.tivi.data.entities.ShowTmdbImage +import app.tivi.data.entities.TiviShow +import app.tivi.data.entities.WatchedShowEntry +import app.tivi.data.entities.findHighestRatedBackdrop +import app.tivi.data.entities.findHighestRatedPoster +import app.tivi.data.views.FollowedShowsWatchStats +import app.tivi.extensions.unsafeLazy + +@Suppress("PropertyName") +class LibraryShow { + @Embedded + lateinit var show: TiviShow + + @Relation(parentColumn = "id", entityColumn = "show_id") + lateinit var _followedEntities: List + + @Relation(parentColumn = "id", entityColumn = "show_id") + lateinit var _watchedEntities: List + + @Relation(parentColumn = "id", entityColumn = "show_id") + lateinit var images: List + + @Relation(parentColumn = "id", entityColumn = "id") + lateinit var _stats: List + + @get:Ignore + val followedEntity: FollowedShowEntry? get() = _followedEntities.firstOrNull() + + @get:Ignore + val watchedEntry: WatchedShowEntry? get() = _watchedEntities.firstOrNull() + + @get:Ignore + val stats: FollowedShowsWatchStats? get() = _stats.firstOrNull() + + @delegate:Ignore + val backdrop: ShowTmdbImage? by unsafeLazy { images.findHighestRatedBackdrop() } + + @delegate:Ignore + val poster: ShowTmdbImage? by unsafeLazy { images.findHighestRatedPoster() } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as LibraryShow + + if (show != other.show) return false + if (_followedEntities != other._followedEntities) return false + if (_watchedEntities != other._watchedEntities) return false + if (images != other.images) return false + if (_stats != other._stats) return false + + return true + } + + override fun hashCode(): Int { + var result = show.hashCode() + result = 31 * result + _followedEntities.hashCode() + result = 31 * result + _watchedEntities.hashCode() + result = 31 * result + images.hashCode() + result = 31 * result + _stats.hashCode() + return result + } +} diff --git a/domain/src/main/java/app/tivi/domain/observers/ObservePagedLibraryShows.kt b/domain/src/main/java/app/tivi/domain/observers/ObservePagedLibraryShows.kt index 3c444c155f..bcdd2c18b3 100644 --- a/domain/src/main/java/app/tivi/domain/observers/ObservePagedLibraryShows.kt +++ b/domain/src/main/java/app/tivi/domain/observers/ObservePagedLibraryShows.kt @@ -1,5 +1,5 @@ /* - * Copyright 2019 Google LLC + * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,26 +19,26 @@ package app.tivi.domain.observers import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData +import app.tivi.data.daos.LibraryShowsDao import app.tivi.data.entities.SortOption -import app.tivi.data.repositories.followedshows.FollowedShowsRepository -import app.tivi.data.resultentities.FollowedShowEntryWithShow +import app.tivi.data.resultentities.LibraryShow import app.tivi.domain.PagingInteractor import javax.inject.Inject import kotlinx.coroutines.flow.Flow class ObservePagedLibraryShows @Inject constructor( - private val followedShowsRepository: FollowedShowsRepository, -) : PagingInteractor() { + private val libraryShowsDao: LibraryShowsDao, +) : PagingInteractor() { override fun createObservable( params: Parameters, - ): Flow> = Pager(config = params.pagingConfig) { - followedShowsRepository.observeFollowedShows(params.sort, params.filter) + ): Flow> = Pager(config = params.pagingConfig) { + libraryShowsDao.observeForPaging(params.sort, params.filter) }.flow data class Parameters( val filter: String? = null, val sort: SortOption, override val pagingConfig: PagingConfig, - ) : PagingInteractor.Parameters + ) : PagingInteractor.Parameters } diff --git a/ui/library/src/main/java/app/tivi/home/library/Library.kt b/ui/library/src/main/java/app/tivi/home/library/Library.kt index 1b7651ed09..8f7cb0eac2 100644 --- a/ui/library/src/main/java/app/tivi/home/library/Library.kt +++ b/ui/library/src/main/java/app/tivi/home/library/Library.kt @@ -78,7 +78,7 @@ import app.tivi.common.ui.resources.R as UiR import app.tivi.data.entities.ShowTmdbImage import app.tivi.data.entities.SortOption import app.tivi.data.entities.TiviShow -import app.tivi.data.resultentities.FollowedShowEntryWithShow +import app.tivi.data.resultentities.LibraryShow import app.tivi.trakt.TraktAuthState @Composable @@ -118,7 +118,7 @@ internal fun Library( @Composable internal fun Library( state: LibraryViewState, - list: LazyPagingItems, + list: LazyPagingItems, openShowDetails: (showId: Long) -> Unit, onMessageShown: (id: Long) -> Unit, refresh: () -> Unit, diff --git a/ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt b/ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt index 62435ae538..cc35358fda 100644 --- a/ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt +++ b/ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt @@ -24,7 +24,7 @@ import androidx.paging.cachedIn import app.tivi.api.UiMessageManager import app.tivi.data.entities.RefreshType import app.tivi.data.entities.SortOption -import app.tivi.data.resultentities.FollowedShowEntryWithShow +import app.tivi.data.resultentities.LibraryShow import app.tivi.domain.executeSync import app.tivi.domain.interactors.ChangeShowFollowStatus import app.tivi.domain.interactors.GetTraktAuthState @@ -53,7 +53,7 @@ import kotlinx.coroutines.launch internal class LibraryViewModel @Inject constructor( private val updateFollowedShows: UpdateFollowedShows, private val observePagedLibraryShows: ObservePagedLibraryShows, - private val observeTraktAuthState: ObserveTraktAuthState, + observeTraktAuthState: ObserveTraktAuthState, private val changeShowFollowStatus: ChangeShowFollowStatus, observeUserDetails: ObserveUserDetails, private val getTraktAuthState: GetTraktAuthState, @@ -62,7 +62,7 @@ internal class LibraryViewModel @Inject constructor( private val loadingState = ObservableLoadingCounter() private val uiMessageManager = UiMessageManager() - val pagedList: Flow> = + val pagedList: Flow> = observePagedLibraryShows.flow.cachedIn(viewModelScope) private val availableSorts = listOf( From 3bd646c2c746ed3cf2712527a50898673869ab14 Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Fri, 30 Dec 2022 11:55:14 +0000 Subject: [PATCH 03/13] Fix view stats --- .../app.tivi.data.TiviRoomDatabase/28.json | 1133 +++++++++++++++++ .../java/app/tivi/data/TiviRoomDatabase.kt | 3 +- .../app/tivi/data/daos/FollowedShowsDao.kt | 4 +- .../tivi/data/resultentities/LibraryShow.kt | 2 +- .../data/views/FollowedShowsWatchStats.kt | 31 +- 5 files changed, 1155 insertions(+), 18 deletions(-) create mode 100644 data-android/schemas/app.tivi.data.TiviRoomDatabase/28.json diff --git a/data-android/schemas/app.tivi.data.TiviRoomDatabase/28.json b/data-android/schemas/app.tivi.data.TiviRoomDatabase/28.json new file mode 100644 index 0000000000..7b658787fb --- /dev/null +++ b/data-android/schemas/app.tivi.data.TiviRoomDatabase/28.json @@ -0,0 +1,1133 @@ +{ + "formatVersion": 1, + "database": { + "version": 28, + "identityHash": "18b19d3e0f1e7d98352f3851dc0505ab", + "entities": [ + { + "tableName": "shows", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT, `original_title` TEXT, `trakt_id` INTEGER, `tmdb_id` INTEGER, `imdb_id` TEXT, `overview` TEXT, `homepage` TEXT, `trakt_rating` REAL, `trakt_votes` INTEGER, `certification` TEXT, `first_aired` TEXT, `country` TEXT, `network` TEXT, `network_logo_path` TEXT, `runtime` INTEGER, `genres` TEXT, `last_trakt_data_update` TEXT, `status` TEXT, `airs_day` INTEGER, `airs_time` TEXT, `airs_tz` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originalTitle", + "columnName": "original_title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "traktId", + "columnName": "trakt_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tmdbId", + "columnName": "tmdb_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imdbId", + "columnName": "imdb_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "summary", + "columnName": "overview", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "homepage", + "columnName": "homepage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "traktRating", + "columnName": "trakt_rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "traktVotes", + "columnName": "trakt_votes", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "certification", + "columnName": "certification", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "firstAired", + "columnName": "first_aired", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "country", + "columnName": "country", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "network", + "columnName": "network", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "networkLogoPath", + "columnName": "network_logo_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "runtime", + "columnName": "runtime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "_genres", + "columnName": "genres", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "traktDataUpdate", + "columnName": "last_trakt_data_update", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "airsDay", + "columnName": "airs_day", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "airsTime", + "columnName": "airs_time", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "airsTimeZone", + "columnName": "airs_tz", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_shows_trakt_id", + "unique": true, + "columnNames": [ + "trakt_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_shows_trakt_id` ON `${TABLE_NAME}` (`trakt_id`)" + }, + { + "name": "index_shows_tmdb_id", + "unique": false, + "columnNames": [ + "tmdb_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shows_tmdb_id` ON `${TABLE_NAME}` (`tmdb_id`)" + } + ], + "foreignKeys": [] + }, + { + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "shows", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_shows_fts_BEFORE_UPDATE BEFORE UPDATE ON `shows` BEGIN DELETE FROM `shows_fts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_shows_fts_BEFORE_DELETE BEFORE DELETE ON `shows` BEGIN DELETE FROM `shows_fts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_shows_fts_AFTER_UPDATE AFTER UPDATE ON `shows` BEGIN INSERT INTO `shows_fts`(`docid`, `title`, `original_title`) VALUES (NEW.`rowid`, NEW.`title`, NEW.`original_title`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_shows_fts_AFTER_INSERT AFTER INSERT ON `shows` BEGIN INSERT INTO `shows_fts`(`docid`, `title`, `original_title`) VALUES (NEW.`rowid`, NEW.`title`, NEW.`original_title`); END" + ], + "tableName": "shows_fts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`title` TEXT, `original_title` TEXT, content=`shows`)", + "fields": [ + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originalTitle", + "columnName": "original_title", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "trending_shows", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `show_id` INTEGER NOT NULL, `page` INTEGER NOT NULL, `watchers` INTEGER NOT NULL, FOREIGN KEY(`show_id`) REFERENCES `shows`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showId", + "columnName": "show_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "page", + "columnName": "page", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "watchers", + "columnName": "watchers", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_trending_shows_show_id", + "unique": true, + "columnNames": [ + "show_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_trending_shows_show_id` ON `${TABLE_NAME}` (`show_id`)" + } + ], + "foreignKeys": [ + { + "table": "shows", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "show_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "popular_shows", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `show_id` INTEGER NOT NULL, `page` INTEGER NOT NULL, `page_order` INTEGER NOT NULL, FOREIGN KEY(`show_id`) REFERENCES `shows`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showId", + "columnName": "show_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "page", + "columnName": "page", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageOrder", + "columnName": "page_order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_popular_shows_show_id", + "unique": true, + "columnNames": [ + "show_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_popular_shows_show_id` ON `${TABLE_NAME}` (`show_id`)" + } + ], + "foreignKeys": [ + { + "table": "shows", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "show_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `username` TEXT NOT NULL, `name` TEXT, `joined_date` TEXT, `location` TEXT, `about` TEXT, `avatar_url` TEXT, `vip` INTEGER, `is_me` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "joined", + "columnName": "joined_date", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "about", + "columnName": "about", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatar_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "vip", + "columnName": "vip", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMe", + "columnName": "is_me", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_users_username", + "unique": true, + "columnNames": [ + "username" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_username` ON `${TABLE_NAME}` (`username`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "watched_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `show_id` INTEGER NOT NULL, `last_watched` TEXT NOT NULL, FOREIGN KEY(`show_id`) REFERENCES `shows`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showId", + "columnName": "show_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastWatched", + "columnName": "last_watched", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_watched_entries_show_id", + "unique": true, + "columnNames": [ + "show_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_watched_entries_show_id` ON `${TABLE_NAME}` (`show_id`)" + } + ], + "foreignKeys": [ + { + "table": "shows", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "show_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "myshows_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `show_id` INTEGER NOT NULL, `followed_at` TEXT, `pending_action` TEXT NOT NULL, `trakt_id` INTEGER, FOREIGN KEY(`show_id`) REFERENCES `shows`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showId", + "columnName": "show_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "followedAt", + "columnName": "followed_at", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pendingAction", + "columnName": "pending_action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "traktId", + "columnName": "trakt_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_myshows_entries_show_id", + "unique": true, + "columnNames": [ + "show_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_myshows_entries_show_id` ON `${TABLE_NAME}` (`show_id`)" + } + ], + "foreignKeys": [ + { + "table": "shows", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "show_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "seasons", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `show_id` INTEGER NOT NULL, `trakt_id` INTEGER, `tmdb_id` INTEGER, `title` TEXT, `overview` TEXT, `number` INTEGER, `network` TEXT, `ep_count` INTEGER, `ep_aired` INTEGER, `trakt_rating` REAL, `trakt_votes` INTEGER, `tmdb_poster_path` TEXT, `tmdb_backdrop_path` TEXT, `ignored` INTEGER NOT NULL, FOREIGN KEY(`show_id`) REFERENCES `shows`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showId", + "columnName": "show_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "traktId", + "columnName": "trakt_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tmdbId", + "columnName": "tmdb_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "summary", + "columnName": "overview", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "network", + "columnName": "network", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "episodeCount", + "columnName": "ep_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "episodesAired", + "columnName": "ep_aired", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "traktRating", + "columnName": "trakt_rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "traktRatingVotes", + "columnName": "trakt_votes", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tmdbPosterPath", + "columnName": "tmdb_poster_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tmdbBackdropPath", + "columnName": "tmdb_backdrop_path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignored", + "columnName": "ignored", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_seasons_trakt_id", + "unique": true, + "columnNames": [ + "trakt_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seasons_trakt_id` ON `${TABLE_NAME}` (`trakt_id`)" + }, + { + "name": "index_seasons_show_id", + "unique": false, + "columnNames": [ + "show_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_seasons_show_id` ON `${TABLE_NAME}` (`show_id`)" + } + ], + "foreignKeys": [ + { + "table": "shows", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "show_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "episodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `season_id` INTEGER NOT NULL, `trakt_id` INTEGER, `tmdb_id` INTEGER, `title` TEXT, `overview` TEXT, `number` INTEGER, `first_aired` TEXT, `trakt_rating` REAL, `trakt_rating_votes` INTEGER, `tmdb_backdrop_path` TEXT, FOREIGN KEY(`season_id`) REFERENCES `seasons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seasonId", + "columnName": "season_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "traktId", + "columnName": "trakt_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tmdbId", + "columnName": "tmdb_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "summary", + "columnName": "overview", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "firstAired", + "columnName": "first_aired", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "traktRating", + "columnName": "trakt_rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "traktRatingVotes", + "columnName": "trakt_rating_votes", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tmdbBackdropPath", + "columnName": "tmdb_backdrop_path", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_episodes_trakt_id", + "unique": true, + "columnNames": [ + "trakt_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_episodes_trakt_id` ON `${TABLE_NAME}` (`trakt_id`)" + }, + { + "name": "index_episodes_season_id", + "unique": false, + "columnNames": [ + "season_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_season_id` ON `${TABLE_NAME}` (`season_id`)" + } + ], + "foreignKeys": [ + { + "table": "seasons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "season_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "related_shows", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `show_id` INTEGER NOT NULL, `other_show_id` INTEGER NOT NULL, `order_index` INTEGER NOT NULL, FOREIGN KEY(`show_id`) REFERENCES `shows`(`id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`other_show_id`) REFERENCES `shows`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showId", + "columnName": "show_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "otherShowId", + "columnName": "other_show_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderIndex", + "columnName": "order_index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_related_shows_show_id", + "unique": false, + "columnNames": [ + "show_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_shows_show_id` ON `${TABLE_NAME}` (`show_id`)" + }, + { + "name": "index_related_shows_other_show_id", + "unique": false, + "columnNames": [ + "other_show_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_shows_other_show_id` ON `${TABLE_NAME}` (`other_show_id`)" + } + ], + "foreignKeys": [ + { + "table": "shows", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "show_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "shows", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "other_show_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "episode_watch_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `episode_id` INTEGER NOT NULL, `trakt_id` INTEGER, `watched_at` TEXT NOT NULL, `pending_action` TEXT NOT NULL, FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "episodeId", + "columnName": "episode_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "traktId", + "columnName": "trakt_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "watchedAt", + "columnName": "watched_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pendingAction", + "columnName": "pending_action", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_episode_watch_entries_episode_id", + "unique": false, + "columnNames": [ + "episode_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_episode_watch_entries_episode_id` ON `${TABLE_NAME}` (`episode_id`)" + }, + { + "name": "index_episode_watch_entries_trakt_id", + "unique": true, + "columnNames": [ + "trakt_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_episode_watch_entries_trakt_id` ON `${TABLE_NAME}` (`trakt_id`)" + } + ], + "foreignKeys": [ + { + "table": "episodes", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "episode_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "last_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `request` TEXT NOT NULL, `entity_id` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "request", + "columnName": "request", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_last_requests_request_entity_id", + "unique": true, + "columnNames": [ + "request", + "entity_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_last_requests_request_entity_id` ON `${TABLE_NAME}` (`request`, `entity_id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "show_images", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `show_id` INTEGER NOT NULL, `path` TEXT NOT NULL, `type` TEXT NOT NULL, `lang` TEXT, `rating` REAL NOT NULL, `is_primary` INTEGER NOT NULL, FOREIGN KEY(`show_id`) REFERENCES `shows`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showId", + "columnName": "show_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "language", + "columnName": "lang", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rating", + "columnName": "rating", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "is_primary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_show_images_show_id", + "unique": false, + "columnNames": [ + "show_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_show_images_show_id` ON `${TABLE_NAME}` (`show_id`)" + } + ], + "foreignKeys": [ + { + "table": "shows", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "show_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "recommended_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `show_id` INTEGER NOT NULL, `page` INTEGER NOT NULL, FOREIGN KEY(`show_id`) REFERENCES `shows`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showId", + "columnName": "show_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "page", + "columnName": "page", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_recommended_entries_show_id", + "unique": true, + "columnNames": [ + "show_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_recommended_entries_show_id` ON `${TABLE_NAME}` (`show_id`)" + } + ], + "foreignKeys": [ + { + "table": "shows", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "show_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "FollowedShowsWatchStats", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT fs.id, fs.show_id, COUNT(*) as episodeCount, COUNT(ew.watched_at) as watchedEpisodeCount\n FROM myshows_entries as fs\n INNER JOIN seasons AS s ON fs.show_id = s.show_id\n INNER JOIN episodes AS eps ON eps.season_id = s.id\n LEFT JOIN episode_watch_entries as ew ON ew.episode_id = eps.id\n WHERE eps.first_aired IS NOT NULL\n AND datetime(eps.first_aired) < datetime('now')\n AND s.number != 0\n AND s.ignored = 0\n GROUP BY fs.id" + }, + { + "viewName": "followed_last_watched_airdate", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT\n fs.id,\n MAX(datetime(eps.first_aired)) as last_watched_air_date\nFROM\n myshows_entries as fs\n INNER JOIN seasons AS s ON fs.show_id = s.show_id\n INNER JOIN episodes AS eps ON eps.season_id = s.id\n INNER JOIN episode_watch_entries as ew ON ew.episode_id = eps.id\nWHERE\n s.number != 0\n AND s.ignored = 0\nGROUP BY\n fs.id" + }, + { + "viewName": "followed_next_to_watch", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT\n fs.id,\n MIN(datetime(eps.first_aired)) AS next_ep_to_watch_air_date\nFROM\n myshows_entries as fs\n INNER JOIN seasons AS s ON fs.show_id = s.show_id\n INNER JOIN episodes AS eps ON eps.season_id = s.id\n LEFT JOIN episode_watch_entries as ew ON ew.episode_id = eps.id\n INNER JOIN followed_last_watched_airdate AS lw ON lw.id = fs.id\nWHERE\n s.number != 0\n AND s.ignored = 0\n AND watched_at IS NULL\n AND datetime(first_aired) < datetime('now')\n AND datetime(first_aired) > datetime(last_watched_air_date)\nGROUP BY\n fs.id" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '18b19d3e0f1e7d98352f3851dc0505ab')" + ] + } +} \ No newline at end of file diff --git a/data-android/src/main/java/app/tivi/data/TiviRoomDatabase.kt b/data-android/src/main/java/app/tivi/data/TiviRoomDatabase.kt index 1137d0c2cf..3fc3941639 100644 --- a/data-android/src/main/java/app/tivi/data/TiviRoomDatabase.kt +++ b/data-android/src/main/java/app/tivi/data/TiviRoomDatabase.kt @@ -60,11 +60,12 @@ import app.tivi.data.views.FollowedShowsWatchStats FollowedShowsLastWatched::class, FollowedShowsNextToWatch::class, ], - version = 27, + version = 28, autoMigrations = [ AutoMigration(from = 24, to = 25), AutoMigration(from = 25, to = 26), AutoMigration(from = 26, to = 27), + AutoMigration(from = 27, to = 28), ], ) @TypeConverters(TiviTypeConverters::class) diff --git a/data/src/main/java/app/tivi/data/daos/FollowedShowsDao.kt b/data/src/main/java/app/tivi/data/daos/FollowedShowsDao.kt index 25940b045e..f0d1fa57f9 100644 --- a/data/src/main/java/app/tivi/data/daos/FollowedShowsDao.kt +++ b/data/src/main/java/app/tivi/data/daos/FollowedShowsDao.kt @@ -98,9 +98,9 @@ abstract class FollowedShowsDao : EntryDao diff --git a/data/src/main/java/app/tivi/data/resultentities/LibraryShow.kt b/data/src/main/java/app/tivi/data/resultentities/LibraryShow.kt index 031c1214d5..d6e1e039a4 100644 --- a/data/src/main/java/app/tivi/data/resultentities/LibraryShow.kt +++ b/data/src/main/java/app/tivi/data/resultentities/LibraryShow.kt @@ -42,7 +42,7 @@ class LibraryShow { @Relation(parentColumn = "id", entityColumn = "show_id") lateinit var images: List - @Relation(parentColumn = "id", entityColumn = "id") + @Relation(parentColumn = "id", entityColumn = "show_id") lateinit var _stats: List @get:Ignore diff --git a/data/src/main/java/app/tivi/data/views/FollowedShowsWatchStats.kt b/data/src/main/java/app/tivi/data/views/FollowedShowsWatchStats.kt index 515112a44e..756e1016d4 100644 --- a/data/src/main/java/app/tivi/data/views/FollowedShowsWatchStats.kt +++ b/data/src/main/java/app/tivi/data/views/FollowedShowsWatchStats.kt @@ -16,25 +16,28 @@ package app.tivi.data.views +import androidx.room.ColumnInfo import androidx.room.DatabaseView import app.tivi.data.entities.Season @DatabaseView( - """ - SELECT fs.id, COUNT(*) as episodeCount, COUNT(ew.watched_at) as watchedEpisodeCount - FROM myshows_entries as fs - INNER JOIN seasons AS s ON fs.show_id = s.show_id - INNER JOIN episodes AS eps ON eps.season_id = s.id - LEFT JOIN episode_watch_entries as ew ON ew.episode_id = eps.id - WHERE eps.first_aired IS NOT NULL - AND datetime(eps.first_aired) < datetime('now') - AND s.number != ${Season.NUMBER_SPECIALS} - AND s.ignored = 0 - GROUP BY fs.id -""", + viewName = "myshows_view_watch_stats", + value = """ + SELECT fs.id AS id, fs.show_id AS show_id, COUNT(*) AS episode_count, COUNT(ew.watched_at) AS watched_episode_count + FROM myshows_entries as fs + INNER JOIN seasons AS s ON fs.show_id = s.show_id + INNER JOIN episodes AS eps ON eps.season_id = s.id + LEFT JOIN episode_watch_entries as ew ON ew.episode_id = eps.id + WHERE eps.first_aired IS NOT NULL + AND datetime(eps.first_aired) < datetime('now') + AND s.number != ${Season.NUMBER_SPECIALS} + AND s.ignored = 0 + GROUP BY fs.id + """, ) data class FollowedShowsWatchStats( val id: Long, - val episodeCount: Int, - val watchedEpisodeCount: Int, + @ColumnInfo(name = "show_id") val showId: Long, + @ColumnInfo(name = "episode_count") val episodeCount: Int, + @ColumnInfo(name = "watched_episode_count") val watchedEpisodeCount: Int, ) From a20e811c34a673483513bcd02ed936cc19c4e031 Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Fri, 30 Dec 2022 13:13:06 +0000 Subject: [PATCH 04/13] Implement sorting and filtering --- .../java/app/tivi/data/TiviTypeConverters.kt | 5 ++ .../app/tivi/data/daos/LibraryShowsDao.kt | 72 ++++++++++++++++--- .../java/app/tivi/data/entities/SortOption.kt | 10 +-- .../observers/ObservePagedLibraryShows.kt | 11 ++- .../app/tivi/home/library/LibraryViewModel.kt | 4 +- 5 files changed, 82 insertions(+), 20 deletions(-) diff --git a/data-android/src/main/java/app/tivi/data/TiviTypeConverters.kt b/data-android/src/main/java/app/tivi/data/TiviTypeConverters.kt index 3aa7db2a4c..7314a3537b 100644 --- a/data-android/src/main/java/app/tivi/data/TiviTypeConverters.kt +++ b/data-android/src/main/java/app/tivi/data/TiviTypeConverters.kt @@ -21,6 +21,7 @@ import app.tivi.data.entities.ImageType import app.tivi.data.entities.PendingAction import app.tivi.data.entities.Request import app.tivi.data.entities.ShowStatus +import app.tivi.data.entities.SortOption import app.tivi.extensions.unsafeLazy import org.threeten.bp.DayOfWeek import org.threeten.bp.Instant @@ -115,4 +116,8 @@ object TiviTypeConverters { @TypeConverter @JvmStatic fun toShowStatus(value: String?) = showStatusValues.firstOrNull { it.storageKey == value } + + @TypeConverter + @JvmStatic + fun fromSortOption(sortOption: SortOption): String = sortOption.sqlValue } diff --git a/data/src/main/java/app/tivi/data/daos/LibraryShowsDao.kt b/data/src/main/java/app/tivi/data/daos/LibraryShowsDao.kt index 3a135cc6b7..2dcddda0af 100644 --- a/data/src/main/java/app/tivi/data/daos/LibraryShowsDao.kt +++ b/data/src/main/java/app/tivi/data/daos/LibraryShowsDao.kt @@ -20,6 +20,7 @@ import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction +import app.tivi.data.entities.Season import app.tivi.data.entities.SortOption import app.tivi.data.entities.TiviShow import app.tivi.data.resultentities.LibraryShow @@ -27,18 +28,33 @@ import app.tivi.data.resultentities.LibraryShow @Dao abstract class LibraryShowsDao : EntityDao() { - @Suppress("UNUSED_PARAMETER", "UNUSED_VARIABLE") fun observeForPaging( sort: SortOption, filter: String?, - ): PagingSource { - val filtered = !filter.isNullOrEmpty() - return pagedListLastWatched() + includeWatched: Boolean, + includeFollowed: Boolean, + ): PagingSource = if (filter.isNullOrEmpty()) { + pagedListLastWatched(sort, includeWatched, includeFollowed) + } else { + pagedListLastWatchedFilter(sort, "*$filter*", includeWatched, includeFollowed) } @Transaction @Query(QUERY_LAST_WATCHED) - internal abstract fun pagedListLastWatched(): PagingSource + internal abstract fun pagedListLastWatched( + sort: SortOption, + includeWatched: Boolean, + includeFollowed: Boolean, + ): PagingSource + + @Transaction + @Query(QUERY_LAST_WATCHED_FILTER) + internal abstract fun pagedListLastWatchedFilter( + sort: SortOption, + filter: String, + includeWatched: Boolean, + includeFollowed: Boolean, + ): PagingSource companion object { private const val QUERY_LAST_WATCHED = """ @@ -48,13 +64,47 @@ abstract class LibraryShowsDao : EntityDao() { LEFT JOIN seasons AS s ON shows.id = s.show_id LEFT JOIN episodes AS eps ON eps.season_id = s.id LEFT JOIN episode_watch_entries as ew ON ew.episode_id = eps.id - WHERE watched_entries.id IS NOT NULL OR myshows_entries.id IS NOT NULL + WHERE + (s.number IS NULL OR s.number != ${Season.NUMBER_SPECIALS}) + AND ( + (:includeWatched = 1 AND watched_entries.id IS NOT NULL) OR + (:includeFollowed = 1 AND myshows_entries.id IS NOT NULL) + ) + GROUP BY shows.id + ORDER BY CASE + WHEN :sort = 'last_watched' THEN + (CASE WHEN MAX(datetime(watched_entries.last_watched)) IS NULL + OR MAX(datetime(ew.watched_at)) > MAX(datetime(watched_entries.last_watched)) + THEN MAX(datetime(ew.watched_at)) + ELSE MAX(datetime(watched_entries.last_watched)) END) + END DESC, + shows.title ASC + """ + + private const val QUERY_LAST_WATCHED_FILTER = """ + SELECT shows.* FROM shows + INNER JOIN shows_fts ON shows.id = shows_fts.docid + LEFT JOIN myshows_entries ON shows.id = myshows_entries.show_id + LEFT JOIN watched_entries ON shows.id = watched_entries.show_id + LEFT JOIN seasons AS s ON shows.id = s.show_id + LEFT JOIN episodes AS eps ON eps.season_id = s.id + LEFT JOIN episode_watch_entries as ew ON ew.episode_id = eps.id + WHERE + (s.number IS NULL OR s.number != ${Season.NUMBER_SPECIALS}) + AND ( + (:includeWatched = 1 AND watched_entries.id IS NOT NULL) OR + (:includeFollowed = 1 AND myshows_entries.id IS NOT NULL) + ) + AND shows_fts.title MATCH :filter GROUP BY shows.id - ORDER BY - CASE WHEN MAX(datetime(watched_entries.last_watched)) IS NULL - OR MAX(datetime(ew.watched_at)) > MAX(datetime(watched_entries.last_watched)) - THEN MAX(datetime(ew.watched_at)) ELSE MAX(datetime(watched_entries.last_watched)) END - DESC + ORDER BY CASE + WHEN :sort = 'last_watched' THEN + (CASE WHEN MAX(datetime(watched_entries.last_watched)) IS NULL + OR MAX(datetime(ew.watched_at)) > MAX(datetime(watched_entries.last_watched)) + THEN MAX(datetime(ew.watched_at)) + ELSE MAX(datetime(watched_entries.last_watched)) END) + END DESC, + shows.title ASC """ } } diff --git a/data/src/main/java/app/tivi/data/entities/SortOption.kt b/data/src/main/java/app/tivi/data/entities/SortOption.kt index ca6699e5ab..ef09afe4fa 100644 --- a/data/src/main/java/app/tivi/data/entities/SortOption.kt +++ b/data/src/main/java/app/tivi/data/entities/SortOption.kt @@ -16,9 +16,9 @@ package app.tivi.data.entities -enum class SortOption { - SUPER_SORT, - LAST_WATCHED, - ALPHABETICAL, - DATE_ADDED, +enum class SortOption(val sqlValue: String) { + SUPER_SORT("super"), + LAST_WATCHED("last_watched"), + ALPHABETICAL("alpha"), + DATE_ADDED("added"), } diff --git a/domain/src/main/java/app/tivi/domain/observers/ObservePagedLibraryShows.kt b/domain/src/main/java/app/tivi/domain/observers/ObservePagedLibraryShows.kt index bcdd2c18b3..95e33bd9a1 100644 --- a/domain/src/main/java/app/tivi/domain/observers/ObservePagedLibraryShows.kt +++ b/domain/src/main/java/app/tivi/domain/observers/ObservePagedLibraryShows.kt @@ -33,12 +33,19 @@ class ObservePagedLibraryShows @Inject constructor( override fun createObservable( params: Parameters, ): Flow> = Pager(config = params.pagingConfig) { - libraryShowsDao.observeForPaging(params.sort, params.filter) + libraryShowsDao.observeForPaging( + sort = params.sort, + filter = params.filter, + includeWatched = params.includeWatched, + includeFollowed = params.includeFollowed, + ) }.flow data class Parameters( - val filter: String? = null, val sort: SortOption, + val filter: String? = null, + val includeWatched: Boolean = true, + val includeFollowed: Boolean = true, override val pagingConfig: PagingConfig, ) : PagingInteractor.Parameters } diff --git a/ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt b/ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt index cc35358fda..bf3018a494 100644 --- a/ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt +++ b/ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt @@ -66,10 +66,8 @@ internal class LibraryViewModel @Inject constructor( observePagedLibraryShows.flow.cachedIn(viewModelScope) private val availableSorts = listOf( - // SortOption.SUPER_SORT, SortOption.LAST_WATCHED, SortOption.ALPHABETICAL, - SortOption.DATE_ADDED, ) private val filter = MutableStateFlow(null) @@ -139,6 +137,8 @@ internal class LibraryViewModel @Inject constructor( ObservePagedLibraryShows.Parameters( sort = sort.value, filter = filter.value, + includeFollowed = includeFollowedShows.value, + includeWatched = includeWatchedShows.value, pagingConfig = PAGING_CONFIG, ), ) From e6a99ce41f89a9b85c63c743b397dffc0984d509 Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Fri, 30 Dec 2022 13:26:23 +0000 Subject: [PATCH 05/13] Implement filter chips --- .../java/app/tivi/home/library/Library.kt | 47 ++++++++++++++----- .../app/tivi/home/library/LibraryViewModel.kt | 12 +++++ 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/ui/library/src/main/java/app/tivi/home/library/Library.kt b/ui/library/src/main/java/app/tivi/home/library/Library.kt index 8f7cb0eac2..288dcacb70 100644 --- a/ui/library/src/main/java/app/tivi/home/library/Library.kt +++ b/ui/library/src/main/java/app/tivi/home/library/Library.kt @@ -43,6 +43,7 @@ import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.rememberDismissState import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -107,6 +108,8 @@ internal fun Library( list = pagingItems, openShowDetails = openShowDetails, onMessageShown = viewModel::clearMessage, + onToggleIncludeFollowedShows = viewModel::toggleFollowedShowsIncluded, + onToggleIncludeWatchedShows = viewModel::toggleWatchedShowsIncluded, openUser = openUser, refresh = viewModel::refresh, onFilterChanged = viewModel::setFilter, @@ -121,6 +124,8 @@ internal fun Library( list: LazyPagingItems, openShowDetails: (showId: Long) -> Unit, onMessageShown: (id: Long) -> Unit, + onToggleIncludeFollowedShows: () -> Unit, + onToggleIncludeWatchedShows: () -> Unit, refresh: () -> Unit, openUser: () -> Unit, onFilterChanged: (String) -> Unit, @@ -201,16 +206,34 @@ internal fun Library( .fillMaxHeight(), ) { fullSpanItem { - FilterSortPanel( - filterHint = stringResource(UiR.string.filter_shows, list.itemCount), - onFilterChanged = onFilterChanged, - sortOptions = state.availableSorts, - currentSortOption = state.sort, - onSortSelected = onSortSelected, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - ) + Column { + FilterSortPanel( + filterHint = stringResource(UiR.string.filter_shows, list.itemCount), + onFilterChanged = onFilterChanged, + sortOptions = state.availableSorts, + currentSortOption = state.sort, + onSortSelected = onSortSelected, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + ) + + Row { + FilterChip( + selected = state.followedShowsIncluded, + onClick = onToggleIncludeFollowedShows, + label = { Text(text = "Followed") }, + ) + + Spacer(Modifier.width(4.dp)) + + FilterChip( + selected = state.watchedShowsIncluded, + onClick = onToggleIncludeWatchedShows, + label = { Text(text = "Watched") }, + ) + } + } } items( @@ -236,7 +259,9 @@ internal fun Library( PullRefreshIndicator( refreshing = state.isLoading, state = refreshState, - modifier = Modifier.align(Alignment.TopCenter).padding(paddingValues), + modifier = Modifier + .align(Alignment.TopCenter) + .padding(paddingValues), scale = true, ) } diff --git a/ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt b/ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt index bf3018a494..7aeab5b623 100644 --- a/ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt +++ b/ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt @@ -164,6 +164,18 @@ internal class LibraryViewModel @Inject constructor( } } + fun toggleFollowedShowsIncluded() { + viewModelScope.launch { + includeFollowedShows.emit(!includeFollowedShows.value) + } + } + + fun toggleWatchedShowsIncluded() { + viewModelScope.launch { + includeWatchedShows.emit(!includeWatchedShows.value) + } + } + private fun refreshFollowed(fromInteraction: Boolean) { viewModelScope.launch { updateFollowedShows( From d0cb0833fcfed16653dc6864260c08e338db6e8c Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Fri, 30 Dec 2022 13:33:01 +0000 Subject: [PATCH 06/13] Refresh watched shows too --- .../app/tivi/home/library/LibraryViewModel.kt | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt b/ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt index 7aeab5b623..f8b19996ac 100644 --- a/ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt +++ b/ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt @@ -29,6 +29,7 @@ import app.tivi.domain.executeSync import app.tivi.domain.interactors.ChangeShowFollowStatus import app.tivi.domain.interactors.GetTraktAuthState import app.tivi.domain.interactors.UpdateFollowedShows +import app.tivi.domain.interactors.UpdateWatchedShows import app.tivi.domain.observers.ObservePagedLibraryShows import app.tivi.domain.observers.ObserveTraktAuthState import app.tivi.domain.observers.ObserveUserDetails @@ -52,6 +53,7 @@ import kotlinx.coroutines.launch @HiltViewModel internal class LibraryViewModel @Inject constructor( private val updateFollowedShows: UpdateFollowedShows, + private val updateWatchedShows: UpdateWatchedShows, private val observePagedLibraryShows: ObservePagedLibraryShows, observeTraktAuthState: ObserveTraktAuthState, private val changeShowFollowStatus: ChangeShowFollowStatus, @@ -59,7 +61,8 @@ internal class LibraryViewModel @Inject constructor( private val getTraktAuthState: GetTraktAuthState, private val logger: Logger, ) : ViewModel() { - private val loadingState = ObservableLoadingCounter() + private val followedLoadingState = ObservableLoadingCounter() + private val watchedLoadingState = ObservableLoadingCounter() private val uiMessageManager = UiMessageManager() val pagedList: Flow> = @@ -77,7 +80,8 @@ internal class LibraryViewModel @Inject constructor( private val includeFollowedShows = MutableStateFlow(true) val state: StateFlow = combine( - loadingState.observable, + followedLoadingState.observable, + watchedLoadingState.observable, observeTraktAuthState.flow, observeUserDetails.flow, filter, @@ -85,11 +89,11 @@ internal class LibraryViewModel @Inject constructor( uiMessageManager.message, includeWatchedShows, includeFollowedShows, - ) { loading, authState, user, filter, sort, message, includeWatchedShows, includeFollowedShows -> + ) { followedLoading, watchedLoading, authState, user, filter, sort, message, includeWatchedShows, includeFollowedShows -> LibraryViewState( user = user, authState = authState, - isLoading = loading, + isLoading = followedLoading || watchedLoading, filter = filter, filterActive = !filter.isNullOrEmpty(), availableSorts = availableSorts, @@ -147,7 +151,12 @@ internal class LibraryViewModel @Inject constructor( fun refresh(fromUser: Boolean = true) { viewModelScope.launch { if (getTraktAuthState.executeSync() == TraktAuthState.LOGGED_IN) { - refreshFollowed(fromUser) + refreshWatched(fromUser) + } + } + viewModelScope.launch { + if (getTraktAuthState.executeSync() == TraktAuthState.LOGGED_IN) { + refreshWatched(fromUser) } } } @@ -180,7 +189,15 @@ internal class LibraryViewModel @Inject constructor( viewModelScope.launch { updateFollowedShows( UpdateFollowedShows.Params(fromInteraction, RefreshType.QUICK), - ).collectStatus(loadingState, logger, uiMessageManager) + ).collectStatus(followedLoadingState, logger, uiMessageManager) + } + } + + private fun refreshWatched(fromUser: Boolean) { + viewModelScope.launch { + updateWatchedShows( + UpdateWatchedShows.Params(forceRefresh = fromUser), + ).collectStatus(watchedLoadingState, logger, uiMessageManager) } } From 1b6b7f7a1601147970ec640ae7d210f487c41afe Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Fri, 30 Dec 2022 13:34:43 +0000 Subject: [PATCH 07/13] Remove followed and watched from main navigation --- app/build.gradle.kts | 2 - app/src/main/java/app/tivi/AppNavigation.kt | 244 +++++++------------- app/src/main/java/app/tivi/home/Home.kt | 62 ++--- 3 files changed, 100 insertions(+), 208 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 02b0d48caf..536a6e4ed8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -170,8 +170,6 @@ dependencies { implementation(projects.ui.showdetails) implementation(projects.ui.episodedetails) implementation(projects.ui.library) - implementation(projects.ui.followed) - implementation(projects.ui.watched) implementation(projects.ui.popular) implementation(projects.ui.trending) implementation(projects.ui.recommended) diff --git a/app/src/main/java/app/tivi/AppNavigation.kt b/app/src/main/java/app/tivi/AppNavigation.kt index e174819f34..750de330e7 100644 --- a/app/src/main/java/app/tivi/AppNavigation.kt +++ b/app/src/main/java/app/tivi/AppNavigation.kt @@ -37,13 +37,11 @@ import androidx.navigation.navArgument import app.tivi.account.AccountUi import app.tivi.episodedetails.EpisodeDetails import app.tivi.home.discover.Discover -import app.tivi.home.followed.Followed import app.tivi.home.library.Library import app.tivi.home.popular.PopularShows import app.tivi.home.recommended.RecommendedShows import app.tivi.home.search.Search import app.tivi.home.trending.TrendingShows -import app.tivi.home.watched.Watched import app.tivi.showdetails.details.ShowDetails import app.tivi.showdetails.seasons.ShowSeasons import com.google.accompanist.navigation.animation.AnimatedNavHost @@ -51,40 +49,37 @@ import com.google.accompanist.navigation.animation.navigation import com.google.accompanist.navigation.material.BottomSheetNavigator import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi -internal sealed class Screen(val route: String) { - object Discover : Screen("discover") - object Following : Screen("following") - object Watched : Screen("watched") - object Library : Screen("library") - object Search : Screen("search") +internal sealed class RootScreen(val route: String) { + object Discover : RootScreen("discover") + object Library : RootScreen("library") + object Search : RootScreen("search") } -private sealed class LeafScreen( +private sealed class Screen( private val route: String, ) { - fun createRoute(root: Screen) = "${root.route}/$route" + fun createRoute(root: RootScreen) = "${root.route}/$route" - object Discover : LeafScreen("discover") - object Following : LeafScreen("following") - object Trending : LeafScreen("trending") - object Library : LeafScreen("library") - object Popular : LeafScreen("popular") + object Discover : Screen("discover") + object Trending : Screen("trending") + object Library : Screen("library") + object Popular : Screen("popular") - object ShowDetails : LeafScreen("show/{showId}") { - fun createRoute(root: Screen, showId: Long): String { + object ShowDetails : Screen("show/{showId}") { + fun createRoute(root: RootScreen, showId: Long): String { return "${root.route}/show/$showId" } } - object EpisodeDetails : LeafScreen("episode/{episodeId}") { - fun createRoute(root: Screen, episodeId: Long): String { + object EpisodeDetails : Screen("episode/{episodeId}") { + fun createRoute(root: RootScreen, episodeId: Long): String { return "${root.route}/episode/$episodeId" } } - object ShowSeasons : LeafScreen("show/{showId}/seasons?seasonId={seasonId}") { + object ShowSeasons : Screen("show/{showId}/seasons?seasonId={seasonId}") { fun createRoute( - root: Screen, + root: RootScreen, showId: Long, seasonId: Long? = null, ): String { @@ -94,10 +89,9 @@ private sealed class LeafScreen( } } - object RecommendedShows : LeafScreen("recommendedshows") - object Watched : LeafScreen("watched") - object Search : LeafScreen("search") - object Account : LeafScreen("account") + object RecommendedShows : Screen("recommendedshows") + object Search : Screen("search") + object Account : Screen("account") } @ExperimentalAnimationApi @@ -109,7 +103,7 @@ internal fun AppNavigation( ) { AnimatedNavHost( navController = navController, - startDestination = Screen.Discover.route, + startDestination = RootScreen.Discover.route, enterTransition = { defaultTiviEnterTransition(initialState, targetState) }, exitTransition = { defaultTiviExitTransition(initialState, targetState) }, popEnterTransition = { defaultTiviPopEnterTransition() }, @@ -118,8 +112,6 @@ internal fun AppNavigation( ) { addDiscoverTopLevel(navController, onOpenSettings) addLibraryTopLevel(navController, onOpenSettings) - addFollowingTopLevel(navController, onOpenSettings) - addWatchedTopLevel(navController, onOpenSettings) addSearchTopLevel(navController, onOpenSettings) } } @@ -130,34 +122,17 @@ private fun NavGraphBuilder.addDiscoverTopLevel( openSettings: () -> Unit, ) { navigation( - route = Screen.Discover.route, - startDestination = LeafScreen.Discover.createRoute(Screen.Discover), + route = RootScreen.Discover.route, + startDestination = Screen.Discover.createRoute(RootScreen.Discover), ) { - addDiscover(navController, Screen.Discover) - addAccount(Screen.Discover, openSettings) - addShowDetails(navController, Screen.Discover) - addShowSeasons(navController, Screen.Discover) - addEpisodeDetails(navController, Screen.Discover) - addRecommendedShows(navController, Screen.Discover) - addTrendingShows(navController, Screen.Discover) - addPopularShows(navController, Screen.Discover) - } -} - -@ExperimentalAnimationApi -private fun NavGraphBuilder.addFollowingTopLevel( - navController: NavController, - openSettings: () -> Unit, -) { - navigation( - route = Screen.Following.route, - startDestination = LeafScreen.Following.createRoute(Screen.Following), - ) { - addFollowedShows(navController, Screen.Following) - addAccount(Screen.Following, openSettings) - addShowDetails(navController, Screen.Following) - addShowSeasons(navController, Screen.Following) - addEpisodeDetails(navController, Screen.Following) + addDiscover(navController, RootScreen.Discover) + addAccount(RootScreen.Discover, openSettings) + addShowDetails(navController, RootScreen.Discover) + addShowSeasons(navController, RootScreen.Discover) + addEpisodeDetails(navController, RootScreen.Discover) + addRecommendedShows(navController, RootScreen.Discover) + addTrendingShows(navController, RootScreen.Discover) + addPopularShows(navController, RootScreen.Discover) } } @@ -167,31 +142,14 @@ private fun NavGraphBuilder.addLibraryTopLevel( openSettings: () -> Unit, ) { navigation( - route = Screen.Library.route, - startDestination = LeafScreen.Library.createRoute(Screen.Library), - ) { - addLibrary(navController, Screen.Library) - addAccount(Screen.Library, openSettings) - addShowDetails(navController, Screen.Library) - addShowSeasons(navController, Screen.Library) - addEpisodeDetails(navController, Screen.Library) - } -} - -@ExperimentalAnimationApi -private fun NavGraphBuilder.addWatchedTopLevel( - navController: NavController, - openSettings: () -> Unit, -) { - navigation( - route = Screen.Watched.route, - startDestination = LeafScreen.Watched.createRoute(Screen.Watched), + route = RootScreen.Library.route, + startDestination = Screen.Library.createRoute(RootScreen.Library), ) { - addWatchedShows(navController, Screen.Watched) - addAccount(Screen.Watched, openSettings) - addShowDetails(navController, Screen.Watched) - addShowSeasons(navController, Screen.Watched) - addEpisodeDetails(navController, Screen.Watched) + addLibrary(navController, RootScreen.Library) + addAccount(RootScreen.Library, openSettings) + addShowDetails(navController, RootScreen.Library) + addShowSeasons(navController, RootScreen.Library) + addEpisodeDetails(navController, RootScreen.Library) } } @@ -201,72 +159,52 @@ private fun NavGraphBuilder.addSearchTopLevel( openSettings: () -> Unit, ) { navigation( - route = Screen.Search.route, - startDestination = LeafScreen.Search.createRoute(Screen.Search), + route = RootScreen.Search.route, + startDestination = Screen.Search.createRoute(RootScreen.Search), ) { - addSearch(navController, Screen.Search) - addAccount(Screen.Search, openSettings) - addShowDetails(navController, Screen.Search) - addShowSeasons(navController, Screen.Search) - addEpisodeDetails(navController, Screen.Search) + addSearch(navController, RootScreen.Search) + addAccount(RootScreen.Search, openSettings) + addShowDetails(navController, RootScreen.Search) + addShowSeasons(navController, RootScreen.Search) + addEpisodeDetails(navController, RootScreen.Search) } } @ExperimentalAnimationApi private fun NavGraphBuilder.addDiscover( navController: NavController, - root: Screen, + root: RootScreen, ) { composable( - route = LeafScreen.Discover.createRoute(root), + route = Screen.Discover.createRoute(root), debugLabel = "Discover()", ) { Discover( openTrendingShows = { - navController.navigate(LeafScreen.Trending.createRoute(root)) + navController.navigate(Screen.Trending.createRoute(root)) }, openPopularShows = { - navController.navigate(LeafScreen.Popular.createRoute(root)) + navController.navigate(Screen.Popular.createRoute(root)) }, openRecommendedShows = { - navController.navigate(LeafScreen.RecommendedShows.createRoute(root)) + navController.navigate(Screen.RecommendedShows.createRoute(root)) }, openShowDetails = { showId, seasonId, episodeId -> - navController.navigate(LeafScreen.ShowDetails.createRoute(root, showId)) + navController.navigate(Screen.ShowDetails.createRoute(root, showId)) // If we have an season id, we also open that if (seasonId != null) { navController.navigate( - LeafScreen.ShowSeasons.createRoute(root, showId, seasonId), + Screen.ShowSeasons.createRoute(root, showId, seasonId), ) } // If we have an episodeId, we also open that if (episodeId != null) { - navController.navigate(LeafScreen.EpisodeDetails.createRoute(root, episodeId)) + navController.navigate(Screen.EpisodeDetails.createRoute(root, episodeId)) } }, openUser = { - navController.navigate(LeafScreen.Account.createRoute(root)) - }, - ) - } -} - -@ExperimentalAnimationApi -private fun NavGraphBuilder.addFollowedShows( - navController: NavController, - root: Screen, -) { - composable( - route = LeafScreen.Following.createRoute(root), - debugLabel = "Followed()", - ) { - Followed( - openShowDetails = { showId -> - navController.navigate(LeafScreen.ShowDetails.createRoute(root, showId)) - }, - openUser = { - navController.navigate(LeafScreen.Account.createRoute(root)) + navController.navigate(Screen.Account.createRoute(root)) }, ) } @@ -275,38 +213,18 @@ private fun NavGraphBuilder.addFollowedShows( @ExperimentalAnimationApi private fun NavGraphBuilder.addLibrary( navController: NavController, - root: Screen, + root: RootScreen, ) { composable( - route = LeafScreen.Library.createRoute(root), + route = Screen.Library.createRoute(root), debugLabel = "Library()", ) { Library( openShowDetails = { showId -> - navController.navigate(LeafScreen.ShowDetails.createRoute(root, showId)) - }, - openUser = { - navController.navigate(LeafScreen.Account.createRoute(root)) - }, - ) - } -} - -@ExperimentalAnimationApi -private fun NavGraphBuilder.addWatchedShows( - navController: NavController, - root: Screen, -) { - composable( - route = LeafScreen.Watched.createRoute(root), - debugLabel = "Watched()", - ) { - Watched( - openShowDetails = { showId -> - navController.navigate(LeafScreen.ShowDetails.createRoute(root, showId)) + navController.navigate(Screen.ShowDetails.createRoute(root, showId)) }, openUser = { - navController.navigate(LeafScreen.Account.createRoute(root)) + navController.navigate(Screen.Account.createRoute(root)) }, ) } @@ -315,12 +233,12 @@ private fun NavGraphBuilder.addWatchedShows( @ExperimentalAnimationApi private fun NavGraphBuilder.addSearch( navController: NavController, - root: Screen, + root: RootScreen, ) { - composable(LeafScreen.Search.createRoute(root)) { + composable(Screen.Search.createRoute(root)) { Search( openShowDetails = { showId -> - navController.navigate(LeafScreen.ShowDetails.createRoute(root, showId)) + navController.navigate(Screen.ShowDetails.createRoute(root, showId)) }, ) } @@ -329,10 +247,10 @@ private fun NavGraphBuilder.addSearch( @ExperimentalAnimationApi private fun NavGraphBuilder.addShowDetails( navController: NavController, - root: Screen, + root: RootScreen, ) { composable( - route = LeafScreen.ShowDetails.createRoute(root), + route = Screen.ShowDetails.createRoute(root), debugLabel = "ShowDetails()", arguments = listOf( navArgument("showId") { type = NavType.LongType }, @@ -341,13 +259,13 @@ private fun NavGraphBuilder.addShowDetails( ShowDetails( navigateUp = navController::navigateUp, openShowDetails = { showId -> - navController.navigate(LeafScreen.ShowDetails.createRoute(root, showId)) + navController.navigate(Screen.ShowDetails.createRoute(root, showId)) }, openEpisodeDetails = { episodeId -> - navController.navigate(LeafScreen.EpisodeDetails.createRoute(root, episodeId)) + navController.navigate(Screen.EpisodeDetails.createRoute(root, episodeId)) }, openSeasons = { showId, seasonId -> - navController.navigate(LeafScreen.ShowSeasons.createRoute(root, showId, seasonId)) + navController.navigate(Screen.ShowSeasons.createRoute(root, showId, seasonId)) }, ) } @@ -357,10 +275,10 @@ private fun NavGraphBuilder.addShowDetails( @ExperimentalAnimationApi private fun NavGraphBuilder.addEpisodeDetails( navController: NavController, - root: Screen, + root: RootScreen, ) { bottomSheet( - route = LeafScreen.EpisodeDetails.createRoute(root), + route = Screen.EpisodeDetails.createRoute(root), debugLabel = "EpisodeDetails()", arguments = listOf( navArgument("episodeId") { type = NavType.LongType }, @@ -378,15 +296,15 @@ private fun NavGraphBuilder.addEpisodeDetails( @ExperimentalAnimationApi private fun NavGraphBuilder.addRecommendedShows( navController: NavController, - root: Screen, + root: RootScreen, ) { composable( - route = LeafScreen.RecommendedShows.createRoute(root), + route = Screen.RecommendedShows.createRoute(root), debugLabel = "RecommendedShows()", ) { RecommendedShows( openShowDetails = { showId -> - navController.navigate(LeafScreen.ShowDetails.createRoute(root, showId)) + navController.navigate(Screen.ShowDetails.createRoute(root, showId)) }, navigateUp = navController::navigateUp, ) @@ -396,15 +314,15 @@ private fun NavGraphBuilder.addRecommendedShows( @ExperimentalAnimationApi private fun NavGraphBuilder.addTrendingShows( navController: NavController, - root: Screen, + root: RootScreen, ) { composable( - route = LeafScreen.Trending.createRoute(root), + route = Screen.Trending.createRoute(root), debugLabel = "TrendingShows()", ) { TrendingShows( openShowDetails = { showId -> - navController.navigate(LeafScreen.ShowDetails.createRoute(root, showId)) + navController.navigate(Screen.ShowDetails.createRoute(root, showId)) }, navigateUp = navController::navigateUp, ) @@ -414,15 +332,15 @@ private fun NavGraphBuilder.addTrendingShows( @ExperimentalAnimationApi private fun NavGraphBuilder.addPopularShows( navController: NavController, - root: Screen, + root: RootScreen, ) { composable( - route = LeafScreen.Popular.createRoute(root), + route = Screen.Popular.createRoute(root), debugLabel = "PopularShows()", ) { PopularShows( openShowDetails = { showId -> - navController.navigate(LeafScreen.ShowDetails.createRoute(root, showId)) + navController.navigate(Screen.ShowDetails.createRoute(root, showId)) }, navigateUp = navController::navigateUp, ) @@ -431,11 +349,11 @@ private fun NavGraphBuilder.addPopularShows( @ExperimentalAnimationApi private fun NavGraphBuilder.addAccount( - root: Screen, + root: RootScreen, onOpenSettings: () -> Unit, ) { dialog( - route = LeafScreen.Account.createRoute(root), + route = Screen.Account.createRoute(root), debugLabel = "AccountUi()", ) { AccountUi( @@ -447,10 +365,10 @@ private fun NavGraphBuilder.addAccount( @ExperimentalAnimationApi private fun NavGraphBuilder.addShowSeasons( navController: NavController, - root: Screen, + root: RootScreen, ) { composable( - route = LeafScreen.ShowSeasons.createRoute(root), + route = Screen.ShowSeasons.createRoute(root), debugLabel = "ShowSeasons()", arguments = listOf( navArgument("showId") { @@ -465,7 +383,7 @@ private fun NavGraphBuilder.addShowSeasons( ShowSeasons( navigateUp = navController::navigateUp, openEpisodeDetails = { episodeId -> - navController.navigate(LeafScreen.EpisodeDetails.createRoute(root, episodeId)) + navController.navigate(Screen.EpisodeDetails.createRoute(root, episodeId)) }, initialSeasonId = it.arguments?.getString("seasonId")?.toLong(), ) diff --git a/app/src/main/java/app/tivi/home/Home.kt b/app/src/main/java/app/tivi/home/Home.kt index 32760f3d2a..e460279fe3 100644 --- a/app/src/main/java/app/tivi/home/Home.kt +++ b/app/src/main/java/app/tivi/home/Home.kt @@ -36,14 +36,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.shape.CornerSize import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.VideoLibrary -import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Weekend import androidx.compose.material.icons.outlined.VideoLibrary -import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.Weekend import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api @@ -75,7 +71,7 @@ import androidx.navigation.NavController import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavGraph.Companion.findStartDestination import app.tivi.AppNavigation -import app.tivi.Screen +import app.tivi.RootScreen import app.tivi.common.ui.resources.R as UiR import app.tivi.debugLabel import app.tivi.util.Analytics @@ -197,26 +193,20 @@ internal fun Home( */ @Stable @Composable -private fun NavController.currentScreenAsState(): State { - val selectedItem = remember { mutableStateOf(Screen.Discover) } +private fun NavController.currentScreenAsState(): State { + val selectedItem = remember { mutableStateOf(RootScreen.Discover) } DisposableEffect(this) { val listener = NavController.OnDestinationChangedListener { _, destination, _ -> when { - destination.hierarchy.any { it.route == Screen.Discover.route } -> { - selectedItem.value = Screen.Discover + destination.hierarchy.any { it.route == RootScreen.Discover.route } -> { + selectedItem.value = RootScreen.Discover } - destination.hierarchy.any { it.route == Screen.Library.route } -> { - selectedItem.value = Screen.Library + destination.hierarchy.any { it.route == RootScreen.Library.route } -> { + selectedItem.value = RootScreen.Library } - destination.hierarchy.any { it.route == Screen.Watched.route } -> { - selectedItem.value = Screen.Watched - } - destination.hierarchy.any { it.route == Screen.Following.route } -> { - selectedItem.value = Screen.Following - } - destination.hierarchy.any { it.route == Screen.Search.route } -> { - selectedItem.value = Screen.Search + destination.hierarchy.any { it.route == RootScreen.Search.route } -> { + selectedItem.value = RootScreen.Search } } } @@ -232,8 +222,8 @@ private fun NavController.currentScreenAsState(): State { @Composable internal fun HomeNavigationBar( - selectedNavigation: Screen, - onNavigationSelected: (Screen) -> Unit, + selectedNavigation: RootScreen, + onNavigationSelected: (RootScreen) -> Unit, modifier: Modifier = Modifier, ) { NavigationBar(modifier = modifier) { @@ -255,8 +245,8 @@ internal fun HomeNavigationBar( @Composable internal fun HomeNavigationRail( - selectedNavigation: Screen, - onNavigationSelected: (Screen) -> Unit, + selectedNavigation: RootScreen, + onNavigationSelected: (RootScreen) -> Unit, modifier: Modifier = Modifier, ) { NavigationRail(modifier = modifier) { @@ -304,12 +294,12 @@ private fun HomeNavigationItemIcon(item: HomeNavigationItem, selected: Boolean) } private sealed class HomeNavigationItem( - val screen: Screen, + val screen: RootScreen, @StringRes val labelResId: Int, @StringRes val contentDescriptionResId: Int, ) { class ResourceIcon( - screen: Screen, + screen: RootScreen, @StringRes labelResId: Int, @StringRes contentDescriptionResId: Int, @DrawableRes val iconResId: Int, @@ -317,7 +307,7 @@ private sealed class HomeNavigationItem( ) : HomeNavigationItem(screen, labelResId, contentDescriptionResId) class ImageVectorIcon( - screen: Screen, + screen: RootScreen, @StringRes labelResId: Int, @StringRes contentDescriptionResId: Int, val iconImageVector: ImageVector, @@ -327,35 +317,21 @@ private sealed class HomeNavigationItem( private val HomeNavigationItems = listOf( HomeNavigationItem.ImageVectorIcon( - screen = Screen.Discover, + screen = RootScreen.Discover, labelResId = UiR.string.discover_title, contentDescriptionResId = UiR.string.cd_discover_title, iconImageVector = Icons.Outlined.Weekend, selectedImageVector = Icons.Default.Weekend, ), HomeNavigationItem.ImageVectorIcon( - screen = Screen.Library, + screen = RootScreen.Library, labelResId = UiR.string.library_title, contentDescriptionResId = UiR.string.cd_library_title, iconImageVector = Icons.Outlined.VideoLibrary, selectedImageVector = Icons.Default.VideoLibrary, ), HomeNavigationItem.ImageVectorIcon( - screen = Screen.Following, - labelResId = UiR.string.following_shows_title, - contentDescriptionResId = UiR.string.cd_following_shows_title, - iconImageVector = Icons.Default.FavoriteBorder, - selectedImageVector = Icons.Default.Favorite, - ), - HomeNavigationItem.ImageVectorIcon( - screen = Screen.Watched, - labelResId = UiR.string.watched_shows_title, - contentDescriptionResId = UiR.string.cd_watched_shows_title, - iconImageVector = Icons.Outlined.Visibility, - selectedImageVector = Icons.Default.Visibility, - ), - HomeNavigationItem.ImageVectorIcon( - screen = Screen.Search, + screen = RootScreen.Search, labelResId = UiR.string.search_navigation_title, contentDescriptionResId = UiR.string.cd_search_navigation_title, iconImageVector = Icons.Default.Search, From c25239a6fd2ab8e336cbc4956e1ec0685ad13864 Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Fri, 30 Dec 2022 13:37:03 +0000 Subject: [PATCH 08/13] Remove :ui:watched and :ui:followed --- settings.gradle.kts | 2 - ui/followed/build.gradle.kts | 64 ---- .../java/app/tivi/home/followed/Followed.kt | 303 ------------------ .../tivi/home/followed/FollowedViewModel.kt | 201 ------------ .../tivi/home/followed/FollowedViewState.kt | 39 --- ui/watched/build.gradle.kts | 62 ---- .../java/app/tivi/home/watched/Watched.kt | 284 ---------------- .../app/tivi/home/watched/WatchedViewModel.kt | 197 ------------ .../app/tivi/home/watched/WatchedViewState.kt | 40 --- 9 files changed, 1192 deletions(-) delete mode 100644 ui/followed/build.gradle.kts delete mode 100644 ui/followed/src/main/java/app/tivi/home/followed/Followed.kt delete mode 100644 ui/followed/src/main/java/app/tivi/home/followed/FollowedViewModel.kt delete mode 100644 ui/followed/src/main/java/app/tivi/home/followed/FollowedViewState.kt delete mode 100644 ui/watched/build.gradle.kts delete mode 100644 ui/watched/src/main/java/app/tivi/home/watched/Watched.kt delete mode 100644 ui/watched/src/main/java/app/tivi/home/watched/WatchedViewModel.kt delete mode 100644 ui/watched/src/main/java/app/tivi/home/watched/WatchedViewState.kt diff --git a/settings.gradle.kts b/settings.gradle.kts index 792eccedfd..0883791d79 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -55,8 +55,6 @@ include( ":ui:discover", ":ui:showdetails", ":ui:episodedetails", - ":ui:followed", - ":ui:watched", ":ui:trending", ":ui:popular", ":ui:recommended", diff --git a/ui/followed/build.gradle.kts b/ui/followed/build.gradle.kts deleted file mode 100644 index d3cb80e76b..0000000000 --- a/ui/followed/build.gradle.kts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2018 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.kapt) - alias(libs.plugins.hilt) -} - -android { - namespace = "app.tivi.home.followed" - - buildFeatures { - compose = true - } - - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.composecompiler.get() - } -} - -dependencies { - implementation(projects.base) - implementation(projects.domain) - implementation(projects.common.ui.compose) - implementation(projects.common.ui.view) - - implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.androidx.lifecycle.viewmodel.compose) - - implementation(libs.androidx.paging.runtime) - implementation(libs.androidx.paging.compose) - - implementation(libs.androidx.core) - - implementation(libs.compose.foundation.foundation) - implementation(libs.compose.foundation.layout) - implementation(libs.compose.material.material) - implementation(libs.compose.material.iconsext) - implementation(libs.compose.material3) - implementation(libs.compose.animation.animation) - implementation(libs.compose.ui.tooling) - - implementation(libs.coil.compose) - - implementation(libs.hilt.compose) - implementation(libs.hilt.library) - kapt(libs.hilt.compiler) -} diff --git a/ui/followed/src/main/java/app/tivi/home/followed/Followed.kt b/ui/followed/src/main/java/app/tivi/home/followed/Followed.kt deleted file mode 100644 index 6783acaea4..0000000000 --- a/ui/followed/src/main/java/app/tivi/home/followed/Followed.kt +++ /dev/null @@ -1,303 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file:OptIn(ExperimentalMaterialApi::class) - -package app.tivi.home.followed - -import androidx.compose.foundation.ExperimentalFoundationApi -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.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.material.DismissValue -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.SwipeToDismiss -import androidx.compose.material.pullrefresh.PullRefreshIndicator -import androidx.compose.material.pullrefresh.pullRefresh -import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.material.rememberDismissState -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Snackbar -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.paging.compose.LazyPagingItems -import androidx.paging.compose.collectAsLazyPagingItems -import app.tivi.common.compose.Layout -import app.tivi.common.compose.LocalTiviTextCreator -import app.tivi.common.compose.bodyWidth -import app.tivi.common.compose.fullSpanItem -import app.tivi.common.compose.items -import app.tivi.common.compose.ui.FilterSortPanel -import app.tivi.common.compose.ui.PosterCard -import app.tivi.common.compose.ui.TiviStandardAppBar -import app.tivi.common.compose.ui.plus -import app.tivi.common.ui.resources.R as UiR -import app.tivi.data.entities.ShowTmdbImage -import app.tivi.data.entities.SortOption -import app.tivi.data.entities.TiviShow -import app.tivi.data.resultentities.FollowedShowEntryWithShow -import app.tivi.trakt.TraktAuthState - -@Composable -fun Followed( - openShowDetails: (showId: Long) -> Unit, - openUser: () -> Unit, -) { - Followed( - viewModel = hiltViewModel(), - openShowDetails = openShowDetails, - openUser = openUser, - ) -} - -@Composable -internal fun Followed( - viewModel: FollowedViewModel, - openShowDetails: (showId: Long) -> Unit, - openUser: () -> Unit, -) { - val viewState by viewModel.state.collectAsState() - val pagingItems = viewModel.pagedList.collectAsLazyPagingItems() - - Followed( - state = viewState, - list = pagingItems, - openShowDetails = openShowDetails, - onMessageShown = viewModel::clearMessage, - openUser = openUser, - refresh = viewModel::refresh, - onFilterChanged = viewModel::setFilter, - onSortSelected = viewModel::setSort, - ) -} - -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) -@Composable -internal fun Followed( - state: FollowedViewState, - list: LazyPagingItems, - openShowDetails: (showId: Long) -> Unit, - onMessageShown: (id: Long) -> Unit, - refresh: () -> Unit, - openUser: () -> Unit, - onFilterChanged: (String) -> Unit, - onSortSelected: (SortOption) -> Unit, -) { - val snackbarHostState = remember { SnackbarHostState() } - - val dismissSnackbarState = rememberDismissState { value -> - when { - value != DismissValue.Default -> { - snackbarHostState.currentSnackbarData?.dismiss() - true - } - - else -> false - } - } - - state.message?.let { message -> - LaunchedEffect(message) { - snackbarHostState.showSnackbar(message.message) - // Notify the view model that the message has been dismissed - onMessageShown(message.id) - } - } - - val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() - - Scaffold( - topBar = { - TiviStandardAppBar( - title = stringResource(UiR.string.following_shows_title), - loggedIn = state.authState == TraktAuthState.LOGGED_IN, - user = state.user, - scrollBehavior = scrollBehavior, - refreshing = state.isLoading, - onRefreshActionClick = refresh, - onUserActionClick = openUser, - modifier = Modifier - .fillMaxWidth(), - ) - }, - snackbarHost = { - SnackbarHost(hostState = snackbarHostState) { data -> - SwipeToDismiss( - state = dismissSnackbarState, - background = {}, - dismissContent = { Snackbar(snackbarData = data) }, - modifier = Modifier - .padding(horizontal = Layout.bodyMargin) - .fillMaxWidth(), - ) - } - }, - modifier = Modifier.fillMaxSize(), - ) { paddingValues -> - val refreshState = rememberPullRefreshState( - refreshing = state.isLoading, - onRefresh = refresh, - ) - Box(modifier = Modifier.pullRefresh(state = refreshState)) { - val columns = Layout.columns - val bodyMargin = Layout.bodyMargin - val gutter = Layout.gutter - - LazyVerticalGrid( - columns = GridCells.Fixed(columns / 4), - contentPadding = paddingValues + PaddingValues( - horizontal = (bodyMargin - 8.dp).coerceAtLeast(0.dp), - vertical = (gutter - 8.dp).coerceAtLeast(0.dp), - ), - // We minus 8.dp off the grid padding, as we use content padding on the items below - horizontalArrangement = Arrangement.spacedBy((gutter - 8.dp).coerceAtLeast(0.dp)), - verticalArrangement = Arrangement.spacedBy((gutter - 8.dp).coerceAtLeast(0.dp)), - modifier = Modifier - .nestedScroll(scrollBehavior.nestedScrollConnection) - .bodyWidth() - .fillMaxHeight(), - ) { - fullSpanItem { - FilterSortPanel( - filterHint = stringResource(UiR.string.filter_shows, list.itemCount), - onFilterChanged = onFilterChanged, - sortOptions = state.availableSorts, - currentSortOption = state.sort, - onSortSelected = onSortSelected, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - ) - } - - items( - items = list, - key = { it.show.id }, - ) { entry -> - if (entry != null) { - FollowedShowItem( - show = entry.show, - poster = entry.poster, - watchedEpisodeCount = entry.stats?.watchedEpisodeCount ?: 0, - totalEpisodeCount = entry.stats?.episodeCount ?: 0, - onClick = { openShowDetails(entry.show.id) }, - contentPadding = PaddingValues(8.dp), - modifier = Modifier - .animateItemPlacement() - .fillMaxWidth(), - ) - } - } - } - - PullRefreshIndicator( - refreshing = state.isLoading, - state = refreshState, - modifier = Modifier.align(Alignment.TopCenter).padding(paddingValues), - scale = true, - ) - } - } -} - -@Composable -private fun FollowedShowItem( - show: TiviShow, - poster: ShowTmdbImage?, - watchedEpisodeCount: Int, - totalEpisodeCount: Int, - onClick: () -> Unit, - contentPadding: PaddingValues, - modifier: Modifier = Modifier, -) { - Row( - modifier - .clip(MaterialTheme.shapes.medium) - .clickable(onClick = onClick) - .padding(contentPadding), - ) { - PosterCard( - show = show, - poster = poster, - modifier = Modifier - .fillMaxWidth(0.2f) // 20% of the width - .aspectRatio(2 / 3f), - ) - - Spacer(Modifier.width(16.dp)) - - Column { - val textCreator = LocalTiviTextCreator.current - - Text( - text = textCreator.showTitle(show = show).toString(), - style = MaterialTheme.typography.titleMedium, - ) - - Spacer(Modifier.height(4.dp)) - - LinearProgressIndicator( - progress = when { - totalEpisodeCount > 0 -> watchedEpisodeCount / totalEpisodeCount.toFloat() - else -> 0f - }, - modifier = Modifier.fillMaxWidth(), - ) - - Spacer(Modifier.height(4.dp)) - - Text( - text = textCreator.followedShowEpisodeWatchStatus( - episodeCount = totalEpisodeCount, - watchedEpisodeCount = watchedEpisodeCount, - ).toString(), - style = MaterialTheme.typography.bodySmall, - ) - - Spacer(Modifier.height(8.dp)) - } - } -} diff --git a/ui/followed/src/main/java/app/tivi/home/followed/FollowedViewModel.kt b/ui/followed/src/main/java/app/tivi/home/followed/FollowedViewModel.kt deleted file mode 100644 index a7dbc73134..0000000000 --- a/ui/followed/src/main/java/app/tivi/home/followed/FollowedViewModel.kt +++ /dev/null @@ -1,201 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.tivi.home.followed - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.paging.PagingConfig -import androidx.paging.PagingData -import androidx.paging.cachedIn -import app.tivi.api.UiMessageManager -import app.tivi.data.entities.RefreshType -import app.tivi.data.entities.SortOption -import app.tivi.data.entities.TiviShow -import app.tivi.data.resultentities.FollowedShowEntryWithShow -import app.tivi.domain.executeSync -import app.tivi.domain.interactors.ChangeShowFollowStatus -import app.tivi.domain.interactors.GetTraktAuthState -import app.tivi.domain.interactors.UpdateFollowedShows -import app.tivi.domain.observers.ObservePagedFollowedShows -import app.tivi.domain.observers.ObserveTraktAuthState -import app.tivi.domain.observers.ObserveUserDetails -import app.tivi.extensions.combine -import app.tivi.trakt.TraktAuthState -import app.tivi.util.Logger -import app.tivi.util.ObservableLoadingCounter -import app.tivi.util.ShowStateSelector -import app.tivi.util.collectStatus -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - -@HiltViewModel -internal class FollowedViewModel @Inject constructor( - private val updateFollowedShows: UpdateFollowedShows, - private val observePagedFollowedShows: ObservePagedFollowedShows, - private val observeTraktAuthState: ObserveTraktAuthState, - private val changeShowFollowStatus: ChangeShowFollowStatus, - observeUserDetails: ObserveUserDetails, - private val getTraktAuthState: GetTraktAuthState, - private val logger: Logger, -) : ViewModel() { - private val loadingState = ObservableLoadingCounter() - private val uiMessageManager = UiMessageManager() - private val showSelection = ShowStateSelector() - - val pagedList: Flow> = - observePagedFollowedShows.flow.cachedIn(viewModelScope) - - private val availableSorts = listOf( - SortOption.SUPER_SORT, - SortOption.LAST_WATCHED, - SortOption.ALPHABETICAL, - SortOption.DATE_ADDED, - ) - - private val filter = MutableStateFlow(null) - private val sort = MutableStateFlow(SortOption.SUPER_SORT) - - val state: StateFlow = combine( - loadingState.observable, - showSelection.observeSelectedShowIds(), - showSelection.observeIsSelectionOpen(), - observeTraktAuthState.flow, - observeUserDetails.flow, - filter, - sort, - uiMessageManager.message, - ) { loading, selectedShowIds, isSelectionOpen, authState, user, filter, sort, message -> - FollowedViewState( - user = user, - authState = authState, - isLoading = loading, - selectionOpen = isSelectionOpen, - selectedShowIds = selectedShowIds, - filter = filter, - filterActive = !filter.isNullOrEmpty(), - availableSorts = availableSorts, - sort = sort, - message = message, - ) - }.stateIn( - scope = viewModelScope, - started = WhileSubscribed(), - initialValue = FollowedViewState.Empty, - ) - - init { - observeTraktAuthState(Unit) - observeUserDetails(ObserveUserDetails.Params("me")) - - // When the filter and sort options change, update the data source - viewModelScope.launch { - filter.collect { updateDataSource() } - } - viewModelScope.launch { - sort.collect { updateDataSource() } - } - - viewModelScope.launch { - // When the user logs in, refresh... - observeTraktAuthState.flow - .filter { it == TraktAuthState.LOGGED_IN } - .collect { refresh(false) } - } - } - - private fun updateDataSource() { - observePagedFollowedShows( - ObservePagedFollowedShows.Parameters( - sort = sort.value, - filter = filter.value, - pagingConfig = PAGING_CONFIG, - ), - ) - } - - fun refresh(fromUser: Boolean = true) { - viewModelScope.launch { - if (getTraktAuthState.executeSync() == TraktAuthState.LOGGED_IN) { - refreshFollowed(fromUser) - } - } - } - - fun setFilter(filter: String?) { - viewModelScope.launch { - this@FollowedViewModel.filter.emit(filter) - } - } - - fun setSort(sort: SortOption) { - viewModelScope.launch { - this@FollowedViewModel.sort.emit(sort) - } - } - - fun clearSelection() { - showSelection.clearSelection() - } - - fun onItemClick(show: TiviShow): Boolean { - return showSelection.onItemClick(show) - } - - fun onItemLongClick(show: TiviShow): Boolean { - return showSelection.onItemLongClick(show) - } - - fun unfollowSelectedShows() { - viewModelScope.launch { - changeShowFollowStatus.executeSync( - ChangeShowFollowStatus.Params( - showSelection.getSelectedShowIds(), - ChangeShowFollowStatus.Action.UNFOLLOW, - ), - ) - } - showSelection.clearSelection() - } - - private fun refreshFollowed(fromInteraction: Boolean) { - viewModelScope.launch { - updateFollowedShows( - UpdateFollowedShows.Params(fromInteraction, RefreshType.QUICK), - ).collectStatus(loadingState, logger, uiMessageManager) - } - } - - fun clearMessage(id: Long) { - viewModelScope.launch { - uiMessageManager.clearMessage(id) - } - } - - companion object { - private val PAGING_CONFIG = PagingConfig( - pageSize = 16, - initialLoadSize = 32, - ) - } -} diff --git a/ui/followed/src/main/java/app/tivi/home/followed/FollowedViewState.kt b/ui/followed/src/main/java/app/tivi/home/followed/FollowedViewState.kt deleted file mode 100644 index 7613203f4b..0000000000 --- a/ui/followed/src/main/java/app/tivi/home/followed/FollowedViewState.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.tivi.home.followed - -import app.tivi.api.UiMessage -import app.tivi.data.entities.SortOption -import app.tivi.data.entities.TraktUser -import app.tivi.trakt.TraktAuthState - -internal data class FollowedViewState( - val user: TraktUser? = null, - val authState: TraktAuthState = TraktAuthState.LOGGED_OUT, - val isLoading: Boolean = false, - val selectionOpen: Boolean = false, - val selectedShowIds: Set = emptySet(), - val filterActive: Boolean = false, - val filter: String? = null, - val availableSorts: List = emptyList(), - val sort: SortOption = SortOption.SUPER_SORT, - val message: UiMessage? = null, -) { - companion object { - val Empty = FollowedViewState() - } -} diff --git a/ui/watched/build.gradle.kts b/ui/watched/build.gradle.kts deleted file mode 100644 index cb0faef991..0000000000 --- a/ui/watched/build.gradle.kts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.kapt) - alias(libs.plugins.hilt) -} - -android { - namespace = "app.tivi.home.watched" - - buildFeatures { - compose = true - } - - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.composecompiler.get() - } -} - -dependencies { - implementation(projects.base) - implementation(projects.domain) - implementation(projects.common.ui.compose) - implementation(projects.common.ui.view) - - implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.androidx.lifecycle.viewmodel.compose) - - implementation(libs.androidx.paging.runtime) - implementation(libs.androidx.paging.compose) - - implementation(libs.compose.foundation.foundation) - implementation(libs.compose.foundation.layout) - implementation(libs.compose.material.material) - implementation(libs.compose.material3) - implementation(libs.compose.material.iconsext) - implementation(libs.compose.animation.animation) - implementation(libs.compose.ui.tooling) - - implementation(libs.coil.compose) - - implementation(libs.hilt.compose) - implementation(libs.hilt.library) - kapt(libs.hilt.compiler) -} diff --git a/ui/watched/src/main/java/app/tivi/home/watched/Watched.kt b/ui/watched/src/main/java/app/tivi/home/watched/Watched.kt deleted file mode 100644 index 6ca0903c87..0000000000 --- a/ui/watched/src/main/java/app/tivi/home/watched/Watched.kt +++ /dev/null @@ -1,284 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.tivi.home.watched - -import androidx.compose.foundation.ExperimentalFoundationApi -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.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.material.DismissValue -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.SwipeToDismiss -import androidx.compose.material.pullrefresh.PullRefreshIndicator -import androidx.compose.material.pullrefresh.pullRefresh -import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.material.rememberDismissState -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Snackbar -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.paging.compose.LazyPagingItems -import androidx.paging.compose.collectAsLazyPagingItems -import app.tivi.common.compose.Layout -import app.tivi.common.compose.LocalTiviDateFormatter -import app.tivi.common.compose.LocalTiviTextCreator -import app.tivi.common.compose.bodyWidth -import app.tivi.common.compose.fullSpanItem -import app.tivi.common.compose.items -import app.tivi.common.compose.ui.FilterSortPanel -import app.tivi.common.compose.ui.PosterCard -import app.tivi.common.compose.ui.TiviStandardAppBar -import app.tivi.common.compose.ui.plus -import app.tivi.common.ui.resources.R as UiR -import app.tivi.data.entities.ShowTmdbImage -import app.tivi.data.entities.SortOption -import app.tivi.data.entities.TiviShow -import app.tivi.data.resultentities.WatchedShowEntryWithShow -import app.tivi.trakt.TraktAuthState -import org.threeten.bp.OffsetDateTime - -@Composable -fun Watched( - openShowDetails: (showId: Long) -> Unit, - openUser: () -> Unit, -) { - Watched( - viewModel = hiltViewModel(), - openShowDetails = openShowDetails, - openUser = openUser, - ) -} - -@Composable -internal fun Watched( - viewModel: WatchedViewModel, - openShowDetails: (showId: Long) -> Unit, - openUser: () -> Unit, -) { - val viewState by viewModel.state.collectAsState() - val pagingItems = viewModel.pagedList.collectAsLazyPagingItems() - - Watched( - state = viewState, - list = pagingItems, - openShowDetails = openShowDetails, - onMessageShown = viewModel::clearMessage, - openUser = openUser, - refresh = viewModel::refresh, - onFilterChanged = viewModel::setFilter, - onSortSelected = viewModel::setSort, - ) -} - -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) -@Composable -internal fun Watched( - state: WatchedViewState, - list: LazyPagingItems, - openShowDetails: (showId: Long) -> Unit, - onMessageShown: (id: Long) -> Unit, - refresh: () -> Unit, - openUser: () -> Unit, - onFilterChanged: (String) -> Unit, - onSortSelected: (SortOption) -> Unit, -) { - val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() - val snackbarHostState = remember { SnackbarHostState() } - - val dismissSnackbarState = rememberDismissState { value -> - when { - value != DismissValue.Default -> { - snackbarHostState.currentSnackbarData?.dismiss() - true - } - else -> false - } - } - - state.message?.let { message -> - LaunchedEffect(message) { - snackbarHostState.showSnackbar(message.message) - // Notify the view model that the message has been dismissed - onMessageShown(message.id) - } - } - - Scaffold( - topBar = { - TiviStandardAppBar( - title = stringResource(UiR.string.watched_shows_title), - loggedIn = state.authState == TraktAuthState.LOGGED_IN, - user = state.user, - scrollBehavior = scrollBehavior, - refreshing = state.isLoading, - onRefreshActionClick = refresh, - onUserActionClick = openUser, - modifier = Modifier.fillMaxWidth(), - ) - }, - snackbarHost = { - SnackbarHost(hostState = snackbarHostState) { data -> - SwipeToDismiss( - state = dismissSnackbarState, - background = {}, - dismissContent = { Snackbar(snackbarData = data) }, - modifier = Modifier - .padding(horizontal = Layout.bodyMargin) - .fillMaxWidth(), - ) - } - }, - modifier = Modifier.fillMaxSize(), - ) { paddingValues -> - val refreshState = rememberPullRefreshState( - refreshing = state.isLoading, - onRefresh = refresh, - ) - Box(modifier = Modifier.pullRefresh(state = refreshState)) { - val columns = Layout.columns - val bodyMargin = Layout.bodyMargin - val gutter = Layout.gutter - - LazyVerticalGrid( - columns = GridCells.Fixed(columns / 4), - contentPadding = paddingValues + PaddingValues( - horizontal = (bodyMargin - 8.dp).coerceAtLeast(0.dp), - vertical = (gutter - 8.dp).coerceAtLeast(0.dp), - ), - // We minus 8.dp off the grid padding, as we use content padding on the items below - horizontalArrangement = Arrangement.spacedBy((gutter - 8.dp).coerceAtLeast(0.dp)), - verticalArrangement = Arrangement.spacedBy((gutter - 8.dp).coerceAtLeast(0.dp)), - modifier = Modifier - .nestedScroll(scrollBehavior.nestedScrollConnection) - .bodyWidth() - .fillMaxHeight(), - ) { - fullSpanItem { - FilterSortPanel( - filterHint = stringResource(UiR.string.filter_shows, list.itemCount), - onFilterChanged = onFilterChanged, - sortOptions = state.availableSorts, - currentSortOption = state.sort, - onSortSelected = onSortSelected, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - ) - } - - items( - items = list, - key = { it.show.id }, - ) { entry -> - if (entry != null) { - WatchedShowItem( - show = entry.show, - poster = entry.poster, - lastWatched = entry.entry.lastWatched, - onClick = { openShowDetails(entry.show.id) }, - contentPadding = PaddingValues(8.dp), - modifier = Modifier - .animateItemPlacement() - .fillMaxWidth(), - ) - } - } - } - - PullRefreshIndicator( - refreshing = state.isLoading, - state = refreshState, - modifier = Modifier.align(Alignment.TopCenter).padding(paddingValues), - scale = true, - ) - } - } -} - -@Composable -private fun WatchedShowItem( - show: TiviShow, - poster: ShowTmdbImage?, - lastWatched: OffsetDateTime, - onClick: () -> Unit, - contentPadding: PaddingValues, - modifier: Modifier = Modifier, -) { - val textCreator = LocalTiviTextCreator.current - Row( - modifier - .clip(MaterialTheme.shapes.medium) - .clickable(onClick = onClick) - .padding(contentPadding), - ) { - PosterCard( - show = show, - poster = poster, - modifier = Modifier - .fillMaxWidth(0.2f) // 20% of the width - .aspectRatio(2 / 3f), - ) - - Spacer(Modifier.width(16.dp)) - - Column { - Text( - text = textCreator.showTitle(show = show).toString(), - style = MaterialTheme.typography.titleMedium, - ) - - Spacer(Modifier.height(2.dp)) - - Text( - text = stringResource( - UiR.string.library_last_watched, - LocalTiviDateFormatter.current.formatShortRelativeTime(lastWatched), - ), - style = MaterialTheme.typography.bodySmall, - ) - } - } -} diff --git a/ui/watched/src/main/java/app/tivi/home/watched/WatchedViewModel.kt b/ui/watched/src/main/java/app/tivi/home/watched/WatchedViewModel.kt deleted file mode 100644 index 43d5041d98..0000000000 --- a/ui/watched/src/main/java/app/tivi/home/watched/WatchedViewModel.kt +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.tivi.home.watched - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.paging.PagingConfig -import androidx.paging.PagingData -import androidx.paging.cachedIn -import app.tivi.api.UiMessageManager -import app.tivi.data.entities.SortOption -import app.tivi.data.entities.TiviShow -import app.tivi.data.resultentities.WatchedShowEntryWithShow -import app.tivi.domain.executeSync -import app.tivi.domain.interactors.ChangeShowFollowStatus -import app.tivi.domain.interactors.GetTraktAuthState -import app.tivi.domain.interactors.UpdateWatchedShows -import app.tivi.domain.observers.ObservePagedWatchedShows -import app.tivi.domain.observers.ObserveTraktAuthState -import app.tivi.domain.observers.ObserveUserDetails -import app.tivi.extensions.combine -import app.tivi.trakt.TraktAuthState -import app.tivi.util.Logger -import app.tivi.util.ObservableLoadingCounter -import app.tivi.util.ShowStateSelector -import app.tivi.util.collectStatus -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - -@HiltViewModel -class WatchedViewModel @Inject constructor( - private val updateWatchedShows: UpdateWatchedShows, - private val changeShowFollowStatus: ChangeShowFollowStatus, - private val observePagedWatchedShows: ObservePagedWatchedShows, - observeTraktAuthState: ObserveTraktAuthState, - private val getTraktAuthState: GetTraktAuthState, - observeUserDetails: ObserveUserDetails, - private val logger: Logger, -) : ViewModel() { - private val uiMessageManager = UiMessageManager() - - private val availableSorts = listOf(SortOption.LAST_WATCHED, SortOption.ALPHABETICAL) - - private val loadingState = ObservableLoadingCounter() - private val showSelection = ShowStateSelector() - - val pagedList: Flow> = - observePagedWatchedShows.flow.cachedIn(viewModelScope) - - private val filter = MutableStateFlow(null) - private val sort = MutableStateFlow(SortOption.LAST_WATCHED) - - val state: StateFlow = combine( - loadingState.observable, - showSelection.observeSelectedShowIds(), - showSelection.observeIsSelectionOpen(), - observeTraktAuthState.flow, - observeUserDetails.flow, - filter, - sort, - uiMessageManager.message, - ) { loading, selectedShowIds, isSelectionOpen, authState, user, filter, sort, message -> - WatchedViewState( - user = user, - authState = authState, - isLoading = loading, - selectionOpen = isSelectionOpen, - selectedShowIds = selectedShowIds, - filter = filter, - filterActive = !filter.isNullOrEmpty(), - availableSorts = availableSorts, - sort = sort, - message = message, - ) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(), - initialValue = WatchedViewState.Empty, - ) - - init { - observeTraktAuthState(Unit) - observeUserDetails(ObserveUserDetails.Params("me")) - - // When the filter and sort options change, update the data source - viewModelScope.launch { - filter.collect { updateDataSource() } - } - viewModelScope.launch { - sort.collect { updateDataSource() } - } - - viewModelScope.launch { - // When the user logs in, refresh... - observeTraktAuthState.flow - .filter { it == TraktAuthState.LOGGED_IN } - .collect { refresh(false) } - } - } - - private fun updateDataSource() { - observePagedWatchedShows( - ObservePagedWatchedShows.Params( - sort = sort.value, - filter = filter.value, - pagingConfig = PAGING_CONFIG, - ), - ) - } - - fun refresh(fromUser: Boolean = true) { - viewModelScope.launch { - if (getTraktAuthState.executeSync() == TraktAuthState.LOGGED_IN) { - refreshWatched(fromUser) - } - } - } - - fun setFilter(filter: String?) { - viewModelScope.launch { - this@WatchedViewModel.filter.emit(filter) - } - } - - fun setSort(sort: SortOption) { - viewModelScope.launch { - this@WatchedViewModel.sort.emit(sort) - } - } - - fun clearSelection() { - showSelection.clearSelection() - } - - fun onItemClick(show: TiviShow): Boolean { - return showSelection.onItemClick(show) - } - - fun onItemLongClick(show: TiviShow): Boolean { - return showSelection.onItemLongClick(show) - } - - fun followSelectedShows() { - viewModelScope.launch { - changeShowFollowStatus.executeSync( - ChangeShowFollowStatus.Params( - showSelection.getSelectedShowIds(), - ChangeShowFollowStatus.Action.FOLLOW, - deferDataFetch = true, - ), - ) - } - showSelection.clearSelection() - } - - private fun refreshWatched(fromUser: Boolean) { - viewModelScope.launch { - updateWatchedShows( - UpdateWatchedShows.Params(forceRefresh = fromUser), - ).collectStatus(loadingState, logger, uiMessageManager) - } - } - - fun clearMessage(id: Long) { - viewModelScope.launch { - uiMessageManager.clearMessage(id) - } - } - - companion object { - private val PAGING_CONFIG = PagingConfig( - pageSize = 16, - initialLoadSize = 32, - ) - } -} diff --git a/ui/watched/src/main/java/app/tivi/home/watched/WatchedViewState.kt b/ui/watched/src/main/java/app/tivi/home/watched/WatchedViewState.kt deleted file mode 100644 index eacc340069..0000000000 --- a/ui/watched/src/main/java/app/tivi/home/watched/WatchedViewState.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.tivi.home.watched - -import app.tivi.api.UiMessage -import app.tivi.data.entities.SortOption -import app.tivi.data.entities.TraktUser -import app.tivi.trakt.TraktAuthState - -data class WatchedViewState( - val user: TraktUser? = null, - val authState: TraktAuthState = TraktAuthState.LOGGED_OUT, - val isLoading: Boolean = false, - val isEmpty: Boolean = false, - val selectionOpen: Boolean = false, - val selectedShowIds: Set = emptySet(), - val filterActive: Boolean = false, - val filter: String? = null, - val availableSorts: List = emptyList(), - val sort: SortOption = SortOption.LAST_WATCHED, - val message: UiMessage? = null, -) { - companion object { - val Empty = WatchedViewState() - } -} From 50b59cf949acc651faeca9d181c630ae2e6c632d Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Sat, 31 Dec 2022 09:28:33 +0000 Subject: [PATCH 09/13] Move sort to SortChip --- .../tivi/common/compose/ui/FilterSortPanel.kt | 114 ------------------ .../app/tivi/common/compose/ui/SortChip.kt | 80 ++++++++++++ ui/library/build.gradle.kts | 2 + .../java/app/tivi/home/library/Library.kt | 63 ++++++---- 4 files changed, 121 insertions(+), 138 deletions(-) delete mode 100644 common/ui/compose/src/main/java/app/tivi/common/compose/ui/FilterSortPanel.kt create mode 100644 common/ui/compose/src/main/java/app/tivi/common/compose/ui/SortChip.kt diff --git a/common/ui/compose/src/main/java/app/tivi/common/compose/ui/FilterSortPanel.kt b/common/ui/compose/src/main/java/app/tivi/common/compose/ui/FilterSortPanel.kt deleted file mode 100644 index 51d4213029..0000000000 --- a/common/ui/compose/src/main/java/app/tivi/common/compose/ui/FilterSortPanel.kt +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright 2022 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.tivi.common.compose.ui - -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.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Sort -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -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.res.stringResource -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.unit.dp -import app.tivi.data.entities.SortOption - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun FilterSortPanel( - filterHint: String, - onFilterChanged: (String) -> Unit, - modifier: Modifier = Modifier, - sortOptions: List, - currentSortOption: SortOption, - onSortSelected: (SortOption) -> Unit, -) { - Column(modifier.padding(vertical = 8.dp)) { - var filter by rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue()) - } - - SearchTextField( - value = filter, - onValueChange = { value -> - filter = value - onFilterChanged(value.text) - }, - hint = filterHint, - modifier = Modifier.fillMaxWidth(), - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Spacer(modifier = Modifier.weight(1f)) - - var expanded by remember { mutableStateOf(false) } - Surface( - onClick = { expanded = true }, - shape = MaterialTheme.shapes.small, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - ) { - Icon( - imageVector = Icons.Default.Sort, - contentDescription = "", - modifier = Modifier.size(14.dp), - ) - Text( - text = stringResource(currentSortOption.labelResId), - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(6.dp), - ) - } - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - SortDropdownMenuContent( - sortOptions = sortOptions, - currentSortOption = currentSortOption, - onItemClick = { - onSortSelected(it) - expanded = false - }, - ) - } - } - } - } -} diff --git a/common/ui/compose/src/main/java/app/tivi/common/compose/ui/SortChip.kt b/common/ui/compose/src/main/java/app/tivi/common/compose/ui/SortChip.kt new file mode 100644 index 0000000000..1bec190ab6 --- /dev/null +++ b/common/ui/compose/src/main/java/app/tivi/common/compose/ui/SortChip.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.tivi.common.compose.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Sort +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.tivi.data.entities.SortOption + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SortChip( + sortOptions: List, + currentSortOption: SortOption, + modifier: Modifier = Modifier, + onSortSelected: (SortOption) -> Unit, +) { + Box(modifier) { + var expanded by remember { mutableStateOf(false) } + + FilterChip( + selected = false, + onClick = { expanded = true }, + label = { + Text( + text = stringResource(currentSortOption.labelResId), + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Sort, + contentDescription = "", + modifier = Modifier.size(14.dp), + ) + }, + ) + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + SortDropdownMenuContent( + sortOptions = sortOptions, + currentSortOption = currentSortOption, + onItemClick = { + onSortSelected(it) + expanded = false + }, + ) + } + } +} diff --git a/ui/library/build.gradle.kts b/ui/library/build.gradle.kts index f4c74e0419..d520b89fa7 100644 --- a/ui/library/build.gradle.kts +++ b/ui/library/build.gradle.kts @@ -56,6 +56,8 @@ dependencies { implementation(libs.compose.animation.animation) implementation(libs.compose.ui.tooling) + implementation(libs.accompanist.flowlayout) + implementation(libs.coil.compose) implementation(libs.hilt.compose) diff --git a/ui/library/src/main/java/app/tivi/home/library/Library.kt b/ui/library/src/main/java/app/tivi/home/library/Library.kt index 288dcacb70..1bfb34a129 100644 --- a/ui/library/src/main/java/app/tivi/home/library/Library.kt +++ b/ui/library/src/main/java/app/tivi/home/library/Library.kt @@ -56,12 +56,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +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.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.compose.LazyPagingItems @@ -71,8 +75,9 @@ import app.tivi.common.compose.LocalTiviTextCreator import app.tivi.common.compose.bodyWidth import app.tivi.common.compose.fullSpanItem import app.tivi.common.compose.items -import app.tivi.common.compose.ui.FilterSortPanel import app.tivi.common.compose.ui.PosterCard +import app.tivi.common.compose.ui.SearchTextField +import app.tivi.common.compose.ui.SortChip import app.tivi.common.compose.ui.TiviStandardAppBar import app.tivi.common.compose.ui.plus import app.tivi.common.ui.resources.R as UiR @@ -81,6 +86,7 @@ import app.tivi.data.entities.SortOption import app.tivi.data.entities.TiviShow import app.tivi.data.resultentities.LibraryShow import app.tivi.trakt.TraktAuthState +import com.google.accompanist.flowlayout.FlowRow @Composable fun Library( @@ -206,33 +212,42 @@ internal fun Library( .fillMaxHeight(), ) { fullSpanItem { - Column { - FilterSortPanel( - filterHint = stringResource(UiR.string.filter_shows, list.itemCount), - onFilterChanged = onFilterChanged, - sortOptions = state.availableSorts, - currentSortOption = state.sort, - onSortSelected = onSortSelected, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), + FlowRow( + mainAxisSpacing = 4.dp, + crossAxisSpacing = 4.dp, + modifier = Modifier.padding(vertical = 8.dp), + ) { + var filter by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue()) + } + + SearchTextField( + value = filter, + onValueChange = { value -> + filter = value + onFilterChanged(value.text) + }, + hint = stringResource(UiR.string.filter_shows, list.itemCount), + modifier = Modifier.fillMaxWidth(), ) - Row { - FilterChip( - selected = state.followedShowsIncluded, - onClick = onToggleIncludeFollowedShows, - label = { Text(text = "Followed") }, - ) + FilterChip( + selected = state.followedShowsIncluded, + onClick = onToggleIncludeFollowedShows, + label = { Text(text = "Followed") }, + ) - Spacer(Modifier.width(4.dp)) + FilterChip( + selected = state.watchedShowsIncluded, + onClick = onToggleIncludeWatchedShows, + label = { Text(text = "Watched") }, + ) - FilterChip( - selected = state.watchedShowsIncluded, - onClick = onToggleIncludeWatchedShows, - label = { Text(text = "Watched") }, - ) - } + SortChip( + sortOptions = state.availableSorts, + currentSortOption = state.sort, + onSortSelected = onSortSelected, + ) } } From 805f2dbdca469a3760bed2584eced8b5d55d8b0a Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Sat, 31 Dec 2022 16:22:55 +0000 Subject: [PATCH 10/13] Add search button --- .../tivi/common/compose/ui/SearchTextField.kt | 6 ++- .../java/app/tivi/home/library/Library.kt | 49 +++++++++++++++---- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/common/ui/compose/src/main/java/app/tivi/common/compose/ui/SearchTextField.kt b/common/ui/compose/src/main/java/app/tivi/common/compose/ui/SearchTextField.kt index ed57079cc4..af0c217ab6 100644 --- a/common/ui/compose/src/main/java/app/tivi/common/compose/ui/SearchTextField.kt +++ b/common/ui/compose/src/main/java/app/tivi/common/compose/ui/SearchTextField.kt @@ -44,6 +44,8 @@ fun SearchTextField( modifier: Modifier = Modifier, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions(), + showClearButton: Boolean = value.text.isNotEmpty(), + onCleared: (() -> Unit) = { onValueChange(TextFieldValue()) }, ) { OutlinedTextField( value = value, @@ -56,11 +58,11 @@ fun SearchTextField( }, trailingIcon = { AnimatedVisibility( - visible = value.text.isNotEmpty(), + visible = showClearButton, enter = fadeIn(), exit = fadeOut(), ) { - IconButton(onClick = { onValueChange(TextFieldValue()) }) { + IconButton(onClick = onCleared) { Icon( imageVector = Icons.Default.Clear, contentDescription = stringResource(UiR.string.cd_clear_text), diff --git a/ui/library/src/main/java/app/tivi/home/library/Library.kt b/ui/library/src/main/java/app/tivi/home/library/Library.kt index 1bfb34a129..8cf1fd6307 100644 --- a/ui/library/src/main/java/app/tivi/home/library/Library.kt +++ b/ui/library/src/main/java/app/tivi/home/library/Library.kt @@ -18,7 +18,10 @@ package app.tivi.home.library +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -32,12 +35,15 @@ import androidx.compose.foundation.layout.fillMaxSize 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.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material.DismissValue import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.SwipeToDismiss +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -46,6 +52,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarHost @@ -123,7 +130,7 @@ internal fun Library( ) } -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class, ExperimentalAnimationApi::class) @Composable internal fun Library( state: LibraryViewState, @@ -217,19 +224,41 @@ internal fun Library( crossAxisSpacing = 4.dp, modifier = Modifier.padding(vertical = 8.dp), ) { + var filterExpanded by remember { mutableStateOf(false) } + var filter by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } - SearchTextField( - value = filter, - onValueChange = { value -> - filter = value - onFilterChanged(value.text) - }, - hint = stringResource(UiR.string.filter_shows, list.itemCount), - modifier = Modifier.fillMaxWidth(), - ) + AnimatedContent( + targetState = filterExpanded, + ) { state -> + if (state) { + SearchTextField( + value = filter, + onValueChange = { value -> + filter = value + onFilterChanged(value.text) + }, + hint = stringResource(UiR.string.filter_shows, list.itemCount), + modifier = Modifier.fillMaxWidth(), + showClearButton = true, + onCleared = { + filter = TextFieldValue() + onFilterChanged("") + filterExpanded = false + }, + ) + } else { + OutlinedButton(onClick = { filterExpanded = true }) { + Image( + imageVector = Icons.Default.Search, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + } + } + } FilterChip( selected = state.followedShowsIncluded, From dbe9c7c263e9ccaa9c3517e56ec24ed53090add9 Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Wed, 4 Jan 2023 15:08:22 +0000 Subject: [PATCH 11/13] Tidy up filter panel --- .../tivi/common/compose/ui/SearchTextField.kt | 4 +- .../app/tivi/common/compose/ui/SortChip.kt | 14 ++- .../java/app/tivi/home/library/Library.kt | 108 +++++++++++------- 3 files changed, 79 insertions(+), 47 deletions(-) diff --git a/common/ui/compose/src/main/java/app/tivi/common/compose/ui/SearchTextField.kt b/common/ui/compose/src/main/java/app/tivi/common/compose/ui/SearchTextField.kt index af0c217ab6..3b90eef5b4 100644 --- a/common/ui/compose/src/main/java/app/tivi/common/compose/ui/SearchTextField.kt +++ b/common/ui/compose/src/main/java/app/tivi/common/compose/ui/SearchTextField.kt @@ -27,8 +27,8 @@ import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -47,7 +47,7 @@ fun SearchTextField( showClearButton: Boolean = value.text.isNotEmpty(), onCleared: (() -> Unit) = { onValueChange(TextFieldValue()) }, ) { - OutlinedTextField( + TextField( value = value, onValueChange = onValueChange, leadingIcon = { diff --git a/common/ui/compose/src/main/java/app/tivi/common/compose/ui/SortChip.kt b/common/ui/compose/src/main/java/app/tivi/common/compose/ui/SortChip.kt index 1bec190ab6..35a61f5336 100644 --- a/common/ui/compose/src/main/java/app/tivi/common/compose/ui/SortChip.kt +++ b/common/ui/compose/src/main/java/app/tivi/common/compose/ui/SortChip.kt @@ -16,9 +16,11 @@ package app.tivi.common.compose.ui +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Sort import androidx.compose.material3.DropdownMenu import androidx.compose.material3.ExperimentalMaterial3Api @@ -47,18 +49,26 @@ fun SortChip( var expanded by remember { mutableStateOf(false) } FilterChip( - selected = false, + selected = true, onClick = { expanded = true }, label = { Text( text = stringResource(currentSortOption.labelResId), + modifier = Modifier.animateContentSize(), ) }, leadingIcon = { Icon( imageVector = Icons.Default.Sort, contentDescription = "", - modifier = Modifier.size(14.dp), + modifier = Modifier.size(16.dp), + ) + }, + trailingIcon = { + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, // decorative + modifier = Modifier.size(16.dp), ) }, ) diff --git a/ui/library/src/main/java/app/tivi/home/library/Library.kt b/ui/library/src/main/java/app/tivi/home/library/Library.kt index 8cf1fd6307..3ce210daea 100644 --- a/ui/library/src/main/java/app/tivi/home/library/Library.kt +++ b/ui/library/src/main/java/app/tivi/home/library/Library.kt @@ -18,16 +18,17 @@ package app.tivi.home.library -import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight @@ -35,10 +36,10 @@ import androidx.compose.foundation.layout.fillMaxSize 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.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.rememberScrollState import androidx.compose.material.DismissValue import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.SwipeToDismiss @@ -50,9 +51,10 @@ import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.rememberDismissState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarHost @@ -93,7 +95,6 @@ import app.tivi.data.entities.SortOption import app.tivi.data.entities.TiviShow import app.tivi.data.resultentities.LibraryShow import app.tivi.trakt.TraktAuthState -import com.google.accompanist.flowlayout.FlowRow @Composable fun Library( @@ -204,6 +205,8 @@ internal fun Library( val bodyMargin = Layout.bodyMargin val gutter = Layout.gutter + var filterExpanded by remember { mutableStateOf(false) } + LazyVerticalGrid( columns = GridCells.Fixed(columns / 4), contentPadding = paddingValues + PaddingValues( @@ -219,47 +222,39 @@ internal fun Library( .fillMaxHeight(), ) { fullSpanItem { - FlowRow( - mainAxisSpacing = 4.dp, - crossAxisSpacing = 4.dp, - modifier = Modifier.padding(vertical = 8.dp), - ) { - var filterExpanded by remember { mutableStateOf(false) } - - var filter by rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue()) - } + var filter by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue(state.filter ?: "")) + } - AnimatedContent( - targetState = filterExpanded, - ) { state -> - if (state) { - SearchTextField( - value = filter, - onValueChange = { value -> - filter = value - onFilterChanged(value.text) - }, - hint = stringResource(UiR.string.filter_shows, list.itemCount), - modifier = Modifier.fillMaxWidth(), - showClearButton = true, - onCleared = { - filter = TextFieldValue() - onFilterChanged("") - filterExpanded = false - }, + FilterSortPanel( + filterIcon = { + IconButton(onClick = { filterExpanded = true }) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, // FIXME ) - } else { - OutlinedButton(onClick = { filterExpanded = true }) { - Image( - imageVector = Icons.Default.Search, - contentDescription = null, - modifier = Modifier.size(14.dp), - ) - } } - } - + }, + filterTextField = { + SearchTextField( + value = filter, + onValueChange = { value -> + filter = value + onFilterChanged(value.text) + }, + hint = stringResource(UiR.string.filter_shows, list.itemCount), + modifier = Modifier.fillMaxWidth(), + showClearButton = true, + onCleared = { + filter = TextFieldValue() + onFilterChanged("") + filterExpanded = false + }, + ) + }, + filterExpanded = filterExpanded, + modifier = Modifier.padding(vertical = 8.dp), + ) { FilterChip( selected = state.followedShowsIncluded, onClick = onToggleIncludeFollowedShows, @@ -312,6 +307,33 @@ internal fun Library( } } +@Composable +private fun FilterSortPanel( + filterExpanded: Boolean, + filterIcon: @Composable () -> Unit, + filterTextField: @Composable () -> Unit, + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit, +) { + Column(modifier = modifier) { + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + AnimatedVisibility(!filterExpanded) { + filterIcon() + } + + content() + } + + AnimatedVisibility(visible = filterExpanded) { + filterTextField() + } + } +} + @Composable private fun FollowedShowItem( show: TiviShow, From ca0c15ed1be2c6b956c71d1f5d37ca2416e954b0 Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Wed, 4 Jan 2023 15:16:52 +0000 Subject: [PATCH 12/13] Show last watched date --- .../java/app/tivi/home/library/Library.kt | 56 ++++++++++++------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/ui/library/src/main/java/app/tivi/home/library/Library.kt b/ui/library/src/main/java/app/tivi/home/library/Library.kt index 3ce210daea..2a4a914099 100644 --- a/ui/library/src/main/java/app/tivi/home/library/Library.kt +++ b/ui/library/src/main/java/app/tivi/home/library/Library.kt @@ -80,6 +80,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import app.tivi.common.compose.Layout +import app.tivi.common.compose.LocalTiviDateFormatter import app.tivi.common.compose.LocalTiviTextCreator import app.tivi.common.compose.bodyWidth import app.tivi.common.compose.fullSpanItem @@ -95,6 +96,7 @@ import app.tivi.data.entities.SortOption import app.tivi.data.entities.TiviShow import app.tivi.data.resultentities.LibraryShow import app.tivi.trakt.TraktAuthState +import org.threeten.bp.OffsetDateTime @Composable fun Library( @@ -280,11 +282,12 @@ internal fun Library( key = { it.show.id }, ) { entry -> if (entry != null) { - FollowedShowItem( + LibraryItem( show = entry.show, poster = entry.poster, - watchedEpisodeCount = entry.stats?.watchedEpisodeCount ?: 0, - totalEpisodeCount = entry.stats?.episodeCount ?: 0, + watchedEpisodeCount = entry.stats?.watchedEpisodeCount, + totalEpisodeCount = entry.stats?.episodeCount, + lastWatchedDate = entry.watchedEntry?.lastWatched, onClick = { openShowDetails(entry.show.id) }, contentPadding = PaddingValues(8.dp), modifier = Modifier @@ -335,11 +338,12 @@ private fun FilterSortPanel( } @Composable -private fun FollowedShowItem( +private fun LibraryItem( show: TiviShow, poster: ShowTmdbImage?, - watchedEpisodeCount: Int, - totalEpisodeCount: Int, + watchedEpisodeCount: Int?, + totalEpisodeCount: Int?, + lastWatchedDate: OffsetDateTime?, onClick: () -> Unit, contentPadding: PaddingValues, modifier: Modifier = Modifier, @@ -370,23 +374,33 @@ private fun FollowedShowItem( Spacer(Modifier.height(4.dp)) - LinearProgressIndicator( - progress = when { - totalEpisodeCount > 0 -> watchedEpisodeCount / totalEpisodeCount.toFloat() - else -> 0f - }, - modifier = Modifier.fillMaxWidth(), - ) + if (watchedEpisodeCount != null && totalEpisodeCount != null) { + LinearProgressIndicator( + progress = when { + totalEpisodeCount > 0 -> watchedEpisodeCount / totalEpisodeCount.toFloat() + else -> 0f + }, + modifier = Modifier.fillMaxWidth(), + ) - Spacer(Modifier.height(4.dp)) + Spacer(Modifier.height(4.dp)) - Text( - text = textCreator.followedShowEpisodeWatchStatus( - episodeCount = totalEpisodeCount, - watchedEpisodeCount = watchedEpisodeCount, - ).toString(), - style = MaterialTheme.typography.bodySmall, - ) + Text( + text = textCreator.followedShowEpisodeWatchStatus( + episodeCount = totalEpisodeCount, + watchedEpisodeCount = watchedEpisodeCount, + ).toString(), + style = MaterialTheme.typography.bodySmall, + ) + } else if (lastWatchedDate != null) { + Text( + text = stringResource( + UiR.string.library_last_watched, + LocalTiviDateFormatter.current.formatShortRelativeTime(lastWatchedDate), + ), + style = MaterialTheme.typography.bodySmall, + ) + } Spacer(Modifier.height(8.dp)) } From c7ffb2d12dbe16e989757ebffe3b5ed928ecd0b7 Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Wed, 4 Jan 2023 15:31:35 +0000 Subject: [PATCH 13/13] Persist selected filters for Library --- .../java/app/tivi/settings/TiviPreferences.kt | 6 +++ .../app/tivi/home/library/LibraryViewModel.kt | 25 ++++----- .../app/tivi/settings/TiviPreferencesImpl.kt | 52 ++++++++++++++----- 3 files changed, 54 insertions(+), 29 deletions(-) diff --git a/base/src/main/java/app/tivi/settings/TiviPreferences.kt b/base/src/main/java/app/tivi/settings/TiviPreferences.kt index a8db453ee8..452c1e64ae 100644 --- a/base/src/main/java/app/tivi/settings/TiviPreferences.kt +++ b/base/src/main/java/app/tivi/settings/TiviPreferences.kt @@ -28,6 +28,12 @@ interface TiviPreferences { var useLessData: Boolean fun observeUseLessData(): Flow + var libraryFollowedActive: Boolean + fun observeLibraryFollowedActive(): Flow + + var libraryWatchedActive: Boolean + fun observeLibraryWatchedActive(): Flow + enum class Theme { LIGHT, DARK, diff --git a/ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt b/ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt index f8b19996ac..39e4a32345 100644 --- a/ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt +++ b/ui/library/src/main/java/app/tivi/home/library/LibraryViewModel.kt @@ -34,6 +34,7 @@ import app.tivi.domain.observers.ObservePagedLibraryShows import app.tivi.domain.observers.ObserveTraktAuthState import app.tivi.domain.observers.ObserveUserDetails import app.tivi.extensions.combine +import app.tivi.settings.TiviPreferences import app.tivi.trakt.TraktAuthState import app.tivi.util.Logger import app.tivi.util.ObservableLoadingCounter @@ -59,6 +60,7 @@ internal class LibraryViewModel @Inject constructor( private val changeShowFollowStatus: ChangeShowFollowStatus, observeUserDetails: ObserveUserDetails, private val getTraktAuthState: GetTraktAuthState, + private val preferences: TiviPreferences, private val logger: Logger, ) : ViewModel() { private val followedLoadingState = ObservableLoadingCounter() @@ -76,9 +78,6 @@ internal class LibraryViewModel @Inject constructor( private val filter = MutableStateFlow(null) private val sort = MutableStateFlow(SortOption.LAST_WATCHED) - private val includeWatchedShows = MutableStateFlow(true) - private val includeFollowedShows = MutableStateFlow(true) - val state: StateFlow = combine( followedLoadingState.observable, watchedLoadingState.observable, @@ -87,8 +86,8 @@ internal class LibraryViewModel @Inject constructor( filter, sort, uiMessageManager.message, - includeWatchedShows, - includeFollowedShows, + preferences.observeLibraryWatchedActive(), + preferences.observeLibraryFollowedActive(), ) { followedLoading, watchedLoading, authState, user, filter, sort, message, includeWatchedShows, includeFollowedShows -> LibraryViewState( user = user, @@ -121,11 +120,11 @@ internal class LibraryViewModel @Inject constructor( .onEach { updateDataSource() } .launchIn(viewModelScope) - includeFollowedShows + preferences.observeLibraryWatchedActive() .onEach { updateDataSource() } .launchIn(viewModelScope) - includeWatchedShows + preferences.observeLibraryFollowedActive() .onEach { updateDataSource() } .launchIn(viewModelScope) @@ -141,8 +140,8 @@ internal class LibraryViewModel @Inject constructor( ObservePagedLibraryShows.Parameters( sort = sort.value, filter = filter.value, - includeFollowed = includeFollowedShows.value, - includeWatched = includeWatchedShows.value, + includeFollowed = preferences.libraryFollowedActive, + includeWatched = preferences.libraryWatchedActive, pagingConfig = PAGING_CONFIG, ), ) @@ -174,15 +173,11 @@ internal class LibraryViewModel @Inject constructor( } fun toggleFollowedShowsIncluded() { - viewModelScope.launch { - includeFollowedShows.emit(!includeFollowedShows.value) - } + preferences.libraryFollowedActive = !preferences.libraryFollowedActive } fun toggleWatchedShowsIncluded() { - viewModelScope.launch { - includeWatchedShows.emit(!includeWatchedShows.value) - } + preferences.libraryWatchedActive = !preferences.libraryWatchedActive } private fun refreshFollowed(fromInteraction: Boolean) { diff --git a/ui/settings/src/main/java/app/tivi/settings/TiviPreferencesImpl.kt b/ui/settings/src/main/java/app/tivi/settings/TiviPreferencesImpl.kt index 6b22190bf8..67b9a9eff4 100644 --- a/ui/settings/src/main/java/app/tivi/settings/TiviPreferencesImpl.kt +++ b/ui/settings/src/main/java/app/tivi/settings/TiviPreferencesImpl.kt @@ -45,6 +45,8 @@ class TiviPreferencesImpl @Inject constructor( companion object { const val KEY_THEME = "pref_theme" const val KEY_DATA_SAVER = "pref_data_saver" + const val KEY_LIBRARY_FOLLOWED_ACTIVE = "pref_library_followed_active" + const val KEY_LIBRARY_WATCHED_ACTIVE = "pref_library_watched_active" } override fun setup() { @@ -63,24 +65,46 @@ class TiviPreferencesImpl @Inject constructor( putBoolean(KEY_DATA_SAVER, value) } - override fun observeUseLessData(): Flow { - return preferenceKeyChangedFlow - // Emit on start so that we always send the initial value - .onStart { emit(KEY_DATA_SAVER) } - .filter { it == KEY_DATA_SAVER } - .map { useLessData } - .distinctUntilChanged() + override fun observeUseLessData(): Flow = createPreferenceFlow(KEY_DATA_SAVER) { + useLessData } - override fun observeTheme(): Flow { - return preferenceKeyChangedFlow - // Emit on start so that we always send the initial value - .onStart { emit(KEY_THEME) } - .filter { it == KEY_THEME } - .map { theme } - .distinctUntilChanged() + override fun observeTheme(): Flow = createPreferenceFlow(KEY_THEME) { theme } + + override var libraryFollowedActive: Boolean + get() = sharedPreferences.getBoolean(KEY_LIBRARY_FOLLOWED_ACTIVE, true) + set(value) = sharedPreferences.edit { + putBoolean(KEY_LIBRARY_FOLLOWED_ACTIVE, value) + } + + override fun observeLibraryFollowedActive(): Flow { + return createPreferenceFlow(KEY_LIBRARY_FOLLOWED_ACTIVE) { + libraryFollowedActive + } + } + + override var libraryWatchedActive: Boolean + get() = sharedPreferences.getBoolean(KEY_LIBRARY_WATCHED_ACTIVE, true) + set(value) = sharedPreferences.edit { + putBoolean(KEY_LIBRARY_WATCHED_ACTIVE, value) + } + + override fun observeLibraryWatchedActive(): Flow { + return createPreferenceFlow(KEY_LIBRARY_WATCHED_ACTIVE) { + libraryWatchedActive + } } + private inline fun createPreferenceFlow( + key: String, + crossinline getValue: () -> T, + ): Flow = preferenceKeyChangedFlow + // Emit on start so that we always send the initial value + .onStart { emit(key) } + .filter { it == key } + .map { getValue() } + .distinctUntilChanged() + private val Theme.storageKey: String get() = when (this) { Theme.LIGHT -> context.getString(R.string.pref_theme_light_value)