Use zigbee2mqtt scenes directly, persist last selected scene locally
This commit is contained in:
parent
d129c407d3
commit
ac8fa6f0c0
11 changed files with 163 additions and 182 deletions
|
@ -1,3 +1,10 @@
|
|||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
|
||||
val keystoreProperties = Properties ()
|
||||
keystoreProperties . load ( FileInputStream ( rootProject . file ("keystore.properties") ) )
|
||||
|
||||
plugins {
|
||||
alias ( libs . plugins . android . application )
|
||||
alias ( libs . plugins . kotlin . android )
|
||||
|
@ -17,10 +24,23 @@ android {
|
|||
versionName = "1.0"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create ("kernelmaft") {
|
||||
keyAlias = "kernelmaft"
|
||||
keyPassword = keystoreProperties ["keyPassword"] as String
|
||||
storeFile = file ( keystoreProperties ["storeFile"] as String )
|
||||
storePassword = keystoreProperties ["storePassword"] as String
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
signingConfig = signingConfigs . getByName ("kernelmaft")
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
signingConfig = signingConfigs . getByName ("kernelmaft")
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
|
@ -47,6 +67,7 @@ dependencies {
|
|||
implementation ( libs . androidx . compose . ui )
|
||||
implementation ( libs . androidx . compose . ui . graphics )
|
||||
debugImplementation ( libs . androidx . compose . ui . tooling )
|
||||
implementation ( libs . androidx . datastore . preferences )
|
||||
implementation ( libs . androidx . lifecycle . runtime . ktx )
|
||||
implementation ( libs . kotlinx . coroutines . android )
|
||||
implementation ( libs . kotlinx . serialization . json )
|
||||
|
@ -55,7 +76,7 @@ dependencies {
|
|||
implementation ( libs . kmqtt . client )
|
||||
}
|
||||
|
||||
tasks . withType ( org . jetbrains . kotlin . gradle . tasks . KotlinCompile :: class ) . all {
|
||||
tasks . withType ( KotlinCompile :: class ) . all {
|
||||
compilerOptions {
|
||||
freeCompilerArgs . addAll ( "-opt-in=kotlin.ExperimentalUnsignedTypes" )
|
||||
}
|
||||
|
|
|
@ -1,17 +1,19 @@
|
|||
package com.kernelmaft.zanbur
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
|
||||
typealias MutableStateMap < Key , Value > = MutableState < Map < Key , Value > >
|
||||
|
||||
object AppState {
|
||||
val devices : MutableStateMap < DeviceName , Device > = mutableStateOf ( emptyMap () )
|
||||
val deviceStates : MutableStateMap < Device , LampState > = mutableStateOf ( emptyMap () )
|
||||
val scenes : MutableStateMap < SceneName , Scene > = mutableStateOf ( emptyMap () )
|
||||
val currentScene : MutableState < Scene ? > = mutableStateOf (null)
|
||||
val groups : MutableState < List <Group> > = mutableStateOf ( emptyList () )
|
||||
|
||||
fun putDevice ( device : Device ) { devices . value += Pair ( device . name , device ) }
|
||||
fun putScene ( scene : Scene ) { scenes . value += Pair ( scene . name , scene ) }
|
||||
fun setCurrentScene ( groupId : Int , currentScene : Scene ) {
|
||||
var groups by this . groups
|
||||
groups = groups . map {
|
||||
if ( it . id == groupId ) it . copy ( currentScene = currentScene )
|
||||
else it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
27
app/src/main/java/com/kernelmaft/zanbur/components.kt
Normal file
27
app/src/main/java/com/kernelmaft/zanbur/components.kt
Normal file
|
@ -0,0 +1,27 @@
|
|||
package com.kernelmaft.zanbur
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
|
||||
@Composable fun SceneSwitcher (
|
||||
scenes : Collection <Scene> ,
|
||||
currentScene : Scene ? ,
|
||||
onSwitch : (Scene) -> Unit ,
|
||||
) {
|
||||
CenteredColumn {
|
||||
for ( scene in scenes ) {
|
||||
val colors =
|
||||
if ( scene . id == currentScene ?. id ) ButtonDefaults . buttonColors ()
|
||||
else ButtonDefaults . filledTonalButtonColors ()
|
||||
|
||||
Button ( { onSwitch (scene) } , Modifier . fillMaxWidth () , true , ButtonDefaults . shape , colors ) {
|
||||
Text ( scene . name )
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,25 +6,13 @@ object Config {
|
|||
const val MQTT_SERVER_PORT = 1883
|
||||
const val MQTT_TOPIC = "zigbee2mqtt"
|
||||
|
||||
private val deskLamp = TemperatureLamp ( DeviceName ("Desk lamp") )
|
||||
private val standingLamp = TemperatureLamp ( DeviceName ("Standing lamp") )
|
||||
private val diningTableLamp = TemperatureLamp ( DeviceName ("Dining table lamp") )
|
||||
private val ledStrip = RgbLamp ( DeviceName ("LED strip") )
|
||||
|
||||
val devices = listOf ( deskLamp , standingLamp , diningTableLamp , ledStrip )
|
||||
|
||||
val scenes = listOf (
|
||||
Scene ( SceneName ("All off") , mapOf (
|
||||
Pair ( deskLamp , TemperatureLampState ( Power . OFF , 254 , 250 ) ) ,
|
||||
Pair ( standingLamp , TemperatureLampState ( Power . OFF , 254 , 250 ) ) ,
|
||||
Pair ( diningTableLamp , TemperatureLampState ( Power . OFF , 254 , 250 ) ) ,
|
||||
Pair ( ledStrip , RgbLampState ( Power . OFF , 254 , Cie1931Color ( 0.0 , 0.0 ) ) ) ,
|
||||
) ) ,
|
||||
Scene ( SceneName ("Evening") , mapOf (
|
||||
Pair ( deskLamp , TemperatureLampState ( Power . ON , 128 , 454 ) ) ,
|
||||
Pair ( standingLamp , TemperatureLampState ( Power . ON , 192 , 454 ) ) ,
|
||||
Pair ( diningTableLamp , TemperatureLampState ( Power . ON , 192 , 454 ) ) ,
|
||||
Pair ( ledStrip , RgbLampState ( Power . ON , 128 , Cie1931Color ( 0.25 , 0.05 ) ) ) ,
|
||||
) ) ,
|
||||
val groups = listOf (
|
||||
Group ( 0 , "All lights" , listOf (
|
||||
Scene ( 0 , "All off" ) ,
|
||||
Scene ( 1 , "Bright, warm, purple accent" ) ,
|
||||
Scene ( 2 , "Medium-bright, warm, purple accent" ) ,
|
||||
Scene ( 3 , "Dim, warm, purple accent" ) ,
|
||||
Scene ( 4 , "Medium-bright, cold, no accent" ) ,
|
||||
) )
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,25 +1,25 @@
|
|||
package com.kernelmaft.zanbur
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Arrangement.Center
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
|
||||
|
||||
|
@ -27,27 +27,35 @@ class MainActivity : ComponentActivity () {
|
|||
override fun onCreate ( savedInstanceState : Bundle ? ) {
|
||||
super . onCreate (savedInstanceState)
|
||||
|
||||
val devices by AppState . devices
|
||||
val scenes by AppState . scenes
|
||||
var currentScene by AppState . currentScene
|
||||
var groups by AppState . groups
|
||||
groups = Config . groups
|
||||
|
||||
for ( device in Config . devices ) AppState . putDevice (device)
|
||||
for ( scene in Config . scenes ) AppState . putScene (scene)
|
||||
lifecycleScope . launch (IO) {
|
||||
val prefs = applicationContext . dataStore . data . firstOrNull ()
|
||||
if ( prefs != null ) {
|
||||
val savedSceneName = prefs [ stringPreferencesKey ("scene") ]
|
||||
if ( savedSceneName != null ) {
|
||||
val savedScene = groups [0] . scenes . find { it . name == savedSceneName }
|
||||
if ( savedScene != null ) {
|
||||
AppState . setCurrentScene ( 0 , savedScene )
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enableEdgeToEdge ()
|
||||
setContent {
|
||||
ZanburTheme {
|
||||
Scaffold { scaffoldPadding ->
|
||||
val wrapperModifier = Modifier
|
||||
.padding(scaffoldPadding)
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
CenteredColumn ( Modifier . padding (scaffoldPadding) . fillMaxSize () ) {
|
||||
|
||||
Column ( wrapperModifier , Center , CenterHorizontally ) {
|
||||
SceneSwitcher ( scenes . values . map { it . name } , currentScene ?. name ) {
|
||||
val newScene = scenes . getValue (it)
|
||||
currentScene = newScene
|
||||
handleSceneChange (newScene)
|
||||
CenteredColumn ( Modifier . width ( 300 . dp ) ) {
|
||||
Text (groups [0] . name)
|
||||
Spacer ( Modifier . height ( compactSpacing ) )
|
||||
SceneSwitcher ( groups [0] . scenes , groups [0] . currentScene ) {
|
||||
AppState . setCurrentScene ( 0 , it )
|
||||
publishSceneChange ( groups [0] , it )
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -56,57 +64,17 @@ class MainActivity : ComponentActivity () {
|
|||
|
||||
MqttClient . run (lifecycleScope)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun SceneSwitcher (
|
||||
scenes : Collection <SceneName> ,
|
||||
currentScene : SceneName ? ,
|
||||
onSwitch : (SceneName) -> Unit ,
|
||||
) = Column {
|
||||
for ( scene in scenes ) {
|
||||
val colors =
|
||||
if ( scene == currentScene ) ButtonDefaults . buttonColors ()
|
||||
else ButtonDefaults . filledTonalButtonColors ()
|
||||
override fun onStop () {
|
||||
super . onStop ()
|
||||
|
||||
Button ( { onSwitch (scene) } , Modifier , true , ButtonDefaults . shape , colors ) {
|
||||
Text ( scene . value )
|
||||
val groups by AppState . groups
|
||||
val currentScene = groups [0] . currentScene
|
||||
|
||||
if ( currentScene != null ) lifecycleScope . launch (IO) {
|
||||
applicationContext . dataStore . edit {
|
||||
it [ stringPreferencesKey ("scene") ] = currentScene . name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSceneChange ( newScene : Scene ) {
|
||||
for ( ( device , newState ) in newScene . states ) {
|
||||
val jsonString = when (device) {
|
||||
is TemperatureLamp -> Json . encodeToString ( newState as TemperatureLampState )
|
||||
is RgbLamp -> Json . encodeToString ( newState as RgbLampState )
|
||||
else -> throw Error ("Unknown type of device state")
|
||||
}
|
||||
MqttClient . publish (
|
||||
Config . MQTT_TOPIC + "/" + device . name . value + "/set" ,
|
||||
jsonString . toByteArray () . asUByteArray () ,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
//private fun handlePowerChange ( deviceName : String , power : Power ) {
|
||||
// when ( val device = AppState . devices . value . getValue (deviceName) ) {
|
||||
//
|
||||
// is TemperatureLamp ->
|
||||
// if ( device . currentState != null && device . currentState . power != power ) {
|
||||
// MqttClient . publish (
|
||||
// "zigbee2mqtt/$deviceName/set/state" ,
|
||||
// power . toString () . toByteArray () . asUByteArray () ,
|
||||
// )
|
||||
// AppState . putDevice ( TemperatureLamp ( deviceName , device . currentState . copy (power) ) )
|
||||
// }
|
||||
//
|
||||
// is RgbLamp ->
|
||||
// if ( device . currentState != null && device . currentState . power != power ) {
|
||||
// MqttClient . publish (
|
||||
// "zigbee2mqtt/$deviceName/set/state" ,
|
||||
// power . toString () . toByteArray () . asUByteArray () ,
|
||||
// )
|
||||
// AppState . putDevice ( RgbLamp ( deviceName , device . currentState . copy (power) ) )
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
|
|
@ -3,6 +3,8 @@ package com.kernelmaft.zanbur
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import MQTTClient
|
||||
|
@ -24,31 +26,11 @@ object MqttClient {
|
|||
val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
coroutineScope . launch (IO) {
|
||||
client = MQTTClient (
|
||||
MQTTVersion . MQTT5 ,
|
||||
Config . MQTT_SERVER_HOST ,
|
||||
Config . MQTT_SERVER_PORT ,
|
||||
null ,
|
||||
) {
|
||||
client = MQTTClient ( MQTTVersion . MQTT5 , Config . MQTT_SERVER_HOST , Config . MQTT_SERVER_PORT , null ) {
|
||||
for ( handler in publishHandlers ) handler ( it , json )
|
||||
}
|
||||
client !! . subscribe ( listOf ( Subscription ( Config . MQTT_TOPIC + "/#" ) ) )
|
||||
|
||||
for ( ( name , device ) in AppState . devices . value ) when (device) {
|
||||
is TemperatureLamp -> publish (
|
||||
Config . MQTT_TOPIC + "/" + name + "/get" ,
|
||||
Json . encodeToString ( TemperatureLampState ( Power . OFF , 0 , 250 ) )
|
||||
. toByteArray ()
|
||||
. asUByteArray () ,
|
||||
)
|
||||
is RgbLamp -> publish (
|
||||
Config . MQTT_TOPIC + "/" + name + "/get" ,
|
||||
Json . encodeToString ( RgbLampState ( Power . OFF , 0 , Cie1931Color ( 0.0 , 0.0 ) ) )
|
||||
. toByteArray ()
|
||||
. asUByteArray () ,
|
||||
)
|
||||
}
|
||||
|
||||
client !! . run ()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,62 +1,14 @@
|
|||
package com.kernelmaft.zanbur
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
|
||||
typealias Real = Double
|
||||
|
||||
data class Group (
|
||||
val id : Int ,
|
||||
val name : String ,
|
||||
val scenes : Collection <Scene> ,
|
||||
val currentScene : Scene ? = null ,
|
||||
)
|
||||
|
||||
data class Scene (
|
||||
val name : SceneName ,
|
||||
val states : Map < Device , LampState > ,
|
||||
)
|
||||
|
||||
data class SceneName ( val value : String )
|
||||
|
||||
|
||||
abstract class Device ( val name : DeviceName )
|
||||
|
||||
class TemperatureLamp ( name : DeviceName ) : Device (name)
|
||||
class RgbLamp ( name : DeviceName ) : Device (name)
|
||||
|
||||
data class DeviceName ( val value : String )
|
||||
|
||||
|
||||
abstract class LampState {
|
||||
abstract val power : Power
|
||||
abstract val brightness : Int // Range 0 to 254
|
||||
}
|
||||
|
||||
@Serializable data class TemperatureLampState (
|
||||
@SerialName ("state") override val power : Power ,
|
||||
@SerialName ("brightness") override val brightness : Int ,
|
||||
@SerialName ("color_temp") val colorTemperature : Int , // Range 250 to 454
|
||||
) : LampState ()
|
||||
|
||||
@Serializable data class RgbLampState (
|
||||
@SerialName ("state") override val power : Power ,
|
||||
@SerialName ("brightness") override val brightness : Int ,
|
||||
@SerialName ("color") val color : Cie1931Color ,
|
||||
) : LampState ()
|
||||
|
||||
@Serializable enum class Power {
|
||||
ON , OFF ;
|
||||
|
||||
fun toBoolean () : Boolean = when (this) {
|
||||
ON -> true
|
||||
OFF -> false
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromBoolean ( power : Boolean ) : Power = when (power) {
|
||||
true -> ON
|
||||
false -> OFF
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable data class Cie1931Color (
|
||||
val x : Real , // Range 0.0 to 1.0 (for shrimp anyway)
|
||||
val y : Real , // Idem
|
||||
val id : Int ,
|
||||
val name : String ,
|
||||
)
|
||||
|
|
9
app/src/main/java/com/kernelmaft/zanbur/persistence.kt
Normal file
9
app/src/main/java/com/kernelmaft/zanbur/persistence.kt
Normal file
|
@ -0,0 +1,9 @@
|
|||
package com.kernelmaft.zanbur
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
|
||||
|
||||
val Context. dataStore : DataStore<Preferences> by preferencesDataStore ("state")
|
|
@ -1,12 +1,17 @@
|
|||
package com.kernelmaft.zanbur
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement.Center
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MaterialTheme.shapes
|
||||
import androidx.compose.material3.MaterialTheme.typography
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
|
@ -24,3 +29,9 @@ val compactSpacing = 16 . dp
|
|||
|
||||
MaterialTheme ( colorScheme, shapes , typography , content )
|
||||
}
|
||||
|
||||
@Composable fun CenteredColumn (
|
||||
modifier : Modifier = Modifier ,
|
||||
content : @Composable ColumnScope . () -> Unit ,
|
||||
) =
|
||||
Column ( modifier , Center , CenterHorizontally , content )
|
||||
|
|
19
app/src/main/java/com/kernelmaft/zanbur/zigbee2mqtt.kt
Normal file
19
app/src/main/java/com/kernelmaft/zanbur/zigbee2mqtt.kt
Normal file
|
@ -0,0 +1,19 @@
|
|||
package com.kernelmaft.zanbur
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
|
||||
fun publishSceneChange ( group : Group , newScene : Scene ) {
|
||||
val topic = Config . MQTT_TOPIC + "/" + group . name + "/set"
|
||||
val packet = Json . encodeToString ( SceneRecallPacket ( newScene . id ) )
|
||||
. toByteArray ()
|
||||
. asUByteArray ()
|
||||
MqttClient.publish ( topic , packet )
|
||||
}
|
||||
|
||||
@Serializable private data class SceneRecallPacket (
|
||||
@SerialName ("scene_recall") val sceneRecall : Int ,
|
||||
)
|
|
@ -3,12 +3,13 @@
|
|||
android-plugin = "8.5.1"
|
||||
kotlin = "2.0.0"
|
||||
# Runtime libraries
|
||||
androidx-core-ktx = "1.13.1"
|
||||
android-material = "1.12.0"
|
||||
androidx-activity-compose = "1.9.1"
|
||||
androidx-compose-material3 = "1.2.1"
|
||||
androidx-lifecycle-runtime-ktx = "2.8.3"
|
||||
androidx-activity-compose = "1.9.0"
|
||||
androidx-compose-ui = "1.6.8"
|
||||
androidx-core-ktx = "1.13.1"
|
||||
androidx-datastore-preferences = "1.1.1"
|
||||
androidx-lifecycle-runtime-ktx = "2.8.4"
|
||||
kotlinx-coroutines-android = "1.8.1"
|
||||
kotlinx-serialization-json = "1.7.1"
|
||||
# Other libraries
|
||||
|
@ -23,6 +24,7 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat
|
|||
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "androidx-compose-ui" }
|
||||
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "androidx-compose-ui" }
|
||||
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "androidx-compose-ui" }
|
||||
androidx-datastore-preferences = { group = "androidx.datastore" , name = "datastore-preferences" , version.ref = "androidx-datastore-preferences" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-runtime-ktx" }
|
||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-android" }
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
|
||||
|
|
Loading…
Add table
Reference in a new issue