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")