Skip to content

Commit

Permalink
Create a separate runtime library for one-time events in a Channel in…
Browse files Browse the repository at this point in the history
…stead of Actionable in the UiState
  • Loading branch information
galex committed May 19, 2024
1 parent aabb4e0 commit 2f101e5
Show file tree
Hide file tree
Showing 24 changed files with 925 additions and 0 deletions.
106 changes: 106 additions & 0 deletions runtime-channel/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.kover)
id("maven-publish")
}

//group = "dev.galex.yamvil"
//version = "0.0.3"

kotlin {
androidTarget {
compilations.all {
@Suppress("DEPRECATION")
kotlinOptions {
jvmTarget = "1.8"
}
}
publishLibraryVariants("release", "debug")
}

/*val xcf = XCFramework()
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach {
it.binaries.framework {
baseName = "mvi"
xcf.add(this)
isStatic = true
}
}*/

listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach {
it.binaries.framework {
baseName = "yamvil"
}
}

sourceSets {
commonMain.dependencies {
api(libs.lifecyle.viewmodel.compose)
api(projects.runtimeCommon)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
}
}

android {
namespace = "dev.galex.yamvil"
compileSdk = 34
defaultConfig {
minSdk = 24
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}

@Suppress("UnstableApiUsage")
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}

dependencies {
api(libs.androidx.fragment.ktx)
testImplementation(libs.robolectric)
testImplementation(libs.truth)
testDebugImplementation(libs.androidx.fragment.testing)
}
}

kover {
reports {
filters {
excludes {
classes(
"dev.galex.yamvil.fragments.simple.*",
"dev.galex.yamvil.models.simple.*",
"dev.galex.yamvil.viewmodels.SimpleMVIViewModel",
)
}
}
}
}

//afterEvaluate {
// publishing {
// publications.configureEach {
// if (this is MavenPublication) {
// groupId = project.group.toString()
// artifactId = artifactId.replace(project.name, "runtime")
// version = project.version.toString()
// }
// }
// }
//}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package dev.galex.yamvil.extensions

import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch

