From f8ac0ab20bb3efb9990a1a5e8ee986150be22f25 Mon Sep 17 00:00:00 2001 From: Lucas Cardoso Date: Sun, 1 Mar 2026 22:36:07 -0300 Subject: [PATCH] =?UTF-8?q?[LUCASCARDOSO#001]=20-=20Vers=C3=A3o=20final=20?= =?UTF-8?q?do=20aplicativo=20Kotlin=20Multiplataform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- quiz-kmp/.gitignore | 10 + ...otlin-compiler-15891118528244374603.salive | 0 quiz-kmp/README.md | 76 +++ quiz-kmp/build.gradle.kts | 8 + quiz-kmp/composeApp/build.gradle.kts | 108 +++++ .../src/androidMain/AndroidManifest.xml | 28 ++ .../kotlin/com/dynamox/quiz/MainActivity.kt | 16 + .../com/dynamox/quiz/QuizApplication.kt | 23 + .../quiz/data/local/DatabaseDriverFactory.kt | 12 + .../com/dynamox/quiz/di/AndroidModule.kt | 11 + .../res/drawable/ic_launcher_background.xml | 9 + .../res/drawable/ic_launcher_foreground.xml | 22 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../src/androidMain/res/values/strings.xml | 4 + .../src/androidMain/res/values/themes.xml | 9 + .../commonMain/kotlin/com/dynamox/quiz/App.kt | 12 + .../quiz/data/api/HttpClientFactory.kt | 69 +++ .../dynamox/quiz/data/api/QuizApiService.kt | 51 +++ .../quiz/data/api/dto/AnswerRequestDto.kt | 11 + .../quiz/data/api/dto/AnswerResponseDto.kt | 12 + .../dynamox/quiz/data/api/dto/QuestionDto.kt | 11 + .../quiz/data/local/DatabaseDriverFactory.kt | 12 + .../data/repository/QuizRepositoryImpl.kt | 127 +++++ .../kotlin/com/dynamox/quiz/di/AppModule.kt | 104 +++++ .../com/dynamox/quiz/domain/model/AppError.kt | 14 + .../com/dynamox/quiz/domain/model/Player.kt | 6 + .../com/dynamox/quiz/domain/model/Question.kt | 7 + .../dynamox/quiz/domain/model/QuizScore.kt | 10 + .../quiz/domain/repository/QuizRepository.kt | 24 + .../domain/usecase/GetLeaderboardUseCase.kt | 14 + .../usecase/GetOrCreatePlayerUseCase.kt | 30 ++ .../quiz/domain/usecase/GetQuestionUseCase.kt | 19 + .../domain/usecase/SaveQuizScoreUseCase.kt | 13 + .../domain/usecase/SubmitAnswerUseCase.kt | 15 + .../presentation/components/AnswerOption.kt | 129 ++++++ .../presentation/components/QuizButton.kt | 98 ++++ .../quiz/presentation/navigation/NavGraph.kt | 116 +++++ .../quiz/presentation/navigation/Screen.kt | 31 ++ .../screens/leaderboard/LeaderboardScreen.kt | 255 +++++++++++ .../leaderboard/LeaderboardViewModel.kt | 48 ++ .../presentation/screens/login/LoginScreen.kt | 218 +++++++++ .../screens/login/LoginViewModel.kt | 72 +++ .../presentation/screens/quiz/QuizScreen.kt | 432 ++++++++++++++++++ .../screens/quiz/QuizViewModel.kt | 180 ++++++++ .../screens/result/ResultScreen.kt | 247 ++++++++++ .../screens/result/ResultViewModel.kt | 40 ++ .../screens/splash/SplashScreen.kt | 156 +++++++ .../dynamox/quiz/presentation/theme/Color.kt | 86 ++++ .../dynamox/quiz/presentation/theme/Theme.kt | 36 ++ .../dynamox/quiz/presentation/theme/Type.kt | 78 ++++ .../com/dynamox/quiz/database/Player.sq | 19 + .../com/dynamox/quiz/database/QuizScore.sq | 21 + .../quiz/domain/usecase/FakeQuizRepository.kt | 67 +++ .../domain/usecase/GetQuestionUseCaseTest.kt | 232 ++++++++++ .../quiz/domain/usecase/QuizViewModelTest.kt | 233 ++++++++++ quiz-kmp/gradle.properties | 6 + quiz-kmp/gradle/libs.versions.toml | 63 +++ .../gradle/wrapper/gradle-wrapper.properties | 7 + quiz-kmp/gradlew.bat | 91 ++++ quiz-kmp/settings.gradle.kts | 30 ++ 61 files changed, 3898 insertions(+) create mode 100644 quiz-kmp/.gitignore create mode 100644 quiz-kmp/.kotlin/sessions/kotlin-compiler-15891118528244374603.salive create mode 100644 quiz-kmp/README.md create mode 100644 quiz-kmp/build.gradle.kts create mode 100644 quiz-kmp/composeApp/build.gradle.kts create mode 100644 quiz-kmp/composeApp/src/androidMain/AndroidManifest.xml create mode 100644 quiz-kmp/composeApp/src/androidMain/kotlin/com/dynamox/quiz/MainActivity.kt create mode 100644 quiz-kmp/composeApp/src/androidMain/kotlin/com/dynamox/quiz/QuizApplication.kt create mode 100644 quiz-kmp/composeApp/src/androidMain/kotlin/com/dynamox/quiz/data/local/DatabaseDriverFactory.kt create mode 100644 quiz-kmp/composeApp/src/androidMain/kotlin/com/dynamox/quiz/di/AndroidModule.kt create mode 100644 quiz-kmp/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml create mode 100644 quiz-kmp/composeApp/src/androidMain/res/drawable/ic_launcher_foreground.xml create mode 100644 quiz-kmp/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 quiz-kmp/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 quiz-kmp/composeApp/src/androidMain/res/values/strings.xml create mode 100644 quiz-kmp/composeApp/src/androidMain/res/values/themes.xml create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/App.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/api/HttpClientFactory.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/api/QuizApiService.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/api/dto/AnswerRequestDto.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/api/dto/AnswerResponseDto.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/api/dto/QuestionDto.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/local/DatabaseDriverFactory.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/repository/QuizRepositoryImpl.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/di/AppModule.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/model/AppError.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/model/Player.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/model/Question.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/model/QuizScore.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/repository/QuizRepository.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/usecase/GetLeaderboardUseCase.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/usecase/GetOrCreatePlayerUseCase.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/usecase/GetQuestionUseCase.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/usecase/SaveQuizScoreUseCase.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/usecase/SubmitAnswerUseCase.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/components/AnswerOption.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/components/QuizButton.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/navigation/NavGraph.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/navigation/Screen.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/leaderboard/LeaderboardScreen.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/leaderboard/LeaderboardViewModel.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/login/LoginScreen.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/login/LoginViewModel.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/quiz/QuizScreen.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/quiz/QuizViewModel.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/result/ResultScreen.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/result/ResultViewModel.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/splash/SplashScreen.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/theme/Color.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/theme/Theme.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/theme/Type.kt create mode 100644 quiz-kmp/composeApp/src/commonMain/sqldelight/com/dynamox/quiz/database/Player.sq create mode 100644 quiz-kmp/composeApp/src/commonMain/sqldelight/com/dynamox/quiz/database/QuizScore.sq create mode 100644 quiz-kmp/composeApp/src/commonTest/kotlin/com/dynamox/quiz/domain/usecase/FakeQuizRepository.kt create mode 100644 quiz-kmp/composeApp/src/commonTest/kotlin/com/dynamox/quiz/domain/usecase/GetQuestionUseCaseTest.kt create mode 100644 quiz-kmp/composeApp/src/commonTest/kotlin/com/dynamox/quiz/domain/usecase/QuizViewModelTest.kt create mode 100644 quiz-kmp/gradle.properties create mode 100644 quiz-kmp/gradle/libs.versions.toml create mode 100644 quiz-kmp/gradle/wrapper/gradle-wrapper.properties create mode 100644 quiz-kmp/gradlew.bat create mode 100644 quiz-kmp/settings.gradle.kts diff --git a/quiz-kmp/.gitignore b/quiz-kmp/.gitignore new file mode 100644 index 0000000000..ed82b42163 --- /dev/null +++ b/quiz-kmp/.gitignore @@ -0,0 +1,10 @@ +.gradle +.idea +*.iml +build/ +local.properties +*.keystore +*.jks +*.DS_Store +composeApp/build/ +gradle-wrapper.jar diff --git a/quiz-kmp/.kotlin/sessions/kotlin-compiler-15891118528244374603.salive b/quiz-kmp/.kotlin/sessions/kotlin-compiler-15891118528244374603.salive new file mode 100644 index 0000000000..e69de29bb2 diff --git a/quiz-kmp/README.md b/quiz-kmp/README.md new file mode 100644 index 0000000000..406e3b246c --- /dev/null +++ b/quiz-kmp/README.md @@ -0,0 +1,76 @@ +# DynaQuiz + +Aplicativo de quiz multiplataforma desenvolvido com **Kotlin Multiplatform** e **Compose Multiplatform**. + +--- + +## Pré-requisitos + +- **Android Studio** Ladybug (2024.2.1) ou superior +- **JDK 17** ou superior +- **Android SDK** API 24+ + +--- + +## Como rodar + +1. Abra a pasta `quiz-kmp/` no Android Studio (**File > Open**) +2. Aguarde o Gradle sincronizar (clique em **Sync Now** se necessário) +3. Conecte um dispositivo Android ou inicie um emulador (API 24+) +4. Selecione a configuração `composeApp` e clique em **Run** (▶) + +### Rodar testes + +```bash +./gradlew :composeApp:allTests +``` + +--- + +## Arquitetura + +**Clean Architecture + MVVM** + +| Camada | Responsabilidade | +|---|---| +| `data/` | API (Ktor), banco de dados (SQLDelight), repositórios | +| `domain/` | Modelos, interfaces de repositório, casos de uso | +| `presentation/` | Telas, ViewModels, navegação, tema | +| `di/` | Módulos de injeção de dependência (Koin) | + +--- + +## Tecnologias + +| Biblioteca | Versão | +|---|---| +| Kotlin Multiplatform | 2.1.0 | +| Compose Multiplatform | 1.7.3 | +| Ktor | 3.0.3 | +| SQLDelight | 2.0.2 | +| Koin | 4.0.0 | +| Kotlinx Coroutines | 1.9.0 | + +--- + +## API + +**Base URL:** `https://quiz-api-bwi5hjqyaq-uc.a.run.app` + +| Endpoint | Método | Descrição | +|---|---|---| +| `/question` | GET | Retorna uma pergunta aleatória | +| `/answer?questionId={id}` | POST | Verifica se a resposta está correta | + +--- + +## Premissas + +- Jogadores são identificados apenas pelo nome/apelido, sem autenticação. +- Cada sessão tem exatamente 10 perguntas. +- O placar é salvo localmente (SQLite) e exibe os 20 melhores resultados. +- Sem cache de perguntas — falhas de rede exibem tela de erro com botão de retry. + +--- + +Desenvolvido como parte do **Desafio Kotlin Multiplatform**. diff --git a/quiz-kmp/build.gradle.kts b/quiz-kmp/build.gradle.kts new file mode 100644 index 0000000000..7b807b112b --- /dev/null +++ b/quiz-kmp/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.kotlinMultiplatform) apply false + alias(libs.plugins.composeMultiplatform) apply false + alias(libs.plugins.composeCompiler) apply false + alias(libs.plugins.kotlinSerialization) apply false + alias(libs.plugins.sqldelight) apply false +} diff --git a/quiz-kmp/composeApp/build.gradle.kts b/quiz-kmp/composeApp/build.gradle.kts new file mode 100644 index 0000000000..fd8708b659 --- /dev/null +++ b/quiz-kmp/composeApp/build.gradle.kts @@ -0,0 +1,108 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidApplication) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) + alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.sqldelight) +} + +kotlin { + androidTarget { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } + } + + sourceSets { + androidMain.dependencies { + implementation(compose.preview) + implementation(libs.activity.compose) + implementation(libs.core.ktx) + implementation(libs.koin.android) + implementation(libs.ktor.client.okhttp) + implementation(libs.sqldelight.android.driver) + implementation(libs.kotlinx.coroutines.android) + } + + commonMain.dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + implementation(compose.materialIconsExtended) + + implementation(libs.lifecycle.viewmodel) + implementation(libs.lifecycle.viewmodel.compose) + implementation(libs.lifecycle.runtime.compose) + + implementation(libs.navigation.compose) + + implementation(libs.koin.core) + implementation(libs.koin.compose) + implementation(libs.koin.compose.viewmodel) + + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.client.logging) + + implementation(libs.sqldelight.coroutines.extensions) + + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + + implementation(libs.kotlinx.datetime) + implementation(libs.kermit) + } + + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } +} + +android { + namespace = "com.dynamox.quiz" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + applicationId = "com.dynamox.quiz" + minSdk = libs.versions.android.minSdk.get().toInt() + targetSdk = libs.versions.android.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0.0" + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +sqldelight { + databases { + create("QuizDatabase") { + packageName.set("com.dynamox.quiz.database") + } + } +} diff --git a/quiz-kmp/composeApp/src/androidMain/AndroidManifest.xml b/quiz-kmp/composeApp/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000000..f67756b6dd --- /dev/null +++ b/quiz-kmp/composeApp/src/androidMain/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + diff --git a/quiz-kmp/composeApp/src/androidMain/kotlin/com/dynamox/quiz/MainActivity.kt b/quiz-kmp/composeApp/src/androidMain/kotlin/com/dynamox/quiz/MainActivity.kt new file mode 100644 index 0000000000..d5a454c569 --- /dev/null +++ b/quiz-kmp/composeApp/src/androidMain/kotlin/com/dynamox/quiz/MainActivity.kt @@ -0,0 +1,16 @@ +package com.dynamox.quiz + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + App() + } + } +} diff --git a/quiz-kmp/composeApp/src/androidMain/kotlin/com/dynamox/quiz/QuizApplication.kt b/quiz-kmp/composeApp/src/androidMain/kotlin/com/dynamox/quiz/QuizApplication.kt new file mode 100644 index 0000000000..573d41249b --- /dev/null +++ b/quiz-kmp/composeApp/src/androidMain/kotlin/com/dynamox/quiz/QuizApplication.kt @@ -0,0 +1,23 @@ +package com.dynamox.quiz + +import android.app.Application +import com.dynamox.quiz.di.androidModule +import com.dynamox.quiz.di.appModules +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.startKoin + +/** + * Classe Application personalizada do Android + * O Android instancia esta classe ANTES de qualquer Activity ou Service. + * Local para inicializar bibliotecas globais como o Koin, pois garante que as dependências estarão + * disponíveis quando qualquer tela for criada. + */ +class QuizApplication : Application() { + override fun onCreate() { + super.onCreate() + startKoin { + androidContext(this@QuizApplication) + modules(appModules + androidModule) + } + } +} diff --git a/quiz-kmp/composeApp/src/androidMain/kotlin/com/dynamox/quiz/data/local/DatabaseDriverFactory.kt b/quiz-kmp/composeApp/src/androidMain/kotlin/com/dynamox/quiz/data/local/DatabaseDriverFactory.kt new file mode 100644 index 0000000000..f153b859c0 --- /dev/null +++ b/quiz-kmp/composeApp/src/androidMain/kotlin/com/dynamox/quiz/data/local/DatabaseDriverFactory.kt @@ -0,0 +1,12 @@ +package com.dynamox.quiz.data.local + +import android.content.Context +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.android.AndroidSqliteDriver +import com.dynamox.quiz.database.QuizDatabase + +class AndroidDatabaseDriverFactory(private val context: Context) : DatabaseDriverFactory { + override fun createDriver(): SqlDriver { + return AndroidSqliteDriver(QuizDatabase.Schema, context, "quiz_database.db") + } +} diff --git a/quiz-kmp/composeApp/src/androidMain/kotlin/com/dynamox/quiz/di/AndroidModule.kt b/quiz-kmp/composeApp/src/androidMain/kotlin/com/dynamox/quiz/di/AndroidModule.kt new file mode 100644 index 0000000000..a73797540f --- /dev/null +++ b/quiz-kmp/composeApp/src/androidMain/kotlin/com/dynamox/quiz/di/AndroidModule.kt @@ -0,0 +1,11 @@ +package com.dynamox.quiz.di + +import com.dynamox.quiz.data.local.AndroidDatabaseDriverFactory +import com.dynamox.quiz.data.local.DatabaseDriverFactory +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.bind +import org.koin.dsl.module + +val androidModule = module { + single { AndroidDatabaseDriverFactory(androidContext()) } bind DatabaseDriverFactory::class +} diff --git a/quiz-kmp/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml b/quiz-kmp/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..1ae1cfe6a1 --- /dev/null +++ b/quiz-kmp/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml @@ -0,0 +1,9 @@ + + + + diff --git a/quiz-kmp/composeApp/src/androidMain/res/drawable/ic_launcher_foreground.xml b/quiz-kmp/composeApp/src/androidMain/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..5fc0c9483f --- /dev/null +++ b/quiz-kmp/composeApp/src/androidMain/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/quiz-kmp/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/quiz-kmp/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..6b78462d61 --- /dev/null +++ b/quiz-kmp/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/quiz-kmp/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/quiz-kmp/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..6b78462d61 --- /dev/null +++ b/quiz-kmp/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/quiz-kmp/composeApp/src/androidMain/res/values/strings.xml b/quiz-kmp/composeApp/src/androidMain/res/values/strings.xml new file mode 100644 index 0000000000..bb59f9e4a8 --- /dev/null +++ b/quiz-kmp/composeApp/src/androidMain/res/values/strings.xml @@ -0,0 +1,4 @@ + + + DynaQuiz + diff --git a/quiz-kmp/composeApp/src/androidMain/res/values/themes.xml b/quiz-kmp/composeApp/src/androidMain/res/values/themes.xml new file mode 100644 index 0000000000..5112c1f2f5 --- /dev/null +++ b/quiz-kmp/composeApp/src/androidMain/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/App.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/App.kt new file mode 100644 index 0000000000..8e072df422 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/App.kt @@ -0,0 +1,12 @@ +package com.dynamox.quiz + +import androidx.compose.runtime.Composable +import com.dynamox.quiz.presentation.navigation.NavGraph +import com.dynamox.quiz.presentation.theme.DynaQuizTheme + +@Composable +fun App() { + DynaQuizTheme { + NavGraph() + } +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/api/HttpClientFactory.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/api/HttpClientFactory.kt new file mode 100644 index 0000000000..202d6fbeaa --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/api/HttpClientFactory.kt @@ -0,0 +1,69 @@ +package com.dynamox.quiz.data.api + +import co.touchlab.kermit.Logger +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpRequestRetry +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logging +import io.ktor.http.ContentType +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +private const val CONNECT_TIMEOUT_MS = 8_000L +private const val REQUEST_TIMEOUT_MS = 15_000L +private const val SOCKET_TIMEOUT_MS = 15_000L + +fun createHttpClient(): HttpClient = HttpClient { + + // Configuração compartilhada de JSON usada em múltiplos content types + val jsonConfig = Json { + // Ignora campos desconhecidos no JSON (tolerante a mudanças na API) + ignoreUnknownKeys = true + // Aceita JSON malformado com aspas simples, valores sem aspas, etc. + isLenient = true + // Desativa formatação bonita para reduzir tamanho do payload + prettyPrint = false + } + + /** + * Plugin ContentNegotiation: responsável por serializar objetos Kotlin + * para JSON nas requisições e desserializar JSON para objetos Kotlin + * nas respostas. + * + * Registramos múltiplos content types porque testando localmente a API retornou diferentes + * valores no header Content-Type dependendo do endpoint: + * - GET /question > "text/application-json" (não-padrão) + * - POST /answer > "text/html;charset=utf-8" (também não-padrão) + * - ContentType.Any > curinga para cobrir qualquer outro caso futuro + */ + install(ContentNegotiation) { + json(jsonConfig) + json(jsonConfig, contentType = ContentType("text", "application-json")) + json(jsonConfig, contentType = ContentType.Text.Plain) + json(jsonConfig, contentType = ContentType.Text.Html) + json(jsonConfig, contentType = ContentType.Any) + } + + install(HttpTimeout) { + connectTimeoutMillis = CONNECT_TIMEOUT_MS + requestTimeoutMillis = REQUEST_TIMEOUT_MS + socketTimeoutMillis = SOCKET_TIMEOUT_MS + } + + install(HttpRequestRetry) { + retryOnServerErrors(maxRetries = 2) + retryOnException(maxRetries = 2, retryOnTimeout = true) + exponentialDelay(base = 1.5, maxDelayMs = 5_000L) + } + + install(Logging) { + level = LogLevel.INFO + logger = object : io.ktor.client.plugins.logging.Logger { + override fun log(message: String) { + Logger.d("HttpClient") { message } + } + } + } +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/api/QuizApiService.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/api/QuizApiService.kt new file mode 100644 index 0000000000..7edea48937 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/api/QuizApiService.kt @@ -0,0 +1,51 @@ +package com.dynamox.quiz.data.api + +import com.dynamox.quiz.data.api.dto.AnswerRequestDto +import com.dynamox.quiz.data.api.dto.AnswerResponseDto +import com.dynamox.quiz.data.api.dto.QuestionDto +import com.dynamox.quiz.domain.model.AppError +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType + +/** Responsável por realizar as chamadas HTTP usando o cliente Ktor */ +class QuizApiService(private val client: HttpClient) { + + private companion object { + const val BASE_URL = "https://quiz-api-bwi5hjqyaq-uc.a.run.app" + } + + suspend fun getQuestion(): QuestionDto { + val response: HttpResponse = client.get("$BASE_URL/question") + return handleResponse(response) + } + + suspend fun submitAnswer(questionId: String, answer: String): AnswerResponseDto { + val response: HttpResponse = client.post("$BASE_URL/answer?questionId=$questionId") { + contentType(ContentType.Application.Json) + setBody(AnswerRequestDto(answer = answer)) + } + return handleResponse(response) + } + + private suspend inline fun handleResponse(response: HttpResponse): T { + return when (response.status) { + HttpStatusCode.OK -> response.body() + HttpStatusCode.BadRequest -> throw AppError.ValidationError( + "Bad request: ${response.status.description}" + ) + HttpStatusCode.NotFound -> throw AppError.NotFoundError("Requisição não encontrada") + HttpStatusCode.InternalServerError -> throw AppError.ServerError(500, "Erro interno do servidor") + else -> throw AppError.ServerError( + response.status.value, + "Unexpected error: ${response.status.description}" + ) + } + } +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/api/dto/AnswerRequestDto.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/api/dto/AnswerRequestDto.kt new file mode 100644 index 0000000000..7b2875893d --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/api/dto/AnswerRequestDto.kt @@ -0,0 +1,11 @@ +package com.dynamox.quiz.data.api.dto + +import kotlinx.serialization.Serializable + +/** + * DTO do body da requisição POST /answer. + * É serializado para JSON pelo Ktor antes de enviar ao servidor. */ +@Serializable +data class AnswerRequestDto( + val answer: String +) diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/api/dto/AnswerResponseDto.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/api/dto/AnswerResponseDto.kt new file mode 100644 index 0000000000..ac47ec0b48 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/api/dto/AnswerResponseDto.kt @@ -0,0 +1,12 @@ +package com.dynamox.quiz.data.api.dto + +import kotlinx.serialization.Serializable + +/** + * DTO da resposta JSON do servidor. + * Desserializado automaticamente do JSON retornado pelo endpoint POST /answer. + */ +@Serializable +data class AnswerResponseDto( + val result: Boolean +) diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/api/dto/QuestionDto.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/api/dto/QuestionDto.kt new file mode 100644 index 0000000000..51fb10e7b7 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/api/dto/QuestionDto.kt @@ -0,0 +1,11 @@ +package com.dynamox.quiz.data.api.dto + +import kotlinx.serialization.Serializable + +/** DTO da estrutura JSON de uma pergunta */ +@Serializable +data class QuestionDto( + val id: String, + val statement: String, + val options: List +) diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/local/DatabaseDriverFactory.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/local/DatabaseDriverFactory.kt new file mode 100644 index 0000000000..7e5fcd261e --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/local/DatabaseDriverFactory.kt @@ -0,0 +1,12 @@ +package com.dynamox.quiz.data.local + +import app.cash.sqldelight.db.SqlDriver +import com.dynamox.quiz.database.QuizDatabase + +interface DatabaseDriverFactory { + fun createDriver(): SqlDriver +} + +fun createDatabase(driverFactory: DatabaseDriverFactory): QuizDatabase { + return QuizDatabase(driverFactory.createDriver()) +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/repository/QuizRepositoryImpl.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/repository/QuizRepositoryImpl.kt new file mode 100644 index 0000000000..aeef78d110 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/data/repository/QuizRepositoryImpl.kt @@ -0,0 +1,127 @@ +package com.dynamox.quiz.data.repository + +import co.touchlab.kermit.Logger +import com.dynamox.quiz.data.api.QuizApiService +import com.dynamox.quiz.database.QuizDatabase +import com.dynamox.quiz.domain.model.AppError +import com.dynamox.quiz.domain.model.Player +import com.dynamox.quiz.domain.model.Question +import com.dynamox.quiz.domain.model.QuizScore +import com.dynamox.quiz.domain.repository.QuizRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock + +class QuizRepositoryImpl( + private val apiService: QuizApiService, + private val database: QuizDatabase +) : QuizRepository { + + private val logger = Logger.withTag("QuizRepository") + + override suspend fun getQuestion(): Result = withContext(Dispatchers.IO) { + runCatching { + val dto = apiService.getQuestion() + // Mapeia DTO > modelo de domínio (separação de responsabilidades) + Question(id = dto.id, statement = dto.statement, options = dto.options) + }.mapNetworkError() + } + + override suspend fun submitAnswer(questionId: String, answer: String): Result = + withContext(Dispatchers.IO) { + runCatching { + val dto = apiService.submitAnswer(questionId, answer) + dto.result + }.mapNetworkError() + } + + override suspend fun getOrCreatePlayer(name: String): Result = + withContext(Dispatchers.IO) { + runCatching { + val existing = database.playerQueries.getPlayerByName(name).executeAsOneOrNull() + if (existing != null) { + // Jogador já existe: retorna sem criar duplicata + Player(id = existing.id, name = existing.name) + } else { + // Novo jogador: insere e busca o ID gerado + database.playerQueries.insertPlayer(name) + val newId = database.playerQueries.lastInsertRowId().executeAsOne() + Player(id = newId, name = name) + } + } + } + + override suspend fun saveQuizScore( + playerId: Long, + playerName: String, + score: Int, + totalQuestions: Int + ): Result = withContext(Dispatchers.IO) { + runCatching { + val timestamp = Clock.System.now().toString() + database.quizScoreQueries.insertScore( + player_id = playerId, + player_name = playerName, + score = score.toLong(), + total_questions = totalQuestions.toLong(), + created_at = timestamp + ) + } + } + + override suspend fun getLeaderboard(): Result> = + withContext(Dispatchers.IO) { + runCatching { + database.quizScoreQueries.getTopScores().executeAsList().map { row -> + QuizScore( + id = row.id, + playerId = row.player_id, + playerName = row.player_name, + score = row.score.toInt(), + totalQuestions = row.total_questions.toInt(), + createdAt = row.created_at + ) + } + } + } + + override suspend fun getPlayerScores(playerId: Long): Result> = + withContext(Dispatchers.IO) { + runCatching { + database.quizScoreQueries.getScoresByPlayerId(playerId).executeAsList().map { row -> + QuizScore( + id = row.id, + playerId = row.player_id, + playerName = row.player_name, + score = row.score.toInt(), + totalQuestions = row.total_questions.toInt(), + createdAt = row.created_at + ) + } + } + } + + + /** Extensão de Result que converte exceções genéricas em AppError.NetworkError. + * Isso garante que as camadas superiores (Use Cases, ViewModels) sempre recebam um AppError, nunca uma exceção inesperada */ + + private fun Result.mapNetworkError(): Result = this.recoverCatching { error -> + logger.e { error.message ?: "Unknown" } + when (error) { + is AppError -> throw error + else -> { + val isTimeout = error.message?.contains("timeout", ignoreCase = true) == true || + error.message?.contains("timed out", ignoreCase = true) == true + if (isTimeout) { + throw AppError.NetworkError( + "Não foi possível obter o Quiz. Verifique sua conexão e tente novamente." + ) + } else { + throw AppError.NetworkError( + error.message ?: "Erro de conexão. Tente novamente." + ) + } + } + } + } +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/di/AppModule.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/di/AppModule.kt new file mode 100644 index 0000000000..8a60d07230 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/di/AppModule.kt @@ -0,0 +1,104 @@ +package com.dynamox.quiz.di + +import com.dynamox.quiz.data.api.QuizApiService +import com.dynamox.quiz.data.api.createHttpClient +import com.dynamox.quiz.data.local.createDatabase +import com.dynamox.quiz.data.repository.QuizRepositoryImpl +import com.dynamox.quiz.domain.repository.QuizRepository +import com.dynamox.quiz.domain.usecase.GetLeaderboardUseCase +import com.dynamox.quiz.domain.usecase.GetOrCreatePlayerUseCase +import com.dynamox.quiz.domain.usecase.GetQuestionUseCase +import com.dynamox.quiz.domain.usecase.SaveQuizScoreUseCase +import com.dynamox.quiz.domain.usecase.SubmitAnswerUseCase +import com.dynamox.quiz.presentation.screens.leaderboard.LeaderboardViewModel +import com.dynamox.quiz.presentation.screens.login.LoginViewModel +import com.dynamox.quiz.presentation.screens.quiz.QuizViewModel +import com.dynamox.quiz.presentation.screens.result.ResultViewModel +import org.koin.core.module.dsl.factoryOf +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind +import org.koin.dsl.module + +/** + * Módulo Koin para dependências de rede. + * + * 'single' = Singleton: cria UMA instância compartilhada em toda a aplicação. + * O HttpClient e o QuizApiService são "caros" para criar, então é correto + * tê-los como singletons, isso evita criar múltiplas conexões TCP. + * + * Ordem de criação: + * 1. createHttpClient() > cria o HttpClient configurado + * 2. QuizApiService(get()) > Koin injeta automaticamente o HttpClient acima + */ +val networkModule = module { + single { createHttpClient() } + single { QuizApiService(get()) } +} + +/** + * Módulo Koin para o banco de dados SQLite. + * + * Singleton: o banco de dados deve ser uma única instância para + * evitar conflitos de acesso concorrente e múltiplas conexões ao arquivo .db. + * + * 'createDatabase(get())': o Koin injeta automaticamente o DatabaseDriverFactory + * que é registrado no androidModule. + */ +val databaseModule = module { + single { createDatabase(get()) } +} + +/** + * Módulo Koin para o repositório. + * + * 'singleOf(::QuizRepositoryImpl)' é uma DSL moderna do Koin 4 equivalente a: single { QuizRepositoryImpl(get(), get()) } + * + * 'bind QuizRepository::class' instrui o Koin a registrar a implementação + * sob a interface. Assim, ao injetar QuizRepository em qualquer lugar, + * o Koin fornece QuizRepositoryImpl — sem que o código cliente saiba disso. + * Isso é o Princípio da Inversão de Dependência (DIP do SOLID). + */ +val repositoryModule = module { + singleOf(::QuizRepositoryImpl) bind QuizRepository::class +} + +/** + * Módulo Koin para os Use Cases. + * + * Factory: cria uma NOVA instância a cada injeção. Criar um novo a cada uso garante thread-safety. + * + * OBS: 'factoryOf(::GetQuestionUseCase)' equivale a: factory { GetQuestionUseCase(get()) } + * O Koin injeta automaticamente o QuizRepository no construtor. + */ +val useCaseModule = module { + factoryOf(::GetQuestionUseCase) + factoryOf(::SubmitAnswerUseCase) + factoryOf(::GetOrCreatePlayerUseCase) + factoryOf(::SaveQuizScoreUseCase) + factoryOf(::GetLeaderboardUseCase) +} + +/** + * Módulo Koin para os ViewModels. + * + * Também usa 'factoryOf' pois cada tela cria seu próprio ViewModel gerenciado pelo ciclo de vida do Compose. + * + */ +val viewModelModule = module { + factoryOf(::LoginViewModel) + factoryOf(::QuizViewModel) + factoryOf(::ResultViewModel) + factoryOf(::LeaderboardViewModel) +} + +/** + * Lista com todos os módulos comuns + * Usada na inicialização do Koin em QuizApplication.kt junto com o androidModule + */ +val appModules = listOf( + networkModule, + databaseModule, + repositoryModule, + useCaseModule, + viewModelModule +) diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/model/AppError.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/model/AppError.kt new file mode 100644 index 0000000000..468a35d69a --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/model/AppError.kt @@ -0,0 +1,14 @@ +package com.dynamox.quiz.domain.model + + +sealed class AppError : Exception() { + data class NetworkError(override val message: String = "Sem conexão com a internet") : AppError() + + data class ServerError(val code: Int, override val message: String = "Erro no servidor") : AppError() + + data class NotFoundError(override val message: String = "Requisição não encontrada") : AppError() + + data class ValidationError(override val message: String = "Dado inválido") : AppError() + + data class UnknownError(override val message: String = "Ocorreu um erro inesperado") : AppError() +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/model/Player.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/model/Player.kt new file mode 100644 index 0000000000..0b96fa0a24 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/model/Player.kt @@ -0,0 +1,6 @@ +package com.dynamox.quiz.domain.model + +data class Player( + val id: Long, + val name: String +) diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/model/Question.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/model/Question.kt new file mode 100644 index 0000000000..594291102d --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/model/Question.kt @@ -0,0 +1,7 @@ +package com.dynamox.quiz.domain.model + +data class Question( + val id: String, + val statement: String, + val options: List +) diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/model/QuizScore.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/model/QuizScore.kt new file mode 100644 index 0000000000..c087ccc9d9 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/model/QuizScore.kt @@ -0,0 +1,10 @@ +package com.dynamox.quiz.domain.model + +data class QuizScore( + val id: Long, + val playerId: Long, + val playerName: String, + val score: Int, + val totalQuestions: Int, + val createdAt: String +) diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/repository/QuizRepository.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/repository/QuizRepository.kt new file mode 100644 index 0000000000..47bc65e8ef --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/repository/QuizRepository.kt @@ -0,0 +1,24 @@ +package com.dynamox.quiz.domain.repository + +import com.dynamox.quiz.domain.model.Player +import com.dynamox.quiz.domain.model.Question +import com.dynamox.quiz.domain.model.QuizScore + +interface QuizRepository { + suspend fun getQuestion(): Result + + suspend fun submitAnswer(questionId: String, answer: String): Result + + suspend fun getOrCreatePlayer(name: String): Result + + suspend fun saveQuizScore( + playerId: Long, + playerName: String, + score: Int, + totalQuestions: Int + ): Result + + suspend fun getLeaderboard(): Result> + + suspend fun getPlayerScores(playerId: Long): Result> +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/usecase/GetLeaderboardUseCase.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/usecase/GetLeaderboardUseCase.kt new file mode 100644 index 0000000000..b8263dbe60 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/usecase/GetLeaderboardUseCase.kt @@ -0,0 +1,14 @@ +package com.dynamox.quiz.domain.usecase + +import com.dynamox.quiz.domain.model.QuizScore +import com.dynamox.quiz.domain.repository.QuizRepository + +/** + * + * Retorna as pontuações salvas localmente no banco SQLite, ordenadas + * da maior para a menor. Usado na tela de Leaderboard. + * + */ +class GetLeaderboardUseCase(private val repository: QuizRepository) { + suspend operator fun invoke(): Result> = repository.getLeaderboard() +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/usecase/GetOrCreatePlayerUseCase.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/usecase/GetOrCreatePlayerUseCase.kt new file mode 100644 index 0000000000..da42fc7168 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/usecase/GetOrCreatePlayerUseCase.kt @@ -0,0 +1,30 @@ +package com.dynamox.quiz.domain.usecase + +import com.dynamox.quiz.domain.model.AppError +import com.dynamox.quiz.domain.model.Player +import com.dynamox.quiz.domain.repository.QuizRepository + +/** + * Registrar ou recuperar um jogador pelo nome + * + * Implementa o padrão "Get or Create" (upsert simplificado) + * - Se o nome já existe no banco > retorna o jogador existente + * - Se o nome é novo > cria e retorna o novo jogador + * + */ +class GetOrCreatePlayerUseCase(private val repository: QuizRepository) { + + suspend operator fun invoke(name: String): Result { + val trimmedName = name.trim() + + if (trimmedName.isBlank()) { + return Result.failure(AppError.ValidationError("O nome não pode ser vazio")) + } + + if (trimmedName.length > 30) { + return Result.failure(AppError.ValidationError("O nome não pode possuir mais de 30 caracteres")) + } + + return repository.getOrCreatePlayer(trimmedName) + } +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/usecase/GetQuestionUseCase.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/usecase/GetQuestionUseCase.kt new file mode 100644 index 0000000000..172b65b302 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/usecase/GetQuestionUseCase.kt @@ -0,0 +1,19 @@ +package com.dynamox.quiz.domain.usecase + +import com.dynamox.quiz.domain.model.Question +import com.dynamox.quiz.domain.repository.QuizRepository + +/** + * Buscar uma pergunta aleatória do servidor. + * + * Cada Use Case recebe dependências via construtor (injetadas pelo Koin) e expõe apenas um método público. + * + * Motivso para abordagem: + * - Testável isoladamente com um repositório fake + * - Fácil de reutilizar em múltiplas telas + * - Segue o Princípio da Responsabilidade Única (SRP do SOLID) + * + */ +class GetQuestionUseCase(private val repository: QuizRepository) { + suspend operator fun invoke(): Result = repository.getQuestion() +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/usecase/SaveQuizScoreUseCase.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/usecase/SaveQuizScoreUseCase.kt new file mode 100644 index 0000000000..1b8d3804c6 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/usecase/SaveQuizScoreUseCase.kt @@ -0,0 +1,13 @@ +package com.dynamox.quiz.domain.usecase + +import com.dynamox.quiz.domain.repository.QuizRepository + +/** Responsável por salvar a pontuação ao final do quiz.*/ +class SaveQuizScoreUseCase(private val repository: QuizRepository) { + suspend operator fun invoke( + playerId: Long, + playerName: String, + score: Int, + totalQuestions: Int + ): Result = repository.saveQuizScore(playerId, playerName, score, totalQuestions) +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/usecase/SubmitAnswerUseCase.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/usecase/SubmitAnswerUseCase.kt new file mode 100644 index 0000000000..056b282895 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/domain/usecase/SubmitAnswerUseCase.kt @@ -0,0 +1,15 @@ +package com.dynamox.quiz.domain.usecase + +import com.dynamox.quiz.domain.model.AppError +import com.dynamox.quiz.domain.repository.QuizRepository + +/** Responsável por validar e enviar a resposta do usuário */ +class SubmitAnswerUseCase(private val repository: QuizRepository) { + suspend operator fun invoke(questionId: String, answer: String): Result { + // Regra de negócio: não permite submeter resposta vazia ou só com espaços + if (answer.isBlank()) { + return Result.failure(AppError.ValidationError("Answer cannot be empty")) + } + return repository.submitAnswer(questionId, answer) + } +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/components/AnswerOption.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/components/AnswerOption.kt new file mode 100644 index 0000000000..c2bc9f28a3 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/components/AnswerOption.kt @@ -0,0 +1,129 @@ +package com.dynamox.quiz.presentation.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.dynamox.quiz.presentation.theme.CorrectGreen +import com.dynamox.quiz.presentation.theme.CorrectGreenLight +import com.dynamox.quiz.presentation.theme.OptionCorrect +import com.dynamox.quiz.presentation.theme.OptionCorrectBorder +import com.dynamox.quiz.presentation.theme.OptionDefault +import com.dynamox.quiz.presentation.theme.OptionSelected +import com.dynamox.quiz.presentation.theme.OptionSelectedBorder +import com.dynamox.quiz.presentation.theme.OptionWrong +import com.dynamox.quiz.presentation.theme.OptionWrongBorder +import com.dynamox.quiz.presentation.theme.TextPrimary +import com.dynamox.quiz.presentation.theme.WrongRed +import com.dynamox.quiz.presentation.theme.WrongRedLight + +/** Enum que representa os estados visuais de uma alternativa */ +enum class AnswerState { DEFAULT, SELECTED, CORRECT, WRONG } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AnswerOption( + text: String, + state: AnswerState, + onClick: () -> Unit, + enabled: Boolean = true, + modifier: Modifier = Modifier +) { + val containerColor by animateColorAsState( + targetValue = when (state) { + AnswerState.DEFAULT -> OptionDefault + AnswerState.SELECTED -> OptionSelected + AnswerState.CORRECT -> OptionCorrect + AnswerState.WRONG -> OptionWrong + }, + animationSpec = tween(300), + label = "container" + ) + + val borderColor by animateColorAsState( + targetValue = when (state) { + AnswerState.DEFAULT -> Color.Transparent + AnswerState.SELECTED -> OptionSelectedBorder + AnswerState.CORRECT -> OptionCorrectBorder + AnswerState.WRONG -> OptionWrongBorder + }, + animationSpec = tween(300), + label = "border" + ) + + val textColor by animateColorAsState( + targetValue = when (state) { + AnswerState.DEFAULT, AnswerState.SELECTED -> TextPrimary + AnswerState.CORRECT -> CorrectGreenLight + AnswerState.WRONG -> WrongRedLight + }, + animationSpec = tween(300), + label = "text" + ) + + Card( + onClick = onClick, + enabled = enabled, + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = containerColor), + border = BorderStroke(1.5.dp, borderColor), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = textColor, + modifier = Modifier.weight(1f) + ) + + when (state) { + AnswerState.CORRECT -> { + Spacer(Modifier.width(12.dp)) + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Correto", + tint = CorrectGreen, + modifier = Modifier.size(20.dp) + ) + } + AnswerState.WRONG -> { + Spacer(Modifier.width(12.dp)) + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Errado", + tint = WrongRed, + modifier = Modifier.size(20.dp) + ) + } + else -> {} + } + } + } +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/components/QuizButton.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/components/QuizButton.kt new file mode 100644 index 0000000000..a9a0ad740d --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/components/QuizButton.kt @@ -0,0 +1,98 @@ +package com.dynamox.quiz.presentation.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.dynamox.quiz.presentation.theme.DeepNavy +import com.dynamox.quiz.presentation.theme.ElectricBlue + +@Composable +fun PrimaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + isLoading: Boolean = false +) { + // animateFloatAsState anima suavemente a escala entre 1.0 (normal) e 0.97 (pressionado/inativo) + // Isso cria uma sensação de "afundar" quando desabilitado + val scale by animateFloatAsState(if (enabled && !isLoading) 1f else 0.97f, label = "scale") + + Button( + onClick = onClick, + enabled = enabled && !isLoading, + modifier = modifier + .fillMaxWidth() + .height(54.dp) + .scale(scale), + shape = RoundedCornerShape(50), + colors = ButtonDefaults.buttonColors( + containerColor = ElectricBlue, + contentColor = DeepNavy, + disabledContainerColor = ElectricBlue.copy(alpha = 0.38f), + disabledContentColor = DeepNavy.copy(alpha = 0.6f) + ), + elevation = ButtonDefaults.buttonElevation( + defaultElevation = 4.dp, + pressedElevation = 2.dp + ) + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.height(20.dp), + color = DeepNavy, + strokeWidth = 2.dp + ) + } else { + Text( + text = text, + style = MaterialTheme.typography.labelLarge + ) + } + } +} + +@Composable +fun SecondaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + contentColor: Color = ElectricBlue +) { + OutlinedButton( + onClick = onClick, + enabled = enabled, + modifier = modifier + .fillMaxWidth() + .height(54.dp), + shape = RoundedCornerShape(50), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = contentColor, + disabledContentColor = contentColor.copy(alpha = 0.38f) + ), + border = BorderStroke( + 1.5.dp, + if (enabled) contentColor else contentColor.copy(alpha = 0.38f) + ) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge + ) + } +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/navigation/NavGraph.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/navigation/NavGraph.kt new file mode 100644 index 0000000000..307a701219 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/navigation/NavGraph.kt @@ -0,0 +1,116 @@ +package com.dynamox.quiz.presentation.navigation + +import androidx.compose.runtime.Composable +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import com.dynamox.quiz.presentation.screens.leaderboard.LeaderboardScreen +import com.dynamox.quiz.presentation.screens.login.LoginScreen +import com.dynamox.quiz.presentation.screens.quiz.QuizScreen +import com.dynamox.quiz.presentation.screens.result.ResultScreen +import com.dynamox.quiz.presentation.screens.splash.SplashScreen + +/** + * + * Função Composable que configura o NavHost, o "gerenciador de telas" + * do Compose Navigation. Define todas as rotas e as transições entre elas. + * + * Importantes: + * - NavController: objeto que controla a navegação (push/pop da back stack) + * - NavHost: container que renderiza a tela atual com base na back stack + * - composable: registra um destino tipado para a classe T + * - popUpTo: ao navegar, remove telas da back stack para economizar memória + */ +@Composable +fun NavGraph() { + // rememberNavController cria e lembra o controlador de navegação entre recomposições (sobrevive a mudanças de estado da UI) + val navController = rememberNavController() + + NavHost( + navController = navController, + startDestination = SplashScreen + ) { + composable { + SplashScreen( + onSplashFinished = { + // Após a animação, navega para Login e REMOVE o Splash da back stack + // (inclusive = true), para que o botão "voltar" não retorne ao Splash + navController.navigate(LoginScreen) { + popUpTo(SplashScreen) { inclusive = true } + } + } + ) + } + + composable { + LoginScreen( + onStartQuiz = { player -> + // Navega para o Quiz passando nome e ID do jogador como parâmetros + navController.navigate(QuizScreen(playerName = player.name, playerId = player.id)) + }, + onViewLeaderboard = { + navController.navigate(LeaderboardScreen) + } + ) + } + + composable { backStackEntry -> + // toRoute() desserializa os parâmetros da rota atual + val route = backStackEntry.toRoute() + QuizScreen( + playerId = route.playerId, + playerName = route.playerName, + onQuizFinished = { score, total -> + navController.navigate( + ResultScreen( + playerName = route.playerName, + playerId = route.playerId, + score = score, + total = total + ) + ) { + // Remove a tela de Quiz da back stack ao ir para Resultado + // (para não voltar ao quiz no meio com o botão Back) + popUpTo(QuizScreen(route.playerName, route.playerId)) { inclusive = true } + } + } + ) + } + + composable { backStackEntry -> + val route = backStackEntry.toRoute() + ResultScreen( + playerName = route.playerName, + playerId = route.playerId, + score = route.score, + total = route.total, + onRestartQuiz = { + // Reinicia o quiz para o mesmo jogador, removendo o Resultado da back stack + navController.navigate( + QuizScreen(playerName = route.playerName, playerId = route.playerId) + ) { + popUpTo(ResultScreen(route.playerName, route.playerId, route.score, route.total)) { + inclusive = true + } + } + }, + onViewLeaderboard = { + navController.navigate(LeaderboardScreen) + }, + onHome = { + navController.navigate(LoginScreen) { + popUpTo(LoginScreen) { inclusive = false } + launchSingleTop = true // evita abrir Login se já está no topo + } + } + ) + } + + composable { + LeaderboardScreen( + onBack = { navController.popBackStack() } + ) + } + } +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/navigation/Screen.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/navigation/Screen.kt new file mode 100644 index 0000000000..68363753ab --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/navigation/Screen.kt @@ -0,0 +1,31 @@ +package com.dynamox.quiz.presentation.navigation + +import kotlinx.serialization.Serializable + +/** + * Definição das rotas de navegação usando Type-Safe Navigation do Compose. + * + * Em vez de usar strings ("splash", "quiz/abc/1"), usamos classes Kotlin @Serializable como rotas. + * O Compose Navigation serializa/desserializa automaticamente os parâmetros. + * + * Vantagens da navegação type-safe: + * - Erros de rota detectados em TEMPO DE COMPILAÇÃO (não em runtime) + * - Passagem de parâmetros tipada (Long, String, Int) sem conversão manual + * - Refactoring seguro: renomear uma propriedade quebra o código, não silencia + * + */ + +@Serializable +object SplashScreen + +@Serializable +object LoginScreen + +@Serializable +data class QuizScreen(val playerName: String, val playerId: Long) + +@Serializable +data class ResultScreen(val playerName: String, val playerId: Long, val score: Int, val total: Int) + +@Serializable +object LeaderboardScreen diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/leaderboard/LeaderboardScreen.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/leaderboard/LeaderboardScreen.kt new file mode 100644 index 0000000000..77a46aabb0 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/leaderboard/LeaderboardScreen.kt @@ -0,0 +1,255 @@ +package com.dynamox.quiz.presentation.screens.leaderboard + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.dynamox.quiz.domain.model.QuizScore +import com.dynamox.quiz.presentation.components.PrimaryButton +import com.dynamox.quiz.presentation.theme.DarkNavy +import com.dynamox.quiz.presentation.theme.DeepNavy +import com.dynamox.quiz.presentation.theme.ElectricBlue +import com.dynamox.quiz.presentation.theme.MidnightBlue +import com.dynamox.quiz.presentation.theme.SurfaceCard +import com.dynamox.quiz.presentation.theme.SurfaceCardLight +import com.dynamox.quiz.presentation.theme.TextPrimary +import com.dynamox.quiz.presentation.theme.TextSecondary +import org.koin.compose.viewmodel.koinViewModel + +private val Gold = Color(0xFFFFD700) +private val Silver = Color(0xFFC0C0C0) +private val Bronze = Color(0xFFCD7F32) + +@Composable +fun LeaderboardScreen( + onBack: () -> Unit, + viewModel: LeaderboardViewModel = koinViewModel() +) { + val state by viewModel.state.collectAsState() + + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf(MidnightBlue, DeepNavy, DarkNavy) + ) + ) + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Top bar + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Voltar", + tint = ElectricBlue + ) + } + Text( + text = "Leaderboard", + style = MaterialTheme.typography.headlineMedium, + color = TextPrimary, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center + ) + // Spacer to balance the back button + Spacer(Modifier.size(48.dp)) + } + + when (val currentState = state) { + is LeaderboardState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = ElectricBlue, + modifier = Modifier.size(48.dp) + ) + } + } + + is LeaderboardState.Empty -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(32.dp) + ) { + Text(text = "🏆", style = MaterialTheme.typography.displayMedium) + Spacer(Modifier.height(16.dp)) + Text( + text = currentState.message, + style = MaterialTheme.typography.bodyLarge, + color = TextSecondary, + textAlign = TextAlign.Center + ) + } + } + } + + is LeaderboardState.Error -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = currentState.message, + style = MaterialTheme.typography.bodyLarge, + color = TextSecondary, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(24.dp)) + PrimaryButton( + text = "Retry", + onClick = viewModel::loadLeaderboard, + modifier = Modifier.padding(horizontal = 32.dp) + ) + } + } + + is LeaderboardState.Success -> { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + item { Spacer(Modifier.height(8.dp)) } + itemsIndexed(currentState.scores) { index, score -> + ScoreItem(index = index, score = score) + } + item { Spacer(Modifier.height(24.dp)) } + } + } + } + } + } +} + +@Composable +private fun ScoreItem(index: Int, score: QuizScore) { + val rankColor = when (index) { + 0 -> Gold + 1 -> Silver + 2 -> Bronze + else -> TextSecondary + } + + val rankEmoji = when (index) { + 0 -> "🥇" + 1 -> "🥈" + 2 -> "🥉" + else -> "#${index + 1}" + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(if (index < 3) SurfaceCardLight else SurfaceCard) + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Rank + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(rankColor.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center + ) { + Text( + text = rankEmoji, + style = if (index < 3) MaterialTheme.typography.titleMedium + else MaterialTheme.typography.labelMedium, + color = rankColor, + fontWeight = FontWeight.Bold + ) + } + + // Info jogador + Column(modifier = Modifier.weight(1f)) { + Text( + text = score.playerName, + style = MaterialTheme.typography.titleMedium, + color = TextPrimary + ) + Text( + text = formatDate(score.createdAt), + style = MaterialTheme.typography.labelMedium, + color = TextSecondary + ) + } + + // Info pontuação + Column(horizontalAlignment = Alignment.End) { + Text( + text = "${score.score}/${score.totalQuestions}", + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + color = ElectricBlue + ) + val percent = (score.score.toFloat() / score.totalQuestions.toFloat() * 100).toInt() + Text( + text = "$percent%", + style = MaterialTheme.typography.labelMedium, + color = TextSecondary + ) + } + } +} + +private fun formatDate(isoDate: String): String { + return try { + val parts = isoDate.split("T")[0].split("-") + if (parts.size >= 3) { + val months = listOf("", "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") + val month = months.getOrNull(parts[1].toIntOrNull() ?: 0) ?: parts[1] + "$month ${parts[2]}, ${parts[0]}" + } else isoDate + } catch (e: Exception) { + isoDate + } +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/leaderboard/LeaderboardViewModel.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/leaderboard/LeaderboardViewModel.kt new file mode 100644 index 0000000000..604c37dca7 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/leaderboard/LeaderboardViewModel.kt @@ -0,0 +1,48 @@ +package com.dynamox.quiz.presentation.screens.leaderboard + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.dynamox.quiz.domain.model.QuizScore +import com.dynamox.quiz.domain.usecase.GetLeaderboardUseCase +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +sealed class LeaderboardState { + data object Loading : LeaderboardState() + data class Success(val scores: List) : LeaderboardState() + data class Empty(val message: String = "Nenhuma pontuação ainda. Seja o primeiro!") : LeaderboardState() + data class Error(val message: String) : LeaderboardState() +} + +// Carrega o ranking de pontuações do banco local assim que é criado (no init{}) e expõe o estado como StateFlow para a View +class LeaderboardViewModel( + private val getLeaderboard: GetLeaderboardUseCase +) : ViewModel() { + private val _state = MutableStateFlow(LeaderboardState.Loading) + + val state: StateFlow = _state.asStateFlow() + init { + loadLeaderboard() + } + + fun loadLeaderboard() { + viewModelScope.launch { + _state.update { LeaderboardState.Loading } + getLeaderboard() + .onSuccess { scores -> + _state.update { + if (scores.isEmpty()) LeaderboardState.Empty() + else LeaderboardState.Success(scores) + } + } + .onFailure { error -> + _state.update { + LeaderboardState.Error(error.message ?: "Falha ao carregar a classificação") + } + } + } + } +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/login/LoginScreen.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/login/LoginScreen.kt new file mode 100644 index 0000000000..00372d4a8a --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/login/LoginScreen.kt @@ -0,0 +1,218 @@ +package com.dynamox.quiz.presentation.screens.login + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dynamox.quiz.domain.model.Player +import com.dynamox.quiz.presentation.components.PrimaryButton +import com.dynamox.quiz.presentation.components.SecondaryButton +import com.dynamox.quiz.presentation.theme.DarkNavy +import com.dynamox.quiz.presentation.theme.DeepNavy +import com.dynamox.quiz.presentation.theme.Divider +import com.dynamox.quiz.presentation.theme.ElectricBlue +import com.dynamox.quiz.presentation.theme.MidnightBlue +import com.dynamox.quiz.presentation.theme.NeonPink +import com.dynamox.quiz.presentation.theme.TextHint +import com.dynamox.quiz.presentation.theme.TextPrimary +import com.dynamox.quiz.presentation.theme.TextSecondary +import com.dynamox.quiz.presentation.theme.WrongRed +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun LoginScreen( + onStartQuiz: (Player) -> Unit, + onViewLeaderboard: () -> Unit, + viewModel: LoginViewModel = koinViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + + // Reseta o estado ao entrar na tela, garantindo campo de nome vazio + LaunchedEffect(Unit) { + viewModel.resetState() + } + + LaunchedEffect(uiState.player) { + uiState.player?.let { player -> + onStartQuiz(player) + viewModel.onNavigated() + } + } + + LaunchedEffect(uiState.error) { + uiState.error?.let { error -> + snackbarHostState.showSnackbar(error) + viewModel.onErrorDismissed() + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf(MidnightBlue, DeepNavy, DarkNavy) + ) + ) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .imePadding() + .padding(horizontal = 32.dp, vertical = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // Logo + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background( + brush = Brush.linearGradient( + colors = listOf(ElectricBlue, NeonPink) + ) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = "DQ", + fontSize = 42.sp, + fontWeight = FontWeight.ExtraBold, + color = DeepNavy + ) + } + + Spacer(Modifier.height(24.dp)) + + Text( + text = "DynaQuiz", + style = MaterialTheme.typography.headlineLarge.copy( + brush = Brush.linearGradient( + colors = listOf(ElectricBlue, NeonPink) + ) + ), + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = "Digite seu nome para iniciarmos!", + style = MaterialTheme.typography.bodyMedium, + color = TextSecondary, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(48.dp)) + + OutlinedTextField( + value = uiState.playerName, + onValueChange = viewModel::onNameChanged, + label = { + Text("Seu nome ou apelido", color = TextHint) + }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = ElectricBlue, + unfocusedBorderColor = Divider, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, + cursorColor = ElectricBlue, + focusedLabelColor = ElectricBlue, + unfocusedLabelColor = TextHint, + focusedContainerColor = DarkNavy.copy(alpha = 0.5f), + unfocusedContainerColor = DarkNavy.copy(alpha = 0.3f) + ), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { viewModel.onStartQuiz() } + ), + isError = uiState.error != null + ) + + AnimatedVisibility( + visible = uiState.error != null, + enter = fadeIn() + slideInVertically(), + exit = fadeOut() + ) { + Text( + text = uiState.error ?: "", + color = WrongRed, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp, start = 16.dp) + ) + } + + Spacer(Modifier.height(32.dp)) + + PrimaryButton( + text = "Iniciar Quiz", + onClick = viewModel::onStartQuiz, + isLoading = uiState.isLoading, + enabled = uiState.playerName.isNotBlank() + ) + + Spacer(Modifier.height(16.dp)) + + SecondaryButton( + text = "Classificação", + onClick = onViewLeaderboard + ) + } + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp) + ) + } +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/login/LoginViewModel.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/login/LoginViewModel.kt new file mode 100644 index 0000000000..3e8080a459 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/login/LoginViewModel.kt @@ -0,0 +1,72 @@ +package com.dynamox.quiz.presentation.screens.login + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.dynamox.quiz.domain.model.Player +import com.dynamox.quiz.domain.usecase.GetOrCreatePlayerUseCase +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Data class imutável que representa um "snapshot" do estado da tela em um dado momento. + */ +data class LoginUiState( + val playerName: String = "", + val isLoading: Boolean = false, + val error: String? = null, + val player: Player? = null +) + +// getOrCreatePlayer Use Case injetado pelo Koin para registrar jogadores. +class LoginViewModel( + private val getOrCreatePlayer: GetOrCreatePlayerUseCase +) : ViewModel() { + private val _uiState = MutableStateFlow(LoginUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onNameChanged(name: String) { + _uiState.update { it.copy(playerName = name, error = null) } + } + + fun onStartQuiz() { + val name = _uiState.value.playerName.trim() + if (name.isBlank()) { + _uiState.update { it.copy(error = "Por favor, insira seu nome ou apelido") } + return + } + + // 'launch' inicia uma coroutine assíncrona no viewModelScope + // Não bloqueia a thread principal enquanto aguarda o banco de dados + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + getOrCreatePlayer(name) + .onSuccess { player -> + // Sucesso: preenche 'player' > a tela vai detectar e navegar + _uiState.update { it.copy(isLoading = false, player = player) } + } + .onFailure { error -> + _uiState.update { + it.copy( + isLoading = false, + error = error.message ?: "Falha ao iniciar. Tente novamente." + ) + } + } + } + } + + fun onNavigated() { + _uiState.update { it.copy(player = null) } + } + + fun onErrorDismissed() { + _uiState.update { it.copy(error = null) } + } + + fun resetState() { + _uiState.update { LoginUiState() } + } +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/quiz/QuizScreen.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/quiz/QuizScreen.kt new file mode 100644 index 0000000000..f24f73da4d --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/quiz/QuizScreen.kt @@ -0,0 +1,432 @@ +package com.dynamox.quiz.presentation.screens.quiz + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.dynamox.quiz.presentation.components.AnswerOption +import com.dynamox.quiz.presentation.components.AnswerState +import com.dynamox.quiz.presentation.components.PrimaryButton +import com.dynamox.quiz.presentation.components.SecondaryButton +import com.dynamox.quiz.presentation.theme.CorrectGreen +import com.dynamox.quiz.presentation.theme.DarkNavy +import com.dynamox.quiz.presentation.theme.DeepNavy +import com.dynamox.quiz.presentation.theme.ElectricBlue +import com.dynamox.quiz.presentation.theme.MidnightBlue +import com.dynamox.quiz.presentation.theme.NeonPink +import com.dynamox.quiz.presentation.theme.SurfaceCard +import com.dynamox.quiz.presentation.theme.TextPrimary +import com.dynamox.quiz.presentation.theme.TextSecondary +import com.dynamox.quiz.presentation.theme.WrongRed +import org.koin.compose.viewmodel.koinViewModel + +/** + * + * Estrutura da tela: + * - LoadingQuestion: spinner + * - ShowQuestion: pergunta + opções + * - SubmittingAnswer: opções + loading + * - ShowResult: feedback colorido + * - Error: mensagem + retry + * + * - playerId ID do jogador (pra salvar score). + * - playerName Nome para exibir no header. + * - onQuizFinished Callback quando as 10 perguntas são concluídas. + * - viewModel ViewModel injetado pelo Koin via koinViewModel(). + */ +@Composable +fun QuizScreen( + playerId: Long, + playerName: String, + onQuizFinished: (score: Int, total: Int) -> Unit, + viewModel: QuizViewModel = koinViewModel() +) { + // collectAsState() converte o StateFlow em State do Compose + // O Composable recompõe automaticamente quando o estado muda + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(playerId) { + viewModel.initQuiz(playerId, playerName) + } + + /** + * LaunchedEffect(uiState.quizState): monitora o estado do quiz. + * Quando o estado vira Finished, chama onQuizFinished() para navegar. + * Usar o estado como chave garante que só executa quando muda. + */ + LaunchedEffect(uiState.quizState) { + if (uiState.quizState is QuizState.Finished) { + onQuizFinished(uiState.score, uiState.totalQuestions) + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf(MidnightBlue, DeepNavy, DarkNavy) + ) + ) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp, vertical = 24.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "Olá, $playerName!", + style = MaterialTheme.typography.labelLarge, + color = ElectricBlue + ) + Text( + text = "Pergunta ${uiState.currentQuestionIndex + 1} de ${uiState.totalQuestions}", + style = MaterialTheme.typography.bodyMedium, + color = TextSecondary + ) + } + + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(SurfaceCard) + .padding(horizontal = 14.dp, vertical = 8.dp) + ) { + Text( + text = "Pontos: ${uiState.score}", + style = MaterialTheme.typography.labelLarge, + color = ElectricBlue + ) + } + } + + Spacer(Modifier.height(16.dp)) + LinearProgressIndicator( + progress = { + (uiState.currentQuestionIndex.toFloat() + 1f) / uiState.totalQuestions.toFloat() + }, + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .clip(RoundedCornerShape(50)), + color = ElectricBlue, + trackColor = SurfaceCard, + strokeCap = StrokeCap.Round + ) + + Spacer(Modifier.height(24.dp)) + + /** + * AnimatedContent: troca o conteúdo com animação quando o estado muda. + * + * transitionSpec: define como entra e sai: + * - Entrada: fade-in + deslizamento suave para cima + * - Saída: fade-out + * + * O label "quiz_content" é para identificar no Compose Layout Inspector. + */ + AnimatedContent( + targetState = uiState.quizState, + transitionSpec = { + (fadeIn(tween(300)) + slideInVertically(tween(300)) { it / 10 }) + .togetherWith(fadeOut(tween(200))) + }, + label = "quiz_content" + ) { state -> + when (state) { + is QuizState.LoadingQuestion -> LoadingContent() + + // Pergunta exibida: mostra opções e botão de confirmar + is QuizState.ShowQuestion -> QuestionContent( + question = state.question, + selectedAnswer = uiState.selectedAnswer, + isSubmitting = false, + onAnswerSelected = viewModel::onAnswerSelected, + onSubmit = viewModel::onSubmitAnswer + ) + + // Enviando resposta: mantém a pergunta visível com loading no botão + is QuizState.SubmittingAnswer -> QuestionContent( + question = state.question, + selectedAnswer = uiState.selectedAnswer, + isSubmitting = true, + onAnswerSelected = viewModel::onAnswerSelected, + onSubmit = {} + ) + + // Resultado recebido: feedback colorido com botão "Próxima" + is QuizState.ShowResult -> ResultFeedbackContent( + question = state.question, + selectedAnswer = state.selectedAnswer, + isCorrect = state.isCorrect, + isLastQuestion = uiState.currentQuestionIndex + 1 >= uiState.totalQuestions, + onNext = viewModel::onNextQuestion + ) + + // Erro: mensagem + botão retry + is QuizState.Error -> ErrorContent( + message = state.message, + onRetry = viewModel::onRetryLoad + ) + + // Finalizado: spinner temporário enquanto navega + is QuizState.Finished -> LoadingContent() + } + } + } + } +} + +@Composable +private fun LoadingContent() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + color = ElectricBlue, + modifier = Modifier.size(48.dp), + strokeWidth = 3.dp + ) + Spacer(Modifier.height(16.dp)) + Text( + text = "Carregando pergunta...", + style = MaterialTheme.typography.bodyMedium, + color = TextSecondary + ) + } + } +} + +@Composable +private fun QuestionContent( + question: com.dynamox.quiz.domain.model.Question, + selectedAnswer: String?, + isSubmitting: Boolean, + onAnswerSelected: (String) -> Unit, + onSubmit: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(SurfaceCard) + .padding(24.dp) + ) { + Text( + text = question.statement, + style = MaterialTheme.typography.headlineSmall, + color = TextPrimary, + textAlign = TextAlign.Start + ) + } + + Spacer(Modifier.height(24.dp)) + + Text( + text = "Escolha sua resposta:", + style = MaterialTheme.typography.labelMedium, + color = TextSecondary, + modifier = Modifier.padding(bottom = 12.dp) + ) + + question.options.forEach { option -> + AnswerOption( + text = option, + // Estado: selecionada se for a escolhida, padrão caso contrário + state = when { + selectedAnswer == option -> AnswerState.SELECTED + else -> AnswerState.DEFAULT + }, + onClick = { onAnswerSelected(option) }, + // Desabilita cliques enquanto aguarda resposta da API + enabled = !isSubmitting, + modifier = Modifier.padding(bottom = 10.dp) + ) + } + + Spacer(Modifier.height(24.dp)) + + // Botão de confirmar — desabilitado se nenhuma alternativa foi selecionada + PrimaryButton( + text = if (isSubmitting) "Verificando..." else "Confirmar Resposta", + onClick = onSubmit, + enabled = selectedAnswer != null && !isSubmitting, + isLoading = isSubmitting + ) + + Spacer(Modifier.height(16.dp)) + } +} + +@Composable +private fun ResultFeedbackContent( + question: com.dynamox.quiz.domain.model.Question, + selectedAnswer: String, + isCorrect: Boolean, + isLastQuestion: Boolean, + onNext: () -> Unit +) { + val feedbackColor = if (isCorrect) CorrectGreen else WrongRed + val feedbackText = if (isCorrect) "Correto!" else "Errado!" + val feedbackEmoji = if (isCorrect) "🎉" else "😕" + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(feedbackColor.copy(alpha = 0.15f)) + .padding(20.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = feedbackEmoji, style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.size(12.dp)) + Text( + text = feedbackText, + style = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.Bold + ), + color = feedbackColor + ) + } + } + + Spacer(Modifier.height(16.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(SurfaceCard) + .padding(20.dp) + ) { + Text( + text = question.statement, + style = MaterialTheme.typography.bodyLarge, + color = TextSecondary + ) + } + + Spacer(Modifier.height(16.dp)) + + Text( + text = "Alternativas:", + style = MaterialTheme.typography.labelMedium, + color = TextSecondary, + modifier = Modifier.padding(bottom = 12.dp) + ) + + question.options.forEach { option -> + AnswerOption( + text = option, + state = when { + option == selectedAnswer && isCorrect -> AnswerState.CORRECT + option == selectedAnswer && !isCorrect -> AnswerState.WRONG + else -> AnswerState.DEFAULT + }, + onClick = {}, //Validar + enabled = false, + modifier = Modifier.padding(bottom = 10.dp) + ) + } + + Spacer(Modifier.height(24.dp)) + + PrimaryButton( + text = if (isLastQuestion) "Ver Resultado Final" else "Próxima Pergunta", + onClick = onNext + ) + + Spacer(Modifier.height(16.dp)) + } +} + +@Composable +private fun ErrorContent(message: String, onRetry: () -> Unit) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text(text = "⚠️", style = MaterialTheme.typography.displayMedium) + Spacer(Modifier.height(16.dp)) + Text( + text = "Algo deu errado", + style = MaterialTheme.typography.headlineSmall, + color = TextPrimary, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(8.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = TextSecondary, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 24.dp) + ) + Spacer(Modifier.height(32.dp)) + PrimaryButton( + text = "Tentar Novamente", + onClick = onRetry, + modifier = Modifier.padding(horizontal = 32.dp) + ) + } +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/quiz/QuizViewModel.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/quiz/QuizViewModel.kt new file mode 100644 index 0000000000..0cab8435d7 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/quiz/QuizViewModel.kt @@ -0,0 +1,180 @@ +package com.dynamox.quiz.presentation.screens.quiz + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.dynamox.quiz.domain.model.Question +import com.dynamox.quiz.domain.usecase.GetQuestionUseCase +import com.dynamox.quiz.domain.usecase.SaveQuizScoreUseCase +import com.dynamox.quiz.domain.usecase.SubmitAnswerUseCase +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +private const val TOTAL_QUESTIONS = 10 + +/** + * Sealed class que representa todos os estados possíveis da tela de Quiz. + * + * A Sealed class garante que ao fazer 'when(quizState)', o compilador exige que todos os + * casos sejam tratados, sem risco de esqucer um estado. + * + */ +sealed class QuizState { + /** Aguardando resposta da API (requisição GET /question em andamento) */ + data object LoadingQuestion : QuizState() + + /** Pergunta carregada e exibida ao usuário */ + data class ShowQuestion(val question: Question) : QuizState() + + /** Resposta enviada à API (requisição POST /answer em andamento) */ + data class SubmittingAnswer(val question: Question) : QuizState() + + /** Resultado recebido — exibe feedback de acerto/erro ao usuário */ + data class ShowResult( + val question: Question, + val selectedAnswer: String, + val isCorrect: Boolean + ) : QuizState() + + /** Quiz concluído — 10 perguntas respondidas. Dispara navegação para Result. */ + data object Finished : QuizState() + + /** Erro ao carregar pergunta ou ao enviar resposta. Exibe botão "Tentar Novamente". */ + data class Error(val message: String) : QuizState() +} + +data class QuizUiState( + val playerId: Long = 0L, + val playerName: String = "", + val currentQuestionIndex: Int = 0, + val score: Int = 0, + val totalQuestions: Int = TOTAL_QUESTIONS, + val selectedAnswer: String? = null, + val quizState: QuizState = QuizState.LoadingQuestion +) + +/** + * Ciclo de vida de uma pergunta: + * LoadingQuestion > ShowQuestion > [usuário seleciona] > SubmittingAnswer + * > ShowResult > [usuário clica Próxima] > LoadingQuestion (próxima) + * (ou Finished se era a 10ª pergunta) + */ +class QuizViewModel( + private val getQuestion: GetQuestionUseCase, + private val submitAnswer: SubmitAnswerUseCase, + private val saveQuizScore: SaveQuizScoreUseCase +) : ViewModel() { + + private val _uiState = MutableStateFlow(QuizUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun initQuiz(playerId: Long, playerName: String) { + _uiState.update { + it.copy( + playerId = playerId, + playerName = playerName, + currentQuestionIndex = 0, + score = 0, + selectedAnswer = null, + quizState = QuizState.LoadingQuestion + ) + } + loadNextQuestion() + } + + fun onAnswerSelected(answer: String) { + val currentState = _uiState.value.quizState + // Só permite seleção se a pergunta está sendo exibida (não enviando nem mostrando resultado) + if (currentState is QuizState.ShowQuestion) { + _uiState.update { it.copy(selectedAnswer = answer) } + } + } + + fun onSubmitAnswer() { + val state = _uiState.value + val question = (state.quizState as? QuizState.ShowQuestion)?.question ?: return + val answer = state.selectedAnswer ?: return + + viewModelScope.launch { + // Transição para loading — mantém a pergunta visível (sem flicker) + _uiState.update { it.copy(quizState = QuizState.SubmittingAnswer(question)) } + + submitAnswer(question.id, answer) + .onSuccess { isCorrect -> + // Incrementa score se acertou + val newScore = if (isCorrect) state.score + 1 else state.score + _uiState.update { + it.copy( + score = newScore, + quizState = QuizState.ShowResult(question, answer, isCorrect) + ) + } + } + .onFailure { error -> + _uiState.update { + it.copy(quizState = QuizState.Error(error.message ?: "Falha ao enviar resposta")) + } + } + } + } + + fun onNextQuestion() { + val state = _uiState.value + val nextIndex = state.currentQuestionIndex + 1 + + if (nextIndex >= state.totalQuestions) { + finishQuiz() + } else { + _uiState.update { + it.copy( + currentQuestionIndex = nextIndex, + selectedAnswer = null, + quizState = QuizState.LoadingQuestion + ) + } + loadNextQuestion() + } + } + + fun onRetryLoad() { + _uiState.update { it.copy(quizState = QuizState.LoadingQuestion, selectedAnswer = null) } + loadNextQuestion() + } + + private fun loadNextQuestion() { + viewModelScope.launch { + getQuestion() + .onSuccess { question -> + _uiState.update { it.copy(quizState = QuizState.ShowQuestion(question)) } + } + .onFailure { error -> + _uiState.update { + it.copy(quizState = QuizState.Error(error.message ?: "Falha ao carregar pergunta")) + } + } + } + } + + /** + * + * A pontuação é salva em background (sem bloquear a UI). + * O estado Finished é definido sincronicamente — não espera o banco salvar. + * + */ + private fun finishQuiz() { + val state = _uiState.value + + viewModelScope.launch { + saveQuizScore( + playerId = state.playerId, + playerName = state.playerName, + score = state.score, + totalQuestions = state.totalQuestions + ) + } + + _uiState.update { it.copy(quizState = QuizState.Finished) } + } +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/result/ResultScreen.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/result/ResultScreen.kt new file mode 100644 index 0000000000..32e43d197e --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/result/ResultScreen.kt @@ -0,0 +1,247 @@ +package com.dynamox.quiz.presentation.screens.result + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.EaseOutBack +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dynamox.quiz.presentation.components.PrimaryButton +import com.dynamox.quiz.presentation.components.SecondaryButton +import com.dynamox.quiz.presentation.theme.CorrectGreen +import com.dynamox.quiz.presentation.theme.DarkNavy +import com.dynamox.quiz.presentation.theme.DeepNavy +import com.dynamox.quiz.presentation.theme.ElectricBlue +import com.dynamox.quiz.presentation.theme.MidnightBlue +import com.dynamox.quiz.presentation.theme.RoyalBlue +import com.dynamox.quiz.presentation.theme.SurfaceCard +import com.dynamox.quiz.presentation.theme.TextPrimary +import com.dynamox.quiz.presentation.theme.TextSecondary +import com.dynamox.quiz.presentation.theme.WrongRed +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun ResultScreen( + playerName: String, + playerId: Long, + score: Int, + total: Int, + onRestartQuiz: () -> Unit, + onViewLeaderboard: () -> Unit, + onHome: () -> Unit, + viewModel: ResultViewModel = koinViewModel() +) { + LaunchedEffect(Unit) { + viewModel.loadRecentScores() + } + + val percentage = (score.toFloat() / total.toFloat()) * 100f + val emoji = when { + percentage >= 90 -> "🏆" + percentage >= 70 -> "🎉" + percentage >= 50 -> "👍" + else -> "💪" + } + val message = when { + percentage >= 90 -> "Surreal!!" + percentage >= 70 -> "Excelente trabalho!" + percentage >= 50 -> "Você foi bem!" + else -> "Continue praticando!" + } + + val scoreColor = when { + percentage >= 70 -> CorrectGreen + percentage >= 50 -> ElectricBlue + else -> WrongRed + } + + val scale = remember { Animatable(0f) } + LaunchedEffect(Unit) { + scale.animateTo(1f, animationSpec = tween(600, easing = EaseOutBack)) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf(MidnightBlue, DeepNavy, DarkNavy) + ) + ) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 28.dp, vertical = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = emoji, + style = MaterialTheme.typography.displayLarge, + modifier = Modifier.scale(scale.value) + ) + + Spacer(Modifier.height(16.dp)) + + Text( + text = message, + style = MaterialTheme.typography.headlineLarge, + color = TextPrimary, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = playerName, + style = MaterialTheme.typography.titleMedium, + color = ElectricBlue, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(40.dp)) + + // Score card + Box( + modifier = Modifier + .scale(scale.value) + .size(160.dp) + .clip(CircleShape) + .background( + brush = Brush.radialGradient( + colors = listOf( + scoreColor.copy(alpha = 0.3f), + scoreColor.copy(alpha = 0.05f) + ) + ) + ) + .padding(4.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + .background(SurfaceCard), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "$score", + fontSize = 52.sp, + fontWeight = FontWeight.ExtraBold, + color = scoreColor + ) + Text( + text = "de $total", + style = MaterialTheme.typography.bodyMedium, + color = TextSecondary + ) + } + } + } + + Spacer(Modifier.height(32.dp)) + + // Stats row + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(SurfaceCard) + .padding(24.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + StatItem( + label = "Correto", + value = "$score", + color = CorrectGreen + ) + StatItem( + label = "Errado", + value = "${total - score}", + color = WrongRed + ) + StatItem( + label = "Pontuação", + value = "${percentage.toInt()}%", + color = ElectricBlue + ) + } + + Spacer(Modifier.height(40.dp)) + + PrimaryButton( + text = "Jogar novamente", + onClick = onRestartQuiz + ) + + Spacer(Modifier.height(12.dp)) + + SecondaryButton( + text = "Classificação", + onClick = onViewLeaderboard, + contentColor = RoyalBlue + ) + + Spacer(Modifier.height(12.dp)) + + SecondaryButton( + text = "Voltar para o início", + onClick = onHome + ) + + Spacer(Modifier.height(24.dp)) + } + } +} + +@Composable +private fun StatItem( + label: String, + value: String, + color: Color +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = value, + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold), + color = color + ) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = TextSecondary + ) + } +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/result/ResultViewModel.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/result/ResultViewModel.kt new file mode 100644 index 0000000000..ec43f9dcd2 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/result/ResultViewModel.kt @@ -0,0 +1,40 @@ +package com.dynamox.quiz.presentation.screens.result + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.dynamox.quiz.domain.model.QuizScore +import com.dynamox.quiz.domain.usecase.GetLeaderboardUseCase +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class ResultUiState( + val playerScores: List = emptyList(), + val isLoadingHistory: Boolean = false +) + + // getLeaderboard Use Case para buscar o ranking do banco local. +class ResultViewModel( + private val getLeaderboard: GetLeaderboardUseCase +) : ViewModel() { + + private val _uiState = MutableStateFlow(ResultUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadRecentScores() { + viewModelScope.launch { + _uiState.update { it.copy(isLoadingHistory = true) } + getLeaderboard() + .onSuccess { scores -> + _uiState.update { + it.copy(playerScores = scores.take(5), isLoadingHistory = false) + } + } + .onFailure { + _uiState.update { it.copy(isLoadingHistory = false) } + } + } + } +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/splash/SplashScreen.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/splash/SplashScreen.kt new file mode 100644 index 0000000000..c927a2f256 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/screens/splash/SplashScreen.kt @@ -0,0 +1,156 @@ +package com.dynamox.quiz.presentation.screens.splash + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.EaseOutBack +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dynamox.quiz.presentation.theme.DeepNavy +import com.dynamox.quiz.presentation.theme.DarkNavy +import com.dynamox.quiz.presentation.theme.ElectricBlue +import com.dynamox.quiz.presentation.theme.MidnightBlue +import com.dynamox.quiz.presentation.theme.NeonPink +import com.dynamox.quiz.domain.usecase.GetQuestionUseCase +import com.dynamox.quiz.presentation.theme.TextSecondary +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.koin.compose.koinInject + +/** + * Tela de splash exibida ao iniciar o app. + * + * Nos testes, tive problemas na primeira inicialização do app, onde a primeira pergunta + * sempre nos retornava um timeout, sendo necessário com isso clicar em retry. Com isso, + * a Splash além das animações visuais, dispara um pré-aquecimento da API em paralelo. + * A API roda no Google Cloud Run, que "adormece" após inatividade (cold start). + * Ao fazer uma requisição silenciosa aqui, o servidor já estará ativo quando + * o usuário chegar na tela do quiz. + * + * getQuestion Use Case injetado pelo Koin para pré-aquecer a API. + */ +@Composable +fun SplashScreen( + onSplashFinished: () -> Unit, + getQuestion: GetQuestionUseCase = koinInject() +) { + //Diferente de animateXAsState, Animatable permte sequenciar animações com awaitCompletion + val scale = remember { Animatable(0f) } // Logo começa invisível (escala 0) + val alpha = remember { Animatable(0f) } // Logo começa transparente + val textAlpha = remember { Animatable(0f) } // Título começa transparente + val subtitleAlpha = remember { Animatable(0f) }// Subtítulo começa transparente + + LaunchedEffect(Unit) { + // Dispara o pré-aquecimento da API em paralelo com as animações. + // O resultado é descartado — não importa se falhar; a única finalidade + // é "acordar" o servidor Cloud Run antes que o usuário chegue ao quiz. + launch { runCatching { getQuestion() } } + + // Animação 1: logo "cresce" com efeito elástico (EaseOutBack) + scale.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 700, easing = EaseOutBack) + ) + // Animação 2: logo aparece gradualmente + alpha.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 500) + ) + // Animação 3: título fade-in (delayMillis = espera antes de iniciar) + textAlpha.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 600, delayMillis = 200) + ) + // Animação 4: subtítulo fade-in + subtitleAlpha.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 600, delayMillis = 100) + ) + delay(1000) + onSplashFinished() + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + // Gradiente vertical de fundo: escuro no topo, mais escuro embaixo + brush = Brush.verticalGradient( + colors = listOf(MidnightBlue, DeepNavy, DarkNavy) + ) + ), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(32.dp) + ) { + Box( + modifier = Modifier + .scale(scale.value) + .alpha(alpha.value) + .size(120.dp) + .clip(CircleShape) + .background( + brush = Brush.linearGradient( + colors = listOf(ElectricBlue, NeonPink) + ) + ), + contentAlignment = Alignment.Center + ) { + //Letras da logo + Text( + text = "DQ", + fontSize = 64.sp, + fontWeight = FontWeight.ExtraBold, + color = DeepNavy + ) + } + + Spacer(Modifier.height(32.dp)) + Text( + text = "DynaQuiz", + style = MaterialTheme.typography.displayMedium.copy( + // Gradiente aplicado DIRETAMENTE no texto via Brush + brush = Brush.linearGradient( + colors = listOf(ElectricBlue, NeonPink) + ) + ), + modifier = Modifier.alpha(textAlpha.value), + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(8.dp)) + Text( + text = "Teste seus conhecimentos", + style = MaterialTheme.typography.bodyLarge, + color = TextSecondary, + modifier = Modifier.alpha(subtitleAlpha.value), + textAlign = TextAlign.Center + ) + } + } +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/theme/Color.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/theme/Color.kt new file mode 100644 index 0000000000..b14fa2ce16 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/theme/Color.kt @@ -0,0 +1,86 @@ +package com.dynamox.quiz.presentation.theme + +import androidx.compose.ui.graphics.Color + +/** + * Paleta de cores da aplicação + * + * Todas as cores são definidas aqui como constantes para garantir + * consistência visual em todo o app. + */ + +// Azul elétrico — cor de destaque principal, usada em botões e progresso +val ElectricBlue = Color(0xFF4CC9F0) + +// Azul escuro profundo — cor de fundo principal das telas +val DeepNavy = Color(0xFF0D1B2A) + +// Azul escuro médio — usado em gradientes e backgrounds secundários +val DarkNavy = Color(0xFF1A2744) + +// Azul meia-noite — topo dos gradientes verticais +val MidnightBlue = Color(0xFF0F2044) + +// Azul royal — usado em botões secundários e destaques suaves +val RoyalBlue = Color(0xFF3A86FF) + +// Roxo vibrante +val VibrantPurple = Color(0xFF7B2FBE) + +// Rosa neon — usado no logo (gradiente com ElectricBlue) e acentos +val NeonPink = Color(0xFFF72585) + + +// Verde para feedback de resposta correta +val CorrectGreen = Color(0xFF4CAF50) + +// Verde claro para texto em fundo escuro indicando acerto +val CorrectGreenLight = Color(0xFF81C784) + +// Vermelho para feedback de resposta errada +val WrongRed = Color(0xFFEF5350) + +// Vermelho claro para texto em fundo escuro indicando erro +val WrongRedLight = Color(0xFFEF9A9A) + +// Fundo de cards e containers elevados +val SurfaceDark = Color(0xFF162035) + +// Fundo de cards do quiz e leaderboard +val SurfaceCard = Color(0xFF1E2D4A) + +// Fundo de cards destacados (top 3 no leaderboard) +val SurfaceCardLight = Color(0xFF253558) + +// Cor de divisores e bordas sutis +val Divider = Color(0xFF2E3F5C) + +// Texto principal — títulos, perguntas, valores de destaque +val TextPrimary = Color(0xFFF0F4FF) + +// Texto secundário — subtítulos, labels, informações complementares +val TextSecondary = Color(0xFFB0BED9) + +// Texto de placeholder/hint — ex: label do TextField antes de digitar +val TextHint = Color(0xFF6B7FA3) + +// Fundo padrão de uma alternativa não selecionada +val OptionDefault = Color(0xFF1E2D4A) + +// Fundo de uma alternativa selecionada pelo usuário +val OptionSelected = Color(0xFF1A3A6B) + +// Borda de alternativa selecionada (antes de confirmar) +val OptionSelectedBorder = Color(0xFF4CC9F0) + +// Fundo de alternativa confirmada como correta +val OptionCorrect = Color(0xFF1B4332) + +// Borda de alternativa confirmada como correta +val OptionCorrectBorder = Color(0xFF4CAF50) + +// Fundo de alternativa confirmada como errada +val OptionWrong = Color(0xFF4A1C20) + +// Borda de alternativa confirmada como errada +val OptionWrongBorder = Color(0xFFEF5350) diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/theme/Theme.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/theme/Theme.kt new file mode 100644 index 0000000000..0a28a9a2a9 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/theme/Theme.kt @@ -0,0 +1,36 @@ +package com.dynamox.quiz.presentation.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable + +private val DarkColorScheme = darkColorScheme( + primary = ElectricBlue, + onPrimary = DeepNavy, + primaryContainer = DarkNavy, + onPrimaryContainer = ElectricBlue, + secondary = RoyalBlue, + onSecondary = TextPrimary, + secondaryContainer = SurfaceCard, + onSecondaryContainer = TextPrimary, + tertiary = NeonPink, + onTertiary = TextPrimary, + background = DeepNavy, + onBackground = TextPrimary, + surface = SurfaceDark, + onSurface = TextPrimary, + surfaceVariant = SurfaceCard, + onSurfaceVariant = TextSecondary, + outline = Divider, + error = WrongRed, + onError = TextPrimary +) + +@Composable +fun DynaQuizTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = DarkColorScheme, + typography = AppTypography, + content = content + ) +} diff --git a/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/theme/Type.kt b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/theme/Type.kt new file mode 100644 index 0000000000..52127a8f01 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/kotlin/com/dynamox/quiz/presentation/theme/Type.kt @@ -0,0 +1,78 @@ +package com.dynamox.quiz.presentation.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val AppTypography = Typography( + displayLarge = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 40.sp, + lineHeight = 48.sp, + letterSpacing = (-0.5).sp, + color = TextPrimary + ), + displayMedium = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 32.sp, + lineHeight = 40.sp, + color = TextPrimary + ), + headlineLarge = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + lineHeight = 36.sp, + color = TextPrimary + ), + headlineMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 30.sp, + color = TextPrimary + ), + headlineSmall = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + lineHeight = 26.sp, + color = TextPrimary + ), + titleLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 28.sp, + color = TextPrimary + ), + titleMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + color = TextPrimary + ), + bodyLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + color = TextPrimary + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + color = TextSecondary + ), + labelLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + color = TextPrimary + ), + labelMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + color = TextSecondary + ) +) diff --git a/quiz-kmp/composeApp/src/commonMain/sqldelight/com/dynamox/quiz/database/Player.sq b/quiz-kmp/composeApp/src/commonMain/sqldelight/com/dynamox/quiz/database/Player.sq new file mode 100644 index 0000000000..f7d53e34cd --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/sqldelight/com/dynamox/quiz/database/Player.sq @@ -0,0 +1,19 @@ +CREATE TABLE Player ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE +); + +getPlayerByName: +SELECT * FROM Player WHERE name = ?; + +insertPlayer: +INSERT INTO Player (name) VALUES (?); + +getAllPlayers: +SELECT * FROM Player; + +getPlayerById: +SELECT * FROM Player WHERE id = ?; + +lastInsertRowId: +SELECT last_insert_rowid(); diff --git a/quiz-kmp/composeApp/src/commonMain/sqldelight/com/dynamox/quiz/database/QuizScore.sq b/quiz-kmp/composeApp/src/commonMain/sqldelight/com/dynamox/quiz/database/QuizScore.sq new file mode 100644 index 0000000000..d565918f53 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonMain/sqldelight/com/dynamox/quiz/database/QuizScore.sq @@ -0,0 +1,21 @@ +CREATE TABLE QuizScore ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + player_id INTEGER NOT NULL, + player_name TEXT NOT NULL, + score INTEGER NOT NULL, + total_questions INTEGER NOT NULL DEFAULT 10, + created_at TEXT NOT NULL +); + +insertScore: +INSERT INTO QuizScore (player_id, player_name, score, total_questions, created_at) +VALUES (?, ?, ?, ?, ?); + +getScoresByPlayerId: +SELECT * FROM QuizScore WHERE player_id = ? ORDER BY created_at DESC; + +getAllScoresOrderedByScore: +SELECT * FROM QuizScore ORDER BY score DESC, created_at DESC; + +getTopScores: +SELECT * FROM QuizScore ORDER BY score DESC LIMIT 20; diff --git a/quiz-kmp/composeApp/src/commonTest/kotlin/com/dynamox/quiz/domain/usecase/FakeQuizRepository.kt b/quiz-kmp/composeApp/src/commonTest/kotlin/com/dynamox/quiz/domain/usecase/FakeQuizRepository.kt new file mode 100644 index 0000000000..4d5fdb42fa --- /dev/null +++ b/quiz-kmp/composeApp/src/commonTest/kotlin/com/dynamox/quiz/domain/usecase/FakeQuizRepository.kt @@ -0,0 +1,67 @@ +package com.dynamox.quiz.domain.usecase + +import com.dynamox.quiz.domain.model.Player +import com.dynamox.quiz.domain.model.Question +import com.dynamox.quiz.domain.model.QuizScore +import com.dynamox.quiz.domain.repository.QuizRepository + +/** + * Implementação falsa (fake/stub) do QuizRepository para uso em testes. + * + * Padrão de teste: Test Double (especificamente um "Fake") + * - Fake: implementação simplificada que funciona, mas não usa infra real + * - Stub: retorna valores pré-definidos (o que fazemos aqui com os Results) + * - Mock: verifica chamadas (faremos isso manualmente via listas) + * + */ +open class FakeQuizRepository( + private val questionResult: Result = Result.failure(NotImplementedError()), + private val answerResult: Result = Result.failure(NotImplementedError()), + private val playerResult: Result = Result.failure(NotImplementedError()), + private val saveScoreResult: Result = Result.success(Unit), + private val leaderboardResult: Result> = Result.success(emptyList()), + private val playerScoresResult: Result> = Result.success(emptyList()) +) : QuizRepository { + + /** + * Registro de chamadas para verificação nos testes. + * Permite checar: "submitAnswer foi chamado com os parâmetros corretos?" + * Cada Pair contém (questionId, answer) da chamada. + */ + val submittedAnswers = mutableListOf>() + + /** + * Registro de pontuações salvas: (playerId, score, totalQuestions). + * Permite checar se saveQuizScore foi chamado corretamente. + */ + val savedScores = mutableListOf>() + + /** Retorna o resultado pré-configurado para perguntas */ + override suspend fun getQuestion(): Result = questionResult + + /** Registra a chamada e retorna o resultado pré-configurado */ + override suspend fun submitAnswer(questionId: String, answer: String): Result { + submittedAnswers.add(questionId to answer) + return answerResult + } + + /** Retorna o jogador pré-configurado — pode ser sobrescrito por subclasse */ + override suspend fun getOrCreatePlayer(name: String): Result = playerResult + + /** Registra a chamada e retorna o resultado pré-configurado */ + override suspend fun saveQuizScore( + playerId: Long, + playerName: String, + score: Int, + totalQuestions: Int + ): Result { + savedScores.add(Triple(playerId, score, totalQuestions)) + return saveScoreResult + } + + /** Retorna a lista de scores pré-configurada */ + override suspend fun getLeaderboard(): Result> = leaderboardResult + + /** Retorna os scores do jogador pré-configurados */ + override suspend fun getPlayerScores(playerId: Long): Result> = playerScoresResult +} diff --git a/quiz-kmp/composeApp/src/commonTest/kotlin/com/dynamox/quiz/domain/usecase/GetQuestionUseCaseTest.kt b/quiz-kmp/composeApp/src/commonTest/kotlin/com/dynamox/quiz/domain/usecase/GetQuestionUseCaseTest.kt new file mode 100644 index 0000000000..75aef764d3 --- /dev/null +++ b/quiz-kmp/composeApp/src/commonTest/kotlin/com/dynamox/quiz/domain/usecase/GetQuestionUseCaseTest.kt @@ -0,0 +1,232 @@ +package com.dynamox.quiz.domain.usecase + +import com.dynamox.quiz.domain.model.AppError +import com.dynamox.quiz.domain.model.Question +import com.dynamox.quiz.domain.repository.QuizRepository +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Testes unitários do GetQuestionUseCase. + * + * Cada método de teste verifica um comportamento específico do Use Case. + * Convenção de nomenclatura: `função testada` `condição` `resultado esperado` + */ +class GetQuestionUseCaseTest { + + /** Pergunta de exemplo reutilizada nos testes */ + private val fakeQuestion = Question( + id = "1", + statement = "What is the name of the coolest company in the world?", + options = listOf("Google", "Microsoft", "Dynamox", "Spotify", "Amazon") + ) + + /** + * Cenário: repositório retorna sucesso > Use Case deve retornar a mesma pergunta. + * Verifica que o Use Case não modifica o resultado do repositório. + */ + @Test + fun `invoke returns question on success`() = runTest { + val repository = FakeQuizRepository(questionResult = Result.success(fakeQuestion)) + val useCase = GetQuestionUseCase(repository) + + val result = useCase() + + assertTrue(result.isSuccess) + assertEquals(fakeQuestion, result.getOrNull()) + } + + /** + * Cenário: falha de rede > Use Case deve propagar o NetworkError. + * Verifica que erros não são engolidos silenciosamente. + */ + @Test + fun `invoke returns error on network failure`() = runTest { + val error = AppError.NetworkError("Connection failed") + val repository = FakeQuizRepository(questionResult = Result.failure(error)) + val useCase = GetQuestionUseCase(repository) + + val result = useCase() + + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + /** + * Cenário: erro 500 no servidor > Use Case deve propagar o ServerError com código. + * Verifica que o código HTTP é preservado para tratamento na camada superior. + */ + @Test + fun `invoke returns error on server failure`() = runTest { + val error = AppError.ServerError(500, "Internal server error") + val repository = FakeQuizRepository(questionResult = Result.failure(error)) + val useCase = GetQuestionUseCase(repository) + + val result = useCase() + + assertTrue(result.isFailure) + val exception = result.exceptionOrNull() + assertIs(exception) + assertEquals(500, exception.code) + } +} + +/** + * Testes unitários do SubmitAnswerUseCase. + * Foco nas validações de entrada implementadas pelo Use Case. + */ +class SubmitAnswerUseCaseTest { + + /** + * Cenário: resposta válida e correta > retorna true. + */ + @Test + fun `invoke returns true when answer is correct`() = runTest { + val repository = FakeQuizRepository(answerResult = Result.success(true)) + val useCase = SubmitAnswerUseCase(repository) + + val result = useCase("1", "Dynamox") + + assertTrue(result.isSuccess) + assertEquals(true, result.getOrNull()) + } + + /** + * Cenário: resposta válida mas errada > retorna false (não é um erro). + * Importante: false não é uma falha, é um resultado válido. + */ + @Test + fun `invoke returns false when answer is wrong`() = runTest { + val repository = FakeQuizRepository(answerResult = Result.success(false)) + val useCase = SubmitAnswerUseCase(repository) + + val result = useCase("1", "Google") + + assertTrue(result.isSuccess) + assertEquals(false, result.getOrNull()) + } + + /** + * Cenário: resposta vazia > ValidationError SEM chamar a API. + * Verifica que a validação acontece antes da requisição de rede. + */ + @Test + fun `invoke returns validation error when answer is blank`() = runTest { + val repository = FakeQuizRepository(answerResult = Result.success(true)) + val useCase = SubmitAnswerUseCase(repository) + + val result = useCase("1", "") + + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + /** + * Cenário: resposta com apenas espaços > deve ser tratada como vazia. + * isBlank() retorna true para strings com apenas whitespace. + */ + @Test + fun `invoke returns validation error when answer is whitespace only`() = runTest { + val repository = FakeQuizRepository(answerResult = Result.success(true)) + val useCase = SubmitAnswerUseCase(repository) + + val result = useCase("1", " ") + + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } +} + +/** + * Testes unitários do GetOrCreatePlayerUseCase. + * Foco na validação do nome e no comportamento de sanitização (trim). + */ +class GetOrCreatePlayerUseCaseTest { + + private val fakePlayer = com.dynamox.quiz.domain.model.Player(id = 1, name = "TestPlayer") + + /** + * Cenário: nome válido > retorna o jogador criado/recuperado com sucesso. + */ + @Test + fun `invoke returns player on valid name`() = runTest { + val repository = FakeQuizRepository(playerResult = Result.success(fakePlayer)) + val useCase = GetOrCreatePlayerUseCase(repository) + + val result = useCase("TestPlayer") + + assertTrue(result.isSuccess) + assertEquals(fakePlayer, result.getOrNull()) + } + + /** + * Cenário: nome vazio > ValidationError imediato (sem tocar no banco). + */ + @Test + fun `invoke returns validation error on blank name`() = runTest { + val repository = FakeQuizRepository(playerResult = Result.success(fakePlayer)) + val useCase = GetOrCreatePlayerUseCase(repository) + + val result = useCase("") + + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + /** + * Cenário: nome só com espaços > deve ser tratado como vazio após trim(). + */ + @Test + fun `invoke returns validation error on whitespace name`() = runTest { + val repository = FakeQuizRepository(playerResult = Result.success(fakePlayer)) + val useCase = GetOrCreatePlayerUseCase(repository) + + val result = useCase(" ") + + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + /** + * Cenário: nome com mais de 30 caracteres > ValidationError. + * Verifica que a mensagem menciona o limite ("30"). + */ + @Test + fun `invoke returns validation error when name exceeds 30 characters`() = runTest { + val repository = FakeQuizRepository(playerResult = Result.success(fakePlayer)) + val useCase = GetOrCreatePlayerUseCase(repository) + + val result = useCase("A".repeat(31)) // 31 caracteres = acima do limite + + assertTrue(result.isFailure) + val error = result.exceptionOrNull() + assertIs(error) + assertTrue(error.message!!.contains("30")) + } + + /** + * Cenário: nome com espaços nas bordas > deve ser trimado antes de salvar. + * + * Usa subclasse anônima do FakeQuizRepository para capturar o nome + * que chegou ao repositório e verificar que foi trimado. + */ + @Test + fun `invoke trims whitespace from name before processing`() = runTest { + var capturedName = "" + val repository = object : FakeQuizRepository(playerResult = Result.success(fakePlayer)) { + override suspend fun getOrCreatePlayer(name: String) = Result.success( + // Captura o nome recebido para verificação + fakePlayer.copy(name = name).also { capturedName = name } + ) + } + val useCase = GetOrCreatePlayerUseCase(repository) + + useCase(" Alice ") // espaços nas bordas + + // Verifica que o repositório recebeu "Alice" sem espaços + assertEquals("Alice", capturedName) + } +} diff --git a/quiz-kmp/composeApp/src/commonTest/kotlin/com/dynamox/quiz/domain/usecase/QuizViewModelTest.kt b/quiz-kmp/composeApp/src/commonTest/kotlin/com/dynamox/quiz/domain/usecase/QuizViewModelTest.kt new file mode 100644 index 0000000000..46097ad40a --- /dev/null +++ b/quiz-kmp/composeApp/src/commonTest/kotlin/com/dynamox/quiz/domain/usecase/QuizViewModelTest.kt @@ -0,0 +1,233 @@ +package com.dynamox.quiz.domain.usecase + +import com.dynamox.quiz.domain.model.AppError +import com.dynamox.quiz.domain.model.Question +import com.dynamox.quiz.presentation.screens.quiz.QuizState +import com.dynamox.quiz.presentation.screens.quiz.QuizViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Testes unitários do QuizViewModel. + * + * Testa o fluxo completo de uma sessão de quiz sem tocar em rede ou banco. + * + * Técnica de teste de coroutines: + * - StandardTestDispatcher: substitui o Dispatchers.Main real por um dispatcher + * controlado. Coroutines só executam quando chamamos advanceUntilIdle(). + * - Isso garante controle preciso sobre quando cada coroutine roda, + * tornando os testes determinísticos. + * + */ +@OptIn(ExperimentalCoroutinesApi::class) +class QuizViewModelTest { + + /** + * Dispatcher controlado para testes. + * 'StandardTestDispatcher' não executa coroutines automaticamente — + * precisamos chamar advanceUntilIdle() explicitamente. + */ + private val testDispatcher = StandardTestDispatcher() + + /** Pergunta fake usada em todos os testes */ + private val fakeQuestion = Question( + id = "1", + statement = "What is 2+2?", + options = listOf("3", "4", "5", "6") + ) + + /** + * Executado antes de cada teste (@BeforeTest). + * Substitui o Main dispatcher pelo testDispatcher para que o viewModelScope + * use nosso dispatcher controlado. + */ + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + /** + * Executado após cada teste (@AfterTest). + * Restaura o Main dispatcher original para não interferir em outros testes. + */ + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + /** + * Helper para criar um ViewModel com resultados pré-configurados. + * Evita duplicação de código boilerplate nos testes. + */ + private fun createViewModel( + questionResult: Result = Result.success(fakeQuestion), + answerResult: Result = Result.success(true) + ): QuizViewModel { + val repo = FakeQuizRepository( + questionResult = questionResult, + answerResult = answerResult + ) + return QuizViewModel( + getQuestion = GetQuestionUseCase(repo), + submitAnswer = SubmitAnswerUseCase(repo), + saveQuizScore = SaveQuizScoreUseCase(repo) + ) + } + + /** + * Verifica que ao iniciar o quiz, a pergunta é carregada corretamente. + * advanceUntilIdle() executa todas as coroutines pendentes. + */ + @Test + fun `initQuiz starts loading question`() = runTest { + val viewModel = createViewModel() + + viewModel.initQuiz(1L, "Alice") + testDispatcher.scheduler.advanceUntilIdle() // executa a coroutine de loadNextQuestion + + val state = viewModel.uiState.value + assertEquals(0, state.currentQuestionIndex) + assertEquals("Alice", state.playerName) + // Após carregar, deve estar em ShowQuestion com a pergunta fake + assertIs(state.quizState) + assertEquals(fakeQuestion, (state.quizState as QuizState.ShowQuestion).question) + } + + /** + * Verifica que selecionar uma alternativa atualiza o selectedAnswer no estado. + */ + @Test + fun `onAnswerSelected updates selected answer`() = runTest { + val viewModel = createViewModel() + viewModel.initQuiz(1L, "Alice") + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.onAnswerSelected("4") + + assertEquals("4", viewModel.uiState.value.selectedAnswer) + } + + /** + * Verifica que submeter a resposta correta incrementa o score e + * vai para o estado ShowResult com isCorrect=true. + */ + @Test + fun `onSubmitAnswer transitions to ShowResult with correct answer`() = runTest { + val viewModel = createViewModel(answerResult = Result.success(true)) + viewModel.initQuiz(1L, "Alice") + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.onAnswerSelected("4") + viewModel.onSubmitAnswer() + testDispatcher.scheduler.advanceUntilIdle() // executa a coroutine de submitAnswer + + val state = viewModel.uiState.value + assertIs(state.quizState) + assertEquals(true, (state.quizState as QuizState.ShowResult).isCorrect) + assertEquals(1, state.score) // score incrementado + } + + /** + * Verifica que resposta errada NÃO incrementa o score. + * Comportamento esperado: score permanece 0. + */ + @Test + fun `onSubmitAnswer does not increment score on wrong answer`() = runTest { + val viewModel = createViewModel(answerResult = Result.success(false)) + viewModel.initQuiz(1L, "Alice") + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.onAnswerSelected("3") + viewModel.onSubmitAnswer() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(0, viewModel.uiState.value.score) // score deve continuar 0 + } + + /** + * Verifica que onNextQuestion incrementa o índice da pergunta. + */ + @Test + fun `onNextQuestion increments question index`() = runTest { + val viewModel = createViewModel() + viewModel.initQuiz(1L, "Alice") + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.onAnswerSelected("4") + viewModel.onSubmitAnswer() + testDispatcher.scheduler.advanceUntilIdle() + viewModel.onNextQuestion() + testDispatcher.scheduler.advanceUntilIdle() // carrega a próxima pergunta + + assertEquals(1, viewModel.uiState.value.currentQuestionIndex) + } + + /** + * Verifica que ao avançar de pergunta, a seleção anterior é limpa. + * Sem isso, a próxima pergunta apareceria com uma alternativa pré-selecionada. + */ + @Test + fun `selected answer is cleared after moving to next question`() = runTest { + val viewModel = createViewModel() + viewModel.initQuiz(1L, "Alice") + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.onAnswerSelected("4") + viewModel.onSubmitAnswer() + testDispatcher.scheduler.advanceUntilIdle() + viewModel.onNextQuestion() + testDispatcher.scheduler.advanceUntilIdle() + + assertNull(viewModel.uiState.value.selectedAnswer) // deve ser null após avançar + } + + /** + * Verifica que após 10 perguntas, o estado vira Finished. + * Este é o teste de integração mais completo do ViewModel — + * simula um quiz completo do início ao fim. + * + * Fluxo: initQuiz > 10x(avança, seleciona, submete, próxima) > Finished + */ + @Test + fun `quiz finishes after 10 questions`() = runTest { + val viewModel = createViewModel() + viewModel.initQuiz(1L, "Alice") + + repeat(10) { + testDispatcher.scheduler.advanceUntilIdle() // carrega pergunta + viewModel.onAnswerSelected("4") + viewModel.onSubmitAnswer() + testDispatcher.scheduler.advanceUntilIdle() // submete resposta + viewModel.onNextQuestion() + } + + testDispatcher.scheduler.advanceUntilIdle() + assertIs(viewModel.uiState.value.quizState) + } + + /** + * Verifica que falha ao carregar pergunta resulta em estado Error. + * A UI deve mostrar mensagem de erro e botão retry. + */ + @Test + fun `error state shown when question fails to load`() = runTest { + val viewModel = createViewModel( + questionResult = Result.failure(AppError.NetworkError("No internet")) + ) + viewModel.initQuiz(1L, "Alice") + testDispatcher.scheduler.advanceUntilIdle() + + assertIs(viewModel.uiState.value.quizState) + } +} diff --git a/quiz-kmp/gradle.properties b/quiz-kmp/gradle.properties new file mode 100644 index 0000000000..c6701ccf32 --- /dev/null +++ b/quiz-kmp/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true +org.gradle.configuration-cache=true +org.gradle.parallel=true diff --git a/quiz-kmp/gradle/libs.versions.toml b/quiz-kmp/gradle/libs.versions.toml new file mode 100644 index 0000000000..8597c749a1 --- /dev/null +++ b/quiz-kmp/gradle/libs.versions.toml @@ -0,0 +1,63 @@ +[versions] +agp = "8.7.3" +kotlin = "2.1.0" +composeMultiplatform = "1.7.3" +koin = "4.0.0" +ktor = "3.0.3" +sqldelight = "2.0.2" +kotlinxCoroutines = "1.9.0" +kotlinxSerialization = "1.7.3" +lifecycle = "2.8.4" +navigationCompose = "2.8.0-alpha10" +activityCompose = "1.9.3" +coreKtx = "1.15.0" +kermit = "2.0.4" +kotlinxDatetime = "0.6.1" +mockk = "1.13.13" +turbine = "1.2.0" +android-minSdk = "24" +android-targetSdk = "35" +android-compileSdk = "35" + +[libraries] +koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } +koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } +koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } +koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } + +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } + +sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } +sqldelight-coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } + +lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "lifecycle" } +lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } +lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } + +navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" } + +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } + +activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } +core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } + +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } +kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version = "2.1.0" } + +[plugins] +androidApplication = { id = "com.android.application", version.ref = "agp" } +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } +composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } diff --git a/quiz-kmp/gradle/wrapper/gradle-wrapper.properties b/quiz-kmp/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..df97d72b8b --- /dev/null +++ b/quiz-kmp/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/quiz-kmp/gradlew.bat b/quiz-kmp/gradlew.bat new file mode 100644 index 0000000000..e1ab175788 --- /dev/null +++ b/quiz-kmp/gradlew.bat @@ -0,0 +1,91 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/quiz-kmp/settings.gradle.kts b/quiz-kmp/settings.gradle.kts new file mode 100644 index 0000000000..f7d85a776f --- /dev/null +++ b/quiz-kmp/settings.gradle.kts @@ -0,0 +1,30 @@ +rootProject.name = "DynaQuiz" + +pluginManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + } +} + +include(":composeApp")