diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fceb0135..95bed15d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -48,6 +48,8 @@ android { dependencies { implementation(project(":shared")) implementation(libs.bundles.app.ui) + implementation(libs.multiplatformSettings.common) + implementation(libs.kotlinx.dateTime) coreLibraryDesugaring(libs.android.desugaring) implementation(libs.koin.android) testImplementation(libs.junit) diff --git a/app/src/main/java/co/touchlab/kampkit/android/BreedViewModel.kt b/app/src/main/java/co/touchlab/kampkit/android/BreedViewModel.kt index 703eaf4c..5c08e114 100644 --- a/app/src/main/java/co/touchlab/kampkit/android/BreedViewModel.kt +++ b/app/src/main/java/co/touchlab/kampkit/android/BreedViewModel.kt @@ -3,70 +3,20 @@ package co.touchlab.kampkit.android import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kampkit.db.Breed -import co.touchlab.kampkit.injectLogger -import co.touchlab.kampkit.models.BreedModel -import co.touchlab.kampkit.models.DataState -import co.touchlab.kampkit.models.ItemDataSummary +import co.touchlab.kampkit.models.BreedCommonViewModel +import co.touchlab.kampkit.models.BreedRepository import co.touchlab.kermit.Logger -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.flattenMerge -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.launch -import org.koin.core.component.KoinComponent -class BreedViewModel : ViewModel(), KoinComponent { +class BreedViewModel( + breedRepository: BreedRepository, + log: Logger +) : ViewModel() { - private val log: Logger by injectLogger("BreedViewModel") - private val scope = viewModelScope - private val breedModel: BreedModel = BreedModel() - private val _breedStateFlow: MutableStateFlow> = MutableStateFlow( - DataState(loading = true) - ) + private val commonViewModel = BreedCommonViewModel(breedRepository, log, viewModelScope) - val breedStateFlow: StateFlow> = _breedStateFlow + val breeds = commonViewModel.breeds - init { - observeBreeds() - } + fun refreshBreeds() = commonViewModel.refreshBreeds() - @OptIn(FlowPreview::class) - private fun observeBreeds() { - scope.launch { - log.v { "getBreeds: Collecting Things" } - flowOf( - breedModel.refreshBreedsIfStale(true), - breedModel.getBreedsFromCache() - ).flattenMerge().collect { dataState -> - if (dataState.loading) { - val temp = _breedStateFlow.value.copy(loading = true) - _breedStateFlow.value = temp - } else { - _breedStateFlow.value = dataState - } - } - } - } - - fun refreshBreeds(forced: Boolean = false) { - scope.launch { - log.v { "refreshBreeds" } - breedModel.refreshBreedsIfStale(forced).collect { dataState -> - if (dataState.loading) { - val temp = _breedStateFlow.value.copy(loading = true) - _breedStateFlow.value = temp - } else { - _breedStateFlow.value = dataState - } - } - } - } - - fun updateBreedFavorite(breed: Breed) { - scope.launch { - breedModel.updateBreedFavorite(breed) - } - } + fun updateBreedFavorite(breed: Breed) = commonViewModel.updateBreedFavorite(breed) } diff --git a/app/src/main/java/co/touchlab/kampkit/android/MainActivity.kt b/app/src/main/java/co/touchlab/kampkit/android/MainActivity.kt index 05037bd4..c6b9546e 100644 --- a/app/src/main/java/co/touchlab/kampkit/android/MainActivity.kt +++ b/app/src/main/java/co/touchlab/kampkit/android/MainActivity.kt @@ -22,8 +22,5 @@ class MainActivity : ComponentActivity(), KoinComponent { MainScreen(viewModel, log) } } - if (viewModel.breedStateFlow.value.data == null) { - viewModel.refreshBreeds() - } } } diff --git a/app/src/main/java/co/touchlab/kampkit/android/MainApp.kt b/app/src/main/java/co/touchlab/kampkit/android/MainApp.kt index 731a77c6..559b4224 100644 --- a/app/src/main/java/co/touchlab/kampkit/android/MainApp.kt +++ b/app/src/main/java/co/touchlab/kampkit/android/MainApp.kt @@ -7,6 +7,7 @@ import android.util.Log import co.touchlab.kampkit.AppInfo import co.touchlab.kampkit.initKoin import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.parameter.parametersOf import org.koin.dsl.module class MainApp : Application() { @@ -16,7 +17,7 @@ class MainApp : Application() { initKoin( module { single { this@MainApp } - viewModel { BreedViewModel() } + viewModel { BreedViewModel(get(), get { parametersOf("BreedViewModel") }) } single { get().getSharedPreferences("KAMPSTARTER_SETTINGS", Context.MODE_PRIVATE) } diff --git a/app/src/main/java/co/touchlab/kampkit/android/ui/Composables.kt b/app/src/main/java/co/touchlab/kampkit/android/ui/Composables.kt index 26fbd06a..c8177353 100644 --- a/app/src/main/java/co/touchlab/kampkit/android/ui/Composables.kt +++ b/app/src/main/java/co/touchlab/kampkit/android/ui/Composables.kt @@ -47,16 +47,16 @@ fun MainScreen( log: Logger ) { val lifecycleOwner = LocalLifecycleOwner.current - val lifecycleAwareDogsFlow = remember(viewModel.breedStateFlow, lifecycleOwner) { - viewModel.breedStateFlow.flowWithLifecycle(lifecycleOwner.lifecycle) + val lifecycleAwareDogsFlow = remember(viewModel.breeds, lifecycleOwner) { + viewModel.breeds.flowWithLifecycle(lifecycleOwner.lifecycle) } @SuppressLint("StateFlowValueCalledInComposition") // False positive lint check when used inside collectAsState() - val dogsState by lifecycleAwareDogsFlow.collectAsState(viewModel.breedStateFlow.value) + val dogsState by lifecycleAwareDogsFlow.collectAsState(viewModel.breeds.value) MainScreenContent( dogsState = dogsState, - onRefresh = { viewModel.refreshBreeds(true) }, + onRefresh = { viewModel.refreshBreeds() }, onSuccess = { data -> log.v { "View updating with ${data.allItems.size} breeds" } }, onError = { exception -> log.e { "Displaying error: $exception" } }, onFavorite = { viewModel.updateBreedFavorite(it) } diff --git a/ios/KaMPKitiOS.xcodeproj/project.pbxproj b/ios/KaMPKitiOS.xcodeproj/project.pbxproj index 759da941..e7735143 100644 --- a/ios/KaMPKitiOS.xcodeproj/project.pbxproj +++ b/ios/KaMPKitiOS.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 3DFF917C64A18A83DA010EE1 /* Pods_KaMPKitiOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B859F3FB23133D22AB9DD835 /* Pods_KaMPKitiOS.framework */; }; + 461C74AA2788F5F3004B1FFC /* CombineAdapters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 461C74A92788F5F3004B1FFC /* CombineAdapters.swift */; }; 46A5B5EF26AF54F7002EFEAA /* BreedListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46A5B5EE26AF54F7002EFEAA /* BreedListScreen.swift */; }; 46A5B60826B04921002EFEAA /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 46A5B60626B04920002EFEAA /* Main.storyboard */; }; 46B5284D249C5CF400A7725D /* Koin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46B5284C249C5CF400A7725D /* Koin.swift */; }; @@ -38,6 +39,7 @@ /* Begin PBXFileReference section */ 1DFCC00C8DAA719770A18D1A /* Pods-KaMPKitiOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-KaMPKitiOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-KaMPKitiOS/Pods-KaMPKitiOS.release.xcconfig"; sourceTree = ""; }; 2A1ED6A4A2A53F5F75C58E5F /* Pods-KaMPKitiOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-KaMPKitiOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-KaMPKitiOS/Pods-KaMPKitiOS.release.xcconfig"; sourceTree = ""; }; + 461C74A92788F5F3004B1FFC /* CombineAdapters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineAdapters.swift; sourceTree = ""; }; 46A5B5EE26AF54F7002EFEAA /* BreedListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreedListScreen.swift; sourceTree = ""; }; 46A5B60726B04920002EFEAA /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 46B5284C249C5CF400A7725D /* Koin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Koin.swift; sourceTree = ""; }; @@ -134,6 +136,7 @@ F1465F0B23AA94BF0055F7C3 /* LaunchScreen.storyboard */, F1465F0E23AA94BF0055F7C3 /* Info.plist */, 46A5B5EE26AF54F7002EFEAA /* BreedListScreen.swift */, + 461C74A92788F5F3004B1FFC /* CombineAdapters.swift */, ); path = KaMPKitiOS; sourceTree = ""; @@ -351,6 +354,7 @@ buildActionMask = 2147483647; files = ( 46B5284D249C5CF400A7725D /* Koin.swift in Sources */, + 461C74AA2788F5F3004B1FFC /* CombineAdapters.swift in Sources */, 46A5B5EF26AF54F7002EFEAA /* BreedListScreen.swift in Sources */, F1465F0123AA94BF0055F7C3 /* AppDelegate.swift in Sources */, ); diff --git a/ios/KaMPKitiOS/AppDelegate.swift b/ios/KaMPKitiOS/AppDelegate.swift index 46d579b6..25924195 100644 --- a/ios/KaMPKitiOS/AppDelegate.swift +++ b/ios/KaMPKitiOS/AppDelegate.swift @@ -15,7 +15,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? // Lazy so it doesn't try to initialize before startKoin() is called - // swiftlint:disable force_cast lazy var log = koin.loggerWithTag(tag: "AppDelegate") func application(_ application: UIApplication, didFinishLaunchingWithOptions diff --git a/ios/KaMPKitiOS/BreedListScreen.swift b/ios/KaMPKitiOS/BreedListScreen.swift index ea6d25ca..c941d175 100644 --- a/ios/KaMPKitiOS/BreedListScreen.swift +++ b/ios/KaMPKitiOS/BreedListScreen.swift @@ -6,14 +6,14 @@ // Copyright © 2021 Touchlab. All rights reserved. // +import Combine import SwiftUI import shared -// swiftlint:disable force_cast private let log = koin.loggerWithTag(tag: "ViewController") class ObservableBreedModel: ObservableObject { - private var viewModel: NativeViewModel? + private var viewModel: BreedCallbackViewModel? @Published var loading = false @@ -24,8 +24,12 @@ class ObservableBreedModel: ObservableObject { @Published var error: String? + private var cancellables = [AnyCancellable]() + func activate() { - viewModel = NativeViewModel { [weak self] dataState in + let viewModel = KotlinDependencies.shared.getBreedViewModel() + + doPublish(viewModel.breeds) { [weak self] dataState in self?.loading = dataState.loading self?.breeds = dataState.data?.allItems self?.error = dataState.exception @@ -36,11 +40,16 @@ class ObservableBreedModel: ObservableObject { if let errorMessage = dataState.exception { log.e(message: {"Displaying error: \(errorMessage)"}) } - } + }.store(in: &cancellables) + + self.viewModel = viewModel } func deactivate() { - viewModel?.onDestroy() + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + + viewModel?.clear() viewModel = nil } @@ -49,7 +58,7 @@ class ObservableBreedModel: ObservableObject { } func refresh() { - viewModel?.refreshBreeds(forced: true) + viewModel?.refreshBreeds() } } diff --git a/ios/KaMPKitiOS/CombineAdapters.swift b/ios/KaMPKitiOS/CombineAdapters.swift new file mode 100644 index 00000000..53435445 --- /dev/null +++ b/ios/KaMPKitiOS/CombineAdapters.swift @@ -0,0 +1,40 @@ +import Combine +import shared + +/// Create a Combine publisher from the supplied `FlowAdapter`. Use this in contexts where more transformation will be +/// done on the Swift side before the value is bound to UI +func createPublisher(_ flowAdapter: FlowAdapter) -> AnyPublisher { + return Deferred>> { + let subject = PassthroughSubject() + let canceller = flowAdapter.subscribe( + onEach: { item in subject.send(item) }, + onComplete: { subject.send(completion: .finished) }, + onThrow: { error in subject.send(completion: .failure(KotlinError(error))) } + ) + return subject.handleEvents(receiveCancel: { canceller.cancel() }) + }.eraseToAnyPublisher() +} + +/// Prepare the supplied `FlowAdapter` to be bound to UI. The `onEach` callback will be called from `DispatchQueue.main` +/// on every new emission. +/// +/// Note that this calls `assertNoFailure()` internally so you should handle errors upstream to avoid crashes. +func doPublish(_ flowAdapter: FlowAdapter, onEach: @escaping (T) -> Void) -> Cancellable { + return createPublisher(flowAdapter) + .assertNoFailure() + .compactMap { $0 } + .receive(on: DispatchQueue.main) + .sink { onEach($0) } +} + +/// Wraps a `KotlinThrowable` in a `LocalizedError` which can be used as a Combine error type +class KotlinError: LocalizedError { + let throwable: KotlinThrowable + + init(_ throwable: KotlinThrowable) { + self.throwable = throwable + } + var errorDescription: String? { + throwable.message + } +} diff --git a/shared/src/androidMain/kotlin/co/touchlab/kampkit/PlatformAndroid.kt b/shared/src/androidMain/kotlin/co/touchlab/kampkit/PlatformAndroid.kt deleted file mode 100644 index 8ac97944..00000000 --- a/shared/src/androidMain/kotlin/co/touchlab/kampkit/PlatformAndroid.kt +++ /dev/null @@ -1,5 +0,0 @@ -package co.touchlab.kampkit - -internal actual fun printThrowable(t: Throwable) { - t.printStackTrace() -} diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/Koin.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/Koin.kt index 00506251..db7c2df1 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/Koin.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/Koin.kt @@ -2,6 +2,7 @@ package co.touchlab.kampkit import co.touchlab.kampkit.ktor.DogApi import co.touchlab.kampkit.ktor.DogApiImpl +import co.touchlab.kampkit.models.BreedRepository import co.touchlab.kermit.Logger import co.touchlab.kermit.StaticConfig import co.touchlab.kermit.platformLogWriter @@ -63,6 +64,16 @@ private val coreModule = module { // See https://github.com/touchlab/Kermit val baseLogger = Logger(config = StaticConfig(logWriterList = listOf(platformLogWriter())), "KampKit") factory { (tag: String?) -> if (tag != null) baseLogger.withTag(tag) else baseLogger } + + single { + BreedRepository( + get(), + get(), + get(), + getWith("BreedRepository"), + get() + ) + } } internal inline fun Scope.getWith(vararg params: Any?): T { diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/Platform.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/Platform.kt deleted file mode 100644 index 69a54575..00000000 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/Platform.kt +++ /dev/null @@ -1,3 +0,0 @@ -package co.touchlab.kampkit - -internal expect fun printThrowable(t: Throwable) diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedCommonViewModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedCommonViewModel.kt new file mode 100644 index 00000000..a5ff0e2e --- /dev/null +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedCommonViewModel.kt @@ -0,0 +1,65 @@ +package co.touchlab.kampkit.models + +import co.touchlab.kampkit.db.Breed +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flattenMerge +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch + +class BreedCommonViewModel( + private val breedRepository: BreedRepository, + log: Logger, + private val scope: CoroutineScope +) { + private val log = log.withTag("BreedCommonViewModel") + + private val mutableBreeds: MutableStateFlow> = MutableStateFlow( + DataState(loading = true) + ) + + val breeds: StateFlow> = mutableBreeds + + init { + observeBreeds() + } + + @OptIn(FlowPreview::class) + private fun observeBreeds() { + scope.launch { + log.v { "getBreeds: Collecting Things" } + flowOf( + breedRepository.refreshBreedsIfStale(true), + breedRepository.getBreedsFromCache() + ).flattenMerge().collect { dataState -> + if (dataState.loading) { + mutableBreeds.value = mutableBreeds.value.copy(loading = true) + } else { + mutableBreeds.value = dataState + } + } + } + } + + fun refreshBreeds() { + scope.launch { + log.v { "refreshBreeds" } + breedRepository.refreshBreedsIfStale(true).collect { dataState -> + if (dataState.loading) { + mutableBreeds.value = mutableBreeds.value.copy(loading = true) + } else { + mutableBreeds.value = dataState + } + } + } + } + + fun updateBreedFavorite(breed: Breed) { + scope.launch { + breedRepository.updateBreedFavorite(breed) + } + } +} diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedRepository.kt similarity index 89% rename from shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedModel.kt rename to shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedRepository.kt index 1e06f8c3..a053baea 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedModel.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedRepository.kt @@ -2,7 +2,6 @@ package co.touchlab.kampkit.models import co.touchlab.kampkit.DatabaseHelper import co.touchlab.kampkit.db.Breed -import co.touchlab.kampkit.injectLogger import co.touchlab.kampkit.ktor.DogApi import co.touchlab.kermit.Logger import co.touchlab.stately.ensureNeverFrozen @@ -11,15 +10,16 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.mapNotNull import kotlinx.datetime.Clock -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -class BreedModel : KoinComponent { - private val dbHelper: DatabaseHelper by inject() - private val settings: Settings by inject() - private val dogApi: DogApi by inject() - private val log: Logger by injectLogger("BreedModel") - private val clock: Clock by inject() +class BreedRepository( + private val dbHelper: DatabaseHelper, + private val settings: Settings, + private val dogApi: DogApi, + log: Logger, + private val clock: Clock +) { + + private val log = log.withTag("BreedModel") companion object { internal const val DB_TIMESTAMP_KEY = "DbTimestampKey" diff --git a/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedModelTest.kt b/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedRepositoryTest.kt similarity index 92% rename from shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedModelTest.kt rename to shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedRepositoryTest.kt index 7c8e0e06..87790c0b 100644 --- a/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedModelTest.kt +++ b/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedRepositoryTest.kt @@ -4,7 +4,7 @@ import app.cash.turbine.test import co.touchlab.kampkit.db.Breed import co.touchlab.kampkit.mock.ClockMock import co.touchlab.kampkit.mock.DogApiMock -import co.touchlab.kampkit.models.BreedModel +import co.touchlab.kampkit.models.BreedRepository import co.touchlab.kampkit.models.DataState import co.touchlab.kampkit.models.ItemDataSummary import co.touchlab.kermit.Logger @@ -18,7 +18,6 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import kotlinx.datetime.Clock import kotlin.test.AfterTest -import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -26,10 +25,10 @@ import kotlin.test.assertTrue import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.seconds +@OptIn(FlowPreview::class) @RunWith(AndroidJUnit4::class) -class BreedModelTest { +class BreedRepositoryTest { - private var model: BreedModel = BreedModel() private var kermit = Logger private var testDbConnection = testDbConnection() private var dbHelper = DatabaseHelper( @@ -43,6 +42,8 @@ class BreedModelTest { // Need to start at non-zero time because the default value for db timestamp is 0 private val clock = ClockMock(Clock.System.now()) + private var model: BreedRepository = BreedRepository(dbHelper, settings, ktorApi, kermit, clock) + companion object { private val appenzeller = Breed(1, "appenzeller", 0L) private val australianNoLike = Breed(2, "australian", 0L) @@ -55,15 +56,10 @@ class BreedModelTest { ) } - @BeforeTest - fun setup() { - appStart(dbHelper, settings, ktorApi, kermit, clock) - } - @Test fun staleDataCheckTest() = runTest { val currentTimeMS = Clock.System.now().toEpochMilliseconds() - settings.putLong(BreedModel.DB_TIMESTAMP_KEY, currentTimeMS) + settings.putLong(BreedRepository.DB_TIMESTAMP_KEY, currentTimeMS) assertTrue(ktorApi.calledCount == 0) val expectedError = DataState(exception = "Unable to download breed list") @@ -76,7 +72,6 @@ class BreedModelTest { assertTrue(ktorApi.calledCount == 0) } - @OptIn(FlowPreview::class) @Test fun updateFavoriteTest() = runBlocking { ktorApi.prepareResult(ktorApi.successResult()) @@ -94,7 +89,6 @@ class BreedModelTest { } } - @OptIn(FlowPreview::class) @Test fun fetchBreedsFromNetworkPreserveFavorites() = runBlocking { ktorApi.prepareResult(ktorApi.successResult()) @@ -122,7 +116,6 @@ class BreedModelTest { } } - @OptIn(FlowPreview::class) @Test fun updateDatabaseTest() = runBlocking { val successResult = ktorApi.successResult() @@ -155,7 +148,7 @@ class BreedModelTest { } @Test - fun notifyErrorOnException() = runTest { + fun showCachedBreedsOnApiErrpr() = runTest { ktorApi.throwOnCall(RuntimeException()) assertNotNull(model.getBreedsFromNetwork(0L)) } @@ -163,6 +156,5 @@ class BreedModelTest { @AfterTest fun breakdown() = runTest { testDbConnection.close() - appEnd() } } diff --git a/shared/src/commonTest/kotlin/co/touchlab/kampkit/TestUtil.kt b/shared/src/commonTest/kotlin/co/touchlab/kampkit/TestUtil.kt index 6c2d0cce..d0974ea5 100644 --- a/shared/src/commonTest/kotlin/co/touchlab/kampkit/TestUtil.kt +++ b/shared/src/commonTest/kotlin/co/touchlab/kampkit/TestUtil.kt @@ -1,34 +1,5 @@ package co.touchlab.kampkit -import co.touchlab.kampkit.ktor.DogApi -import co.touchlab.kermit.Logger -import com.russhwolf.settings.Settings import com.squareup.sqldelight.db.SqlDriver -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.withTimeout -import kotlinx.datetime.Clock -import org.koin.core.context.startKoin -import org.koin.core.context.stopKoin -import org.koin.dsl.module - -fun appStart(helper: DatabaseHelper, settings: Settings, dogApi: DogApi, log: Logger, clock: Clock) { - val coreModule = module { - single { helper } - single { settings } - single { dogApi } - single { log } - single { clock } - } - - startKoin { modules(coreModule) } -} - -fun appEnd() { - stopKoin() -} - -// Await with a timeout -suspend fun Deferred.await(timeoutMillis: Long) = - withTimeout(timeoutMillis) { await() } internal expect fun testDbConnection(): SqlDriver diff --git a/shared/src/iosMain/kotlin/co/touchlab/kampkit/BreedCallbackViewModel.kt b/shared/src/iosMain/kotlin/co/touchlab/kampkit/BreedCallbackViewModel.kt new file mode 100644 index 00000000..875b9332 --- /dev/null +++ b/shared/src/iosMain/kotlin/co/touchlab/kampkit/BreedCallbackViewModel.kt @@ -0,0 +1,21 @@ +package co.touchlab.kampkit + +import co.touchlab.kampkit.db.Breed +import co.touchlab.kampkit.models.BreedCommonViewModel +import co.touchlab.kampkit.models.BreedRepository +import co.touchlab.kermit.Logger + +@Suppress("Unused") // Called from Swift +class BreedCallbackViewModel( + breedRepository: BreedRepository, + log: Logger +) : CallbackViewModel(log) { + + private val commonViewModel = BreedCommonViewModel(breedRepository, log, viewModelScope) + + val breeds = commonViewModel.breeds.asCallbacks() + + fun refreshBreeds() = commonViewModel.refreshBreeds() + + fun updateBreedFavorite(breed: Breed) = commonViewModel.updateBreedFavorite(breed) +} diff --git a/shared/src/iosMain/kotlin/co/touchlab/kampkit/CallbackViewModel.kt b/shared/src/iosMain/kotlin/co/touchlab/kampkit/CallbackViewModel.kt new file mode 100644 index 00000000..fee4f820 --- /dev/null +++ b/shared/src/iosMain/kotlin/co/touchlab/kampkit/CallbackViewModel.kt @@ -0,0 +1,40 @@ +package co.touchlab.kampkit + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.plus + +/** + * Base class that provides a Kotlin/Native equivalent to the AndroidX `ViewModel`. In particular, this provides + * a [CoroutineScope][kotlinx.coroutines.CoroutineScope] which uses [Dispatchers.Main][kotlinx.coroutines.Dispatchers.Main] + * and can be tied into an arbitrary lifecycle by calling [clear] at the appropriate time. + */ +abstract class CallbackViewModel(log: Logger) { + + val viewModelScope = MainScope() + kermitExceptionHandler(log) + + /** + * Override this to do any cleanup immediately before the internal [CoroutineScope][kotlinx.coroutines.CoroutineScope] + * is cancelled in [clear] + */ + protected open fun onClear() { + } + + /** + * Cancels the internal [CoroutineScope][kotlinx.coroutines.CoroutineScope]. After this is called, the ViewModel should + * no longer be used. + */ + @Suppress("Unused") // Called from Swift + fun clear() { + onClear() + viewModelScope.cancel() + } + + /** + * Create a [FlowAdapter] from this [Flow] to make it easier to interact with from Swift. + */ + fun Flow.asCallbacks() = + FlowAdapter(viewModelScope, this) +} diff --git a/shared/src/iosMain/kotlin/co/touchlab/kampkit/CoroutineAdapters.kt b/shared/src/iosMain/kotlin/co/touchlab/kampkit/CoroutineAdapters.kt new file mode 100644 index 00000000..0cbdb4e2 --- /dev/null +++ b/shared/src/iosMain/kotlin/co/touchlab/kampkit/CoroutineAdapters.kt @@ -0,0 +1,35 @@ +package co.touchlab.kampkit + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach + +class FlowAdapter( + private val scope: CoroutineScope, + private val flow: Flow +) { + fun subscribe( + onEach: (item: T) -> Unit, + onComplete: () -> Unit, + onThrow: (error: Throwable) -> Unit + ): Canceller = JobCanceller( + flow.onEach { onEach(it) } + .catch { onThrow(it) } + .onCompletion { onComplete() } + .launchIn(scope) + ) +} + +interface Canceller { + fun cancel() +} + +private class JobCanceller(private val job: Job) : Canceller { + override fun cancel() { + job.cancel() + } +} diff --git a/shared/src/iosMain/kotlin/co/touchlab/kampkit/KermitExceptionHandler.kt b/shared/src/iosMain/kotlin/co/touchlab/kampkit/KermitExceptionHandler.kt new file mode 100644 index 00000000..e13f9ed7 --- /dev/null +++ b/shared/src/iosMain/kotlin/co/touchlab/kampkit/KermitExceptionHandler.kt @@ -0,0 +1,9 @@ +package co.touchlab.kampkit + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineExceptionHandler + +fun kermitExceptionHandler(log: Logger) = CoroutineExceptionHandler { _, throwable -> + throwable.printStackTrace() + log.e(throwable = throwable) { "Error in MainScope" } +} diff --git a/shared/src/iosMain/kotlin/co/touchlab/kampkit/KoinIOS.kt b/shared/src/iosMain/kotlin/co/touchlab/kampkit/KoinIOS.kt index 21cf1f47..a140cd19 100644 --- a/shared/src/iosMain/kotlin/co/touchlab/kampkit/KoinIOS.kt +++ b/shared/src/iosMain/kotlin/co/touchlab/kampkit/KoinIOS.kt @@ -9,6 +9,7 @@ import com.squareup.sqldelight.drivers.native.NativeSqliteDriver import io.ktor.client.engine.darwin.Darwin import org.koin.core.Koin import org.koin.core.KoinApplication +import org.koin.core.component.KoinComponent import org.koin.core.parameter.parametersOf import org.koin.dsl.module import platform.Foundation.NSUserDefaults @@ -29,9 +30,16 @@ actual val platformModule = module { single { NativeSqliteDriver(KaMPKitDb.Schema, "KampkitDb") } single { Darwin.create() } + + single { BreedCallbackViewModel(get(), getWith("BreedCallbackViewModel")) } } // Access from Swift to create a logger @Suppress("unused") fun Koin.loggerWithTag(tag: String) = get(qualifier = null) { parametersOf(tag) } + +@Suppress("unused") // Called from Swift +object KotlinDependencies : KoinComponent { + fun getBreedViewModel() = getKoin().get() +} diff --git a/shared/src/iosMain/kotlin/co/touchlab/kampkit/MainScope.kt b/shared/src/iosMain/kotlin/co/touchlab/kampkit/MainScope.kt deleted file mode 100644 index f9bd2eed..00000000 --- a/shared/src/iosMain/kotlin/co/touchlab/kampkit/MainScope.kt +++ /dev/null @@ -1,28 +0,0 @@ -package co.touchlab.kampkit - -import co.touchlab.kermit.Logger -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlin.coroutines.CoroutineContext - -class MainScope(private val mainContext: CoroutineContext, private val log: Logger) : - CoroutineScope { - override val coroutineContext: CoroutineContext - get() = mainContext + job + exceptionHandler - - internal val job = SupervisorJob() - private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> - printThrowable(throwable) - showError(throwable) - } - - // TODO: Some way of exposing this to the caller without trapping a reference and freezing it. - private fun showError(t: Throwable) { - log.e(throwable = t) { "Error in MainScope" } - } - - fun onDestroy() { - job.cancel() - } -} diff --git a/shared/src/iosMain/kotlin/co/touchlab/kampkit/NativeViewModel.kt b/shared/src/iosMain/kotlin/co/touchlab/kampkit/NativeViewModel.kt deleted file mode 100644 index b4908657..00000000 --- a/shared/src/iosMain/kotlin/co/touchlab/kampkit/NativeViewModel.kt +++ /dev/null @@ -1,86 +0,0 @@ -package co.touchlab.kampkit - -import co.touchlab.kampkit.db.Breed -import co.touchlab.kampkit.models.BreedModel -import co.touchlab.kampkit.models.DataState -import co.touchlab.kampkit.models.ItemDataSummary -import co.touchlab.kermit.Logger -import co.touchlab.stately.ensureNeverFrozen -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.flattenMerge -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.launch -import org.koin.core.component.KoinComponent - -class NativeViewModel( - private val onDataState: (DataState) -> Unit -) : KoinComponent { - - private val log: Logger by injectLogger("BreedModel") - private val scope = MainScope(Dispatchers.Main, log) - private val breedModel: BreedModel = BreedModel() - private val _breedStateFlow: MutableStateFlow> = MutableStateFlow( - DataState(loading = true) - ) - - init { - ensureNeverFrozen() - observeBreeds() - } - - fun consumeError() { - _breedStateFlow.value = _breedStateFlow.value.copy(exception = null) - } - - @OptIn(FlowPreview::class) - fun observeBreeds() { - scope.launch { - log.v { "getBreeds: Collecting Things" } - flowOf( - breedModel.refreshBreedsIfStale(true), - breedModel.getBreedsFromCache() - ).flattenMerge().collect { dataState -> - if (dataState.loading) { - val temp = _breedStateFlow.value.copy(loading = true) - _breedStateFlow.value = temp - } else { - _breedStateFlow.value = dataState - } - } - } - - scope.launch { - log.v { "Exposing flow through callbacks" } - _breedStateFlow.collect { dataState -> - onDataState(dataState) - } - } - } - - fun refreshBreeds(forced: Boolean = false) { - scope.launch { - log.v { "refreshBreeds" } - breedModel.refreshBreedsIfStale(forced).collect { dataState -> - if (dataState.loading) { - val temp = _breedStateFlow.value.copy(loading = true) - _breedStateFlow.value = temp - } else { - _breedStateFlow.value = dataState - } - } - } - } - - fun updateBreedFavorite(breed: Breed) { - scope.launch { - breedModel.updateBreedFavorite(breed) - } - } - - fun onDestroy() { - scope.onDestroy() - } -} diff --git a/shared/src/iosMain/kotlin/co/touchlab/kampkit/PlatformiOS.kt b/shared/src/iosMain/kotlin/co/touchlab/kampkit/PlatformiOS.kt deleted file mode 100644 index 8ac97944..00000000 --- a/shared/src/iosMain/kotlin/co/touchlab/kampkit/PlatformiOS.kt +++ /dev/null @@ -1,5 +0,0 @@ -package co.touchlab.kampkit - -internal actual fun printThrowable(t: Throwable) { - t.printStackTrace() -}