Sprint 1 complete: hybrid Compose + LibGDX architecture building

Full AL-001 through AL-008 implementation: shared/ module with ModuleType
enum, GameHost routing, StylusInputBridge, LineCorridorScreen with live
stroke rendering, LineScore (straightness + anglePrecision), score returned
to Compose hub via ActivityResult. Fixes LineScore field mismatch
(pressureConsistency → anglePrecision) caught during AL-009 build check.
Adds .gitignore and handoff notes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 19:21:48 -06:00
parent 24d78786db
commit ac81ec51f4
39 changed files with 1797 additions and 41 deletions

View File

@@ -4,6 +4,8 @@ plugins {
}
val gdxVersion: String by project
val composeBom: String by project
val composeCompilerExtension: String by project
val natives: Configuration by configurations.creating
android {
@@ -27,6 +29,14 @@ android {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = composeCompilerExtension
}
sourceSets {
getByName("main") {
assets.srcDirs(rootProject.file("assets"))
@@ -37,24 +47,49 @@ android {
dependencies {
implementation(project(":core"))
implementation(project(":shared"))
// LibGDX
implementation("com.badlogicgames.gdx:gdx-backend-android:$gdxVersion")
natives("com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-armeabi-v7a")
natives("com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-arm64-v8a")
natives("com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-x86")
natives("com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-x86_64")
// Compose BOM
val composeBomDep = platform("androidx.compose:compose-bom:$composeBom")
implementation(composeBomDep)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-core")
// Activity + Navigation + Lifecycle
implementation("androidx.activity:activity-compose:1.9.2")
implementation("androidx.navigation:navigation-compose:2.8.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4")
// Material XML themes (for Activity window styling)
implementation("com.google.android.material:material:1.12.0")
}
tasks.register("copyAndroidNatives") {
doFirst {
val validAbis = setOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
natives.files.forEach { jar ->
val outputDir = file("libs")
outputDir.mkdirs()
copy {
from(zipTree(jar))
into(outputDir)
include("*.so")
eachFile { path = name }
includeEmptyDirs = false
// Extract ABI from classifier, e.g. "gdx-platform-1.12.1-natives-arm64-v8a.jar" → "arm64-v8a"
val abi = jar.name.substringAfter("natives-").removeSuffix(".jar")
if (abi in validAbis) {
val outputDir = file("libs/$abi")
outputDir.mkdirs()
copy {
from(zipTree(jar))
into(outputDir)
include("*.so")
eachFile { path = name }
includeEmptyDirs = false
}
}
}
}

BIN
android/libs/arm64-v8a/libgdx.so Executable file

Binary file not shown.

Binary file not shown.

BIN
android/libs/x86/libgdx.so Executable file

Binary file not shown.

BIN
android/libs/x86_64/libgdx.so Executable file

Binary file not shown.

View File

@@ -10,18 +10,28 @@
<application
android:allowBackup="true"
android:label="@string/app_name"
android:theme="@style/GdxTheme">
android:theme="@style/Theme.ArtLegend">
<!-- Compose hub — LAUNCHER entry point -->
<activity
android:name=".AndroidLauncher"
android:name=".MainActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout"
android:exported="true"
android:screenOrientation="fullSensor">
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- LibGDX game activity — launched by Intent from MainActivity -->
<activity
android:name=".GameActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout"
android:exported="false"
android:screenOrientation="landscape"
android:theme="@style/Theme.ArtLegend.Fullscreen" />
</application>
</manifest>

View File

@@ -1,17 +0,0 @@
package com.artlegend
import android.os.Bundle
import com.badlogic.gdx.backends.android.AndroidApplication
import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration
class AndroidLauncher : AndroidApplication() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val config = AndroidApplicationConfiguration().apply {
useImmersiveMode = true
useGyroscope = false
useAccelerometer = false
}
initialize(ArtLegend(), config)
}
}

View File

@@ -0,0 +1,65 @@
package com.artlegend
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.MotionEvent
import com.artlegend.input.StylusFrame
import com.artlegend.shared.ModuleType
import com.badlogic.gdx.backends.android.AndroidApplication
import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration
class GameActivity : AndroidApplication() {
private lateinit var gameHost: GameHost
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val moduleName = intent.getStringExtra("MODULE_TYPE") ?: ModuleType.LINE_CORRIDOR.name
val moduleType = ModuleType.valueOf(moduleName)
gameHost = GameHost(moduleType)
val config = AndroidApplicationConfiguration().apply {
useImmersiveMode = true
useGyroscope = false
useAccelerometer = false
}
initialize(gameHost, config)
// Wire result callback — invoked on GL thread, must switch to UI thread.
gameHost.onSessionComplete = { score ->
runOnUiThread {
val result = Intent().apply {
putExtra("straightness", score.straightness)
putExtra("anglePrecision", score.anglePrecision)
putExtra("overall", score.overall)
}
setResult(Activity.RESULT_OK, result)
finish()
}
}
// Capture stylus data on LibGDX's GL view; return false so LibGDX also sees the event.
graphics.view.setOnTouchListener { _, event ->
handleStylusEvent(event)
false
}
}
private fun handleStylusEvent(event: MotionEvent) {
val isDown = event.action != MotionEvent.ACTION_UP &&
event.action != MotionEvent.ACTION_CANCEL
val frame = StylusFrame(
x = event.x,
y = event.y,
pressure = event.pressure,
tiltRad = event.getAxisValue(MotionEvent.AXIS_TILT),
orientRad = event.getAxisValue(MotionEvent.AXIS_ORIENTATION),
isStylus = event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS,
isDown = isDown
)
android.util.Log.d("GameActivity", "pressure=%.3f isStylus=${frame.isStylus} isDown=$isDown"
.format(frame.pressure))
gameHost.bridge.post(frame)
}
}

View File

@@ -0,0 +1,48 @@
package com.artlegend
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.artlegend.modules.LineScore
import com.artlegend.shared.ModuleType
import com.artlegend.ui.ArtLegendApp
import com.artlegend.ui.theme.ArtLegendTheme
class MainActivity : ComponentActivity() {
private var lastScore: LineScore? by mutableStateOf(null)
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)
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ArtLegendTheme {
ArtLegendApp(
onLaunchModule = { moduleType ->
lastScore = null // clear previous score when launching
val intent = Intent(this, GameActivity::class.java).apply {
putExtra("MODULE_TYPE", moduleType.name)
}
gameResultLauncher.launch(intent)
},
lastScore = lastScore
)
}
}
}
}

