diff --git a/README.md b/README.md index 87efaa8..0cbd0ea 100644 --- a/README.md +++ b/README.md @@ -4,42 +4,53 @@ ### API Key -To run the application you need to supply an API key from [TMBD](https://developers.themoviedb.org/3/getting-started/introduction). When you get the key please add following variable to your local environment: +To run the application you need to supply an API key +from [TMBD](https://developers.themoviedb.org/3/getting-started/introduction). When you get the key +please add following variable to your local environment: `` API_KEY_TMDB = Your API Key `` -How to set an environment variable in [Mac](https://medium.com/@himanshuagarwal1395/setting-up-environment-variables-in-macos-sierra-f5978369b255) / [Windows](https://www.architectryan.com/2018/08/31/how-to-change-environment-variables-on-windows-10/) +How to set an environment variable +in [Mac](https://medium.com/@himanshuagarwal1395/setting-up-environment-variables-in-macos-sierra-f5978369b255) / [Windows](https://www.architectryan.com/2018/08/31/how-to-change-environment-variables-on-windows-10/) ### Code style [*](https://github.com/VMadalin/kotlin-sample-app) -To maintain the style and quality of the code, are used the bellow static analysis tools. All of them use properly configuration and you find them in the project root directory `config/.{toolName}`. +To maintain the style and quality of the code, are used the bellow static analysis tools. All of +them use properly configuration and you find them in the project root +directory `config/.{toolName}`. -| Tools | Config file | Check command | Fix command | -|-----------------------------------|---------------------------------------:|------------------------------|---------------------------| -| [detekt][detekt] | [.detekt.yml](/config/.detekt.yml) | `./gradlew detekt` | - | -| [ktlint][ktlint] | - | `./gradlew ktlint` | `./gradlew ktlintFormat` | -| [spotless][spotless] | - | `./gradlew spotlessCheck` | `./gradlew spotlessApply` | -| [lint][lint] | [.lint.xml](/config/.lint.xml) | `./gradlew lint` | - | -| [gradle versions plugin][gvPlugin]| - | `./gradlew dependencyUpdates`| - | +| Tools | Config file | Check command | Fix command | +|------------------------------------|-----------------------------------:|-------------------------------|---------------------------| +| [detekt][detekt] | [.detekt.yml](/config/.detekt.yml) | `./gradlew detekt` | - | +| [ktlint][ktlint] | - | `./gradlew ktlint` | `./gradlew ktlintFormat` | +| [spotless][spotless] | - | `./gradlew spotlessCheck` | `./gradlew spotlessApply` | +| [lint][lint] | [.lint.xml](/config/.lint.xml) | `./gradlew lint` | - | +| [gradle versions plugin][gvPlugin] | - | `./gradlew dependencyUpdates` | - | -All these tools, except [Gradle Versions Plugin][gvPlugin], are integrated in [pre-commit git hook](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks), in order -ensure that all static analysis and tests passes before you can commit your changes. [Gradle Versions Plugin][gvPlugin] can be run optionally. To skip them for specific commit add this option at your git command: +All these tools, except [Gradle Versions Plugin][gvPlugin], are integrated +in [pre-commit git hook](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks), in order +ensure that all static analysis and tests passes before you can commit your +changes. [Gradle Versions Plugin][gvPlugin] can be run optionally. To skip them for specific commit +add this option at your git command: ```properties git commit --no-verify ``` -It's highly recommended to fix broken code styles. There is [a gradle task](/build.gradle#L57) which execute `ktlintFormat` and `spotlessApply` for you: +It's highly recommended to fix broken code styles. There is [a gradle task](/build.gradle#L57) which +execute `ktlintFormat` and `spotlessApply` for you: ```properties ./gradlew reformat ``` +The pre-commit git hooks have exactly the same checks as [CircleCI](https://circleci.com/) and are +defined in this [script](/config/scripts/git-hooks/pre-commit.sh). This step ensures that all +commits comply with the established rules. However the continuous integration will ultimately be +validated that the changes are correct. -The pre-commit git hooks have exactly the same checks as [CircleCI](https://circleci.com/) and are defined in this [script](/config/scripts/git-hooks/pre-commit.sh). This step ensures that all commits comply with the established rules. However the continuous integration will ultimately be validated that the changes are correct. - - -If you want to know more about naming convention, code style and more please look at our [Android guideline](https://github.com/adessoTurkey/android-guideline) repository. +If you want to know more about naming convention, code style and more please look at +our [Android guideline](https://github.com/adessoTurkey/android-guideline) repository. ## Architecture @@ -50,7 +61,8 @@ If you want to know more about naming convention, code style and more please loo **ViewModel:** Can have simple UI logic but most of the time just gets the data from UseCase -**UseCase:** Contains all business rules and they written in the manner of single responsibility principle +**UseCase:** Contains all business rules and they written in the manner of single responsibility +principle **Repository:** Single source of data. Responsible to get data from one or more data sources @@ -60,10 +72,13 @@ If you want to know more about naming convention, code style and more please loo #### Dependencies -- **[Navigation Component](https://developer.android.com/jetpack/androidx/releases/navigation):** Consistent navigation between views -- **[LiveData](https://developer.android.com/topic/libraries/architecture/livedata):** Lifecycle aware observable and data holder -- **[ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel):** Holds UI data across configuration changes -- **[Databinding](https://developer.android.com/topic/libraries/data-binding/):** Binds UI components in layouts to data sources +- **[Navigation Component](https://developer.android.com/jetpack/androidx/releases/navigation):** + Consistent navigation between views +- **[Flow](https://developer.android.com/kotlin/flow):** Asynchronous data streams +- **[ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel):** Holds UI + data across configuration changes +- **[Databinding](https://developer.android.com/topic/libraries/data-binding/):** Binds UI + components in layouts to data sources - **[Dagger](https://github.com/google/dagger):** Dependency injector - **[Coroutines](https://github.com/Kotlin/kotlinx.coroutines):** Asynchronous programming - **[Glide](https://github.com/bumptech/glide):** Image loading and caching @@ -100,7 +115,11 @@ limitations under the License. ``` [detekt]: https://github.com/arturbosch/detekt + [ktlint]: https://github.com/pinterest/ktlint -[spotless]: https://github.com/diffplug/spotless + +[spotless]: https://github.com/diffplug/spotless + [lint]: https://developer.android.com/studio/write/lint + [gvPlugin]: https://github.com/ben-manes/gradle-versions-plugin diff --git a/app/src/main/kotlin/com/adesso/movee/base/BaseAndroidViewModel.kt b/app/src/main/kotlin/com/adesso/movee/base/BaseAndroidViewModel.kt index c036ecc..bb0ba42 100644 --- a/app/src/main/kotlin/com/adesso/movee/base/BaseAndroidViewModel.kt +++ b/app/src/main/kotlin/com/adesso/movee/base/BaseAndroidViewModel.kt @@ -4,31 +4,32 @@ import android.annotation.SuppressLint import android.app.Application import androidx.annotation.StringRes import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import androidx.navigation.NavDirections import com.adesso.movee.R import com.adesso.movee.internal.popup.PopUpType import com.adesso.movee.internal.popup.PopupCallback import com.adesso.movee.internal.popup.PopupUiModel -import com.adesso.movee.internal.util.Event import com.adesso.movee.internal.util.Failure import com.adesso.movee.navigation.NavigationCommand import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.withContext @SuppressLint("StaticFieldLeak") abstract class BaseAndroidViewModel(application: Application) : AndroidViewModel(application) { - private val _failurePopup = MutableLiveData>() - val failurePopup: LiveData> = _failurePopup + private val _failurePopup = Channel(Channel.CONFLATED) + val failurePopup = _failurePopup.receiveAsFlow() - private val _success = MutableLiveData>() - val success: LiveData> = _success + private val _success = Channel(Channel.CONFLATED) + val success = _success.receiveAsFlow() - private val _navigation = MutableLiveData>() - val navigation: LiveData> = _navigation + private val _navigation = Channel(Channel.CONFLATED) + val navigation = _navigation.receiveAsFlow() protected open fun handleFailure(failure: Failure) { val (title, message) = when (failure) { @@ -65,7 +66,7 @@ abstract class BaseAndroidViewModel(application: Application) : AndroidViewModel else -> Pair("", failure.message ?: failure.toString()) } - _failurePopup.value = Event( + _failurePopup.trySend( PopupUiModel( title = title, message = message, @@ -75,15 +76,15 @@ abstract class BaseAndroidViewModel(application: Application) : AndroidViewModel } protected fun showSnackBar(message: String) { - _success.value = Event(message) + _success.trySend(message) } fun navigate(directions: NavDirections) { - _navigation.value = Event(NavigationCommand.ToDirection(directions)) + _navigation.trySend(NavigationCommand.ToDirection(directions)) } fun navigate(deepLink: String) { - _navigation.value = Event(NavigationCommand.ToDeepLink(deepLink)) + _navigation.trySend(NavigationCommand.ToDeepLink(deepLink)) } fun navigate(@StringRes deepLinkRes: Int) { @@ -91,11 +92,11 @@ abstract class BaseAndroidViewModel(application: Application) : AndroidViewModel } fun navigate(model: PopupUiModel, callback: PopupCallback?) { - _navigation.value = Event(NavigationCommand.Popup(model, callback)) + _navigation.trySend(NavigationCommand.Popup(model, callback)) } fun navigateBack() { - _navigation.value = Event(NavigationCommand.Back) + _navigation.trySend(NavigationCommand.Back) } protected suspend fun runOnViewModelScope(block: suspend CoroutineScope.() -> T): T { diff --git a/app/src/main/kotlin/com/adesso/movee/base/BaseBottomSheetDialogFragment.kt b/app/src/main/kotlin/com/adesso/movee/base/BaseBottomSheetDialogFragment.kt index fa71952..f688b77 100644 --- a/app/src/main/kotlin/com/adesso/movee/base/BaseBottomSheetDialogFragment.kt +++ b/app/src/main/kotlin/com/adesso/movee/base/BaseBottomSheetDialogFragment.kt @@ -18,7 +18,7 @@ import androidx.navigation.fragment.FragmentNavigatorExtras import androidx.navigation.fragment.findNavController import androidx.navigation.navGraphViewModels import com.adesso.movee.BR -import com.adesso.movee.internal.extension.observeNonNull +import com.adesso.movee.internal.extension.collectFlow import com.adesso.movee.internal.extension.showPopup import com.adesso.movee.internal.util.functional.lazyThreadSafetyNone import com.adesso.movee.navigation.NavigationCommand @@ -88,10 +88,9 @@ abstract class BaseBottomSheetDialogFragment - handleNavigation(command) - } + + collectFlow(viewModel.navigation) { command -> + handleNavigation(command) } } @@ -100,33 +99,36 @@ abstract class BaseBottomSheetDialogFragment { findNavController().navigate(command.directions, getExtras()) } + is NavigationCommand.ToDeepLink -> { (activity as? MainActivity) ?.navController ?.navigate(command.deepLink.toUri(), null, getExtras()) } + is NavigationCommand.Popup -> { with(command) { context?.showPopup(model, callback) } } + is NavigationCommand.Back -> findNavController().navigateUp() } } private fun observeFailure() { - viewModel.failurePopup.observeNonNull(viewLifecycleOwner) { - it.getContentIfNotHandled()?.let { popupUiModel -> - context?.showPopup(popupUiModel) - } + + collectFlow(viewModel.failurePopup) { popupUiModel -> + + context?.showPopup(popupUiModel) } } private fun observeSuccess() { - viewModel.success.observeNonNull(viewLifecycleOwner) { - it.getContentIfNotHandled()?.let { message -> - showSnackBarMessage(message) - } + + collectFlow(viewModel.success) { message -> + + showSnackBarMessage(message) } } diff --git a/app/src/main/kotlin/com/adesso/movee/base/BaseFragment.kt b/app/src/main/kotlin/com/adesso/movee/base/BaseFragment.kt index 5074c99..0171ddb 100644 --- a/app/src/main/kotlin/com/adesso/movee/base/BaseFragment.kt +++ b/app/src/main/kotlin/com/adesso/movee/base/BaseFragment.kt @@ -15,7 +15,8 @@ import androidx.navigation.fragment.FragmentNavigator import androidx.navigation.fragment.FragmentNavigatorExtras import androidx.navigation.fragment.findNavController import com.adesso.movee.BR -import com.adesso.movee.internal.extension.observeNonNull +import com.adesso.movee.internal.extension.collectFlow +import com.adesso.movee.internal.extension.collectFlowNonNull import com.adesso.movee.internal.extension.showPopup import com.adesso.movee.navigation.NavigationCommand import com.adesso.movee.scene.main.MainActivity @@ -70,10 +71,10 @@ abstract class BaseFragment : Fr } private fun observeNavigation() { - viewModel.navigation.observeNonNull(viewLifecycleOwner) { - it.getContentIfNotHandled()?.let { command -> - handleNavigation(command) - } + + collectFlow(viewModel.navigation) { command -> + + handleNavigation(command) } } @@ -100,18 +101,18 @@ abstract class BaseFragment : Fr } private fun observeFailure() { - viewModel.failurePopup.observeNonNull(viewLifecycleOwner) { - it.getContentIfNotHandled()?.let { popupUiModel -> - context?.showPopup(popupUiModel) - } + + collectFlow(viewModel.failurePopup) { popupUiModel -> + + context?.showPopup(popupUiModel) } } private fun observeSuccess() { - viewModel.success.observeNonNull(viewLifecycleOwner) { - it.getContentIfNotHandled()?.let { message -> - showSnackBarMessage(message) - } + + collectFlowNonNull(viewModel.success) { message -> + + showSnackBarMessage(message) } } diff --git a/app/src/main/kotlin/com/adesso/movee/internal/extension/Activity.kt b/app/src/main/kotlin/com/adesso/movee/internal/extension/Activity.kt index 30304fa..a658a87 100644 --- a/app/src/main/kotlin/com/adesso/movee/internal/extension/Activity.kt +++ b/app/src/main/kotlin/com/adesso/movee/internal/extension/Activity.kt @@ -3,6 +3,9 @@ package com.adesso.movee.internal.extension import android.app.Activity import android.content.Context import android.view.inputmethod.InputMethodManager +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.flow.Flow fun Activity.closeKeyboard() { currentFocus?.let { view -> @@ -17,3 +20,19 @@ fun Activity.showKeyboard() { imm?.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) } } + +inline fun Activity.collectFlow( + flow: Flow, + minActiveState: Lifecycle.State = Lifecycle.State.STARTED, + crossinline action: suspend (T) -> Unit +) { + flow.collectWithLifecycle(this as LifecycleOwner, minActiveState, action) +} + +inline fun Activity.collectFlowNonNull( + flow: Flow, + minActiveState: Lifecycle.State = Lifecycle.State.STARTED, + crossinline action: suspend (T) -> Unit +) { + flow.collectWithLifecycleNonNull(this as LifecycleOwner, minActiveState, action) +} diff --git a/app/src/main/kotlin/com/adesso/movee/internal/extension/Flow.kt b/app/src/main/kotlin/com/adesso/movee/internal/extension/Flow.kt new file mode 100644 index 0000000..3128297 --- /dev/null +++ b/app/src/main/kotlin/com/adesso/movee/internal/extension/Flow.kt @@ -0,0 +1,53 @@ +package com.adesso.movee.internal.extension + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +inline fun Flow.collect( + scope: CoroutineScope, + crossinline action: suspend (T) -> Unit +) { + scope.launch { + collect { action(it) } + } +} + +inline fun Flow.collectNonNull( + scope: CoroutineScope, + crossinline action: suspend (T) -> Unit +) { + scope.launch { + collect { value -> + value?.let { action(it) } + } + } +} + +inline fun Flow.collectWithLifecycle( + lifecycleOwner: LifecycleOwner, + minActiveState: Lifecycle.State = Lifecycle.State.STARTED, + crossinline action: suspend (T) -> Unit +) { + lifecycleOwner.lifecycleScope.launch { + flowWithLifecycle(lifecycleOwner.lifecycle, minActiveState) + .collect { action.invoke(it) } + } +} + +inline fun Flow.collectWithLifecycleNonNull( + lifecycleOwner: LifecycleOwner, + minActiveState: Lifecycle.State = Lifecycle.State.STARTED, + crossinline action: suspend (T) -> Unit +) { + lifecycleOwner.lifecycleScope.launch { + flowWithLifecycle(lifecycleOwner.lifecycle, minActiveState) + .collect { value -> + value?.let { action.invoke(it) } + } + } +} diff --git a/app/src/main/kotlin/com/adesso/movee/internal/extension/Fragment.kt b/app/src/main/kotlin/com/adesso/movee/internal/extension/Fragment.kt index 0c7b2e0..86e7e1b 100644 --- a/app/src/main/kotlin/com/adesso/movee/internal/extension/Fragment.kt +++ b/app/src/main/kotlin/com/adesso/movee/internal/extension/Fragment.kt @@ -3,11 +3,7 @@ package com.adesso.movee.internal.extension import android.widget.Toast import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle -import androidx.lifecycle.flowWithLifecycle -import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch fun Fragment.toast(msg: String, duration: Int = Toast.LENGTH_SHORT) { context.toast(msg, duration) @@ -16,9 +12,15 @@ fun Fragment.toast(msg: String, duration: Int = Toast.LENGTH_SHORT) { inline fun Fragment.collectFlow( flow: Flow, minActiveState: Lifecycle.State = Lifecycle.State.STARTED, - noinline action: suspend (T) -> Unit + crossinline action: suspend (T) -> Unit ) { - lifecycleScope.launch { - flow.flowWithLifecycle(viewLifecycleOwner.lifecycle, minActiveState).collectLatest(action) - } + flow.collectWithLifecycle(viewLifecycleOwner, minActiveState, action) +} + +inline fun Fragment.collectFlowNonNull( + flow: Flow, + minActiveState: Lifecycle.State = Lifecycle.State.STARTED, + crossinline action: suspend (T) -> Unit +) { + flow.collectWithLifecycleNonNull(viewLifecycleOwner, minActiveState, action) } diff --git a/app/src/main/kotlin/com/adesso/movee/internal/extension/LiveData.kt b/app/src/main/kotlin/com/adesso/movee/internal/extension/LiveData.kt deleted file mode 100644 index e180f3f..0000000 --- a/app/src/main/kotlin/com/adesso/movee/internal/extension/LiveData.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.adesso.movee.internal.extension - -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer - -fun MutableLiveData.notifyDataChange() { - this.value = this.value -} - -fun LiveData.observeNonNull(owner: LifecycleOwner, observer: (t: T) -> Unit) { - this.observe( - owner, - Observer { - it?.let(observer) - } - ) -} diff --git a/app/src/main/kotlin/com/adesso/movee/internal/util/Event.kt b/app/src/main/kotlin/com/adesso/movee/internal/util/Event.kt deleted file mode 100644 index c4e03e6..0000000 --- a/app/src/main/kotlin/com/adesso/movee/internal/util/Event.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.adesso.movee.internal.util - -/** - * Used as a wrapper for data that is exposed via a LiveData that represents an event. - * @see https://medium.com/androiddevelopers/livedata-with-snackbar-navigation-and-other-events-the-singleliveevent-case-ac2622673150 - */ -open class Event(private val content: T) { - - private var hasBeenHandled = false - - /** - * Returns the content and prevents its use again. - */ - fun getContentIfNotHandled(): T? { - return if (hasBeenHandled) { - null - } else { - hasBeenHandled = true - content - } - } - - /** - * Returns the content, even if it's already been handled. - */ - fun peekContent(): T = content -} diff --git a/app/src/main/kotlin/com/adesso/movee/internal/util/SingleLiveData.kt b/app/src/main/kotlin/com/adesso/movee/internal/util/SingleLiveData.kt deleted file mode 100644 index f0953f4..0000000 --- a/app/src/main/kotlin/com/adesso/movee/internal/util/SingleLiveData.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.adesso.movee.internal.util - -import android.util.Log -import androidx.annotation.MainThread -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer -import java.util.concurrent.atomic.AtomicBoolean - -/** - * A lifecycle-aware observable that sends only new updates after subscription, used for events like - * navigation and Snackbar messages. - * - * - * This avoids a common problem with events: on configuration change (like rotation) an update - * can be emitted if the observer is active. This LiveData only calls the observable if there's an - * explicit call to setValue() or call(). - * - * - * Note that only one observer is going to be notified of changes. - */ -class SingleLiveData : MutableLiveData() { - - private val pending = AtomicBoolean(false) - - @MainThread - override fun observe(owner: LifecycleOwner, observer: Observer) { - - if (hasActiveObservers()) { - Log.w(TAG, "Multiple observers registered but only one will be notified of changes.") - } - - // Observe the internal MutableLiveData - super.observe( - owner, - Observer { t -> - if (pending.compareAndSet(true, false)) { - observer.onChanged(t) - } - } - ) - } - - @MainThread - override fun setValue(t: T?) { - pending.set(true) - super.setValue(t) - } - - /** - * Used for cases where T is Void, to make calls cleaner. - */ - @MainThread - fun call() { - value = null - } - - companion object { - private const val TAG = "SingleLiveData" - } -} diff --git a/app/src/main/kotlin/com/adesso/movee/internal/util/TripleCombinedLiveData.kt b/app/src/main/kotlin/com/adesso/movee/internal/util/TripleCombinedLiveData.kt deleted file mode 100644 index 092810d..0000000 --- a/app/src/main/kotlin/com/adesso/movee/internal/util/TripleCombinedLiveData.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.adesso.movee.internal.util - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MediatorLiveData -import androidx.lifecycle.Observer - -class TripleCombinedLiveData( - source1: LiveData, - source2: LiveData, - source3: LiveData, - private val combine: (M?, N?, O?) -> S -) : MediatorLiveData() { - - private var data1: M? = null - private var data2: N? = null - private var data3: O? = null - - init { - super.addSource(source1) { - data1 = it - value = combine(data1, data2, data3) - } - - super.addSource(source2) { - data2 = it - value = combine(data1, data2, data3) - } - - super.addSource(source3) { - data3 = it - value = combine(data1, data2, data3) - } - } - - override fun addSource(source: LiveData, onChanged: Observer) { - throw UnsupportedOperationException() - } - - override fun removeSource(toRemote: LiveData) { - throw UnsupportedOperationException() - } -} diff --git a/app/src/main/kotlin/com/adesso/movee/internal/util/functional/General.kt b/app/src/main/kotlin/com/adesso/movee/internal/util/functional/General.kt index d3444bf..0cf8bba 100644 --- a/app/src/main/kotlin/com/adesso/movee/internal/util/functional/General.kt +++ b/app/src/main/kotlin/com/adesso/movee/internal/util/functional/General.kt @@ -1,7 +1,5 @@ package com.adesso.movee.internal.util.functional -import androidx.lifecycle.MutableLiveData - fun lazyThreadSafetyNone(initializer: () -> T): Lazy = lazy(LazyThreadSafetyMode.NONE, initializer) @@ -18,9 +16,3 @@ inline fun ifLet(vararg elements: T?, closure: (List) -> Unit) { closure(elements.filterNotNull()) } } - -fun liveDataOf(initialValue: T): MutableLiveData { - return MutableLiveData().apply { - value = initialValue - } -} diff --git a/app/src/main/kotlin/com/adesso/movee/scene/login/LoginFragment.kt b/app/src/main/kotlin/com/adesso/movee/scene/login/LoginFragment.kt index a38b992..d172727 100644 --- a/app/src/main/kotlin/com/adesso/movee/scene/login/LoginFragment.kt +++ b/app/src/main/kotlin/com/adesso/movee/scene/login/LoginFragment.kt @@ -5,24 +5,24 @@ import android.net.Uri import com.adesso.movee.R import com.adesso.movee.base.BaseTransparentStatusBarFragment import com.adesso.movee.databinding.FragmentLoginBinding -import com.adesso.movee.internal.extension.observeNonNull -import com.adesso.movee.internal.util.Event +import com.adesso.movee.internal.extension.collectFlow import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class LoginFragment : BaseTransparentStatusBarFragment() { override val layoutId = R.layout.fragment_login + override fun initialize() { super.initialize() - viewModel.navigateUri.observeNonNull(viewLifecycleOwner, ::handleNavigateUriEvent) + collectFlow(viewModel.navigateUri) { + handleNavigateUri(it) + } } - private fun handleNavigateUriEvent(event: Event) { - event.getContentIfNotHandled()?.let { uri -> - val intent = Intent(Intent.ACTION_VIEW, uri) - startActivity(intent) - } + private fun handleNavigateUri(uri: Uri) { + val intent = Intent(Intent.ACTION_VIEW, uri) + startActivity(intent) } } diff --git a/app/src/main/kotlin/com/adesso/movee/scene/login/LoginViewModel.kt b/app/src/main/kotlin/com/adesso/movee/scene/login/LoginViewModel.kt index 7a6bb51..e904a33 100644 --- a/app/src/main/kotlin/com/adesso/movee/scene/login/LoginViewModel.kt +++ b/app/src/main/kotlin/com/adesso/movee/scene/login/LoginViewModel.kt @@ -2,14 +2,15 @@ package com.adesso.movee.scene.login import android.app.Application import android.net.Uri -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.adesso.movee.base.BaseAndroidViewModel import com.adesso.movee.domain.LoginUseCase -import com.adesso.movee.internal.util.Event import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.onSuccess +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @@ -20,13 +21,14 @@ class LoginViewModel @Inject constructor( application: Application ) : BaseAndroidViewModel(application) { - private val _navigateUri = MutableLiveData>() - private val _loginInProgress = MutableLiveData() - val navigateUri: LiveData> get() = _navigateUri - val loginInProgress: LiveData get() = _loginInProgress + private val _navigateUri = Channel(Channel.CONFLATED) + val navigateUri = _navigateUri.receiveAsFlow() - val username = MutableLiveData() - val password = MutableLiveData() + private val _loginInProgress = MutableStateFlow(false) + val loginInProgress: StateFlow get() = _loginInProgress + + val username = MutableStateFlow(null) + val password = MutableStateFlow(null) fun onForgotPasswordClick() { postNavigateUri(URL_FORGOT_PASSWORD) @@ -60,7 +62,7 @@ class LoginViewModel @Inject constructor( } private fun postNavigateUri(url: String) { - _navigateUri.value = Event(Uri.parse(url)) + _navigateUri.trySend(Uri.parse(url)) } companion object { diff --git a/app/src/main/kotlin/com/adesso/movee/scene/main/MainActivity.kt b/app/src/main/kotlin/com/adesso/movee/scene/main/MainActivity.kt index 6a66d03..1d0c4fa 100644 --- a/app/src/main/kotlin/com/adesso/movee/scene/main/MainActivity.kt +++ b/app/src/main/kotlin/com/adesso/movee/scene/main/MainActivity.kt @@ -12,7 +12,7 @@ import androidx.navigation.ui.setupWithNavController import com.adesso.movee.R import com.adesso.movee.base.BaseBindingActivity import com.adesso.movee.databinding.ActivityMainBinding -import com.adesso.movee.internal.extension.observeNonNull +import com.adesso.movee.internal.extension.collectFlow import com.adesso.movee.internal.extension.showPopup import com.adesso.movee.navigation.NavigationCommand import dagger.hilt.android.AndroidEntryPoint @@ -41,10 +41,10 @@ class MainActivity : BaseBindingActivity() { } private fun observeNavigation() { - viewModel.navigation.observeNonNull(this) { - it.getContentIfNotHandled()?.let { command -> - handleNavigation(command) - } + + collectFlow(viewModel.navigation) { command -> + + handleNavigation(command) } } diff --git a/app/src/main/kotlin/com/adesso/movee/scene/movie/MovieViewModel.kt b/app/src/main/kotlin/com/adesso/movee/scene/movie/MovieViewModel.kt index 418c320..808e39c 100644 --- a/app/src/main/kotlin/com/adesso/movee/scene/movie/MovieViewModel.kt +++ b/app/src/main/kotlin/com/adesso/movee/scene/movie/MovieViewModel.kt @@ -2,7 +2,6 @@ package com.adesso.movee.scene.movie import android.app.Application import androidx.annotation.StringRes -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn @@ -15,7 +14,6 @@ import com.adesso.movee.internal.util.AppBarStateChangeListener import com.adesso.movee.internal.util.AppBarStateChangeListener.State.COLLAPSED import com.adesso.movee.internal.util.AppBarStateChangeListener.State.EXPANDED import com.adesso.movee.internal.util.AppBarStateChangeListener.State.IDLE -import com.adesso.movee.internal.util.TripleCombinedLiveData import com.adesso.movee.internal.util.UseCase import com.adesso.movee.uimodel.MovieUiModel import com.adesso.movee.uimodel.ShowHeaderUiModel @@ -26,7 +24,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @HiltViewModel @@ -38,11 +39,12 @@ class MovieViewModel @Inject constructor( ) : BaseAndroidViewModel(application) { private val _popularMovies = MutableStateFlow>(PagingData.empty()) - private val _toolbarTitle = MutableLiveData() - private val _toolbarSubtitle = MutableLiveData(getString(R.string.movie_message_popular)) - private val _nowPlayingMovies = MutableLiveData>() + private val _toolbarTitle = MutableStateFlow(null) + private val _toolbarSubtitle = MutableStateFlow(getString(R.string.movie_message_popular)) + private val _nowPlayingMovies = MutableStateFlow?>(null) val popularMovies = _popularMovies.asStateFlow() - val showHeader = TripleCombinedLiveData( + + val showHeader = combine( _toolbarTitle, _toolbarSubtitle, _nowPlayingMovies @@ -52,7 +54,8 @@ class MovieViewModel @Inject constructor( subtitle, nowPlayingShows ) - } + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + val shouldRefreshPaging = shouldRefreshPagingUseCase.execute() diff --git a/app/src/main/kotlin/com/adesso/movee/scene/moviedetail/MovieDetailViewModel.kt b/app/src/main/kotlin/com/adesso/movee/scene/moviedetail/MovieDetailViewModel.kt index d309bf4..2d1ded9 100644 --- a/app/src/main/kotlin/com/adesso/movee/scene/moviedetail/MovieDetailViewModel.kt +++ b/app/src/main/kotlin/com/adesso/movee/scene/moviedetail/MovieDetailViewModel.kt @@ -1,8 +1,6 @@ package com.adesso.movee.scene.moviedetail import android.app.Application -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.adesso.movee.base.BaseAndroidViewModel import com.adesso.movee.domain.FetchMovieCreditsUseCase @@ -12,6 +10,8 @@ import com.adesso.movee.uimodel.MovieCreditUiModel import com.adesso.movee.uimodel.MovieDetailUiModel import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.onSuccess +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @@ -23,10 +23,10 @@ class MovieDetailViewModel @Inject constructor( application: Application ) : BaseAndroidViewModel(application) { - private val _movieDetails = MutableLiveData() - private val _movieCredits = MutableLiveData() - val movieDetails: LiveData get() = _movieDetails - val movieCredits: LiveData get() = _movieCredits + private val _movieDetails = MutableStateFlow(null) + private val _movieCredits = MutableStateFlow(null) + val movieDetails: StateFlow get() = _movieDetails + val movieCredits: StateFlow get() = _movieCredits fun fetchMovieDetails(id: Long) { if (_movieDetails.value == null) { diff --git a/app/src/main/kotlin/com/adesso/movee/scene/persondetail/PersonDetailViewModel.kt b/app/src/main/kotlin/com/adesso/movee/scene/persondetail/PersonDetailViewModel.kt index 03c582b..69fc3bf 100644 --- a/app/src/main/kotlin/com/adesso/movee/scene/persondetail/PersonDetailViewModel.kt +++ b/app/src/main/kotlin/com/adesso/movee/scene/persondetail/PersonDetailViewModel.kt @@ -1,8 +1,6 @@ package com.adesso.movee.scene.persondetail import android.app.Application -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.adesso.movee.base.BaseAndroidViewModel import com.adesso.movee.domain.FetchPersonDetailsUseCase @@ -13,6 +11,8 @@ import com.adesso.movee.internal.util.AppBarStateChangeListener.State.IDLE import com.adesso.movee.uimodel.PersonDetailUiModel import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.onSuccess +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @@ -23,10 +23,10 @@ class PersonDetailViewModel @Inject constructor( application: Application ) : BaseAndroidViewModel(application) { - private val _personDetails = MutableLiveData() - private val _profileToolbarTitle = MutableLiveData() - val personDetails: LiveData get() = _personDetails - val profileToolbarTitle: LiveData get() = _profileToolbarTitle + private val _personDetails = MutableStateFlow(null) + private val _profileToolbarTitle = MutableStateFlow(null) + val personDetails: StateFlow get() = _personDetails + val profileToolbarTitle: StateFlow get() = _profileToolbarTitle fun fetchPersonDetails(personId: Long) { if (_personDetails.value == null) { diff --git a/app/src/main/kotlin/com/adesso/movee/scene/profile/ProfileViewModel.kt b/app/src/main/kotlin/com/adesso/movee/scene/profile/ProfileViewModel.kt index 9b571ef..3846e70 100644 --- a/app/src/main/kotlin/com/adesso/movee/scene/profile/ProfileViewModel.kt +++ b/app/src/main/kotlin/com/adesso/movee/scene/profile/ProfileViewModel.kt @@ -1,10 +1,10 @@ package com.adesso.movee.scene.profile import android.app.Application +import androidx.lifecycle.viewModelScope import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Transformations -import androidx.lifecycle.viewModelScope import com.adesso.movee.base.BaseAndroidViewModel import com.adesso.movee.domain.FetchUserDetailsUseCase import com.adesso.movee.domain.GetLoginStateUseCase @@ -13,24 +13,36 @@ import com.adesso.movee.uimodel.LoginState import com.adesso.movee.uimodel.UserDetailUiModel import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.onSuccess +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel +@OptIn(ExperimentalCoroutinesApi::class) class ProfileViewModel @Inject constructor( private val fetchUserDetailsUseCase: FetchUserDetailsUseCase, private val getLoginStateUseCase: GetLoginStateUseCase, application: Application ) : BaseAndroidViewModel(application) { - private val _userDetails = MutableLiveData() - val userDetails: LiveData get() = _userDetails - private val _loginState = MutableLiveData() - val shouldShowUserDetails: LiveData = Transformations.map(_userDetails) { it != null } - val shouldShowLoginView: LiveData = Transformations.map(_loginState) { loginState -> - loginState == LoginState.LOGGED_IN - } + private val _userDetails = MutableStateFlow(null) + val userDetails: StateFlow get() = _userDetails + + private val _loginState = MutableStateFlow(null) + + val shouldShowUserDetails: StateFlow = + _userDetails.mapLatest { it != null } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val shouldShowLoginView: StateFlow = + _loginState.mapLatest { it == LoginState.LOGGED_OUT } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) init { getLoginState() diff --git a/app/src/main/kotlin/com/adesso/movee/scene/search/SearchViewModel.kt b/app/src/main/kotlin/com/adesso/movee/scene/search/SearchViewModel.kt index b6b3aa8..e0c9667 100644 --- a/app/src/main/kotlin/com/adesso/movee/scene/search/SearchViewModel.kt +++ b/app/src/main/kotlin/com/adesso/movee/scene/search/SearchViewModel.kt @@ -1,8 +1,6 @@ package com.adesso.movee.scene.search import android.app.Application -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.adesso.movee.base.BaseAndroidViewModel import com.adesso.movee.domain.MultiSearchUseCase @@ -16,6 +14,8 @@ import com.github.michaelbull.result.onSuccess import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import javax.inject.Inject @@ -25,10 +25,12 @@ class SearchViewModel @Inject constructor( application: Application ) : BaseAndroidViewModel(application) { - private val _multiSearchResults = MutableLiveData>() - private val _shouldShowEmptyResultView = MutableLiveData() - val multiSearchResults: LiveData> = _multiSearchResults - val shouldShowEmptyResultView: LiveData get() = _shouldShowEmptyResultView + private val _multiSearchResults = MutableStateFlow?>(null) + val multiSearchResults: StateFlow?> = _multiSearchResults + + private val _shouldShowEmptyResultView = MutableStateFlow(false) + val shouldShowEmptyResultView: StateFlow get() = _shouldShowEmptyResultView + private var multiSearchJob: Job? = null val searchDebounce = DURATION_MS_INPUT_TIMEOUT diff --git a/app/src/main/kotlin/com/adesso/movee/scene/tvshow/TvShowViewModel.kt b/app/src/main/kotlin/com/adesso/movee/scene/tvshow/TvShowViewModel.kt index e5ea1f7..76a0232 100644 --- a/app/src/main/kotlin/com/adesso/movee/scene/tvshow/TvShowViewModel.kt +++ b/app/src/main/kotlin/com/adesso/movee/scene/tvshow/TvShowViewModel.kt @@ -1,8 +1,6 @@ package com.adesso.movee.scene.tvshow import android.app.Application -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.adesso.movee.R import com.adesso.movee.base.BaseAndroidViewModel @@ -12,7 +10,6 @@ import com.adesso.movee.internal.util.AppBarStateChangeListener import com.adesso.movee.internal.util.AppBarStateChangeListener.State.COLLAPSED import com.adesso.movee.internal.util.AppBarStateChangeListener.State.EXPANDED import com.adesso.movee.internal.util.AppBarStateChangeListener.State.IDLE -import com.adesso.movee.internal.util.TripleCombinedLiveData import com.adesso.movee.internal.util.UseCase import com.adesso.movee.uimodel.ShowHeaderUiModel import com.adesso.movee.uimodel.ShowUiModel @@ -20,6 +17,11 @@ import com.adesso.movee.uimodel.TvShowUiModel import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.onSuccess import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -30,22 +32,22 @@ class TvShowViewModel @Inject constructor( application: Application ) : BaseAndroidViewModel(application) { - private val _topRatedTvShows = MutableLiveData>() - private val _toolbarTitle = MutableLiveData() - private val _toolbarSubtitle = MutableLiveData(getString(R.string.tv_show_message_top_rated)) - private val _nowPlayingTvShows = MutableLiveData>() - val topRatedTvShows: LiveData> get() = _topRatedTvShows - val showHeader = TripleCombinedLiveData( + private val _topRatedTvShows = MutableStateFlow?>(null) + private val _toolbarTitle = MutableStateFlow(null) + private val _toolbarSubtitle = MutableStateFlow(getString(R.string.tv_show_message_top_rated)) + private val _nowPlayingTvShows = MutableStateFlow?>(null) + val topRatedTvShows: StateFlow?> get() = _topRatedTvShows + val showHeader = combine( _toolbarTitle, _toolbarSubtitle, _nowPlayingTvShows - ) { title, subtitle, nowPlayingShows -> + ) { title, subtitle, nowPlayingTvShows -> ShowHeaderUiModel( - title, - subtitle, - nowPlayingShows + title = title, + subtitle = subtitle, + nowPlayingShows = nowPlayingTvShows ) - } + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) init { fetchTopRatedTvShows() diff --git a/app/src/main/kotlin/com/adesso/movee/scene/tvshowdetail/TvShowDetailViewModel.kt b/app/src/main/kotlin/com/adesso/movee/scene/tvshowdetail/TvShowDetailViewModel.kt index 0f4952f..1d40bd4 100644 --- a/app/src/main/kotlin/com/adesso/movee/scene/tvshowdetail/TvShowDetailViewModel.kt +++ b/app/src/main/kotlin/com/adesso/movee/scene/tvshowdetail/TvShowDetailViewModel.kt @@ -1,9 +1,6 @@ package com.adesso.movee.scene.tvshowdetail import android.app.Application -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Transformations import androidx.lifecycle.viewModelScope import com.adesso.movee.base.BaseAndroidViewModel import com.adesso.movee.domain.FetchTvShowCreditsUseCase @@ -13,6 +10,11 @@ import com.adesso.movee.uimodel.TvShowCreditUiModel import com.adesso.movee.uimodel.TvShowDetailUiModel import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.onSuccess +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @@ -24,13 +26,11 @@ class TvShowDetailViewModel @Inject constructor( application: Application ) : BaseAndroidViewModel(application) { - private val _tvShowDetails = MutableLiveData() - val tvShowDetails: LiveData get() = _tvShowDetails - private val _tvShowCredits = MutableLiveData() - val tvShowCasts: LiveData> = - Transformations.map(_tvShowCredits) { tvShowCredits -> - tvShowCredits.cast - } + private val _tvShowDetails = MutableStateFlow(null) + val tvShowDetails: StateFlow get() = _tvShowDetails + private val _tvShowCredits = MutableStateFlow(null) + val tvShowCasts: StateFlow?> = + _tvShowCredits.map { it?.cast }.stateIn(viewModelScope, SharingStarted.Eagerly, null) fun fetchTvShowDetail(id: Long) { if (_tvShowDetails.value == null) { diff --git a/app/src/main/res/layout/layout_profile_logged_in.xml b/app/src/main/res/layout/layout_profile_logged_in.xml index 816a6a2..f9855b0 100644 --- a/app/src/main/res/layout/layout_profile_logged_in.xml +++ b/app/src/main/res/layout/layout_profile_logged_in.xml @@ -13,6 +13,6 @@ + app:visibleIf="@{!viewModel.shouldShowLoginView}" /> diff --git a/app/src/main/res/layout/layout_profile_logged_out.xml b/app/src/main/res/layout/layout_profile_logged_out.xml index 8e1b28b..53cc8b8 100644 --- a/app/src/main/res/layout/layout_profile_logged_out.xml +++ b/app/src/main/res/layout/layout_profile_logged_out.xml @@ -14,7 +14,7 @@