diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 1469097..96fc4b4 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -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") { diff --git a/android/src/main/kotlin/com/artlegend/MainActivity.kt b/android/src/main/kotlin/com/artlegend/MainActivity.kt index 98552ab..a2829c6 100644 --- a/android/src/main/kotlin/com/artlegend/MainActivity.kt +++ b/android/src/main/kotlin/com/artlegend/MainActivity.kt @@ -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) } diff --git a/android/src/main/kotlin/com/artlegend/data/ArtLegendDatabase.kt b/android/src/main/kotlin/com/artlegend/data/ArtLegendDatabase.kt new file mode 100644 index 0000000..114c91b --- /dev/null +++ b/android/src/main/kotlin/com/artlegend/data/ArtLegendDatabase.kt @@ -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 } + } + } +} diff --git a/android/src/main/kotlin/com/artlegend/data/SessionDao.kt b/android/src/main/kotlin/com/artlegend/data/SessionDao.kt new file mode 100644 index 0000000..13384b6 --- /dev/null +++ b/android/src/main/kotlin/com/artlegend/data/SessionDao.kt @@ -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> +} diff --git a/android/src/main/kotlin/com/artlegend/data/SessionEntity.kt b/android/src/main/kotlin/com/artlegend/data/SessionEntity.kt new file mode 100644 index 0000000..6e4ef77 --- /dev/null +++ b/android/src/main/kotlin/com/artlegend/data/SessionEntity.kt @@ -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 +) diff --git a/android/src/main/kotlin/com/artlegend/data/SessionRepository.kt b/android/src/main/kotlin/com/artlegend/data/SessionRepository.kt new file mode 100644 index 0000000..86e7e63 --- /dev/null +++ b/android/src/main/kotlin/com/artlegend/data/SessionRepository.kt @@ -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> = + dao.recentByModule(moduleType.name, limit) +} diff --git a/android/src/main/kotlin/com/artlegend/ui/ProgressViewModel.kt b/android/src/main/kotlin/com/artlegend/ui/ProgressViewModel.kt new file mode 100644 index 0000000..c9e0445 --- /dev/null +++ b/android/src/main/kotlin/com/artlegend/ui/ProgressViewModel.kt @@ -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> = repo + .recentSessions(ModuleType.LINE_CORRIDOR, limit = 7) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) +} diff --git a/android/src/main/kotlin/com/artlegend/ui/screens/ProgressScreen.kt b/android/src/main/kotlin/com/artlegend/ui/screens/ProgressScreen.kt index 88a8950..247ae76 100644 --- a/android/src/main/kotlin/com/artlegend/ui/screens/ProgressScreen.kt +++ b/android/src/main/kotlin/com/artlegend/ui/screens/ProgressScreen.kt @@ -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, 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 + ) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 9441833..93c66cd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") } } diff --git a/gradle.properties b/gradle.properties index 6c3bd06..ceac8de 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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