View File

@@ -0,0 +1,46 @@
package com.artlegend.ui
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.artlegend.modules.LineScore
import com.artlegend.shared.ModuleType
import com.artlegend.ui.screens.HubScreen
import com.artlegend.ui.screens.ModuleDetailScreen
import com.artlegend.ui.screens.ProgressScreen
import com.artlegend.ui.screens.SettingsScreen
@Composable
fun ArtLegendApp(
onLaunchModule: (ModuleType) -> Unit,
lastScore: LineScore?
) {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "hub") {
composable("hub") {
HubScreen(
onModuleClick = { moduleType -> navController.navigate("detail/${moduleType.name}") },
onProgressClick = { navController.navigate("progress") },
onSettingsClick = { navController.navigate("settings") }
)
}
composable("detail/{moduleType}") { backStackEntry ->
val moduleType = ModuleType.valueOf(
backStackEntry.arguments?.getString("moduleType") ?: ModuleType.LINE_CORRIDOR.name
)
ModuleDetailScreen(
moduleType = moduleType,
lastScore = lastScore,
onStartTraining = { onLaunchModule(moduleType) },
onBack = { navController.popBackStack() }
)
}
composable("progress") {
ProgressScreen(onBack = { navController.popBackStack() })
}
composable("settings") {
SettingsScreen(onBack = { navController.popBackStack() })
}
}
}

View File

