From 124856ce2db158aae3b3bee32fedc43e488912da Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Wed, 1 Jul 2026 15:11:31 +0200 Subject: [PATCH] Add active profile screens --- composeApp/build.gradle.kts | 1 + .../ui/composable/ActiveProfileScreen.kt | 367 ++++++++++++++++++ .../torch/compose/ui/composable/HomeScreen.kt | 4 +- .../ui/composable/navigation/TorchNavHost.kt | 27 +- .../navigation/routes/ActiveProfileRoute.kt | 9 + .../ui/composable/widgets/QRCodeView.kt | 101 +++++ .../ui/view/model/ActiveProfileViewModel.kt | 69 ++++ .../ui/view/state/ActiveProfileUIState.kt | 12 + gradle/libs.versions.toml | 2 + 9 files changed, 588 insertions(+), 4 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ActiveProfileScreen.kt create mode 100755 composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/routes/ActiveProfileRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/widgets/QRCodeView.kt create mode 100755 composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ActiveProfileViewModel.kt create mode 100755 composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/state/ActiveProfileUIState.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 67e8c8ec..d83e4e5e 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -94,6 +94,7 @@ kotlin { implementation(libs.okio) + implementation(libs.qrose) implementation(libs.sqldelight.runtime) implementation(libs.sqldelight.coroutines.extensions) diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ActiveProfileScreen.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ActiveProfileScreen.kt new file mode 100644 index 00000000..4542e5ee --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ActiveProfileScreen.kt @@ -0,0 +1,367 @@ +package at.torch.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Hub +import androidx.compose.material.icons.filled.ImportExport +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Logout +import androidx.compose.material.icons.filled.QrCode +import androidx.compose.material.icons.filled.SwitchVideo +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import at.torch.compose.database.model.NostrEvent +import at.torch.compose.database.model.intermdiate.LocalNostrEvent +import at.torch.compose.extensions.hexToNpubHrp +import at.torch.compose.repository.NostrRepository +import at.torch.compose.ui.composable.navigation.routes.ImplementationPendingRoute +import at.torch.compose.ui.composable.navigation.routes.Route +import at.torch.compose.ui.composable.widgets.LoadingDataIndicator +import at.torch.compose.ui.composable.widgets.profile.ProfileAvatar +import at.torch.compose.ui.view.model.ActiveProfileViewModel +import at.torch.compose.ui.view.state.ActiveProfileUIState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent + +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun ActiveProfileScreen( + initialActiveProfileUIState: ActiveProfileUIState = ActiveProfileUIState.Loading, + activeUserPublicKey: HexKey, + nostrEventId: HexKey, + onNavigateBack: () -> Unit, + onNavigateToRoute: (Route) -> Unit, + nostrRepository: NostrRepository, +) { + + val activeProfileViewModel: ActiveProfileViewModel = viewModel( + factory = ActiveProfileViewModel.factory( + nostrEventId = nostrEventId, + initialActiveProfileUIState = initialActiveProfileUIState, + nostrRepository = nostrRepository, + activeUserPublicKey = activeUserPublicKey + ), + ) + + when(val activeProfileUIState = activeProfileViewModel.activeProfileUIState) { + ActiveProfileUIState.Error -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("Something went wrong") + } + } + is ActiveProfileUIState.Loaded -> { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Profile" + ) + }, + navigationIcon = { + IconButton( + onClick = { + onNavigateBack.invoke() + } + ) { + Icon( + Icons.Default.ArrowBack, + contentDescription = "Back" + ) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding) + ) { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + Row( + modifier = Modifier.fillMaxWidth().padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + ProfileAvatar( + profile = activeProfileUIState.localNostrEvent.profile, + publicKey = activeUserPublicKey + ) + + Column { + activeProfileUIState.localNostrEvent.profile?.humanReadableNameOrPubkey()?.let { humanReadableNameOrPubkey -> + Text( + text = humanReadableNameOrPubkey, + style = MaterialTheme.typography.titleLarge + ) + } + activeProfileUIState.localNostrEvent.profile?.about?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium + ) + } + + Spacer( + modifier = Modifier.height(10.dp) + ) + + Text( + text = activeUserPublicKey.hexToNpubHrp(), + style = MaterialTheme.typography.labelSmall + ) + } + } + } + + item { + Row( + modifier = Modifier.fillMaxWidth().padding(10.dp) + ) { + Spacer( + modifier = Modifier.weight(1f) + ) + + TextButton( + onClick = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Edit Profile") + ) + } + ) { + Icon( + Icons.Default.Edit, + contentDescription = "Edit profile" + ) + + Spacer( + modifier = Modifier.width(10.dp) + ) + + Text( + "Edit Profile" + ) + } + + Spacer( + modifier = Modifier.weight(1f) + ) + + Button( + onClick = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Share and Connect") + ) + }, + ) { + Icon( + Icons.Default.QrCode, + contentDescription = "QR code" + ) + + Spacer( + modifier = Modifier.width(10.dp) + ) + Text( + "Share Profile" + ) + } + + Spacer( + modifier = Modifier.weight(1f) + ) + } + } + + + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Change Account") + ) + } + ) { + + Icon( + Icons.Default.ImportExport, + contentDescription = "Change Profile" + ) + + Spacer( + modifier = Modifier.width(10.dp) + ) + + Text( + "Change Account" + ) + } + } + + + item { + TextButton( + onClick = { + + onNavigateToRoute.invoke( + ImplementationPendingRoute("Profile Keys") + ) + } + ) { + Icon( + Icons.Default.Key, + contentDescription = "Profile Keys" + ) + + Spacer( + modifier = Modifier.width(10.dp) + ) + + Text("Profile Keys") + } + } + + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Network Relays") + ) + + } + ) { + Icon( + Icons.Default.Hub, + contentDescription = "Network Relays" + ) + + Spacer( + modifier = Modifier.width(10.dp) + ) + + Text( + "Network Relays" + ) + } + } + + item { + Button( + onClick = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Sign Out") + ) + } + ) { + Icon( + Icons.Default.Logout, + contentDescription = "Logout" + ) + + Spacer( + modifier = Modifier.width(10.dp) + ) + + Text( + "Sign out" + ) + } + } + } + } + } + } + ActiveProfileUIState.Loading -> { + LoadingDataIndicator() + + LaunchedEffect(true) { + activeProfileViewModel.loadNostrFeed() + } + } + ActiveProfileUIState.NotFound -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("We couldn't find the local profile. Please try again later.") + } + } + } +} + +@Preview +@Composable +private fun UnannouncedProfileScreenPreview() { + _root_ide_package_.at.torch.compose.ui.theme.TorchTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + ActiveProfileScreen( + initialActiveProfileUIState = ActiveProfileUIState.Loaded( + localNostrEvent = LocalNostrEvent( + nostrEvent = NostrEvent( + id = "eventId", + pubKey = "84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240", + content = """Lorem Ipsum is simply dummy text of the printing and typesetting industry. + +Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. + +It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.""".trimIndent(), + tags = emptyArray(), + sig = "", + kind = MetadataEvent.KIND + ), + profile = _root_ide_package_.at.torch.compose.database.model.Profile( + publicKey = "84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240", + displayName = "John Doe", + nostrEventId = "nostrEventId", + about = "I been here, there and everywhere." + ) + + ) + ), + activeUserPublicKey = "84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240", + nostrEventId = "", + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + onNavigateToRoute = {}, + onNavigateBack = {} + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/HomeScreen.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/HomeScreen.kt index 849fceb1..43edbd75 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/HomeScreen.kt @@ -32,6 +32,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import at.torch.compose.ui.composable.navigation.routes.ActiveProfileRoute +import at.torch.compose.ui.composable.navigation.routes.NostrEventDetailRoute import at.torch.compose.ui.view.model.HomeScreenType import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -100,7 +102,7 @@ fun HomeScreen( IconButton( onClick = { onNavigateToEvent.invoke( - _root_ide_package_.at.torch.compose.ui.composable.navigation.routes.NostrEventDetailRoute( + ActiveProfileRoute( activeUserPublicKey = activeUserPublicKey, nostrEventId = homeScreenUIState.profileWithFollowing.nostrEvent.id ) diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/TorchNavHost.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/TorchNavHost.kt index a39454c5..4ffe8c8b 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/TorchNavHost.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/TorchNavHost.kt @@ -13,12 +13,16 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.toRoute import at.torch.compose.database.repository.DatabaseMarmotRepository +import at.torch.compose.ui.composable.ActiveProfileScreen import at.torch.compose.ui.composable.ChatRoomCreationScreen import at.torch.compose.ui.composable.ChatRoomDetailScreen import at.torch.compose.ui.composable.HomeScreen +import at.torch.compose.ui.composable.NostrEventDetailScreen import at.torch.compose.ui.composable.UnsyncedProfileScreen +import at.torch.compose.ui.composable.navigation.routes.ActiveProfileRoute import at.torch.compose.ui.composable.navigation.routes.ChatRoomCreationRoute import at.torch.compose.ui.composable.navigation.routes.LoadingRoute +import at.torch.compose.ui.composable.navigation.routes.NostrEventDetailRoute import at.torch.compose.ui.composable.navigation.routes.SovereignWalletStartupRoute import at.torch.compose.ui.composable.navigation.routes.WriteNewNoteRoute import at.torch.compose.ui.view.state.NavigationUIState @@ -443,6 +447,23 @@ fun TorchNavHost( } } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + ActiveProfileScreen( + activeUserPublicKey = route.activeUserPublicKey, + nostrEventId = route.nostrEventId, + nostrRepository = databaseNostrRepository, + onNavigateBack = { + navController.popBackStack() + }, + onNavigateToRoute = { route -> + navController.navigate( + route = route + ) + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() @@ -492,10 +513,10 @@ fun TorchNavHost( } ) } - composable { backStackEntry -> - val route = backStackEntry.toRoute() + composable { backStackEntry -> + val route = backStackEntry.toRoute() - at.torch.compose.ui.composable.NostrEventDetailScreen( + NostrEventDetailScreen( activeUserPublicKey = route.activeUserPublicKey, initialNostrEventDetailUIState = at.torch.compose.ui.view.state.NostrEventDetailUIState.Loading, nostrEventId = route.nostrEventId, diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/routes/ActiveProfileRoute.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/routes/ActiveProfileRoute.kt new file mode 100755 index 00000000..f19ea00f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/routes/ActiveProfileRoute.kt @@ -0,0 +1,9 @@ +package at.torch.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class ActiveProfileRoute( + val activeUserPublicKey: String, + val nostrEventId: String, +): Route() \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/widgets/QRCodeView.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/widgets/QRCodeView.kt new file mode 100644 index 00000000..fb2a42dc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/widgets/QRCodeView.kt @@ -0,0 +1,101 @@ +package at.torch.compose.ui.composable.widgets + +import androidx.compose.foundation.Image +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import at.torch.compose.ui.theme.BluePill +import io.github.alexzhirkevich.qrose.options.QrBrush +import io.github.alexzhirkevich.qrose.options.QrLogoPadding +import io.github.alexzhirkevich.qrose.options.QrLogoShape +import io.github.alexzhirkevich.qrose.options.solid +import io.github.alexzhirkevich.qrose.rememberQrCodePainter +import org.jetbrains.compose.resources.painterResource +import torch.composeapp.generated.resources.Res +import torch.composeapp.generated.resources.compose_multiplatform + +class QRCodeBackgroundPainter( + private val painter: Painter, + private val backgroundColor: Color = BluePill, + private val padding: Dp = 0.dp, +): Painter() { + override val intrinsicSize: Size = painter.intrinsicSize + + override fun DrawScope.onDraw() { + val paddingPx = padding.toPx() // Convert Dp to Px + + // Calculate the size excluding the padding + val paddedSize = Size( + width = size.width - paddingPx * 2, + height = size.height - paddingPx * 2 + ) + + // Draw the white background + drawRect( + color = backgroundColor, + size = size + ) + + // Draw the original painter with the padding + with(painter) { + // Translate to apply padding + translate(left = paddingPx, top = paddingPx) { + draw(size = paddedSize) + } + } + } + +} + +@Composable +fun QRCodeView( + data: String, +) { + val qrCodeColor = if (isSystemInDarkTheme()) { + Color.White + } else { + Color.Black + } + + val logoPainter = QRCodeBackgroundPainter( + painter = painterResource( + Res.drawable.compose_multiplatform + ), + ) + + val painter = rememberQrCodePainter( + data = data, + ) { + logo { + painter = logoPainter + padding = QrLogoPadding.Natural(.1f) + shape = QrLogoShape.Default + size = 0.3f + } + colors { + dark = QrBrush.solid( + color = qrCodeColor, + ) + } + } + + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Image( + modifier = Modifier.fillMaxWidth().aspectRatio(1f), + painter = painter, + contentDescription = data, + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ActiveProfileViewModel.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ActiveProfileViewModel.kt new file mode 100755 index 00000000..a18a2363 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ActiveProfileViewModel.kt @@ -0,0 +1,69 @@ +package at.torch.compose.ui.view.model + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch + +class ActiveProfileViewModel( + val activeUserPublicKey: HexKey, + val nostrEventId: HexKey, + initialActiveProfileUIState: at.torch.compose.ui.view.state.ActiveProfileUIState, + val nostrRepository: at.torch.compose.repository.NostrRepository +): ViewModel() { + var activeProfileUIState: at.torch.compose.ui.view.state.ActiveProfileUIState by mutableStateOf(initialActiveProfileUIState) + private set + + fun retryLoad() { + activeProfileUIState = at.torch.compose.ui.view.state.ActiveProfileUIState.Loading + loadNostrFeed() + } + + fun loadNostrFeed() { + viewModelScope.launch(Dispatchers.IO) { + + // Get contact list and sync feed... + nostrRepository.observeLocalNostrEventById( + nostrEventId + ).firstOrNull().let { localNostrEvent -> + activeProfileUIState = if (localNostrEvent != null && localNostrEvent.nostrEvent.kind == MetadataEvent.KIND) { + at.torch.compose.ui.view.state.ActiveProfileUIState.Loaded( + localNostrEvent = localNostrEvent + ) + } else { + at.torch.compose.ui.view.state.ActiveProfileUIState.Error + } + } + } + } + + companion object { + const val TAG = "ActiveProfileViewModel" + + fun factory( + nostrEventId: HexKey, + activeUserPublicKey: HexKey, + initialActiveProfileUIState: at.torch.compose.ui.view.state.ActiveProfileUIState = at.torch.compose.ui.view.state.ActiveProfileUIState.Loading, + nostrRepository: at.torch.compose.repository.NostrRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + ActiveProfileViewModel( + nostrEventId = nostrEventId, + initialActiveProfileUIState = initialActiveProfileUIState, + nostrRepository = nostrRepository, + activeUserPublicKey = activeUserPublicKey + ) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/state/ActiveProfileUIState.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/state/ActiveProfileUIState.kt new file mode 100755 index 00000000..5a34849e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/state/ActiveProfileUIState.kt @@ -0,0 +1,12 @@ +package at.torch.compose.ui.view.state + +sealed interface ActiveProfileUIState { + data class Loaded( + val localNostrEvent: at.torch.compose.database.model.intermdiate.LocalNostrEvent + ): ActiveProfileUIState + + data object Error: ActiveProfileUIState + data object NotFound: ActiveProfileUIState + data object Loading: ActiveProfileUIState +} + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ffa7d415..a22d92c7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ navigationCompose = "2.9.2" okhttp = "5.3.2" okio = "3.17.0" pagingCommon = "3.5.0" +qrose = "1.1.2" quartz = "1.10.0" room3 = "3.0.0-alpha06" sqldelight = "2.3.2" @@ -79,6 +80,7 @@ lightning-kmp-core = { module = "fr.acinq.lightning:lightning-kmp-core", version navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" } okhttp-coroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" } +qrose = { module = "io.github.alexzhirkevich:qrose", version.ref = "qrose" } sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } sqldelight-native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }