diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml
index 2f8be82..737d371 100644
--- a/.github/workflows/lint.yaml
+++ b/.github/workflows/lint.yaml
@@ -24,7 +24,7 @@ jobs:
uses: gradle/gradle-build-action@v2
- name: Lint
- run: ./gradlew ktlintCheck
+ run: ./gradlew clean ktlintCheck
- name: Tests
run: ./gradlew :shared:testReleaseUnitTest
diff --git a/.run/iosApp.run.xml b/.run/iosApp.run.xml
index 5c7b3fd..ba840de 100644
--- a/.run/iosApp.run.xml
+++ b/.run/iosApp.run.xml
@@ -4,4 +4,4 @@
-
\ No newline at end of file
+
diff --git a/CHANGELOG/CHANGELOG.md b/CHANGELOG/CHANGELOG.md
index 11babf5..6e728d2 100644
--- a/CHANGELOG/CHANGELOG.md
+++ b/CHANGELOG/CHANGELOG.md
@@ -1,2 +1,2 @@
# Fixes
-- Crash fix because of proguard obfuscation #36 [**Android**]
+- Offline cache using SQLDelight #40
diff --git a/build.gradle.kts b/build.gradle.kts
index ab7d679..e8f5d4c 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,4 +1,3 @@
-
import org.jlleitschuh.gradle.ktlint.KtlintExtension
import org.jlleitschuh.gradle.ktlint.KtlintPlugin
@@ -13,6 +12,7 @@ plugins {
id("com.adarshr.test-logger") version "3.2.0" apply false
id("org.jlleitschuh.gradle.ktlint") version "11.5.0" apply true
id("com.codingfeline.buildkonfig") version "0.14.0" apply false
+ id("app.cash.sqldelight") version Versions.SQLDELIGHT apply false
}
allprojects {
@@ -27,6 +27,11 @@ allprojects {
configure {
version.set("0.50.0")
+ filter {
+ exclude {
+ it.file.path.contains("/build")
+ }
+ }
}
}
diff --git a/buildSrc/src/main/kotlin/Artifacts.kt b/buildSrc/src/main/kotlin/Artifacts.kt
index 2f3439f..64ed6d1 100644
--- a/buildSrc/src/main/kotlin/Artifacts.kt
+++ b/buildSrc/src/main/kotlin/Artifacts.kt
@@ -1,8 +1,8 @@
object Artifact {
const val APP_ID = "me.sujanpoudel.playdeals"
const val APP_NAME = "App Deals"
- const val VERSION_CODE = 23
- const val VERSION_NAME = "2.0.5"
+ const val VERSION_CODE = 24
+ const val VERSION_NAME = "2.0.6"
const val MAJOR_RELEASE = true
const val ANDROID_COMPILE_SDK = 34
diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt
index c94306b..a727b08 100644
--- a/buildSrc/src/main/kotlin/Versions.kt
+++ b/buildSrc/src/main/kotlin/Versions.kt
@@ -2,7 +2,8 @@ object Versions {
const val KOTLIN = "1.9.10"
const val AGP = "8.1.1"
const val COMPOSE = "1.5.1"
- const val KO_TEST = "5.5.5"
+ const val SETTINGS = "1.0.0"
+ const val SQLDELIGHT = "2.0.0"
const val KTOR = "2.3.4"
const val KODE_IN = "7.20.2"
@@ -11,5 +12,5 @@ object Versions {
const val JUNIT_JUPITER = "5.9.3"
const val MOCKK = "1.13.5"
- const val SETTINGS = "1.0.0"
+ const val KO_TEST = "5.5.5"
}
diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj
index be88637..471a96a 100644
--- a/iosApp/iosApp.xcodeproj/project.pbxproj
+++ b/iosApp/iosApp.xcodeproj/project.pbxproj
@@ -329,6 +329,7 @@
"$(inherited)",
"-framework",
shared,
+ "-lsqlite3",
);
PRODUCT_BUNDLE_IDENTIFIER = me.sujanpoudel.playdeals;
PRODUCT_NAME = "App Deals";
@@ -360,6 +361,7 @@
"$(inherited)",
"-framework",
shared,
+ "-lsqlite3",
);
PRODUCT_BUNDLE_IDENTIFIER = me.sujanpoudel.playdeals;
PRODUCT_NAME = "App Deals";
diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts
index 1f4b4a6..246f141 100644
--- a/shared/build.gradle.kts
+++ b/shared/build.gradle.kts
@@ -1,4 +1,3 @@
-
import com.android.build.gradle.tasks.factory.AndroidUnitTest
import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.BOOLEAN
import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.INT
@@ -12,6 +11,7 @@ plugins {
id("org.jetbrains.compose")
id("com.adarshr.test-logger")
id("com.codingfeline.buildkonfig")
+ id("app.cash.sqldelight")
}
version = "1.0-SNAPSHOT"
@@ -59,13 +59,17 @@ kotlin {
implementation("com.russhwolf:multiplatform-settings-no-arg:${Versions.SETTINGS}")
implementation("com.mikepenz:multiplatform-markdown-renderer:0.7.2")
+ implementation("app.cash.sqldelight:coroutines-extensions:2.0.0-alpha05")
+ implementation("app.cash.sqldelight:primitive-adapters:2.0.0-alpha05")
}
}
val androidMain by getting {
dependencies {
implementation("io.ktor:ktor-client-android:${Versions.KTOR}")
- api("androidx.appcompat:appcompat:1.6.1")
+ implementation("androidx.appcompat:appcompat:1.6.1")
+ implementation("app.cash.sqldelight:android-driver:${Versions.SQLDELIGHT}")
+ implementation("androidx.startup:startup-runtime:1.1.1")
}
}
@@ -84,6 +88,7 @@ kotlin {
val iosMain by getting {
dependencies {
implementation("io.ktor:ktor-client-darwin:${Versions.KTOR}")
+ implementation("app.cash.sqldelight:native-driver:${Versions.SQLDELIGHT}")
}
}
@@ -95,6 +100,7 @@ kotlin {
dependencies {
implementation(compose.desktop.common)
implementation("io.ktor:ktor-client-okhttp:${Versions.KTOR}")
+ implementation("app.cash.sqldelight:sqlite-driver:${Versions.SQLDELIGHT}")
}
}
}
@@ -134,15 +140,27 @@ android {
}
}
-tasks.withType {
- useJUnitPlatform()
-}
-
buildkonfig {
packageName = pkgName
defaultConfigs {
buildConfigField(STRING, "VERSION_NAME", Artifact.VERSION_NAME)
+ buildConfigField(STRING, "PACKAGE_NAME", Artifact.APP_ID)
buildConfigField(INT, "VERSION_CODE", Artifact.VERSION_CODE.toString())
buildConfigField(BOOLEAN, "MAJOR_RELEASE", Artifact.MAJOR_RELEASE.toString())
}
}
+
+sqldelight {
+ databases {
+ create("SqliteDatabase") {
+ generateAsync.set(true)
+ verifyMigrations.set(true)
+ deriveSchemaFromMigrations.set(true)
+ packageName.set(pkgName)
+ }
+ }
+}
+
+tasks.withType {
+ useJUnitPlatform()
+}
diff --git a/shared/src/androidMain/AndroidManifest.xml b/shared/src/androidMain/AndroidManifest.xml
index a880029..325edd1 100644
--- a/shared/src/androidMain/AndroidManifest.xml
+++ b/shared/src/androidMain/AndroidManifest.xml
@@ -1,4 +1,19 @@
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
diff --git a/shared/src/androidMain/kotlin/me/sujanpoudel/playdeals/common/context.kt b/shared/src/androidMain/kotlin/me/sujanpoudel/playdeals/common/context.kt
new file mode 100644
index 0000000..b4fc977
--- /dev/null
+++ b/shared/src/androidMain/kotlin/me/sujanpoudel/playdeals/common/context.kt
@@ -0,0 +1,14 @@
+package me.sujanpoudel.playdeals.common
+
+import android.content.Context
+import androidx.startup.Initializer
+
+internal lateinit var applicationContext: Context
+
+internal class ApplicationContextInitializer : Initializer {
+ override fun create(context: Context): Context = context.also {
+ applicationContext = it
+ }
+
+ override fun dependencies(): List>> = emptyList()
+}
diff --git a/shared/src/androidMain/kotlin/me/sujanpoudel/playdeals/common/domain/persistent/db/sqlDriver.android.kt b/shared/src/androidMain/kotlin/me/sujanpoudel/playdeals/common/domain/persistent/db/sqlDriver.android.kt
new file mode 100644
index 0000000..c646b3e
--- /dev/null
+++ b/shared/src/androidMain/kotlin/me/sujanpoudel/playdeals/common/domain/persistent/db/sqlDriver.android.kt
@@ -0,0 +1,14 @@
+package me.sujanpoudel.playdeals.common.domain.persistent.db
+
+import app.cash.sqldelight.async.coroutines.synchronous
+import app.cash.sqldelight.db.SqlDriver
+import app.cash.sqldelight.driver.android.AndroidSqliteDriver
+import me.sujanpoudel.playdeals.common.Constants
+import me.sujanpoudel.playdeals.common.SqliteDatabase
+import me.sujanpoudel.playdeals.common.applicationContext
+
+actual fun createSqlDriver(): SqlDriver = AndroidSqliteDriver(
+ schema = SqliteDatabase.Schema.synchronous(),
+ context = applicationContext,
+ name = Constants.DATABASE_NAME,
+)
diff --git a/shared/src/androidUnitTest/kotlin/me/sujanpoudel/playdeals/common/ui/screens/HomeScreenViewModelTest.kt b/shared/src/androidUnitTest/kotlin/me/sujanpoudel/playdeals/common/ui/screens/HomeScreenViewModelTest.kt
index 15587b6..c61a7b8 100644
--- a/shared/src/androidUnitTest/kotlin/me/sujanpoudel/playdeals/common/ui/screens/HomeScreenViewModelTest.kt
+++ b/shared/src/androidUnitTest/kotlin/me/sujanpoudel/playdeals/common/ui/screens/HomeScreenViewModelTest.kt
@@ -1,6 +1,7 @@
package me.sujanpoudel.playdeals.common.ui.screens
import com.russhwolf.settings.MapSettings
+import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
@@ -11,14 +12,17 @@ import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
-import me.sujanpoudel.playdeals.common.AppPreferences
import me.sujanpoudel.playdeals.common.domain.entities.DealEntity
-import me.sujanpoudel.playdeals.common.networking.Failure
-import me.sujanpoudel.playdeals.common.networking.RemoteAPI
-import me.sujanpoudel.playdeals.common.networking.Result
+import me.sujanpoudel.playdeals.common.domain.models.Failure
+import me.sujanpoudel.playdeals.common.domain.models.Result
+import me.sujanpoudel.playdeals.common.domain.persistent.AppPreferences
+import me.sujanpoudel.playdeals.common.domain.repositories.DealsRepository
import me.sujanpoudel.playdeals.common.ui.screens.home.HomeScreenState
import me.sujanpoudel.playdeals.common.ui.screens.home.HomeScreenViewModel
import me.sujanpoudel.playdeals.common.utils.setMainDispatcher
@@ -28,8 +32,9 @@ import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
class HomeScreenViewModelTest {
+
@MockK
- lateinit var remoteAPI: RemoteAPI
+ lateinit var dealsRepository: DealsRepository
@BeforeEach
fun setup() {
@@ -54,71 +59,112 @@ class HomeScreenViewModelTest {
runTest {
setMainDispatcher(UnconfinedTestDispatcher())
- coEvery { remoteAPI.getDeals() } returns Result.success(emptyList())
+ coEvery { dealsRepository.dealsFlow() } returns emptyFlow()
+ coEvery { dealsRepository.refreshDeals() } returns Result.success(Unit)
- HomeScreenViewModel(remoteAPI, appPreference())
+ HomeScreenViewModel(appPreference(), dealsRepository)
- coVerify { remoteAPI.getDeals() }
+ coVerify {
+ dealsRepository.dealsFlow()
+ }
}
@Test
- fun `should correctly update state before calling the remoteAPI_getDeals`(): Unit =
- runTest {
- val dispatcher = StandardTestDispatcher()
+ fun `should call to refresh the deals when view model is created`() = runTest {
+ setMainDispatcher(UnconfinedTestDispatcher())
- setMainDispatcher(dispatcher)
+ coEvery { dealsRepository.dealsFlow() } returns emptyFlow()
+ coEvery { dealsRepository.refreshDeals() } returns Result.success(Unit)
- coEvery { remoteAPI.getDeals() } returns Result.success(emptyList())
+ HomeScreenViewModel(appPreference(), dealsRepository)
- val viewModel = HomeScreenViewModel(remoteAPI, appPreference())
+ coVerify { dealsRepository.refreshDeals() }
+ }
- viewModel.state.value.let {
- it.allDeals shouldHaveSize 0
- it.isLoading shouldBe true
- it.isRefreshing shouldBe false
- }
+ @Test
+ fun `should correctly update state before calling the remoteAPI_getDeals`(): Unit = runTest {
+ val dispatcher = StandardTestDispatcher()
+
+ setMainDispatcher(dispatcher)
+
+ coEvery { dealsRepository.dealsFlow() } returns emptyFlow()
+ coEvery { dealsRepository.refreshDeals() } returns Result.success(Unit)
+
+ val viewModel = HomeScreenViewModel(appPreference(), dealsRepository)
+
+ viewModel.state.value.let {
+ it.allDeals shouldHaveSize 0
+ it.isLoading shouldBe true
+ it.isRefreshing shouldBe false
}
+ }
@Test
- fun `should correctly update state after getting failure result from remoteAPI`(): Unit =
+ fun `should correctly update state after getting failure result from remoteAPI`(): Unit = runTest {
+ val dispatcher = StandardTestDispatcher()
+
+ setMainDispatcher(dispatcher)
+
+ coEvery { dealsRepository.dealsFlow() } returns emptyFlow()
+ coEvery { dealsRepository.refreshDeals() } returns Result.failure(Failure.UnknownError)
+
+ val viewModel = HomeScreenViewModel(appPreference(), dealsRepository)
+
+ dispatcher.scheduler.runCurrent()
+
+ viewModel.state.value.also { state ->
+ state.allDeals shouldHaveSize 0
+ state.isLoading shouldBe false
+ state.isRefreshing shouldBe false
+ state.persistentError shouldBe Failure.UnknownError.message
+ }
+ }
+
+ @Test
+ fun `should correctly update state after getting success result from remoteAPI`(): Unit =
runTest {
val dispatcher = StandardTestDispatcher()
+ val deal = mockk()
+
+ every { deal.category } returns ""
+
setMainDispatcher(dispatcher)
- coEvery { remoteAPI.getDeals() } returns Result.failure(Failure.UnknownError)
+ coEvery { dealsRepository.dealsFlow() } returns emptyFlow()
+ coEvery { dealsRepository.refreshDeals() } returns Result.success(Unit)
- val viewModel = HomeScreenViewModel(remoteAPI, appPreference())
+ val viewModel = HomeScreenViewModel(appPreference(), dealsRepository)
dispatcher.scheduler.runCurrent()
viewModel.state.value.also { state ->
- state.allDeals shouldHaveSize 0
+ state.allDeals shouldContainExactly emptyList()
state.isLoading shouldBe false
state.isRefreshing shouldBe false
- state.persistentError shouldBe Failure.UnknownError.message
+ state.persistentError shouldBe null
}
}
@Test
- fun `should correctly update state after getting success result from remoteAPI`(): Unit =
+ fun `should correctly update state when flow emits deals`(): Unit =
runTest {
val dispatcher = StandardTestDispatcher()
- val deal = mockk()
-
- every { deal.category } returns ""
+ val entity = mockk()
+ every { entity.category } returns ""
setMainDispatcher(dispatcher)
- coEvery { remoteAPI.getDeals() } returns Result.success(listOf(deal))
+ coEvery { dealsRepository.dealsFlow() } returns flowOf(listOf(entity))
+ coEvery { dealsRepository.refreshDeals() } returns Result.success(Unit)
- val viewModel = HomeScreenViewModel(remoteAPI, appPreference())
+ val viewModel = HomeScreenViewModel(appPreference(), dealsRepository)
dispatcher.scheduler.runCurrent()
viewModel.state.value.also { state ->
- state.allDeals shouldContainExactly listOf(deal)
+ state.allDeals.shouldContainExactly(entity)
state.isLoading shouldBe false
state.isRefreshing shouldBe false
state.persistentError shouldBe null
@@ -126,111 +172,192 @@ class HomeScreenViewModelTest {
}
@Test
- fun `it should reset the error message when retrying`(): Unit =
+ fun `should reset the error message when retrying`(): Unit =
runTest {
val dispatcher = StandardTestDispatcher()
- val deal = mockk()
-
- every { deal.category } returns ""
-
setMainDispatcher(dispatcher)
- coEvery { remoteAPI.getDeals() } returns
+ coEvery { dealsRepository.refreshDeals() } returns
Result.failure(Failure.UnknownError) andThen
- Result.success(listOf(deal))
+ Result.success(Unit)
- val viewModel = HomeScreenViewModel(remoteAPI, appPreference())
+ coEvery { dealsRepository.dealsFlow() } returns emptyFlow()
+
+ val viewModel = HomeScreenViewModel(appPreference(), dealsRepository)
+
+ viewModel.state.value.also { state ->
+ state.allDeals shouldHaveSize 0
+ state.isLoading shouldBe true
+ state.isRefreshing shouldBe false
+ state.persistentError shouldBe null
+ state.errorOneOff shouldBe null
+ }
dispatcher.scheduler.advanceUntilIdle()
+ viewModel.state.value.also { state ->
+ state.allDeals shouldContainExactly emptyList()
+ state.isLoading shouldBe false
+ state.isRefreshing shouldBe false
+ state.persistentError shouldBe Failure.UnknownError.message
+ state.errorOneOff shouldBe null
+ }
+
viewModel.refreshDeals()
viewModel.state.value.also { state ->
- state.allDeals shouldHaveSize 0
+ state.allDeals shouldContainExactly emptyList()
state.isLoading shouldBe true
state.isRefreshing shouldBe false
state.persistentError shouldBe null
+ state.errorOneOff shouldBe null
}
dispatcher.scheduler.advanceUntilIdle()
viewModel.state.value.also { state ->
- state.allDeals shouldContainExactly listOf(deal)
+ state.allDeals shouldContainExactly emptyList()
state.isLoading shouldBe false
state.isRefreshing shouldBe false
state.persistentError shouldBe null
+ state.errorOneOff shouldBe null
}
}
@Test
- fun `it should correctly represent refresh in state`(): Unit =
+ fun `should switch to one off error and loading if there is already cache`(): Unit =
runTest {
val dispatcher = StandardTestDispatcher()
- val deal = mockk()
+ val entity = mockk()
- every { deal.category } returns ""
+ every { entity.category } returns ""
setMainDispatcher(dispatcher)
- coEvery { remoteAPI.getDeals() } returns
- Result.success(listOf(deal)) andThen
- Result.success(listOf(deal))
+ coEvery { dealsRepository.refreshDeals() } returns
+ Result.failure(Failure.UnknownError) andThen
+ Result.success(Unit)
+
+ coEvery { dealsRepository.dealsFlow() } returns flowOf(listOf(entity))
- val viewModel = HomeScreenViewModel(remoteAPI, appPreference())
+ val viewModel = HomeScreenViewModel(appPreference(), dealsRepository)
+ viewModel.state.value.also { state ->
+ state.allDeals shouldHaveSize 0
+ state.isLoading shouldBe true
+ state.isRefreshing shouldBe false
+ state.persistentError shouldBe null
+ state.errorOneOff shouldBe null
+ }
+ dispatcher.scheduler.advanceTimeBy(10000)
dispatcher.scheduler.advanceUntilIdle()
+ viewModel.state.value.also { state ->
+ state.allDeals.shouldContainExactly(entity)
+ state.isLoading shouldBe false
+ state.isRefreshing shouldBe false
+ state.persistentError shouldBe null
+ state.errorOneOff shouldBe Failure.UnknownError.message
+ }
+
viewModel.refreshDeals()
viewModel.state.value.also { state ->
- state.allDeals shouldContainExactly listOf(deal)
+ state.allDeals.shouldContainExactly(entity)
state.isLoading shouldBe false
state.isRefreshing shouldBe true
state.persistentError shouldBe null
+ state.errorOneOff shouldBe null
}
dispatcher.scheduler.advanceUntilIdle()
viewModel.state.value.also { state ->
- state.allDeals shouldContainExactly listOf(deal)
+ state.allDeals.shouldContainExactly(entity)
state.isLoading shouldBe false
state.isRefreshing shouldBe false
state.persistentError shouldBe null
+ state.errorOneOff shouldBe null
}
}
@Test
- fun `it should replace the app deals on refresh`(): Unit =
+ fun `should replace the app deals when new value is emitted`(): Unit =
runTest {
val dispatcher = StandardTestDispatcher()
+ setMainDispatcher(dispatcher)
- val deal = mockk()
+ val deal1 = mockk()
val deal2 = mockk()
- every { deal.category } returns ""
+ val flow = MutableStateFlow>(emptyList())
+
+ every { deal1.category } returns ""
every { deal2.category } returns ""
- setMainDispatcher(dispatcher)
+ coEvery { dealsRepository.refreshDeals() } returns Result.success(Unit)
+ coEvery { dealsRepository.dealsFlow() } returns flow
+
+ val viewModel = HomeScreenViewModel(appPreference(), dealsRepository)
- coEvery { remoteAPI.getDeals() } returns
- Result.success(listOf(deal)) andThen
- Result.success(listOf(deal, deal2))
+ dispatcher.scheduler.advanceTimeBy(10000)
+ dispatcher.scheduler.advanceUntilIdle()
- val viewModel = HomeScreenViewModel(remoteAPI, appPreference())
+ viewModel.state.value.also { state ->
+ state.allDeals.shouldBeEmpty()
+ }
+
+ flow.emit(listOf(deal1, deal2))
dispatcher.scheduler.advanceUntilIdle()
- viewModel.refreshDeals()
+ viewModel.state.value.also { state ->
+ state.allDeals.shouldContainExactly(deal1, deal2)
+ }
+ }
+
+ @Test
+ fun `should correctly update the app deals when new value is emitted`(): Unit =
+ runTest {
+ val dispatcher = StandardTestDispatcher()
+ setMainDispatcher(dispatcher)
+
+ val deal1 = mockk()
+ val deal2 = mockk()
+
+ val flow = MutableStateFlow>(emptyList())
+ every { deal1.category } returns ""
+ every { deal2.category } returns ""
+
+ coEvery { dealsRepository.refreshDeals() } returns Result.failure(Failure.UnknownError)
+ coEvery { dealsRepository.dealsFlow() } returns flow
+
+ val viewModel = HomeScreenViewModel(appPreference(), dealsRepository)
+
+ dispatcher.scheduler.advanceTimeBy(10000)
dispatcher.scheduler.advanceUntilIdle()
viewModel.state.value.also { state ->
- state.allDeals shouldContainExactly listOf(deal, deal2)
+ state.allDeals.shouldBeEmpty()
+ state.persistentError shouldBe Failure.UnknownError.message
+ state.errorOneOff shouldBe null
state.isLoading shouldBe false
state.isRefreshing shouldBe false
+ }
+
+ flow.emit(listOf(deal1, deal2))
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ viewModel.state.value.also { state ->
+ state.allDeals.shouldContainExactly(deal1, deal2)
state.persistentError shouldBe null
+ state.errorOneOff shouldBe Failure.UnknownError.message
+ state.isLoading shouldBe false
+ state.isRefreshing shouldBe false
}
}
}
diff --git a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/Constants.kt b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/Constants.kt
index a75ff32..00dc400 100644
--- a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/Constants.kt
+++ b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/Constants.kt
@@ -3,4 +3,5 @@ package me.sujanpoudel.playdeals.common
object Constants {
const val API_BASE_URL = "https://api.play-deals.contabo.sujanpoudel.me/api"
const val ABOUT_ME_URL = "https://sujanpoudel.me"
+ val DATABASE_NAME = "${BuildKonfig.PACKAGE_NAME}.db"
}
diff --git a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/PlayDealApp.kt b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/PlayDealApp.kt
index 0d0fa7d..c16447d 100644
--- a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/PlayDealApp.kt
+++ b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/PlayDealApp.kt
@@ -16,6 +16,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import me.sujanpoudel.playdeals.common.di.PrimaryDI
+import me.sujanpoudel.playdeals.common.domain.persistent.AppPreferences
import me.sujanpoudel.playdeals.common.navigation.NavGraph
import me.sujanpoudel.playdeals.common.navigation.NavHost
import me.sujanpoudel.playdeals.common.strings.LocalAppLanguage
diff --git a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/di/conf/ConfigurableDI.kt b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/di/conf/ConfigurableDI.kt
deleted file mode 100644
index 7374ae6..0000000
--- a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/di/conf/ConfigurableDI.kt
+++ /dev/null
@@ -1,198 +0,0 @@
-@file:Suppress("ktlint:standard:max-line-length")
-
-package me.sujanpoudel.playdeals.common.di.conf
-
-import org.kodein.di.Copy
-import org.kodein.di.DI
-import org.kodein.di.DIContainer
-import org.kodein.di.internal.maySynchronized
-import org.kodein.di.internal.synchronizedIfNull
-import kotlin.jvm.Volatile
-
-// From : https://github.com/kosi-libs/Kodein/blob/master/kodein-di-conf/src/commonMain/kotlin/org/kodein/di/conf/ConfigurableDI.kt
-
-/**
- * A class that can be used to configure a DI object and as a DI object.
- *
- * If you want it to be mutable, the [mutable] property needs to be set **before** any dependency retrieval.
- * The non-mutable configuration methods ([addImport], [addExtend] & [addConfig]) needs to happen **before** any dependency retrieval.
- */
-public class ConfigurableDI : DI {
-// override val container: KodeinContainer
-// get() = TODO("not implemented") //To change initializer of created properties use File | Settings | File Templates.
-
- /** @suppress */
- override val di: DI get() = this
-
- private val _lock = Any()
-
- /**
- * Whether this Configurabledi can be mutated.
- *
- * `null` = not set yet. `true` = can be mutated. `false` = cannot be mutated.
- *
- * Note that if not set, this field will be set to false on `first` DI retrieval.
- */
- public var mutable: Boolean? = null
- set(value) {
- if (value == field) {
- return
- }
- if (field != null) {
- throw IllegalStateException(
- "Mutable field has already been set. You must set the mutable field before first retrieval.",
- )
- }
- field = value
- }
-
- /**
- * Default constructor.
- */
- public constructor()
-
- /**
- * Convenient constructor to directly set the mutability.
- *
- * @param mutable Whether this DI can be mutated.
- */
- public constructor(mutable: Boolean) {
- this.mutable = mutable
- }
-
- /**
- * Configuration lambdas.
- *
- * When constructing the DI instance (upon first retrieval), all configuration lambdas will be applied.
- */
- private var _configs: MutableList Unit>? = ArrayList()
-
- /**
- * DI instance. If it is not null, than it cannot be configured anymore.
- */
- @Volatile
- private var _instance: DI? = null
-
- /**
- * Get the DI instance if it has already been constructed, or construct it if not.
- *
- * The first time this function is called is the end of the configuration.
- */
- public fun getOrConstruct(): DI {
- return synchronizedIfNull(
- lock = _lock,
- predicate = { _instance },
- ifNotNull = { it },
- ifNull = {
- if (mutable == null) {
- mutable = false
- }
-
- val configs = checkNotNull(_configs) { "recursive initialization detected" }
- _configs = null
-
- val (di, init) =
- DI.withDelayedCallbacks {
- for (config in configs)
- config()
- }
-
- _instance = di
-
- init()
- di
- },
- )
- }
-
- /**
- * Clear all the bindings of the DI instance. Needs [mutable] to be true.
- *
- * @throws IllegalStateException if [mutable] is not `true`.
- */
- public fun clear() {
- if (mutable != true) {
- throw IllegalStateException("Configurabledi is not mutable, you cannot clear bindings.")
- }
-
- maySynchronized(_lock) {
- val configs = _configs
-
- if (configs != null) {
- configs.clear()
- } else {
- _instance = null
- _configs = ArrayList()
- }
- }
- }
-
- /**
- * Whether or not this DI can be configured (meaning that it has not been used for retrieval yet).
- */
- public val canConfigure: Boolean get() = _instance == null
-
- /**
- * Adds a configuration to the bindings that will be applied when the DI is constructed.
- *
- * @param config The lambda to be applied when the DI instance is constructed.
- * @exception IllegalStateException When calling this function after [getOrConstruct] or any `DI` retrieval function.
- */
- public fun addConfig(config: DI.MainBuilder.() -> Unit): ConfigurableDI {
- maySynchronized(_lock) {
- val configs = _configs
- if (configs == null) {
- if (mutable != true) {
- throw IllegalStateException(
- "The non-mutable Configurabledi has been accessed and therefore constructed, you cannot add bindings after first retrieval.",
- )
- }
- val previous = _instance
- _instance = null
- _configs = ArrayList()
- if (previous != null) {
- addExtend(previous)
- }
- }
- _configs!!.add(config)
- }
- return this
- }
-
- /**
- * Adds a module to the bindings that will be applied when the DI is constructed.
- *
- * @param module The module to apply when the DI instance is constructed.
- * @param allowOverride Whether this module is allowed to override existing bindings.
- * @exception IllegalStateException When calling this function after [getOrConstruct] or any `DI` retrieval function.
- */
- public fun addImport(
- module: DI.Module,
- allowOverride: Boolean = false,
- ): ConfigurableDI =
- addConfig {
- import(module, allowOverride)
- }
-
- /**
- * Adds the bindings of an existing DI instance to the bindings that will be applied when the DI is constructed.
- *
- * @param di The existing DI instance whose bindings to be apply when the DI instance is constructed.
- * @param allowOverride Whether these bindings are allowed to override existing bindings.
- * @param copy The copy specifications, that defines which bindings will be copied to the new container.
- * All bindings from the extended container will be accessible in the new container, but only the copied bindings are able to access overridden bindings in this new container.
- * By default, all bindings that do not hold references (e.g. not singleton or multiton) are copied.
- * @exception IllegalStateException When calling this function after [getOrConstruct] or any `DI` retrieval function.
- */
- public fun addExtend(
- di: DI,
- allowOverride: Boolean = false,
- copy: Copy = Copy.NonCached,
- ): ConfigurableDI =
- addConfig {
- extend(di, allowOverride, copy)
- }
-
- /** @suppress */
- override val container: DIContainer get() = getOrConstruct().container
-}
diff --git a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/di/di.kt b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/di/di.kt
deleted file mode 100644
index 0be9d5a..0000000
--- a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/di/di.kt
+++ /dev/null
@@ -1,62 +0,0 @@
-package me.sujanpoudel.playdeals.common.di
-
-import com.russhwolf.settings.ObservableSettings
-import com.russhwolf.settings.Settings
-import io.ktor.client.HttpClient
-import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
-import io.ktor.client.plugins.logging.LogLevel
-import io.ktor.client.plugins.logging.Logger
-import io.ktor.client.plugins.logging.Logging
-import io.ktor.serialization.kotlinx.json.json
-import me.sujanpoudel.playdeals.common.AppPreferences
-import me.sujanpoudel.playdeals.common.di.conf.ConfigurableDI
-import me.sujanpoudel.playdeals.common.networking.RemoteAPI
-import me.sujanpoudel.playdeals.common.ui.screens.home.HomeScreenViewModel
-import me.sujanpoudel.playdeals.common.ui.screens.newDeal.NewDealScreenViewModel
-import me.sujanpoudel.playdeals.common.ui.screens.settings.SettingsScreenViewModel
-import me.sujanpoudel.playdeals.common.utils.isDebugBuild
-import me.sujanpoudel.playdeals.common.utils.settings.asObservableSettings
-import org.kodein.di.DI
-import org.kodein.di.bindProvider
-import org.kodein.di.bindSingleton
-import org.kodein.di.instance
-
-private val mainModule = DI.Module("mainModule") {
-
- bindProvider {
- HomeScreenViewModel(
- remoteAPI = instance(),
- appPreferences = instance(),
- )
- }
-
- bindSingleton {
- HttpClient {
- install(ContentNegotiation) { json() }
- Logging {
- level = if (isDebugBuild) LogLevel.ALL else LogLevel.NONE
- logger = object : Logger {
- override fun log(message: String) {
- println(message)
- }
- }
- }
- expectSuccess = true
- }
- }
-
- bindSingleton { RemoteAPI(instance()) }
-
- bindSingleton {
- Settings().asObservableSettings()
- }
-
- bindSingleton { AppPreferences(settings = instance()) }
-
- bindProvider { NewDealScreenViewModel() }
- bindProvider { SettingsScreenViewModel(instance()) }
-}
-
-val PrimaryDI = ConfigurableDI(true).apply {
- addImport(mainModule, true)
-}
diff --git a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/di/networking.kt b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/di/networking.kt
new file mode 100644
index 0000000..b60d594
--- /dev/null
+++ b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/di/networking.kt
@@ -0,0 +1,27 @@
+package me.sujanpoudel.playdeals.common.di
+
+import io.ktor.client.HttpClient
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.logging.LogLevel
+import io.ktor.client.plugins.logging.Logging
+import io.ktor.serialization.kotlinx.json.json
+import me.sujanpoudel.playdeals.common.domain.networking.RemoteAPI
+import me.sujanpoudel.playdeals.common.utils.isDebugBuild
+import org.kodein.di.DI
+import org.kodein.di.bindSingleton
+import org.kodein.di.instance
+
+internal val networkingModule = DI.Module("networking") {
+
+ bindSingleton {
+ HttpClient {
+ install(ContentNegotiation) { json() }
+ Logging {
+ level = if (isDebugBuild) LogLevel.ALL else LogLevel.NONE
+ }
+ expectSuccess = true
+ }
+ }
+
+ bindSingleton { RemoteAPI(instance()) }
+}
diff --git a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/di/persistent.kt b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/di/persistent.kt
new file mode 100644
index 0000000..5cc3849
--- /dev/null
+++ b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/di/persistent.kt
@@ -0,0 +1,33 @@
+package me.sujanpoudel.playdeals.common.di
+
+import com.russhwolf.settings.Settings
+import me.sujanpoudel.playdeals.common.SqliteDatabase
+import me.sujanpoudel.playdeals.common.domain.persistent.AppPreferences
+import me.sujanpoudel.playdeals.common.domain.persistent.db.buildDealAdaptor
+import me.sujanpoudel.playdeals.common.domain.persistent.db.createSqlDriver
+import me.sujanpoudel.playdeals.common.domain.repositories.DealsRepository
+import me.sujanpoudel.playdeals.common.utils.settings.asObservableSettings
+import org.kodein.di.DI
+import org.kodein.di.bindSingleton
+import org.kodein.di.instance
+
+internal val persistentModule = DI.Module("persistent") {
+
+ bindSingleton {
+ Settings().asObservableSettings()
+ }
+
+ bindSingleton { AppPreferences(settings = instance()) }
+
+ bindSingleton { createSqlDriver() }
+
+ bindSingleton { SqliteDatabase(instance(), buildDealAdaptor()) }
+
+ bindSingleton {
+ DealsRepository(
+ remoteAPI = instance(),
+ database = instance(),
+ appPreferences = instance(),
+ )
+ }
+}
diff --git a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/di/primary.kt b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/di/primary.kt
new file mode 100644
index 0000000..0514ebb
--- /dev/null
+++ b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/di/primary.kt
@@ -0,0 +1,9 @@
+package me.sujanpoudel.playdeals.common.di
+
+import org.kodein.di.DI
+
+val PrimaryDI = DI {
+ importOnce(persistentModule)
+ importOnce(networkingModule)
+ importOnce(viewModelModule)
+}
diff --git a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/di/viewModel.kt b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/di/viewModel.kt
new file mode 100644
index 0000000..8dcada3
--- /dev/null
+++ b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/di/viewModel.kt
@@ -0,0 +1,22 @@
+package me.sujanpoudel.playdeals.common.di
+
+import me.sujanpoudel.playdeals.common.ui.screens.home.HomeScreenViewModel
+import me.sujanpoudel.playdeals.common.ui.screens.newDeal.NewDealScreenViewModel
+import me.sujanpoudel.playdeals.common.ui.screens.settings.SettingsScreenViewModel
+import org.kodein.di.DI
+import org.kodein.di.bindProvider
+import org.kodein.di.instance
+
+internal val viewModelModule = DI.Module("viewModel") {
+
+ bindProvider {
+ HomeScreenViewModel(
+ appPreferences = instance(),
+ repository = instance(),
+ )
+ }
+
+ bindProvider { NewDealScreenViewModel() }
+
+ bindProvider { SettingsScreenViewModel(instance()) }
+}
diff --git a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/entities/DealEntity.kt b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/entities/DealEntity.kt
index ff46baf..fb9b5a3 100644
--- a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/entities/DealEntity.kt
+++ b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/entities/DealEntity.kt
@@ -21,7 +21,7 @@ data class DealEntity(
val url: String,
val category: String,
val downloads: String,
- private val rating: Float,
+ val rating: Float,
val offerExpiresIn: Instant,
val type: String,
val source: String,
diff --git a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/networking/Failure.kt b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/models/Failure.kt
similarity index 85%
rename from shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/networking/Failure.kt
rename to shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/models/Failure.kt
index c95c277..99286aa 100644
--- a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/networking/Failure.kt
+++ b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/models/Failure.kt
@@ -1,6 +1,7 @@
-package me.sujanpoudel.playdeals.common.networking
+package me.sujanpoudel.playdeals.common.domain.models
import io.ktor.client.network.sockets.ConnectTimeoutException
+import me.sujanpoudel.playdeals.common.domain.models.Failure.FeatureFailure
/**
* Base Class for handling errors/failures/exceptions.
diff --git a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/networking/Result.kt b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/models/Result.kt
similarity index 59%
rename from shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/networking/Result.kt
rename to shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/models/Result.kt
index 0888fc5..c6a83c7 100644
--- a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/networking/Result.kt
+++ b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/models/Result.kt
@@ -1,4 +1,4 @@
-package me.sujanpoudel.playdeals.common.networking
+package me.sujanpoudel.playdeals.common.domain.models
sealed class Result {
class Success(val data: T) : Result()
@@ -16,9 +16,18 @@ sealed class Result {
}
}
-fun Result.map(mapper: (From) -> To): Result {
+inline fun Result.map(mapper: (From) -> To): Result {
return when (this) {
is Result.Error -> Result.failure(this.failure)
is Result.Success -> Result.success(mapper(data))
}
}
+
+inline fun Result.skipData(): Result = map { }
+
+inline fun Result.onSuccess(block: (T) -> Unit): Result {
+ if (this is Result.Success) {
+ block(this.data)
+ }
+ return this
+}
diff --git a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/networking/HttpClient.kt b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/networking/HttpClient.kt
similarity index 88%
rename from shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/networking/HttpClient.kt
rename to shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/networking/HttpClient.kt
index f71ced8..eae87fd 100644
--- a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/networking/HttpClient.kt
+++ b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/networking/HttpClient.kt
@@ -1,4 +1,4 @@
-package me.sujanpoudel.playdeals.common.networking
+package me.sujanpoudel.playdeals.common.domain.networking
import io.ktor.client.HttpClient
import io.ktor.client.call.body
@@ -14,7 +14,9 @@ import kotlinx.coroutines.IO
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import me.sujanpoudel.playdeals.common.Constants.API_BASE_URL
+import me.sujanpoudel.playdeals.common.domain.models.Result
import me.sujanpoudel.playdeals.common.domain.models.api.Response
+import me.sujanpoudel.playdeals.common.domain.models.resolveToFailure
@OptIn(InternalAPI::class)
suspend inline fun HttpClient.request(
diff --git a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/networking/RemoteAPI.kt b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/networking/RemoteAPI.kt
similarity index 75%
rename from shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/networking/RemoteAPI.kt
rename to shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/networking/RemoteAPI.kt
index 361c849..4b344d5 100644
--- a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/networking/RemoteAPI.kt
+++ b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/networking/RemoteAPI.kt
@@ -1,8 +1,9 @@
-package me.sujanpoudel.playdeals.common.networking
+package me.sujanpoudel.playdeals.common.domain.networking
import io.ktor.client.HttpClient
import me.sujanpoudel.playdeals.common.domain.models.api.AppDealModel
import me.sujanpoudel.playdeals.common.domain.models.api.toEntity
+import me.sujanpoudel.playdeals.common.domain.models.map
class RemoteAPI(
private val client: HttpClient,
diff --git a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/AppPreferences.kt b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/persistent/AppPreferences.kt
similarity index 96%
rename from shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/AppPreferences.kt
rename to shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/persistent/AppPreferences.kt
index 30e2178..ac9ad77 100644
--- a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/AppPreferences.kt
+++ b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/persistent/AppPreferences.kt
@@ -1,4 +1,4 @@
-package me.sujanpoudel.playdeals.common
+package me.sujanpoudel.playdeals.common.domain.persistent
import com.russhwolf.settings.ObservableSettings
import com.russhwolf.settings.set
diff --git a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/persistent/db/DealAdaptor.kt b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/persistent/db/DealAdaptor.kt
new file mode 100644
index 0000000..87f911f
--- /dev/null
+++ b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/persistent/db/DealAdaptor.kt
@@ -0,0 +1,25 @@
+package me.sujanpoudel.playdeals.common.domain.persistent.db
+
+import app.cash.sqldelight.ColumnAdapter
+import kotlinx.datetime.Instant
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import migrations.Deal
+
+private object ImagesAdaptor : ColumnAdapter, String> {
+ override fun decode(databaseValue: String) = Json.decodeFromString>(databaseValue)
+ override fun encode(value: List) = Json.encodeToString(value)
+}
+
+private object InstantAdaptor : ColumnAdapter {
+ override fun decode(databaseValue: String) = Instant.parse(databaseValue)
+
+ override fun encode(value: Instant) = value.toString()
+}
+
+fun buildDealAdaptor() = Deal.Adapter(
+ imagesAdapter = ImagesAdaptor,
+ offer_expires_inAdapter = InstantAdaptor,
+ created_atAdapter = InstantAdaptor,
+ updated_atAdapter = InstantAdaptor,
+)
diff --git a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/persistent/db/DealQueries.kt b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/persistent/db/DealQueries.kt
new file mode 100644
index 0000000..589df2b
--- /dev/null
+++ b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/persistent/db/DealQueries.kt
@@ -0,0 +1,60 @@
+package me.sujanpoudel.playdeals.common.domain.persistent.db
+
+import app.cash.sqldelight.SuspendingTransactionWithoutReturn
+import me.sujanpoudel.playdeals.common.DealQueries
+import me.sujanpoudel.playdeals.common.domain.entities.DealEntity
+import migrations.Deal
+
+suspend fun DealQueries.upsert(dealEntity: DealEntity) {
+ with(dealEntity) {
+ upsert(
+ id = id,
+ name = name,
+ icon = icon,
+ images = images,
+ normal_price = normalPrice.toDouble(),
+ current_price = currentPrice.toDouble(),
+ currency = currency,
+ url = url,
+ category = category,
+ downloads = downloads,
+ rating = rating.toDouble(),
+ type = type,
+ source = source,
+ offer_expires_in = offerExpiresIn,
+ created_at = createdAt,
+ updated_at = updatedAt,
+ )
+ }
+}
+
+suspend fun DealQueries.upsertAll(
+ deals: List,
+ runInTnx: suspend SuspendingTransactionWithoutReturn.() -> Unit = {},
+) {
+ transaction {
+ deals.forEach {
+ upsert(it)
+ }
+ runInTnx()
+ }
+}
+
+fun Deal.toEntity() = DealEntity(
+ id = id,
+ name = name,
+ icon = icon,
+ images = images,
+ normalPrice = normal_price.toFloat(),
+ currentPrice = current_price.toFloat(),
+ currency = currency,
+ url = url,
+ category = category,
+ downloads = downloads,
+ rating = rating.toFloat(),
+ offerExpiresIn = offer_expires_in,
+ type = type,
+ source = source,
+ createdAt = created_at,
+ updatedAt = updated_at,
+)
diff --git a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/persistent/db/sqlDriver.kt b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/persistent/db/sqlDriver.kt
new file mode 100644
index 0000000..136ae98
--- /dev/null
+++ b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/persistent/db/sqlDriver.kt
@@ -0,0 +1,5 @@
+package me.sujanpoudel.playdeals.common.domain.persistent.db
+
+import app.cash.sqldelight.db.SqlDriver
+
+expect fun createSqlDriver(): SqlDriver
diff --git a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/repositories/DealsRepository.kt b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/repositories/DealsRepository.kt
new file mode 100644
index 0000000..64cf677
--- /dev/null
+++ b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/domain/repositories/DealsRepository.kt
@@ -0,0 +1,52 @@
+package me.sujanpoudel.playdeals.common.domain.repositories
+
+import app.cash.sqldelight.coroutines.asFlow
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.IO
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.withContext
+import kotlinx.datetime.Clock
+import me.sujanpoudel.playdeals.common.SqliteDatabase
+import me.sujanpoudel.playdeals.common.domain.entities.DealEntity
+import me.sujanpoudel.playdeals.common.domain.models.onSuccess
+import me.sujanpoudel.playdeals.common.domain.models.skipData
+import me.sujanpoudel.playdeals.common.domain.networking.RemoteAPI
+import me.sujanpoudel.playdeals.common.domain.persistent.AppPreferences
+import me.sujanpoudel.playdeals.common.domain.persistent.db.toEntity
+import me.sujanpoudel.playdeals.common.domain.persistent.db.upsertAll
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class DealsRepository(
+ private val remoteAPI: RemoteAPI,
+ private val database: SqliteDatabase,
+ private val appPreferences: AppPreferences,
+) {
+
+ suspend fun dealsFlow(): Flow> = withContext(Dispatchers.IO) {
+ database.dealQueries.getAll()
+ .asFlow()
+ .mapLatest { it.executeAsList() }
+ .map { deals ->
+ deals.map { deal -> deal.toEntity() }
+ }
+ }
+
+ suspend fun refreshDeals() = withContext(Dispatchers.IO) {
+ remoteAPI.getDeals()
+ .onSuccess { deals ->
+ appPreferences.lastUpdatedTime.update(Clock.System.now())
+ database.dealQueries.upsertAll(deals) {
+ removeStaleDeals(deals)
+ }
+ }
+ .skipData()
+ }
+
+ private suspend fun removeStaleDeals(upToDateDeals: List) {
+ val upToDateIds = upToDateDeals.map { it.id }
+ database.dealQueries.removeStale(upToDateIds)
+ }
+}
diff --git a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/ui/Modifiers.kt b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/ui/Modifiers.kt
deleted file mode 100644
index f672bbb..0000000
--- a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/ui/Modifiers.kt
+++ /dev/null
@@ -1 +0,0 @@
-package me.sujanpoudel.playdeals.common.ui
diff --git a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/ui/screens/ChangeLogScreen.kt b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/ui/screens/ChangeLogScreen.kt
index 436571a..3f2937a 100644
--- a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/ui/screens/ChangeLogScreen.kt
+++ b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/ui/screens/ChangeLogScreen.kt
@@ -31,9 +31,9 @@ import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.Markdown
import com.mikepenz.markdown.model.markdownColor
import com.mikepenz.markdown.model.markdownTypography
-import me.sujanpoudel.playdeals.common.AppPreferences
import me.sujanpoudel.playdeals.common.BuildKonfig
import me.sujanpoudel.playdeals.common.di.PrimaryDI
+import me.sujanpoudel.playdeals.common.domain.persistent.AppPreferences
import me.sujanpoudel.playdeals.common.strings.Strings
import me.sujanpoudel.playdeals.common.ui.components.ChangeLog
import me.sujanpoudel.playdeals.common.ui.components.common.Scaffold
diff --git a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/ui/screens/home/HomeScreenViewModel.kt b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/ui/screens/home/HomeScreenViewModel.kt
index b9f3df7..fc83b62 100644
--- a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/ui/screens/home/HomeScreenViewModel.kt
+++ b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/ui/screens/home/HomeScreenViewModel.kt
@@ -1,28 +1,31 @@
package me.sujanpoudel.playdeals.common.ui.screens.home
import io.ktor.util.reflect.instanceOf
+import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
-import kotlinx.datetime.Clock
-import me.sujanpoudel.playdeals.common.AppPreferences
import me.sujanpoudel.playdeals.common.BuildKonfig
import me.sujanpoudel.playdeals.common.Screens
import me.sujanpoudel.playdeals.common.domain.entities.DealEntity
import me.sujanpoudel.playdeals.common.domain.models.DealFilterOption
+import me.sujanpoudel.playdeals.common.domain.models.Result
import me.sujanpoudel.playdeals.common.domain.models.Selectable
+import me.sujanpoudel.playdeals.common.domain.persistent.AppPreferences
+import me.sujanpoudel.playdeals.common.domain.repositories.DealsRepository
import me.sujanpoudel.playdeals.common.extensions.capitalizeWords
-import me.sujanpoudel.playdeals.common.networking.RemoteAPI
-import me.sujanpoudel.playdeals.common.networking.Result
import me.sujanpoudel.playdeals.common.viewModel.ViewModel
import me.sujanpoudel.playdeals.common.viewModel.viewModelScope
+import kotlin.time.Duration.Companion.milliseconds
-@OptIn(ExperimentalStdlibApi::class)
+@OptIn(ExperimentalStdlibApi::class, FlowPreview::class)
class HomeScreenViewModel(
- private val remoteAPI: RemoteAPI,
private val appPreferences: AppPreferences,
+ private val repository: DealsRepository,
) : ViewModel() {
private val _state = MutableStateFlow(
@@ -31,10 +34,28 @@ class HomeScreenViewModel(
val state = _state as StateFlow
init {
- getDeals()
+ observeDeals()
+ refreshDeals()
checkIfChangelogNeedsToBeShown()
}
+ private fun observeDeals() = viewModelScope.launch {
+ repository.dealsFlow()
+ .debounce(300.milliseconds)
+ .collectLatest { deals ->
+ _state.update { state ->
+ state.copy(
+ persistentError = if (deals.isNotEmpty()) null else state.persistentError,
+ errorOneOff = if (deals.isNotEmpty()) state.persistentError else null,
+ allDeals = deals,
+ filterOptions = buildFilterOption(deals, state),
+ isRefreshing = state.isRefreshing || (deals.isNotEmpty() && state.isLoading),
+ isLoading = deals.isEmpty() && state.isLoading,
+ )
+ }
+ }
+ }
+
private fun checkIfChangelogNeedsToBeShown() {
if (BuildKonfig.MAJOR_RELEASE && appPreferences.getChangelogShownVersion() != BuildKonfig.VERSION_CODE) {
viewModelScope.launch {
@@ -48,40 +69,33 @@ class HomeScreenViewModel(
}
}
- fun refreshDeals() = getDeals()
-
- private fun getDeals() {
+ fun refreshDeals() {
_state.update { state ->
state.copy(
isLoading = state.allDeals.isEmpty(),
isRefreshing = state.allDeals.isNotEmpty(),
persistentError = null,
+ errorOneOff = null,
)
}
viewModelScope.launch {
- val result = remoteAPI.getDeals()
+ val result = repository.refreshDeals()
_state.update { state ->
when (result) {
- is Result.Error ->
- state.copy(
- isLoading = false,
- isRefreshing = false,
- persistentError = if (state.allDeals.isEmpty()) result.failure.message else null,
- errorOneOff = if (state.allDeals.isNotEmpty()) result.failure.message else null,
- )
+ is Result.Error -> state.copy(
+ isLoading = false,
+ isRefreshing = false,
+ persistentError = if (state.allDeals.isEmpty()) result.failure.message else null,
+ errorOneOff = if (state.allDeals.isNotEmpty()) result.failure.message else null,
+ )
- is Result.Success -> {
- appPreferences.lastUpdatedTime.update(Clock.System.now())
- state.copy(
- isLoading = false,
- isRefreshing = false,
- persistentError = null,
- errorOneOff = null,
- allDeals = result.data,
- filterOptions = buildFilterOption(result.data, state),
- )
- }
+ is Result.Success -> state.copy(
+ isLoading = false,
+ isRefreshing = false,
+ persistentError = null,
+ errorOneOff = null,
+ )
}
}
}
diff --git a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/ui/screens/settings/SettingsScreenViewModel.kt b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/ui/screens/settings/SettingsScreenViewModel.kt
index 9126d03..704ae5e 100644
--- a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/ui/screens/settings/SettingsScreenViewModel.kt
+++ b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/ui/screens/settings/SettingsScreenViewModel.kt
@@ -1,7 +1,7 @@
package me.sujanpoudel.playdeals.common.ui.screens.settings
import kotlinx.coroutines.flow.StateFlow
-import me.sujanpoudel.playdeals.common.AppPreferences
+import me.sujanpoudel.playdeals.common.domain.persistent.AppPreferences
import me.sujanpoudel.playdeals.common.strings.AppLanguage
import me.sujanpoudel.playdeals.common.ui.theme.AppearanceMode
import me.sujanpoudel.playdeals.common.viewModel.ViewModel
diff --git a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/ui/theme/Theme.kt b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/ui/theme/Theme.kt
index 1b9ee45..7ac5726 100644
--- a/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/ui/theme/Theme.kt
+++ b/shared/src/commonMain/kotlin/me/sujanpoudel/playdeals/common/ui/theme/Theme.kt
@@ -11,7 +11,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
-import me.sujanpoudel.playdeals.common.AppPreferences
+import me.sujanpoudel.playdeals.common.domain.persistent.AppPreferences
import me.sujanpoudel.playdeals.common.ui.ConfigureThemeForSystemUI
val blueishPurple = Color(0xFF7477CC)
diff --git a/shared/src/commonMain/sqldelight/me/sujanpoudel/playdeals/common/Deal.sq b/shared/src/commonMain/sqldelight/me/sujanpoudel/playdeals/common/Deal.sq
new file mode 100644
index 0000000..df429f3
--- /dev/null
+++ b/shared/src/commonMain/sqldelight/me/sujanpoudel/playdeals/common/Deal.sq
@@ -0,0 +1,26 @@
+
+upsert:
+ INSERT OR REPLACE INTO Deal (
+ id ,
+ name,
+ icon,
+ images,
+ normal_price,
+ current_price,
+ currency,
+ url,
+ category,
+ downloads,
+ rating,
+ type,
+ source,
+ offer_expires_in,
+ created_at,
+ updated_at
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);
+
+getAll:
+ SELECT * FROM Deal ORDER BY date(created_at) DESC;
+
+removeStale:
+ DELETE FROM Deal WHERE id NOT IN ?;
diff --git a/shared/src/commonMain/sqldelight/migrations/0.sqm b/shared/src/commonMain/sqldelight/migrations/0.sqm
new file mode 100644
index 0000000..86170f3
--- /dev/null
+++ b/shared/src/commonMain/sqldelight/migrations/0.sqm
@@ -0,0 +1,24 @@
+import kotlin.String;
+import kotlin.collections.List;
+import kotlinx.datetime.Instant;
+
+CREATE TABLE Deal
+(
+ id TEXT NOT NULL PRIMARY KEY,
+ name TEXT NOT NULL,
+ icon TEXT NOT NULL,
+ images TEXT AS List NOT NULL,
+ normal_price REAL NOT NULL,
+ current_price REAL NOT NULL,
+ currency TEXT NOT NULL,
+ url TEXT NOT NULL,
+ category TEXT NOT NULL,
+ downloads TEXT NOT NULL,
+ rating REAL NOT NULL,
+ type TEXT NOT NULL,
+ source TEXT NOT NULL,
+
+ offer_expires_in TEXT AS Instant NOT NULL,
+ created_at TEXT AS Instant NOT NULL,
+ updated_at TEXT AS Instant NOT NULL
+);
diff --git a/shared/src/desktopMain/kotlin/me/sujanpoudel/playdeals/common/domain/persistent/db/sqlDriver.desktop.kt b/shared/src/desktopMain/kotlin/me/sujanpoudel/playdeals/common/domain/persistent/db/sqlDriver.desktop.kt
new file mode 100644
index 0000000..0406a5e
--- /dev/null
+++ b/shared/src/desktopMain/kotlin/me/sujanpoudel/playdeals/common/domain/persistent/db/sqlDriver.desktop.kt
@@ -0,0 +1,11 @@
+package me.sujanpoudel.playdeals.common.domain.persistent.db
+
+import app.cash.sqldelight.async.coroutines.synchronous
+import app.cash.sqldelight.db.SqlDriver
+import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
+import me.sujanpoudel.playdeals.common.Constants
+import me.sujanpoudel.playdeals.common.SqliteDatabase
+
+actual fun createSqlDriver(): SqlDriver = JdbcSqliteDriver("jdbc:sqlite:${Constants.DATABASE_NAME}").apply {
+ SqliteDatabase.Schema.synchronous().create(this)
+}
diff --git a/shared/src/iosMain/kotlin/me/sujanpoudel/playdeals/common/domain/persistent/db/sqlDriver.ios.kt b/shared/src/iosMain/kotlin/me/sujanpoudel/playdeals/common/domain/persistent/db/sqlDriver.ios.kt
new file mode 100644
index 0000000..17c8ab2
--- /dev/null
+++ b/shared/src/iosMain/kotlin/me/sujanpoudel/playdeals/common/domain/persistent/db/sqlDriver.ios.kt
@@ -0,0 +1,12 @@
+package me.sujanpoudel.playdeals.common.domain.persistent.db
+
+import app.cash.sqldelight.async.coroutines.synchronous
+import app.cash.sqldelight.db.SqlDriver
+import app.cash.sqldelight.driver.native.NativeSqliteDriver
+import me.sujanpoudel.playdeals.common.Constants
+import me.sujanpoudel.playdeals.common.SqliteDatabase
+
+actual fun createSqlDriver(): SqlDriver = NativeSqliteDriver(
+ schema = SqliteDatabase.Schema.synchronous(),
+ name = Constants.DATABASE_NAME,
+)