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:
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
15
android/src/main/kotlin/com/artlegend/data/SessionDao.kt
Normal file
15
android/src/main/kotlin/com/artlegend/data/SessionDao.kt
Normal 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>>
|
||||
}
|
||||
14
android/src/main/kotlin/com/artlegend/data/SessionEntity.kt
Normal file
14
android/src/main/kotlin/com/artlegend/data/SessionEntity.kt
Normal 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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
buildscript {
|
||||
val kotlinVersion: String by project
|
||||
val androidGradleVersion: String by project
|
||||
val kspVersion: String by project
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
@@ -10,6 +11,7 @@ buildscript {
|
||||
dependencies {
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
|
||||
classpath("com.android.tools.build:gradle:$androidGradleVersion")
|
||||
classpath("com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:$kspVersion")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,3 +6,5 @@ kotlinVersion=1.9.23
|
||||
androidGradleVersion=8.3.0
|
||||
composeBom=2024.09.00
|
||||
composeCompilerExtension=1.5.12
|
||||
kspVersion=1.9.23-1.0.20
|
||||
roomVersion=2.6.1
|
||||
|
||||
Reference in New Issue
Block a user