Skip to content

Commit

Permalink
feat: compose multiplatform support
Browse files Browse the repository at this point in the history
  • Loading branch information
programadorthi committed Dec 20, 2023
1 parent 76ce6d4 commit d29335a
Show file tree
Hide file tree
Showing 14 changed files with 368 additions and 5 deletions.
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
plugins {
alias(libs.plugins.jetbrains.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.ktlint) apply false
}
1 change: 0 additions & 1 deletion buildSrc/src/main/kotlin/NativeUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ fun Project.iosTargets(): List<String> = fastOr {
listOf(
iosX64(),
iosArm64(),
iosArm32(),
iosSimulatorArm64(),
).map { it.name }
}
Expand Down
1 change: 1 addition & 0 deletions compose/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
build
20 changes: 20 additions & 0 deletions compose/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
plugins {
kotlin("multiplatform")
alias(libs.plugins.jetbrains.compose)
id("org.jlleitschuh.gradle.ktlint")
id("org.jetbrains.kotlinx.kover")
alias(libs.plugins.maven.publish)
}

applyBasicSetup()

kotlin {
sourceSets {
commonMain {
dependencies {
api(projects.core)
implementation(libs.compose.runtime)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package dev.programadorthi.routing.compose

import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import dev.programadorthi.routing.core.application.Application
import dev.programadorthi.routing.core.application.ApplicationCall
import io.ktor.util.AttributeKey

public typealias Content = @Composable () -> Unit

private val ComposeRoutingAttributeKey: AttributeKey<MutableState<Content>> =
AttributeKey("ComposeRoutingAttributeKey")

internal var ApplicationCall.content: Content
get() = application.contentState.value
set(value) {
application.contentState.value = value
}

internal var Application.contentState: MutableState<Content>
get() = attributes[ComposeRoutingAttributeKey]
set(value) {
attributes.put(ComposeRoutingAttributeKey, value)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package dev.programadorthi.routing.compose

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import dev.programadorthi.routing.core.Routing
import dev.programadorthi.routing.core.application

@Composable
public fun Routing(
routing: Routing,
initial: Content,
) {
val composable by remember(routing) {
mutableStateOf(initial).also {
routing.application.contentState = it
}
}

composable()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package dev.programadorthi.routing.compose

import androidx.compose.runtime.Composable
import dev.programadorthi.routing.core.Route
import dev.programadorthi.routing.core.RouteMethod
import dev.programadorthi.routing.core.application.ApplicationCall
import dev.programadorthi.routing.core.application.call
import dev.programadorthi.routing.core.route
import io.ktor.util.KtorDsl
import io.ktor.util.pipeline.PipelineContext

@KtorDsl
public fun Route.composable(
path: String,
name: String? = null,
body: @Composable PipelineContext<Unit, ApplicationCall>.() -> Unit,
): Route = route(path = path, name = name) { composable(body) }

@KtorDsl
public fun Route.composable(
path: String,
method: RouteMethod,
name: String? = null,
body: @Composable PipelineContext<Unit, ApplicationCall>.() -> Unit,
): Route = route(path = path, name = name, method = method) { composable(body) }

@KtorDsl
public fun Route.composable(
body: @Composable PipelineContext<Unit, ApplicationCall>.() -> Unit,
) {
handle {
call.content = { body(this) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package dev.programadorthi.routing.compose

import dev.programadorthi.routing.core.RouteMethod
import dev.programadorthi.routing.core.application
import dev.programadorthi.routing.core.application.ApplicationCall
import dev.programadorthi.routing.core.route
import dev.programadorthi.routing.core.routing
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceTimeBy
import kotlin.test.Test
import kotlin.test.assertEquals

@OptIn(ExperimentalCoroutinesApi::class)
internal class ComposeRoutingTest {

@Test
fun shouldInvokeInitialContentWhenThereIsNoEmittedComposable() =
runComposeTest { coroutineContext, composition, clock ->
// GIVEN
val routing = routing(parentCoroutineContext = coroutineContext) {}
val fakeContent = FakeContent()

// WHEN
composition.setContent {
Routing(
routing = routing,
initial = {
fakeContent.content = "I'm the initial content"
fakeContent.Composable()
},
)
}
clock.sendFrame(0L) // Ask for recomposition

// THEN
assertEquals("I'm the initial content", fakeContent.result)
}

@Test
fun shouldComposeByPath() =
runComposeTest { coroutineContext, composition, clock ->
// GIVEN
val fakeContent = FakeContent()

val routing = routing(parentCoroutineContext = coroutineContext) {
composable(path = "/path") {
fakeContent.content = "I'm the path based content"
fakeContent.Composable()
}
}

composition.setContent {
Routing(
routing = routing,
initial = {
fakeContent.content = "I'm the initial content"
fakeContent.Composable()
},
)
}

// WHEN
routing.execute(
ApplicationCall(
application = routing.application,
uri = "/path",
)
)
advanceTimeBy(99) // Ask for routing
clock.sendFrame(0L) // Ask for recomposition

// THEN
assertEquals("I'm the path based content", fakeContent.result)
}

@Test
fun shouldComposeByName() =
runComposeTest { coroutineContext, composition, clock ->
// GIVEN
val fakeContent = FakeContent()

val routing = routing(parentCoroutineContext = coroutineContext) {
composable(path = "/path", name = "path") {
fakeContent.content = "I'm the name based content"
fakeContent.Composable()
}
}

composition.setContent {
Routing(
routing = routing,
initial = {
fakeContent.content = "I'm the initial content"
fakeContent.Composable()
},
)
}

// WHEN
routing.execute(
ApplicationCall(
application = routing.application,
name = "path",
)
)
advanceTimeBy(99) // Ask for routing
clock.sendFrame(0L) // Ask for recomposition

// THEN
assertEquals("I'm the name based content", fakeContent.result)
}

@Test
fun shouldComposeByCustomRouteMethod() =
runComposeTest { coroutineContext, composition, clock ->
// GIVEN
val fakeContent = FakeContent()

val routing = routing(parentCoroutineContext = coroutineContext) {
composable(path = "/path", method = RouteMethod.Empty) {
fakeContent.content = "I'm the route method based content"
fakeContent.Composable()
}
}

composition.setContent {
Routing(
routing = routing,
initial = {
fakeContent.content = "I'm the initial content"
fakeContent.Composable()
},
)
}

// WHEN
routing.execute(
ApplicationCall(
application = routing.application,
uri = "/path",
routeMethod = RouteMethod.Empty,
)
)
advanceTimeBy(99) // Ask for routing
clock.sendFrame(0L) // Ask for recomposition

// THEN
assertEquals("I'm the route method based content", fakeContent.result)
}

@Test
fun shouldComposeByAnyRoute() =
runComposeTest { coroutineContext, composition, clock ->
// GIVEN
val fakeContent = FakeContent()

val routing = routing(parentCoroutineContext = coroutineContext) {
route(path = "/any") {
composable {
fakeContent.content = "I'm the generic based content"
fakeContent.Composable()
}
}
}

composition.setContent {
Routing(
routing = routing,
initial = {
fakeContent.content = "I'm the initial content"
fakeContent.Composable()
},
)
}

// WHEN
routing.execute(
ApplicationCall(
application = routing.application,
uri = "/any",
)
)
advanceTimeBy(99) // Ask for routing
clock.sendFrame(0L) // Ask for recomposition

// THEN
assertEquals("I'm the generic based content", fakeContent.result)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package dev.programadorthi.routing.compose

import androidx.compose.runtime.BroadcastFrameClock
import androidx.compose.runtime.Composition
import androidx.compose.runtime.Recomposer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import kotlin.coroutines.CoroutineContext

@OptIn(ExperimentalCoroutinesApi::class)
internal fun runComposeTest(
content: TestScope.(CoroutineContext, Composition, BroadcastFrameClock) -> Unit
) = runTest {
val job = Job()
val clock = BroadcastFrameClock()
val scope = CoroutineScope(coroutineContext + job + clock)
val recomposer = Recomposer(scope.coroutineContext)
val runner = scope.launch {
recomposer.runRecomposeAndApplyChanges()
}
val composition = Composition(TestApplier(), recomposer)
try {
content(scope.coroutineContext, composition, clock)
} finally {
runner.cancel()
recomposer.close()
job.cancel()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package dev.programadorthi.routing.compose

import androidx.compose.runtime.Composable

internal class FakeContent {
var content = ""

var result = ""
private set

@Composable
fun Composable() {
result = content
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package dev.programadorthi.routing.compose

import androidx.compose.runtime.Applier

internal class TestApplier : Applier<Unit> {
override val current: Unit
get() = Unit

override fun down(node: Unit) {}

override fun up() {}

override fun insertTopDown(index: Int, instance: Unit) {}

override fun insertBottomUp(index: Int, instance: Unit) {}

override fun remove(index: Int, count: Int) {}

override fun move(from: Int, to: Int, count: Int) {}

override fun clear() {}
}
2 changes: 2 additions & 0 deletions compose/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
POM_NAME=Compose
POM_ARTIFACT_ID=compose
Loading

0 comments on commit d29335a

Please sign in to comment.