Skip to content

Commit

Permalink
Repository refactors
Browse files Browse the repository at this point in the history
- Remove generic DataState and replace with BreedListState for better generic handling in Swift
- Better separation between Repository and ViewModel. The repository exposes raw data, and the viewmodel packages it into success/loading/error/empty states
- Tests can use jvm sqlite driver instead of robolectric (this avoids deadlocks that were happening with runBlocking and viewModelScope on robolectric)
- Database exposes favorite as a Boolean instead of an Int
- Rewritten tests for both Repository and ViewModel
  • Loading branch information
russhwolf committed Apr 4, 2022
1 parent d636def commit ca73cae
Show file tree
Hide file tree
Showing 13 changed files with 492 additions and 255 deletions.
56 changes: 26 additions & 30 deletions app/src/main/java/co/touchlab/kampkit/android/ui/Composables.kt
Expand Up @@ -35,8 +35,7 @@ import androidx.lifecycle.flowWithLifecycle
import co.touchlab.kampkit.android.R
import co.touchlab.kampkit.db.Breed
import co.touchlab.kampkit.models.BreedViewModel
import co.touchlab.kampkit.models.DataState
import co.touchlab.kampkit.models.ItemDataSummary
import co.touchlab.kampkit.models.BreedViewState
import co.touchlab.kermit.Logger
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
Expand All @@ -47,27 +46,27 @@ fun MainScreen(
log: Logger
) {
val lifecycleOwner = LocalLifecycleOwner.current
val lifecycleAwareDogsFlow = remember(viewModel.breeds, lifecycleOwner) {
viewModel.breeds.flowWithLifecycle(lifecycleOwner.lifecycle)
val lifecycleAwareDogsFlow = remember(viewModel.breedState, lifecycleOwner) {
viewModel.breedState.flowWithLifecycle(lifecycleOwner.lifecycle)
}

@SuppressLint("StateFlowValueCalledInComposition") // False positive lint check when used inside collectAsState()
val dogsState by lifecycleAwareDogsFlow.collectAsState(viewModel.breeds.value)
val dogsState by lifecycleAwareDogsFlow.collectAsState(viewModel.breedState.value)

MainScreenContent(
dogsState = dogsState,
onRefresh = { viewModel.refreshBreeds() },
onSuccess = { data -> log.v { "View updating with ${data.allItems.size} breeds" } },
onSuccess = { data -> log.v { "View updating with ${data.size} breeds" } },
onError = { exception -> log.e { "Displaying error: $exception" } },
onFavorite = { viewModel.updateBreedFavorite(it) }
)
}

@Composable
fun MainScreenContent(
dogsState: DataState<ItemDataSummary>,
dogsState: BreedViewState,
onRefresh: () -> Unit = {},
onSuccess: (ItemDataSummary) -> Unit = {},
onSuccess: (List<Breed>) -> Unit = {},
onError: (String) -> Unit = {},
onFavorite: (Breed) -> Unit = {}
) {
Expand All @@ -76,25 +75,25 @@ fun MainScreenContent(
modifier = Modifier.fillMaxSize()
) {
SwipeRefresh(
state = rememberSwipeRefreshState(isRefreshing = dogsState.loading),
state = rememberSwipeRefreshState(isRefreshing = dogsState.isLoading),
onRefresh = onRefresh
) {
if (dogsState.empty) {
if (dogsState.isEmpty) {
Empty()
}
val data = dogsState.data
if (data != null) {
LaunchedEffect(data) {
onSuccess(data)
val breeds = dogsState.breeds
if (breeds != null) {
LaunchedEffect(breeds) {
onSuccess(breeds)
}
Success(successData = data, favoriteBreed = onFavorite)
Success(successData = breeds, favoriteBreed = onFavorite)
}
val exception = dogsState.exception
if (exception != null) {
LaunchedEffect(exception) {
onError(exception)
val error = dogsState.error
if (error != null) {
LaunchedEffect(error) {
onError(error)
}
Error(exception)
Error(error)
}
}
}
Expand Down Expand Up @@ -128,10 +127,10 @@ fun Error(error: String) {

@Composable
fun Success(
successData: ItemDataSummary,
successData: List<Breed>,
favoriteBreed: (Breed) -> Unit
) {
DogList(breeds = successData.allItems, favoriteBreed)
DogList(breeds = successData, favoriteBreed)
}

@Composable
Expand Down Expand Up @@ -161,7 +160,7 @@ fun DogRow(breed: Breed, onClick: (Breed) -> Unit) {
@Composable
fun FavoriteIcon(breed: Breed) {
Crossfade(
targetState = breed.favorite == 0L,
targetState = !breed.favorite,
animationSpec = TweenSpec(
durationMillis = 500,
easing = FastOutSlowInEasing
Expand All @@ -185,13 +184,10 @@ fun FavoriteIcon(breed: Breed) {
@Composable
fun MainScreenContentPreview_Success() {
MainScreenContent(
dogsState = DataState(
data = ItemDataSummary(
longestItem = null,
allItems = listOf(
Breed(0, "appenzeller", 0),
Breed(1, "australian", 1)
)
dogsState = BreedViewState(
breeds = listOf(
Breed(0, "appenzeller", false),
Breed(1, "australian", true)
)
)
)
Expand Down
4 changes: 3 additions & 1 deletion gradle/libs.versions.toml
Expand Up @@ -62,6 +62,7 @@ multiplatformSettings-test = { module = "com.russhwolf:multiplatform-settings-te
roboelectric = { module = "org.robolectric:robolectric", version = "4.7.3" }

sqlDelight-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqlDelight" }
sqlDelight-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqlDelight" }
sqlDelight-coroutinesExt = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqlDelight" }
sqlDelight-native = { module = "com.squareup.sqldelight:native-driver", version.ref = "sqlDelight" }
sqlDelight-runtime = { module = "com.squareup.sqldelight:runtime", version.ref = "sqlDelight" }
Expand Down Expand Up @@ -106,6 +107,7 @@ shared-commonTest = [
shared-androidTest = [
"androidx-test-junit",
"coroutines-test",
"roboelectric"
"roboelectric",
"sqlDelight-jvm"
]

18 changes: 9 additions & 9 deletions ios/KaMPKitiOS/BreedListScreen.swift
Expand Up @@ -29,15 +29,15 @@ class ObservableBreedModel: ObservableObject {
func activate() {
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
doPublish(viewModel.breeds) { [weak self] dogsState in
self?.loading = dogsState.isLoading
self?.breeds = dogsState.breeds
self?.error = dogsState.error

if let breeds = dataState.data?.allItems {
if let breeds = dogsState.breeds {
log.d(message: {"View updating with \(breeds.count) breeds"})
}
if let errorMessage = dataState.exception {
if let errorMessage = dogsState.error {
log.e(message: {"Displaying error: \(errorMessage)"})
}
}.store(in: &cancellables)
Expand Down Expand Up @@ -123,7 +123,7 @@ struct BreedRowView: View {
Text(breed.name)
.padding(4.0)
Spacer()
Image(systemName: (breed.favorite == 0) ? "heart" : "heart.fill")
Image(systemName: (!breed.favorite) ? "heart" : "heart.fill")
.padding(4.0)
}
}
Expand All @@ -135,8 +135,8 @@ struct BreedListScreen_Previews: PreviewProvider {
BreedListContent(
loading: false,
breeds: [
Breed(id: 0, name: "appenzeller", favorite: 0),
Breed(id: 1, name: "australian", favorite: 1)
Breed(id: 0, name: "appenzeller", favorite: false),
Breed(id: 1, name: "australian", favorite: true)
],
error: nil,
onBreedFavorite: { _ in },
Expand Down
Expand Up @@ -5,8 +5,16 @@ import androidx.test.core.app.ApplicationProvider
import co.touchlab.kampkit.db.KaMPKitDb
import com.squareup.sqldelight.android.AndroidSqliteDriver
import com.squareup.sqldelight.db.SqlDriver
import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver

internal actual fun testDbConnection(): SqlDriver {
val app = ApplicationProvider.getApplicationContext<Application>()
return AndroidSqliteDriver(KaMPKitDb.Schema, app, "kampkitdb")
// Try to use the android driver (which only works if we're on robolectric).
// Fall back to jdbc if that fails.
return try {
val app = ApplicationProvider.getApplicationContext<Application>()
AndroidSqliteDriver(KaMPKitDb.Schema, app, "kampkitdb")
} catch (exception: IllegalStateException) {
JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
.also { KaMPKitDb.Schema.create(it) }
}
}
Expand Up @@ -25,11 +25,11 @@ class DatabaseHelper(
.mapToList()
.flowOn(backgroundDispatcher)

suspend fun insertBreeds(breeds: List<Breed>) {
suspend fun insertBreeds(breeds: List<String>) {
log.d { "Inserting ${breeds.size} breeds into database" }
dbRef.transactionWithContext(backgroundDispatcher) {
breeds.forEach { breed ->
dbRef.tableQueries.insertBreed(null, breed.name)
dbRef.tableQueries.insertBreed(breed)
}
}
}
Expand All @@ -51,10 +51,7 @@ class DatabaseHelper(
suspend fun updateFavorite(breedId: Long, favorite: Boolean) {
log.i { "Breed $breedId: Favorited $favorite" }
dbRef.transactionWithContext(backgroundDispatcher) {
dbRef.tableQueries.updateFavorite(favorite.toLong(), breedId)
dbRef.tableQueries.updateFavorite(favorite, breedId)
}
}
}

fun Breed.isFavorited(): Boolean = this.favorite != 0L
internal fun Boolean.toLong(): Long = if (this) 1L else 0L
Expand Up @@ -7,8 +7,6 @@ import co.touchlab.kermit.Logger
import co.touchlab.stately.ensureNeverFrozen
import com.russhwolf.settings.Settings
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.datetime.Clock

class BreedRepository(
Expand All @@ -29,72 +27,37 @@ class BreedRepository(
ensureNeverFrozen()
}

fun refreshBreedsIfStale(forced: Boolean = false): Flow<DataState<ItemDataSummary>> = flow {
emit(DataState(loading = true))
val currentTimeMS = clock.now().toEpochMilliseconds()
val stale = isBreedListStale(currentTimeMS)
val networkBreedDataState: DataState<ItemDataSummary>
if (stale || forced) {
networkBreedDataState = getBreedsFromNetwork(currentTimeMS)
if (networkBreedDataState.data != null) {
dbHelper.insertBreeds(networkBreedDataState.data.allItems)
} else {
emit(networkBreedDataState)
}
fun getBreeds(): Flow<List<Breed>> = dbHelper.selectAllItems()

suspend fun refreshBreedsIfStale() {
if (isBreedListStale()) {
refreshBreeds()
}
}

fun getBreedsFromCache(): Flow<DataState<ItemDataSummary>> =
dbHelper.selectAllItems()
.mapNotNull { itemList ->
if (itemList.isEmpty()) {
null
} else {
DataState<ItemDataSummary>(
data = ItemDataSummary(
itemList.maxByOrNull { it.name.length },
itemList
)
)
}
}
suspend fun refreshBreeds() {
val breedResult = dogApi.getJsonFromApi()
log.v { "Breed network result: ${breedResult.status}" }
val breedList = breedResult.message.keys.sorted().toList()
log.v { "Fetched ${breedList.size} breeds from network" }
settings.putLong(DB_TIMESTAMP_KEY, clock.now().toEpochMilliseconds())

if (breedList.isNotEmpty()) {
dbHelper.insertBreeds(breedList)
}
}

suspend fun updateBreedFavorite(breed: Breed) {
dbHelper.updateFavorite(breed.id, !breed.favorite)
}

private fun isBreedListStale(currentTimeMS: Long): Boolean {
private fun isBreedListStale(): Boolean {
val lastDownloadTimeMS = settings.getLong(DB_TIMESTAMP_KEY, 0)
val oneHourMS = 60 * 60 * 1000
val stale = lastDownloadTimeMS + oneHourMS < currentTimeMS
val stale = lastDownloadTimeMS + oneHourMS < clock.now().toEpochMilliseconds()
if (!stale) {
log.i { "Breeds not fetched from network. Recently updated" }
}
return stale
}

suspend fun getBreedsFromNetwork(currentTimeMS: Long): DataState<ItemDataSummary> {
return try {
val breedResult = dogApi.getJsonFromApi()
log.v { "Breed network result: ${breedResult.status}" }
val breedList = breedResult.message.keys.sorted().toList()
log.v { "Fetched ${breedList.size} breeds from network" }
settings.putLong(DB_TIMESTAMP_KEY, currentTimeMS)
if (breedList.isEmpty()) {
DataState<ItemDataSummary>(empty = true)
} else {
DataState<ItemDataSummary>(
ItemDataSummary(
null,
breedList.map { Breed(0L, it, 0L) }
)
)
}
} catch (e: Exception) {
log.e(e) { "Error downloading breed list" }
DataState<ItemDataSummary>(exception = "Unable to download breed list")
}
}

suspend fun updateBreedFavorite(breed: Breed) {
dbHelper.updateFavorite(breed.id, breed.favorite != 1L)
}
}

data class ItemDataSummary(val longestItem: Breed?, val allItems: List<Breed>)

0 comments on commit ca73cae

Please sign in to comment.