diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6c286ef..a08c767 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -67,9 +67,13 @@ 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.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..c9cf4bd 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()) } 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..263eafd --- /dev/null +++ b/app/src/main/java/com/iqball/app/component/BasketCourt.kt @@ -0,0 +1,72 @@ +package com.iqball.app.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import com.iqball.app.R +import com.iqball.app.domains.getPlayerInfo +import com.iqball.app.model.tactic.BallComponent +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.rememberZoomState +import net.engawapg.lib.zoomable.zoomable + +@Composable +fun BasketCourt(content: StepContent, type: CourtType) { + val courtImg = when (type) { + CourtType.Plain -> R.drawable.plain_court + CourtType.Half -> R.drawable.half_court + } + + val zoomState = rememberZoomState() + val components = content.components + + Row( + modifier = Modifier + .background(Color.LightGray) + .fillMaxSize() + .zoomable(zoomState), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Box { + Image( + painter = painterResource(id = courtImg), + contentDescription = "court", + modifier = Modifier + .background(Color.White) + .align(Alignment.Center) + .fillMaxHeight() + ) + + for (component in components) { + + when (component) { + is PlayerLike -> { + val info = getPlayerInfo(component, content) + PlayerPiece( + player = info, + modifier = Modifier.align(info.pos.toBiasAlignment()) + ) + } + + is BallComponent -> BallPiece( + modifier = Modifier.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..0629b94 --- /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) { + + val color = 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/domains/PlayerDomains.kt b/app/src/main/java/com/iqball/app/domains/PlayerDomains.kt new file mode 100644 index 0000000..a0ca3a3 --- /dev/null +++ b/app/src/main/java/com/iqball/app/domains/PlayerDomains.kt @@ -0,0 +1,110 @@ +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 [PhantomPositioning] 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 { + 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 +} + +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/geo/Area.kt b/app/src/main/java/com/iqball/app/geo/Area.kt new file mode 100644 index 0000000..af0e53c --- /dev/null +++ b/app/src/main/java/com/iqball/app/geo/Area.kt @@ -0,0 +1,3 @@ +package com.iqball.app.geo + +data class Area(val pos: Vector, val width: Float, val height: Float) 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..a768a68 --- /dev/null +++ b/app/src/main/java/com/iqball/app/geo/Vector.kt @@ -0,0 +1,25 @@ +package com.iqball.app.geo + +import androidx.compose.ui.BiasAlignment +import kotlin.math.sqrt + +typealias Pos = Vector + +data class Vector(val x: Double, val y: Double) { + fun toBiasAlignment(): BiasAlignment = + BiasAlignment((x * 2 - 1).toFloat(), (y * 2 - 1).toFloat()) + + 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) + + fun distanceWith(other: Vector) = + sqrt(((x - other.x) * (x - other.x) + (y - other.y) * (y - other.y))) + + fun norm() = NullVector.distanceWith(this) + + fun posWithinArea(area: Area) = Vector(x * area.width, y * area.height) + fun ratioWithinArea(area: Area) = + Vector((x - area.pos.x) * area.width, (y - area.pos.y) * area.height) +} + +val NullVector = Vector(.0, .0) \ 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..bfa35c5 --- /dev/null +++ b/app/src/main/java/com/iqball/app/model/tactic/Action.kt @@ -0,0 +1,23 @@ +package com.iqball.app.model.tactic + +import arrow.core.Either +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..e9be5bf --- /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: Double, val y: Double) : 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..3c3b7bf --- /dev/null +++ b/app/src/main/java/com/iqball/app/model/tactic/StepNodeInfo.kt @@ -0,0 +1,3 @@ +package com.iqball.app.model.tactic + +data class StepNodeInfo(val id: Int, val children: List) 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..2627018 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,9 @@ 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.net.service.IQBallService 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..ce7bac8 --- /dev/null +++ b/app/src/main/java/com/iqball/app/page/VisualizerPage.kt @@ -0,0 +1,158 @@ +package com.iqball.app.page + +import android.app.Activity +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import arrow.core.Either +import arrow.core.flatMap +import com.iqball.app.component.BasketCourt +import com.iqball.app.model.TacticInfo +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.net.service.TacticService +import com.iqball.app.session.Token +import kotlinx.coroutines.runBlocking +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId + +private data class VisualizerInitialData( + val info: TacticInfo, + val rootStep: StepNodeInfo, + val rootContent: StepContent +) + +@Composable +fun VisualizerPage( + service: TacticService, + auth: Token, + tacticId: Int, +) { + val dataEither = initializeVisualizer(service, auth, tacticId) + + val (info, rootStep, rootContent) = when (dataEither) { + is Either.Left -> return Text(text = dataEither.value) + is Either.Right -> dataEither.value + } + + val screenOrientation = LocalConfiguration.current.orientation + + Column { + VisualizerHeader(title = info.name) + when (screenOrientation) { + Configuration.ORIENTATION_PORTRAIT -> Text( + text = "Visualizing Tactic Steps tree", + modifier = Modifier + .fillMaxSize() + .background(Color.Green) + ) + + Configuration.ORIENTATION_LANDSCAPE -> BasketCourt( + content = rootContent, + type = info.type + ) + + else -> throw Exception("Could not determine device's orientation.") + } + } + +} + +@Composable +private fun VisualizerHeader(title: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .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) + + Text(text = "") + } +} + +private fun initializeVisualizer( + service: TacticService, + auth: Token, + tacticId: Int +): Either { + val (tacticInfo, tacticTree, rootStepContent) = 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() + ) + } + + val rootStepContent = tacticTree + .flatMap { + service.getTacticStepContent(auth, tacticId, it.id) + .onLeft { + Log.e( + "received error response from server when retrieving root content: {}", + it.toString() + ) + } + } + + Triple(tacticInfo.getOrNull(), tacticTree.getOrNull(), rootStepContent.getOrNull()) + } + + if (tacticInfo == null || tacticTree == null || rootStepContent == null) { + return Either.Left("Unable to retrieve tactic information") + } + + + return Either.Right(VisualizerInitialData(tacticInfo, tacticTree, rootStepContent)) +} \ 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..0ad4d36 --- /dev/null +++ b/app/src/main/java/com/iqball/app/serialization/EitherTypeAdapter.kt @@ -0,0 +1,74 @@ +package com.iqball.app.serialization + +import arrow.core.Either +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.ClassCastException +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type + + +private class EitherTypeAdapter(val leftType: JsonPrimitiveType, val rightType: JsonPrimitiveType) : + JsonAdapter>() { + override fun fromJson(reader: JsonReader): Either<*, *>? { + val value = reader.readJsonValue() ?: return null + + val valueJsonType = value.javaClass.getJsonPrimitive() + if (valueJsonType == leftType) + return Either.Left(value) + + if (valueJsonType == rightType) + return Either.Right(value) + throw ClassCastException("Cannot cast a value of type " + value.javaClass + " 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.getJsonPrimitive() + val rightType = type.actualTypeArguments[1].rawType.getJsonPrimitive() + if (leftType == rightType) { + throw UnsupportedOperationException("Cannot handle Either types with both sides being object, array, string or number. Provided type is : $type") + } + return EitherTypeAdapter(leftType, rightType) + } +} + +private enum class JsonPrimitiveType { + Array, + Object, + String, + Number; +} + +private fun Class<*>.getJsonPrimitive(): JsonPrimitiveType { + if (isPrimitive) + return 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..9f29911 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,8 @@ 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) \ 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..d84c400 --- /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..412a487 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,8 @@ agp = "8.2.2" arrowCore = "1.2.1" converterGson = "2.9.0" +converterMoshi = "2.4.0" +converterMoshiVersion = "2.5.0" kotlin = "1.9.0" coreKtx = "1.10.1" junit = "4.13.2" @@ -12,15 +14,21 @@ kotlinxSerializationJsonJvm = "1.6.3" lifecycleRuntimeKtx = "2.6.1" activityCompose = "1.7.0" composeBom = "2023.08.00" +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" } 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" } +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" } @@ -36,10 +44,14 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit 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" }