diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6c286ef..69fd629 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -67,9 +67,14 @@ dependencies { implementation(libs.androidx.material3) implementation(libs.retrofit) - implementation(libs.converter.gson) implementation(libs.retrofit2.kotlinx.serialization.converter) implementation(libs.kotlinx.datetime) + implementation(libs.moshi) + implementation(libs.moshi.adapters) + implementation(libs.converter.moshi.v250) + implementation(libs.moshi.kotlin) + implementation(libs.zoomable) + implementation(libs.compose.free.scroll) implementation(libs.retrofit.adapters.arrow) implementation(libs.arrow.core) diff --git a/app/icons/ball.svg b/app/icons/ball.svg new file mode 100644 index 0000000..6351088 --- /dev/null +++ b/app/icons/ball.svg @@ -0,0 +1,62 @@ + + + + +Created by potrace 1.15, written by Peter Selinger 2001-2017 + + + + + + + + + + + + diff --git a/app/icons/full_court.svg b/app/icons/full_court.svg new file mode 100644 index 0000000..5bfc0de --- /dev/null +++ b/app/icons/full_court.svg @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/icons/half_court.svg b/app/icons/half_court.svg new file mode 100644 index 0000000..f621f93 --- /dev/null +++ b/app/icons/half_court.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/iqball/app/MainActivity.kt b/app/src/main/java/com/iqball/app/MainActivity.kt index 5e247fd..2bab3f4 100644 --- a/app/src/main/java/com/iqball/app/MainActivity.kt +++ b/app/src/main/java/com/iqball/app/MainActivity.kt @@ -8,40 +8,73 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import com.google.gson.Gson -import com.iqball.app.api.EitherBodyConverter -import com.iqball.app.api.EitherCallAdapterFactory -import com.iqball.app.api.service.IQBallService +import com.iqball.app.model.tactic.ActionType +import com.iqball.app.model.tactic.BallComponent +import com.iqball.app.model.tactic.BallState +import com.iqball.app.model.tactic.FixedPosition +import com.iqball.app.model.tactic.PhantomComponent +import com.iqball.app.model.tactic.PlayerComponent +import com.iqball.app.model.tactic.PlayerTeam +import com.iqball.app.model.tactic.Positioning +import com.iqball.app.model.tactic.RelativePositioning +import com.iqball.app.model.tactic.StepComponent +import com.iqball.app.net.EitherBodyConverter +import com.iqball.app.net.EitherCallAdapterFactory +import com.iqball.app.net.service.IQBallService import com.iqball.app.page.HomePage import com.iqball.app.page.LoginPage import com.iqball.app.page.RegisterPage +import com.iqball.app.serialization.EitherTypeAdapterFactory +import com.iqball.app.serialization.EnumTypeAdapterFactory import com.iqball.app.session.DataSession import com.iqball.app.session.Session import com.iqball.app.ui.theme.IQBallTheme +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import okhttp3.OkHttpClient import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.create class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val gson = Gson() + val moshi = Moshi.Builder() + .add(EitherTypeAdapterFactory) + .add( + PolymorphicJsonAdapterFactory.of(StepComponent::class.java, "type") + .withSubtype(PlayerComponent::class.java, "player") + .withSubtype(PhantomComponent::class.java, "phantom") + .withSubtype(BallComponent::class.java, "ball") + ) + .add( + PolymorphicJsonAdapterFactory.of(Positioning::class.java, "type") + .withSubtype(FixedPosition::class.java, "fixed") + .withSubtype(RelativePositioning::class.java, "follows") + ) + .add(EnumTypeAdapterFactory.create(true)) + .add(EnumTypeAdapterFactory.create(true) { + "HOLDS_ORIGIN" means BallState.HoldsOrigin + "HOLDS_BY_PASS" means BallState.HoldsByPass + "PASSED_ORIGIN" means BallState.PassedOrigin + }) + .add(EnumTypeAdapterFactory.create(true)) + .add(KotlinJsonAdapterFactory()) + .build() val retrofit = Retrofit.Builder() .addConverterFactory(EitherBodyConverter.create()) - .addConverterFactory(GsonConverterFactory.create(gson)) - .addCallAdapterFactory(EitherCallAdapterFactory.create(gson)) - .baseUrl("https://iqball.maxou.dev/api/dotnet-master/") + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .addCallAdapterFactory(EitherCallAdapterFactory.create()) + .baseUrl("http://grospc:5254/") .client( OkHttpClient.Builder() .addInterceptor { it.proceed(it.request()) } @@ -52,7 +85,7 @@ class MainActivity : ComponentActivity() { val service = retrofit.create() setContent { - IQBallTheme { + IQBallTheme(darkTheme = false) { // A surface container using the 'background' color from the theme Surface( modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/com/iqball/app/component/Actions.kt b/app/src/main/java/com/iqball/app/component/Actions.kt new file mode 100644 index 0000000..cff94ee --- /dev/null +++ b/app/src/main/java/com/iqball/app/component/Actions.kt @@ -0,0 +1,330 @@ +package com.iqball.app.component + +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate +import arrow.core.Either +import com.iqball.app.geo.Vector +import com.iqball.app.model.tactic.Action +import com.iqball.app.model.tactic.ActionType +import com.iqball.app.model.tactic.ComponentId +import com.iqball.app.model.tactic.Segment +import com.iqball.app.model.tactic.StepContent +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.sin + +private const val ArrowWidthPx = 5F +private const val ArrowHeadHeightPx = 35F + +fun drawActions( + drawer: ContentDrawScope, + content: StepContent, + offsets: Map, + area: Rect, + playersPixelsRadius: Float, + color: Color +) { + for (component in content.components) { + val originPos = offsets[component.id]!! + for (action in component.actions) { + + val type = action.type + + val forceStraight = type == ActionType.Shoot + + val strokeStyle = when (type) { + ActionType.Screen, ActionType.Move, ActionType.Dribble -> Stroke(width = ArrowWidthPx) + ActionType.Shoot -> Stroke( + width = ArrowWidthPx, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(15F, 15F)) + ) + } + + // draw the arrow body + if (forceStraight) { + val targetOffset = when (val value = action.target) { + is Either.Left -> offsets[value.value]!! + is Either.Right -> value.value.posWithinArea(area) + } + val path = Path() + val pathStart = constraintInCircle( + originPos, + getOrientationPointFromSegmentBounds( + action.segments, + originPos, + offsets, + area, + false + ), + playersPixelsRadius + ) + val pathEnd = constraintInCircle( + targetOffset, + getOrientationPointFromSegmentBounds( + action.segments, + originPos, + offsets, + area, + true + ), + playersPixelsRadius + ArrowHeadHeightPx + ) + path.moveTo(pathStart.x, pathStart.y) + path.lineTo(pathEnd.x, pathEnd.y) + drawer.drawPath( + path = path, + color = color, + style = strokeStyle + ) + } else { + drawer.drawPath( + path = computeSegmentsToPath( + originPos, + action.segments, + offsets, + area, + type == ActionType.Dribble, + playersPixelsRadius + ), + color = color, + style = strokeStyle + ) + } + + drawArrowHead(drawer, originPos, action, offsets, area, playersPixelsRadius) + } + } +} + +private fun drawArrowHead( + drawer: DrawScope, + originPos: Vector, + action: Action, + offsets: Map, + area: Rect, + playersPixelsRadius: Float +) { + val segments = action.segments + val lastSegment = segments.last() + val target = lastSegment.next + var targetPos = extractPos(target, offsets, area) + val segmentOrientationPoint = + getOrientationPointFromSegmentBounds(segments, originPos, offsets, area, true) + + targetPos = constraintInCircle(targetPos, segmentOrientationPoint, playersPixelsRadius) + + val pathAngleToTarget = -targetPos.angleDegWith(segmentOrientationPoint) + + drawer.rotate(pathAngleToTarget, pivot = targetPos.toOffset()) { + + val path = + if (action.type == ActionType.Screen) getRectangleHeadPath(targetPos) else getTriangleHeadPath( + targetPos + ) + drawPath(path = path, color = Color.Black, style = Fill) + } +} + +private fun getTriangleHeadPath(start: Vector): Path { + val path = Path() + path.moveTo(start.x, start.y) + path.relativeLineTo(-ArrowHeadHeightPx, -ArrowHeadHeightPx) + path.relativeLineTo(ArrowHeadHeightPx * 2, 0F) + return path +} + +private fun getRectangleHeadPath(start: Vector): Path { + val path = Path() + path.moveTo(start.x, start.y - ArrowHeadHeightPx / 2F) + path.relativeLineTo(-ArrowHeadHeightPx, 0F) + path.relativeLineTo(0F, -ArrowHeadHeightPx / 2F) + path.relativeLineTo(ArrowHeadHeightPx * 2, 0F) + path.relativeLineTo(0F, ArrowHeadHeightPx / 2F) + return path +} + +private fun getOrientationPointFromSegmentBounds( + segments: List, + originPos: Vector, + offsets: Map, + area: Rect, + head: Boolean +): Vector { + val boundSegment = if (head) segments.last() else segments.first() + return boundSegment + .controlPoint?.posWithinArea(area) + ?: if (segments.size == 1) + if (head) originPos else extractPos(segments.last().next, offsets, area) + else run { + val referenceSegment = if (head) segments[segments.size - 2] else segments[1] + extractPos(referenceSegment.next, offsets, area) + } +} + +private fun computeSegmentsToPath( + originPos: Vector, + segments: List, + offsets: Map, + area: Rect, + wavy: Boolean, + playersPixelsRadius: Float +): Path { + val path = Path() + + + val firstSegment = segments.first() + + var segmentStart = constraintInCircle( + originPos, + getOrientationPointFromSegmentBounds(segments, originPos, offsets, area, false), + playersPixelsRadius + ) + + var lastSegmentCp = + firstSegment.controlPoint?.posWithinArea(area) ?: (segmentStart + extractPos( + firstSegment.next, offsets, area + ) / 2F) + + path.moveTo(segmentStart.x, segmentStart.y) + + + for ((i, segment) in segments.withIndex()) { + var nextPos = extractPos(segment.next, offsets, area) + + if (i == segments.size - 1) { + // if it is the last segment, the next position must be constrained to the player's radius + nextPos = constraintInCircle( + nextPos, + getOrientationPointFromSegmentBounds( + segments, + originPos, + offsets, + area, + true + ), + playersPixelsRadius + ArrowHeadHeightPx + ) + } + + val segmentCp = segment.controlPoint?.posWithinArea(area) ?: ((segmentStart + nextPos) / 2F) + val castedCp = + if (i == 0) segmentCp else segmentStart + (segmentStart - lastSegmentCp) + + if (wavy) { + wavyBezier(segmentStart, castedCp, segmentCp, nextPos, 7, 15, path) + } else { + path.cubicTo(castedCp.x, castedCp.y, segmentCp.x, segmentCp.y, nextPos.x, nextPos.y) + } + + lastSegmentCp = segmentCp + segmentStart = nextPos + } + + return path +} + +private fun cubicBeziersDerivative( + start: Vector, + cp1: Vector, + cp2: Vector, + end: Vector, + t: Float +): Vector = ((cp1 - start) * 3F * (1 - t).pow(2)) + + ((cp2 - cp1) * 6F * (1 - t) * t) + + ((end - cp2) * 3F * t.pow(2)) + + +private fun cubicBeziers( + start: Vector, + cp1: Vector, + cp2: Vector, + end: Vector, + t: Float +): Vector = (start * (1 - t).pow(3)) + + (cp1 * 3F * t * (1 - t).pow(2)) + + (cp2 * 3F * t.pow(2) * (1 - t)) + + (end * t.pow(3)) + +private fun wavyBezier( + start: Vector, + cp1: Vector, + cp2: Vector, + end: Vector, + wavesPer100Px: Int, + amplitude: Int, + path: Path +) { + + fun getVerticalDerivativeProjectionAmplification(t: Float): Vector { + val velocity = cubicBeziersDerivative(start, cp1, cp2, end, t) + val velocityLength = velocity.norm() + val projection = Vector(velocity.y, -velocity.x) + + return (projection / velocityLength) * amplitude + } + + + val dist = start.distanceFrom(cp1) + cp1.distanceFrom(cp2) + cp2.distanceFrom(end) + + val waveLength = (dist / 100) * wavesPer100Px * 2 + val step = 1F / waveLength + + // 0 : middle to up + // 1 : up to middle + // 2 : middle to down + // 3 : down to middle + var phase = 0 + + var t = step + while (t <= 1) { + val pos = cubicBeziers(start, cp1, cp2, end, t) + val amplification = getVerticalDerivativeProjectionAmplification(t) + + val nextPos = when (phase) { + 1, 3 -> pos + 0 -> pos + amplification + else -> pos - amplification + } + + val controlPointBase = cubicBeziers(start, cp1, cp2, end, t - step / 2) + val controlPoint = + if (phase == 0 || phase == 1) controlPointBase + amplification else controlPointBase - amplification + path.quadraticBezierTo(controlPoint.x, controlPoint.y, nextPos.x, nextPos.y) + phase = (phase + 1) % 4 + t += step + if (t < 1 && t > 1 - step) t = 1F + } +} + +/** + * Given a circle shaped by a central position, and a radius, return + * a position that is constrained on its perimeter, pointing to the direction + * between the circle's center and the reference position. + * @param center circle's center. + * @param reference a reference point used to create the angle where the returned position + * will point to on the circle's perimeter + * @param radius circle's radius. + */ +private fun constraintInCircle(center: Vector, reference: Vector, radius: Float): Vector { + val theta = center.angleRadWith(reference) + return Vector( + x = center.x - sin(theta) * radius, + y = center.y - cos(theta) * radius + ) +} + +private fun extractPos( + next: Either, + offsets: Map, + area: Rect +) = when (next) { + is Either.Left -> offsets[next.value]!! + is Either.Right -> next.value.posWithinArea(area) +} \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/component/BallPiece.kt b/app/src/main/java/com/iqball/app/component/BallPiece.kt new file mode 100644 index 0000000..3d855f1 --- /dev/null +++ b/app/src/main/java/com/iqball/app/component/BallPiece.kt @@ -0,0 +1,22 @@ +package com.iqball.app.component + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.iqball.app.R +import com.iqball.app.ui.theme.BallColor + +const val BallPieceDiameterDp = 20 + +@Composable +fun BallPiece(modifier: Modifier = Modifier) { + Icon( + painter = painterResource(R.drawable.ball), + contentDescription = "ball", + tint = BallColor, + modifier = modifier.size(BallPieceDiameterDp.dp) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/component/BasketCourt.kt b/app/src/main/java/com/iqball/app/component/BasketCourt.kt new file mode 100644 index 0000000..9b16bdb --- /dev/null +++ b/app/src/main/java/com/iqball/app/component/BasketCourt.kt @@ -0,0 +1,164 @@ +package com.iqball.app.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +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.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import arrow.core.getOrNone +import com.iqball.app.R +import com.iqball.app.domains.getPlayerInfo +import com.iqball.app.geo.toVector +import com.iqball.app.model.tactic.BallComponent +import com.iqball.app.model.tactic.ComponentId +import com.iqball.app.model.tactic.CourtType +import com.iqball.app.model.tactic.PlayerLike +import com.iqball.app.model.tactic.StepContent +import net.engawapg.lib.zoomable.ZoomState +import net.engawapg.lib.zoomable.zoomable + +data class BasketCourtStates( + val stepComponentsOffsets: MutableMap, + val parentComponentsOffsets: MutableMap, + val courtArea: MutableState, + val zoomState: ZoomState +) + +@Composable +fun BasketCourt( + content: StepContent, + parentContent: StepContent?, + type: CourtType, + modifier: Modifier, + state: BasketCourtStates +) { + val courtImg = when (type) { + CourtType.Plain -> R.drawable.plain_court + CourtType.Half -> R.drawable.half_court + } + + var courtArea by state.courtArea + val zoomState = state.zoomState + + + Box( + modifier = modifier + .background(Color.LightGray) + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .width(IntrinsicSize.Min) + .zoomable(zoomState), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(id = courtImg), + contentDescription = "court", + modifier = Modifier + .background(Color.White) + .onGloballyPositioned { + if (courtArea == Rect.Zero) + courtArea = it.boundsInRoot() + } + ) + + CourtContent( + courtArea = courtArea, + content = content, + offsets = state.stepComponentsOffsets, + isFromParent = false + ) + if (parentContent != null) { + CourtContent( + courtArea = courtArea, + content = parentContent, + offsets = state.parentComponentsOffsets, + isFromParent = true + ) + } + } + } +} + +@Composable +private fun CourtContent( + courtArea: Rect, + content: StepContent, + offsets: MutableMap, + isFromParent: Boolean +) { + val playersPixelsRadius = LocalDensity.current.run { PlayerPieceDiameterDp.dp.toPx() / 2 } + + val width = LocalDensity.current.run { courtArea.width.toDp() } + val height = LocalDensity.current.run { courtArea.height.toDp() } + + Box( + modifier = Modifier + .requiredWidth(width) + .requiredHeight(height) + .drawWithContent { + val relativeOffsets = + offsets.mapValues { (it.value).toVector() } + drawActions( + this, + content, + relativeOffsets, + courtArea, + playersPixelsRadius, + if (isFromParent) Color.Gray else Color.Black + ) + drawContent() + } + ) { + + + for (component in content.components) { + val componentModifier = Modifier + .onGloballyPositioned { + if (!offsets.getOrNone(component.id).isSome { it != Offset.Zero }) + offsets[component.id] = it.boundsInParent().center + } + when (component) { + is PlayerLike -> { + val info = getPlayerInfo(component, content) + PlayerPiece( + player = info, + isFromParent = isFromParent, + modifier = componentModifier + .align(info.pos.toBiasAlignment()) + ) + } + + is BallComponent -> BallPiece( + modifier = componentModifier + .align( + component.pos + .toPos() + .toBiasAlignment() + ) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/component/PlayerPiece.kt b/app/src/main/java/com/iqball/app/component/PlayerPiece.kt new file mode 100644 index 0000000..b679a3f --- /dev/null +++ b/app/src/main/java/com/iqball/app/component/PlayerPiece.kt @@ -0,0 +1,42 @@ +package com.iqball.app.component + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +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.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.iqball.app.model.tactic.PlayerInfo +import com.iqball.app.model.tactic.PlayerTeam +import com.iqball.app.ui.theme.Allies +import com.iqball.app.ui.theme.Opponents + +const val PlayerPieceDiameterDp = 25 + +@Composable +fun PlayerPiece(player: PlayerInfo, modifier: Modifier = Modifier, isFromParent: Boolean) { + + val color = if (isFromParent) Color.LightGray else if (player.team === PlayerTeam.Allies) Allies else Opponents + + return Surface( + shape = CircleShape, + border = if (player.ballState.hasBall()) BorderStroke(2.dp, Color.Black) else null, + modifier = modifier + .alpha(if (player.isPhantom) .5F else 1F) + ) { + Text( + text = player.role, + textAlign = TextAlign.Center, + color = 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/component/StepTree.kt b/app/src/main/java/com/iqball/app/component/StepTree.kt new file mode 100644 index 0000000..c1e4d8c --- /dev/null +++ b/app/src/main/java/com/iqball/app/component/StepTree.kt @@ -0,0 +1,152 @@ +package com.iqball.app.component + +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.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.domains.getStepName +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.Zero) } + + Box( + modifier = Modifier + .fillMaxSize() + .freeScroll(scrollState) + .onGloballyPositioned { + if (globalOffset == Offset.Zero) + 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 - globalOffset + for (children in parent.children) { + val childrenCenter = nodesOffsets[children]!!.center - globalOffset + drawLine( + Color.Black, + start = parentCenter, + end = childrenCenter, + strokeWidth = 5F + ) + toDraw += children + } + } + + drawContent() + + }, + contentAlignment = Alignment.TopCenter + ) { + StepsTreeContent(root, root, selectedNodeId, onNodeSelected, nodesOffsets) + } +} + +@Composable +private fun StepsTreeContent( + root: StepNodeInfo, + node: StepNodeInfo, + selectedNodeId: Int, + onNodeSelected: (StepNodeInfo) -> Unit, + nodesOffsets: MutableMap +) { + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + ) { + StepPiece( + name = getStepName(root, node.id), + node = node, + isSelected = selectedNodeId == node.id, + onNodeSelected = { onNodeSelected(node) }, + modifier = Modifier + .padding(10.dp) + .onGloballyPositioned { + if (!nodesOffsets.containsKey(node)) + nodesOffsets[node] = it.boundsInRoot() + } + ) + + Row( + modifier = Modifier + .padding(top = 50.dp) + ) { + for (children in node.children) { + StepsTreeContent( + root = root, + node = children, + selectedNodeId = selectedNodeId, + onNodeSelected = onNodeSelected, + nodesOffsets = nodesOffsets + ) + } + } + } +} + +@Composable +fun StepPiece( + name: String, + 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 = name, + 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 new file mode 100644 index 0000000..df0fe7d --- /dev/null +++ b/app/src/main/java/com/iqball/app/domains/PlayerDomains.kt @@ -0,0 +1,112 @@ +package com.iqball.app.domains + +import arrow.core.merge +import com.iqball.app.geo.Vector +import com.iqball.app.model.tactic.BallComponent +import com.iqball.app.model.tactic.FixedPosition +import com.iqball.app.model.tactic.MalformedStepContentException +import com.iqball.app.model.tactic.PhantomComponent +import com.iqball.app.model.tactic.PlayerComponent +import com.iqball.app.model.tactic.PlayerInfo +import com.iqball.app.model.tactic.PlayerLike +import com.iqball.app.model.tactic.RelativePositioning +import com.iqball.app.model.tactic.StepComponent +import com.iqball.app.model.tactic.StepContent + +/** + * Converts the phantom's [Positioning] to a XY Position + * if the phantom is a [RelativePositioning], the XY coords are determined + * using the attached component, and by expecting that there is an action on the attached component that + * targets the given phantom. + * If so, then the position is determined by projecting the attached component's position, and the direction + * of the action's last segment. + * @throws MalformedStepContentException if the step content contains incoherent data + */ +fun computePhantomPosition(phantom: PhantomComponent, content: StepContent): Vector { + 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 = (directionalPos - referentPos) + val segmentLength = axisSegment.norm() + val projectedVector = Vector( + x = (axisSegment.x / segmentLength) * 0.05F, + y = (axisSegment.y / segmentLength) * 0.05F, + ) + + referentPos + projectedVector + } + } + + +} + +fun computeComponentPosition(component: StepComponent, content: StepContent): Vector = + when (component) { + is PhantomComponent -> computePhantomPosition(component, content) + is PlayerComponent -> component.pos.toPos() + is BallComponent -> component.pos.toPos() + } + + +fun getPlayerBefore(phantom: PhantomComponent, content: StepContent): PlayerLike? { + val origin = content.findComponent(phantom.originPlayerId)!! + val items = origin.path!!.items + val phantomIdx = items.indexOf(phantom.id) + if (phantomIdx == -1) + throw MalformedStepContentException("phantom player is not registered it its origin's path") + if (phantomIdx == 0) + return origin + return content.findComponent(items[phantomIdx - 1]) +} + +fun getPlayerInfo(player: PlayerLike, content: StepContent): PlayerInfo { + + return when (player) { + is PlayerComponent -> PlayerInfo( + player.team, + player.role, + false, + player.pos.toPos(), + player.id, + player.actions, + player.ballState + ) + + is PhantomComponent -> { + val origin = content.findComponent(player.originPlayerId)!! + val pos = computePhantomPosition(player, content) + + PlayerInfo( + origin.team, + origin.role, + true, + pos, + player.id, + player.actions, + player.ballState + ) + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/domains/StepsTreeDomains.kt b/app/src/main/java/com/iqball/app/domains/StepsTreeDomains.kt new file mode 100644 index 0000000..697df9e --- /dev/null +++ b/app/src/main/java/com/iqball/app/domains/StepsTreeDomains.kt @@ -0,0 +1,19 @@ +package com.iqball.app.domains + +import com.iqball.app.model.tactic.StepNodeInfo + + +fun getStepName(root: StepNodeInfo, step: Int): String { + var ord = 1 + val nodes = mutableListOf(root) + while (nodes.isNotEmpty()) { + val node = nodes.removeFirst() + + if (node.id == step) break + + ord += 1 + nodes.addAll(node.children.reversed()) + } + + return ord.toString() +} \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/geo/Vector.kt b/app/src/main/java/com/iqball/app/geo/Vector.kt new file mode 100644 index 0000000..9140f54 --- /dev/null +++ b/app/src/main/java/com/iqball/app/geo/Vector.kt @@ -0,0 +1,45 @@ +package com.iqball.app.geo + +import androidx.compose.ui.BiasAlignment +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import kotlin.math.atan2 +import kotlin.math.sqrt + +typealias Pos = Vector + +data class Vector(val x: Float, val y: Float) { + fun toBiasAlignment(): BiasAlignment = + BiasAlignment((x * 2 - 1), (y * 2 - 1)) + + infix operator fun minus(other: Vector) = Vector(x - other.x, y - other.y) + infix operator fun plus(other: Vector) = Vector(x + other.x, y + other.y) + infix operator fun div(other: Vector) = Vector(x / other.x, y / other.y) + infix operator fun div(n: Float) = Vector(x / n, y / n) + + infix operator fun times(other: Vector) = Vector(x * other.x, y * other.y) + infix operator fun times(n: Float) = Vector(x * n, y * n) + infix operator fun times(n: Int) = Vector(x * n, y * n) + + + fun angleRadWith(other: Vector): Float { + val (x, y) = this - other + return atan2(x, y) + } + + fun angleDegWith(other: Vector): Float = (angleRadWith(other) * (180 / Math.PI)).toFloat() + + + fun distanceFrom(other: Vector) = + sqrt(((x - other.x) * (x - other.x) + (y - other.y) * (y - other.y))) + + fun norm() = NullVector.distanceFrom(this) + + fun posWithinArea(area: Rect) = Vector(x * area.width, y * area.height) + + fun toOffset() = Offset(x, y) +} + +val NullVector = Vector(0F, 0F) + +fun Offset.toVector() = Vector(x, y) \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/model/TacticInfo.kt b/app/src/main/java/com/iqball/app/model/TacticInfo.kt new file mode 100644 index 0000000..495a648 --- /dev/null +++ b/app/src/main/java/com/iqball/app/model/TacticInfo.kt @@ -0,0 +1,11 @@ +package com.iqball.app.model + +import com.iqball.app.model.tactic.CourtType +import java.time.LocalDateTime + +data class TacticInfo( + val id: Int, + val name: String, + val type: CourtType, + val creationDate: LocalDateTime +) diff --git a/app/src/main/java/com/iqball/app/model/tactic/Action.kt b/app/src/main/java/com/iqball/app/model/tactic/Action.kt new file mode 100644 index 0000000..9a68d9e --- /dev/null +++ b/app/src/main/java/com/iqball/app/model/tactic/Action.kt @@ -0,0 +1,24 @@ +package com.iqball.app.model.tactic + +import arrow.core.Either +import arrow.core.NonEmptyList +import com.iqball.app.geo.Vector + + +enum class ActionType { + Screen, + Dribble, + Move, + Shoot +} + +data class Segment( + val next: Either, + val controlPoint: Vector? +) + +data class Action( + val type: ActionType, + val target: Either, + val segments: List +) \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/model/tactic/BallState.kt b/app/src/main/java/com/iqball/app/model/tactic/BallState.kt new file mode 100644 index 0000000..572c760 --- /dev/null +++ b/app/src/main/java/com/iqball/app/model/tactic/BallState.kt @@ -0,0 +1,13 @@ +package com.iqball.app.model.tactic + +enum class BallState { + None, + HoldsOrigin, + HoldsByPass, + Passed, + PassedOrigin; + + + fun hasBall() = this != None + +} \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/model/tactic/CourtType.kt b/app/src/main/java/com/iqball/app/model/tactic/CourtType.kt new file mode 100644 index 0000000..c74f09f --- /dev/null +++ b/app/src/main/java/com/iqball/app/model/tactic/CourtType.kt @@ -0,0 +1,6 @@ +package com.iqball.app.model.tactic + +enum class CourtType { + Plain, + Half +} \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/model/tactic/MovementPath.kt b/app/src/main/java/com/iqball/app/model/tactic/MovementPath.kt new file mode 100644 index 0000000..3d80488 --- /dev/null +++ b/app/src/main/java/com/iqball/app/model/tactic/MovementPath.kt @@ -0,0 +1,3 @@ +package com.iqball.app.model.tactic + +data class MovementPath(val items: List) \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/model/tactic/PlayerInfo.kt b/app/src/main/java/com/iqball/app/model/tactic/PlayerInfo.kt new file mode 100644 index 0000000..d717924 --- /dev/null +++ b/app/src/main/java/com/iqball/app/model/tactic/PlayerInfo.kt @@ -0,0 +1,13 @@ +package com.iqball.app.model.tactic + +import com.iqball.app.geo.Vector + +data class PlayerInfo( + val team: PlayerTeam, + val role: String, + val isPhantom: Boolean, + val pos: Vector, + val id: ComponentId, + val actions: List, + val ballState: BallState +) \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/model/tactic/PlayerTeam.kt b/app/src/main/java/com/iqball/app/model/tactic/PlayerTeam.kt new file mode 100644 index 0000000..eccb603 --- /dev/null +++ b/app/src/main/java/com/iqball/app/model/tactic/PlayerTeam.kt @@ -0,0 +1,6 @@ +package com.iqball.app.model.tactic + +enum class PlayerTeam { + Allies, + Opponents +} \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/model/tactic/Positioning.kt b/app/src/main/java/com/iqball/app/model/tactic/Positioning.kt new file mode 100644 index 0000000..ed02c62 --- /dev/null +++ b/app/src/main/java/com/iqball/app/model/tactic/Positioning.kt @@ -0,0 +1,9 @@ +package com.iqball.app.model.tactic + +import com.iqball.app.geo.Pos + +sealed interface Positioning +data class RelativePositioning(val attach: ComponentId) : Positioning +data class FixedPosition(val x: Float, val y: Float) : Positioning { + fun toPos() = Pos(x, y) +} diff --git a/app/src/main/java/com/iqball/app/model/tactic/StepComponent.kt b/app/src/main/java/com/iqball/app/model/tactic/StepComponent.kt new file mode 100644 index 0000000..7dac6be --- /dev/null +++ b/app/src/main/java/com/iqball/app/model/tactic/StepComponent.kt @@ -0,0 +1,42 @@ +package com.iqball.app.model.tactic + +typealias ComponentId = String + + +sealed interface StepComponent { + val id: ComponentId + val actions: List +} + +sealed interface PositionableComponent

{ + val pos: P +} + +sealed interface PlayerLike : PositionableComponent, StepComponent { + val ballState: BallState +} + +data class PlayerComponent( + val path: MovementPath?, + val team: PlayerTeam, + val role: String, + override val ballState: BallState, + override val pos: FixedPosition, + override val id: ComponentId, + override val actions: List, +) : PlayerLike, StepComponent + +data class PhantomComponent( + val attachedTo: ComponentId?, + val originPlayerId: ComponentId, + override val ballState: BallState, + override val pos: Positioning, + override val id: ComponentId, + override val actions: List +) : PlayerLike, StepComponent + +data class BallComponent( + override val id: ComponentId, + override val actions: List, + override val pos: FixedPosition +) : StepComponent, PositionableComponent diff --git a/app/src/main/java/com/iqball/app/model/tactic/StepContent.kt b/app/src/main/java/com/iqball/app/model/tactic/StepContent.kt new file mode 100644 index 0000000..968d251 --- /dev/null +++ b/app/src/main/java/com/iqball/app/model/tactic/StepContent.kt @@ -0,0 +1,15 @@ +package com.iqball.app.model.tactic + +import java.lang.RuntimeException + +data class StepContent(val components: List) { + inline fun findComponent(id: String): C? { + val value = components.find { it.id == id } ?: return null + if (!C::class.java.isAssignableFrom(value.javaClass)) + return null + return value as C + } +} + + +class MalformedStepContentException(msg: String, cause: Throwable? = null): RuntimeException(msg, cause) \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/model/tactic/StepNodeInfo.kt b/app/src/main/java/com/iqball/app/model/tactic/StepNodeInfo.kt new file mode 100644 index 0000000..f91931d --- /dev/null +++ b/app/src/main/java/com/iqball/app/model/tactic/StepNodeInfo.kt @@ -0,0 +1,17 @@ +package com.iqball.app.model.tactic + +data class StepNodeInfo(val id: Int, val children: List) + + +fun getParent(root: StepNodeInfo, child: Int): StepNodeInfo? { + for (children in root.children) { + if (children.id == child) { + return root + } + val result = getParent(children, child) + if (result != null) + return result + } + + return null +} \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/api/EitherBodyConverter.kt b/app/src/main/java/com/iqball/app/net/EitherBodyConverter.kt similarity index 97% rename from app/src/main/java/com/iqball/app/api/EitherBodyConverter.kt rename to app/src/main/java/com/iqball/app/net/EitherBodyConverter.kt index 27da66c..8739a96 100644 --- a/app/src/main/java/com/iqball/app/api/EitherBodyConverter.kt +++ b/app/src/main/java/com/iqball/app/net/EitherBodyConverter.kt @@ -1,4 +1,4 @@ -package com.iqball.app.api +package com.iqball.app.net import arrow.core.Either import okhttp3.ResponseBody diff --git a/app/src/main/java/com/iqball/app/api/EitherCall.kt b/app/src/main/java/com/iqball/app/net/EitherCall.kt similarity index 77% rename from app/src/main/java/com/iqball/app/api/EitherCall.kt rename to app/src/main/java/com/iqball/app/net/EitherCall.kt index 0ceacc9..eb0ce7a 100644 --- a/app/src/main/java/com/iqball/app/api/EitherCall.kt +++ b/app/src/main/java/com/iqball/app/net/EitherCall.kt @@ -1,16 +1,16 @@ -package com.iqball.app.api +package com.iqball.app.net import arrow.core.Either -import com.google.gson.Gson import okhttp3.Request import okio.Timeout import retrofit2.Call import retrofit2.Callback import retrofit2.Response +import retrofit2.Retrofit import java.lang.reflect.ParameterizedType class EitherCall( - private val gson: Gson, + private val retrofit: Retrofit, private val eitherType: ParameterizedType, private val delegate: Call ) : Call> { @@ -21,8 +21,9 @@ class EitherCall( Either.Right(response.body()!! as R) } else { val leftType = eitherType.actualTypeArguments[0] - val parsed = gson.fromJson(response.errorBody()!!.charStream(), leftType) - Either.Left(parsed) + val converter = retrofit.nextResponseBodyConverter(null, leftType, arrayOf()) + val result = converter.convert(response.errorBody()!!)!! + Either.Left(result) } callback.onResponse(this@EitherCall, Response.success(result)) } @@ -34,7 +35,7 @@ class EitherCall( }) } - override fun clone(): Call> = EitherCall(gson, eitherType, delegate.clone()) + override fun clone(): Call> = EitherCall(retrofit, eitherType, delegate.clone()) override fun execute(): Response> { throw UnsupportedOperationException() diff --git a/app/src/main/java/com/iqball/app/api/EitherCallAdapterFactory.kt b/app/src/main/java/com/iqball/app/net/EitherCallAdapterFactory.kt similarity index 70% rename from app/src/main/java/com/iqball/app/api/EitherCallAdapterFactory.kt rename to app/src/main/java/com/iqball/app/net/EitherCallAdapterFactory.kt index 7d115b4..251f2ac 100644 --- a/app/src/main/java/com/iqball/app/api/EitherCallAdapterFactory.kt +++ b/app/src/main/java/com/iqball/app/net/EitherCallAdapterFactory.kt @@ -1,17 +1,13 @@ -package com.iqball.app.api +package com.iqball.app.net -import android.os.Build -import androidx.annotation.RequiresApi import arrow.core.Either -import com.google.gson.Gson -import com.skydoves.retrofit.adapters.arrow.EitherCallAdapterFactory import retrofit2.Call import retrofit2.CallAdapter import retrofit2.Retrofit import java.lang.reflect.ParameterizedType import java.lang.reflect.Type -class EitherCallAdapterFactory(private val gson: Gson) : CallAdapter.Factory() { +class EitherCallAdapterFactory : CallAdapter.Factory() { override fun get( returnType: Type, annotations: Array, @@ -31,12 +27,12 @@ class EitherCallAdapterFactory(private val gson: Gson) : CallAdapter.Factory() { override fun responseType(): Type = returnType override fun adapt(call: Call): EitherCall { - return EitherCall(gson, eitherType, call) + return EitherCall(retrofit, eitherType, call) } } } companion object { - fun create(gson: Gson) = EitherCallAdapterFactory(gson) + fun create() = EitherCallAdapterFactory() } } \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/api/service/AuthService.kt b/app/src/main/java/com/iqball/app/net/service/AuthService.kt similarity index 79% rename from app/src/main/java/com/iqball/app/api/service/AuthService.kt rename to app/src/main/java/com/iqball/app/net/service/AuthService.kt index bb444fe..064c8b1 100644 --- a/app/src/main/java/com/iqball/app/api/service/AuthService.kt +++ b/app/src/main/java/com/iqball/app/net/service/AuthService.kt @@ -1,11 +1,7 @@ -package com.iqball.app.api.service +package com.iqball.app.net.service -import arrow.core.Either -import kotlinx.datetime.LocalDateTime import kotlinx.serialization.Serializable import retrofit2.http.Body -import retrofit2.http.GET -import retrofit2.http.Header import retrofit2.http.POST interface AuthService { diff --git a/app/src/main/java/com/iqball/app/api/service/IQBallService.kt b/app/src/main/java/com/iqball/app/net/service/IQBallService.kt similarity index 57% rename from app/src/main/java/com/iqball/app/api/service/IQBallService.kt rename to app/src/main/java/com/iqball/app/net/service/IQBallService.kt index bd73875..a80a363 100644 --- a/app/src/main/java/com/iqball/app/api/service/IQBallService.kt +++ b/app/src/main/java/com/iqball/app/net/service/IQBallService.kt @@ -1,9 +1,8 @@ -package com.iqball.app.api.service +package com.iqball.app.net.service import arrow.core.Either -import retrofit2.Call typealias ErrorResponseResult = Map> typealias APIResult = Either -interface IQBallService : AuthService, UserService \ No newline at end of file +interface IQBallService : AuthService, UserService, TacticService \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/net/service/TacticService.kt b/app/src/main/java/com/iqball/app/net/service/TacticService.kt new file mode 100644 index 0000000..b6af5df --- /dev/null +++ b/app/src/main/java/com/iqball/app/net/service/TacticService.kt @@ -0,0 +1,44 @@ +package com.iqball.app.net.service + +import com.iqball.app.model.tactic.StepContent +import com.iqball.app.model.tactic.StepNodeInfo +import com.iqball.app.session.Token +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Path + +interface TacticService { + + data class GetTacticInfoResponse( + val id: Int, + val name: String, + val courtType: String, + val creationDate: Long + ) + + @GET("tactics/{tacticId}") + suspend fun getTacticInfo( + @Header("Authorization") auth: Token, + @Path("tacticId") tacticId: Int + ): APIResult + + data class GetTacticStepsTreeResponse( + val root: StepNodeInfo + ) + + @GET("tactics/{tacticId}/tree") + suspend fun getTacticStepsTree( + @Header("Authorization") auth: Token, + @Path("tacticId") tacticId: Int + ): APIResult + + + + @GET("tactics/{tacticId}/steps/{stepId}") + suspend fun getTacticStepContent( + @Header("Authorization") auth: Token, + @Path("tacticId") tacticId: Int, + @Path("stepId") stepId: Int + ): APIResult + +} \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/api/service/UserService.kt b/app/src/main/java/com/iqball/app/net/service/UserService.kt similarity index 88% rename from app/src/main/java/com/iqball/app/api/service/UserService.kt rename to app/src/main/java/com/iqball/app/net/service/UserService.kt index f82376d..2045697 100644 --- a/app/src/main/java/com/iqball/app/api/service/UserService.kt +++ b/app/src/main/java/com/iqball/app/net/service/UserService.kt @@ -1,4 +1,4 @@ -package com.iqball.app.api.service +package com.iqball.app.net.service import retrofit2.http.GET import retrofit2.http.Header diff --git a/app/src/main/java/com/iqball/app/page/HomePage.kt b/app/src/main/java/com/iqball/app/page/HomePage.kt index 8bcb3bf..94496b5 100644 --- a/app/src/main/java/com/iqball/app/page/HomePage.kt +++ b/app/src/main/java/com/iqball/app/page/HomePage.kt @@ -2,7 +2,7 @@ package com.iqball.app.page import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import com.iqball.app.api.service.IQBallService +import com.iqball.app.net.service.IQBallService import com.iqball.app.session.Session @Composable diff --git a/app/src/main/java/com/iqball/app/page/LoginPage.kt b/app/src/main/java/com/iqball/app/page/LoginPage.kt index 9c30099..725b448 100644 --- a/app/src/main/java/com/iqball/app/page/LoginPage.kt +++ b/app/src/main/java/com/iqball/app/page/LoginPage.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import arrow.core.Either -import com.iqball.app.api.service.AuthService +import com.iqball.app.net.service.AuthService import com.iqball.app.session.Authentication import kotlinx.coroutines.runBlocking 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 898ca43..4ee3f98 100644 --- a/app/src/main/java/com/iqball/app/page/RegisterPage.kt +++ b/app/src/main/java/com/iqball/app/page/RegisterPage.kt @@ -24,7 +24,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import arrow.core.Either -import com.iqball.app.api.service.AuthService +import com.iqball.app.net.service.AuthService import com.iqball.app.session.Authentication import kotlinx.coroutines.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 new file mode 100644 index 0000000..f85787a --- /dev/null +++ b/app/src/main/java/com/iqball/app/page/VisualizerPage.kt @@ -0,0 +1,213 @@ +package com.iqball.app.page + +import android.content.res.Configuration +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.zIndex +import arrow.core.Either +import com.iqball.app.R +import com.iqball.app.component.BasketCourt +import com.iqball.app.component.BasketCourtStates +import com.iqball.app.component.StepsTree +import com.iqball.app.model.TacticInfo +import com.iqball.app.model.tactic.ComponentId +import com.iqball.app.model.tactic.CourtType +import com.iqball.app.model.tactic.StepContent +import com.iqball.app.model.tactic.StepNodeInfo +import com.iqball.app.model.tactic.getParent +import com.iqball.app.net.service.TacticService +import com.iqball.app.session.Token +import kotlinx.coroutines.runBlocking +import net.engawapg.lib.zoomable.ZoomState +import net.engawapg.lib.zoomable.rememberZoomState +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId + +private data class VisualizerInitialData( + val info: TacticInfo, + val rootStep: StepNodeInfo, +) + +@Composable +fun VisualizerPage( + service: TacticService, + auth: Token, + tacticId: Int, +) { + val dataEither = remember { initializeVisualizer(service, auth, tacticId) } + val showTree = remember { mutableStateOf(true) } + + val (info, stepsTree) = when (dataEither) { + // On error return a text to print it to the user + is Either.Left -> return Text(text = dataEither.value) + is Either.Right -> dataEither.value + } + + fun getStepContent(step: Int): StepContent = runBlocking { + val result = service.getTacticStepContent(auth, tacticId, step).onLeft { + Log.e( + "received error response from server when retrieving step content: {}", + it.toString() + ) + } + when (result) { + is Either.Left -> throw Error("Unexpected error") + is Either.Right -> result.value + } + } + + + val screenOrientation = LocalConfiguration.current.orientation + var selectedStepId by rememberSaveable { mutableIntStateOf(stepsTree.id) } + val (content, parentContent) = remember(selectedStepId) { + val parentId = getParent(stepsTree, selectedStepId)?.id + Pair( + getStepContent(selectedStepId), + parentId?.let { getStepContent(it) } + ) + } + + + Column { + VisualizerHeader(title = info.name, showTree) + when (screenOrientation) { + Configuration.ORIENTATION_PORTRAIT -> StepsTree(root = stepsTree, + selectedNodeId = selectedStepId, + onNodeSelected = { selectedStepId = it.id }) + + Configuration.ORIENTATION_LANDSCAPE -> { + val courtArea = remember { mutableStateOf(Rect.Zero) } + val stepOffsets = + remember(selectedStepId) { mutableStateMapOf() } + val parentOffsets = + remember(selectedStepId) { mutableStateMapOf() } + + val courtModifier = + if (showTree.value) Modifier.width(IntrinsicSize.Min) else Modifier.fillMaxWidth() + + + val courtZoomState = remember { ZoomState() } + + Row(modifier = Modifier.background(Color.LightGray)) { + BasketCourt( + content = content, + parentContent, + type = info.type, + modifier = courtModifier, + state = BasketCourtStates( + stepOffsets, + parentOffsets, + courtArea, + courtZoomState + ) + ) + if (showTree.value) { + StepsTree( + root = stepsTree, + selectedNodeId = selectedStepId, + onNodeSelected = { selectedStepId = it.id } + ) + } + } + } + + else -> throw Exception("Could not determine device's orientation.") + } + } + +} + +@Composable +private fun VisualizerHeader(title: String, showTree: MutableState) { + + + Row( + modifier = Modifier + .fillMaxWidth() + .zIndex(10000F) + .background(Color.White), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = { /*TODO*/ }) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = "Back", + tint = Color.Black + ) + } + + Text(text = title, color = Color.Black) + + IconButton(onClick = { showTree.value = !showTree.value }) { + Icon( + painter = painterResource(id = R.drawable.tree_icon), + contentDescription = "toggle show tree" + ) + } + } +} + +private fun initializeVisualizer( + service: TacticService, auth: Token, tacticId: Int +): Either { + val (tacticInfo, tacticTree) = runBlocking { + val tacticInfo = service.getTacticInfo(auth, tacticId).map { + TacticInfo( + id = it.id, name = it.name, type = CourtType.valueOf( + it.courtType.lowercase().replaceFirstChar(Char::uppercaseChar) + ), creationDate = LocalDateTime.ofInstant( + Instant.ofEpochMilli(it.creationDate), ZoneId.systemDefault() + ) + ) + }.onLeft { + Log.e( + "received error response from server when retrieving tacticInfo : {}", it.toString() + ) + } + + val tacticTree = service.getTacticStepsTree(auth, tacticId).map { it.root }.onLeft { + Log.e( + "received error response from server when retrieving tactic steps tree: {}", + it.toString() + ) + } + + Pair(tacticInfo.getOrNull(), tacticTree.getOrNull()) + } + + if (tacticInfo == null || tacticTree == null) { + return Either.Left("Unable to retrieve tactic information") + } + + + return Either.Right(VisualizerInitialData(tacticInfo, tacticTree)) +} \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/serialization/EitherTypeAdapter.kt b/app/src/main/java/com/iqball/app/serialization/EitherTypeAdapter.kt new file mode 100644 index 0000000..b09376a --- /dev/null +++ b/app/src/main/java/com/iqball/app/serialization/EitherTypeAdapter.kt @@ -0,0 +1,97 @@ +package com.iqball.app.serialization + +import arrow.core.Either +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonDataException +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.Moshi +import com.squareup.moshi.rawType +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type + + +private class EitherTypeAdapter( + val leftType: Class<*>, + val rightType: Class<*>, + private val moshi: Moshi +) : JsonAdapter>() { + + private val leftJsonType = leftType.getJsonPrimitive() + private val rightJsonType = rightType.getJsonPrimitive() + + override fun fromJson(reader: JsonReader): Either<*, *>? { + + val valueJsonType = when (val token = reader.peek()) { + JsonReader.Token.BEGIN_ARRAY -> JsonPrimitiveType.Array + JsonReader.Token.BEGIN_OBJECT -> JsonPrimitiveType.Object + JsonReader.Token.STRING -> JsonPrimitiveType.String + JsonReader.Token.NUMBER -> JsonPrimitiveType.Number + JsonReader.Token.BOOLEAN -> JsonPrimitiveType.Boolean + JsonReader.Token.NULL -> { + reader.nextNull() + return null + } + else -> throw JsonDataException("unexpected token : $token") + } + + if (valueJsonType == leftJsonType) { + val value = moshi.adapter(leftType).fromJson(reader) + return Either.Left(value) + } + + if (valueJsonType == rightJsonType) { + val value = moshi.adapter(rightType).fromJson(reader) + return Either.Right(value) + } + throw ClassCastException("Cannot cast a json value of type " + valueJsonType + " as either " + leftType.name.lowercase() + " or " + rightType.name.lowercase()) + } + + + override fun toJson(writer: JsonWriter, value: Either<*, *>?) { + when (value) { + is Either.Left -> writer.jsonValue(value.value) + is Either.Right -> writer.jsonValue(value.value) + null -> writer.nullValue() + } + } + + +} + +object EitherTypeAdapterFactory : JsonAdapter.Factory { + override fun create( + type: Type, + annotations: MutableSet, + moshi: Moshi + ): JsonAdapter<*>? { + if (type !is ParameterizedType) + return null + if (type.rawType != Either::class.java) + return null + val leftType = type.actualTypeArguments[0].rawType + val rightType = type.actualTypeArguments[1].rawType + if (leftType.getJsonPrimitive() == rightType.getJsonPrimitive()) { + throw UnsupportedOperationException("Cannot handle Either types with both sides being object, array, string or number. Provided type is : $type") + } + return EitherTypeAdapter(leftType, rightType, moshi) + } +} + +private enum class JsonPrimitiveType { + Array, + Object, + String, + Number, + Boolean +} + +private fun Class<*>.getJsonPrimitive(): JsonPrimitiveType { + if (isPrimitive) + return if (java.lang.Boolean.TYPE == this) JsonPrimitiveType.Boolean else JsonPrimitiveType.Number + if (isArray) + return JsonPrimitiveType.Array + if (this == String::class.java) + return JsonPrimitiveType.String + return JsonPrimitiveType.Object +} \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/serialization/EnumTypeAdapter.kt b/app/src/main/java/com/iqball/app/serialization/EnumTypeAdapter.kt new file mode 100644 index 0000000..01e76b8 --- /dev/null +++ b/app/src/main/java/com/iqball/app/serialization/EnumTypeAdapter.kt @@ -0,0 +1,81 @@ +package com.iqball.app.serialization + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.Moshi +import com.squareup.moshi.rawType +import java.lang.reflect.Type +import java.util.NoSuchElementException + +class EnumTypeAdapter>( + values: Map, + private val bindNames: Boolean, + private val ignoreCase: Boolean, + private val fallback: E?, + private val clazz: Class +) : JsonAdapter() { + + private val values = if (ignoreCase) values.mapKeys { it.key.lowercase() } else values + + override fun fromJson(reader: JsonReader): E { + val value = reader.nextString() + val key = if (ignoreCase) value.lowercase() else value + + var result = values[key] + if (result == null && bindNames) { + result = clazz.enumConstants?.find { it.name.lowercase() == key } + } + + return result + ?: fallback + ?: throw NoSuchElementException("No enum variant matched given values bindings, and no fallback was provided (value = $value, enum type = $clazz)") + } + + override fun toJson(writer: JsonWriter, value: E?) { + throw UnsupportedOperationException() + } + +} + +class EnumTypeAdapterFactory>( + private val values: Map, + private val bindNames: Boolean, + private val ignoreCase: Boolean, + private val fallback: E?, + private val clazz: Class +) : JsonAdapter.Factory { + override fun create( + type: Type, + annotations: MutableSet, + moshi: Moshi + ): JsonAdapter<*>? { + if (type.rawType != clazz) + return null + + return EnumTypeAdapter(values, bindNames, ignoreCase, fallback, clazz) + } + + + companion object { + + class Builder>(val values: MutableMap = HashMap()) { + infix fun String.means(e: E) { + values[this] = e + } + } + + inline fun > create( + ignoreCase: Boolean = false, + bindNames: Boolean = true, + fallback: E? = null, + build: Builder.() -> Unit = {} + ): EnumTypeAdapterFactory { + val builder = Builder() + build(builder) + return EnumTypeAdapterFactory(builder.values, bindNames, ignoreCase, fallback, E::class.java) + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/session/Authentication.kt b/app/src/main/java/com/iqball/app/session/Authentication.kt index b5dbeaf..ff2855f 100644 --- a/app/src/main/java/com/iqball/app/session/Authentication.kt +++ b/app/src/main/java/com/iqball/app/session/Authentication.kt @@ -1,3 +1,4 @@ package com.iqball.app.session +typealias Token = String data class Authentication(val token: String, val expirationDate: Long) 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 6b01d6c..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 @@ -8,4 +8,11 @@ val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +val Pink40 = Color(0xFF7D5260) + +val Allies = Color(0xFF64e4f5) +val Opponents = Color(0xFFf59264) +val BallColor = Color(0XFFc5520d) + +val StepNode = Color(0xFF2AC008) +val SelectedStepNode = Color(0xFF213519) \ No newline at end of file diff --git a/app/src/main/res/drawable/ball.xml b/app/src/main/res/drawable/ball.xml new file mode 100644 index 0000000..5b55cb0 --- /dev/null +++ b/app/src/main/res/drawable/ball.xml @@ -0,0 +1,38 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/half_court.xml b/app/src/main/res/drawable/half_court.xml new file mode 100644 index 0000000..1f7e07f --- /dev/null +++ b/app/src/main/res/drawable/half_court.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/plain_court.xml b/app/src/main/res/drawable/plain_court.xml new file mode 100644 index 0000000..8ae62a7 --- /dev/null +++ b/app/src/main/res/drawable/plain_court.xml @@ -0,0 +1,301 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/tree_icon.png b/app/src/main/res/drawable/tree_icon.png new file mode 100644 index 0000000..221099c Binary files /dev/null and b/app/src/main/res/drawable/tree_icon.png differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 395069b..ca4c795 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,26 +1,36 @@ [versions] agp = "8.2.2" arrowCore = "1.2.1" +composeFreeScroll = "0.2.2" converterGson = "2.9.0" +converterMoshi = "2.5.0" +converterMoshiVersion = "2.5.0" +graphicsShapes = "1.0.0-alpha05" 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" retrofit = "2.9.0" retrofit2KotlinxSerializationConverter = "1.0.0" retrofitAdaptersArrow = "1.0.9" navigationCompose = "2.7.7" +zoomable = "1.6.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-graphics-shapes = { module = "androidx.graphics:graphics-shapes", version.ref = "graphicsShapes" } arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrowCore" } -converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } +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" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -35,11 +45,14 @@ 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" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-adapters-arrow = { module = "com.github.skydoves:retrofit-adapters-arrow", version.ref = "retrofitAdaptersArrow" } retrofit2-kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit2KotlinxSerializationConverter" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +zoomable = { module = "net.engawapg.lib:zoomable", version.ref = "zoomable" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } 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") } } }