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 fe0ff529..d954096f 100644 --- a/app/src/main/java/co/touchlab/kampkit/android/MainActivity.kt +++ b/app/src/main/java/co/touchlab/kampkit/android/MainActivity.kt @@ -3,8 +3,10 @@ package co.touchlab.kampkit.android import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import co.touchlab.kampkit.AndroidBreedViewModel import co.touchlab.kampkit.android.ui.MainScreen import co.touchlab.kampkit.android.ui.theme.KaMPKitTheme +import co.touchlab.kampkit.models.DataState import co.touchlab.kermit.Kermit import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.component.KoinComponent @@ -14,7 +16,7 @@ import org.koin.core.parameter.parametersOf class MainActivity : ComponentActivity(), KoinComponent { private val log: Kermit by inject { parametersOf("MainActivity") } - private val viewModel: BreedViewModel by viewModel() + private val viewModel: AndroidBreedViewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -23,7 +25,7 @@ class MainActivity : ComponentActivity(), KoinComponent { MainScreen(viewModel, log) } } - if (viewModel.breedStateFlow.value.data == null) { + if (viewModel.breedStateFlow.value !is DataState.Success) { 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..dd93f090 100644 --- a/app/src/main/java/co/touchlab/kampkit/android/MainApp.kt +++ b/app/src/main/java/co/touchlab/kampkit/android/MainApp.kt @@ -4,6 +4,7 @@ import android.app.Application import android.content.Context import android.content.SharedPreferences import android.util.Log +import co.touchlab.kampkit.AndroidBreedViewModel import co.touchlab.kampkit.AppInfo import co.touchlab.kampkit.initKoin import org.koin.androidx.viewmodel.dsl.viewModel @@ -16,7 +17,7 @@ class MainApp : Application() { initKoin( module { single { this@MainApp } - viewModel { BreedViewModel() } + viewModel { AndroidBreedViewModel() } 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 61fe8fae..23136b7b 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 @@ -31,7 +31,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.flowWithLifecycle -import co.touchlab.kampkit.android.BreedViewModel +import co.touchlab.kampkit.AndroidBreedViewModel import co.touchlab.kampkit.android.R import co.touchlab.kampkit.db.Breed import co.touchlab.kampkit.models.DataState @@ -42,7 +42,7 @@ import com.google.accompanist.swiperefresh.rememberSwipeRefreshState @Composable fun MainScreen( - viewModel: BreedViewModel, + viewModel: AndroidBreedViewModel, log: Kermit ) { val lifecycleOwner = LocalLifecycleOwner.current @@ -76,22 +76,25 @@ fun MainScreenContent( state = rememberSwipeRefreshState(isRefreshing = dogsState.loading), onRefresh = onRefresh ) { - if (dogsState.empty) { - Empty() - } - val data = dogsState.data - if (data != null) { - LaunchedEffect(data) { - onSuccess(data) + when (dogsState) { + is DataState.Empty -> { + Empty() } - Success(successData = data, favoriteBreed = onFavorite) - } - val exception = dogsState.exception - if (exception != null) { - LaunchedEffect(exception) { - onError(exception) + is DataState.Error -> { + LaunchedEffect(dogsState.exception) { + onError(dogsState.exception) + } + Error(dogsState.exception) + } + DataState.Loading -> { + // Taken care of in SwipeRefresh above + } + is DataState.Success -> { + LaunchedEffect(dogsState.data) { + onSuccess(dogsState.data) + } + Success(successData = dogsState.data, favoriteBreed = onFavorite) } - Error(exception) } } } @@ -182,7 +185,7 @@ fun FavoriteIcon(breed: Breed) { @Composable fun MainScreenContentPreview_Success() { MainScreenContent( - dogsState = DataState( + dogsState = DataState.Success( data = ItemDataSummary( longestItem = null, allItems = listOf( diff --git a/ios/KaMPKitiOS/BreedListScreen.swift b/ios/KaMPKitiOS/BreedListScreen.swift index 332d4fb3..8191edc4 100644 --- a/ios/KaMPKitiOS/BreedListScreen.swift +++ b/ios/KaMPKitiOS/BreedListScreen.swift @@ -24,18 +24,28 @@ class ObservableBreedModel: ObservableObject { var error: String? = nil func activate() { - viewModel = NativeViewModel { [weak self] dataState in - self?.loading = dataState.loading - self?.breeds = dataState.data?.allItems - self?.error = dataState.exception - - if let breeds = dataState.data?.allItems { - log.d(withMessage: {"View updating with \(breeds.count) breeds"}) - } - if let errorMessage = dataState.exception { - log.e(withMessage: {"Displaying error: \(errorMessage)"}) + viewModel = NativeViewModel( + onSuccess: { [weak self] success in + self?.loading = success.loading + let items = success.data?.allItems + let count = items?.count ?? 0 + self?.breeds = items + log.d(withMessage: {"View updating with \(count) breeds"}) + self?.error = nil + }, + onError: { [weak self] error in + self?.loading = error.loading + self?.error = error.exception + log.e(withMessage: {"Displaying error: \(error.exception)"}) + }, + onEmpty: { [weak self] empty in + self?.loading = empty.loading + self?.error = nil + }, + onLoading: { [weak self] in + self?.loading = true } - } + ) } func deactivate() { @@ -95,6 +105,9 @@ struct BreedListContent : View{ Text(error) .foregroundColor(.red) } + if (breeds == nil || error == nil) { + Spacer() + } Button("Refresh") { refresh() } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 5c6a685f..790114e4 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -90,6 +90,7 @@ kotlin { implementation(Deps.SqlDelight.driverAndroid) implementation(Deps.Coroutines.android) implementation(Deps.Ktor.androidCore) + implementation(Deps.AndroidX.lifecycle_viewmodel_extensions) } sourceSets["androidTest"].dependencies { diff --git a/shared/src/androidMain/kotlin/co/touchlab/kampkit/AndroidBreedViewModel.kt b/shared/src/androidMain/kotlin/co/touchlab/kampkit/AndroidBreedViewModel.kt new file mode 100644 index 00000000..d33c1a85 --- /dev/null +++ b/shared/src/androidMain/kotlin/co/touchlab/kampkit/AndroidBreedViewModel.kt @@ -0,0 +1,34 @@ +package co.touchlab.kampkit + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kampkit.models.BreedModel +import co.touchlab.kampkit.models.DataState +import co.touchlab.kampkit.models.ItemDataSummary +import co.touchlab.kermit.Kermit +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.koin.core.component.inject +import org.koin.core.parameter.parametersOf + +class AndroidBreedViewModel : ViewModel(), BreedViewModel { + + override val log: Kermit by inject { parametersOf("BreedViewModel") } + override val scope = viewModelScope + override val breedModel: BreedModel = BreedModel() + private val _breedStateFlow: MutableStateFlow> = + MutableStateFlow(DataState.Loading) + + override fun getFlowValue(): DataState = _breedStateFlow.value + + override fun setFlowValue(value: DataState) { + _breedStateFlow.value = value + } + + override val breedStateFlow: StateFlow> + get() = _breedStateFlow + + init { + observeBreeds() + } +} diff --git a/app/src/main/java/co/touchlab/kampkit/android/BreedViewModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/BreedViewModel.kt similarity index 54% rename from app/src/main/java/co/touchlab/kampkit/android/BreedViewModel.kt rename to shared/src/commonMain/kotlin/co/touchlab/kampkit/BreedViewModel.kt index 97d77c42..673fb9ab 100644 --- a/app/src/main/java/co/touchlab/kampkit/android/BreedViewModel.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/BreedViewModel.kt @@ -1,40 +1,32 @@ -package co.touchlab.kampkit.android +package co.touchlab.kampkit -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope 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.Kermit +import kotlinx.coroutines.CoroutineScope 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 -import org.koin.core.component.inject -import org.koin.core.parameter.parametersOf -class BreedViewModel : ViewModel(), KoinComponent { +interface BreedViewModel : KoinComponent { - private val log: Kermit by inject { parametersOf("BreedViewModel") } - private val scope = viewModelScope - private val breedModel: BreedModel = BreedModel() - private val _breedStateFlow: MutableStateFlow> = MutableStateFlow( - DataState(loading = true) - ) + val log: Kermit + val scope: CoroutineScope + val breedModel: BreedModel - val breedStateFlow: StateFlow> = _breedStateFlow + fun getFlowValue(): DataState + fun setFlowValue(value: DataState) - init { - observeBreeds() - } + val breedStateFlow: StateFlow> @OptIn(FlowPreview::class) - private fun observeBreeds() { + fun observeBreeds() { scope.launch { log.v { "getBreeds: Collecting Things" } flowOf( @@ -42,10 +34,10 @@ class BreedViewModel : ViewModel(), KoinComponent { breedModel.getBreedsFromCache() ).flattenMerge().collect { dataState -> if (dataState.loading) { - val temp = _breedStateFlow.value.copy(loading = true) - _breedStateFlow.value = temp + val temp = getFlowValue().copy(isLoading = true) + setFlowValue(temp) } else { - _breedStateFlow.value = dataState + setFlowValue(dataState) } } } @@ -56,10 +48,10 @@ class BreedViewModel : ViewModel(), KoinComponent { log.v { "refreshBreeds" } breedModel.refreshBreedsIfStale(forced).collect { dataState -> if (dataState.loading) { - val temp = _breedStateFlow.value.copy(loading = true) - _breedStateFlow.value = temp + val temp = getFlowValue().copy(isLoading = true) + setFlowValue(temp) } else { - _breedStateFlow.value = dataState + setFlowValue(dataState) } } } diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ktor/DogApiImpl.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ktor/DogApiImpl.kt index 130e4b27..17b9ed86 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ktor/DogApiImpl.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ktor/DogApiImpl.kt @@ -35,7 +35,7 @@ class DogApiImpl(log: Kermit) : KtorApi { level = LogLevel.INFO } install(HttpTimeout) { - val timeout = 30000L + val timeout = 3000L connectTimeoutMillis = timeout requestTimeoutMillis = timeout socketTimeoutMillis = timeout diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedModel.kt index d5f83512..df5078d6 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedModel.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedModel.kt @@ -30,13 +30,13 @@ class BreedModel : KoinComponent { } fun refreshBreedsIfStale(forced: Boolean = false): Flow> = flow { - emit(DataState(loading = true)) + emit(DataState.Loading) val currentTimeMS = clock.now().toEpochMilliseconds() val stale = isBreedListStale(currentTimeMS) val networkBreedDataState: DataState if (stale || forced) { networkBreedDataState = getBreedsFromNetwork(currentTimeMS) - if (networkBreedDataState.data != null) { + if (networkBreedDataState is DataState.Success) { dbHelper.insertBreeds(networkBreedDataState.data.allItems) } else { emit(networkBreedDataState) @@ -50,7 +50,7 @@ class BreedModel : KoinComponent { if (itemList.isEmpty()) { null } else { - DataState( + DataState.Success( data = ItemDataSummary( itemList.maxByOrNull { it.name.length }, itemList @@ -77,9 +77,9 @@ class BreedModel : KoinComponent { log.v { "Fetched ${breedList.size} breeds from network" } settings.putLong(DB_TIMESTAMP_KEY, currentTimeMS) if (breedList.isEmpty()) { - DataState(empty = true) + DataState.Empty() } else { - DataState( + DataState.Success( ItemDataSummary( null, breedList.map { Breed(0L, it, 0L) } @@ -88,7 +88,7 @@ class BreedModel : KoinComponent { } } catch (e: Exception) { log.e(e) { "Error downloading breed list" } - DataState(exception = "Unable to download breed list") + DataState.Error(exception = "Unable to download breed list") } } diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/DataState.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/DataState.kt index f2b881bd..e8b206ca 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/DataState.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/DataState.kt @@ -1,8 +1,24 @@ package co.touchlab.kampkit.models -data class DataState( - val data: T? = null, - val exception: String? = null, - val empty: Boolean = false, - val loading: Boolean = false -) +sealed class DataState(open val loading: Boolean) { + class Success(val data: T, override val loading: Boolean = false) : DataState(loading) { + override fun copy(isLoading: Boolean): DataState = Success(data, isLoading) + override fun equals(other: Any?) = other is Success<*> && other.data == data && other.loading == loading + } + + class Error(val exception: String, override val loading: Boolean = false) : DataState(loading) { + override fun copy(isLoading: Boolean): DataState = Error(exception, isLoading) + override fun equals(other: Any?) = other is Error && other.exception == exception && other.loading == loading + } + + class Empty(override val loading: Boolean = false) : DataState(loading) { + override fun copy(isLoading: Boolean) = this + override fun equals(other: Any?) = other is Empty && other.loading == loading + } + + object Loading : DataState(true) { + override fun copy(isLoading: Boolean): DataState = this + } + + abstract fun copy(isLoading: Boolean): DataState +} diff --git a/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedModelTest.kt b/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedModelTest.kt index 3a1d8884..92d880d1 100644 --- a/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedModelTest.kt +++ b/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedModelTest.kt @@ -42,10 +42,10 @@ class BreedModelTest : BaseTest() { private val appenzeller = Breed(1, "appenzeller", 0L) private val australianNoLike = Breed(2, "australian", 0L) private val australianLike = Breed(2, "australian", 1L) - val dataStateSuccessNoFavorite = DataState( + val dataStateSuccessNoFavorite = DataState.Success( data = ItemDataSummary(appenzeller, listOf(appenzeller, australianNoLike)) ) - private val dataStateSuccessFavorite = DataState( + private val dataStateSuccessFavorite = DataState.Success( data = ItemDataSummary(appenzeller, listOf(appenzeller, australianLike)) ) } @@ -61,7 +61,7 @@ class BreedModelTest : BaseTest() { settings.putLong(BreedModel.DB_TIMESTAMP_KEY, currentTimeMS) assertTrue(ktorApi.calledCount == 0) - val expectedError = DataState(exception = "Unable to download breed list") + val expectedError = DataState.Error(exception = "Unable to download breed list") val actualError = model.getBreedsFromNetwork(0L) assertEquals( @@ -79,7 +79,7 @@ class BreedModelTest : BaseTest() { flowOf(model.refreshBreedsIfStale(), model.getBreedsFromCache()) .flattenMerge().test { // Loading - assertEquals(DataState(loading = true), expectItem()) + assertEquals(DataState.Loading, expectItem()) // No Favorites assertEquals(dataStateSuccessNoFavorite, expectItem()) // Add 1 favorite breed @@ -98,7 +98,7 @@ class BreedModelTest : BaseTest() { flowOf(model.refreshBreedsIfStale(), model.getBreedsFromCache()) .flattenMerge().test { // Loading - assertEquals(DataState(loading = true), expectItem()) + assertEquals(DataState.Loading, expectItem()) assertEquals(dataStateSuccessNoFavorite, expectItem()) // "Like" the Australian breed model.updateBreedFavorite(australianNoLike) @@ -114,7 +114,7 @@ class BreedModelTest : BaseTest() { flowOf(model.refreshBreedsIfStale(true), model.getBreedsFromCache()) .flattenMerge().test { // Loading - assertEquals(DataState(loading = true), expectItem()) + assertEquals(DataState.Loading, expectItem()) // Get the new result with the Australian breed liked assertEquals(dataStateSuccessFavorite, expectItem()) cancel() @@ -129,10 +129,10 @@ class BreedModelTest : BaseTest() { ktorApi.prepareResult(successResult) flowOf(model.refreshBreedsIfStale(), model.getBreedsFromCache()).flattenMerge() .test(timeout = Duration.seconds(30)) { - assertEquals(DataState(loading = true), expectItem()) + assertEquals(DataState.Loading, expectItem()) val oldBreeds = expectItem() + assertTrue(oldBreeds is DataState.Success) val data = oldBreeds.data - assertTrue(data != null) assertEquals( ktorApi.successResult().message.keys.size, data.allItems.size @@ -146,11 +146,14 @@ class BreedModelTest : BaseTest() { ktorApi.prepareResult(resultWithExtraBreed) flowOf(model.refreshBreedsIfStale(), model.getBreedsFromCache()).flattenMerge() .test(timeout = Duration.seconds(30)) { - assertEquals(DataState(loading = true), expectItem()) + assertEquals(DataState.Loading, expectItem()) val updated = expectItem() + assertTrue(updated is DataState.Success) val data = updated.data - assertTrue(data != null) - assertEquals(resultWithExtraBreed.message.keys.size, data.allItems.size) + assertEquals( + resultWithExtraBreed.message.keys.size, + data.allItems.size + ) } } diff --git a/shared/src/iosMain/kotlin/co/touchlab/kampkit/NativeViewModel.kt b/shared/src/iosMain/kotlin/co/touchlab/kampkit/NativeViewModel.kt index 6db8ac68..b8b69fc4 100644 --- a/shared/src/iosMain/kotlin/co/touchlab/kampkit/NativeViewModel.kt +++ b/shared/src/iosMain/kotlin/co/touchlab/kampkit/NativeViewModel.kt @@ -1,6 +1,5 @@ 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 @@ -9,79 +8,56 @@ import co.touchlab.stately.ensureNeverFrozen import kotlinx.coroutines.Dispatchers 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 import org.koin.core.component.inject import org.koin.core.parameter.parametersOf class NativeViewModel( - private val onDataState: (DataState) -> Unit -) : KoinComponent { + private val onSuccess: (DataState.Success) -> Unit, + private val onError: (DataState.Error) -> Unit, + private val onEmpty: (DataState.Empty) -> Unit, + private val onLoading: () -> Unit, +) : BreedViewModel { - private val log: Kermit by inject { parametersOf("BreedModel") } - private val scope = MainScope(Dispatchers.Main, log) - private val breedModel: BreedModel = BreedModel() - private val _breedStateFlow: MutableStateFlow> = MutableStateFlow( - DataState(loading = true) - ) + override val log: Kermit by inject { parametersOf("BreedModel") } + override val scope = MainScope(Dispatchers.Main, log) + override val breedModel: BreedModel = BreedModel() + private val _breedStateFlow: MutableStateFlow> = + MutableStateFlow(DataState.Loading) + + override fun getFlowValue(): DataState = _breedStateFlow.value + + override fun setFlowValue(value: DataState) { + _breedStateFlow.value = value + } + + override val breedStateFlow: StateFlow> + get() = _breedStateFlow 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 - } - } - } + override fun observeBreeds() { + super.observeBreeds() 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 + breedStateFlow.collect { dataState -> + when (dataState) { + is DataState.Success -> onSuccess(dataState) + is DataState.Error -> onError(dataState) + is DataState.Empty -> onEmpty(dataState) + is DataState.Loading -> onLoading() } } } } - fun updateBreedFavorite(breed: Breed) { - scope.launch { - breedModel.updateBreedFavorite(breed) - } - } - fun onDestroy() { scope.onDestroy() }