From a3fbe984b6f5222b20c1447f5fb95284425f9158 Mon Sep 17 00:00:00 2001 From: samuel Date: Tue, 19 Mar 2024 17:57:55 +0100 Subject: [PATCH 01/14] start login --- .../main/java/com/iqball/app/MainActivity.kt | 34 +++++++--- .../main/java/com/iqball/app/page/HomePage.kt | 10 +++ .../java/com/iqball/app/page/LoginPage.kt | 66 ++++++++++++++++++- .../java/com/iqball/app/page/RegisterPage.kt | 11 +--- .../com/iqball/app/session/DataSession.kt | 3 + .../com/iqball/app/session/MutableSession.kt | 5 -- .../java/com/iqball/app/session/Session.kt | 2 +- gradle/libs.versions.toml | 2 +- 8 files changed, 106 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/com/iqball/app/page/HomePage.kt create mode 100644 app/src/main/java/com/iqball/app/session/DataSession.kt delete mode 100644 app/src/main/java/com/iqball/app/session/MutableSession.kt diff --git a/app/src/main/java/com/iqball/app/MainActivity.kt b/app/src/main/java/com/iqball/app/MainActivity.kt index 964daee..de4e2e4 100644 --- a/app/src/main/java/com/iqball/app/MainActivity.kt +++ b/app/src/main/java/com/iqball/app/MainActivity.kt @@ -7,21 +7,23 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.google.gson.Gson -import com.google.gson.GsonBuilder import com.iqball.app.api.EitherBodyConverter import com.iqball.app.api.EitherCallAdapterFactory import com.iqball.app.api.service.IQBallService -import com.iqball.app.page.RegisterPage +import com.iqball.app.page.HomePage +import com.iqball.app.page.LoginPage +import com.iqball.app.session.DataSession +import com.iqball.app.session.Session import com.iqball.app.ui.theme.IQBallTheme import okhttp3.OkHttpClient -import okhttp3.ResponseBody -import retrofit2.Converter import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import retrofit2.create -import java.lang.reflect.Type class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -46,8 +48,12 @@ class MainActivity : ComponentActivity() { setContent { IQBallTheme { // A surface container using the 'background' color from the theme - Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { - App(service) + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + val sessionState = remember { mutableStateOf(DataSession()) } + App(service, sessionState) } } } @@ -55,6 +61,16 @@ class MainActivity : ComponentActivity() { } @Composable -fun App(service: IQBallService) { - RegisterPage(service) +fun App(service: IQBallService, sessionState: MutableState) { + + val loginPage: @Composable () -> Unit = { + LoginPage(service = service, onLoginSuccess = { auth -> + sessionState.value = DataSession(auth) + }) + } + + val homePage : @Composable () -> Unit = { HomePage(service, sessionState.value) } + val currentPage = remember(sessionState.value.auth) { if (sessionState.value.auth == null) loginPage else homePage } + + currentPage() } \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/page/HomePage.kt b/app/src/main/java/com/iqball/app/page/HomePage.kt new file mode 100644 index 0000000..da8f56e --- /dev/null +++ b/app/src/main/java/com/iqball/app/page/HomePage.kt @@ -0,0 +1,10 @@ +package com.iqball.app.page + +import androidx.compose.runtime.Composable +import com.iqball.app.api.service.IQBallService +import com.iqball.app.session.Session + +@Composable +fun HomePage(service: IQBallService, session: Session) { + +} \ No newline at end of file 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 f6f8748..55f6de7 100644 --- a/app/src/main/java/com/iqball/app/page/LoginPage.kt +++ b/app/src/main/java/com/iqball/app/page/LoginPage.kt @@ -2,8 +2,70 @@ package com.iqball.app.page import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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.session.Authentication +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.LocalDateTime @Composable -fun LoginPage() { - Text(text = "Login Page") +fun LoginPage(service: AuthService, onLoginSuccess : (Authentication) -> Unit) { + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + + Surface( + color = Color.White, + modifier = Modifier.fillMaxSize() + ) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Login", + fontSize = 28.sp, + color = Color.Black + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text("Username") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + + Button(onClick = { + runBlocking { + when(val response = service.login(AuthService.LoginRequest(username, password))){ + is Either.Left -> println(response.value) + + is Either.Right -> onLoginSuccess(Authentication(response.value.token, LocalDateTime.parse(response.value.expirationDate))) + } + } + }) { + Text(text = "Se connecter") + } + } + } } \ No newline at end of file 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 6bb14bc..720c36e 100644 --- a/app/src/main/java/com/iqball/app/page/RegisterPage.kt +++ b/app/src/main/java/com/iqball/app/page/RegisterPage.kt @@ -3,21 +3,14 @@ package com.iqball.app.page import android.util.Log import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import arrow.core.Either import com.iqball.app.api.service.AuthService -import com.iqball.app.api.service.AuthService.RegisterRequest import com.iqball.app.api.service.IQBallService -import com.iqball.app.session.Authentication -import com.iqball.app.session.MutableSession -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking @Composable fun RegisterPage(service: IQBallService) { @@ -25,7 +18,7 @@ fun RegisterPage(service: IQBallService) { var text by remember { mutableStateOf("No message !") } - runBlocking { + LaunchedEffect(Unit) { val result = service.login(AuthService.LoginRequest("maxime@mail.com", "123456")) when (result) { diff --git a/app/src/main/java/com/iqball/app/session/DataSession.kt b/app/src/main/java/com/iqball/app/session/DataSession.kt new file mode 100644 index 0000000..686adc1 --- /dev/null +++ b/app/src/main/java/com/iqball/app/session/DataSession.kt @@ -0,0 +1,3 @@ +package com.iqball.app.session + +data class DataSession(override val auth: Authentication? = null) : Session \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/session/MutableSession.kt b/app/src/main/java/com/iqball/app/session/MutableSession.kt deleted file mode 100644 index 37219bf..0000000 --- a/app/src/main/java/com/iqball/app/session/MutableSession.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.iqball.app.session - -interface MutableSession : Session { - override var auth: Authentication -} \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/session/Session.kt b/app/src/main/java/com/iqball/app/session/Session.kt index bb34b40..e0c1975 100644 --- a/app/src/main/java/com/iqball/app/session/Session.kt +++ b/app/src/main/java/com/iqball/app/session/Session.kt @@ -1,5 +1,5 @@ package com.iqball.app.session interface Session { - val auth: Authentication + val auth: Authentication? } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9c53489..f10c49e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.3.0" +agp = "8.2.2" arrowCore = "1.2.1" converterGson = "2.9.0" kotlin = "1.9.0" From 9990c69374ca7ad3820329dcdc056aac03e735be Mon Sep 17 00:00:00 2001 From: samuel Date: Thu, 21 Mar 2024 09:49:02 +0100 Subject: [PATCH 02/14] register wip --- .../main/java/com/iqball/app/MainActivity.kt | 12 +- .../main/java/com/iqball/app/page/HomePage.kt | 2 + .../java/com/iqball/app/page/LoginPage.kt | 18 +-- .../java/com/iqball/app/page/RegisterPage.kt | 106 ++++++++++++++---- 4 files changed, 106 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/iqball/app/MainActivity.kt b/app/src/main/java/com/iqball/app/MainActivity.kt index de4e2e4..bc2fc63 100644 --- a/app/src/main/java/com/iqball/app/MainActivity.kt +++ b/app/src/main/java/com/iqball/app/MainActivity.kt @@ -17,6 +17,7 @@ import com.iqball.app.api.EitherCallAdapterFactory import com.iqball.app.api.service.IQBallService import com.iqball.app.page.HomePage import com.iqball.app.page.LoginPage +import com.iqball.app.page.RegisterPage import com.iqball.app.session.DataSession import com.iqball.app.session.Session import com.iqball.app.ui.theme.IQBallTheme @@ -63,14 +64,21 @@ class MainActivity : ComponentActivity() { @Composable fun App(service: IQBallService, sessionState: MutableState) { + val registerPage: @Composable () -> Unit = { + RegisterPage(service = service, onLoginSuccess = { auth -> + sessionState.value = DataSession(auth) + }) + } + val loginPage: @Composable () -> Unit = { LoginPage(service = service, onLoginSuccess = { auth -> sessionState.value = DataSession(auth) }) } - val homePage : @Composable () -> Unit = { HomePage(service, sessionState.value) } - val currentPage = remember(sessionState.value.auth) { if (sessionState.value.auth == null) loginPage else homePage } + registerPage() + val currentPage = remember(sessionState.value.auth) { if (sessionState.value.auth == null) loginPage else homePage } currentPage() + } \ No newline at end of file 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 da8f56e..8bcb3bf 100644 --- a/app/src/main/java/com/iqball/app/page/HomePage.kt +++ b/app/src/main/java/com/iqball/app/page/HomePage.kt @@ -1,5 +1,6 @@ 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.session.Session @@ -7,4 +8,5 @@ import com.iqball.app.session.Session @Composable fun HomePage(service: IQBallService, session: Session) { + Text(text = "HELLO WELCOME") } \ No newline at end of file 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 55f6de7..053e14d 100644 --- a/app/src/main/java/com/iqball/app/page/LoginPage.kt +++ b/app/src/main/java/com/iqball/app/page/LoginPage.kt @@ -18,10 +18,12 @@ import com.iqball.app.session.Authentication import kotlinx.coroutines.runBlocking import kotlinx.datetime.LocalDateTime + @Composable fun LoginPage(service: AuthService, onLoginSuccess : (Authentication) -> Unit) { - var username by remember { mutableStateOf("") } + var email by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } + var errors by remember { mutableStateOf("") } Surface( color = Color.White, @@ -35,15 +37,15 @@ fun LoginPage(service: AuthService, onLoginSuccess : (Authentication) -> Unit) { horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "Login", + text = "S'identifier", fontSize = 28.sp, color = Color.Black ) Spacer(modifier = Modifier.height(16.dp)) OutlinedTextField( - value = username, - onValueChange = { username = it }, - label = { Text("Username") }, + value = email, + onValueChange = { email = it }, + label = { Text("Email") }, modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(16.dp)) @@ -57,8 +59,10 @@ fun LoginPage(service: AuthService, onLoginSuccess : (Authentication) -> Unit) { Button(onClick = { runBlocking { - when(val response = service.login(AuthService.LoginRequest(username, password))){ - is Either.Left -> println(response.value) + when(val response = service.login(AuthService.LoginRequest(email, password))){ + is Either.Left -> { + errors = response.value.toList().flatMap { entry -> entry.second.map { "${entry.first} : ${it}" } }.joinToString("\n") + } is Either.Right -> onLoginSuccess(Authentication(response.value.token, LocalDateTime.parse(response.value.expirationDate))) } 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 720c36e..869a777 100644 --- a/app/src/main/java/com/iqball/app/page/RegisterPage.kt +++ b/app/src/main/java/com/iqball/app/page/RegisterPage.kt @@ -1,6 +1,16 @@ package com.iqball.app.page import android.util.Log +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -8,40 +18,90 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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.api.service.IQBallService +import com.iqball.app.session.Authentication +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.LocalDateTime +import androidx.compose.ui.text.input.VisualTransformation @Composable -fun RegisterPage(service: IQBallService) { +fun RegisterPage(service: AuthService, onLoginSuccess: (Authentication) -> Unit) { + var username by remember { mutableStateOf("") } + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var errors by remember { mutableStateOf("") } + Surface( + color = Color.White, + modifier = Modifier.fillMaxSize() - var text by remember { mutableStateOf("No message !") } - - LaunchedEffect(Unit) { - val result = service.login(AuthService.LoginRequest("maxime@mail.com", "123456")) - - when (result) { - is Either.Left -> { - println("Error : " + result.value) - text = result.toString() + ) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "S'enregistrer", + fontSize = 28.sp, + color = Color.Black + ) + Spacer(modifier = Modifier.height(16.dp)) + errors?.let { message -> + Text( + text = message, + color = Color.Red, + fontSize = 14.sp, + modifier = Modifier.padding(vertical = 8.dp) + ) } - is Either.Right -> { - val token = result.value.token - val userDataResponse = service.getUserData(token) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text("Nom d'utilisateur") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Mot de passe") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = email, + onValueChange = { email = it }, + label = { Text("Email") }, + modifier = Modifier.fillMaxWidth() + ) + Button(onClick = { + runBlocking { + when (val response = + service.register(AuthService.RegisterRequest(username, email, password))) { + is Either.Left -> { + errors = response.value.toList().flatMap { entry -> entry.second.map { "${entry.first} : ${it}" } }.joinToString("\n") + } - when (userDataResponse) { - is Either.Left -> println("Error User Data : " + userDataResponse.value) - is Either.Right -> println("Success User Data : " + userDataResponse.value) + is Either.Right -> { + onLoginSuccess(Authentication(response.value.token,LocalDateTime.parse(response.value.expirationDate))) + } + } } - - text = userDataResponse.toString() + }) { + Text(text = "Créer votre compte") } } - - println(result) - Log.i("%", result.toString()) } - - Text(text = text) } + From 07252b902589a6f028cf855df8c7ee383abf8cfa Mon Sep 17 00:00:00 2001 From: KaulH Date: Wed, 27 Mar 2024 17:32:42 +0100 Subject: [PATCH 03/14] register and login wip --- .../main/java/com/iqball/app/MainActivity.kt | 4 +-- .../com/iqball/app/api/service/AuthService.kt | 2 +- .../java/com/iqball/app/page/LoginPage.kt | 27 +++++++++++++++---- .../java/com/iqball/app/page/RegisterPage.kt | 22 ++++++++------- .../com/iqball/app/session/Authentication.kt | 4 +-- 5 files changed, 39 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/iqball/app/MainActivity.kt b/app/src/main/java/com/iqball/app/MainActivity.kt index bc2fc63..9accff0 100644 --- a/app/src/main/java/com/iqball/app/MainActivity.kt +++ b/app/src/main/java/com/iqball/app/MainActivity.kt @@ -65,7 +65,7 @@ class MainActivity : ComponentActivity() { fun App(service: IQBallService, sessionState: MutableState) { val registerPage: @Composable () -> Unit = { - RegisterPage(service = service, onLoginSuccess = { auth -> + RegisterPage(service = service, onRegisterSuccess = { auth -> sessionState.value = DataSession(auth) }) } @@ -77,7 +77,7 @@ fun App(service: IQBallService, sessionState: MutableState) { } val homePage : @Composable () -> Unit = { HomePage(service, sessionState.value) } - registerPage() + //registerPage() val currentPage = remember(sessionState.value.auth) { if (sessionState.value.auth == null) loginPage else homePage } currentPage() diff --git a/app/src/main/java/com/iqball/app/api/service/AuthService.kt b/app/src/main/java/com/iqball/app/api/service/AuthService.kt index 5b88a09..bb444fe 100644 --- a/app/src/main/java/com/iqball/app/api/service/AuthService.kt +++ b/app/src/main/java/com/iqball/app/api/service/AuthService.kt @@ -11,7 +11,7 @@ import retrofit2.http.POST interface AuthService { @Serializable - data class AuthResponse(val token: String, val expirationDate: String) + data class AuthResponse(val token: String, val expirationDate: Long) @Serializable data class RegisterRequest(val username: String, val email: String, val password: String) 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 053e14d..d2d50d6 100644 --- a/app/src/main/java/com/iqball/app/page/LoginPage.kt +++ b/app/src/main/java/com/iqball/app/page/LoginPage.kt @@ -10,17 +10,17 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +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.session.Authentication import kotlinx.coroutines.runBlocking -import kotlinx.datetime.LocalDateTime @Composable -fun LoginPage(service: AuthService, onLoginSuccess : (Authentication) -> Unit) { +fun LoginPage(service: AuthService, onLoginSuccess: (Authentication) -> Unit) { var email by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } var errors by remember { mutableStateOf("") } @@ -42,6 +42,15 @@ fun LoginPage(service: AuthService, onLoginSuccess : (Authentication) -> Unit) { color = Color.Black ) Spacer(modifier = Modifier.height(16.dp)) + errors?.let { message -> + Text( + text = message, + color = Color.Red, + fontSize = 14.sp, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + Spacer(modifier = Modifier.height(16.dp)) OutlinedTextField( value = email, onValueChange = { email = it }, @@ -53,18 +62,26 @@ fun LoginPage(service: AuthService, onLoginSuccess : (Authentication) -> Unit) { value = password, onValueChange = { password = it }, label = { Text("Password") }, + visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(16.dp)) Button(onClick = { runBlocking { - when(val response = service.login(AuthService.LoginRequest(email, password))){ + when (val response = service.login(AuthService.LoginRequest(email, password))) { is Either.Left -> { - errors = response.value.toList().flatMap { entry -> entry.second.map { "${entry.first} : ${it}" } }.joinToString("\n") + errors = response.value.toList() + .flatMap { entry -> entry.second.map { "${entry.first} : ${it}" } } + .joinToString("\n") } - is Either.Right -> onLoginSuccess(Authentication(response.value.token, LocalDateTime.parse(response.value.expirationDate))) + is Either.Right -> onLoginSuccess( + Authentication( + response.value.token, + response.value.expirationDate.toLong() + ) + ) } } }) { 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 869a777..2e02b03 100644 --- a/app/src/main/java/com/iqball/app/page/RegisterPage.kt +++ b/app/src/main/java/com/iqball/app/page/RegisterPage.kt @@ -1,6 +1,5 @@ package com.iqball.app.page -import android.util.Log import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -13,7 +12,6 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -21,18 +19,16 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +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.api.service.IQBallService import com.iqball.app.session.Authentication import kotlinx.coroutines.runBlocking -import kotlinx.datetime.LocalDateTime -import androidx.compose.ui.text.input.VisualTransformation @Composable -fun RegisterPage(service: AuthService, onLoginSuccess: (Authentication) -> Unit) { +fun RegisterPage(service: AuthService, onRegisterSuccess: (Authentication) -> Unit) { var username by remember { mutableStateOf("") } var email by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } @@ -76,7 +72,8 @@ fun RegisterPage(service: AuthService, onLoginSuccess: (Authentication) -> Unit) value = password, onValueChange = { password = it }, label = { Text("Mot de passe") }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + visualTransformation = PasswordVisualTransformation() ) Spacer(modifier = Modifier.height(16.dp)) OutlinedTextField( @@ -90,11 +87,18 @@ fun RegisterPage(service: AuthService, onLoginSuccess: (Authentication) -> Unit) when (val response = service.register(AuthService.RegisterRequest(username, email, password))) { is Either.Left -> { - errors = response.value.toList().flatMap { entry -> entry.second.map { "${entry.first} : ${it}" } }.joinToString("\n") + errors = response.value.toList() + .flatMap { entry -> entry.second.map { "${entry.first} : ${it}" } } + .joinToString("\n") } is Either.Right -> { - onLoginSuccess(Authentication(response.value.token,LocalDateTime.parse(response.value.expirationDate))) + onRegisterSuccess( + Authentication( + response.value.token, + response.value.expirationDate + ) + ) } } } 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 1f9aed4..b5dbeaf 100644 --- a/app/src/main/java/com/iqball/app/session/Authentication.kt +++ b/app/src/main/java/com/iqball/app/session/Authentication.kt @@ -1,5 +1,3 @@ package com.iqball.app.session -import kotlinx.datetime.LocalDateTime - -data class Authentication(val token: String, val expirationDate: LocalDateTime) +data class Authentication(val token: String, val expirationDate: Long) From 72577b8505ff5ed66b258c77cb892aee73e1d0c4 Mon Sep 17 00:00:00 2001 From: KaulH Date: Sat, 30 Mar 2024 11:38:35 +0100 Subject: [PATCH 04/14] navigation --- app/build.gradle.kts | 1 + .../main/java/com/iqball/app/MainActivity.kt | 50 +++++++++++++------ .../java/com/iqball/app/page/LoginPage.kt | 10 +++- .../java/com/iqball/app/page/RegisterPage.kt | 10 +++- gradle/libs.versions.toml | 2 + 5 files changed, 55 insertions(+), 18 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0e3ab1c..6c286ef 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -73,6 +73,7 @@ dependencies { implementation(libs.retrofit.adapters.arrow) implementation(libs.arrow.core) + implementation(libs.androidx.navigation.compose) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/app/src/main/java/com/iqball/app/MainActivity.kt b/app/src/main/java/com/iqball/app/MainActivity.kt index 9accff0..5e247fd 100644 --- a/app/src/main/java/com/iqball/app/MainActivity.kt +++ b/app/src/main/java/com/iqball/app/MainActivity.kt @@ -8,9 +8,14 @@ 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 @@ -63,22 +68,35 @@ class MainActivity : ComponentActivity() { @Composable fun App(service: IQBallService, sessionState: MutableState) { + val navController = rememberNavController() - val registerPage: @Composable () -> Unit = { - RegisterPage(service = service, onRegisterSuccess = { auth -> - sessionState.value = DataSession(auth) - }) - } - - val loginPage: @Composable () -> Unit = { - LoginPage(service = service, onLoginSuccess = { auth -> - sessionState.value = DataSession(auth) - }) + NavHost(navController = navController, startDestination = "login") { + composable("login") { + LoginPage( + service = service, + onLoginSuccess = { auth -> + sessionState.value = DataSession(auth) + navController.navigate("home") + }, + onNavigateToRegister = { + navController.navigate("register") + } + ) + } + composable("register") { + RegisterPage( + service = service, + onRegisterSuccess = { auth -> + sessionState.value = DataSession(auth) + navController.navigate("home") + }, + onNavigateToLogin = { + navController.navigate("login") + } + ) + } + composable("home") { + HomePage(service = service, session = sessionState.value) + } } - val homePage : @Composable () -> Unit = { HomePage(service, sessionState.value) } - - //registerPage() - val currentPage = remember(sessionState.value.auth) { if (sessionState.value.auth == null) loginPage else homePage } - currentPage() - } \ No newline at end of file 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 d2d50d6..f1cd62e 100644 --- a/app/src/main/java/com/iqball/app/page/LoginPage.kt +++ b/app/src/main/java/com/iqball/app/page/LoginPage.kt @@ -20,7 +20,11 @@ import kotlinx.coroutines.runBlocking @Composable -fun LoginPage(service: AuthService, onLoginSuccess: (Authentication) -> Unit) { +fun LoginPage( + service: AuthService, + onLoginSuccess: (Authentication) -> Unit, + onNavigateToRegister: () -> Unit +) { var email by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } var errors by remember { mutableStateOf("") } @@ -87,6 +91,10 @@ fun LoginPage(service: AuthService, onLoginSuccess: (Authentication) -> Unit) { }) { Text(text = "Se connecter") } + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { onNavigateToRegister() }) { + Text(text = "Vous n'avez pas de compte ?") + } } } } \ No newline at end of file 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 2e02b03..ce91d21 100644 --- a/app/src/main/java/com/iqball/app/page/RegisterPage.kt +++ b/app/src/main/java/com/iqball/app/page/RegisterPage.kt @@ -28,7 +28,11 @@ import com.iqball.app.session.Authentication import kotlinx.coroutines.runBlocking @Composable -fun RegisterPage(service: AuthService, onRegisterSuccess: (Authentication) -> Unit) { +fun RegisterPage( + service: AuthService, + onRegisterSuccess: (Authentication) -> Unit, + onNavigateToLogin: () -> Unit +) { var username by remember { mutableStateOf("") } var email by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } @@ -105,6 +109,10 @@ fun RegisterPage(service: AuthService, onRegisterSuccess: (Authentication) -> Un }) { Text(text = "Créer votre compte") } + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { onNavigateToLogin() }) { + Text(text = "Vous avez déjà un compte ?") + } } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f10c49e..395069b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ composeBom = "2023.08.00" retrofit = "2.9.0" retrofit2KotlinxSerializationConverter = "1.0.0" retrofitAdaptersArrow = "1.0.9" +navigationCompose = "2.7.7" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -38,6 +39,7 @@ kotlinx-serialization-json-jvm = { module = "org.jetbrains.kotlinx:kotlinx-seria 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" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } From 67669bddae057cb7bc621450a5cb0041b51c3cd2 Mon Sep 17 00:00:00 2001 From: KaulH Date: Sat, 30 Mar 2024 15:45:18 +0100 Subject: [PATCH 05/14] apply suggestions --- .../java/com/iqball/app/page/LoginPage.kt | 31 ++++++++++++------- .../java/com/iqball/app/page/RegisterPage.kt | 31 +++++++++++++------ 2 files changed, 40 insertions(+), 22 deletions(-) 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 f1cd62e..9c30099 100644 --- a/app/src/main/java/com/iqball/app/page/LoginPage.kt +++ b/app/src/main/java/com/iqball/app/page/LoginPage.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.foundation.layout.* import androidx.compose.material3.Button import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -18,7 +19,6 @@ import com.iqball.app.api.service.AuthService import com.iqball.app.session.Authentication import kotlinx.coroutines.runBlocking - @Composable fun LoginPage( service: AuthService, @@ -45,21 +45,24 @@ fun LoginPage( fontSize = 28.sp, color = Color.Black ) - Spacer(modifier = Modifier.height(16.dp)) - errors?.let { message -> - Text( - text = message, - color = Color.Red, - fontSize = 14.sp, - modifier = Modifier.padding(vertical = 8.dp) - ) - } + + Text( + text = errors, + color = Color.Red, + fontSize = 14.sp, + modifier = Modifier.padding(vertical = 8.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) OutlinedTextField( value = email, onValueChange = { email = it }, label = { Text("Email") }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.Black, + unfocusedTextColor = Color.Black + ) ) Spacer(modifier = Modifier.height(16.dp)) OutlinedTextField( @@ -67,7 +70,11 @@ fun LoginPage( onValueChange = { password = it }, label = { Text("Password") }, visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.Black, + unfocusedTextColor = Color.Black + ) ) Spacer(modifier = Modifier.height(16.dp)) 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 ce91d21..898ca43 100644 --- a/app/src/main/java/com/iqball/app/page/RegisterPage.kt +++ b/app/src/main/java/com/iqball/app/page/RegisterPage.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -56,20 +57,22 @@ fun RegisterPage( color = Color.Black ) Spacer(modifier = Modifier.height(16.dp)) - errors?.let { message -> - Text( - text = message, - color = Color.Red, - fontSize = 14.sp, - modifier = Modifier.padding(vertical = 8.dp) - ) - } + Text( + text = errors, + color = Color.Red, + fontSize = 14.sp, + modifier = Modifier.padding(vertical = 8.dp) + ) Spacer(modifier = Modifier.height(16.dp)) OutlinedTextField( value = username, onValueChange = { username = it }, label = { Text("Nom d'utilisateur") }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.Black, + unfocusedTextColor = Color.Black + ) ) Spacer(modifier = Modifier.height(16.dp)) OutlinedTextField( @@ -77,6 +80,10 @@ fun RegisterPage( onValueChange = { password = it }, label = { Text("Mot de passe") }, modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.Black, + unfocusedTextColor = Color.Black + ), visualTransformation = PasswordVisualTransformation() ) Spacer(modifier = Modifier.height(16.dp)) @@ -84,7 +91,11 @@ fun RegisterPage( value = email, onValueChange = { email = it }, label = { Text("Email") }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.Black, + unfocusedTextColor = Color.Black + ) ) Button(onClick = { runBlocking { From 5dd97d182a92c9a2e806fe722e5f79010fa95aa9 Mon Sep 17 00:00:00 2001 From: maxime Date: Wed, 13 Mar 2024 15:09:17 +0100 Subject: [PATCH 06/14] add step visualizer (does not supports actions arrows) --- app/build.gradle.kts | 6 +- app/icons/ball.svg | 62 ++++ app/icons/full_court.svg | 135 ++++++++ app/icons/half_court.svg | 73 +++++ .../main/java/com/iqball/app/MainActivity.kt | 55 +++- .../com/iqball/app/component/BallPiece.kt | 22 ++ .../com/iqball/app/component/BasketCourt.kt | 72 +++++ .../com/iqball/app/component/PlayerPiece.kt | 42 +++ .../com/iqball/app/domains/PlayerDomains.kt | 110 +++++++ app/src/main/java/com/iqball/app/geo/Area.kt | 3 + .../main/java/com/iqball/app/geo/Vector.kt | 25 ++ .../java/com/iqball/app/model/TacticInfo.kt | 11 + .../com/iqball/app/model/tactic/Action.kt | 23 ++ .../com/iqball/app/model/tactic/BallState.kt | 13 + .../com/iqball/app/model/tactic/CourtType.kt | 6 + .../iqball/app/model/tactic/MovementPath.kt | 3 + .../com/iqball/app/model/tactic/PlayerInfo.kt | 13 + .../com/iqball/app/model/tactic/PlayerTeam.kt | 6 + .../iqball/app/model/tactic/Positioning.kt | 9 + .../iqball/app/model/tactic/StepComponent.kt | 42 +++ .../iqball/app/model/tactic/StepContent.kt | 15 + .../iqball/app/model/tactic/StepNodeInfo.kt | 3 + .../app/{api => net}/EitherBodyConverter.kt | 2 +- .../com/iqball/app/{api => net}/EitherCall.kt | 13 +- .../{api => net}/EitherCallAdapterFactory.kt | 12 +- .../app/{api => net}/service/AuthService.kt | 6 +- .../app/{api => net}/service/IQBallService.kt | 5 +- .../iqball/app/net/service/TacticService.kt | 44 +++ .../app/{api => net}/service/UserService.kt | 2 +- .../main/java/com/iqball/app/page/HomePage.kt | 2 +- .../java/com/iqball/app/page/LoginPage.kt | 2 +- .../java/com/iqball/app/page/RegisterPage.kt | 4 +- .../com/iqball/app/page/VisualizerPage.kt | 158 +++++++++ .../app/serialization/EitherTypeAdapter.kt | 74 +++++ .../app/serialization/EnumTypeAdapter.kt | 81 +++++ .../com/iqball/app/session/Authentication.kt | 1 + .../java/com/iqball/app/ui/theme/Color.kt | 6 +- app/src/main/res/drawable/ball.xml | 38 +++ app/src/main/res/drawable/half_court.xml | 171 ++++++++++ app/src/main/res/drawable/plain_court.xml | 301 ++++++++++++++++++ app/src/main/res/drawable/tree_icon.png | Bin 0 -> 5779 bytes gradle/libs.versions.toml | 12 + 42 files changed, 1643 insertions(+), 40 deletions(-) create mode 100644 app/icons/ball.svg create mode 100644 app/icons/full_court.svg create mode 100644 app/icons/half_court.svg create mode 100644 app/src/main/java/com/iqball/app/component/BallPiece.kt create mode 100644 app/src/main/java/com/iqball/app/component/BasketCourt.kt create mode 100644 app/src/main/java/com/iqball/app/component/PlayerPiece.kt create mode 100644 app/src/main/java/com/iqball/app/domains/PlayerDomains.kt create mode 100644 app/src/main/java/com/iqball/app/geo/Area.kt create mode 100644 app/src/main/java/com/iqball/app/geo/Vector.kt create mode 100644 app/src/main/java/com/iqball/app/model/TacticInfo.kt create mode 100644 app/src/main/java/com/iqball/app/model/tactic/Action.kt create mode 100644 app/src/main/java/com/iqball/app/model/tactic/BallState.kt create mode 100644 app/src/main/java/com/iqball/app/model/tactic/CourtType.kt create mode 100644 app/src/main/java/com/iqball/app/model/tactic/MovementPath.kt create mode 100644 app/src/main/java/com/iqball/app/model/tactic/PlayerInfo.kt create mode 100644 app/src/main/java/com/iqball/app/model/tactic/PlayerTeam.kt create mode 100644 app/src/main/java/com/iqball/app/model/tactic/Positioning.kt create mode 100644 app/src/main/java/com/iqball/app/model/tactic/StepComponent.kt create mode 100644 app/src/main/java/com/iqball/app/model/tactic/StepContent.kt create mode 100644 app/src/main/java/com/iqball/app/model/tactic/StepNodeInfo.kt rename app/src/main/java/com/iqball/app/{api => net}/EitherBodyConverter.kt (97%) rename app/src/main/java/com/iqball/app/{api => net}/EitherCall.kt (77%) rename app/src/main/java/com/iqball/app/{api => net}/EitherCallAdapterFactory.kt (70%) rename app/src/main/java/com/iqball/app/{api => net}/service/AuthService.kt (79%) rename app/src/main/java/com/iqball/app/{api => net}/service/IQBallService.kt (57%) create mode 100644 app/src/main/java/com/iqball/app/net/service/TacticService.kt rename app/src/main/java/com/iqball/app/{api => net}/service/UserService.kt (88%) create mode 100644 app/src/main/java/com/iqball/app/page/VisualizerPage.kt create mode 100644 app/src/main/java/com/iqball/app/serialization/EitherTypeAdapter.kt create mode 100644 app/src/main/java/com/iqball/app/serialization/EnumTypeAdapter.kt create mode 100644 app/src/main/res/drawable/ball.xml create mode 100644 app/src/main/res/drawable/half_court.xml create mode 100644 app/src/main/res/drawable/plain_court.xml create mode 100644 app/src/main/res/drawable/tree_icon.png 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 0000000000000000000000000000000000000000..221099c2e922dda3fe22ea741ed546bce87cd4f6 GIT binary patch literal 5779 zcma)=i9b}||Hp|>$et}3YxZ5TM~Mj|BZjO)lkHP6mKjUgvJECg)?$)n2or-WV=FZF zUG{wnG1eGF_)VYhKk$1z&b{~Xc;9p1kM}v}eb4i}-tqUrMy!{vUZSC)VKp%}w4^?} z{$7k1srQezQ*w)L7$>3x;3oj9jzbic@3k%;>I0~hmCsMXy1 z)(rG8fYQ8x7zp+Q7o8oKotFkTu79j(dCk(U{QqnTW4m+dNgt50AS0Uw3#?qYDO3BT zSWEMWu3U4&NCfD-@%A}%tsos-fpk4}K~+wLIABxdpdXIthOr2_%?y~Q^u27o4?&Ac zW!18RV(pV6VO1k;Xoi#f(EDo_uP3lB>X%M%Er|%1y_B1~k5X~ja$LK`&A#QReShu5 z^@?hA#0iiXHO(jdZo;a;8!KFjB!U+5j{A3ZK;eW%k2}p~C=~;zk|5=i=TEWZ%``Bg z7?k_q&t6NId+{e&9}S?)TUMwGJP1c0ke;LuSUA2Kz4c+a^p@26GN|mwl`r(FD1V)~aXF7Gz$)|IFF^0zo`i^?@(P)9Va#H>YJ!-S1Jycs z$ebu%V;i3D+@%Z>uOS4!b^cd;^=}^%-}QvcCe8Xz>wH(crFbS@ZLmJ#9^^+eC_aDX z{4f1RY$2)9(`ZimowLAI{(I(IxTOwU4~3A`q!JSVt)`#Ml_f~zA5*t1j|D6 zgE(|x|0p4b)-uvt-q#Bi2i^d(x(MC8yL(o#!{#Z^1$2P8&(Nfnq_s?pq8j zE{e)KkZe?op3Ry>dQt^4VLim|Rq6h7tN|!Q7m8AWlw)C9_~UhR*MW2umn<>C`#153 zwL|LauK1%F{NWhs_AJp(8KJ&14yHBv_#73v0PABLV$@W8;WuaJopVUrZo|K==Yhvh z$7G^d3WB;6+uR>&#$hxw&!DBM=^Tznr6A$*eWg;>yoWPkCjLI(_hjeH{4sTw+Qyu^ zrYIG<16iRaFw2>P1~7A_8%;)%+JS5e?a~=DWxDL!N*X|h2<(X>k)o}_>cY36j4tRM zaT~>ZO*AP@SOnk8u={*B<&#|lPN~Ci7xf89$>*Xbii+-6tC*;76Xt5bYW<+#qu?Ay z3l;K&bR_gi9n!bt0j-huIe2#)ah*JomD7^3RVu`trG}CQwr#jI&JE=^xBSzH z={r`F{2pNc5j(?&Tll8sA596SU`9}t7xBoMwn>CtL+Dyn*uo^YTEf_#JuzyC$@DNb zJO61x?{Q62bBF!UDx~lbH&n;{^y(B$7Ks=O47{oDALdZ?+9zI;%%V7k+!(o$0e8-* z2{Um${GKiZU9*wZHSj0tZ24~^!t*OzeSmvAI4pzzY?)!WU?XLFCos2?@p zoW-Jr%Z}TB-J+WKemB393*PhOF)9h&eiS$EwswH+KNb|5)h;h`b;KWY2Za#j}vjT zUt+$a22%jswrv|6fn!8Csj-zRqZ@yX$b8%ZDbS&5ra@uZ`(^e0>-)%9Df zp@j){h$<48m2v<&9I2ZoeEAgb;6mVt1qK}6|>viLnv!|kcR_xxLVC*> z`3#$_4$O?S()DCS`D>>qyPf)?XO_QayGY|S+KzL&I6eE~g5|ahiz}ywrXtwMNZHo2 zKkrEaqT_(jB5SWgoL|6JypOtj+e@B6v9>oooNQIjzH@Q@J!$wYP|Pd5B7ZNOnzWps z-TI=f`o3k!pv-#&uZdqnwvRJK#KN+3GN1^BNWtSlxpD!_db;W*hFIB++iAUZonx2C z0SzfO@Hvijfm(*j!4&3S@EFQ9#Xl1})K zSoL$Vg?b5QvGFA4?h8}i@lB*r#cadUO`}PnuW71$pNs0l3_(^dl8&YIbi?g`?p#|` zwy>?^imfP$YzM=FIhL*wf@(;|!@?&q301$dNNk4n4qA)0Ubc0kX#rATOR5`z^qmv{ zM+NWrE2l-i>pkwd)J_}kR~gO1qeu;$QaeS3oBgOgbzYV@yi>8Gw}%TWprW1*BScCU zXoG=bp~*L3PmYpNn|J7iE<3297OWkW9&HoB0t1Wz3Gp{rbuNeMw-*a+&^1APG2q#Y zYHBi>Oo=A$MyQUo-@fl`2C|q+ymbs8Y2c>aw4lCFuq)RR-3>KyTVuBnaoNl-?QVGk z;Z}~hseY{E&|Mq5gn_rrO6lINj-W|&JRr*Kt8fK6G&II z-SK|;m0=MG66U?LeV_kC1iE1nifZb=5ZKn*mKVGn8NS!+!!nh{X;h|b%{pElMb8Gf z@kn9!!5Cd+HEX?=c?pt1?d7oUlR91DaQrMl9t~}4025z7o&mNPey=cc z@s>!}q7SvC44g>%2@?m&wCb0WyIp^8X zw#_UPxUA~GeYfGL$c##kylp%Uh z3y=anb@@b4@oE;8p+QWvFuY~FE&ijaeHHn{~5%lY7~SUt5XQlmubi1Q>%L^j9w z^Ve#`zALVJTpiXKH}q~~A>Y)%_36wsEFo^OIjP6h+MBcZH74&w2JWq>!aRj#jUWc) zdRy?q>#qO~Z+;vxm1{OSo!6ER;!nV~5|Z&BU+!Csk+0`JWO7(~9-}VR$M-l96s6%Y zGfY>k5<0Np*~1K}{*q5npr^xBXfun9FNk%0P0|^oFm#?N#Jlj+4fA){n1)Z#x0Q=*-B53i_C z1Ut6+d;_n+h4S_>;{PsyyVtZ#d{07_HxOr%(MNrkR3q_JK6`lcIHmSffWLEs;Ia={w(5#)@BQy{gvY72iF0>bgt3^ym4; zufdwZ6OyNTD_U-*v9FI1H4hSvM7Nj-xc0vHlz6qH;!6Yw(^o=(p!AkS8X@h zxXep4-#@MEb;@9P3>Q%BOKEaei$O&HYi;17=nu1av$)VC$3ot=&x?Has?m+Ho4)q` zxrmLx2g!r#A&kY23?4}qjv9|dZ%UMez7%BxqthtAk)Ma&5>iPAnjaemD}z1$=`LuG z_35cOdE?jZqgM0-N)2#QbPy)8WmOni)qQ-*F3qz zLd5y_SdRU#=5fAf5r%T6*TvXl1LKsHa&ibMr#4>=aHq4NX1 zPFI%M5$@Xl_{5gH!f}l_hpHm??2{0VRoO6+VuaNy#Qx&S0{uyhzt-s!2+}e@j+#B* znW6&EqXT07wRRuyYajVle+<=&n?nnC@ioqezB2c_^HG7HMynVEN->bl#wUUemoEoR zN-@!nmey)8h~??aY&@9NbIzV!jnXvTjr#p4H5Td~f+Tr`l$UBB$}QYn8jN|AStG7# zeMmj??E@D@A&PBAcaBzE=P~vO%}V8#k=9(97_ZGeeV>w*f6&746d#!*l$JRK8eT$# zOTT`V-o=ETX)yl!fK#uHO)L9FNodmj97)&nyc@NIK?Z&9rwd@`2RX2T8c5@%135TC z(TsqYCsJ2YWYSFc{SmA)?#4UF%F-suiONd|)_JcsF;wpoZ;m(Q&rmPgOeOOCSl+KV zQhdxd88(ApR$aU#o9VT_=b2n8$x}6lJuMC0eGU840`;A-!FHB~P&2u@U89xGI@fwd z{@IbyF45}3#P@*n^VH=%TjJp2*GRzO@{O@|f5Dz3k7Gt{cNhAgu3@@$Hzlw7ehOl6&G4H*@lzFP$2XP0$@x zTH6x5v^soV;Eh+?Bq@YpF3WErj=PKgZ37$GTu(UVpVv{|YPirDW8U2LygFYmqn(ut zajdH_YhLNk(t>8d2iot27uJVKt!!!ji?U#WW^84@&9@VY>RRPF7m_--NIv8$ z#i+I0>ag6r_)t?C(_#Og$|0n6D37zzDFG6w3W>~OaLdkDAY=*8-!Z+|<*CUSTFKP{ zpB`xajt>o|3f2b<8H&NIOCrb~ueF1ir@hm-g=(rWtGtRwPPs5L zc3Qd+XULX%FH&_!_(3Q?n?)x?sz6n8@ygiLndPCU{N>>)uZ*oVS-OkovD|foYcHk) zPYrhSri(*<`zJ;c%75j+nTZ;3lQ+Ny+=3`+v#lj*v&>Vvuy_NMr)o&9{SV1(W1TlP zGMow?YHoFFJe-`;x*?hVv-B^aewwc5{^^$n%mc(_nRT=>-xXdL6%{aY_4y(wx=rP}3i#je{myh%G`~|%8aJk*8 z(Sc7vS{S|e%OSJ!-4IUrkGR%9LKyd87n|fR{Q%DmUD zvG+AEj5cyG((`*sGgpzHL=$itenpZ;I=i4ZvZi-N9XGhfZ)V}brp#zb_a@0{rJu9S z-x-DhcRpJ#dX!5F&=Q>CcDTy>qe-Dnd)TANuY={G6CfZTGV!*6S+Ue)&MxAkwuF3^ zlEtiI^pbK$u}t|yJO{6fWZ|Fi*-}8$ZQr?{a$$K)ns_u^T-T z)pd}?tj7pD{n;NTTHtBvAvQ_hM~gVc!;32?1YbokE3_YocrG~JH1$3GcY|l5DhRT91XN3%nyRST* zi7jY^oyO5U=(uMLZOv(4k4~HG&VJgHmOG2(tnGe`Yi5<-hXjk7Q!76))i%BiVi(vu z!`k=EavECw*Pm(9Y5Hq)^Hb|XKJ*&6iM1prtpcCvqTBRBNfBKWcyO^Jq+X@4==O$l z0wFA-_nD>5he}`jk$6x6L$2j)3YcDqEyCbVrc62eZa_l-1bJN(z)NKV@pvex(nh9y zuv>BKji?u2Ki>E5;?nhTduTTe=~)od?7bf9Gv6TzY{3{8ih|YXdsiQ9zM4#o}nM#giel zW7cU->cX2_Tz^H5&JWKERfDbC7wcqeL=LUnrmuV#_f)_xs`>;nuW#0q9Z3a?^DrpYX7pI3l(jRzR@b$4Z z+TdH*2);ry@o~}^D(b2K6Rar@`wR8T$y*RNf_3km!rexmTMn7Zvf!bn7Vd}``?)P&Lf&v^;oVlS>g49EN}40E yE@as@#@c9uY81er_q8E*DA<+%XA|f)0hCkhUIr2M*bhikzHi~ literal 0 HcmV?d00001 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" } From 33e31a2599cd005b1a3fd945f6d17bfe2f625196 Mon Sep 17 00:00:00 2001 From: maxime Date: Fri, 15 Mar 2024 17:48:19 +0100 Subject: [PATCH 07/14] add three visualizer --- app/build.gradle.kts | 5 +- .../com/iqball/app/component/BasketCourt.kt | 2 - .../java/com/iqball/app/component/StepTree.kt | 142 ++++++++++++++++++ .../com/iqball/app/domains/PlayerDomains.kt | 68 +++++---- .../java/com/iqball/app/page/RegisterPage.kt | 2 - .../com/iqball/app/page/VisualizerPage.kt | 16 +- .../java/com/iqball/app/ui/theme/Color.kt | 5 +- gradle/libs.versions.toml | 15 +- settings.gradle.kts | 3 + 9 files changed, 206 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/com/iqball/app/component/StepTree.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a08c767..c895a00 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.net.URI + plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.jetbrainsKotlinAndroid) @@ -73,7 +75,8 @@ dependencies { implementation(libs.moshi.adapters) implementation(libs.converter.moshi.v250) implementation(libs.moshi.kotlin) - implementation (libs.zoomable) + implementation(libs.zoomable) + implementation(libs.compose.free.scroll) implementation(libs.retrofit.adapters.arrow) implementation(libs.arrow.core) diff --git a/app/src/main/java/com/iqball/app/component/BasketCourt.kt b/app/src/main/java/com/iqball/app/component/BasketCourt.kt index 263eafd..7c7ffce 100644 --- a/app/src/main/java/com/iqball/app/component/BasketCourt.kt +++ b/app/src/main/java/com/iqball/app/component/BasketCourt.kt @@ -50,7 +50,6 @@ fun BasketCourt(content: StepContent, type: CourtType) { ) for (component in components) { - when (component) { is PlayerLike -> { val info = getPlayerInfo(component, content) @@ -65,7 +64,6 @@ fun BasketCourt(content: StepContent, type: CourtType) { ) } } - } } diff --git a/app/src/main/java/com/iqball/app/component/StepTree.kt b/app/src/main/java/com/iqball/app/component/StepTree.kt new file mode 100644 index 0000000..fa294bc --- /dev/null +++ b/app/src/main/java/com/iqball/app/component/StepTree.kt @@ -0,0 +1,142 @@ +package com.iqball.app.component + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.chihsuanwu.freescroll.freeScroll +import com.chihsuanwu.freescroll.rememberFreeScrollState +import com.iqball.app.model.tactic.StepNodeInfo +import com.iqball.app.ui.theme.SelectedStepNode +import com.iqball.app.ui.theme.StepNode + +@Composable +fun StepsTree(root: StepNodeInfo, selectedNodeId: Int, onNodeSelected: (StepNodeInfo) -> Unit) { + + val scrollState = rememberFreeScrollState() + val nodesOffsets = remember { mutableStateMapOf() } + var globalOffset by remember { mutableStateOf(Offset(0F, 0F)) } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.LightGray) + .freeScroll(scrollState) + .onGloballyPositioned { globalOffset = it.boundsInRoot().topLeft } + .drawWithContent { + + if (nodesOffsets.isEmpty()) { + drawContent() + return@drawWithContent + } + + val toDraw = mutableListOf(root) + while (toDraw.isNotEmpty()) { + val parent = toDraw.removeLast() + val parentCenter = nodesOffsets[parent]!!.center.minus(globalOffset) + for (children in parent.children) { + val childrenCenter = nodesOffsets[children]!!.center.minus(globalOffset) + Log.d("STATE", "$parentCenter, $childrenCenter") + drawLine(Color.Black, start = parentCenter, end = childrenCenter, strokeWidth = 5F) + toDraw += children + } + } + + drawContent() + + }, + contentAlignment = Alignment.TopCenter + ) { + StepsTreeContent(root, selectedNodeId, onNodeSelected, nodesOffsets) + } +} + +@Composable +private fun StepsTreeContent( + node: StepNodeInfo, + selectedNodeId: Int, + onNodeSelected: (StepNodeInfo) -> Unit, + nodesOffsets: MutableMap +) { + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + ) { + StepPiece( + node = node, + isSelected = selectedNodeId == node.id, + onNodeSelected = { onNodeSelected(node) }, + modifier = Modifier + .padding(10.dp) + .onGloballyPositioned { + nodesOffsets[node] = it.boundsInRoot() + } + ) + + Row( + modifier = Modifier + .padding(top = 50.dp) + ) { + for (children in node.children) { + StepsTreeContent( + node = children, + selectedNodeId = selectedNodeId, + onNodeSelected = onNodeSelected, + nodesOffsets = nodesOffsets + ) + } + } + } +} + +@Composable +fun StepPiece( + node: StepNodeInfo, + isSelected: Boolean, + onNodeSelected: () -> Unit, + modifier: Modifier = Modifier +) { + val color = if (isSelected) SelectedStepNode else StepNode + + return Surface( + shape = CircleShape, + modifier = modifier.clickable { + onNodeSelected() + } + ) { + Text( + text = node.id.toString(), + textAlign = TextAlign.Center, + color = if (isSelected) Color.White else Color.Black, + modifier = Modifier + .background(color) + .size(PlayerPieceDiameterDp.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/iqball/app/domains/PlayerDomains.kt b/app/src/main/java/com/iqball/app/domains/PlayerDomains.kt index a0ca3a3..deed797 100644 --- a/app/src/main/java/com/iqball/app/domains/PlayerDomains.kt +++ b/app/src/main/java/com/iqball/app/domains/PlayerDomains.kt @@ -23,39 +23,41 @@ import com.iqball.app.model.tactic.StepContent * @throws MalformedStepContentException if the step content contains incoherent data */ fun computePhantomPosition(phantom: PhantomComponent, content: StepContent): Vector { - val pos = phantom.pos - if (pos is FixedPosition) - return pos.toPos() - - pos as RelativePositioning - - val phantomBefore = getPlayerBefore(phantom, content)!! - - val referentId = pos.attach - val actions = phantomBefore.actions - val linkAction = actions.find { it.target.isLeft(referentId::equals) } - ?: throw MalformedStepContentException("phantom ${phantom.id} is casted by ${phantom}, but there is no action between them.") - - val segments = linkAction.segments - val lastSegment = segments.last() - - val referent = content.findComponent(referentId)!! - val referentPos = computeComponentPosition(referent, content) - val directionalPos = lastSegment.controlPoint - ?: segments.elementAtOrNull(segments.size - 2) - ?.next - ?.mapLeft { computeComponentPosition(content.findComponent(it)!!, content) } - ?.merge() - ?: computeComponentPosition(phantomBefore, content) - - val axisSegment = (referentPos - directionalPos) - val segmentLength = axisSegment.norm() - val projectedVector = Vector( - x = (axisSegment.x / segmentLength) * 0.05, - y = (axisSegment.y / segmentLength) * 0.05, - ) - - return referentPos + projectedVector + return when (val pos = phantom.pos) { + is FixedPosition -> pos.toPos() + + is RelativePositioning -> { + val phantomBefore = getPlayerBefore(phantom, content)!! + + val referentId = pos.attach + val actions = phantomBefore.actions + val linkAction = actions.find { it.target.isLeft(referentId::equals) } + ?: throw MalformedStepContentException("phantom ${phantom.id} is casted by ${phantom}, but there is no action between them.") + + val segments = linkAction.segments + val lastSegment = segments.last() + + val referent = content.findComponent(referentId)!! + val referentPos = computeComponentPosition(referent, content) + val directionalPos = lastSegment.controlPoint + ?: segments.elementAtOrNull(segments.size - 2) + ?.next + ?.mapLeft { computeComponentPosition(content.findComponent(it)!!, content) } + ?.merge() + ?: computeComponentPosition(phantomBefore, content) + + val axisSegment = (referentPos - directionalPos) + val segmentLength = axisSegment.norm() + val projectedVector = Vector( + x = (axisSegment.x / segmentLength) * 0.05, + y = (axisSegment.y / segmentLength) * 0.05, + ) + + referentPos + projectedVector + } + } + + } fun computeComponentPosition(component: StepComponent, content: StepContent): Vector = diff --git a/app/src/main/java/com/iqball/app/page/RegisterPage.kt b/app/src/main/java/com/iqball/app/page/RegisterPage.kt index 2627018..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,9 +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.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 index ce7bac8..1e84849 100644 --- a/app/src/main/java/com/iqball/app/page/VisualizerPage.kt +++ b/app/src/main/java/com/iqball/app/page/VisualizerPage.kt @@ -15,6 +15,11 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -22,6 +27,7 @@ import androidx.compose.ui.platform.LocalConfiguration import arrow.core.Either import arrow.core.flatMap import com.iqball.app.component.BasketCourt +import com.iqball.app.component.StepsTree import com.iqball.app.model.TacticInfo import com.iqball.app.model.tactic.CourtType import com.iqball.app.model.tactic.StepContent @@ -53,15 +59,15 @@ fun VisualizerPage( } val screenOrientation = LocalConfiguration.current.orientation + var selectedStepId by remember { mutableIntStateOf(rootStep.id) } Column { VisualizerHeader(title = info.name) when (screenOrientation) { - Configuration.ORIENTATION_PORTRAIT -> Text( - text = "Visualizing Tactic Steps tree", - modifier = Modifier - .fillMaxSize() - .background(Color.Green) + Configuration.ORIENTATION_PORTRAIT -> StepsTree( + root = rootStep, + selectedNodeId = selectedStepId, + onNodeSelected = { selectedStepId = it.id } ) Configuration.ORIENTATION_LANDSCAPE -> BasketCourt( diff --git a/app/src/main/java/com/iqball/app/ui/theme/Color.kt b/app/src/main/java/com/iqball/app/ui/theme/Color.kt index 9f29911..17833f0 100644 --- a/app/src/main/java/com/iqball/app/ui/theme/Color.kt +++ b/app/src/main/java/com/iqball/app/ui/theme/Color.kt @@ -12,4 +12,7 @@ val Pink40 = Color(0xFF7D5260) val Allies = Color(0xFF64e4f5) val Opponents = Color(0xFFf59264) -val BallColor = Color(0XFFc5520d) \ No newline at end of file +val BallColor = Color(0XFFc5520d) + +val StepNode = Color(0xFF2AC008) +val SelectedStepNode = Color(0xFF213519) \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 412a487..3a9e53e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,19 +1,20 @@ [versions] agp = "8.2.2" arrowCore = "1.2.1" +composeFreeScroll = "0.2.2" converterGson = "2.9.0" -converterMoshi = "2.4.0" +converterMoshi = "2.5.0" converterMoshiVersion = "2.5.0" kotlin = "1.9.0" -coreKtx = "1.10.1" +coreKtx = "1.12.0" junit = "4.13.2" junitVersion = "1.1.5" espressoCore = "3.5.1" kotlinxDatetime = "0.3.2" kotlinxSerializationJsonJvm = "1.6.3" -lifecycleRuntimeKtx = "2.6.1" -activityCompose = "1.7.0" -composeBom = "2023.08.00" +lifecycleRuntimeKtx = "2.7.0" +activityCompose = "1.8.2" +composeBom = "2024.02.02" moshi = "1.15.1" moshiAdapters = "1.15.1" moshiKotlin = "1.15.1" @@ -26,8 +27,7 @@ zoomable = "1.6.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrowCore" } -converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } -converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "converterMoshi" } +compose-free-scroll = { module = "com.github.chihsuanwu:compose-free-scroll", version.ref = "composeFreeScroll" } converter-moshi-v250 = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "converterMoshiVersion" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } @@ -43,7 +43,6 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } -kotlinx-serialization-json-jvm = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-jvm", version.ref = "kotlinxSerializationJsonJvm" } moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } moshi-adapters = { module = "com.squareup.moshi:moshi-adapters", version.ref = "moshiAdapters" } moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshiKotlin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index ef8f906..7071bcf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,5 @@ +import java.net.URI + pluginManagement { repositories { google { @@ -16,6 +18,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = URI.create("https://jitpack.io") } } } From 2c721de548d6b9ca6e71e865a9f01e4cc8574741 Mon Sep 17 00:00:00 2001 From: maxime Date: Sat, 16 Mar 2024 20:58:18 +0100 Subject: [PATCH 08/14] be able to select a specific step from the tree --- .../java/com/iqball/app/component/StepTree.kt | 3 -- .../com/iqball/app/page/VisualizerPage.kt | 52 ++++++++++--------- 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/iqball/app/component/StepTree.kt b/app/src/main/java/com/iqball/app/component/StepTree.kt index fa294bc..869a353 100644 --- a/app/src/main/java/com/iqball/app/component/StepTree.kt +++ b/app/src/main/java/com/iqball/app/component/StepTree.kt @@ -1,6 +1,5 @@ package com.iqball.app.component -import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -24,7 +23,6 @@ import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.boundsInParent import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.style.TextAlign @@ -61,7 +59,6 @@ fun StepsTree(root: StepNodeInfo, selectedNodeId: Int, onNodeSelected: (StepNode val parentCenter = nodesOffsets[parent]!!.center.minus(globalOffset) for (children in parent.children) { val childrenCenter = nodesOffsets[children]!!.center.minus(globalOffset) - Log.d("STATE", "$parentCenter, $childrenCenter") drawLine(Color.Black, start = parentCenter, end = childrenCenter, strokeWidth = 5F) toDraw += children } diff --git a/app/src/main/java/com/iqball/app/page/VisualizerPage.kt b/app/src/main/java/com/iqball/app/page/VisualizerPage.kt index 1e84849..882b1a5 100644 --- a/app/src/main/java/com/iqball/app/page/VisualizerPage.kt +++ b/app/src/main/java/com/iqball/app/page/VisualizerPage.kt @@ -1,13 +1,11 @@ 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 @@ -17,20 +15,18 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue 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.component.StepsTree 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 @@ -42,7 +38,6 @@ import java.time.ZoneId private data class VisualizerInitialData( val info: TacticInfo, val rootStep: StepNodeInfo, - val rootContent: StepContent ) @Composable @@ -51,15 +46,33 @@ fun VisualizerPage( auth: Token, tacticId: Int, ) { - val dataEither = initializeVisualizer(service, auth, tacticId) + val dataEither = remember { initializeVisualizer(service, auth, tacticId) } - val (info, rootStep, rootContent) = when (dataEither) { + val (info, rootStep) = 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 } val screenOrientation = LocalConfiguration.current.orientation - var selectedStepId by remember { mutableIntStateOf(rootStep.id) } + var selectedStepId by rememberSaveable { mutableIntStateOf(rootStep.id) } + val content = remember(selectedStepId) { + runBlocking { + val result = service.getTacticStepContent(auth, tacticId, selectedStepId) + .onLeft { + Log.e( + "received error response from server when retrieving root content: {}", + it.toString() + ) + } + when (result) { + is Either.Left -> throw Error("Unexpected error") + is Either.Right -> result.value + } + } + } + + Log.d("CONTENT", content.toString()) Column { VisualizerHeader(title = info.name) @@ -71,7 +84,7 @@ fun VisualizerPage( ) Configuration.ORIENTATION_LANDSCAPE -> BasketCourt( - content = rootContent, + content = content, type = info.type ) @@ -111,7 +124,7 @@ private fun initializeVisualizer( auth: Token, tacticId: Int ): Either { - val (tacticInfo, tacticTree, rootStepContent) = runBlocking { + val (tacticInfo, tacticTree) = runBlocking { val tacticInfo = service.getTacticInfo(auth, tacticId) .map { TacticInfo( @@ -141,24 +154,13 @@ private fun initializeVisualizer( ) } - 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()) + Pair(tacticInfo.getOrNull(), tacticTree.getOrNull()) } - if (tacticInfo == null || tacticTree == null || rootStepContent == null) { + if (tacticInfo == null || tacticTree == null) { return Either.Left("Unable to retrieve tactic information") } - return Either.Right(VisualizerInitialData(tacticInfo, tacticTree, rootStepContent)) + return Either.Right(VisualizerInitialData(tacticInfo, tacticTree)) } \ No newline at end of file From f32278246ca6951e44a3f74182cfa276965d240b Mon Sep 17 00:00:00 2001 From: maxime Date: Sun, 17 Mar 2024 17:49:43 +0100 Subject: [PATCH 09/14] add arrows visualisation --- .../java/com/iqball/app/component/Actions.kt | 330 ++++++++++++++++++ .../com/iqball/app/component/BasketCourt.kt | 92 +++-- .../java/com/iqball/app/component/StepTree.kt | 21 +- .../com/iqball/app/domains/PlayerDomains.kt | 6 +- app/src/main/java/com/iqball/app/geo/Area.kt | 3 - .../main/java/com/iqball/app/geo/Vector.kt | 36 +- .../com/iqball/app/model/tactic/Action.kt | 1 + .../iqball/app/model/tactic/Positioning.kt | 2 +- .../app/serialization/EitherTypeAdapter.kt | 50 ++- gradle/libs.versions.toml | 2 + 10 files changed, 484 insertions(+), 59 deletions(-) create mode 100644 app/src/main/java/com/iqball/app/component/Actions.kt delete mode 100644 app/src/main/java/com/iqball/app/geo/Area.kt 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..6ae6025 --- /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 +) { + 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.Black, + style = strokeStyle + ) + } else { + drawer.drawPath( + path = computeSegmentsToPath( + originPos, + action.segments, + offsets, + area, + type == ActionType.Dribble, + playersPixelsRadius + ), + color = Color.Black, + 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 in segments.indices) { + val segment = segments[i] + 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/BasketCourt.kt b/app/src/main/java/com/iqball/app/component/BasketCourt.kt index 7c7ffce..cb17aaf 100644 --- a/app/src/main/java/com/iqball/app/component/BasketCourt.kt +++ b/app/src/main/java/com/iqball/app/component/BasketCourt.kt @@ -2,19 +2,32 @@ 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.IntrinsicSize import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width 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.platform.LocalDensity import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp 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 @@ -31,40 +44,73 @@ fun BasketCourt(content: StepContent, type: CourtType) { val zoomState = rememberZoomState() val components = content.components - Row( + val componentsOffset = remember { mutableStateMapOf() } + var courtArea by remember { mutableStateOf(Rect.Zero) } + + val playersPixelsRadius = LocalDensity.current.run { PlayerPieceDiameterDp.dp.toPx() / 2 } + + Box( modifier = Modifier .background(Color.LightGray) - .fillMaxSize() - .zoomable(zoomState), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically + .fillMaxSize(), + contentAlignment = Alignment.Center ) { - Box { + Box( + modifier = Modifier + .width(IntrinsicSize.Min) + .zoomable(zoomState), + contentAlignment = Alignment.Center, + ) { Image( painter = painterResource(id = courtImg), contentDescription = "court", modifier = Modifier .background(Color.White) - .align(Alignment.Center) - .fillMaxHeight() + .fillMaxSize() ) - for (component in components) { - when (component) { - is PlayerLike -> { - val info = getPlayerInfo(component, content) - PlayerPiece( - player = info, - modifier = Modifier.align(info.pos.toBiasAlignment()) - ) + Box( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { + if (courtArea == Rect.Zero) + courtArea = it.boundsInRoot() + } + .drawWithContent { + val relativeOffsets = + componentsOffset.mapValues { (it.value - courtArea.topLeft).toVector() } + drawActions(this, content, relativeOffsets, courtArea, playersPixelsRadius) + drawContent() } + ) { + for (component in components) { + val modifier = Modifier + .onGloballyPositioned { + if (!componentsOffset.containsKey(component.id)) + componentsOffset[component.id] = it.boundsInRoot().center + } + 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()) - ) + ) + } + + 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/StepTree.kt b/app/src/main/java/com/iqball/app/component/StepTree.kt index 869a353..21a55af 100644 --- a/app/src/main/java/com/iqball/app/component/StepTree.kt +++ b/app/src/main/java/com/iqball/app/component/StepTree.kt @@ -38,14 +38,17 @@ fun StepsTree(root: StepNodeInfo, selectedNodeId: Int, onNodeSelected: (StepNode val scrollState = rememberFreeScrollState() val nodesOffsets = remember { mutableStateMapOf() } - var globalOffset by remember { mutableStateOf(Offset(0F, 0F)) } + var globalOffset by remember { mutableStateOf(Offset.Zero) } Box( modifier = Modifier .fillMaxSize() .background(Color.LightGray) .freeScroll(scrollState) - .onGloballyPositioned { globalOffset = it.boundsInRoot().topLeft } + .onGloballyPositioned { + if (globalOffset == Offset.Zero) + globalOffset = it.boundsInRoot().topLeft + } .drawWithContent { if (nodesOffsets.isEmpty()) { @@ -56,10 +59,15 @@ fun StepsTree(root: StepNodeInfo, selectedNodeId: Int, onNodeSelected: (StepNode val toDraw = mutableListOf(root) while (toDraw.isNotEmpty()) { val parent = toDraw.removeLast() - val parentCenter = nodesOffsets[parent]!!.center.minus(globalOffset) + val parentCenter = nodesOffsets[parent]!!.center - globalOffset for (children in parent.children) { - val childrenCenter = nodesOffsets[children]!!.center.minus(globalOffset) - drawLine(Color.Black, start = parentCenter, end = childrenCenter, strokeWidth = 5F) + val childrenCenter = nodesOffsets[children]!!.center - globalOffset + drawLine( + Color.Black, + start = parentCenter, + end = childrenCenter, + strokeWidth = 5F + ) toDraw += children } } @@ -92,7 +100,8 @@ private fun StepsTreeContent( modifier = Modifier .padding(10.dp) .onGloballyPositioned { - nodesOffsets[node] = it.boundsInRoot() + if (!nodesOffsets.containsKey(node)) + nodesOffsets[node] = it.boundsInRoot() } ) diff --git a/app/src/main/java/com/iqball/app/domains/PlayerDomains.kt b/app/src/main/java/com/iqball/app/domains/PlayerDomains.kt index deed797..c084757 100644 --- a/app/src/main/java/com/iqball/app/domains/PlayerDomains.kt +++ b/app/src/main/java/com/iqball/app/domains/PlayerDomains.kt @@ -46,11 +46,11 @@ fun computePhantomPosition(phantom: PhantomComponent, content: StepContent): Vec ?.merge() ?: computeComponentPosition(phantomBefore, content) - val axisSegment = (referentPos - directionalPos) + val axisSegment = (directionalPos - referentPos) val segmentLength = axisSegment.norm() val projectedVector = Vector( - x = (axisSegment.x / segmentLength) * 0.05, - y = (axisSegment.y / segmentLength) * 0.05, + x = (axisSegment.x / segmentLength) * 0.05F, + y = (axisSegment.y / segmentLength) * 0.05F, ) referentPos + projectedVector diff --git a/app/src/main/java/com/iqball/app/geo/Area.kt b/app/src/main/java/com/iqball/app/geo/Area.kt deleted file mode 100644 index af0e53c..0000000 --- a/app/src/main/java/com/iqball/app/geo/Area.kt +++ /dev/null @@ -1,3 +0,0 @@ -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 index a768a68..9140f54 100644 --- a/app/src/main/java/com/iqball/app/geo/Vector.kt +++ b/app/src/main/java/com/iqball/app/geo/Vector.kt @@ -1,25 +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: Double, val y: Double) { +data class Vector(val x: Float, val y: Float) { fun toBiasAlignment(): BiasAlignment = - BiasAlignment((x * 2 - 1).toFloat(), (y * 2 - 1).toFloat()) + 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) - fun distanceWith(other: Vector) = + 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.distanceWith(this) + fun norm() = NullVector.distanceFrom(this) + + fun posWithinArea(area: Rect) = Vector(x * area.width, y * area.height) - 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) + fun toOffset() = Offset(x, y) } -val NullVector = Vector(.0, .0) \ No newline at end of file +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/tactic/Action.kt b/app/src/main/java/com/iqball/app/model/tactic/Action.kt index bfa35c5..9a68d9e 100644 --- a/app/src/main/java/com/iqball/app/model/tactic/Action.kt +++ b/app/src/main/java/com/iqball/app/model/tactic/Action.kt @@ -1,6 +1,7 @@ package com.iqball.app.model.tactic import arrow.core.Either +import arrow.core.NonEmptyList import com.iqball.app.geo.Vector 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 index e9be5bf..ed02c62 100644 --- a/app/src/main/java/com/iqball/app/model/tactic/Positioning.kt +++ b/app/src/main/java/com/iqball/app/model/tactic/Positioning.kt @@ -4,6 +4,6 @@ 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 { +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/serialization/EitherTypeAdapter.kt b/app/src/main/java/com/iqball/app/serialization/EitherTypeAdapter.kt index 0ad4d36..6854a60 100644 --- a/app/src/main/java/com/iqball/app/serialization/EitherTypeAdapter.kt +++ b/app/src/main/java/com/iqball/app/serialization/EitherTypeAdapter.kt @@ -2,27 +2,46 @@ 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.ClassCastException import java.lang.reflect.ParameterizedType import java.lang.reflect.Type -private class EitherTypeAdapter(val leftType: JsonPrimitiveType, val rightType: JsonPrimitiveType) : - JsonAdapter>() { +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 value = reader.readJsonValue() ?: return null - val valueJsonType = value.javaClass.getJsonPrimitive() - if (valueJsonType == leftType) + 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 -> return null + else -> throw JsonDataException("unexpected token : $token") + } + + if (valueJsonType == leftJsonType) { + val value = moshi.adapter(leftType).fromJson(reader) return Either.Left(value) + } - if (valueJsonType == rightType) + if (valueJsonType == rightJsonType) { + val value = moshi.adapter(rightType).fromJson(reader) return Either.Right(value) - throw ClassCastException("Cannot cast a value of type " + value.javaClass + " as either " + leftType.name.lowercase() + " or " + rightType.name.lowercase()) + } + throw ClassCastException("Cannot cast a json value of type " + valueJsonType + " as either " + leftType.name.lowercase() + " or " + rightType.name.lowercase()) } @@ -37,7 +56,7 @@ private class EitherTypeAdapter(val leftType: JsonPrimitiveType, val rightType: } -object EitherTypeAdapterFactory: JsonAdapter.Factory { +object EitherTypeAdapterFactory : JsonAdapter.Factory { override fun create( type: Type, annotations: MutableSet, @@ -47,12 +66,12 @@ object EitherTypeAdapterFactory: JsonAdapter.Factory { 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) { + 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) + return EitherTypeAdapter(leftType, rightType, moshi) } } @@ -60,12 +79,13 @@ private enum class JsonPrimitiveType { Array, Object, String, - Number; + Number, + Boolean } private fun Class<*>.getJsonPrimitive(): JsonPrimitiveType { if (isPrimitive) - return JsonPrimitiveType.Number + return if (java.lang.Boolean.TYPE == this) JsonPrimitiveType.Boolean else JsonPrimitiveType.Number if (isArray) return JsonPrimitiveType.Array if (this == String::class.java) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3a9e53e..ca4c795 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,7 @@ 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.12.0" junit = "4.13.2" @@ -26,6 +27,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" } 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" } From ec57f0b7de80c75d98c80387714cd00211e992e6 Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Thu, 21 Mar 2024 11:19:42 +0100 Subject: [PATCH 10/14] Steps tree is now displayed alongside the visualizer --- .../main/java/com/iqball/app/MainActivity.kt | 2 +- .../com/iqball/app/component/BasketCourt.kt | 41 +++--- .../java/com/iqball/app/component/StepTree.kt | 1 - .../com/iqball/app/page/VisualizerPage.kt | 132 +++++++++++------- 4 files changed, 106 insertions(+), 70 deletions(-) diff --git a/app/src/main/java/com/iqball/app/MainActivity.kt b/app/src/main/java/com/iqball/app/MainActivity.kt index c9cf4bd..0ccbd39 100644 --- a/app/src/main/java/com/iqball/app/MainActivity.kt +++ b/app/src/main/java/com/iqball/app/MainActivity.kt @@ -74,7 +74,7 @@ class MainActivity : ComponentActivity() { .addConverterFactory(EitherBodyConverter.create()) .addConverterFactory(MoshiConverterFactory.create(moshi)) .addCallAdapterFactory(EitherCallAdapterFactory.create()) - .baseUrl("http://grospc:5254/") + .baseUrl("http://192.168.127.83:5254/") .client( OkHttpClient.Builder() .addInterceptor { it.proceed(it.request()) } diff --git a/app/src/main/java/com/iqball/app/component/BasketCourt.kt b/app/src/main/java/com/iqball/app/component/BasketCourt.kt index cb17aaf..8b5d87c 100644 --- a/app/src/main/java/com/iqball/app/component/BasketCourt.kt +++ b/app/src/main/java/com/iqball/app/component/BasketCourt.kt @@ -3,14 +3,10 @@ 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.width import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState 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 @@ -31,42 +27,51 @@ 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.rememberZoomState import net.engawapg.lib.zoomable.zoomable +data class BasketCourtStates( + val componentsOffsets: MutableMap, + val courtArea: MutableState, + val zoomState: ZoomState +) + @Composable -fun BasketCourt(content: StepContent, type: CourtType) { +fun BasketCourt( + content: StepContent, + type: CourtType, + modifier: Modifier, + state: BasketCourtStates +) { val courtImg = when (type) { CourtType.Plain -> R.drawable.plain_court CourtType.Half -> R.drawable.half_court } - val zoomState = rememberZoomState() + val zoomState = state.zoomState val components = content.components - val componentsOffset = remember { mutableStateMapOf() } - var courtArea by remember { mutableStateOf(Rect.Zero) } + val componentsOffset = state.componentsOffsets + var courtArea by state.courtArea val playersPixelsRadius = LocalDensity.current.run { PlayerPieceDiameterDp.dp.toPx() / 2 } Box( - modifier = Modifier - .background(Color.LightGray) - .fillMaxSize(), + modifier = modifier, 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) - .fillMaxSize() ) Box( @@ -83,8 +88,11 @@ fun BasketCourt(content: StepContent, type: CourtType) { drawContent() } ) { + + + for (component in components) { - val modifier = Modifier + val componentModifier = Modifier .onGloballyPositioned { if (!componentsOffset.containsKey(component.id)) componentsOffset[component.id] = it.boundsInRoot().center @@ -94,9 +102,8 @@ fun BasketCourt(content: StepContent, type: CourtType) { val info = getPlayerInfo(component, content) PlayerPiece( player = info, - modifier = modifier + modifier = componentModifier .align(info.pos.toBiasAlignment()) - ) } diff --git a/app/src/main/java/com/iqball/app/component/StepTree.kt b/app/src/main/java/com/iqball/app/component/StepTree.kt index 21a55af..c8fec61 100644 --- a/app/src/main/java/com/iqball/app/component/StepTree.kt +++ b/app/src/main/java/com/iqball/app/component/StepTree.kt @@ -43,7 +43,6 @@ fun StepsTree(root: StepNodeInfo, selectedNodeId: Int, onNodeSelected: (StepNode Box( modifier = Modifier .fillMaxSize() - .background(Color.LightGray) .freeScroll(scrollState) .onGloballyPositioned { if (globalOffset == Offset.Zero) diff --git a/app/src/main/java/com/iqball/app/page/VisualizerPage.kt b/app/src/main/java/com/iqball/app/page/VisualizerPage.kt index 882b1a5..6fe5499 100644 --- a/app/src/main/java/com/iqball/app/page/VisualizerPage.kt +++ b/app/src/main/java/com/iqball/app/page/VisualizerPage.kt @@ -5,32 +5,45 @@ 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.StepNodeInfo import com.iqball.app.net.service.TacticService import com.iqball.app.session.Token import kotlinx.coroutines.runBlocking +import net.engawapg.lib.zoomable.rememberZoomState import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId @@ -47,6 +60,7 @@ fun VisualizerPage( tacticId: Int, ) { val dataEither = remember { initializeVisualizer(service, auth, tacticId) } + val showTree = remember { mutableStateOf(true) } val (info, rootStep) = when (dataEither) { // On error return a text to print it to the user @@ -58,13 +72,12 @@ fun VisualizerPage( var selectedStepId by rememberSaveable { mutableIntStateOf(rootStep.id) } val content = remember(selectedStepId) { runBlocking { - val result = service.getTacticStepContent(auth, tacticId, selectedStepId) - .onLeft { - Log.e( - "received error response from server when retrieving root content: {}", - it.toString() - ) - } + val result = service.getTacticStepContent(auth, tacticId, selectedStepId).onLeft { + Log.e( + "received error response from server when retrieving root content: {}", + it.toString() + ) + } when (result) { is Either.Left -> throw Error("Unexpected error") is Either.Right -> result.value @@ -72,21 +85,42 @@ fun VisualizerPage( } } - Log.d("CONTENT", content.toString()) Column { - VisualizerHeader(title = info.name) + VisualizerHeader(title = info.name, showTree) when (screenOrientation) { - Configuration.ORIENTATION_PORTRAIT -> StepsTree( - root = rootStep, + Configuration.ORIENTATION_PORTRAIT -> StepsTree(root = rootStep, selectedNodeId = selectedStepId, - onNodeSelected = { selectedStepId = it.id } - ) + onNodeSelected = { selectedStepId = it.id }) + + Configuration.ORIENTATION_LANDSCAPE -> { + val courtOffsets = + remember(showTree.value, content) { mutableStateMapOf() } + val courtArea = remember(showTree.value) { mutableStateOf(Rect.Zero) } + val courtZoomState = rememberZoomState() + remember(showTree.value, content) { + runBlocking { + courtZoomState.reset() + } + } - Configuration.ORIENTATION_LANDSCAPE -> BasketCourt( - content = content, - type = info.type - ) + val courtModifier = + if (showTree.value) Modifier.width(IntrinsicSize.Min) else Modifier.fillMaxWidth() + + Row(modifier = Modifier.background(Color.LightGray)) { + BasketCourt( + content = content, + type = info.type, + modifier = courtModifier, + state = BasketCourtStates(courtOffsets, courtArea, courtZoomState) + ) + if (showTree.value) { + StepsTree(root = rootStep, + selectedNodeId = selectedStepId, + onNodeSelected = { selectedStepId = it.id }) + } + } + } else -> throw Exception("Could not determine device's orientation.") } @@ -95,17 +129,18 @@ fun VisualizerPage( } @Composable -private fun VisualizerHeader(title: String) { +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*/ } - ) { + IconButton(onClick = { /*TODO*/ }) { Icon( imageVector = Icons.Filled.ArrowBack, contentDescription = "Back", @@ -115,44 +150,39 @@ private fun VisualizerHeader(title: String) { Text(text = title, color = Color.Black) - Text(text = "") + 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 + 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() - ) + 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() - ) - } + ) + }.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 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()) } From fc9a2181e93e5464967b27a268ab3624ad26d88f Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Thu, 21 Mar 2024 11:50:07 +0100 Subject: [PATCH 11/14] visualize parent's step with a grey color --- .../java/com/iqball/app/component/Actions.kt | 9 +- .../com/iqball/app/component/BasketCourt.kt | 114 +++++++++++------- .../com/iqball/app/component/PlayerPiece.kt | 4 +- .../iqball/app/model/tactic/StepNodeInfo.kt | 14 +++ .../com/iqball/app/page/VisualizerPage.kt | 59 +++++---- 5 files changed, 123 insertions(+), 77 deletions(-) diff --git a/app/src/main/java/com/iqball/app/component/Actions.kt b/app/src/main/java/com/iqball/app/component/Actions.kt index 6ae6025..6460dc0 100644 --- a/app/src/main/java/com/iqball/app/component/Actions.kt +++ b/app/src/main/java/com/iqball/app/component/Actions.kt @@ -26,9 +26,10 @@ private const val ArrowHeadHeightPx = 35F fun drawActions( drawer: ContentDrawScope, content: StepContent, - offsets: Map, + offsets: Map, area: Rect, - playersPixelsRadius: Float + playersPixelsRadius: Float, + color: Color ) { for (component in content.components) { val originPos = offsets[component.id]!! @@ -79,7 +80,7 @@ fun drawActions( path.lineTo(pathEnd.x, pathEnd.y) drawer.drawPath( path = path, - color = Color.Black, + color = color, style = strokeStyle ) } else { @@ -92,7 +93,7 @@ fun drawActions( type == ActionType.Dribble, playersPixelsRadius ), - color = Color.Black, + color = color, style = strokeStyle ) } diff --git a/app/src/main/java/com/iqball/app/component/BasketCourt.kt b/app/src/main/java/com/iqball/app/component/BasketCourt.kt index 8b5d87c..f6876cf 100644 --- a/app/src/main/java/com/iqball/app/component/BasketCourt.kt +++ b/app/src/main/java/com/iqball/app/component/BasketCourt.kt @@ -28,11 +28,11 @@ 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.rememberZoomState import net.engawapg.lib.zoomable.zoomable data class BasketCourtStates( - val componentsOffsets: MutableMap, + val stepComponentsOffsets: MutableMap, + val parentComponentsOffsets: MutableMap, val courtArea: MutableState, val zoomState: ZoomState ) @@ -40,6 +40,7 @@ data class BasketCourtStates( @Composable fun BasketCourt( content: StepContent, + parentContent: StepContent?, type: CourtType, modifier: Modifier, state: BasketCourtStates @@ -50,12 +51,6 @@ fun BasketCourt( } val zoomState = state.zoomState - val components = content.components - - val componentsOffset = state.componentsOffsets - var courtArea by state.courtArea - - val playersPixelsRadius = LocalDensity.current.run { PlayerPieceDiameterDp.dp.toPx() / 2 } Box( modifier = modifier, @@ -74,49 +69,76 @@ fun BasketCourt( .background(Color.White) ) - Box( - modifier = Modifier - .fillMaxSize() - .onGloballyPositioned { - if (courtArea == Rect.Zero) - courtArea = it.boundsInRoot() - } - .drawWithContent { - val relativeOffsets = - componentsOffset.mapValues { (it.value - courtArea.topLeft).toVector() } - drawActions(this, content, relativeOffsets, courtArea, playersPixelsRadius) - drawContent() - } - ) { + CourtContent( + courtAreaState = state.courtArea, + content = content, + offsets = state.stepComponentsOffsets, + isFromParent = false + ) + if (parentContent != null) { + CourtContent( + courtAreaState = state.courtArea, + content = parentContent, + offsets = state.parentComponentsOffsets, + isFromParent = true + ) + } + } + } +} +@Composable +private fun CourtContent( + courtAreaState: MutableState, + content: StepContent, + offsets: MutableMap, + isFromParent: Boolean +) { + var courtArea by courtAreaState + val playersPixelsRadius = LocalDensity.current.run { PlayerPieceDiameterDp.dp.toPx() / 2 } + + Box( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { + if (courtArea == Rect.Zero) + courtArea = it.boundsInRoot() + } + .drawWithContent { + val relativeOffsets = + offsets.mapValues { (it.value - courtArea.topLeft).toVector() } + drawActions(this, content, relativeOffsets, courtArea, playersPixelsRadius, if (isFromParent) Color.Gray else Color.Black) + drawContent() + } + ) { - for (component in components) { - val componentModifier = Modifier - .onGloballyPositioned { - if (!componentsOffset.containsKey(component.id)) - componentsOffset[component.id] = it.boundsInRoot().center - } - when (component) { - is PlayerLike -> { - val info = getPlayerInfo(component, content) - PlayerPiece( - player = info, - modifier = componentModifier - .align(info.pos.toBiasAlignment()) - ) - } - is BallComponent -> BallPiece( - modifier = modifier - .align( - component.pos - .toPos() - .toBiasAlignment() - ) - ) - } + for (component in content.components) { + val componentModifier = Modifier + .onGloballyPositioned { + if (!offsets.containsKey(component.id)) + offsets[component.id] = it.boundsInRoot().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() + ) + ) } } } diff --git a/app/src/main/java/com/iqball/app/component/PlayerPiece.kt b/app/src/main/java/com/iqball/app/component/PlayerPiece.kt index 0629b94..b679a3f 100644 --- a/app/src/main/java/com/iqball/app/component/PlayerPiece.kt +++ b/app/src/main/java/com/iqball/app/component/PlayerPiece.kt @@ -20,9 +20,9 @@ import com.iqball.app.ui.theme.Opponents const val PlayerPieceDiameterDp = 25 @Composable -fun PlayerPiece(player: PlayerInfo, modifier: Modifier = Modifier) { +fun PlayerPiece(player: PlayerInfo, modifier: Modifier = Modifier, isFromParent: Boolean) { - val color = if (player.team === PlayerTeam.Allies) Allies else Opponents + val color = if (isFromParent) Color.LightGray else if (player.team === PlayerTeam.Allies) Allies else Opponents return Surface( shape = CircleShape, 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 index 3c3b7bf..f91931d 100644 --- a/app/src/main/java/com/iqball/app/model/tactic/StepNodeInfo.kt +++ b/app/src/main/java/com/iqball/app/model/tactic/StepNodeInfo.kt @@ -1,3 +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/page/VisualizerPage.kt b/app/src/main/java/com/iqball/app/page/VisualizerPage.kt index 6fe5499..2159878 100644 --- a/app/src/main/java/com/iqball/app/page/VisualizerPage.kt +++ b/app/src/main/java/com/iqball/app/page/VisualizerPage.kt @@ -39,10 +39,13 @@ 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 @@ -62,47 +65,52 @@ fun VisualizerPage( val dataEither = remember { initializeVisualizer(service, auth, tacticId) } val showTree = remember { mutableStateOf(true) } - val (info, rootStep) = when (dataEither) { + 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 } - val screenOrientation = LocalConfiguration.current.orientation - var selectedStepId by rememberSaveable { mutableIntStateOf(rootStep.id) } - val content = remember(selectedStepId) { - runBlocking { - val result = service.getTacticStepContent(auth, tacticId, selectedStepId).onLeft { - Log.e( - "received error response from server when retrieving root content: {}", - it.toString() - ) - } - when (result) { - is Either.Left -> throw Error("Unexpected error") - is Either.Right -> result.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), + if (parentId == null) null else getStepContent(parentId) + ) + } + + + Column { VisualizerHeader(title = info.name, showTree) when (screenOrientation) { - Configuration.ORIENTATION_PORTRAIT -> StepsTree(root = rootStep, + Configuration.ORIENTATION_PORTRAIT -> StepsTree(root = stepsTree, selectedNodeId = selectedStepId, onNodeSelected = { selectedStepId = it.id }) Configuration.ORIENTATION_LANDSCAPE -> { - val courtOffsets = + val stepOffsets = remember(showTree.value, content) { mutableStateMapOf() } + val parentOffsets = + remember(showTree.value, parentContent) { mutableStateMapOf() } val courtArea = remember(showTree.value) { mutableStateOf(Rect.Zero) } - val courtZoomState = rememberZoomState() - remember(showTree.value, content) { - runBlocking { - courtZoomState.reset() - } - } + val courtZoomState = remember(showTree.value, selectedStepId) { ZoomState() } val courtModifier = if (showTree.value) Modifier.width(IntrinsicSize.Min) else Modifier.fillMaxWidth() @@ -110,12 +118,13 @@ fun VisualizerPage( Row(modifier = Modifier.background(Color.LightGray)) { BasketCourt( content = content, + parentContent, type = info.type, modifier = courtModifier, - state = BasketCourtStates(courtOffsets, courtArea, courtZoomState) + state = BasketCourtStates(stepOffsets, parentOffsets, courtArea, courtZoomState) ) if (showTree.value) { - StepsTree(root = rootStep, + StepsTree(root = stepsTree, selectedNodeId = selectedStepId, onNodeSelected = { selectedStepId = it.id }) } From 3bd501af3a454bc9c30547da3987a338a72a1bb8 Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Thu, 28 Mar 2024 12:05:46 +0100 Subject: [PATCH 12/14] fix content shift when the tree is toggled --- app/build.gradle.kts | 2 - .../main/java/com/iqball/app/MainActivity.kt | 4 +- .../java/com/iqball/app/component/Actions.kt | 3 +- .../com/iqball/app/component/BasketCourt.kt | 59 ++++++++++++++----- .../com/iqball/app/domains/PlayerDomains.kt | 2 +- .../com/iqball/app/page/VisualizerPage.kt | 23 +++++--- .../app/serialization/EitherTypeAdapter.kt | 5 +- 7 files changed, 67 insertions(+), 31 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c895a00..69fd629 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,3 @@ -import java.net.URI - plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.jetbrainsKotlinAndroid) diff --git a/app/src/main/java/com/iqball/app/MainActivity.kt b/app/src/main/java/com/iqball/app/MainActivity.kt index 0ccbd39..2bab3f4 100644 --- a/app/src/main/java/com/iqball/app/MainActivity.kt +++ b/app/src/main/java/com/iqball/app/MainActivity.kt @@ -74,7 +74,7 @@ class MainActivity : ComponentActivity() { .addConverterFactory(EitherBodyConverter.create()) .addConverterFactory(MoshiConverterFactory.create(moshi)) .addCallAdapterFactory(EitherCallAdapterFactory.create()) - .baseUrl("http://192.168.127.83:5254/") + .baseUrl("http://grospc:5254/") .client( OkHttpClient.Builder() .addInterceptor { it.proceed(it.request()) } @@ -85,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 index 6460dc0..cff94ee 100644 --- a/app/src/main/java/com/iqball/app/component/Actions.kt +++ b/app/src/main/java/com/iqball/app/component/Actions.kt @@ -195,8 +195,7 @@ private fun computeSegmentsToPath( path.moveTo(segmentStart.x, segmentStart.y) - for (i in segments.indices) { - val segment = segments[i] + for ((i, segment) in segments.withIndex()) { var nextPos = extractPos(segment.next, offsets, area) if (i == segments.size - 1) { diff --git a/app/src/main/java/com/iqball/app/component/BasketCourt.kt b/app/src/main/java/com/iqball/app/component/BasketCourt.kt index f6876cf..a5ad403 100644 --- a/app/src/main/java/com/iqball/app/component/BasketCourt.kt +++ b/app/src/main/java/com/iqball/app/component/BasketCourt.kt @@ -1,12 +1,24 @@ package com.iqball.app.component +import androidx.compose.animation.animateContentSize 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.height +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize + import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue +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 @@ -14,11 +26,13 @@ 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 @@ -50,34 +64,42 @@ fun BasketCourt( CourtType.Half -> R.drawable.half_court } + var courtArea by state.courtArea val zoomState = state.zoomState + Box( - modifier = modifier, + 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( - courtAreaState = state.courtArea, + courtArea = courtArea, content = content, offsets = state.stepComponentsOffsets, isFromParent = false ) if (parentContent != null) { CourtContent( - courtAreaState = state.courtArea, + courtArea = courtArea, content = parentContent, offsets = state.parentComponentsOffsets, isFromParent = true @@ -89,26 +111,31 @@ fun BasketCourt( @Composable private fun CourtContent( - courtAreaState: MutableState, + courtArea: Rect, content: StepContent, offsets: MutableMap, isFromParent: Boolean ) { - var courtArea by courtAreaState - 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 - .fillMaxSize() - .onGloballyPositioned { - if (courtArea == Rect.Zero) - courtArea = it.boundsInRoot() - } + .requiredWidth(width) + .requiredHeight(height) .drawWithContent { val relativeOffsets = - offsets.mapValues { (it.value - courtArea.topLeft).toVector() } - drawActions(this, content, relativeOffsets, courtArea, playersPixelsRadius, if (isFromParent) Color.Gray else Color.Black) + offsets.mapValues { (it.value).toVector() } + drawActions( + this, + content, + relativeOffsets, + courtArea, + playersPixelsRadius, + if (isFromParent) Color.Gray else Color.Black + ) drawContent() } ) { @@ -117,8 +144,8 @@ private fun CourtContent( for (component in content.components) { val componentModifier = Modifier .onGloballyPositioned { - if (!offsets.containsKey(component.id)) - offsets[component.id] = it.boundsInRoot().center + if (!offsets.getOrNone(component.id).isSome { it != Offset.Zero }) + offsets[component.id] = it.boundsInParent().center } when (component) { is PlayerLike -> { diff --git a/app/src/main/java/com/iqball/app/domains/PlayerDomains.kt b/app/src/main/java/com/iqball/app/domains/PlayerDomains.kt index c084757..df0fe7d 100644 --- a/app/src/main/java/com/iqball/app/domains/PlayerDomains.kt +++ b/app/src/main/java/com/iqball/app/domains/PlayerDomains.kt @@ -14,7 +14,7 @@ import com.iqball.app.model.tactic.StepComponent import com.iqball.app.model.tactic.StepContent /** - * Converts the phantom's [PhantomPositioning] to a XY Position + * 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. diff --git a/app/src/main/java/com/iqball/app/page/VisualizerPage.kt b/app/src/main/java/com/iqball/app/page/VisualizerPage.kt index 2159878..bb8f602 100644 --- a/app/src/main/java/com/iqball/app/page/VisualizerPage.kt +++ b/app/src/main/java/com/iqball/app/page/VisualizerPage.kt @@ -105,28 +105,37 @@ fun VisualizerPage( onNodeSelected = { selectedStepId = it.id }) Configuration.ORIENTATION_LANDSCAPE -> { + val courtArea = remember { mutableStateOf(Rect.Zero) } val stepOffsets = - remember(showTree.value, content) { mutableStateMapOf() } + remember(selectedStepId) { mutableStateMapOf() } val parentOffsets = - remember(showTree.value, parentContent) { mutableStateMapOf() } - val courtArea = remember(showTree.value) { mutableStateOf(Rect.Zero) } - val courtZoomState = remember(showTree.value, selectedStepId) { ZoomState() } + 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) + state = BasketCourtStates( + stepOffsets, + parentOffsets, + courtArea, + courtZoomState + ) ) if (showTree.value) { - StepsTree(root = stepsTree, + StepsTree( + root = stepsTree, selectedNodeId = selectedStepId, - onNodeSelected = { selectedStepId = it.id }) + onNodeSelected = { selectedStepId = it.id } + ) } } } diff --git a/app/src/main/java/com/iqball/app/serialization/EitherTypeAdapter.kt b/app/src/main/java/com/iqball/app/serialization/EitherTypeAdapter.kt index 6854a60..b09376a 100644 --- a/app/src/main/java/com/iqball/app/serialization/EitherTypeAdapter.kt +++ b/app/src/main/java/com/iqball/app/serialization/EitherTypeAdapter.kt @@ -28,7 +28,10 @@ private class EitherTypeAdapter( JsonReader.Token.STRING -> JsonPrimitiveType.String JsonReader.Token.NUMBER -> JsonPrimitiveType.Number JsonReader.Token.BOOLEAN -> JsonPrimitiveType.Boolean - JsonReader.Token.NULL -> return null + JsonReader.Token.NULL -> { + reader.nextNull() + return null + } else -> throw JsonDataException("unexpected token : $token") } From aac19cdb58e5ad53c84c6da7361ed17ff2c290ef Mon Sep 17 00:00:00 2001 From: maxime Date: Sun, 31 Mar 2024 16:52:29 +0200 Subject: [PATCH 13/14] add display name for steps --- .../com/iqball/app/component/BasketCourt.kt | 8 -------- .../java/com/iqball/app/component/StepTree.kt | 9 +++++++-- .../iqball/app/domains/StepsTreeDomains.kt | 19 +++++++++++++++++++ .../com/iqball/app/page/VisualizerPage.kt | 1 - app/src/main/res/drawable/half_court.xml | 8 ++++---- 5 files changed, 30 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/iqball/app/domains/StepsTreeDomains.kt diff --git a/app/src/main/java/com/iqball/app/component/BasketCourt.kt b/app/src/main/java/com/iqball/app/component/BasketCourt.kt index a5ad403..9b16bdb 100644 --- a/app/src/main/java/com/iqball/app/component/BasketCourt.kt +++ b/app/src/main/java/com/iqball/app/component/BasketCourt.kt @@ -1,24 +1,16 @@ package com.iqball.app.component -import androidx.compose.animation.animateContentSize 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.height import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentSize - import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue -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 diff --git a/app/src/main/java/com/iqball/app/component/StepTree.kt b/app/src/main/java/com/iqball/app/component/StepTree.kt index c8fec61..c1e4d8c 100644 --- a/app/src/main/java/com/iqball/app/component/StepTree.kt +++ b/app/src/main/java/com/iqball/app/component/StepTree.kt @@ -29,6 +29,7 @@ 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 @@ -76,12 +77,13 @@ fun StepsTree(root: StepNodeInfo, selectedNodeId: Int, onNodeSelected: (StepNode }, contentAlignment = Alignment.TopCenter ) { - StepsTreeContent(root, selectedNodeId, onNodeSelected, nodesOffsets) + StepsTreeContent(root, root, selectedNodeId, onNodeSelected, nodesOffsets) } } @Composable private fun StepsTreeContent( + root: StepNodeInfo, node: StepNodeInfo, selectedNodeId: Int, onNodeSelected: (StepNodeInfo) -> Unit, @@ -93,6 +95,7 @@ private fun StepsTreeContent( modifier = Modifier ) { StepPiece( + name = getStepName(root, node.id), node = node, isSelected = selectedNodeId == node.id, onNodeSelected = { onNodeSelected(node) }, @@ -110,6 +113,7 @@ private fun StepsTreeContent( ) { for (children in node.children) { StepsTreeContent( + root = root, node = children, selectedNodeId = selectedNodeId, onNodeSelected = onNodeSelected, @@ -122,6 +126,7 @@ private fun StepsTreeContent( @Composable fun StepPiece( + name: String, node: StepNodeInfo, isSelected: Boolean, onNodeSelected: () -> Unit, @@ -136,7 +141,7 @@ fun StepPiece( } ) { Text( - text = node.id.toString(), + text = name, textAlign = TextAlign.Center, color = if (isSelected) Color.White else Color.Black, modifier = Modifier 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/page/VisualizerPage.kt b/app/src/main/java/com/iqball/app/page/VisualizerPage.kt index bb8f602..bc9e6ff 100644 --- a/app/src/main/java/com/iqball/app/page/VisualizerPage.kt +++ b/app/src/main/java/com/iqball/app/page/VisualizerPage.kt @@ -96,7 +96,6 @@ fun VisualizerPage( } - Column { VisualizerHeader(title = info.name, showTree) when (screenOrientation) { diff --git a/app/src/main/res/drawable/half_court.xml b/app/src/main/res/drawable/half_court.xml index d84c400..1f7e07f 100644 --- a/app/src/main/res/drawable/half_court.xml +++ b/app/src/main/res/drawable/half_court.xml @@ -1,8 +1,8 @@ + android:width="200dp" + android:height="200dp" + android:viewportWidth="270" + android:viewportHeight="270"> Date: Mon, 1 Apr 2024 18:41:47 +0200 Subject: [PATCH 14/14] apply suggestions --- app/src/main/java/com/iqball/app/page/VisualizerPage.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/iqball/app/page/VisualizerPage.kt b/app/src/main/java/com/iqball/app/page/VisualizerPage.kt index bc9e6ff..f85787a 100644 --- a/app/src/main/java/com/iqball/app/page/VisualizerPage.kt +++ b/app/src/main/java/com/iqball/app/page/VisualizerPage.kt @@ -91,7 +91,7 @@ fun VisualizerPage( val parentId = getParent(stepsTree, selectedStepId)?.id Pair( getStepContent(selectedStepId), - if (parentId == null) null else getStepContent(parentId) + parentId?.let { getStepContent(it) } ) }