Skip to content

Commit

Permalink
Saved state handle, kotlin 1.8.21, compose compiler 1.4.7, coroutines…
Browse files Browse the repository at this point in the history
… 1.7.0 (#56)

* simplify viewmodel

* savedStateHandle

* savedStateHandle

* fix sideeffects

* fix sideeffects

* update ios

* fix sideeffects

* kotlin 1.8.21

* readme

* test

* autocloseable
  • Loading branch information
hoc081098 authored May 10, 2023
1 parent ce8335c commit 313a8b3
Show file tree
Hide file tree
Showing 26 changed files with 392 additions and 166 deletions.
105 changes: 72 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Github Repos Search - Kotlin Multiplatform Mobile using Jetpack Compose, SwiftUI
[![iOS Build CI](https://github.com/hoc081098/GithubSearchKMM/actions/workflows/ios-build.yml/badge.svg)](https://github.com/hoc081098/GithubSearchKMM/actions/workflows/ios-build.yml)
[![Validate Gradle Wrapper](https://github.com/hoc081098/GithubSearchKMM/actions/workflows/gradle-wrapper-validation.yml/badge.svg)](https://github.com/hoc081098/GithubSearchKMM/actions/workflows/gradle-wrapper-validation.yml)
[![API](https://img.shields.io/badge/API-23%2B-brightgreen.svg?style=flat)](https://android-arsenal.com/api?level=23)
[![Kotlin](https://img.shields.io/badge/kotlin-1.8.10-blue.svg?logo=kotlin)](http://kotlinlang.org)
[![Kotlin](https://img.shields.io/badge/kotlin-1.8.21-blue.svg?logo=kotlin)](http://kotlinlang.org)
[![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Fhoc081098%2FGithubSearchKMM&count_bg=%2379C83D&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false)](https://hits.seeyoufarm.com)
[![License: MIT](https://img.shields.io/badge/License-MIT-purple.svg)](https://opensource.org/licenses/MIT)
[![codecov](https://codecov.io/gh/hoc081098/GithubSearchKMM/branch/master/graph/badge.svg?token=qzSAFkj09P)](https://codecov.io/gh/hoc081098/GithubSearchKMM)
Expand Down Expand Up @@ -101,44 +101,73 @@ Liked some of my work? Buy me a coffee (or more likely a beer)

```kotlin
public sealed interface FlowReduxStore<Action, State> {
public val coroutineScope: CoroutineScope

/**
* The state of this store.
*/
public val stateFlow: StateFlow<State>

/** Get streams of actions.
*
* This [Flow] includes dispatched [Action]s (via [dispatch] function)
* and [Action]s returned from [SideEffect]s.
/**
* @return false if cannot dispatch action (this store was closed).
*/
public fun dispatch(action: Action): Boolean

/**
* Call this method to close this store.
* A closed store will not accept any action anymore, thus state will not change anymore.
* All [SideEffect]s will be cancelled.
*/
public val actionSharedFlow: SharedFlow<Action>
public fun close()

/**
* @return false if cannot dispatch action ([coroutineScope] was cancelled).
* After calling [close] method, this function will return true.
*
* @return true if this store was closed.
*/
public fun dispatch(action: Action): Boolean
public fun isClosed(): Boolean
}
```

### Multiplatform ViewModel

```kotlin
open class GithubSearchViewModel(
searchRepoItemsUseCase: SearchRepoItemsUseCase,
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val effectsContainer = GithubSearchSideEffectsContainer(searchRepoItemsUseCase)

private val store = viewModelScope.createFlowReduxStore(
initialState = GithubSearchState.initial(),
sideEffects = GithubSearchSideEffects(
searchRepoItemsUseCase = searchRepoItemsUseCase,
).sideEffects,
reducer = { state, action -> action.reduce(state) }
sideEffects = effectsContainer.sideEffects,
reducer = Reducer(flip(GithubSearchAction::reduce))
.withLogger(githubSearchFlowReduxLogger())
)
private val eventChannel = store.actionSharedFlow
.mapNotNull { it.toGithubSearchSingleEventOrNull() }
.buffer(Channel.UNLIMITED)
.produceIn(viewModelScope)

fun dispatch(action: GithubSearchAction) = store.dispatch(action)
val stateFlow: StateFlow<GithubSearchState> by store::stateFlow
val eventFlow: Flow<GithubSearchSingleEvent> get() = eventChannel.receiveAsFlow()

val termStateFlow: NonNullStateFlowWrapper<String> = savedStateHandle.getStateFlow(TERM_KEY, "").wrap()
val stateFlow: NonNullStateFlowWrapper<GithubSearchState> = store.stateFlow.wrap()
val eventFlow: NonNullFlowWrapper<GithubSearchSingleEvent> = effectsContainer.eventFlow.wrap()

init {
store.dispatch(InitialSearchAction(termStateFlow.value))
}

@MainThread
fun dispatch(action: GithubSearchAction): Boolean {
if (action is GithubSearchAction.Search) {
savedStateHandle[TERM_KEY] = action.term
}
return store.dispatch(action)
}

companion object {
private const val TERM_KEY = "com.hoc081098.github_search_kmm.presentation.GithubSearchViewModel.term"

/**
* Used by non-Android platforms.
*/
fun create(searchRepoItemsUseCase: SearchRepoItemsUseCase): GithubSearchViewModel =
GithubSearchViewModel(searchRepoItemsUseCase, SavedStateHandle())
}
}
```

Expand All @@ -150,8 +179,10 @@ Extends `GithubSearchViewModel` to use `Dagger Constructor Injection`.

```kotlin
@HiltViewModel
class DaggerGithubSearchViewModel @Inject constructor(searchRepoItemsUseCase: SearchRepoItemsUseCase) :
GithubSearchViewModel(searchRepoItemsUseCase)
class DaggerGithubSearchViewModel @Inject constructor(
searchRepoItemsUseCase: SearchRepoItemsUseCase,
savedStateHandle: SavedStateHandle,
) : GithubSearchViewModel(searchRepoItemsUseCase, savedStateHandle)
```

#### iOS
Expand All @@ -169,6 +200,7 @@ class IOSGithubSearchViewModel: ObservableObject {
private let vm: GithubSearchViewModel

@Published private(set) var state: GithubSearchState
@Published private(set) var term: String = ""
let eventPublisher: AnyPublisher<GithubSearchSingleEventKs, Never>

init(vm: GithubSearchViewModel) {
Expand All @@ -179,11 +211,18 @@ class IOSGithubSearchViewModel: ObservableObject {
.map(GithubSearchSingleEventKs.init)
.eraseToAnyPublisher()

self.state = vm.stateFlow.typedValue()
vm.stateFlow.subscribeNonNullFlow(
self.state = vm.stateFlow.value
vm.stateFlow.subscribe(
scope: vm.viewModelScope,
onValue: { [weak self] in self?.state = $0 }
)

self.vm
.termStateFlow
.asNonNullPublisher(NSString.self)
.assertNoFailure()
.map { $0 as String }
.assign(to: &$term)
}

@discardableResult
Expand Down Expand Up @@ -242,14 +281,14 @@ class IOSGithubSearchViewModel: ObservableObject {
--------------------------------------------------------------------------------
Language Files Lines Blank Comment Code
--------------------------------------------------------------------------------
Kotlin 96 7111 863 398 5850
Kotlin 105 7647 936 439 6272
JSON 7 3938 0 0 3938
Swift 16 857 110 98 649
Markdown 1 255 47 0 208
Bourne Shell 2 245 28 110 107
Batch 1 91 21 0 70
XML 7 71 6 0 65
Swift 16 903 118 102 683
Markdown 1 294 54 0 240
Bourne Shell 2 250 28 116 106
Batch 1 92 21 0 71
XML 6 69 6 0 63
--------------------------------------------------------------------------------
Total 130 12568 1075 606 10887
Total 138 13193 1163 657 11373
--------------------------------------------------------------------------------
```
2 changes: 2 additions & 0 deletions androidApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ dependencies {
implementation(deps.compose.uiToolingPreview)
implementation(deps.compose.uiUtil)
implementation(deps.compose.runtime)

lintChecks(deps.slack.composeLint)
}

fun Project.buildComposeMetricsParameters(): List<String> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (C) 2023 Slack Technologies, LLC
// SPDX-License-Identifier: Apache-2.0
package com.hoc081098.github_search_kmm.android

import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.CoroutineScope

/**
* Returns a [StableCoroutineScope] around a [rememberCoroutineScope]. This is useful for event
* callback lambdas that capture a local scope variable to launch new coroutines, as it allows them
* to be stable.
*/
@Composable
fun rememberStableCoroutineScope(): StableCoroutineScope {
val scope = rememberCoroutineScope()
return remember { StableCoroutineScope(scope) }
}

/** @see rememberStableCoroutineScope */
@Stable
class StableCoroutineScope(scope: CoroutineScope) : CoroutineScope by scope
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ internal fun GithubRepoItemsList(
error: AppError?,
hasReachedMax: Boolean,
onRetry: () -> Unit,
onLoadNextPage: () -> Unit
onLoadNextPage: () -> Unit,
modifier: Modifier = Modifier,
) {
val lazyListState = rememberLazyListState()
val currentOnLoadNextPage by rememberUpdatedState(onLoadNextPage)
Expand Down Expand Up @@ -70,7 +71,7 @@ internal fun GithubRepoItemsList(
val decimalFormat = remember { StableWrapper(DecimalFormat("#,###")) }

LazyColumn(
modifier = Modifier
modifier = modifier
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
state = lazyListState
Expand Down
Loading

0 comments on commit 313a8b3

Please sign in to comment.