This commit is contained in:
Damontecres 2025-10-17 21:47:46 -04:00
parent 548f942609
commit 5f6eb7d313
No known key found for this signature in database
3 changed files with 327 additions and 443 deletions

View file

@ -228,7 +228,6 @@ dependencies {
implementation(libs.timber) implementation(libs.timber)
implementation(libs.aboutlibraries.core) implementation(libs.aboutlibraries.core)
implementation(libs.aboutlibraries.compose.m3) implementation(libs.aboutlibraries.compose.m3)
implementation(libs.programguide)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.junit4)

View file

@ -1,490 +1,377 @@
package com.github.damontecres.wholphin.ui.detail.livetv package com.github.damontecres.wholphin.ui.detail.livetv
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times import androidx.compose.ui.unit.times
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.ListItem import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.playback.isDown
import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.playback.isUp
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.util.LoadingState
import eu.wewox.programguide.ProgramGuide
import eu.wewox.programguide.ProgramGuideItem
import timber.log.Timber import timber.log.Timber
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.UUID import java.util.UUID
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
private val hourWidth = 200.dp @Composable
fun TvGrid(modifier: Modifier = Modifier) = TvGridBox(channels, programs, modifier)
data class Program(
val channel: Int,
val start: Float,
val end: Float,
val title: String,
)
/**
* Formats time.
*/
fun formatTime(
from: Float,
to: Float? = null,
): String {
fun Float.hour(): String = toInt().formatWithPrefix(2)
fun Float.minutes(): String = ((this % 1) * 60).toInt().formatWithPrefix(2)
val fromFormatted = "${from.hour()}:${from.minutes()}"
return if (to != null) {
val toFormatted = "${to.hour()}:${to.minutes()}"
"$fromFormatted - $toFormatted"
} else {
fromFormatted
}
}
/**
* Creates a list of programs to view in program guide.
*/
fun createPrograms(
channels: Int = CHANNELS_COUNT,
timeline: IntRange = 0..HOURS_COUNT,
): List<Program> {
var channel = 0
var hour = timeline.first + HOURS.random()
return buildList {
while (channel < channels) {
while (true) {
val end = hour + HOURS.random()
if (end > timeline.last) {
break
}
add(Program(channel, hour, end, "Program #$size"))
hour = end
}
hour = timeline.first + HOURS.random()
channel += 1
}
}
}
private fun Int.formatWithPrefix(
length: Int,
prefix: Char = '0',
): String {
val number = toString()
val prefixLength = (length - number.length).coerceAtLeast(0)
val prefixFull = List(prefixLength) { prefix }.joinToString(separator = "")
return "$prefixFull$number"
}
private val HOURS = listOf(0.5f, 1f, 1.25f, 1.5f, 2f, 2.25f, 2.5f)
private const val CHANNELS_COUNT = 30
private const val HOURS_COUNT = 24
@Composable @Composable
fun TvGrid(modifier: Modifier = Modifier) { fun TvGridBox(
val channels = 20 channels: List<TvChannel>,
val timeline = 8..22 programs: Map<UUID, List<TvProgram>>,
val programs = remember { createPrograms(channels, timeline) }
ProgramGuide(
modifier =
modifier,
) {
guideStartHour = timeline.first.toFloat()
programs(
items = programs,
layoutInfo = {
ProgramGuideItem.Program(
channelIndex = it.channel,
startHour = it.start,
endHour = it.end,
)
},
itemContent = {
ListItem(
selected = false,
onClick = {},
headlineContent = {
Text(
text = it.title,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier,
)
},
modifier = Modifier,
)
},
)
channels(
count = channels,
layoutInfo = {
ProgramGuideItem.Channel(
index = it,
)
},
itemContent = {
Text(
text = it.toString(),
color = MaterialTheme.colorScheme.onSurface,
)
},
)
timeline(
count = timeline.count(),
layoutInfo = {
val start = timeline.toList()[it].toFloat()
ProgramGuideItem.Timeline(
startHour = start,
endHour = start + 1f,
)
},
itemContent = {
Text(
text = "${timeline.toList()[it].toFloat()} o'clock",
color = MaterialTheme.colorScheme.onSurface,
)
},
)
}
}
@Composable
fun TvGrid3(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: LiveTvViewModel = hiltViewModel(),
) { ) {
OneTimeLaunchedEffect { val startHour = 18f
viewModel.init() var hour by remember { mutableFloatStateOf(startHour) }
} Timber.v("hour=$hour")
val loading by viewModel.loading.observeAsState(LoadingState.Pending) val hoursToShow = 3f
val widthPerHour = 200.dp
when (val state = loading) { Column(modifier = modifier) {
is LoadingState.Error -> ErrorMessage(state) // Header
Text(
LoadingState.Loading, text = hour.toString(),
LoadingState.Pending, )
-> LoadingPage() LazyColumn(
modifier =
LoadingState.Success -> { Modifier,
val channels by viewModel.channels.observeAsState(listOf()) ) {
val programs by viewModel.programs.observeAsState(listOf()) itemsIndexed(channels) { channelIndex, channel ->
ProgramGuide( val programs = programs[channel.id].orEmpty()
modifier = modifier, var rowHasFocus by remember { mutableStateOf(false) }
) { Row(
guideStartHour = 22f modifier =
programs( Modifier
items = programs, .height(120.dp)
layoutInfo = { p -> .fillMaxWidth()
if (p != null) { .background(
Timber.v("${p.id}: ${p.start.hour + p.start.minute / 60f}") if (rowHasFocus) {
ProgramGuideItem.Program( MaterialTheme.colorScheme.surfaceColorAtElevation(
channelIndex = channels.indexOfFirst { it.id == p.channelId }, 3.dp,
startHour = p.start.hour + p.start.minute / 60f, )
endHour = p.start.hour + p.start.minute / 60f, } else {
MaterialTheme.colorScheme.surface
},
),
) {
val channelFocusRequester = remember { FocusRequester() }
// Channel info
ListItem(
selected = false,
onClick = {
Timber.v("Clicked channel $channel")
},
leadingContent = {
Text(
text = channel.number ?: "",
modifier = Modifier.width(40.dp),
) )
} else { },
ProgramGuideItem.Program(0, 0f, 0f) headlineContent = {
Text(
text = channel.name ?: "",
modifier = Modifier.width(80.dp),
)
},
trailingContent = {
AsyncImage(
model = channel.imageUrl,
contentDescription = null,
modifier =
Modifier
.width(64.dp)
.fillMaxHeight(),
)
},
modifier =
Modifier
.width(220.dp)
.fillMaxHeight()
.focusRequester(channelFocusRequester),
)
val focusRequester = remember { FocusRequester() }
var capturedFocus by remember { mutableStateOf(false) }
Row(
modifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onKeyEvent {
Timber.v("onKeyEvent=$it")
if (it.type == KeyEventType.KeyUp) {
if (rowHasFocus) {
if (it.type == KeyEventType.KeyUp && it.key == Key.DirectionLeft && hour <= startHour) {
focusRequester.freeFocus()
channelFocusRequester.tryRequestFocus()
capturedFocus = false
return@onKeyEvent false
}
if (isUp(it) || isDown(it)) {
focusRequester.freeFocus()
capturedFocus = false
return@onKeyEvent false
}
if (it.type == KeyEventType.KeyUp && it.key == Key.DirectionLeft && hour > startHour) {
hour -= .5f
return@onKeyEvent true
} else if (it.type == KeyEventType.KeyUp && it.key == Key.DirectionRight) {
hour += .5f
return@onKeyEvent true
}
}
}
return@onKeyEvent false
}.onFocusChanged {
Timber.v("onFocusChanged=$it")
rowHasFocus = it.hasFocus
if (it.isFocused && !capturedFocus) {
focusRequester.captureFocus()
capturedFocus = true
}
}.focusProperties {
onEnter = {
Timber.v("onEnter")
focusRequester.captureFocus()
}
onExit = {
Timber.v("onExit")
capturedFocus = false
}
}.focusable(),
) {
val filtered =
programs
.filter {
it.start.hours <= hour && it.end.hours > hour ||
(it.start.hours > hour && it.start.hours < (hour + hoursToShow))
}.sortedBy { it.start }
LaunchedEffect(filtered) {
Timber.v("Got ${filtered.size} for ${channel.number}")
} }
}, filtered
itemContent = { program -> .forEach { p ->
// Timber.v("Render $program") Timber.v("Showing program: $p")
Text( val width = p.duration.inWholeMinutes / 60f * widthPerHour
text = program?.name ?: "", val backgroundColor =
color = MaterialTheme.colorScheme.onSurface, if (rowHasFocus && p.start.hours <= hour && p.end.hours > (hour)) {
modifier = Modifier.focusable(), MaterialTheme.colorScheme.error
) } else {
// ListItem( Color.Unspecified
// selected = false, }
// onClick = {}, Box(
// headlineContent = { modifier =
// Text( Modifier
// text = program?.name ?: "", .width(width)
// color = MaterialTheme.colorScheme.onSurface, .fillMaxHeight()
// modifier = Modifier, .background(backgroundColor)
// ) .border(
// }, width = 2.dp,
// supportingContent = { color = MaterialTheme.colorScheme.inverseSurface,
// program?.subtitle?.let { ),
// Text( ) {
// text = program.subtitle, Text(
// color = MaterialTheme.colorScheme.onSurface, text = p.name ?: "Unknown",
// ) color = MaterialTheme.colorScheme.onSurface,
// }
// },
// modifier = Modifier,
// )
},
)
channels(
count = channels.size,
layoutInfo = {
ProgramGuideItem.Channel(
index = it,
)
},
itemContent = {
val channel = channels[it]
Text(
text = channel.number ?: "",
)
},
)
timeline(
count = 12,
layoutInfo = {
val start = 22f + it
ProgramGuideItem.Timeline(
startHour = start,
endHour = start + 1f,
)
},
itemContent = {
Text(
text = "${22f + it} o'clock",
)
},
)
}
}
}
}
@Composable
fun TvGrid2(
modifier: Modifier = Modifier,
viewModel: LiveTvViewModel = hiltViewModel(),
) {
OneTimeLaunchedEffect {
viewModel.init()
}
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success -> {
val channels by viewModel.channels.observeAsState(listOf())
var focusedRowIndex by rememberInt()
val columnState = rememberLazyListState()
val rowStates =
rememberSaveable(
saver =
listSaver(
save = { it.map { listOf(it.firstVisibleItemIndex, it.firstVisibleItemScrollOffset) } },
restore = {
it.map {
LazyListState(
firstVisibleItemIndex = it[0],
firstVisibleItemScrollOffset = it[1],
) )
} }
}, }
),
) { List(channels.size) { LazyListState(0, 0) } }
val currentRowState = rowStates[focusedRowIndex]
LaunchedEffect(currentRowState.firstVisibleItemScrollOffset) {
rowStates.forEachIndexed { index, state ->
if (index != focusedRowIndex) {
currentRowState.layoutInfo.visibleItemsInfo
state.scrollToItem(currentRowState.firstVisibleItemIndex, currentRowState.firstVisibleItemScrollOffset)
} }
} }
} }
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
state = columnState,
modifier = modifier,
) {
itemsIndexed(channels) { channelIndex, channel ->
val programs by viewModel.getPrograms(channel.id).observeAsState(listOf())
ChannelRow(
lazyListState = rowStates[channelIndex],
channel = channel,
programs = programs,
modifier =
Modifier
.height(56.dp)
.fillMaxWidth()
.onFocusChanged {
if (it.hasFocus) {
focusedRowIndex = channelIndex
}
},
)
}
}
} }
} }
} }
@Composable val LocalDateTime.hours get() = hour + minute / 60f
fun ChannelRow(
lazyListState: LazyListState,
channel: TvChannel,
programs: List<TvProgram?>,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
) {
ListItem(
selected = false,
onClick = {
Timber.v("Clicked channel $channel")
},
leadingContent = {
Text(
text = channel.number ?: "",
modifier = Modifier.width(40.dp),
)
},
headlineContent = {
Text(
text = channel.name ?: "",
modifier = Modifier.width(80.dp),
)
},
trailingContent = {
AsyncImage(
model = channel.imageUrl,
contentDescription = null,
modifier = Modifier.width(64.dp).fillMaxHeight(),
)
},
modifier = Modifier.width(220.dp).fillMaxHeight(),
)
LazyRow(
state = lazyListState,
modifier =
Modifier
.weight(1f)
.fillMaxSize(),
) {
itemsIndexed(programs) { programIndex, program ->
Timber.v("program=$program")
val duration = (program?.duration ?: 30.minutes).coerceAtLeast(2.minutes)
val width = duration.inWholeMinutes / 60f * hourWidth
ListItem(
selected = false,
onClick = {},
headlineContent = {
Text(
text = program?.name ?: "",
modifier = Modifier,
)
},
supportingContent = {
program?.subtitle?.let {
Text(
text = program.subtitle,
)
}
},
modifier = Modifier.width(width).fillMaxHeight(),
)
}
}
}
}
@PreviewTvSpec val channel1Id = UUID.randomUUID()
@Composable val channel2Id = UUID.randomUUID()
private fun ChannelRowPreview() { val channel3Id = UUID.randomUUID()
val channelId = UUID.randomUUID()
val channel = val channels =
listOf(
TvChannel( TvChannel(
id = channelId, id = channel1Id,
number = "2.1", number = "2.1",
name = "WJTV", name = "WJTV",
imageUrl = "", imageUrl = "",
) ),
val programs = TvChannel(
listOf( id = channel2Id,
TvProgram( number = "3.1",
id = UUID.randomUUID(), name = "JYTV",
channelId = channelId, imageUrl = "",
start = LocalDateTime.of(2025, 10, 16, 18, 0, 0), ),
end = LocalDateTime.of(2025, 10, 16, 19, 0, 0), TvChannel(
duration = 60.minutes, id = channel3Id,
name = "Program #1", number = "4.1",
subtitle = null, name = "AQTV",
seasonEpisode = null, imageUrl = "",
), ),
TvProgram( )
id = UUID.randomUUID(),
channelId = channelId,
start = LocalDateTime.of(2025, 10, 16, 19, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 19, 30, 0),
duration = 30.minutes,
name = "Program #1",
subtitle = null,
seasonEpisode = null,
),
TvProgram(
id = UUID.randomUUID(),
channelId = channelId,
start = LocalDateTime.of(2025, 10, 16, 19, 30, 0),
end = LocalDateTime.of(2025, 10, 16, 20, 0, 0),
duration = 30.minutes,
name = "Program #1",
subtitle = null,
seasonEpisode = null,
),
)
WholphinTheme {
ChannelRow(
lazyListState = rememberLazyListState(),
channel = channel,
programs = programs,
modifier = Modifier.height(140.dp),
)
}
}
@PreviewTvSpec val programs =
@Composable mapOf(
private fun TvGridPreview() { channel1Id to
} listOf(
TvProgram(
id = UUID.randomUUID(),
channelId = channel1Id,
start = LocalDateTime.of(2025, 10, 16, 18, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 19, 0, 0),
duration = 60.minutes,
name = "Program #1",
subtitle = null,
seasonEpisode = null,
),
TvProgram(
id = UUID.randomUUID(),
channelId = channel1Id,
start = LocalDateTime.of(2025, 10, 16, 19, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 19, 30, 0),
duration = 30.minutes,
name = "Program #2",
subtitle = null,
seasonEpisode = null,
),
TvProgram(
id = UUID.randomUUID(),
channelId = channel1Id,
start = LocalDateTime.of(2025, 10, 16, 19, 30, 0),
end = LocalDateTime.of(2025, 10, 16, 20, 0, 0),
duration = 30.minutes,
name = "Program #3",
subtitle = null,
seasonEpisode = null,
),
TvProgram(
id = UUID.randomUUID(),
channelId = channel1Id,
start = LocalDateTime.of(2025, 10, 16, 20, 0, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 21, 0, 0),
duration = 60.minutes,
name = "Program #3",
subtitle = null,
seasonEpisode = null,
),
TvProgram(
id = UUID.randomUUID(),
channelId = channel1Id,
start = LocalDateTime.of(2025, 10, 16, 21, 0, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 22, 0, 0),
duration = 60.minutes,
name = "Program #3",
subtitle = null,
seasonEpisode = null,
),
TvProgram(
id = UUID.randomUUID(),
channelId = channel1Id,
start = LocalDateTime.of(2025, 10, 16, 22, 0, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 23, 0, 0),
duration = 60.minutes,
name = "Program #3",
subtitle = null,
seasonEpisode = null,
),
),
channel2Id to
listOf(
TvProgram(
id = UUID.randomUUID(),
channelId = channel2Id,
start = LocalDateTime.of(2025, 10, 16, 18, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 18, 30, 0),
duration = 30.minutes,
name = "Program #1",
subtitle = null,
seasonEpisode = null,
),
TvProgram(
id = UUID.randomUUID(),
channelId = channel2Id,
start = LocalDateTime.of(2025, 10, 16, 18, 30, 0),
end = LocalDateTime.of(2025, 10, 16, 19, 30, 0),
duration = 60.minutes,
name = "Program #2",
subtitle = null,
seasonEpisode = null,
),
TvProgram(
id = UUID.randomUUID(),
channelId = channel2Id,
start = LocalDateTime.of(2025, 10, 16, 19, 30, 0),
end = LocalDateTime.of(2025, 10, 16, 20, 0, 0),
duration = 30.minutes,
name = "Program #3",
subtitle = null,
seasonEpisode = null,
),
),
channel3Id to
listOf(
TvProgram(
id = UUID.randomUUID(),
channelId = channel3Id,
start = LocalDateTime.of(2025, 10, 16, 18, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 19, 0, 0),
duration = 60.minutes,
name = "Program #1",
subtitle = null,
seasonEpisode = null,
),
TvProgram(
id = UUID.randomUUID(),
channelId = channel3Id,
start = LocalDateTime.of(2025, 10, 16, 19, 0, 0),
end = LocalDateTime.of(2025, 10, 16, 19, 30, 0),
duration = 30.minutes,
name = "Program #2",
subtitle = null,
seasonEpisode = null,
),
TvProgram(
id = UUID.randomUUID(),
channelId = channel3Id,
start = LocalDateTime.of(2025, 10, 16, 19, 30, 0),
end = LocalDateTime.of(2025, 10, 16, 20, 0, 0),
duration = 30.minutes,
name = "Program #3",
subtitle = null,
seasonEpisode = null,
),
),
)

View file

@ -9,7 +9,6 @@ coreKtx = "1.17.0"
appcompat = "1.7.1" appcompat = "1.7.1"
composeBom = "2025.10.00" composeBom = "2025.10.00"
compose-runtime = "1.9.3" compose-runtime = "1.9.3"
programguide = "1.6.0"
timber = "5.0.1" timber = "5.0.1"
tvFoundation = "1.0.0-alpha12" tvFoundation = "1.0.0-alpha12"
tvMaterial = "1.0.1" tvMaterial = "1.0.1"
@ -57,7 +56,6 @@ androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "d
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } 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" }
programguide = { module = "io.github.oleksandrbalan:programguide", version.ref = "programguide" }
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" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-android" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-android" }
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }