diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c511cfb..5779813 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.net.URI + plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.jetbrainsKotlinAndroid) @@ -73,7 +75,8 @@ dependencies { implementation(libs.moshi.adapters) implementation(libs.converter.moshi.v250) implementation(libs.moshi.kotlin) - implementation (libs.zoomable) + implementation(libs.zoomable) + implementation(libs.compose.free.scroll) implementation(libs.retrofit.adapters.arrow) implementation(libs.arrow.core) diff --git a/app/src/main/java/com/iqball/app/component/BasketCourt.kt b/app/src/main/java/com/iqball/app/component/BasketCourt.kt index 263eafd..7c7ffce 100644 --- a/app/src/main/java/com/iqball/app/component/BasketCourt.kt +++ b/app/src/main/java/com/iqball/app/component/BasketCourt.kt @@ -50,7 +50,6 @@ fun BasketCourt(content: StepContent, type: CourtType) { ) for (component in components) { - when (component) { is PlayerLike -> { val info = getPlayerInfo(component, content) @@ -65,7 +64,6 @@ fun BasketCourt(content: StepContent, type: CourtType) { ) } } - } } diff --git a/app/src/main/java/com/iqball/app/component/StepTree.kt b/app/src/main/java/com/iqball/app/component/StepTree.kt new file mode 100644 index 0000000..fa294bc --- /dev/null +++ b/app/src/main/java/com/iqball/app/component/StepTree.kt @@ -0,0 +1,142 @@ +package com.iqball.app.component + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.chihsuanwu.freescroll.freeScroll +import com.chihsuanwu.freescroll.rememberFreeScrollState +import com.iqball.app.model.tactic.StepNodeInfo +import com.iqball.app.ui.theme.SelectedStepNode +import com.iqball.app.ui.theme.StepNode + +@Composable +fun StepsTree(root: StepNodeInfo, selectedNodeId: Int, onNodeSelected: (StepNodeInfo) -> Unit) { + + val scrollState = rememberFreeScrollState() + val nodesOffsets = remember { mutableStateMapOf() } + var globalOffset by remember { mutableStateOf(Offset(0F, 0F)) } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.LightGray) + .freeScroll(scrollState) + .onGloballyPositioned { globalOffset = it.boundsInRoot().topLeft } + .drawWithContent { + + if (nodesOffsets.isEmpty()) { + drawContent() + return@drawWithContent + } + + val toDraw = mutableListOf(root) + while (toDraw.isNotEmpty()) { + val parent = toDraw.removeLast() + val parentCenter = nodesOffsets[parent]!!.center.minus(globalOffset) + for (children in parent.children) { + val childrenCenter = nodesOffsets[children]!!.center.minus(globalOffset) + Log.d("STATE", "$parentCenter, $childrenCenter") + drawLine(Color.Black, start = parentCenter, end = childrenCenter, strokeWidth = 5F) + toDraw += children + } + } + + drawContent() + + }, + contentAlignment = Alignment.TopCenter + ) { + StepsTreeContent(root, selectedNodeId, onNodeSelected, nodesOffsets) + } +} + +@Composable +private fun StepsTreeContent( + node: StepNodeInfo, + selectedNodeId: Int, + onNodeSelected: (StepNodeInfo) -> Unit, + nodesOffsets: MutableMap +) { + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + ) { + StepPiece( + node = node, + isSelected = selectedNodeId == node.id, + onNodeSelected = { onNodeSelected(node) }, + modifier = Modifier + .padding(10.dp) + .onGloballyPositioned { + nodesOffsets[node] = it.boundsInRoot() + } + ) + + Row( + modifier = Modifier + .padding(top = 50.dp) + ) { + for (children in node.children) { + StepsTreeContent( + node = children, + selectedNodeId = selectedNodeId, + onNodeSelected = onNodeSelected, + nodesOffsets = nodesOffsets + ) + } + } + } +} + +@Composable +fun StepPiece( + node: StepNodeInfo, + isSelected: Boolean, + onNodeSelected: () -> Unit, + modifier: Modifier = Modifier +) { + val color = if (isSelected) SelectedStepNode else StepNode + + return Surface( + shape = CircleShape, + modifier = modifier.clickable { + onNodeSelected() + } + ) { + Text( + text = node.id.toString(), + textAlign = TextAlign.Center, + color = if (isSelected) Color.White else Color.Black, + modifier = Modifier + .background(color) + .size(PlayerPieceDiameterDp.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/domains/PlayerDomains.kt b/app/src/main/java/com/iqball/app/domains/PlayerDomains.kt index a0ca3a3..deed797 100644 --- a/app/src/main/java/com/iqball/app/domains/PlayerDomains.kt +++ b/app/src/main/java/com/iqball/app/domains/PlayerDomains.kt @@ -23,39 +23,41 @@ import com.iqball.app.model.tactic.StepContent * @throws MalformedStepContentException if the step content contains incoherent data */ fun computePhantomPosition(phantom: PhantomComponent, content: StepContent): Vector { - val pos = phantom.pos - if (pos is FixedPosition) - return pos.toPos() - - pos as RelativePositioning - - val phantomBefore = getPlayerBefore(phantom, content)!! - - val referentId = pos.attach - val actions = phantomBefore.actions - val linkAction = actions.find { it.target.isLeft(referentId::equals) } - ?: throw MalformedStepContentException("phantom ${phantom.id} is casted by ${phantom}, but there is no action between them.") - - val segments = linkAction.segments - val lastSegment = segments.last() - - val referent = content.findComponent(referentId)!! - val referentPos = computeComponentPosition(referent, content) - val directionalPos = lastSegment.controlPoint - ?: segments.elementAtOrNull(segments.size - 2) - ?.next - ?.mapLeft { computeComponentPosition(content.findComponent(it)!!, content) } - ?.merge() - ?: computeComponentPosition(phantomBefore, content) - - val axisSegment = (referentPos - directionalPos) - val segmentLength = axisSegment.norm() - val projectedVector = Vector( - x = (axisSegment.x / segmentLength) * 0.05, - y = (axisSegment.y / segmentLength) * 0.05, - ) - - return referentPos + projectedVector + return when (val pos = phantom.pos) { + is FixedPosition -> pos.toPos() + + is RelativePositioning -> { + val phantomBefore = getPlayerBefore(phantom, content)!! + + val referentId = pos.attach + val actions = phantomBefore.actions + val linkAction = actions.find { it.target.isLeft(referentId::equals) } + ?: throw MalformedStepContentException("phantom ${phantom.id} is casted by ${phantom}, but there is no action between them.") + + val segments = linkAction.segments + val lastSegment = segments.last() + + val referent = content.findComponent(referentId)!! + val referentPos = computeComponentPosition(referent, content) + val directionalPos = lastSegment.controlPoint + ?: segments.elementAtOrNull(segments.size - 2) + ?.next + ?.mapLeft { computeComponentPosition(content.findComponent(it)!!, content) } + ?.merge() + ?: computeComponentPosition(phantomBefore, content) + + val axisSegment = (referentPos - directionalPos) + val segmentLength = axisSegment.norm() + val projectedVector = Vector( + x = (axisSegment.x / segmentLength) * 0.05, + y = (axisSegment.y / segmentLength) * 0.05, + ) + + referentPos + projectedVector + } + } + + } fun computeComponentPosition(component: StepComponent, content: StepContent): Vector = diff --git a/app/src/main/java/com/iqball/app/page/RegisterPage.kt b/app/src/main/java/com/iqball/app/page/RegisterPage.kt index 501869f..9394a40 100644 --- a/app/src/main/java/com/iqball/app/page/RegisterPage.kt +++ b/app/src/main/java/com/iqball/app/page/RegisterPage.kt @@ -14,8 +14,6 @@ import kotlinx.coroutines.runBlocking @Composable fun RegisterPage(service: IQBallService) { - - var text by remember { mutableStateOf("No message !") } runBlocking { diff --git a/app/src/main/java/com/iqball/app/page/VisualizerPage.kt b/app/src/main/java/com/iqball/app/page/VisualizerPage.kt index ce7bac8..1e84849 100644 --- a/app/src/main/java/com/iqball/app/page/VisualizerPage.kt +++ b/app/src/main/java/com/iqball/app/page/VisualizerPage.kt @@ -15,6 +15,11 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -22,6 +27,7 @@ import androidx.compose.ui.platform.LocalConfiguration import arrow.core.Either import arrow.core.flatMap import com.iqball.app.component.BasketCourt +import com.iqball.app.component.StepsTree import com.iqball.app.model.TacticInfo import com.iqball.app.model.tactic.CourtType import com.iqball.app.model.tactic.StepContent @@ -53,15 +59,15 @@ fun VisualizerPage( } val screenOrientation = LocalConfiguration.current.orientation + var selectedStepId by remember { mutableIntStateOf(rootStep.id) } Column { VisualizerHeader(title = info.name) when (screenOrientation) { - Configuration.ORIENTATION_PORTRAIT -> Text( - text = "Visualizing Tactic Steps tree", - modifier = Modifier - .fillMaxSize() - .background(Color.Green) + Configuration.ORIENTATION_PORTRAIT -> StepsTree( + root = rootStep, + selectedNodeId = selectedStepId, + onNodeSelected = { selectedStepId = it.id } ) Configuration.ORIENTATION_LANDSCAPE -> BasketCourt( diff --git a/app/src/main/java/com/iqball/app/ui/theme/Color.kt b/app/src/main/java/com/iqball/app/ui/theme/Color.kt index 9f29911..17833f0 100644 --- a/app/src/main/java/com/iqball/app/ui/theme/Color.kt +++ b/app/src/main/java/com/iqball/app/ui/theme/Color.kt @@ -12,4 +12,7 @@ val Pink40 = Color(0xFF7D5260) val Allies = Color(0xFF64e4f5) val Opponents = Color(0xFFf59264) -val BallColor = Color(0XFFc5520d) \ No newline at end of file +val BallColor = Color(0XFFc5520d) + +val StepNode = Color(0xFF2AC008) +val SelectedStepNode = Color(0xFF213519) \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eb274e4..62231a2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,19 +1,20 @@ [versions] agp = "8.3.0" arrowCore = "1.2.1" +composeFreeScroll = "0.2.2" converterGson = "2.9.0" -converterMoshi = "2.4.0" +converterMoshi = "2.5.0" converterMoshiVersion = "2.5.0" kotlin = "1.9.0" -coreKtx = "1.10.1" +coreKtx = "1.12.0" junit = "4.13.2" junitVersion = "1.1.5" espressoCore = "3.5.1" kotlinxDatetime = "0.3.2" kotlinxSerializationJsonJvm = "1.6.3" -lifecycleRuntimeKtx = "2.6.1" -activityCompose = "1.7.0" -composeBom = "2023.08.00" +lifecycleRuntimeKtx = "2.7.0" +activityCompose = "1.8.2" +composeBom = "2024.02.02" moshi = "1.15.1" moshiAdapters = "1.15.1" moshiKotlin = "1.15.1" @@ -25,8 +26,7 @@ zoomable = "1.6.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrowCore" } -converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } -converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "converterMoshi" } +compose-free-scroll = { module = "com.github.chihsuanwu:compose-free-scroll", version.ref = "composeFreeScroll" } converter-moshi-v250 = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "converterMoshiVersion" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } @@ -42,7 +42,6 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } -kotlinx-serialization-json-jvm = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-jvm", version.ref = "kotlinxSerializationJsonJvm" } moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } moshi-adapters = { module = "com.squareup.moshi:moshi-adapters", version.ref = "moshiAdapters" } moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshiKotlin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index ef8f906..7071bcf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,5 @@ +import java.net.URI + pluginManagement { repositories { google { @@ -16,6 +18,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = URI.create("https://jitpack.io") } } }