@@ -0,0 +1,102 @@
package com.artlegend.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.artlegend.shared.ModuleType
private data class ModuleCard(
val type: ModuleType,
val title: String,
val description: String
)
private val modules = listOf(
ModuleCard(ModuleType.LINE_CORRIDOR, "Line Corridor", "Master straight lines under pressure"),
ModuleCard(ModuleType.PRESSURE_RAMP, "Pressure Ramp", "Control stroke weight dynamically"),
ModuleCard(ModuleType.CURVE_SCULPTOR, "Curve Sculptor", "Shape smooth bezier curves"),
ModuleCard(ModuleType.VECTOR_SNAP, "Vector Snap", "Hit precise vector endpoints")
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HubScreen(
onModuleClick: (ModuleType) -> Unit,
onProgressClick: () -> Unit,
onSettingsClick: () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("ArtLegend") },
actions = {
IconButton(onClick = onSettingsClick) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
}
)
},
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = true,
onClick = {},
icon = { Text("Train") },
label = { Text("Train") }
)
NavigationBarItem(
selected = false,
onClick = onProgressClick,
icon = { Text("Progress") },
label = { Text("Progress") }
)
}
}
) { padding ->
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
items(modules) { module ->
Card(
onClick = { onModuleClick(module.type) },
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = module.title, style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(4.dp))
Text(text = module.description, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
}

View File

@@ -0,0 +1,136 @@
package com.artlegend.ui.screens
import androidx.compose.foundation.layout.Arrangement
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.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Button
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
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.artlegend.modules.LineScore
import com.artlegend.shared.ModuleType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ModuleDetailScreen(
moduleType: ModuleType,
lastScore: LineScore?,
onStartTraining: () -> Unit,
onBack: () -> Unit
) {
val title = moduleType.name.split('_').joinToString(" ") { word ->
word.lowercase().replaceFirstChar { it.uppercase() }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(text = title, style = MaterialTheme.typography.headlineMedium)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = when (moduleType) {
ModuleType.LINE_CORRIDOR ->
"Train your hand to draw perfectly straight lines using S-Pen pressure " +
"feedback. Keep your stroke within the corridor to score points."
ModuleType.PRESSURE_RAMP ->
"Learn to control stroke weight through gradual pressure ramping exercises. " +
"Match the target pressure curve for a perfect score."
ModuleType.CURVE_SCULPTOR ->
"Master the art of smooth curve drawing with guided bezier targets. " +
"Follow the path precisely to develop muscle memory."
ModuleType.VECTOR_SNAP ->
"Develop precision for hitting exact vector endpoints and anchor points. " +
"Speed and accuracy both contribute to your score."
},
style = MaterialTheme.typography.bodyLarge
)
if (lastScore != null) {
Spacer(modifier = Modifier.height(24.dp))
ScoreCard(lastScore)
}
}
Button(
onClick = onStartTraining,
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
) {
Text(if (lastScore != null) "Train Again" else "Start Training")
}
}
}
}
@Composable
private fun ScoreCard(score: LineScore) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Last Session", style = MaterialTheme.typography.labelMedium)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
ScoreStat("Overall", score.overall)
ScoreStat("Line", score.straightness)
ScoreStat("Angle", score.anglePrecision)
}
}
}
}
@Composable
private fun ScoreStat(label: String, value: Float) {
Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) {
Text(
text = "${(value * 100).toInt()}%",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}

View File

@@ -0,0 +1,43 @@
package com.artlegend.ui.screens
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProgressScreen(onBack: () -> Unit) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Progress") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text("Progress tracking coming soon", style = MaterialTheme.typography.bodyLarge)
}
}
}

View File

@@ -0,0 +1,43 @@
package com.artlegend.ui.screens
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(onBack: () -> Unit) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Settings") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text("Settings coming soon", style = MaterialTheme.typography.bodyLarge)
}
}
}

View File

@@ -0,0 +1,35 @@
package com.artlegend.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val Violet = Color(0xFF7B2FBE)
private val VioletContainer = Color(0xFF4A0E8F)
private val Cyan = Color(0xFF00E5FF)
private val CyanContainer = Color(0xFF004D57)
private val DarkBackground = Color(0xFF0D0D12)
private val DarkSurface = Color(0xFF16161F)
private val DarkColors = darkColorScheme(
primary = Violet,
onPrimary = Color.White,
primaryContainer = VioletContainer,
onPrimaryContainer = Cyan,
secondary = Cyan,
onSecondary = Color.Black,
secondaryContainer = CyanContainer,
background = DarkBackground,
surface = DarkSurface,
onBackground = Color.White,
onSurface = Color.White
)
@Composable
fun ArtLegendTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = DarkColors,
content = content
)
}

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="GdxTheme" parent="android:Theme.NoTitleBar.Fullscreen" />
</resources>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Main Compose activity: Material3 dark, no action bar -->
<style name="Theme.ArtLegend" parent="Theme.Material3.Dark.NoActionBar" />
<!-- LibGDX game activity: fullscreen, no action bar -->
<style name="Theme.ArtLegend.Fullscreen" parent="Theme.Material3.Dark.NoActionBar">
<item name="android:windowFullscreen">true</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
</resources>