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 {
kotlin("android")
id("com.android.application")
id("com.google.devtools.ksp")
}
val gdxVersion: String by project
val composeBom: String by project
val composeCompilerExtension: String by project
val roomVersion: String by project
val natives: Configuration by configurations.creating
android {
@@ -72,6 +74,11 @@ dependencies {
// Material XML themes (for Activity window styling)
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") {

View File

@@ -8,24 +8,34 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.shared.ModuleType
import com.artlegend.ui.ArtLegendApp
import com.artlegend.ui.theme.ArtLegendTheme
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private var lastScore: LineScore? by mutableStateOf(null)
private var currentModuleType: ModuleType = ModuleType.LINE_CORRIDOR
private val gameResultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val data = result.data ?: return@registerForActivityResult
lastScore = LineScore(
straightness = data.getFloatExtra("straightness", 0f),
anglePrecision = data.getFloatExtra("anglePrecision", 0f),
overall = data.getFloatExtra("overall", 0f)
val score = LineScore(
straightness = data.getFloatExtra("straightness", 0f),
anglePrecision = data.getFloatExtra("anglePrecision", 0f),
overall = data.getFloatExtra("overall", 0f)
)
lastScore = score
lifecycleScope.launch {
SessionRepository(ArtLegendDatabase.get(this@MainActivity).sessionDao())
.save(currentModuleType, score)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -34,7 +44,8 @@ class MainActivity : ComponentActivity() {
ArtLegendTheme {
ArtLegendApp(
onLaunchModule = { moduleType ->
lastScore = null // clear previous score when launching
lastScore = null
currentModuleType = moduleType
val intent = Intent(this, GameActivity::class.java).apply {
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
import androidx.compose.foundation.Canvas
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
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.Icon
import androidx.compose.material3.IconButton
@@ -13,12 +24,28 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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.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)
@Composable
fun ProgressScreen(onBack: () -> Unit) {
val vm: ProgressViewModel = viewModel()
val sessions by vm.sessions.collectAsState()
Scaffold(
topBar = {
TopAppBar(
@@ -31,13 +58,133 @@ fun ProgressScreen(onBack: () -> Unit) {
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text("Progress tracking coming soon", style = MaterialTheme.typography.bodyLarge)
if (sessions.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
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
)
}
}