Skip to content

Commit

Permalink
migrate to decompose (ios wip)
Browse files Browse the repository at this point in the history
  • Loading branch information
avan1235 committed Mar 9, 2024
1 parent 40915e4 commit cc55025
Show file tree
Hide file tree
Showing 15 changed files with 418 additions and 80 deletions.
4 changes: 4 additions & 0 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ kotlin {
androidMain.dependencies {
implementation(libs.androidx.activity.compose)

implementation(libs.kotlinx.coroutines.android)
implementation(libs.ktor.client.android)
}
commonMain.dependencies {
Expand All @@ -76,10 +77,13 @@ kotlin {

implementation(libs.decompose)
implementation(libs.decompose.extensionsComposeJetbrains)

implementation(libs.kotlinx.datetime)
}
desktopMain.dependencies {
implementation(compose.desktop.currentOs)

implementation(libs.kotlinx.coroutines.swing)
implementation(libs.ktor.client.java)
}
iosMain.dependencies {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ import ShinApp
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import com.arkivanov.decompose.defaultComponentContext
import `in`.procyk.shin.component.ShinAppComponentContext
import `in`.procyk.shin.component.ShinComponentImpl

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

val appContext = ShinAppComponentContext()
val component = ShinComponentImpl(appContext, defaultComponentContext())
setContent {
ShinApp()
ShinApp(component)
}
}
}
38 changes: 10 additions & 28 deletions composeApp/src/commonMain/kotlin/in/procyk/shin/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,30 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.*
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import `in`.procyk.shin.createHttpClient
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import `in`.procyk.shin.component.ShinComponent
import `in`.procyk.shin.ui.ShortenRequest
import `in`.procyk.shin.ui.ShortenResponse
import `in`.procyk.shin.ui.theme.ShinTheme
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.ExperimentalResourceApi
import org.jetbrains.compose.resources.Font
import shin.composeapp.generated.resources.Mansalva_Regular
import shin.composeapp.generated.resources.Res

@OptIn(ExperimentalResourceApi::class)
@Composable
fun ShinApp() {
val client = remember { createHttpClient() }
fun ShinApp(component: ShinComponent) {
ShinTheme {
val snackbarHostState = remember { SnackbarHostState() }
val snackbarHostStateScope = rememberCoroutineScope()
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
snackbarHost = { SnackbarHost(component.snackbarHostState) },
modifier = Modifier.fillMaxSize()
) {
BoxWithConstraints(
Expand All @@ -37,12 +34,11 @@ fun ShinApp() {
val isVertical = maxHeight > maxWidth
val maxWidth = maxWidth

var shortenedUrl by remember<MutableState<String?>> { mutableStateOf(null) }
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.onKeyEvent { event -> event.isEscDown.also { if (it) shortenedUrl = null } }
.onKeyEvent { event -> event.isEscDown.also { if (it) component.onShortenedUrlReset() } }
.verticalScroll(scrollState),
verticalArrangement = Arrangement.Center,
) {
Expand All @@ -61,31 +57,17 @@ fun ShinApp() {
)
Spacer(Modifier.size(32.dp))
ShortenRequest(
client = client,
component = component,
maxWidth = maxWidth,
isVertical = isVertical,
onResponse = { shortenedUrl = it },
onError = { snackbarHostStateScope.showErrorSnackbarNotification(snackbarHostState, it) },
)
ShortenResponse(shortenedUrl)
val shortenedUrl by component.shortenedUrl.subscribeAsState()
ShortenResponse(shortenedUrl.toNullable())
}
}
}
}
}

private fun CoroutineScope.showErrorSnackbarNotification(
snackbarHostState: SnackbarHostState,
message: String,
) {
launch {
snackbarHostState.showSnackbar(
message = message,
withDismissAction = true,
duration = SnackbarDuration.Short,
)
}
}

private val KeyEvent.isEscDown: Boolean
get() = key == Key.Escape && type == KeyEventType.KeyDown
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package `in`.procyk.shin.component

import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.value.Value
import com.arkivanov.essenty.lifecycle.Lifecycle
import `in`.procyk.shin.ui.util.coroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
import `in`.procyk.shin.ui.util.asValue as asValueUtil
import kotlinx.coroutines.flow.combine as coroutinesFlowCombine
import kotlinx.coroutines.flow.map as coroutinesFlowMap

interface Component {
val appContext: ShinAppComponentContext

val snackbarHostState: SnackbarHostState

fun toast(
message: String,
actionLabel: String? = null,
withDismissAction: Boolean = false,
duration: SnackbarDuration = if (actionLabel == null) SnackbarDuration.Short else SnackbarDuration.Indefinite,
)
}

abstract class AbstractComponent(
final override val appContext: ShinAppComponentContext,
componentContext: ComponentContext,
) : ComponentContext by componentContext, Component {

protected val scope: CoroutineScope = coroutineScope()

override val snackbarHostState: SnackbarHostState
get() = appContext.snackbarHostState

override fun toast(
message: String,
actionLabel: String?,
withDismissAction: Boolean,
duration: SnackbarDuration,
) {
scope.launch {
appContext.snackbarHostState.showSnackbar(message, actionLabel, withDismissAction, duration)
}
}

protected fun <T, M> StateFlow<T>.map(
coroutineScope: CoroutineScope = scope,
mapper: (value: T) -> M,
): StateFlow<M> =
coroutinesFlowMap(mapper)
.stateIn(
coroutineScope,
SharingStarted.Eagerly,
mapper(value),
)

protected fun <T1, T2, R> combine(
flow1: StateFlow<T1>,
flow2: StateFlow<T2>,
coroutineScope: CoroutineScope = scope,
transform: (T1, T2) -> R,
): StateFlow<R> =
coroutinesFlowCombine(flow1, flow2, transform)
.stateIn(
coroutineScope,
SharingStarted.Eagerly,
transform(flow1.value, flow2.value)
)

protected fun <T1, T2, T3, R> combine(
flow1: StateFlow<T1>,
flow2: StateFlow<T2>,
flow3: StateFlow<T3>,
coroutineScope: CoroutineScope = scope,
transform: (T1, T2, T3) -> R,
): StateFlow<R> =
coroutinesFlowCombine(flow1, flow2, flow3, transform)
.stateIn(
coroutineScope,
SharingStarted.Eagerly,
transform(flow1.value, flow2.value, flow3.value)
)

protected fun <T1, T2, T3, T4, R> combine(
flow1: StateFlow<T1>,
flow2: StateFlow<T2>,
flow3: StateFlow<T3>,
flow4: StateFlow<T4>,
coroutineScope: CoroutineScope = scope,
transform: (T1, T2, T3, T4) -> R,
): StateFlow<R> =
coroutinesFlowCombine(flow1, flow2, flow3, flow4, transform)
.stateIn(
coroutineScope,
SharingStarted.Eagerly,
transform(flow1.value, flow2.value, flow3.value, flow4.value)
)

protected fun <T : Any> StateFlow<T>.asValue(
lifecycle: Lifecycle = [email protected],
context: CoroutineContext = Dispatchers.Main.immediate,
): Value<T> = asValueUtil(lifecycle, context)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package `in`.procyk.shin.component

import androidx.compose.material3.SnackbarHostState

class ShinAppComponentContext {
val snackbarHostState: SnackbarHostState = SnackbarHostState()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package `in`.procyk.shin.component

import Option
import Option.Some
import Shorten
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.value.Value
import `in`.procyk.shin.createHttpClient
import `in`.procyk.shin.model.ShortenedProtocol
import io.ktor.client.*
import io.ktor.client.plugins.resources.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.datetime.Instant

interface ShinComponent : Component {

val expirationDateTime: Value<Option<Instant>>

val url: Value<String>

val shortenedUrl: Value<Option<String>>

val protocol: Value<ShortenedProtocol>

fun onExpirationDateTimeChange(expirationDateTime: Instant?)

fun onUrlChange(url: String)

fun onProtocolChange(protocol: ShortenedProtocol)

fun onShortenedUrlReset()

fun onShorten()
}

class ShinComponentImpl(
appContext: ShinAppComponentContext,
componentContext: ComponentContext,
) : AbstractComponent(appContext, componentContext), ShinComponent {

private val httpClient: HttpClient = createHttpClient()

private val _expirationDateTime = MutableStateFlow<Option<Instant>>(Option.None)
override val expirationDateTime: Value<Option<Instant>> = _expirationDateTime.asValue()

private val _url = MutableStateFlow("")
override val url: Value<String> = _url.asValue()

private val _shortenedUrl = MutableStateFlow<Option<String>>(Option.None)
override val shortenedUrl: Value<Option<String>> = _shortenedUrl.asValue()

private val _protocol = MutableStateFlow(ShortenedProtocol.HTTPS)
override val protocol: Value<ShortenedProtocol> = _protocol.asValue()

override fun onExpirationDateTimeChange(expirationDateTime: Instant?) {
val updatedValue = Option.fromNullable(expirationDateTime)
_expirationDateTime.update { updatedValue }
}

override fun onUrlChange(url: String) {
val (updatedUrl, updatedProtocol) = ShortenedProtocol.simplifyInputUrl(url)
updatedProtocol?.let { protocol -> _protocol.update { protocol } }
_url.update { updatedUrl }
}

override fun onProtocolChange(protocol: ShortenedProtocol) {
_protocol.update { protocol }
}

override fun onShortenedUrlReset() {
_shortenedUrl.update { Option.None }
}

override fun onShorten() {
scope.launch {
httpClient.requestShortenedUrl(
url = _url.value,
shortenedProtocol = _protocol.value,
onResponse = { response ->
val some = Some(response)
_shortenedUrl.update { some }
},
onError = { toast(it) }
)
}
}
}

private suspend fun HttpClient.requestShortenedUrl(
url: String,
shortenedProtocol: ShortenedProtocol,
onResponse: (String) -> Unit,
onError: (String) -> Unit,
) {
try {
post<Shorten>(Shorten(shortenedProtocol.buildUrl(url)))
.takeIf { it.status == HttpStatusCode.OK }
?.bodyAsText()
?.let(onResponse)
} catch (_: Exception) {
onError("Cannot connect to Shin. Try again later…")
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package `in`.procyk.shin.model

internal enum class ShortenedProtocol(val presentableName: String) {
enum class ShortenedProtocol(val presentableName: String) {
HTTPS("https"),
HTTP("http"),
;
Expand Down
Loading

0 comments on commit cc55025

Please sign in to comment.