diff --git a/processor/build.gradle b/processor/build.gradle index eff84310..92f8df96 100644 --- a/processor/build.gradle +++ b/processor/build.gradle @@ -1,4 +1,4 @@ apply from: "${project.rootDir}/gradle/kotlin/processor.gradle" group = 'com.kotlitecture.kotli' -version = '0.3.0' \ No newline at end of file +version = '0.4.0' \ No newline at end of file diff --git a/processor/src/main/kotlin/kotli/template/multiplatform/compose/MultiplatformComposeTemplateProcessor.kt b/processor/src/main/kotlin/kotli/template/multiplatform/compose/MultiplatformComposeTemplateProcessor.kt index 2f165977..87389b4a 100644 --- a/processor/src/main/kotlin/kotli/template/multiplatform/compose/MultiplatformComposeTemplateProcessor.kt +++ b/processor/src/main/kotlin/kotli/template/multiplatform/compose/MultiplatformComposeTemplateProcessor.kt @@ -11,6 +11,7 @@ import kotli.engine.template.rule.RenamePackage import kotli.engine.template.rule.ReplaceMarkedText import kotli.template.multiplatform.compose.common.CommonProvider import kotli.template.multiplatform.compose.dataflow.analytics.AnalyticsProvider +import kotli.template.multiplatform.compose.dataflow.cache.CacheProvider import kotli.template.multiplatform.compose.dataflow.common.CommonDataFlowProvider import kotli.template.multiplatform.compose.dataflow.config.ConfigProvider import kotli.template.multiplatform.compose.dataflow.database.DatabaseProvider @@ -85,6 +86,7 @@ object MultiplatformComposeTemplateProcessor : BaseTemplateProcessor() { ConfigProvider, DatabaseProvider, KeyValueProvider, + CacheProvider, HttpProvider, PagingProvider, diff --git a/processor/src/main/kotlin/kotli/template/multiplatform/compose/Rules.kt b/processor/src/main/kotlin/kotli/template/multiplatform/compose/Rules.kt index 8675ef23..1a219066 100644 --- a/processor/src/main/kotlin/kotli/template/multiplatform/compose/Rules.kt +++ b/processor/src/main/kotlin/kotli/template/multiplatform/compose/Rules.kt @@ -65,6 +65,7 @@ object Rules { const val UserFlowNavigationRailProvider = "${UserFlowNavigationDir}/RailProvider.kt" const val ShowcasesDir = "${CommonAppMainDir}/kotlin/kotli/app/showcases" const val ShowcasesHttpDir = "${ShowcasesDir}/datasource/http" + const val ShowcasesCacheDir = "${ShowcasesDir}/datasource/cache" const val ShowcasesPagingDir = "${ShowcasesDir}/datasource/paging" const val ShowcasesKeyValueDir = "${ShowcasesDir}/datasource/keyvalue" const val ShowcasesSqlDelightDir = "${ShowcasesDir}/datasource/sqldelight" @@ -77,6 +78,7 @@ object Rules { // dataflow const val AppConfigSource = "${CommonAppMainDir}/kotlin/kotli/app/datasource/config/AppConfigSource.kt" const val AnalyticsSource = "*/*AnalyticsSource.kt" + const val CacheSource = "*/*CacheSource*.kt" const val SqlDelightSource = "*/*SqlDelightSource.kt" const val ConfigSource = "*/*ConfigSource.kt" const val PagingSource = "*/*Paging*.kt" diff --git a/processor/src/main/kotlin/kotli/template/multiplatform/compose/common/CommonProvider.kt b/processor/src/main/kotlin/kotli/template/multiplatform/compose/common/CommonProvider.kt index 395f6c87..c535bc6d 100644 --- a/processor/src/main/kotlin/kotli/template/multiplatform/compose/common/CommonProvider.kt +++ b/processor/src/main/kotlin/kotli/template/multiplatform/compose/common/CommonProvider.kt @@ -10,7 +10,8 @@ object CommonProvider : BaseFeatureProvider() { override fun getType(): FeatureType = FeatureTypes.Unspecified override fun getId(): String = "common" override fun createProcessors(): List = listOf( - CommonKtorProcessor + CommonKtorProcessor, + CommonStatelyProcessor ) } \ No newline at end of file diff --git a/processor/src/main/kotlin/kotli/template/multiplatform/compose/common/CommonStatelyProcessor.kt b/processor/src/main/kotlin/kotli/template/multiplatform/compose/common/CommonStatelyProcessor.kt new file mode 100644 index 00000000..21604c18 --- /dev/null +++ b/processor/src/main/kotlin/kotli/template/multiplatform/compose/common/CommonStatelyProcessor.kt @@ -0,0 +1,20 @@ +package kotli.template.multiplatform.compose.common + +import kotli.engine.BaseFeatureProcessor +import kotli.engine.TemplateState +import kotli.engine.template.VersionCatalogRules +import kotli.engine.template.rule.RemoveMarkedLine + +object CommonStatelyProcessor : BaseFeatureProcessor() { + + override fun getId(): String = "common.stately" + override fun isInternal(): Boolean = true + + override fun doRemove(state: TemplateState) { + state.onApplyRules( + VersionCatalogRules( + RemoveMarkedLine("stately") + ) + ) + } +} \ No newline at end of file diff --git a/processor/src/main/kotlin/kotli/template/multiplatform/compose/dataflow/cache/CacheProvider.kt b/processor/src/main/kotlin/kotli/template/multiplatform/compose/dataflow/cache/CacheProvider.kt new file mode 100644 index 00000000..f780ee84 --- /dev/null +++ b/processor/src/main/kotlin/kotli/template/multiplatform/compose/dataflow/cache/CacheProvider.kt @@ -0,0 +1,14 @@ +package kotli.template.multiplatform.compose.dataflow.cache + +import kotli.engine.FeatureProcessor +import kotli.template.multiplatform.compose.dataflow.BaseDataFlowProvider +import kotli.template.multiplatform.compose.dataflow.cache.basic.BasicCacheProcessor + +object CacheProvider : BaseDataFlowProvider() { + + override fun getId(): String = "dataflow.cache" + override fun createProcessors(): List = listOf( + BasicCacheProcessor + ) + +} \ No newline at end of file diff --git a/processor/src/main/kotlin/kotli/template/multiplatform/compose/dataflow/cache/basic/BasicCacheProcessor.kt b/processor/src/main/kotlin/kotli/template/multiplatform/compose/dataflow/cache/basic/BasicCacheProcessor.kt new file mode 100644 index 00000000..b3fa2a3e --- /dev/null +++ b/processor/src/main/kotlin/kotli/template/multiplatform/compose/dataflow/cache/basic/BasicCacheProcessor.kt @@ -0,0 +1,53 @@ +package kotli.template.multiplatform.compose.dataflow.cache.basic + +import kotli.engine.BaseFeatureProcessor +import kotli.engine.FeatureProcessor +import kotli.engine.TemplateState +import kotli.engine.template.VersionCatalogRules +import kotli.engine.template.rule.CleanupMarkedLine +import kotli.engine.template.rule.RemoveFile +import kotli.engine.template.rule.RemoveMarkedLine +import kotli.template.multiplatform.compose.Rules +import kotli.template.multiplatform.compose.common.CommonStatelyProcessor +import kotli.template.multiplatform.compose.showcases.datasource.cache.CacheShowcasesProcessor +import kotlin.time.Duration.Companion.hours + +object BasicCacheProcessor : BaseFeatureProcessor() { + + const val ID = "dataflow.cache.basic" + + override fun getId(): String = ID + override fun getIntegrationEstimate(state: TemplateState): Long = 8.hours.inWholeMilliseconds + override fun dependencies(): List> = listOf( + CacheShowcasesProcessor::class.java, + CommonStatelyProcessor::class.java + ) + + override fun doApply(state: TemplateState) { + state.onApplyRules( + Rules.BuildGradleSharedData, + CleanupMarkedLine("{dataflow.cache.basic}") + ) + } + + override fun doRemove(state: TemplateState) { + state.onApplyRules( + Rules.CacheSource, + RemoveFile() + ) + state.onApplyRules( + Rules.AppDIKt, + RemoveMarkedLine("CacheSource") + ) + state.onApplyRules( + Rules.BuildGradleSharedData, + RemoveMarkedLine("{dataflow.cache.basic}") + ) + state.onApplyRules( + VersionCatalogRules( + RemoveMarkedLine("stately-concurrent-collections") + ) + ) + } + +} \ No newline at end of file diff --git a/processor/src/main/kotlin/kotli/template/multiplatform/compose/dataflow/database/sqldelight/SqlDelightProcessor.kt b/processor/src/main/kotlin/kotli/template/multiplatform/compose/dataflow/database/sqldelight/SqlDelightProcessor.kt index f0963bd0..40498a5f 100644 --- a/processor/src/main/kotlin/kotli/template/multiplatform/compose/dataflow/database/sqldelight/SqlDelightProcessor.kt +++ b/processor/src/main/kotlin/kotli/template/multiplatform/compose/dataflow/database/sqldelight/SqlDelightProcessor.kt @@ -1,6 +1,7 @@ package kotli.template.multiplatform.compose.dataflow.database.sqldelight import kotli.engine.BaseFeatureProcessor +import kotli.engine.FeatureProcessor import kotli.engine.TemplateState import kotli.engine.template.VersionCatalogRules import kotli.engine.template.rule.CleanupMarkedBlock @@ -10,6 +11,7 @@ import kotli.engine.template.rule.RemoveMarkedBlock import kotli.engine.template.rule.RemoveMarkedLine import kotli.engine.template.rule.RenamePackage import kotli.template.multiplatform.compose.Rules +import kotli.template.multiplatform.compose.common.CommonStatelyProcessor import kotli.template.multiplatform.compose.dataflow.paging.cashapp.CashAppPagingProcessor import kotlin.time.Duration.Companion.hours @@ -23,6 +25,10 @@ object SqlDelightProcessor : BaseFeatureProcessor() { override fun getIntegrationEstimate(state: TemplateState): Long = 4.hours.inWholeMilliseconds + override fun dependencies(): List> = listOf( + CommonStatelyProcessor::class.java + ) + override fun doApply(state: TemplateState) { state.onApplyRules( Rules.BuildGradleApp, @@ -55,8 +61,10 @@ object SqlDelightProcessor : BaseFeatureProcessor() { ) state.onApplyRules( VersionCatalogRules( - RemoveMarkedLine("touchlab-stately"), - RemoveMarkedLine("sqldelight") + RemoveMarkedLine("sqldelight"), + RemoveMarkedLine("stately-common"), + RemoveMarkedLine("stately-isolate"), + RemoveMarkedLine("stately-iso-collections"), ) ) state.onApplyRules( diff --git a/processor/src/main/kotlin/kotli/template/multiplatform/compose/showcases/ShowcasesProvider.kt b/processor/src/main/kotlin/kotli/template/multiplatform/compose/showcases/ShowcasesProvider.kt index 3db1e8ac..89177bf7 100644 --- a/processor/src/main/kotlin/kotli/template/multiplatform/compose/showcases/ShowcasesProvider.kt +++ b/processor/src/main/kotlin/kotli/template/multiplatform/compose/showcases/ShowcasesProvider.kt @@ -4,15 +4,16 @@ import kotli.engine.BaseFeatureProvider import kotli.engine.FeatureProcessor import kotli.engine.FeatureType import kotli.engine.model.FeatureTypes +import kotli.template.multiplatform.compose.showcases.datasource.cache.CacheShowcasesProcessor import kotli.template.multiplatform.compose.showcases.datasource.http.HttpShowcasesProcessor import kotli.template.multiplatform.compose.showcases.datasource.keyvalue.KeyValueShowcasesProcessor import kotli.template.multiplatform.compose.showcases.datasource.paging.PagingShowcasesProcessor -import kotli.template.multiplatform.compose.showcases.navigation.NavigationShowcasesProcessor import kotli.template.multiplatform.compose.showcases.feature.loader.data.DataLoaderShowcasesProcessor import kotli.template.multiplatform.compose.showcases.feature.passcode.PasscodeShowcasesProcessor import kotli.template.multiplatform.compose.showcases.feature.theme.ThemeShowcasesProcessor import kotli.template.multiplatform.compose.showcases.feature.theme.change.ChangeThemeShowcasesProcessor import kotli.template.multiplatform.compose.showcases.feature.theme.toggle.ToggleThemeShowcasesProcessor +import kotli.template.multiplatform.compose.showcases.navigation.NavigationShowcasesProcessor object ShowcasesProvider : BaseFeatureProvider() { @@ -33,7 +34,8 @@ object ShowcasesProvider : BaseFeatureProvider() { PagingShowcasesProcessor, HttpShowcasesProcessor, KeyValueShowcasesProcessor, - DataLoaderShowcasesProcessor + DataLoaderShowcasesProcessor, + CacheShowcasesProcessor ) } \ No newline at end of file diff --git a/processor/src/main/kotlin/kotli/template/multiplatform/compose/showcases/datasource/cache/CacheShowcasesProcessor.kt b/processor/src/main/kotlin/kotli/template/multiplatform/compose/showcases/datasource/cache/CacheShowcasesProcessor.kt new file mode 100644 index 00000000..ec1b3128 --- /dev/null +++ b/processor/src/main/kotlin/kotli/template/multiplatform/compose/showcases/datasource/cache/CacheShowcasesProcessor.kt @@ -0,0 +1,31 @@ +package kotli.template.multiplatform.compose.showcases.datasource.cache + +import kotli.engine.BaseFeatureProcessor +import kotli.engine.TemplateState +import kotli.engine.template.rule.RemoveFile +import kotli.engine.template.rule.RemoveMarkedLine +import kotli.template.multiplatform.compose.Rules + +object CacheShowcasesProcessor : BaseFeatureProcessor() { + + const val ID = "showcases.datasource.cache" + + override fun getId(): String = ID + override fun isInternal(): Boolean = true + + override fun doRemove(state: TemplateState) { + state.onApplyRules( + Rules.ShowcasesKt, + RemoveMarkedLine("Cache") + ) + state.onApplyRules( + Rules.ShowcasesCacheDir, + RemoveFile() + ) + state.onApplyRules( + Rules.AppKt, + RemoveMarkedLine("BasicCacheViewModel") + ) + } + +} \ No newline at end of file diff --git a/processor/src/main/resources/kotli/template/multiplatform/compose/dataflow/cache/basic/description.md b/processor/src/main/resources/kotli/template/multiplatform/compose/dataflow/cache/basic/description.md new file mode 100644 index 00000000..1c515b3d --- /dev/null +++ b/processor/src/main/resources/kotli/template/multiplatform/compose/dataflow/cache/basic/description.md @@ -0,0 +1 @@ +Thread-safe API for storing and retrieving any in-memory data. Can be utilized as an L1 Cache when managing HTTP requests, offering an efficient means to present data without delays, but with the ability to update based on expiration and other conditions. \ No newline at end of file diff --git a/processor/src/main/resources/kotli/template/multiplatform/compose/dataflow/cache/basic/title.md b/processor/src/main/resources/kotli/template/multiplatform/compose/dataflow/cache/basic/title.md new file mode 100644 index 00000000..3b79c1c3 --- /dev/null +++ b/processor/src/main/resources/kotli/template/multiplatform/compose/dataflow/cache/basic/title.md @@ -0,0 +1 @@ +Basic In-Memory Cache API \ No newline at end of file diff --git a/processor/src/main/resources/kotli/template/multiplatform/compose/dataflow/cache/basic/usage.md b/processor/src/main/resources/kotli/template/multiplatform/compose/dataflow/cache/basic/usage.md new file mode 100644 index 00000000..ffc72483 --- /dev/null +++ b/processor/src/main/resources/kotli/template/multiplatform/compose/dataflow/cache/basic/usage.md @@ -0,0 +1,53 @@ +## Overview + +The API can be accessed through: +- `shared.data.datasource.cache.CacheSource` - facade interface at the core module level. +- `app.datasource.cache.AppCacheSource` - decorator class at the app level. + +The difference is that the class serves as a **decorator** and can provide extra methods without impacting facade implementations. + +Facade **CacheSource** provides the following methods: + +- `getState(key: CacheKey, valueProvider: suspend () -> T?): CacheState` - Retrieves the state of a cache entry associated with the specified key. +- `get(key: CacheKey, valueProvider: suspend () -> T?): T?` - Retrieves the value associated with the specified key from the cache. +- `invalidate(type: Class)` - Invalidates all cache entries associated with the specified key type. +- `invalidate(key: K)` - Invalidates the cache entry associated with the specified key. +- `remove(type: Class)` - Removes all cache entries associated with the specified key type. +- `remove(key: K)` - Removes the cache entry associated with the specified key. +- `put(key: CacheKey, value: T)` - Associates the specified value with the specified key in the cache. +- `clear()` - Clears all entries from the cache. + +## Example + +Both the **facade** and **decorator** are pre-configured via dependency injection (DI) as singletons in `app.di.datasource.ProvidesCacheSource`. + +To start using, just inject it to your DI managed class. + +```kotlin +class BasicCacheViewModel( + private val cacheSource: CacheSource = get() +) : BaseViewModel() { + + val cacheStore = StoreObject() + + override fun doBind() { + launchAsync { + val cacheKey = SimpleCacheKey() + val cacheState = cacheSource.getState(cacheKey, ::getDateAsFormattedString) + cacheState.changes().collectLatest(cacheStore::set) + } + } + + private fun getDateAsFormattedString(): String { + val time = Clock.System.now() + return time.format(DateTimeComponents.Format { + byUnicodePattern("yyyy-MM-dd HH:mm:ss") + }) + } + + private data class SimpleCacheKey( + override val ttl: Long = CacheKey.TTL_10_SECONDS + ) : CacheKey + +} +``` \ No newline at end of file diff --git a/processor/src/main/resources/kotli/template/multiplatform/compose/dataflow/cache/title.md b/processor/src/main/resources/kotli/template/multiplatform/compose/dataflow/cache/title.md new file mode 100644 index 00000000..91ad0202 --- /dev/null +++ b/processor/src/main/resources/kotli/template/multiplatform/compose/dataflow/cache/title.md @@ -0,0 +1 @@ +Cache \ No newline at end of file diff --git a/template/app/build.gradle.kts b/template/app/build.gradle.kts index d1d2e6c6..41062b97 100644 --- a/template/app/build.gradle.kts +++ b/template/app/build.gradle.kts @@ -149,10 +149,6 @@ android { sourceCompatibility(libs.versions.android.jvmTarget.get()) targetCompatibility(libs.versions.android.jvmTarget.get()) } - dependencies { - debugImplementation(libs.compose.ui.tooling) - debugImplementation(libs.compose.ui.tooling.preview) - } } // {platform.android.config} // {platform.jvm.config} diff --git a/template/app/src/commonMain/kotlin/kotli/app/App.kt b/template/app/src/commonMain/kotlin/kotli/app/App.kt index f243c066..473fb3b1 100644 --- a/template/app/src/commonMain/kotlin/kotli/app/App.kt +++ b/template/app/src/commonMain/kotlin/kotli/app/App.kt @@ -5,30 +5,31 @@ import androidx.compose.runtime.remember import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import kotli.app.di.get +import kotli.app.feature.navigation.NavigationBarViewModel +import kotli.app.feature.navigation.samples.a.NavigationAViewModel +import kotli.app.feature.navigation.samples.b.NavigationBViewModel +import kotli.app.feature.navigation.samples.c.NavigationCViewModel +import kotli.app.feature.theme.change.ChangeThemeViewModel +import kotli.app.feature.theme.toggle.ToggleThemeViewModel import kotli.app.showcases.ShowcasesViewModel +import kotli.app.showcases.datasource.cache.basic.BasicCacheViewModel import kotli.app.showcases.datasource.http.basic.BasicHttpViewModel import kotli.app.showcases.datasource.keyvalue.`object`.ObjectKeyValueViewModel import kotli.app.showcases.datasource.keyvalue.primitive.PrimitiveKeyValueViewModel import kotli.app.showcases.datasource.paging.basic.BasicPagingViewModel +import kotli.app.showcases.datasource.sqldelight.crud.SqlDelightCrudViewModel +import kotli.app.showcases.datasource.sqldelight.paging.SqlDelightPagingViewModel +import kotli.app.showcases.feature.loader.data.DataLoaderShowcaseViewModel import kotli.app.showcases.navigation.args.from.ArgsNavigationFromViewModel import kotli.app.showcases.navigation.args.to.ArgsNavigationToViewModel import kotli.app.showcases.navigation.no_args.from.NoArgsNavigationFromViewModel import kotli.app.showcases.navigation.no_args.to.NoArgsNavigationToViewModel -import kotli.app.showcases.feature.loader.data.DataLoaderShowcaseViewModel +import kotli.app.ui.loader.LoaderViewModel import kotli.app.ui.screen.template.TemplateViewModel import kotli.app.ui.screen.template_no_args.TemplateNoArgsViewModel import kotli.app.ui.theme.AppThemePersistenceViewModel import kotli.app.ui.theme.AppThemeProvider import kotli.app.ui.theme.AppThemeViewModel -import kotli.app.ui.loader.LoaderViewModel -import kotli.app.feature.navigation.NavigationBarViewModel -import kotli.app.feature.navigation.samples.a.NavigationAViewModel -import kotli.app.feature.navigation.samples.b.NavigationBViewModel -import kotli.app.feature.navigation.samples.c.NavigationCViewModel -import kotli.app.feature.theme.change.ChangeThemeViewModel -import kotli.app.feature.theme.toggle.ToggleThemeViewModel -import kotli.app.showcases.datasource.sqldelight.crud.SqlDelightCrudViewModel -import kotli.app.showcases.datasource.sqldelight.paging.SqlDelightPagingViewModel import shared.presentation.ViewModelProvider /** @@ -66,4 +67,5 @@ private val AppViewModelFactory = viewModelFactory { initializer { ObjectKeyValueViewModel(get(), get()) } initializer { SqlDelightCrudViewModel(get(), get()) } initializer { SqlDelightPagingViewModel(get(), get(), get(), get()) } + initializer { BasicCacheViewModel(get(), get()) } } diff --git a/template/app/src/commonMain/kotlin/kotli/app/datasource/cache/AppCacheSource.kt b/template/app/src/commonMain/kotlin/kotli/app/datasource/cache/AppCacheSource.kt new file mode 100644 index 00000000..811a9893 --- /dev/null +++ b/template/app/src/commonMain/kotlin/kotli/app/datasource/cache/AppCacheSource.kt @@ -0,0 +1,10 @@ +package kotli.app.datasource.cache + +import shared.data.datasource.cache.InMemoryCacheSource + +/** + * Decorator class for working with L1 Cache. + * + * Can provide extra methods without impacting facade implementations. + */ +class AppCacheSource : InMemoryCacheSource() \ No newline at end of file diff --git a/template/app/src/commonMain/kotlin/kotli/app/di/DI.kt b/template/app/src/commonMain/kotlin/kotli/app/di/DI.kt index a61e9465..ebe18a1d 100644 --- a/template/app/src/commonMain/kotlin/kotli/app/di/DI.kt +++ b/template/app/src/commonMain/kotlin/kotli/app/di/DI.kt @@ -1,6 +1,7 @@ package kotli.app.di import kotli.app.di.datasource.ProvidesAnalyticsSource +import kotli.app.di.datasource.ProvidesCacheSource import kotli.app.di.datasource.ProvidesConfigSource import kotli.app.di.datasource.ProvidesHttpSource import kotli.app.di.datasource.ProvidesKeyValueSource @@ -17,6 +18,7 @@ val koinDI = startKoin { modules( ProvidesAnalyticsSource, ProvidesConfigSource, + ProvidesCacheSource, ProvidesHttpSource, ProvidesKeyValueSource, ProvidesPagingSource, diff --git a/template/app/src/commonMain/kotlin/kotli/app/di/datasource/ProvidesCacheSource.kt b/template/app/src/commonMain/kotlin/kotli/app/di/datasource/ProvidesCacheSource.kt new file mode 100644 index 00000000..7de17c36 --- /dev/null +++ b/template/app/src/commonMain/kotlin/kotli/app/di/datasource/ProvidesCacheSource.kt @@ -0,0 +1,10 @@ +package kotli.app.di.datasource + +import kotli.app.datasource.cache.AppCacheSource +import org.koin.dsl.bind +import org.koin.dsl.module +import shared.data.datasource.cache.CacheSource + +val ProvidesCacheSource = module { + single { AppCacheSource() }.bind(CacheSource::class) +} \ No newline at end of file diff --git a/template/app/src/commonMain/kotlin/kotli/app/showcases/Showcases.kt b/template/app/src/commonMain/kotlin/kotli/app/showcases/Showcases.kt index eb7cc59f..2232b8b4 100644 --- a/template/app/src/commonMain/kotlin/kotli/app/showcases/Showcases.kt +++ b/template/app/src/commonMain/kotlin/kotli/app/showcases/Showcases.kt @@ -1,5 +1,6 @@ package kotli.app.showcases +import kotli.app.showcases.datasource.cache.basic.BasicCacheShowcase import kotli.app.showcases.datasource.http.basic.BasicHttpShowcase import kotli.app.showcases.datasource.keyvalue.`object`.ObjectKeyValueShowcase import kotli.app.showcases.datasource.keyvalue.primitive.PrimitiveKeyValueShowcase @@ -25,16 +26,18 @@ object Showcases { ShowcaseItemGroup("Navigation + MVVM"), NoArgsNavigationShowcase, ArgsNavigationShowcase, + ShowcaseItemGroup("Datasource :: Cache"), + BasicCacheShowcase, ShowcaseItemGroup("Datasource :: Http"), BasicHttpShowcase, ShowcaseItemGroup("Datasource :: KeyValue"), PrimitiveKeyValueShowcase, ObjectKeyValueShowcase, + ShowcaseItemGroup("Datasource :: Paging"), + BasicPagingShowcase, ShowcaseItemGroup("Datasource :: SqlDelight"), SqlDelightCrudShowcase, SqlDelightPagingShowcase, - ShowcaseItemGroup("Datasource :: Paging"), - BasicPagingShowcase, ShowcaseItemGroup("Userflow :: Loader"), DataLoaderShowcase, ShowcaseItemGroup("Userflow :: Theme"), diff --git a/template/app/src/commonMain/kotlin/kotli/app/showcases/datasource/cache/basic/BasicCacheDestination.kt b/template/app/src/commonMain/kotlin/kotli/app/showcases/datasource/cache/basic/BasicCacheDestination.kt new file mode 100644 index 00000000..5908e557 --- /dev/null +++ b/template/app/src/commonMain/kotlin/kotli/app/showcases/datasource/cache/basic/BasicCacheDestination.kt @@ -0,0 +1,13 @@ +package kotli.app.showcases.datasource.cache.basic + +import androidx.navigation.NavGraphBuilder +import shared.presentation.navigation.NavigationDestinationNoArgs +import shared.presentation.navigation.NavigationStrategy + +object BasicCacheDestination : NavigationDestinationNoArgs() { + + override val id: String = "basic_cache_screen" + override val navStrategy: NavigationStrategy = NavigationStrategy.NewInstance + override fun doBind(builder: NavGraphBuilder) = composable(builder) { BasicCacheScreen() } + +} \ No newline at end of file diff --git a/template/app/src/commonMain/kotlin/kotli/app/showcases/datasource/cache/basic/BasicCacheScreen.kt b/template/app/src/commonMain/kotlin/kotli/app/showcases/datasource/cache/basic/BasicCacheScreen.kt new file mode 100644 index 00000000..0eb06efb --- /dev/null +++ b/template/app/src/commonMain/kotlin/kotli/app/showcases/datasource/cache/basic/BasicCacheScreen.kt @@ -0,0 +1,40 @@ +package kotli.app.showcases.datasource.cache.basic + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotli.app.showcases.ShowcaseHintBlock +import shared.design.component.AppText +import shared.design.container.AppFixedTopBarColumn +import shared.presentation.provideViewModel +import shared.presentation.state.StoreObject + +@Composable +fun BasicCacheScreen() { + val viewModel: BasicCacheViewModel = provideViewModel() + AppFixedTopBarColumn( + title = BasicCacheShowcase.label, + onBack = viewModel::onBack, + content = { + ShowcaseHintBlock( + text = """ + This showcase demonstrates the simple usage of the Cache API. + + It caches the current time and stores it for 10 seconds. + + You can navigate back and forth to this screen, but once 10 seconds have passed, the cache is updated with the new value. + """.trimIndent() + ) + CacheBlock(viewModel.cacheStore) + } + ) +} + +@Composable +private fun CacheBlock(store: StoreObject) { + AppText( + modifier = Modifier.padding(horizontal = 16.dp), + text = "Current cache value : ${store.asStateValue()}" + ) +} \ No newline at end of file diff --git a/template/app/src/commonMain/kotlin/kotli/app/showcases/datasource/cache/basic/BasicCacheShowcase.kt b/template/app/src/commonMain/kotlin/kotli/app/showcases/datasource/cache/basic/BasicCacheShowcase.kt new file mode 100644 index 00000000..23fccf55 --- /dev/null +++ b/template/app/src/commonMain/kotlin/kotli/app/showcases/datasource/cache/basic/BasicCacheShowcase.kt @@ -0,0 +1,19 @@ +package kotli.app.showcases.datasource.cache.basic + +import kotli.app.showcases.ShowcaseItem +import kotli.app.showcases.ShowcasesViewModel +import shared.presentation.navigation.NavigationDestination + +object BasicCacheShowcase : ShowcaseItem { + + override val label: String = "Basic In-Memory Cache Usage" + + override fun onClick(viewModel: ShowcasesViewModel) { + viewModel.navigationState.onNext(BasicCacheDestination) + } + + override fun dependsOn(): List> = listOf( + BasicCacheDestination + ) + +} \ No newline at end of file diff --git a/template/app/src/commonMain/kotlin/kotli/app/showcases/datasource/cache/basic/BasicCacheViewModel.kt b/template/app/src/commonMain/kotlin/kotli/app/showcases/datasource/cache/basic/BasicCacheViewModel.kt new file mode 100644 index 00000000..839e8478 --- /dev/null +++ b/template/app/src/commonMain/kotlin/kotli/app/showcases/datasource/cache/basic/BasicCacheViewModel.kt @@ -0,0 +1,42 @@ +package kotli.app.showcases.datasource.cache.basic + +import kotlinx.coroutines.flow.collectLatest +import kotlinx.datetime.Clock +import kotlinx.datetime.format +import kotlinx.datetime.format.DateTimeComponents +import kotlinx.datetime.format.byUnicodePattern +import shared.data.datasource.cache.CacheKey +import shared.data.datasource.cache.CacheSource +import shared.presentation.BaseViewModel +import shared.presentation.navigation.NavigationState +import shared.presentation.state.StoreObject + +class BasicCacheViewModel( + private val navigationState: NavigationState, + private val cacheSource: CacheSource +) : BaseViewModel() { + + val cacheStore = StoreObject() + + fun onBack() = navigationState.onBack() + + override fun doBind() { + launchAsync { + val cacheKey = SimpleCacheKey() + val cacheState = cacheSource.getState(cacheKey, ::getDateAsFormattedString) + cacheState.changes().collectLatest(cacheStore::set) + } + } + + private fun getDateAsFormattedString(): String { + val time = Clock.System.now() + return time.format(DateTimeComponents.Format { + byUnicodePattern("yyyy-MM-dd HH:mm:ss") + }) + } + + private data class SimpleCacheKey( + override val ttl: Long = CacheKey.TTL_10_SECONDS + ) : CacheKey + +} diff --git a/template/gradle/libs.versions.toml b/template/gradle/libs.versions.toml index cd1cb8ef..0f66c54b 100644 --- a/template/gradle/libs.versions.toml +++ b/template/gradle/libs.versions.toml @@ -11,7 +11,6 @@ androidx-navigation = "2.7.0-alpha07" androidx-paging = "3.3.0" androidx-splashscreen = "1.0.1" cashapp-paging = "3.3.0-alpha02-0.5.1" -compose-android = "1.6.7" compose-multiplatform = "1.6.11" junit = "4.13.2" koin = "3.6.0-Beta4" @@ -36,13 +35,12 @@ androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navi androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging" } androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" } cashapp-paging-compose-common = { module = "app.cash.paging:paging-compose-common", version.ref = "cashapp-paging" } -compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose-android" } -compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose-android" } junit = { group = "junit", name = "junit", version.ref = "junit" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -76,6 +74,7 @@ sqldelight-sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", versi sqldelight-web-worker-driver = { module = "app.cash.sqldelight:web-worker-driver", version.ref = "sqldelight" } touchlab-kermit = { module = "co.touchlab:kermit", version.ref = "touchlab-kermit" } touchlab-stately-common = { module = "co.touchlab:stately-common", version.ref = "touchlab-stately" } +touchlab-stately-concurrent-collections = { module = "co.touchlab:stately-concurrent-collections", version.ref = "touchlab-stately" } touchlab-stately-isolate = { module = "co.touchlab:stately-isolate", version.ref = "touchlab-stately" } touchlab-stately-iso-collections = { module = "co.touchlab:stately-iso-collections", version.ref = "touchlab-stately" } touchlab-stately-iso-collections-js = { module = "co.touchlab:stately-iso-collections-js", version.ref = "touchlab-stately" } diff --git a/template/shared/data/build.gradle.kts b/template/shared/data/build.gradle.kts index 579ef8f4..c7b4b0c5 100644 --- a/template/shared/data/build.gradle.kts +++ b/template/shared/data/build.gradle.kts @@ -36,10 +36,15 @@ kotlin { } } commonMain.dependencies { + api(libs.kotlinx.datetime) api(libs.bundles.ktor.common) api(libs.kotlinx.coroutines.core) api(libs.kotlinx.serialization.json) implementation(libs.multiplatform.settings.no.arg) + implementation(libs.touchlab.stately.concurrent.collections) // {dataflow.cache.basic} + } + commonTest.dependencies { + implementation(libs.kotlin.test) } // {platform.android.dependencies} androidMain.dependencies { diff --git a/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/CacheKey.kt b/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/CacheKey.kt new file mode 100644 index 00000000..fc351c12 --- /dev/null +++ b/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/CacheKey.kt @@ -0,0 +1,66 @@ +package shared.data.datasource.cache + +/** + * Interface representing a cache key for storing and retrieving data in the cache. + * + * @param T The type of the cached data. + */ +interface CacheKey { + + /** + * Time-to-live (TTL) for the cached data, in milliseconds. + */ + val ttl: Long + + /** + * Determines if the cached data associated with this key is considered immortal. + * In terms of the API, immortality means that the cache entry initialization request + * is not bound to the lifecycle of the component from which it is called. + * + * For example, if a cache entry initialization request is called from a View (Fragment/Activity), + * it will be canceled once the view is destroyed. + * When a key is immortal, the cache entry will be initialized regardless of the component lifecycle. + * + * This can be useful when you need to proceed with some request without interruptions. + + * Another example: + * + * an OAuth refresh token actualization response needs to be completed and stored locally + * as an atomic action, so any further calls under OAuth authorization can continue with the newly obtained token. + * If such an action is processed on the server but interrupted and not stored on the client, + * it is possible that the old token becomes outdated and any further request to update it will fail. + * + * The immortal key helps reduce such issues by ensuring that cache entries are initialized + * even if the associated component lifecycle ends. + * + * @return {@code true} if the data associated with this key is immortal, {@code false} otherwise. + */ + fun immortal(): Boolean = false + + companion object { + const val TTL_UNLIMITED = -1L + const val TTL_1_SECOND = 1_000L + const val TTL_3_SECONDS = 3_000L + const val TTL_5_SECONDS = 5_000L + const val TTL_10_SECONDS = 10_000L + const val TTL_15_SECONDS = 15_000L + const val TTL_30_SECONDS = 30_000L + const val TTL_60_SECONDS = 60_000L + const val TTL_5_MINUTES = 5 * 60_000L + + /** + * Creates a new CacheKey with the specified time-to-live (TTL) duration. + * + * @param duration The time-to-live (TTL) duration for the cache key. + * @param immortal Specifies whether the cache key is immortal or not. Defaults to false. + * When set to true, the cache entry will not be bound to the lifecycle of the component + * from which it is initialized. + * @return A new CacheKey instance with the specified TTL duration and immortality status. + */ + fun of(duration: Long, immortal: Boolean = false): CacheKey = object : CacheKey { + override val ttl: Long = duration + override fun immortal(): Boolean = immortal + } + } + +} \ No newline at end of file diff --git a/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/CacheSource.kt b/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/CacheSource.kt new file mode 100644 index 00000000..02c9a86d --- /dev/null +++ b/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/CacheSource.kt @@ -0,0 +1,76 @@ +package shared.data.datasource.cache + +import shared.data.datasource.DataSource +import kotlin.reflect.KClass + +/** + * Provides an interface for a basic thread-safe caching mechanism, serving as an L1 Cache. + * + * The cache allows for storing and retrieving any in-memory data efficiently. + * + * It supports operations such as getting, putting, removing, and invalidating cache entries. + */ +interface CacheSource : DataSource { + + /** + * Retrieves the state of a cache entry associated with the specified key. + * If the entry is not found in the cache, the provided value provider function is invoked to obtain the value. + * + * @param key The cache key associated with the entry. + * @param valueProvider A suspend function that provides the value if the cache entry is not found. + * @return A CacheState object representing the state of the cache entry. + */ + fun getState(key: CacheKey, valueProvider: suspend () -> T?): CacheState + + /** + * Retrieves the value associated with the specified key from the cache. + * If the value is not found in the cache, the provided value provider function is invoked to obtain the value. + * + * @param key The cache key associated with the value. + * @param valueProvider A suspend function that provides the value if it is not found in the cache. + * @return The value associated with the key, or null if not found. + */ + suspend fun get(key: CacheKey, valueProvider: suspend () -> T?): T? + + /** + * Invalidates all cache entries associated with the specified key type. + * + * @param type The type of cache keys to invalidate. + */ + fun > invalidate(type: KClass) + + /** + * Invalidates the cache entry associated with the specified key. + * + * @param key The cache key to invalidate. + */ + fun > invalidate(key: K) + + /** + * Removes all cache entries associated with the specified key type. + * + * @param type The type of cache keys to remove. + */ + fun > remove(type: KClass) + + /** + * Removes the cache entry associated with the specified key. + * + * @param key The cache key to remove. + */ + fun > remove(key: K) + + /** + * Associates the specified value with the specified key in the cache. + * + *@param key The cache key to associate with the value. + * @param value The value to be stored in the cache. + */ + fun put(key: CacheKey, value: T) + + /** + * Clears all entries from the cache. + */ + fun clear() + +} \ No newline at end of file diff --git a/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/CacheState.kt b/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/CacheState.kt new file mode 100644 index 00000000..98bf2f49 --- /dev/null +++ b/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/CacheState.kt @@ -0,0 +1,69 @@ +package shared.data.datasource.cache + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +/** + * Represents the state of a cache entry. + * + * @param T The type of the cached item. + */ +interface CacheState { + + /** The key associated with this cache state. */ + val key: CacheKey + + /** + * Retrieves the cached item. + * + * @return The cached item, or new one if the item is not present in the cache or expired. + */ + suspend fun get(): T? + + /** + * Retrieves the last cached item. + * + * @return The last cached item, or null if the item is not present in the cache. + */ + suspend fun last(): T? + + /** + * Retrieves a fresh copy of the cached item. + * + * @return A fresh copy of the cached item, or null if the item is not available. + */ + suspend fun fresh(): T? + + /** + * Retrieves the last cached item if available, otherwise retrieves a fresh copy of the item. + * + * @return The last cached item if available, or a fresh copy of the item. Returns null if the item is not present in the cache. + */ + suspend fun lastOrFresh() = last() ?: fresh() + + /** + * Emits the cached item whenever it changes. + * The flow updates an item in the cache based on the expiration of the key. + * + * @return A flow representing the changes to the cached item. + */ + suspend fun changes(): Flow + + companion object { + /** + * Creates a CacheState instance representing a passed item. + * + * @param key The cache key associated with the item. + * @param item The cached item. + * @return A CacheState instance representing the single cached item. + */ + fun single(key: CacheKey, item: T): CacheState = object : CacheState { + override val key: CacheKey = key + override suspend fun get(): T? = item + override suspend fun last(): T? = item + override suspend fun fresh(): T? = item + override suspend fun changes(): Flow = flowOf(item) + } + } + +} \ No newline at end of file diff --git a/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/InMemoryCacheSource.kt b/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/InMemoryCacheSource.kt new file mode 100644 index 00000000..9777c702 --- /dev/null +++ b/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/InMemoryCacheSource.kt @@ -0,0 +1,221 @@ +@file:OptIn(DelicateCoroutinesApi::class) +@file:Suppress("UNCHECKED_CAST") + +package shared.data.datasource.cache + +import co.touchlab.stately.collections.ConcurrentMutableMap +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.retry +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import shared.data.misc.extensions.isCancellationException +import kotlin.reflect.KClass + +/** + * Basic implementation of a thread-safe cache for storing and retrieving in-memory data. + * This cache can be utilized as an L1 Cache when managing HTTP requests, offering an efficient means + * to present data without delays, but with the ability to update based on expiration and other conditions. + * + * @param changesRetryInterval The interval, in milliseconds, to retry cache changes. + * @param exceptionRetryInterval The interval, in milliseconds, to retry cache operations in case of exceptions. + * @param exceptionRetryCount The maximum number of retries for cache operations in case of exceptions. + */ +open class InMemoryCacheSource( + private val changesRetryInterval: Long = 1000L, + private val exceptionRetryInterval: Long = 3000L, + private val exceptionRetryCount: Int = 10 +) : CacheSource { + + private val dispatcher = Dispatchers.Default + private val jobs = ConcurrentMutableMap>() + private val cache = ConcurrentMutableMap() + + override fun getState(key: CacheKey, valueProvider: suspend () -> T?): CacheState = + CacheStateImpl(key, valueProvider) + + override suspend fun get(key: CacheKey, valueProvider: suspend () -> T?): T? { + val cacheKey = CacheKeySnapshot(key) + val cacheItem = cache[cacheKey] + if (cacheItem == null || !cacheItem.isValid(key.ttl)) { + val data = getValue(cacheKey, valueProvider) ?: return null + cache[cacheKey] = CacheData(data) + return data + } else { + return cacheItem.data as T? + } + } + + override fun put(key: CacheKey, value: T) { + val cacheKey = CacheKeySnapshot(key) + cache[cacheKey] = CacheData(value) + } + + override fun clear() { + jobs.onEach { it.value.cancel() } + jobs.clear() + cache.clear() + } + + override fun > invalidate(type: KClass) { + jobs.iterator().forEach { entry -> + val key = entry.key + if (key.type == type) { + jobs.remove(key)?.cancel() + } + } + cache.iterator().forEach { entry -> + if (entry.key.type == type) { + entry.value.invalidate() + } + } + } + + override fun > invalidate(key: K) { + val cacheKey = CacheKeySnapshot(key) + jobs.remove(cacheKey)?.cancel() + cache[cacheKey]?.invalidate() + } + + override fun > remove(type: KClass) { + jobs.iterator().forEach { entry -> + val key = entry.key + if (key.type == type) { + jobs.remove(key)?.cancel() + } + } + cache.iterator().forEach { entry -> + if (entry.key.type == type) { + cache.remove(entry.key)?.invalidate() + } + } + } + + override fun > remove(key: K) { + val cacheKey = CacheKeySnapshot(key) + jobs.remove(cacheKey)?.cancel() + cache.remove(cacheKey) + } + + private suspend fun getValue( + cacheKey: CacheKeySnapshot, + valueProvider: suspend () -> T? + ): T? { + val job = jobs[cacheKey] + ?.let { deferredJob -> + if (deferredJob.isCancelled) { + jobs.remove(cacheKey)?.cancel() + null + } else { + deferredJob + } + } + ?: run { + if (cacheKey.key.immortal()) { + jobs.computeIfAbsent(cacheKey) { + GlobalScope.async { valueProvider() } + } + } else { + withContext(dispatcher) { + jobs.computeIfAbsent(cacheKey) { + async { valueProvider() } + } + } + } + } + job.invokeOnCompletion { jobs.remove(cacheKey)?.key } + + return job.await() as? T + } + + private data class CacheData( + val data: Any?, + val createTime: Long = Clock.System.now().toEpochMilliseconds() + ) { + private var invalid: Boolean = false + + fun isValid(ttl: Long): Boolean = when { + invalid -> false + data == null -> false + ttl > 0 -> !isExpired(ttl) + ttl == 0L -> false + else -> true + } + + fun isExpired(ttl: Long): Boolean { + val now = Clock.System.now().toEpochMilliseconds() + return ttl > 0 && createTime + ttl <= now + } + + fun invalidate() { + invalid = true + } + } + + private inner class CacheStateImpl( + override val key: CacheKey, + private val valueProvider: suspend () -> T? + ) : CacheState { + + private val cacheKey = CacheKeySnapshot(key) + + override suspend fun fresh(): T? = cache[cacheKey]?.invalidate().run { get() } + override suspend fun last(): T? = cache[cacheKey]?.data as? T + override suspend fun get(): T? = get(key, valueProvider) + + override suspend fun changes(): Flow = flow { + getLastData()?.data + ?.let { data -> data as? T } + ?.let { data -> emit(data) } + + var retryAttempt = 0 + while (currentCoroutineContext().isActive) { + try { + val prev = getLastData() + val next = get() + + if (next != null) { + emit(next) + } + + if (prev?.data != next) { + delay(key.ttl) + } else if (prev != null) { + val now = Clock.System.now().toEpochMilliseconds() + val time = key.ttl - (now - prev.createTime) + delay(time) + } + + retryAttempt = 0 + } catch (e: Exception) { + retryAttempt++ + when { + retryAttempt >= exceptionRetryCount -> throw e + else -> Unit + } + } + } + }.distinctUntilChanged().retry { th -> + !th.isCancellationException().also { + delay(changesRetryInterval) + } + } + + private fun getLastData(): CacheData? = cache[cacheKey] + } + + private data class CacheKeySnapshot( + val key: CacheKey<*>, + val type: KClass<*> = key::class + ) + +} \ No newline at end of file diff --git a/template/shared/data/src/jvmTest/kotlin/shared/data/datasource/cache/InMemoryCacheSourceTest.kt b/template/shared/data/src/jvmTest/kotlin/shared/data/datasource/cache/InMemoryCacheSourceTest.kt new file mode 100644 index 00000000..399fcd01 --- /dev/null +++ b/template/shared/data/src/jvmTest/kotlin/shared/data/datasource/cache/InMemoryCacheSourceTest.kt @@ -0,0 +1,80 @@ +package shared.data.datasource.cache + +import io.ktor.util.collections.ConcurrentSet +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.time.Duration.Companion.seconds + +class InMemoryCacheSourceTest { + + private val cache: CacheSource = InMemoryCacheSource() + + @Test + fun `make sure all cache actions are performed in not blocking way`() = runBlocking { + val iterations = 1000 + val cached = ConcurrentSet() + repeat(iterations) { iteration -> + GlobalScope.launch { + cache + .get(TestCacheKey(iteration)) { + delay(2.seconds) + iteration + } + ?.let(cached::add) + } + } + delay(3.seconds) + assertEquals(iterations, cached.size) + } + + @Test + fun `make sure same actions use the same cached value`() = runBlocking { + val key = UUIDCacheKey(Int.MAX_VALUE) + val iterations = 1000 + val cached = ConcurrentSet() + repeat(iterations) { + launch { + delay(300) + cache + .get(key) { + delay(1.seconds) + UUID.randomUUID() + } + ?.let(cached::add) + } + } + delay(2.seconds) + assertEquals(1, cached.size) + } + + @Test + fun `check cached state logic`() = runBlocking { + val key = UUIDCacheKey(Int.MAX_VALUE, ttl = 100) + val valueState = cache.getState(key) { UUID.randomUUID() } + val value1 = valueState.get() + val value1Last = valueState.last() + delay(100) + val value2 = valueState.get() + delay(100) + assertNotEquals(value1, value2) + assertEquals(value1, value1Last) + assertNotEquals(value2, valueState.get()) + } + + private data class TestCacheKey( + val id: Int, + override val ttl: Long = CacheKey.TTL_UNLIMITED + ) : CacheKey + + private data class UUIDCacheKey( + val id: Int, + override val ttl: Long = CacheKey.TTL_UNLIMITED + ) : CacheKey + +} \ No newline at end of file diff --git a/template/shared/presentation/src/commonMain/kotlin/shared/presentation/navigation/NavigationContentProvider.kt b/template/shared/presentation/src/commonMain/kotlin/shared/presentation/navigation/NavigationContentProvider.kt index 28f7236c..6aeb9c17 100644 --- a/template/shared/presentation/src/commonMain/kotlin/shared/presentation/navigation/NavigationContentProvider.kt +++ b/template/shared/presentation/src/commonMain/kotlin/shared/presentation/navigation/NavigationContentProvider.kt @@ -2,8 +2,22 @@ package shared.presentation.navigation import androidx.compose.runtime.Composable +/** + * Functional interface for providing navigation content. + * + * This interface is used to define a composable function that provides navigation content based on the given data. + * + * @param The type of the data to be provided. + */ fun interface NavigationContentProvider { + /** + * Provides the navigation content based on the given data. + * + * This composable function uses the provided data to display relevant navigation content. + * + * @param data The data used to provide the navigation content. It can be nullable. + */ @Composable fun provide(data: D?) diff --git a/template/shared/presentation/src/commonMain/kotlin/shared/presentation/navigation/NavigationContext.kt b/template/shared/presentation/src/commonMain/kotlin/shared/presentation/navigation/NavigationContext.kt index c5025384..112c3741 100644 --- a/template/shared/presentation/src/commonMain/kotlin/shared/presentation/navigation/NavigationContext.kt +++ b/template/shared/presentation/src/commonMain/kotlin/shared/presentation/navigation/NavigationContext.kt @@ -29,9 +29,9 @@ data class NavigationContext( @Stable @Composable fun rememberNavigationContext(navigationState: NavigationState): NavigationContext { - val navController = rememberNavController() val scope = rememberCoroutineScope() - return remember(navigationState, navController) { + val navController = rememberNavController() + return remember(navigationState, navController, scope) { NavigationContext( navigationState = navigationState, navController = navController, diff --git a/template/shared/presentation/src/commonMain/kotlin/shared/presentation/navigation/NavigationDestination.kt b/template/shared/presentation/src/commonMain/kotlin/shared/presentation/navigation/NavigationDestination.kt index 6c35a361..034bbbae 100644 --- a/template/shared/presentation/src/commonMain/kotlin/shared/presentation/navigation/NavigationDestination.kt +++ b/template/shared/presentation/src/commonMain/kotlin/shared/presentation/navigation/NavigationDestination.kt @@ -137,7 +137,7 @@ abstract class NavigationDestination { @Composable private fun route(routeData: RouteData) { val value = routeData.entry.arguments?.getString(ATTR_DATA) - val data = value?.let { argsStrategy.toObject(it) } + val data = value?.let(argsStrategy::toObject) routeData.provider.provide(data) } diff --git a/template/shared/presentation/src/commonMain/kotlin/shared/presentation/navigation/NavigationHost.kt b/template/shared/presentation/src/commonMain/kotlin/shared/presentation/navigation/NavigationHost.kt index 62a4d02b..106282c6 100644 --- a/template/shared/presentation/src/commonMain/kotlin/shared/presentation/navigation/NavigationHost.kt +++ b/template/shared/presentation/src/commonMain/kotlin/shared/presentation/navigation/NavigationHost.kt @@ -8,6 +8,17 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.navigation.compose.NavHost +/** + * Composable function that sets up a navigation host. + * + * This function configures the navigation host using the provided state, context, and start destination. + * It also applies transitions for entering and exiting destinations. + * + * @param modifier The modifier to be applied to the navigation host. Defaults to [Modifier]. + * @param navigationState The state containing navigation destinations and their bindings. + * @param navigationContext The context providing navigation-related dependencies, such as the navController. + * @param startDestination The starting destination for the navigation host. + */ @Composable fun NavigationHost( modifier: Modifier = Modifier,