fun <UiAction> Fragment.observeActionFlow(
channel: Flow<UiAction>,
observeUiAction: (UiAction) -> Unit,
) {
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main.immediate) {
repeatOnLifecycle(androidx.lifecycle.Lifecycle.State.STARTED) {
channel.collect { action: UiAction ->
observeUiAction(action)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package dev.galex.yamvil.fragments.base

import android.os.Bundle
import android.view.View
import androidx.annotation.LayoutRes
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import dev.galex.yamvil.extensions.observeActionFlow
import dev.galex.yamvil.extensions.observeStateFlow

/**
* Base class for Fragments that use MVI architecture.
* @param UiState The UiState class that the ViewModel uses.
* @param UiEvent The Event class that the ViewModel uses.
* @param UiAction The Action class that the ViewModel uses.
*/
abstract class MVIChannelDialogFragment<UiState, UiEvent, UiAction>(
@LayoutRes contentLayoutId: Int = 0,
) : DialogFragment(contentLayoutId), MVIChannelFragmentInterface<UiState, UiEvent, UiAction> {

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
observeStateFlow(this.viewModel.uiState, ::observeUiState)
observeActionFlow(this.viewModel.uiAction, ::consumeAction)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package dev.galex.yamvil.fragments.base

import android.os.Bundle
import android.view.View
import androidx.annotation.LayoutRes
import androidx.fragment.app.Fragment
import dev.galex.yamvil.extensions.observeActionFlow
import dev.galex.yamvil.extensions.observeStateFlow

/**
* Base class for Fragments that use MVI architecture.
* @param UiState The UiState class that the ViewModel uses.
* @param UiEvent The Event class that the ViewModel uses.
* @param UiAction The Action class that the ViewModel uses.
*/
abstract class MVIChannelFragment<UiState, UiEvent, UiAction>(
@LayoutRes contentLayoutId: Int = 0,
) : Fragment(contentLayoutId), MVIChannelFragmentInterface<UiState, UiEvent, UiAction> {

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
observeStateFlow(this.viewModel.uiState, ::observeUiState)
observeActionFlow(this.viewModel.uiAction, ::consumeAction)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package dev.galex.yamvil.fragments.base

import androidx.annotation.VisibleForTesting
import dev.galex.yamvil.viewmodels.MVIChannelViewModel

interface MVIChannelFragmentInterface<UiState, UiEvent, UiAction> {

@VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
val viewModel: MVIChannelViewModel<UiState, UiEvent, UiAction>

@VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
fun UiEvent.send() = viewModel.handleEvent(this)

fun observeUiState(state: UiState)

fun consumeAction(action: UiAction)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package dev.galex.yamvil.fragments.simple

import androidx.fragment.app.viewModels
import dev.galex.yamvil.fragments.base.MVIChannelDialogFragment
import dev.galex.yamvil.models.simple.SimpleUiAction
import dev.galex.yamvil.models.simple.SimpleUiEvent
import dev.galex.yamvil.models.simple.SimpleUiState
import dev.galex.yamvil.viewmodels.SimpleMVIChannelViewModel

/**
* A simple MVI dialog fragment.
*/
class SimpleMVIChannelDialogFragment: MVIChannelDialogFragment<SimpleUiState, SimpleUiEvent, SimpleUiAction>() {

override val viewModel: SimpleMVIChannelViewModel by viewModels()

override fun observeUiState(state: SimpleUiState) { /* NO-OP */ }

override fun consumeAction(action: SimpleUiAction) { /* NO-OP */ }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package dev.galex.yamvil.fragments.simple

import androidx.fragment.app.viewModels
import dev.galex.yamvil.fragments.base.MVIChannelFragment
import dev.galex.yamvil.models.simple.SimpleUiAction
import dev.galex.yamvil.models.simple.SimpleUiEvent
import dev.galex.yamvil.models.simple.SimpleUiState
import dev.galex.yamvil.viewmodels.SimpleMVIChannelViewModel

/**
* A simple MVI fragment.
*/
class SimpleMVIChannelFragment: MVIChannelFragment<SimpleUiState, SimpleUiEvent, SimpleUiAction>() {

override val viewModel: SimpleMVIChannelViewModel by viewModels()

override fun observeUiState(state: SimpleUiState) { /* NO-OP */ }

override fun consumeAction(action: SimpleUiAction) { /* NO-OP */ }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package dev.galex.yamvil.models.base


import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class ConsumableTest {

@Test
fun `Consumable - Not consumed`() {
val consumable = Consumable("Hello World")
assertTrue(consumable.consumed.not())
}

@Test(expected = IllegalStateException::class)
fun `Consumable - Consumed`() {
val consumable = Consumable("Hello World")
assertTrue(consumable.consumed.not())
val consumed = consumable.consume()
assertEquals(consumed, "Hello World")
assertTrue(consumable.consumed)
consumable.consume()
}

@Test
fun `Consumable - Peeked`() {
val consumable = Consumable("Hello World")
assertTrue(consumable.consumed.not())
consumable.consume()
assertTrue(consumable.consumed)
val peeked = consumable.peek()
assertTrue(peeked == "Hello World")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package dev.galex.yamvil.viewmodels.models

import dev.galex.yamvil.models.base.Consumable
import dev.galex.yamvil.viewmodels.MVIViewModel

class TestedViewModel: MVIViewModel<TestedUiState, TestedUiEvent>() {

override fun initializeUiState() = TestedUiState()

override fun handleEvent(event: TestedUiEvent) {
when (event) {
is TestedUiEvent.FirstEvent -> update { copy(firstEventTriggered = true) }
is TestedUiEvent.SecondEvent -> update { copy(secondEventTriggered = true) }
is TestedUiEvent.ThirdEvent -> update { copy(action = Consumable(TestedUiAction.FirstAction)) }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package dev.galex.yamvil.viewmodels.models

sealed interface TestedUiAction {
object FirstAction: TestedUiAction
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package dev.galex.yamvil.viewmodels.models

sealed interface TestedUiEvent {
object FirstEvent: TestedUiEvent
object SecondEvent: TestedUiEvent
object ThirdEvent: TestedUiEvent
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package dev.galex.yamvil.viewmodels.models

import dev.galex.yamvil.models.base.Actionable
import dev.galex.yamvil.models.base.Consumable

data class TestedUiState(
override val action: Consumable<TestedUiAction>? = null,
var firstEventTriggered: Boolean = false,
var secondEventTriggered: Boolean = false,
): Actionable<TestedUiAction>
Loading

0 comments on commit 2f101e5

Please sign in to comment.