diff --git a/build.gradle.kts b/build.gradle.kts index 9f937cc0..9e28989c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,3 @@ plugins { id("org.jetbrains.compose") version libs.versions.composePlugin.get() apply false id("com.google.devtools.ksp") version libs.versions.ksp.get() apply false } - -tasks.register("clean", Delete::class) { - delete(rootProject.buildDir) -} diff --git a/gradle.properties b/gradle.properties index 870edd9d..38b0e9ce 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,3 +14,4 @@ kotlin.mpp.androidSourceSetLayoutVersion=2 #Compose org.jetbrains.compose.experimental.uikit.enabled=true +org.jetbrains.compose.experimental.jscanvas.enabled=true diff --git a/settings.gradle.kts b/settings.gradle.kts index d771ac77..5cfccdf1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,4 +25,5 @@ rootProject.name = "puzzyx" include(":androidApp") include(":desktopApp") +include(":webApp") include(":shared") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 85bf1be0..6786bb8a 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -19,6 +19,12 @@ kotlin { } } + js(IR) { + // Adding moduleName as a workaround for this issue: https://youtrack.jetbrains.com/issue/KT-51942 + moduleName = "puzzyx-common" + browser() + } + sourceSets { val commonMain by getting { dependencies { @@ -57,4 +63,5 @@ dependencies { add("kspCommonMainMetadata", libs.appyx.mutable.ui.processor) add("kspAndroid", libs.appyx.mutable.ui.processor) add("kspDesktop", libs.appyx.mutable.ui.processor) + add("kspJs", libs.appyx.mutable.ui.processor) } diff --git a/shared/src/androidMain/kotlin/com/bumble/puzzyx/imageloader/ResourceImage.kt b/shared/src/androidMain/kotlin/com/bumble/puzzyx/imageloader/ResourceImage.kt new file mode 100644 index 00000000..89b97be0 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/bumble/puzzyx/imageloader/ResourceImage.kt @@ -0,0 +1,20 @@ +package com.bumble.puzzyx.imageloader + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale + +@Composable +actual fun EmbeddableResourceImage( + path: String, + modifier: Modifier, + contentDescription: String?, + contentScale: ContentScale +) { + ResourceImage( + path = path, + modifier = modifier, + contentScale = contentScale, + contentDescription = contentDescription, + ) +} diff --git a/shared/src/commonMain/kotlin/com/bumble/puzzyx/appyx/component/backstackclipper/ClipShapeProgress.kt b/shared/src/commonMain/kotlin/com/bumble/puzzyx/appyx/component/backstackclipper/ClipShapeProgress.kt index 7402d079..67419e1f 100644 --- a/shared/src/commonMain/kotlin/com/bumble/puzzyx/appyx/component/backstackclipper/ClipShapeProgress.kt +++ b/shared/src/commonMain/kotlin/com/bumble/puzzyx/appyx/component/backstackclipper/ClipShapeProgress.kt @@ -28,7 +28,8 @@ class ClipShapeProgress( coroutineScope: CoroutineScope, target: Target, displacement: StateFlow = MutableStateFlow(0f), - private val shape: @Composable (progress: Float) -> Shape = { RectangleShape }, + // web-target doesn't like a lambda here for some reason + private val shape: @Composable ((progress: Float) -> Shape)? = null, ) : MotionProperty( coroutineScope = coroutineScope, animatable = Animatable(target.value), @@ -47,10 +48,9 @@ class ClipShapeProgress( get() = Modifier.composed { val progress = renderValueFlow.collectAsState().value if (progress == 0f) this - else this.clip(shape.invoke(progress)) + else this.clip(shape?.invoke(progress) ?: RectangleShape) } - override suspend fun lerpTo(start: Target, end: Target, fraction: Float) { snapTo( lerpFloat( diff --git a/shared/src/commonMain/kotlin/com/bumble/puzzyx/composable/EntryCard.kt b/shared/src/commonMain/kotlin/com/bumble/puzzyx/composable/EntryCard.kt index b60ca85c..2956d60d 100644 --- a/shared/src/commonMain/kotlin/com/bumble/puzzyx/composable/EntryCard.kt +++ b/shared/src/commonMain/kotlin/com/bumble/puzzyx/composable/EntryCard.kt @@ -19,7 +19,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.bumble.puzzyx.imageloader.ResourceImage +import com.bumble.puzzyx.imageloader.EmbeddableResourceImage import com.bumble.puzzyx.model.Entry import com.bumble.puzzyx.ui.colors @@ -33,7 +33,7 @@ fun EntryCard( ) { when (entry) { is Entry.Text -> TextEntry(entry) - is Entry.Image -> ResourceImage( + is Entry.Image -> EmbeddableResourceImage( path = "participant/${entry.path}", contentDescription = entry.contentDescription, contentScale = entry.contentScale, @@ -58,7 +58,7 @@ fun GitHubHeader( verticalAlignment = Alignment.CenterVertically, modifier = modifier ) { - ResourceImage( + EmbeddableResourceImage( path = "github.png", contentScale = ContentScale.Inside, modifier = Modifier diff --git a/shared/src/commonMain/kotlin/com/bumble/puzzyx/imageloader/ResourceImage.kt b/shared/src/commonMain/kotlin/com/bumble/puzzyx/imageloader/ResourceImage.kt index 871eeb95..ff9c72eb 100644 --- a/shared/src/commonMain/kotlin/com/bumble/puzzyx/imageloader/ResourceImage.kt +++ b/shared/src/commonMain/kotlin/com/bumble/puzzyx/imageloader/ResourceImage.kt @@ -10,23 +10,45 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.layout.ContentScale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.resource +@Composable +expect fun EmbeddableResourceImage( + path: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + contentScale: ContentScale = ContentScale.Fit, +) + @OptIn(ExperimentalResourceApi::class) @Composable -fun ResourceImage( +internal fun ResourceImage( path: String, + fallbackUrl: String = path, modifier: Modifier = Modifier, contentDescription: String? = null, contentScale: ContentScale = ContentScale.Fit ) { var image: ImageBitmap? by remember { mutableStateOf(null) } LaunchedEffect(Unit) { - image = - resource(path) - .readBytes() - .toImageBitmap() + image = withContext(Dispatchers.Default) { + try { + resource(path) + .readBytes() + .toImageBitmap() + } catch (e: Throwable) { + try { + resource(fallbackUrl) + .readBytes() + .toImageBitmap() + } catch (e: Throwable) { + null + } + } + } } image?.let { Image( diff --git a/shared/src/commonMain/kotlin/com/bumble/puzzyx/node/puzzle1/Puzzle1Node.kt b/shared/src/commonMain/kotlin/com/bumble/puzzyx/node/puzzle1/Puzzle1Node.kt index 591c5b9d..d9fee9ec 100644 --- a/shared/src/commonMain/kotlin/com/bumble/puzzyx/node/puzzle1/Puzzle1Node.kt +++ b/shared/src/commonMain/kotlin/com/bumble/puzzyx/node/puzzle1/Puzzle1Node.kt @@ -38,7 +38,7 @@ import com.bumble.puzzyx.appyx.component.gridpuzzle.operation.scatter import com.bumble.puzzyx.composable.AutoPlayScript import com.bumble.puzzyx.composable.EntryCardSmall import com.bumble.puzzyx.composable.FlashCard -import com.bumble.puzzyx.imageloader.ResourceImage +import com.bumble.puzzyx.imageloader.EmbeddableResourceImage import com.bumble.puzzyx.model.Entry import com.bumble.puzzyx.model.Puzzle import com.bumble.puzzyx.model.PuzzlePiece @@ -90,7 +90,7 @@ class Puzzle1Node( FlashCard( flash = Color.White, front = { modifier -> - ResourceImage( + EmbeddableResourceImage( path = "${puzzle.imagesDir}/slice_${puzzlePiece.j}_${puzzlePiece.i}.png", contentScale = ContentScale.FillBounds, modifier = modifier diff --git a/shared/src/commonMain/kotlin/com/bumble/puzzyx/ui/DottedMeshShape.kt b/shared/src/commonMain/kotlin/com/bumble/puzzyx/ui/DottedMeshShape.kt index 97c2bac7..7da878b4 100644 --- a/shared/src/commonMain/kotlin/com/bumble/puzzyx/ui/DottedMeshShape.kt +++ b/shared/src/commonMain/kotlin/com/bumble/puzzyx/ui/DottedMeshShape.kt @@ -12,8 +12,8 @@ import androidx.compose.ui.unit.LayoutDirection import com.bumble.appyx.interactions.core.annotations.FloatRange import com.bumble.appyx.interactions.core.ui.math.lerpFloat import com.bumble.puzzyx.math.mapValueRange -import java.lang.Integer.max import kotlin.math.abs +import kotlin.math.max import kotlin.math.sqrt /** diff --git a/shared/src/desktopMain/kotlin/com/bumble/puzzyx/imageloader/EmbeddableResourceImage.kt b/shared/src/desktopMain/kotlin/com/bumble/puzzyx/imageloader/EmbeddableResourceImage.kt new file mode 100644 index 00000000..89b97be0 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/bumble/puzzyx/imageloader/EmbeddableResourceImage.kt @@ -0,0 +1,20 @@ +package com.bumble.puzzyx.imageloader + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale + +@Composable +actual fun EmbeddableResourceImage( + path: String, + modifier: Modifier, + contentDescription: String?, + contentScale: ContentScale +) { + ResourceImage( + path = path, + modifier = modifier, + contentScale = contentScale, + contentDescription = contentDescription, + ) +} diff --git a/shared/src/jsMain/kotlin/com/bumble/puzzyx/imageloader/EmbeddableResourceImage.kt b/shared/src/jsMain/kotlin/com/bumble/puzzyx/imageloader/EmbeddableResourceImage.kt new file mode 100644 index 00000000..6d0d2888 --- /dev/null +++ b/shared/src/jsMain/kotlin/com/bumble/puzzyx/imageloader/EmbeddableResourceImage.kt @@ -0,0 +1,23 @@ +package com.bumble.puzzyx.imageloader + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale + +private const val EMBED_URL = "appyx/where/hosted/sample" + +@Composable +actual fun EmbeddableResourceImage( + path: String, + modifier: Modifier, + contentDescription: String?, + contentScale: ContentScale +) { + ResourceImage( + path = EMBED_URL + path, + fallbackUrl = path, + modifier = modifier, + contentScale = contentScale, + contentDescription = contentDescription, + ) +} diff --git a/shared/src/jsMain/kotlin/com/bumble/puzzyx/imageloader/toImageBitmap.kt b/shared/src/jsMain/kotlin/com/bumble/puzzyx/imageloader/toImageBitmap.kt new file mode 100644 index 00000000..8af036f6 --- /dev/null +++ b/shared/src/jsMain/kotlin/com/bumble/puzzyx/imageloader/toImageBitmap.kt @@ -0,0 +1,8 @@ +package com.bumble.puzzyx.imageloader + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap + +actual fun ByteArray.toImageBitmap(): ImageBitmap { + return org.jetbrains.skia.Image.makeFromEncoded(this).toComposeImageBitmap() +} diff --git a/webApp/build.gradle.kts b/webApp/build.gradle.kts new file mode 100644 index 00000000..bbf6d1df --- /dev/null +++ b/webApp/build.gradle.kts @@ -0,0 +1,48 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + kotlin("multiplatform") + id("org.jetbrains.compose") +} + +kotlin { + js(IR) { + moduleName = "puzzyx-web" + browser() + binaries.executable() + } + sourceSets { + val commonMain by getting { + dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(project(":shared")) + implementation(libs.appyx.navigation) + implementation(libs.appyx.components.backstack) + } + } + } +} + +compose.experimental { + web.application {} +} + +tasks.register("copyResources") { + // Dirs containing files we want to copy + from("../shared/src/commonMain/resources") + + // Output for web resources + into("$buildDir/processedResources/js/main") + + include("**/*") +} + +tasks.named("jsBrowserProductionExecutableDistributeResources") { + dependsOn("copyResources") +} + +tasks.named("compileKotlinJs") { + dependsOn("copyResources") +} diff --git a/webApp/src/jsMain/kotlin/Main.kt b/webApp/src/jsMain/kotlin/Main.kt new file mode 100644 index 00000000..99704166 --- /dev/null +++ b/webApp/src/jsMain/kotlin/Main.kt @@ -0,0 +1,93 @@ +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Surface +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.dp +import com.bumble.appyx.navigation.integration.ScreenSize +import com.bumble.appyx.navigation.integration.WebNodeHost +import com.bumble.puzzyx.node.app.PuzzyxAppNode +import com.bumble.puzzyx.ui.PuzzyxTheme +import com.bumble.puzzyx.ui.appyx_dark +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.launch +import org.jetbrains.skiko.wasm.onWasmReady + +fun main() { + val events: Channel = Channel() + onWasmReady { + BrowserViewportWindow() { + PuzzyxTheme { + val requester = remember { FocusRequester() } + var hasFocus by remember { mutableStateOf(false) } + + var screenSize by remember { mutableStateOf(ScreenSize(0.dp, 0.dp)) } + val eventScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) } + + Surface( + modifier = Modifier + .fillMaxSize() + .onSizeChanged { screenSize = ScreenSize(it.width.dp, it.height.dp) } + .onKeyEvent { event -> + onKeyEvent(event, events, eventScope) + } + .focusRequester(requester) + .focusable() + .onFocusChanged { hasFocus = it.hasFocus }, + color = appyx_dark, + ) { + WebNodeHost( + screenSize = screenSize, + onBackPressedEvents = events.receiveAsFlow(), + ) { buildContext -> + PuzzyxAppNode( + buildContext = buildContext, + ) + } + } + + + if (!hasFocus) { + LaunchedEffect(Unit) { + requester.requestFocus() + } + } + } + } + } +} + + +@OptIn(ExperimentalComposeUiApi::class) +private fun onKeyEvent( + keyEvent: KeyEvent, + events: Channel, + coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main), +): Boolean = + when { + keyEvent.type == KeyEventType.KeyUp && keyEvent.key == Key.Backspace -> { + coroutineScope.launch { events.send(Unit) } + true + } + + else -> false + } diff --git a/webApp/src/jsMain/resources/index.html b/webApp/src/jsMain/resources/index.html new file mode 100644 index 00000000..aa9114da --- /dev/null +++ b/webApp/src/jsMain/resources/index.html @@ -0,0 +1,15 @@ + + + + + Puzzyx + + + + +
+ +
+ + + diff --git a/webApp/src/jsMain/resources/styles.css b/webApp/src/jsMain/resources/styles.css new file mode 100644 index 00000000..8655f2e7 --- /dev/null +++ b/webApp/src/jsMain/resources/styles.css @@ -0,0 +1,12 @@ +#root { + width: 100%; + height: 100vh; +} + +body { + margin: 0; +} + +#root > .compose-web-column > div { + position: relative; +}