Skip to content

Commit ccdb03d

Browse files
authored
[Jetcaster] Refactor: create domain models (#1289)
Added the following domain layer objects and corresponding test cases: * `GetLatestFollowedEpisodesUseCase` * `PodcastCategoryFilterUseCase` * `FilterableCategoriesUseCase` This also required creating interfaces for repository objects to enable creating fakes for testing. Introducing these changes improves readability/maintainability of `HomeViewModel` and encourages splitting up responsibilities to smaller easily testable contained [domain] objects.
2 parents fe5ed81 + eae0554 commit ccdb03d

File tree

21 files changed

+944
-149
lines changed

21 files changed

+944
-149
lines changed

Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,9 @@ import com.example.jetcaster.R
7676
import com.example.jetcaster.core.data.database.model.Category
7777
import com.example.jetcaster.core.data.database.model.EpisodeToPodcast
7878
import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo
79+
import com.example.jetcaster.core.data.model.FilterableCategoriesModel
80+
import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult
7981
import com.example.jetcaster.designsystem.theme.Keyline1
80-
import com.example.jetcaster.ui.home.category.PodcastCategoryViewState
81-
import com.example.jetcaster.ui.home.discover.DiscoverViewState
8282
import com.example.jetcaster.ui.home.discover.discoverItems
8383
import com.example.jetcaster.ui.home.library.libraryItems
8484
import com.example.jetcaster.ui.theme.JetcasterTheme
@@ -102,8 +102,8 @@ fun Home(
102102
isRefreshing = viewState.refreshing,
103103
homeCategories = viewState.homeCategories,
104104
selectedHomeCategory = viewState.selectedHomeCategory,
105-
discoverViewState = viewState.discoverViewState,
106-
podcastCategoryViewState = viewState.podcastCategoryViewState,
105+
filterableCategoriesModel = viewState.filterableCategoriesModel,
106+
podcastCategoryFilterResult = viewState.podcastCategoryFilterResult,
107107
libraryEpisodes = viewState.libraryEpisodes,
108108
onHomeCategorySelected = viewModel::onHomeCategorySelected,
109109
onCategorySelected = viewModel::onCategorySelected,
@@ -159,15 +159,14 @@ fun HomeAppBar(
159159
)
160160
}
161161

162-
@OptIn(ExperimentalFoundationApi::class)
163162
@Composable
164163
fun Home(
165164
featuredPodcasts: PersistentList<PodcastWithExtraInfo>,
166165
isRefreshing: Boolean,
167166
selectedHomeCategory: HomeCategory,
168167
homeCategories: List<HomeCategory>,
169-
discoverViewState: DiscoverViewState,
170-
podcastCategoryViewState: PodcastCategoryViewState,
168+
filterableCategoriesModel: FilterableCategoriesModel,
169+
podcastCategoryFilterResult: PodcastCategoryFilterResult,
171170
libraryEpisodes: List<EpisodeToPodcast>,
172171
modifier: Modifier = Modifier,
173172
onPodcastUnfollowed: (String) -> Unit,
@@ -214,8 +213,8 @@ fun Home(
214213
isRefreshing = isRefreshing,
215214
selectedHomeCategory = selectedHomeCategory,
216215
homeCategories = homeCategories,
217-
discoverViewState = discoverViewState,
218-
podcastCategoryViewState = podcastCategoryViewState,
216+
filterableCategoriesModel = filterableCategoriesModel,
217+
podcastCategoryFilterResult = podcastCategoryFilterResult,
219218
libraryEpisodes = libraryEpisodes,
220219
scrimColor = scrimColor,
221220
onPodcastUnfollowed = onPodcastUnfollowed,
@@ -234,8 +233,8 @@ private fun HomeContent(
234233
isRefreshing: Boolean,
235234
selectedHomeCategory: HomeCategory,
236235
homeCategories: List<HomeCategory>,
237-
discoverViewState: DiscoverViewState,
238-
podcastCategoryViewState: PodcastCategoryViewState,
236+
filterableCategoriesModel: FilterableCategoriesModel,
237+
podcastCategoryFilterResult: PodcastCategoryFilterResult,
239238
libraryEpisodes: List<EpisodeToPodcast>,
240239
scrimColor: Color,
241240
modifier: Modifier = Modifier,
@@ -286,8 +285,8 @@ private fun HomeContent(
286285

287286
HomeCategory.Discover -> {
288287
discoverItems(
289-
discoverViewState = discoverViewState,
290-
podcastCategoryViewState = podcastCategoryViewState,
288+
filterableCategoriesModel = filterableCategoriesModel,
289+
podcastCategoryFilterResult = podcastCategoryFilterResult,
291290
navigateToPlayer = navigateToPlayer,
292291
onCategorySelected = onCategorySelected,
293292
onTogglePodcastFollowed = onTogglePodcastFollowed
@@ -472,11 +471,11 @@ fun PreviewHomeContent() {
472471
isRefreshing = false,
473472
homeCategories = HomeCategory.entries,
474473
selectedHomeCategory = HomeCategory.Discover,
475-
discoverViewState = DiscoverViewState(
474+
filterableCategoriesModel = FilterableCategoriesModel(
476475
categories = PreviewCategories,
477-
selectedCategory = PreviewCategories.first(),
476+
selectedCategory = PreviewCategories.firstOrNull()
478477
),
479-
podcastCategoryViewState = PodcastCategoryViewState(
478+
podcastCategoryFilterResult = PodcastCategoryFilterResult(
480479
topPodcasts = PreviewPodcastsWithExtraInfo,
481480
episodes = PreviewEpisodeToPodcasts,
482481
),

Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt

Lines changed: 28 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,13 @@ import com.example.jetcaster.core.data.database.model.Category
2222
import com.example.jetcaster.core.data.database.model.EpisodeToPodcast
2323
import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo
2424
import com.example.jetcaster.core.data.di.Graph
25-
import com.example.jetcaster.core.data.repository.CategoryStore
26-
import com.example.jetcaster.core.data.repository.EpisodeStore
25+
import com.example.jetcaster.core.data.domain.FilterableCategoriesUseCase
26+
import com.example.jetcaster.core.data.domain.GetLatestFollowedEpisodesUseCase
27+
import com.example.jetcaster.core.data.domain.PodcastCategoryFilterUseCase
28+
import com.example.jetcaster.core.data.model.FilterableCategoriesModel
29+
import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult
2730
import com.example.jetcaster.core.data.repository.PodcastStore
2831
import com.example.jetcaster.core.data.repository.PodcastsRepository
29-
import com.example.jetcaster.ui.home.category.PodcastCategoryViewState
30-
import com.example.jetcaster.ui.home.discover.DiscoverViewState
3132
import com.example.jetcaster.util.combine
3233
import kotlinx.collections.immutable.PersistentList
3334
import kotlinx.collections.immutable.persistentListOf
@@ -36,17 +37,19 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
3637
import kotlinx.coroutines.flow.MutableStateFlow
3738
import kotlinx.coroutines.flow.StateFlow
3839
import kotlinx.coroutines.flow.catch
39-
import kotlinx.coroutines.flow.combine
4040
import kotlinx.coroutines.flow.flatMapLatest
41-
import kotlinx.coroutines.flow.flowOf
42-
import kotlinx.coroutines.flow.onEach
4341
import kotlinx.coroutines.launch
4442

43+
@OptIn(ExperimentalCoroutinesApi::class)
4544
class HomeViewModel(
4645
private val podcastsRepository: PodcastsRepository = Graph.podcastRepository,
47-
private val categoryStore: CategoryStore = Graph.categoryStore,
4846
private val podcastStore: PodcastStore = Graph.podcastStore,
49-
private val episodeStore: EpisodeStore = Graph.episodeStore
47+
private val getLatestFollowedEpisodesUseCase: GetLatestFollowedEpisodesUseCase =
48+
Graph.getLatestFollowedEpisodesUseCase,
49+
private val podcastCategoryFilterUseCase: PodcastCategoryFilterUseCase =
50+
Graph.podcastCategoryFilterUseCase,
51+
private val filterableCategoriesUseCase: FilterableCategoriesUseCase =
52+
Graph.filterableCategoriesUseCase
5053
) : ViewModel() {
5154
// Holds our currently selected home category
5255
private val selectedHomeCategory = MutableStateFlow(HomeCategory.Discover)
@@ -59,64 +62,6 @@ class HomeViewModel(
5962
// Holds the view state if the UI is refreshing for new data
6063
private val refreshing = MutableStateFlow(false)
6164

62-
@OptIn(ExperimentalCoroutinesApi::class)
63-
private val libraryEpisodes =
64-
podcastStore.followedPodcastsSortedByLastEpisode()
65-
.flatMapLatest { followedPodcasts ->
66-
if (followedPodcasts.isEmpty()) {
67-
flowOf(emptyList())
68-
} else {
69-
combine(
70-
followedPodcasts.map { p ->
71-
episodeStore.episodesInPodcast(p.podcast.uri, 5)
72-
}
73-
) { allEpisodes ->
74-
allEpisodes.toList().flatten().sortedByDescending { it.episode.published }
75-
}
76-
}
77-
}
78-
79-
private val discover = combine(
80-
categoryStore.categoriesSortedByPodcastCount()
81-
.onEach { categories ->
82-
// If we haven't got a selected category yet, select the first
83-
if (categories.isNotEmpty() && _selectedCategory.value == null) {
84-
_selectedCategory.value = categories[0]
85-
}
86-
},
87-
_selectedCategory
88-
) { categories, selectedCategory ->
89-
DiscoverViewState(
90-
categories = categories,
91-
selectedCategory = selectedCategory
92-
)
93-
}
94-
95-
@OptIn(ExperimentalCoroutinesApi::class)
96-
private val podcastCategory = _selectedCategory.flatMapLatest { category ->
97-
if (category == null) {
98-
return@flatMapLatest flowOf(PodcastCategoryViewState())
99-
}
100-
101-
val recentPodcastsFlow = categoryStore.podcastsInCategorySortedByPodcastCount(
102-
category.id,
103-
limit = 10
104-
)
105-
106-
val episodesFlow = categoryStore.episodesFromPodcastsInCategory(
107-
category.id,
108-
limit = 20
109-
)
110-
111-
// Combine our flows and collect them into the view state StateFlow
112-
combine(recentPodcastsFlow, episodesFlow) { topPodcasts, episodes ->
113-
PodcastCategoryViewState(
114-
topPodcasts = topPodcasts,
115-
episodes = episodes
116-
)
117-
}
118-
}
119-
12065
val state: StateFlow<HomeViewState>
12166
get() = _state
12267

@@ -129,23 +74,30 @@ class HomeViewModel(
12974
selectedHomeCategory,
13075
podcastStore.followedPodcastsSortedByLastEpisode(limit = 20),
13176
refreshing,
132-
discover,
133-
podcastCategory,
134-
libraryEpisodes
77+
_selectedCategory.flatMapLatest { selectedCategory ->
78+
filterableCategoriesUseCase(selectedCategory)
79+
},
80+
_selectedCategory.flatMapLatest {
81+
podcastCategoryFilterUseCase(it)
82+
},
83+
getLatestFollowedEpisodesUseCase()
13584
) { homeCategories,
13685
selectedHomeCategory,
13786
podcasts,
13887
refreshing,
139-
discoverViewState,
140-
podcastCategoryViewState,
88+
filterableCategories,
89+
podcastCategoryFilterResult,
14190
libraryEpisodes ->
91+
92+
_selectedCategory.value = filterableCategories.selectedCategory
93+
14294
HomeViewState(
14395
homeCategories = homeCategories,
14496
selectedHomeCategory = selectedHomeCategory,
14597
featuredPodcasts = podcasts.toPersistentList(),
14698
refreshing = refreshing,
147-
discoverViewState = discoverViewState,
148-
podcastCategoryViewState = podcastCategoryViewState,
99+
filterableCategoriesModel = filterableCategories,
100+
podcastCategoryFilterResult = podcastCategoryFilterResult,
149101
libraryEpisodes = libraryEpisodes,
150102
errorMessage = null, /* TODO */
151103
)
@@ -202,8 +154,8 @@ data class HomeViewState(
202154
val refreshing: Boolean = false,
203155
val selectedHomeCategory: HomeCategory = HomeCategory.Discover,
204156
val homeCategories: List<HomeCategory> = emptyList(),
205-
val discoverViewState: DiscoverViewState = DiscoverViewState(),
206-
val podcastCategoryViewState: PodcastCategoryViewState = PodcastCategoryViewState(),
157+
val filterableCategoriesModel: FilterableCategoriesModel = FilterableCategoriesModel(),
158+
val podcastCategoryFilterResult: PodcastCategoryFilterResult = PodcastCategoryFilterResult(),
207159
val libraryEpisodes: List<EpisodeToPodcast> = emptyList(),
208160
val errorMessage: String? = null
209161
)

Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,6 @@ import com.example.jetcaster.util.ToggleFollowPodcastIconButton
7676
import java.time.format.DateTimeFormatter
7777
import java.time.format.FormatStyle
7878

79-
data class PodcastCategoryViewState(
80-
val topPodcasts: List<PodcastWithExtraInfo> = emptyList(),
81-
val episodes: List<EpisodeToPodcast> = emptyList()
82-
)
83-
8479
fun LazyListScope.podcastCategory(
8580
topPodcasts: List<PodcastWithExtraInfo>,
8681
episodes: List<EpisodeToPodcast>,

Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -38,23 +38,19 @@ import androidx.compose.ui.res.stringResource
3838
import androidx.compose.ui.unit.dp
3939
import com.example.jetcaster.R
4040
import com.example.jetcaster.core.data.database.model.Category
41+
import com.example.jetcaster.core.data.model.FilterableCategoriesModel
42+
import com.example.jetcaster.core.data.model.PodcastCategoryFilterResult
4143
import com.example.jetcaster.designsystem.theme.Keyline1
42-
import com.example.jetcaster.ui.home.category.PodcastCategoryViewState
4344
import com.example.jetcaster.ui.home.category.podcastCategory
4445

45-
data class DiscoverViewState(
46-
val categories: List<Category> = emptyList(),
47-
val selectedCategory: Category? = null
48-
)
49-
5046
fun LazyListScope.discoverItems(
51-
discoverViewState: DiscoverViewState,
52-
podcastCategoryViewState: PodcastCategoryViewState,
47+
filterableCategoriesModel: FilterableCategoriesModel,
48+
podcastCategoryFilterResult: PodcastCategoryFilterResult,
5349
navigateToPlayer: (String) -> Unit,
5450
onCategorySelected: (Category) -> Unit,
5551
onTogglePodcastFollowed: (String) -> Unit,
5652
) {
57-
if (discoverViewState.categories.isEmpty() || discoverViewState.selectedCategory == null) {
53+
if (filterableCategoriesModel.isEmpty) {
5854
// TODO: empty state
5955
return
6056
}
@@ -63,8 +59,7 @@ fun LazyListScope.discoverItems(
6359
Spacer(Modifier.height(8.dp))
6460

6561
PodcastCategoryTabs(
66-
categories = discoverViewState.categories,
67-
selectedCategory = discoverViewState.selectedCategory,
62+
filterableCategoriesModel = filterableCategoriesModel,
6863
onCategorySelected = onCategorySelected,
6964
modifier = Modifier.fillMaxWidth()
7065
)
@@ -73,8 +68,8 @@ fun LazyListScope.discoverItems(
7368
}
7469

7570
podcastCategory(
76-
topPodcasts = podcastCategoryViewState.topPodcasts,
77-
episodes = podcastCategoryViewState.episodes,
71+
topPodcasts = podcastCategoryFilterResult.topPodcasts,
72+
episodes = podcastCategoryFilterResult.episodes,
7873
navigateToPlayer = navigateToPlayer,
7974
onTogglePodcastFollowed = onTogglePodcastFollowed
8075
)
@@ -84,20 +79,21 @@ private val emptyTabIndicator: @Composable (List<TabPosition>) -> Unit = {}
8479

8580
@Composable
8681
private fun PodcastCategoryTabs(
87-
categories: List<Category>,
88-
selectedCategory: Category,
82+
filterableCategoriesModel: FilterableCategoriesModel,
8983
onCategorySelected: (Category) -> Unit,
9084
modifier: Modifier = Modifier
9185
) {
92-
val selectedIndex = categories.indexOfFirst { it == selectedCategory }
86+
val selectedIndex = filterableCategoriesModel.categories.indexOf(
87+
filterableCategoriesModel.selectedCategory
88+
)
9389
ScrollableTabRow(
9490
selectedTabIndex = selectedIndex,
9591
divider = {}, /* Disable the built-in divider */
9692
edgePadding = Keyline1,
9793
indicator = emptyTabIndicator,
9894
modifier = modifier
9995
) {
100-
categories.forEachIndexed { index, category ->
96+
filterableCategoriesModel.categories.forEachIndexed { index, category ->
10197
Tab(
10298
selected = index == selectedIndex,
10399
onClick = { onCategorySelected(category) }

Jetcaster/core/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,8 @@ dependencies {
5454
implementation(libs.rometools.modules)
5555

5656
coreLibraryDesugaring(libs.core.jdk.desugaring)
57+
58+
// Testing
59+
testImplementation(libs.junit)
60+
testImplementation(libs.kotlinx.coroutines.test)
5761
}

Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,16 @@ abstract class EpisodesDao : BaseDao<Episode> {
6565

6666
@Query("SELECT COUNT(*) FROM episodes")
6767
abstract suspend fun count(): Int
68+
69+
@Query(
70+
"""
71+
SELECT * FROM episodes WHERE podcast_uri IN (:podcastUris)
72+
ORDER BY datetime(published) DESC
73+
LIMIT :limit
74+
"""
75+
)
76+
abstract fun episodesForPodcasts(
77+
podcastUris: List<String>,
78+
limit: Int
79+
): Flow<List<EpisodeToPodcast>>
6880
}

0 commit comments

Comments
 (0)