From 2351f688efbe5a8d12fbbb3ba99abfb49a05b22e Mon Sep 17 00:00:00 2001 From: Nimrod Dayan Date: Mon, 27 Apr 2020 00:16:07 +0300 Subject: [PATCH] Migrated commit list to redux POC --- .../commitbrowser/common/ui/Redux.kt | 57 ++++++++++++++ .../home/commitlist/CommitListController.kt | 7 +- .../home/commitlist/CommitListFragment.kt | 50 ++++++------ .../home/commitlist/CommitListModels.kt | 5 +- .../home/commitlist/CommitListModule.kt | 13 ++++ .../home/commitlist/CommitListReducer.kt | 35 +++++++++ .../home/commitlist/CommitListViewModel.kt | 78 ++++--------------- .../home/commitlist/FetchCommitListAction.kt | 67 ++++++++++++++++ 8 files changed, 223 insertions(+), 89 deletions(-) create mode 100644 base/src/main/java/com/nimroddayan/commitbrowser/common/ui/Redux.kt create mode 100644 commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListReducer.kt create mode 100644 commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/FetchCommitListAction.kt diff --git a/base/src/main/java/com/nimroddayan/commitbrowser/common/ui/Redux.kt b/base/src/main/java/com/nimroddayan/commitbrowser/common/ui/Redux.kt new file mode 100644 index 0000000..ebc550a --- /dev/null +++ b/base/src/main/java/com/nimroddayan/commitbrowser/common/ui/Redux.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2020 Nimrod Dayan nimroddayan.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nimroddayan.commitbrowser.common.ui + +import kotlinx.coroutines.channels.ConflatedBroadcastChannel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow + +interface Action + +typealias AsyncAction = suspend (previousState: State, dispatch: (action: Action) -> Unit) -> Unit + +interface Reducer { + fun reduce(previousState: State, action: Action): State +} + +interface ReduxStore { + val stateFlow: Flow + fun getState(): State + fun dispatch(action: Action) + suspend fun dispatch(action: AsyncAction) +} + +class Store( + initialState: State, + private val reducer: Reducer +) : ReduxStore { + private var state: State = initialState + private val stateChannel = ConflatedBroadcastChannel() + + override val stateFlow = stateChannel.asFlow() + + override fun getState(): State = state + + override fun dispatch(action: Action) { + state = reducer.reduce(state, action) + stateChannel.offer(state) + } + + override suspend fun dispatch(action: AsyncAction) { + action(state, ::dispatch) + } +} diff --git a/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListController.kt b/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListController.kt index 4ae68c9..d2ce551 100644 --- a/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListController.kt +++ b/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListController.kt @@ -20,16 +20,17 @@ import com.nimroddayan.commitbrowser.base.loading import com.nimroddayan.commitbrowser.commitlist.commitInfo import com.nimroddayan.commitbrowser.common.epoxy.ViewStateEpoxyController import com.nimroddayan.commitbrowser.common.ui.ViewState -import javax.inject.Inject -class CommitListController @Inject constructor() : ViewStateEpoxyController() { +class CommitListController( + private val commitListViewModel: CommitListViewModel +) : ViewStateEpoxyController() { override fun buildModels(state: ViewState) { state.data.list.forEach { commitInfo { id(it.sha) commitInfo(it) clickListener { model, _, _, _ -> - state.data.onClick?.invoke(model.commitInfo()) + } } } diff --git a/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListFragment.kt b/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListFragment.kt index 08b8c6b..1e31668 100644 --- a/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListFragment.kt +++ b/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListFragment.kt @@ -20,41 +20,49 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels -import androidx.lifecycle.Observer import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import com.nimroddayan.commitbrowser.commitlist.R import com.nimroddayan.commitbrowser.commitlist.databinding.CommitListFragmentBinding import com.nimroddayan.commitbrowser.common.recyclerview.OnLoadMoreScrollListener -import com.nimroddayan.commitbrowser.common.ui.BaseFragment import com.nimroddayan.commitbrowser.di.withFactory +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach import timber.log.Timber import javax.inject.Inject class CommitListFragment @Inject constructor( commitListViewModelFactory: CommitListViewModel.Factory, - commitListController: CommitListController, private val navigation: CommitListNavigation -) : BaseFragment( - commitListController, - R.layout.commit_list_fragment -) { - override val viewModel: CommitListViewModel by viewModels { withFactory(commitListViewModelFactory) } +) : Fragment() { + private val viewModel: CommitListViewModel by viewModels { withFactory(commitListViewModelFactory) } + private lateinit var binding: CommitListFragmentBinding + private lateinit var controller: CommitListController - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - viewModel.commitItemClickedEvent.observe(this, Observer { commitInfo -> - lifecycleScope.launchWhenStarted { - Timber.d("Item with sha: %s was clicked", commitInfo) - navigation.navigateToCommitDetails(commitInfo) - } - }) + init { + lifecycleScope.launchWhenStarted { + viewModel.stateFlow.onEach { commitListViewState -> + controller.setData(commitListViewState) + }.collect() + } } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - val view = super.onCreateView(inflater, container, savedInstanceState) +// override fun onCreate(savedInstanceState: Bundle?) { +// super.onCreate(savedInstanceState) +// viewModel.commitItemClickedEvent.observe(this, Observer { commitInfo -> +// lifecycleScope.launchWhenStarted { +// Timber.d("Item with sha: %s was clicked", commitInfo) +// navigation.navigateToCommitDetails(commitInfo) +// } +// }) +// } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = CommitListFragmentBinding.inflate(inflater, container, false) + controller = CommitListController(viewModel) binding.recyclerview.apply { layoutManager = LinearLayoutManager(context) @@ -66,9 +74,7 @@ class CommitListFragment @Inject constructor( ) addOnScrollListener(object : OnLoadMoreScrollListener(resources.getInteger(R.integer.load_threshold)) { override fun onLoadMore() { - controller.currentData?.let { - viewModel.loadData(it.data.page + 1) - } + viewModel.loadCommits(viewModel.getState().data.page + 1) } }) adapter = controller.adapter @@ -82,7 +88,7 @@ class CommitListFragment @Inject constructor( } } - return view + return binding.root } override fun onSaveInstanceState(outState: Bundle) { diff --git a/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListModels.kt b/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListModels.kt index 02cdccf..771102d 100644 --- a/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListModels.kt +++ b/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListModels.kt @@ -17,9 +17,8 @@ package com.nimroddayan.commitbrowser.home.commitlist data class CommitListViewState( - val page: Int, - val list: List, - val onClick: ((commitInfo: CommitInfo) -> Unit)? = null + val page: Int = 0, + val list: List = emptyList() ) data class CommitInfo( diff --git a/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListModule.kt b/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListModule.kt index ff8cf89..3a1a644 100644 --- a/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListModule.kt +++ b/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListModule.kt @@ -16,9 +16,12 @@ package com.nimroddayan.commitbrowser.home.commitlist import androidx.fragment.app.Fragment +import com.nimroddayan.commitbrowser.common.ui.Store +import com.nimroddayan.commitbrowser.common.ui.ViewState import com.nimroddayan.commitbrowser.di.FragmentKey import dagger.Binds import dagger.Module +import dagger.Provides import dagger.multibindings.IntoMap @Module @@ -27,4 +30,14 @@ abstract class CommitListModule { @IntoMap @FragmentKey(CommitListFragment::class) abstract fun bindCommitListFragment(fragment: CommitListFragment): Fragment + + companion object { + @Provides + fun provideStore(commitListReducer: CommitListReducer): Store> { + return Store( + initialState = ViewState.Loading(CommitListViewState()), + reducer = commitListReducer + ) + } + } } diff --git a/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListReducer.kt b/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListReducer.kt new file mode 100644 index 0000000..f0c996e --- /dev/null +++ b/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListReducer.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2020 Nimrod Dayan nimroddayan.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nimroddayan.commitbrowser.home.commitlist + +import com.nimroddayan.commitbrowser.common.ui.Action +import com.nimroddayan.commitbrowser.common.ui.Reducer +import com.nimroddayan.commitbrowser.common.ui.ViewState +import javax.inject.Inject + +class CommitListReducer @Inject constructor() : Reducer> { + override fun reduce(previousState: ViewState, action: Action): ViewState { + return when (action) { + is FetchCommitListAction -> when (action) { + is FetchCommitListAction.Loading -> ViewState.Loading(previousState.data.copy(page = action.page)) + is FetchCommitListAction.Success -> ViewState.Loaded(previousState.data.copy(list = previousState.data.list + action.commits)) + is FetchCommitListAction.Failure -> ViewState.Error(action.throwable, previousState.data) + } + else -> previousState + } + } +} diff --git a/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListViewModel.kt b/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListViewModel.kt index b1b98f9..365f9f7 100644 --- a/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListViewModel.kt +++ b/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/CommitListViewModel.kt @@ -16,18 +16,16 @@ package com.nimroddayan.commitbrowser.home.commitlist -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.nimroddayan.commitbrowser.api.GithubApi -import com.nimroddayan.commitbrowser.common.coroutines.withInternet import com.nimroddayan.commitbrowser.common.network.InternetConnection import com.nimroddayan.commitbrowser.common.ui.BaseViewModel +import com.nimroddayan.commitbrowser.common.ui.ReduxStore +import com.nimroddayan.commitbrowser.common.ui.Store +import com.nimroddayan.commitbrowser.common.ui.ViewState import com.nimroddayan.commitbrowser.di.CoroutinesDispatcherProvider -import com.nimroddayan.commitbrowser.model.CommitResponse import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject @@ -37,74 +35,30 @@ class CommitListViewModel( handle: SavedStateHandle, githubApi: GithubApi, dispatchers: CoroutinesDispatcherProvider, - internetConnection: InternetConnection -) : BaseViewModel(handle, githubApi, dispatchers, internetConnection) { - val commitItemClickedEvent: LiveData - get() = _commitItemClickedEvent - private val _commitItemClickedEvent = MutableLiveData() - - private val commitList = mutableListOf() + internetConnection: InternetConnection, + private val store: Store>, + private val fetchCommitListActionCreator: FetchCommitListActionCreator +) : BaseViewModel(handle, githubApi, dispatchers, internetConnection), + ReduxStore> by store { init { Timber.d("Initializing") val page = handle[STATE_PAGE] ?: 0 - loadData(page, page > 0) + loadCommits(page) } - fun loadData(page: Int, restoringState: Boolean = false) { - handle[STATE_PAGE] = page - notifyLoading( - CommitListViewState( - page = page, - list = commitList - ) - ) + fun loadCommits(page: Int) { viewModelScope.launch { - val pages = (if (restoringState) 0 else page)..page - val response = mutableListOf() - pages.forEach { currentPage -> - Timber.d("Fetching data for page: %d", currentPage) - response += withContext(io) { - withInternet({ waitForInternetAndNotifyLoading(page) }, { reportErrorAndRetry(it, page) }) { - githubApi.getCommits(currentPage) - } - } - } - Timber.d("Data loaded for page %d", page) - notifyDataLoaded( - CommitListViewState( - page = page, - list = commitList.apply { - addAll(response.map { - CommitInfo( - sha = it.sha, - avatar = it.author?.avatarUrl ?: "", - message = it.commit?.message ?: "", - date = it.commit?.author?.date ?: "", - author = it.author?.name ?: "" - ) - }) - }, - onClick = _commitItemClickedEvent::setValue - ) - ) + dispatch(fetchCommitListActionCreator.create(page)) } } - private suspend fun waitForInternetAndNotifyLoading(page: Int) { - waitForInternet() - notifyLoading(CommitListViewState(page, commitList)) - } - - private fun reportErrorAndRetry(throwable: Throwable, page: Int): Boolean { - notifyError(throwable, CommitListViewState(page = page, list = commitList)) - return retryAfterError(throwable) - } - class Factory @Inject constructor( githubApi: GithubApi, dispatchers: CoroutinesDispatcherProvider, - internetConnection: InternetConnection + internetConnection: InternetConnection, + private val store: Store>, + private val fetchCommitListActionCreator: FetchCommitListActionCreator ) : BaseViewModel.Factory( githubApi, dispatchers, @@ -115,7 +69,9 @@ class CommitListViewModel( handle, githubApi, dispatchers, - internetConnection + internetConnection, + store, + fetchCommitListActionCreator ) } } diff --git a/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/FetchCommitListAction.kt b/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/FetchCommitListAction.kt new file mode 100644 index 0000000..ede76e6 --- /dev/null +++ b/commitlist/src/main/java/com/nimroddayan/commitbrowser/home/commitlist/FetchCommitListAction.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2020 Nimrod Dayan nimroddayan.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nimroddayan.commitbrowser.home.commitlist + +import com.nimroddayan.commitbrowser.api.GithubApi +import com.nimroddayan.commitbrowser.common.ui.Action +import com.nimroddayan.commitbrowser.common.ui.AsyncAction +import com.nimroddayan.commitbrowser.common.ui.ViewState +import javax.inject.Inject + +sealed class FetchCommitListAction : Action { + class Loading(val page: Int) : FetchCommitListAction() + class Success(val commits: List) : FetchCommitListAction() + class Failure(val throwable: Throwable) : FetchCommitListAction() +} + +class FetchCommitListActionCreator @Inject constructor( + private val githubApi: GithubApi +) { + fun create( + page: Int + ): AsyncAction> { + return { previousState, dispatch -> + dispatch(FetchCommitListAction.Loading(page)) + try { + val pages = (if (isRestoringSavedState(page, previousState)) 0 else page)..page + dispatch( + FetchCommitListAction.Success( + pages.map { currentPage -> + githubApi.getCommits(currentPage).map { + CommitInfo( + sha = it.sha, + avatar = it.author?.avatarUrl ?: "", + message = it.commit?.message ?: "", + date = it.commit?.author?.date ?: "", + author = it.author?.name ?: "" + ) + } + }.flatten() + ) + ) + } catch (e: Exception) { + dispatch(FetchCommitListAction.Failure(e)) + } + } + } + + + private fun isRestoringSavedState( + page: Int, + previousState: ViewState + ) = page > 0 && previousState.data.list.isEmpty() +}