Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

View Model Refactor Concept - Shared Interface #206

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions app/src/main/java/co/touchlab/kampkit/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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()
}
}
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/co/touchlab/kampkit/android/MainApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,7 +17,7 @@ class MainApp : Application() {
initKoin(
module {
single<Context> { this@MainApp }
viewModel { BreedViewModel() }
viewModel { AndroidBreedViewModel() }
single<SharedPreferences> {
get<Context>().getSharedPreferences("KAMPSTARTER_SETTINGS", Context.MODE_PRIVATE)
}
Expand Down
37 changes: 20 additions & 17 deletions app/src/main/java/co/touchlab/kampkit/android/ui/Composables.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -42,7 +42,7 @@ import com.google.accompanist.swiperefresh.rememberSwipeRefreshState

@Composable
fun MainScreen(
viewModel: BreedViewModel,
viewModel: AndroidBreedViewModel,
log: Kermit
) {
val lifecycleOwner = LocalLifecycleOwner.current
Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -182,7 +185,7 @@ fun FavoriteIcon(breed: Breed) {
@Composable
fun MainScreenContentPreview_Success() {
MainScreenContent(
dogsState = DataState(
dogsState = DataState.Success(
data = ItemDataSummary(
longestItem = null,
allItems = listOf(
Expand Down
35 changes: 24 additions & 11 deletions ios/KaMPKitiOS/BreedListScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -95,6 +105,9 @@ struct BreedListContent : View{
Text(error)
.foregroundColor(.red)
}
if (breeds == nil || error == nil) {
Spacer()
}
Button("Refresh") {
refresh()
}
Expand Down
1 change: 1 addition & 0 deletions shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<DataState<ItemDataSummary>> =
MutableStateFlow(DataState.Loading)

override fun getFlowValue(): DataState<ItemDataSummary> = _breedStateFlow.value

override fun setFlowValue(value: DataState<ItemDataSummary>) {
_breedStateFlow.value = value
}

override val breedStateFlow: StateFlow<DataState<ItemDataSummary>>
get() = _breedStateFlow

init {
observeBreeds()
}
}
Original file line number Diff line number Diff line change
@@ -1,51 +1,43 @@
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<DataState<ItemDataSummary>> = MutableStateFlow(
DataState(loading = true)
)
val log: Kermit
val scope: CoroutineScope
val breedModel: BreedModel

val breedStateFlow: StateFlow<DataState<ItemDataSummary>> = _breedStateFlow
fun getFlowValue(): DataState<ItemDataSummary>
fun setFlowValue(value: DataState<ItemDataSummary>)

init {
observeBreeds()
}
val breedStateFlow: StateFlow<DataState<ItemDataSummary>>

@OptIn(FlowPreview::class)
private fun observeBreeds() {
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
val temp = getFlowValue().copy(isLoading = true)
setFlowValue(temp)
} else {
_breedStateFlow.value = dataState
setFlowValue(dataState)
}
}
}
Expand All @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ class BreedModel : KoinComponent {
}

fun refreshBreedsIfStale(forced: Boolean = false): Flow<DataState<ItemDataSummary>> = flow {
emit(DataState(loading = true))
emit(DataState.Loading)
val currentTimeMS = clock.now().toEpochMilliseconds()
val stale = isBreedListStale(currentTimeMS)
val networkBreedDataState: DataState<ItemDataSummary>
if (stale || forced) {
networkBreedDataState = getBreedsFromNetwork(currentTimeMS)
if (networkBreedDataState.data != null) {
if (networkBreedDataState is DataState.Success) {
dbHelper.insertBreeds(networkBreedDataState.data.allItems)
} else {
emit(networkBreedDataState)
Expand All @@ -50,7 +50,7 @@ class BreedModel : KoinComponent {
if (itemList.isEmpty()) {
null
} else {
DataState<ItemDataSummary>(
DataState.Success(
data = ItemDataSummary(
itemList.maxByOrNull { it.name.length },
itemList
Expand All @@ -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<ItemDataSummary>(empty = true)
DataState.Empty()
} else {
DataState<ItemDataSummary>(
DataState.Success(
ItemDataSummary(
null,
breedList.map { Breed(0L, it, 0L) }
Expand All @@ -88,7 +88,7 @@ class BreedModel : KoinComponent {
}
} catch (e: Exception) {
log.e(e) { "Error downloading breed list" }
DataState<ItemDataSummary>(exception = "Unable to download breed list")
DataState.Error(exception = "Unable to download breed list")
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
package co.touchlab.kampkit.models

data class DataState<out T>(
val data: T? = null,
val exception: String? = null,
val empty: Boolean = false,
val loading: Boolean = false
)
sealed class DataState<out T>(open val loading: Boolean) {
class Success<T>(val data: T, override val loading: Boolean = false) : DataState<T>(loading) {
override fun copy(isLoading: Boolean): DataState<T> = 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<Nothing>(loading) {
override fun copy(isLoading: Boolean): DataState<Nothing> = 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<Nothing>(loading) {
override fun copy(isLoading: Boolean) = this
override fun equals(other: Any?) = other is Empty && other.loading == loading
}

object Loading : DataState<Nothing>(true) {
override fun copy(isLoading: Boolean): DataState<Nothing> = this
}

abstract fun copy(isLoading: Boolean): DataState<T>
}
Loading