Sprint 2 head-start: Room persistence, ProgressViewModel, ProgressScreen

- Room DB (ArtLegendDatabase, SessionDao, SessionEntity) wired up
- SessionRepository saves scores and queries last 7 sessions per module
- ProgressViewModel exposes sessions as StateFlow
- ProgressScreen replaced with real bar chart + session history list
- MainActivity now persists each game result to Room on return
- build.gradle.kts + gradle.properties: KSP and Room 2.6.1 added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-17 22:53:42 -06:00
parent d4c48ed393
commit 2b208aff7e
10 changed files with 275 additions and 12 deletions

View File

@@ -1,11 +1,13 @@
plugins { plugins {
kotlin("android") kotlin("android")
id("com.android.application") id("com.android.application")
id("com.google.devtools.ksp")
} }
val gdxVersion: String by project val gdxVersion: String by project
val composeBom: String by project val composeBom: String by project
val composeCompilerExtension: String by project val composeCompilerExtension: String by project
val roomVersion: String by project
val natives: Configuration by configurations.creating val natives: Configuration by configurations.creating
android { android {
@@ -72,6 +74,11 @@ dependencies {
// Material XML themes (for Activity window styling) // Material XML themes (for Activity window styling)
implementation("com.google.android.material:material:1.12.0") implementation("com.google.android.material:material:1.12.0")
// Room
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion")
} }
tasks.register("copyAndroidNatives") { tasks.register("copyAndroidNatives") {

View File

@@ -8,24 +8,34 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.lifecycleScope
import com.artlegend.data.ArtLegendDatabase
import com.artlegend.data.SessionRepository
import com.artlegend.modules.LineScore import com.artlegend.modules.LineScore
import com.artlegend.shared.ModuleType import com.artlegend.shared.ModuleType
import com.artlegend.ui.ArtLegendApp import com.artlegend.ui.ArtLegendApp
import com.artlegend.ui.theme.ArtLegendTheme import com.artlegend.ui.theme.ArtLegendTheme
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var lastScore: LineScore? by mutableStateOf(null) private var lastScore: LineScore? by mutableStateOf(null)
private var currentModuleType: ModuleType = ModuleType.LINE_CORRIDOR
private val gameResultLauncher = registerForActivityResult( private val gameResultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult() ActivityResultContracts.StartActivityForResult()
) { result -> ) { result ->
val data = result.data ?: return@registerForActivityResult val data = result.data ?: return@registerForActivityResult
lastScore = LineScore( val score = LineScore(
straightness = data.getFloatExtra("straightness", 0f), straightness = data.getFloatExtra("straightness", 0f),
anglePrecision = data.getFloatExtra("anglePrecision", 0f), anglePrecision = data.getFloatExtra("anglePrecision", 0f),
overall = data.getFloatExtra("overall", 0f) overall = data.getFloatExtra("overall", 0f)
) )
lastScore = score
lifecycleScope.launch {
SessionRepository(ArtLegendDatabase.get(this@MainActivity).sessionDao())
.save(currentModuleType, score)
}
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -34,7 +44,8 @@ class MainActivity : ComponentActivity() {
ArtLegendTheme { ArtLegendTheme {
ArtLegendApp( ArtLegendApp(
onLaunchModule = { moduleType -> onLaunchModule = { moduleType ->
lastScore = null // clear previous score when launching lastScore = null
currentModuleType = moduleType
val intent = Intent(this, GameActivity::class.java).apply { val intent = Intent(this, GameActivity::class.java).apply {
putExtra("MODULE_TYPE", moduleType.name) putExtra("MODULE_TYPE", moduleType.name)
} }

View File

@@ -0,0 +1,23 @@
package com.artlegend.data
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [SessionEntity::class], version = 1)
abstract class ArtLegendDatabase : RoomDatabase() {
abstract fun sessionDao(): SessionDao
companion object {
@Volatile private var INSTANCE: ArtLegendDatabase? = null
fun get(context: Context): ArtLegendDatabase = INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
ArtLegendDatabase::class.java,
"artlegend.db"
).build().also { INSTANCE = it }
}
}
}

View File

@@ -0,0 +1,15 @@
package com.artlegend.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface SessionDao {
@Insert
suspend fun insert(session: SessionEntity)
@Query("SELECT * FROM sessions WHERE moduleType = :module ORDER BY timestamp DESC LIMIT :limit")
fun recentByModule(module: String, limit: Int): Flow<List<SessionEntity>>
}

View File

@@ -0,0 +1,14 @@
package com.artlegend.data
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "sessions")
data class SessionEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val moduleType: String,
val straightness: Float,
val anglePrecision: Float,
val overall: Float,
val timestamp: Long
)

View File

@@ -0,0 +1,22 @@
package com.artlegend.data
import com.artlegend.modules.LineScore
import com.artlegend.shared.ModuleType
import kotlinx.coroutines.flow.Flow
class SessionRepository(private val dao: SessionDao) {
suspend fun save(moduleType: ModuleType, score: LineScore) {
dao.insert(
SessionEntity(
moduleType = moduleType.name,
straightness = score.straightness,
anglePrecision = score.anglePrecision,
overall = score.overall,
timestamp = System.currentTimeMillis()
)
)
}
fun recentSessions(moduleType: ModuleType, limit: Int = 7): Flow<List<SessionEntity>> =
dao.recentByModule(moduleType.name, limit)
}

View File

@@ -0,0 +1,20 @@
package com.artlegend.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.artlegend.data.ArtLegendDatabase
import com.artlegend.data.SessionEntity
import com.artlegend.data.SessionRepository
import com.artlegend.shared.ModuleType
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
class ProgressViewModel(app: Application) : AndroidViewModel(app) {
private val repo = SessionRepository(ArtLegendDatabase.get(app).sessionDao())
val sessions: StateFlow<List<SessionEntity>> = repo
.recentSessions(ModuleType.LINE_CORRIDOR, limit = 7)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
}

View File

@@ -1,10 +1,21 @@
package com.artlegend.ui.screens package com.artlegend.ui.screens
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -13,12 +24,28 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.artlegend.data.SessionEntity
import com.artlegend.ui.ProgressViewModel
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ProgressScreen(onBack: () -> Unit) { fun ProgressScreen(onBack: () -> Unit) {
val vm: ProgressViewModel = viewModel()
val sessions by vm.sessions.collectAsState()
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -31,13 +58,133 @@ fun ProgressScreen(onBack: () -> Unit) {
) )
} }
) { padding -> ) { padding ->
if (sessions.isEmpty()) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding), .padding(padding),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text("Progress tracking coming soon", style = MaterialTheme.typography.bodyLarge) Text(
"Complete a session to see your progress",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Spacer(modifier = Modifier.height(4.dp))
Text("Line Corridor — Last 7 Sessions", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(12.dp))
SessionBarChart(
sessions = sessions,
modifier = Modifier
.fillMaxWidth()
.height(140.dp)
)
Spacer(modifier = Modifier.height(20.dp))
Text("Session History", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(4.dp))
}
items(sessions) { session ->
SessionCard(session)
}
item { Spacer(modifier = Modifier.height(16.dp)) }
}
} }
} }
} }
@Composable
private fun SessionBarChart(sessions: List<SessionEntity>, modifier: Modifier = Modifier) {
val cyan = Color(0xFF00E5FF)
val dimCyan = cyan.copy(alpha = 0.25f)
val ordered = sessions.reversed()
Canvas(modifier = modifier) {
val barCount = 7
val gap = size.width * 0.04f
val barWidth = (size.width - gap * (barCount + 1)) / barCount
val maxBarHeight = size.height - 20.dp.toPx()
val baselineY = size.height - 4.dp.toPx()
repeat(barCount) { i ->
val x = gap + i * (barWidth + gap)
val session = ordered.getOrNull(i)
val fillHeight = if (session != null) (session.overall * maxBarHeight).coerceAtLeast(4.dp.toPx()) else 0f
// ghost bar (slot)
drawRoundRect(
color = dimCyan,
topLeft = Offset(x, baselineY - maxBarHeight),
size = Size(barWidth, maxBarHeight),
cornerRadius = CornerRadius(4.dp.toPx())
)
if (session != null) {
drawRoundRect(
color = cyan,
topLeft = Offset(x, baselineY - fillHeight),
size = Size(barWidth, fillHeight),
cornerRadius = CornerRadius(4.dp.toPx())
)
}
}
// baseline
drawLine(
color = dimCyan.copy(alpha = 0.5f),
start = Offset(0f, baselineY),
end = Offset(size.width, baselineY),
strokeWidth = 1.dp.toPx()
)
}
}
@Composable
private fun SessionCard(session: SessionEntity) {
val dateStr = SimpleDateFormat("MMM d, h:mm a", Locale.getDefault())
.format(Date(session.timestamp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(dateStr, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
SessionStat("Overall", session.overall)
SessionStat("Line", session.straightness)
SessionStat("Angle", session.anglePrecision)
}
}
}
}
@Composable
private fun SessionStat(label: String, value: Float) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "${(value * 100).toInt()}%",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.secondary
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View File

@@ -1,6 +1,7 @@
buildscript { buildscript {
val kotlinVersion: String by project val kotlinVersion: String by project
val androidGradleVersion: String by project val androidGradleVersion: String by project
val kspVersion: String by project
repositories { repositories {
mavenCentral() mavenCentral()
@@ -10,6 +11,7 @@ buildscript {
dependencies { dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
classpath("com.android.tools.build:gradle:$androidGradleVersion") classpath("com.android.tools.build:gradle:$androidGradleVersion")
classpath("com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:$kspVersion")
} }
} }

View File

@@ -6,3 +6,5 @@ kotlinVersion=1.9.23
androidGradleVersion=8.3.0 androidGradleVersion=8.3.0
composeBom=2024.09.00 composeBom=2024.09.00
composeCompilerExtension=1.5.12 composeCompilerExtension=1.5.12
kspVersion=1.9.23-1.0.20
roomVersion=2.6.1