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 {
|
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") {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
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 ->
|
||||||
Box(
|
if (sessions.isEmpty()) {
|
||||||
modifier = Modifier
|
Box(
|
||||||
.fillMaxSize()
|
modifier = Modifier
|
||||||
.padding(padding),
|
.fillMaxSize()
|
||||||
contentAlignment = Alignment.Center
|
.padding(padding),
|
||||||
) {
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user