Show loading/error for large seasons

This commit is contained in:
Damontecres 2025-10-15 13:27:48 -04:00
parent ba45c3f509
commit d4b3c554f8
No known key found for this signature in database
8 changed files with 173 additions and 112 deletions

View file

@ -72,6 +72,7 @@ android {
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
} }
kotlinOptions { kotlinOptions {
jvmTarget = "11" jvmTarget = "11"
@ -227,4 +228,5 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.test.manifest)
coreLibraryDesugaring(libs.desugar.jdk.libs)
} }

View file

@ -31,9 +31,6 @@ data class BaseItem(
val subtitle = val subtitle =
if (type == BaseItemKind.EPISODE) data.seasonEpisode + " - " + name else data.productionYear?.toString() if (type == BaseItemKind.EPISODE) data.seasonEpisode + " - " + name else data.productionYear?.toString()
@Transient
val indexNumber = data.indexNumber
@Transient @Transient
val resumeMs = val resumeMs =
data.userData data.userData
@ -41,12 +38,23 @@ data class BaseItem(
?.ticks ?.ticks
?.inWholeMilliseconds ?.inWholeMilliseconds
@Transient
val indexNumber = data.indexNumber ?: dateAsIndex()
private fun dateAsIndex(): Int? =
data.premiereDate
?.let {
it.year.toString() +
it.monthValue.toString().padStart(2, '0') +
it.dayOfMonth.toString().padStart(2, '0')
}?.toIntOrNull()
fun destination(): Destination { fun destination(): Destination {
val result = val result =
// Redirect episodes & seasons to their series if possible // Redirect episodes & seasons to their series if possible
when (type) { when (type) {
BaseItemKind.EPISODE -> { BaseItemKind.EPISODE -> {
data.indexNumber?.let { episode -> indexNumber?.let { episode ->
data.parentIndexNumber?.let { season -> data.parentIndexNumber?.let { season ->
Destination.SeriesOverview( Destination.SeriesOverview(
data.seriesId!!, data.seriesId!!,

View file

@ -63,7 +63,7 @@ class SeriesViewModel
private lateinit var prefs: UserPreferences private lateinit var prefs: UserPreferences
val loading = MutableLiveData<LoadingState>(LoadingState.Loading) val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val seasons = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty()) val seasons = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
val episodes = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty()) val episodes = MutableLiveData<EpisodeList>(EpisodeList.Loading)
val people = MutableLiveData<List<Person>>(listOf()) val people = MutableLiveData<List<Person>>(listOf())
fun init( fun init(
@ -82,7 +82,6 @@ class SeriesViewModel
) + Dispatchers.IO, ) + Dispatchers.IO,
) { ) {
val item = fetchItem(seriesId, potential) val item = fetchItem(seriesId, potential)
if (item != null) {
val seasonsInfo = getSeasons(item) val seasonsInfo = getSeasons(item)
// If a particular season was requested, fetch those episodes, otherwise get the first season // If a particular season was requested, fetch those episodes, otherwise get the first season
@ -90,7 +89,7 @@ class SeriesViewModel
(season ?: seasonsInfo.items.firstOrNull()?.indexNumber) (season ?: seasonsInfo.items.firstOrNull()?.indexNumber)
?.let { seasonNum -> ?.let { seasonNum ->
loadEpisodesInternal(seasonNum) loadEpisodesInternal(seasonNum)
} ?: ItemListAndMapping.empty() } ?: EpisodeList.Error("Could not determine season")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
seasons.value = seasonsInfo seasons.value = seasonsInfo
episodes.value = episodeInfo episodes.value = episodeInfo
@ -102,13 +101,6 @@ class SeriesViewModel
}.orEmpty() }.orEmpty()
} }
maybePlayThemeSong(prefs.appPreferences.interfacePreferences.playThemeSongs) maybePlayThemeSong(prefs.appPreferences.interfacePreferences.playThemeSongs)
} else {
withContext(Dispatchers.Main) {
seasons.value = ItemListAndMapping.empty()
episodes.value = ItemListAndMapping.empty()
loading.value = LoadingState.Error("Series $seriesId not found")
}
}
} }
} }
@ -203,7 +195,7 @@ class SeriesViewModel
return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum) return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum)
} }
private suspend fun loadEpisodesInternal(season: Int): ItemListAndMapping { private suspend fun loadEpisodesInternal(season: Int): EpisodeList {
val request = val request =
GetEpisodesRequest( GetEpisodesRequest(
seriesId = item.value!!.id, seriesId = item.value!!.id,
@ -222,23 +214,24 @@ class SeriesViewModel
val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope) val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope)
pager.init() pager.init()
Timber.Forest.v("Loaded ${pager.size} episodes for season $season") Timber.Forest.v("Loaded ${pager.size} episodes for season $season")
return convertPager(pager) return EpisodeList.Success(convertPager(pager))
} }
fun loadEpisodes(season: Int) = fun loadEpisodes(season: Int) {
this@SeriesViewModel.episodes.value = EpisodeList.Loading
viewModelScope.async(ExceptionHandler(true)) { viewModelScope.async(ExceptionHandler(true)) {
val episodes = val episodes =
try { try {
loadEpisodesInternal(season) loadEpisodesInternal(season)
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error loading episodes for $seriesId for season $season") Timber.e(e, "Error loading episodes for $seriesId for season $season")
// TODO show error in UI? EpisodeList.Error(e)
ItemListAndMapping.empty()
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@SeriesViewModel.episodes.value = episodes this@SeriesViewModel.episodes.value = episodes
} }
} }
}
fun setWatched( fun setWatched(
itemId: UUID, itemId: UUID,
@ -272,14 +265,15 @@ class SeriesViewModel
val base = api.userLibraryApi.getItem(itemId).content val base = api.userLibraryApi.getItem(itemId).content
val item = BaseItem.Companion.from(base, api) val item = BaseItem.Companion.from(base, api)
val eps = episodes.value!! val eps = episodes.value!!
withContext(Dispatchers.Main) { if (eps is EpisodeList.Success) {
episodes.value = val newItems =
eps.copy( eps.episodes.items.toMutableList().apply {
items =
eps.items.toMutableList().apply {
this[listIndex] = item this[listIndex] = item
}, }
) val newValue = EpisodeList.Success(eps.episodes.copy(items = newItems))
withContext(Dispatchers.Main) {
episodes.value = newValue
}
} }
} }
@ -325,17 +319,32 @@ data class ItemListAndMapping(
} }
/** /**
* Calculate the index<->season number pairings * Calculate the index<->season/ep number pairings
* *
* This allows for handling of missing seasons * This allows for handling of missing seasons
*/ */
private suspend fun convertPager(pager: ApiRequestPager<*>): ItemListAndMapping { private suspend fun convertPager(pager: ApiRequestPager<*>): ItemListAndMapping {
val pairs = val pairs =
pager.mapIndexed { index, _ -> pager.mapIndexed { index, _ ->
val season = pager.getBlocking(index) val item = pager.getBlocking(index)
Pair(season?.indexNumber!!, index) Pair(item?.indexNumber ?: index, index)
} }
val seasonNumToIndex = mapOf(*pairs.toTypedArray()) val seasonNumToIndex = mapOf(*pairs.toTypedArray())
val indexToSeasonNum = mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray()) val indexToSeasonNum = mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray())
return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum) return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum)
} }
sealed interface EpisodeList {
data object Loading : EpisodeList
data class Error(
val message: String? = null,
val exception: Throwable? = null,
) : EpisodeList {
constructor(exception: Throwable) : this(null, exception)
}
data class Success(
val episodes: ItemListAndMapping,
) : EpisodeList
}

View file

@ -25,6 +25,7 @@ import com.github.damontecres.dolphin.ui.components.ErrorMessage
import com.github.damontecres.dolphin.ui.components.LoadingPage import com.github.damontecres.dolphin.ui.components.LoadingPage
import com.github.damontecres.dolphin.ui.data.ItemDetailsDialog import com.github.damontecres.dolphin.ui.data.ItemDetailsDialog
import com.github.damontecres.dolphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.dolphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.dolphin.ui.detail.EpisodeList
import com.github.damontecres.dolphin.ui.detail.ItemListAndMapping import com.github.damontecres.dolphin.ui.detail.ItemListAndMapping
import com.github.damontecres.dolphin.ui.detail.SeriesViewModel import com.github.damontecres.dolphin.ui.detail.SeriesViewModel
import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.Destination
@ -75,7 +76,8 @@ fun SeriesOverview(
val series by viewModel.item.observeAsState(null) val series by viewModel.item.observeAsState(null)
val seasons by viewModel.seasons.observeAsState(ItemListAndMapping.empty()) val seasons by viewModel.seasons.observeAsState(ItemListAndMapping.empty())
val episodes by viewModel.episodes.observeAsState(ItemListAndMapping.empty()) val episodes by viewModel.episodes.observeAsState(EpisodeList.Loading)
val episodeList = (episodes as? EpisodeList.Success)?.episodes?.items
var position by rememberSaveable( var position by rememberSaveable(
destination, destination,
@ -89,7 +91,10 @@ fun SeriesOverview(
mutableStateOf( mutableStateOf(
SeriesOverviewPosition( SeriesOverviewPosition(
seasons.numberToIndex[initialSeasonEpisode?.season ?: 0] ?: 0, seasons.numberToIndex[initialSeasonEpisode?.season ?: 0] ?: 0,
episodes.numberToIndex[initialSeasonEpisode?.episode ?: 0] ?: 0, (episodes as? EpisodeList.Success)?.episodes?.numberToIndex[
initialSeasonEpisode?.episode
?: 0,
] ?: 0,
), ),
) )
} }
@ -97,15 +102,19 @@ fun SeriesOverview(
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) } var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
LaunchedEffect(episodes.items) { LaunchedEffect(episodes) {
if (episodes.items.isNotEmpty()) { episodes?.let { episodes ->
if (episodes is EpisodeList.Success) {
if (episodes.episodes.items.isNotEmpty()) {
// TODO focus on first episode when changing seasons? // TODO focus on first episode when changing seasons?
// firstItemFocusRequester.requestFocus() // firstItemFocusRequester.requestFocus()
episodes.items.getOrNull(position.episodeRowIndex)?.let { episodes.episodes.items.getOrNull(position.episodeRowIndex)?.let {
viewModel.refreshEpisode(it.id, position.episodeRowIndex) viewModel.refreshEpisode(it.id, position.episodeRowIndex)
} }
} }
} }
}
}
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state) is LoadingState.Error -> ErrorMessage(state)
@ -120,7 +129,7 @@ fun SeriesOverview(
SeriesOverviewContent( SeriesOverviewContent(
series = series, series = series,
seasons = seasons.items, seasons = seasons.items,
episodes = episodes.items, episodes = episodes,
position = position, position = position,
backdropImageUrl = backdropImageUrl =
remember { remember {
@ -140,7 +149,6 @@ fun SeriesOverview(
position = it position = it
}, },
onClick = { onClick = {
viewModel.release()
val resumePosition = val resumePosition =
it.data.userData it.data.userData
?.playbackPositionTicks ?.playbackPositionTicks
@ -157,7 +165,7 @@ fun SeriesOverview(
// TODO // TODO
}, },
playOnClick = { resume -> playOnClick = { resume ->
episodes.items.getOrNull(position.episodeRowIndex)?.let { episodeList?.getOrNull(position.episodeRowIndex)?.let {
viewModel.release() viewModel.release()
viewModel.navigateTo( viewModel.navigateTo(
Destination.Playback( Destination.Playback(
@ -169,13 +177,13 @@ fun SeriesOverview(
} }
}, },
watchOnClick = { watchOnClick = {
episodes.items.getOrNull(position.episodeRowIndex)?.let { episodeList?.getOrNull(position.episodeRowIndex)?.let {
val played = it.data.userData?.played ?: false val played = it.data.userData?.played ?: false
viewModel.setWatched(it.id, !played, position.episodeRowIndex) viewModel.setWatched(it.id, !played, position.episodeRowIndex)
} }
}, },
moreOnClick = { moreOnClick = {
episodes.items.getOrNull(position.episodeRowIndex)?.let { ep -> episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
moreDialog = moreDialog =
DialogParams( DialogParams(
fromLongClick = false, fromLongClick = false,
@ -227,7 +235,7 @@ fun SeriesOverview(
} }
}, },
overviewOnClick = { overviewOnClick = {
episodes.items.getOrNull(position.episodeRowIndex)?.let { episodeList?.getOrNull(position.episodeRowIndex)?.let {
overviewDialog = overviewDialog =
ItemDetailsDialogInfo( ItemDetailsDialogInfo(
title = it.name ?: "Unknown", title = it.name ?: "Unknown",

View file

@ -46,16 +46,20 @@ import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.aspectRatioFloat import com.github.damontecres.dolphin.data.model.aspectRatioFloat
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.dolphin.ui.cards.BannerCard import com.github.damontecres.dolphin.ui.cards.BannerCard
import com.github.damontecres.dolphin.ui.components.ErrorMessage
import com.github.damontecres.dolphin.ui.components.LoadingPage
import com.github.damontecres.dolphin.ui.detail.EpisodeList
import com.github.damontecres.dolphin.ui.ifElse import com.github.damontecres.dolphin.ui.ifElse
import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.tryRequestFocus import com.github.damontecres.dolphin.ui.tryRequestFocus
import com.github.damontecres.dolphin.util.formatDateTime
import kotlin.time.Duration import kotlin.time.Duration
@Composable @Composable
fun SeriesOverviewContent( fun SeriesOverviewContent(
series: BaseItem, series: BaseItem,
seasons: List<BaseItem?>, seasons: List<BaseItem?>,
episodes: List<BaseItem?>, episodes: EpisodeList,
position: SeriesOverviewPosition, position: SeriesOverviewPosition,
backdropImageUrl: String?, backdropImageUrl: String?,
firstItemFocusRequester: FocusRequester, firstItemFocusRequester: FocusRequester,
@ -76,7 +80,8 @@ fun SeriesOverviewContent(
var resolvedTabIndex by remember { mutableIntStateOf(selectedTabIndex) } var resolvedTabIndex by remember { mutableIntStateOf(selectedTabIndex) }
val tabRowFocusRequester = remember { FocusRequester() } val tabRowFocusRequester = remember { FocusRequester() }
val focusedEpisode = episodes.getOrNull(position.episodeRowIndex) val focusedEpisode =
(episodes as? EpisodeList.Success)?.episodes?.items?.getOrNull(position.episodeRowIndex)
Box( Box(
modifier = modifier =
@ -200,12 +205,16 @@ fun SeriesOverviewContent(
} }
item { item {
key(position.seasonTabIndex) { key(position.seasonTabIndex) {
when (val eps = episodes) {
EpisodeList.Loading -> LoadingPage()
is EpisodeList.Error -> ErrorMessage(eps.message, eps.exception)
is EpisodeList.Success -> {
val state = rememberLazyListState() val state = rememberLazyListState()
OneTimeLaunchedEffect { OneTimeLaunchedEffect {
if (state.firstVisibleItemIndex != position.episodeRowIndex) { if (state.firstVisibleItemIndex != position.episodeRowIndex) {
state.scrollToItem(position.episodeRowIndex) state.scrollToItem(position.episodeRowIndex)
firstItemFocusRequester.tryRequestFocus()
} }
firstItemFocusRequester.tryRequestFocus()
} }
LazyRow( LazyRow(
state = state, state = state,
@ -216,7 +225,7 @@ fun SeriesOverviewContent(
.focusRestorer(firstItemFocusRequester) .focusRestorer(firstItemFocusRequester)
.focusRequester(episodeRowFocusRequester), .focusRequester(episodeRowFocusRequester),
) { ) {
itemsIndexed(episodes) { episodeIndex, episode -> itemsIndexed(eps.episodes.items) { episodeIndex, episode ->
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
if (interactionSource.collectIsFocusedAsState().value) { if (interactionSource.collectIsFocusedAsState().value) {
onFocus.invoke( onFocus.invoke(
@ -226,7 +235,9 @@ fun SeriesOverviewContent(
), ),
) )
} }
val cornerText =
episode?.data?.indexNumber?.let { "E$it" }
?: episode?.data?.premiereDate?.let(::formatDateTime)
BannerCard( BannerCard(
name = episode?.name, name = episode?.name,
imageUrl = episode?.imageUrl, imageUrl = episode?.imageUrl,
@ -235,13 +246,23 @@ fun SeriesOverviewContent(
?.data ?.data
?.primaryImageAspectRatio ?.primaryImageAspectRatio
?.toFloat() ?.toFloat()
?.coerceAtLeast(episode.data.aspectRatioFloat ?: (4f / 3f)) ?.coerceAtLeast(
episode.data.aspectRatioFloat ?: (4f / 3f),
)
?: (16f / 9), // TODO some episode images don't match the file's aspect ratio ?: (16f / 9), // TODO some episode images don't match the file's aspect ratio
cornerText = "E${episode?.data?.indexNumber}", cornerText = cornerText,
played = episode?.data?.userData?.played ?: false, played = episode?.data?.userData?.played ?: false,
playPercent = episode?.data?.userData?.playedPercentage ?: 0.0, playPercent =
episode?.data?.userData?.playedPercentage
?: 0.0,
onClick = { if (episode != null) onClick.invoke(episode) }, onClick = { if (episode != null) onClick.invoke(episode) },
onLongClick = { if (episode != null) onLongClick.invoke(episode) }, onLongClick = {
if (episode != null) {
onLongClick.invoke(
episode,
)
}
},
modifier = modifier =
Modifier.ifElse( Modifier.ifElse(
episodeIndex == position.episodeRowIndex, episodeIndex == position.episodeRowIndex,
@ -254,6 +275,8 @@ fun SeriesOverviewContent(
} }
} }
} }
}
}
item { item {
focusedEpisode?.let { ep -> focusedEpisode?.let { ep ->
FocusedEpisodeFooter( FocusedEpisodeFooter(

View file

@ -54,6 +54,7 @@ import com.github.damontecres.dolphin.ui.timeRemaining
import com.github.damontecres.dolphin.ui.tryRequestFocus import com.github.damontecres.dolphin.ui.tryRequestFocus
import com.github.damontecres.dolphin.util.LoadingState import com.github.damontecres.dolphin.util.LoadingState
import com.github.damontecres.dolphin.util.formatDateTime import com.github.damontecres.dolphin.util.formatDateTime
import com.github.damontecres.dolphin.util.seasonEpisode
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
@ -231,7 +232,13 @@ fun MainPageHeader(
val details = val details =
buildList { buildList {
if (isEpisode) { if (isEpisode) {
add("S${dto.parentIndexNumber} E${dto.indexNumber}") val se = dto.seasonEpisode
if (se != null) {
add(se)
} else if (dto.parentIndexNumber != null) {
// Maybe a daily episode, so just show season, the date is added below
add("S${dto.parentIndexNumber}")
}
} }
if (isEpisode) { if (isEpisode) {
dto.premiereDate?.let { add(formatDateTime(it)) } dto.premiereDate?.let { add(formatDateTime(it)) }
@ -271,13 +278,13 @@ fun MainPageHeader(
val overviewModifier = val overviewModifier =
Modifier Modifier
.padding(0.dp) .padding(0.dp)
.height(48.dp) .height(48.dp + if (!isEpisode) 12.dp else 0.dp)
if (overview.isNotNullOrBlank()) { if (overview.isNotNullOrBlank()) {
Text( Text(
text = overview, text = overview,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = 2, maxLines = if (isEpisode) 2 else 3,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = overviewModifier, modifier = overviewModifier,
) )

View file

@ -13,6 +13,8 @@ import java.time.format.DateTimeFormatter
*/ */
fun formatDateTime(dateTime: LocalDateTime): String = fun formatDateTime(dateTime: LocalDateTime): String =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// TODO server returns in UTC, but sdk converts to local time
// eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy") val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy")
formatter.format(dateTime) formatter.format(dateTime)
} else if (dateTime.toString().length >= 10) { } else if (dateTime.toString().length >= 10) {

View file

@ -1,6 +1,7 @@
[versions] [versions]
aboutLibraries = "12.2.4" aboutLibraries = "12.2.4"
agp = "8.13.0" agp = "8.13.0"
desugar_jdk_libs = "2.1.5"
hiltNavigationCompose = "1.3.0" hiltNavigationCompose = "1.3.0"
kotlin = "2.2.20" kotlin = "2.2.20"
ksp = "2.2.20-2.0.2" # https://github.com/google/ksp/issues/2596 2.0.3 is broken ksp = "2.2.20-2.0.2" # https://github.com/google/ksp/issues/2596 2.0.3 is broken
@ -51,6 +52,7 @@ androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.re
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-javalite" } protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-javalite" }