commit 21fde2f5b922dccee1a784a438d75d17206633c9
parent a038d506273a827e95ff15ca454f88ded98a6b96
Author: massi <mdsiboldi@gmail.com>
Date: Tue, 2 Jul 2024 00:07:32 -0700
state management
Diffstat:
4 files changed, 275 insertions(+), 140 deletions(-)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
@@ -50,7 +50,6 @@ android {
}
dependencies {
-
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
@@ -59,6 +58,8 @@ dependencies {
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.datastore.preferences)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
diff --git a/app/src/main/java/com/example/fretboardtrainer/MainActivity.kt b/app/src/main/java/com/example/fretboardtrainer/MainActivity.kt
@@ -1,11 +1,13 @@
package com.example.fretboardtrainer
+import android.app.Application
+import android.content.Context
import android.content.pm.ActivityInfo
import android.os.Bundle
+import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
-import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -22,7 +24,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
@@ -34,16 +35,52 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.dynamicDarkColorScheme
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.graphics.Color
import androidx.compose.ui.unit.dp
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.byteArrayPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.preferencesDataStore
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.fretboardtrainer.ui.theme.FretboardTrainerTheme
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+val FRET_COUNT = 13; // open + 12 frets
+val STRING_COUNT = 6;
+
+val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
+
+fun encodeBoolList(l: List<Boolean>): ByteArray {
+ return ByteArray(l.size) {
+ when (l[it]) {
+ true -> 1.toByte()
+ else -> 0.toByte()
+ }
+ }
+}
+
+fun decodeBoolList(arr: ByteArray): List<Boolean> {
+ return arr.toList().map { n ->
+ when (n) {
+ 1.toByte() -> true
+ else -> false
+ }
+ }
+}
fun weightForFret(fretIdx: Int): Float {
var weight = 24 - fretIdx * 1.0f
@@ -62,171 +99,263 @@ val FRETS = arrayOf(
"6", "7", "8", "9", "10", "11", "12"
);
-class MainActivity : ComponentActivity() {
- @OptIn(ExperimentalMaterial3Api::class)
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- enableEdgeToEdge()
- requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
- val stringSettings = MutableList(6) { true };
- val fretSettings = Array(13) { true };
+fun noteName(note: List<Int>): String {
+ val startIdx = when (note[1]) {
+ 0 -> 7 // E
+ 1 -> 2 // B
+ 2 -> 10 // G
+ 3 -> 5 // D
+ 4 -> 0 // A
+ 5 -> 7 // E
+ else -> -1
+ }
+ return NOTES[(startIdx + note[0]) % NOTES.size]
+}
- // a note is [stringIdx, fretIdx]
- fun randomNote(): Array<Int> {
- // find all true values in settings
- // randomly pick one of those
- val notes = mutableSetOf<Array<Int>>();
- for (stringIdx in stringSettings.indices) {
- if (stringSettings[stringIdx]) {
- for (fretIdx in fretSettings.indices) {
- if (fretSettings[fretIdx]) {
- notes.add(arrayOf(fretIdx, stringIdx));
- }
- }
+// TODO: persist string and fret settings across app launches.
+data class FretfretState(
+ val showSettings: Boolean = false,
+ val stringSettings: List<Boolean> = List(STRING_COUNT) { true },
+ val fretSettings: List<Boolean> = List(FRET_COUNT) { true },
+ val note: List<Int>? = null,
+ val lastNote: List<Int>? = null,
+)
+
+val STRING_KEY = byteArrayPreferencesKey("string_key")
+val FRET_KEY = byteArrayPreferencesKey("fret_key")
+
+
+class FretfretViewModel(application: Application) : AndroidViewModel(application) {
+ private val _uiState = MutableStateFlow(FretfretState())
+ val uiState = _uiState.asStateFlow()
+
+ init {
+ viewModelScope.launch {
+ try {
+ _uiState.update { currentState ->
+ currentState.copy(
+ stringSettings = application.dataStore.data.map { prefs ->
+ when (prefs[STRING_KEY]) {
+ is ByteArray -> decodeBoolList(prefs[STRING_KEY]!!)
+ else -> List(STRING_COUNT) { true }
+ }
+ }.first(),
+ fretSettings = application.dataStore.data.map { prefs ->
+ when (prefs[FRET_KEY]) {
+ is ByteArray -> decodeBoolList(prefs[FRET_KEY]!!)
+ else -> List(FRET_COUNT) { true }
+ }
+ }.first(),
+ )
}
+ updateNote()
+ } catch (e: Exception) {
+ Log.w(
+ "WARN",
+ "Error while reading settings. Starting fresh."
+ )
}
- return notes.random();
}
+ }
+
+ fun toggleString(idx: Int) {
- fun noteName(note: Array<Int>): String {
- val startIdx = when (note[1]) {
- 0 -> 7 // E
- 1 -> 2 // B
- 2 -> 10 // G
- 3 -> 5 // D
- 4 -> 0 // A
- 5 -> 7 // E
- else -> -1
+ _uiState.update { currentState ->
+ val res = currentState.stringSettings.toMutableList()
+ res[idx] = !res[idx]
+ if (!res.any { it }) {
+ currentState
+ }
+ else {
+ currentState.copy(
+ stringSettings = res.toList()
+ )
}
- return NOTES[(startIdx + note[0]) % NOTES.size]
}
+ viewModelScope.launch { updateSettings() }
+ }
- fun toggleString(idx: Int) {
- stringSettings[idx] = !stringSettings[idx];
+ fun toggleFret(idx: Int) {
+ _uiState.update { currentState ->
+ val res = currentState.fretSettings.toMutableList()
+ res[idx] = !res[idx]
+ if (!res.any { it }) {
+ currentState
+ } else {
+ currentState.copy(
+ fretSettings = res.toList()
+ )
+ }
}
+ viewModelScope.launch { updateSettings() }
+ }
- fun toggleFret(idx: Int) {
- fretSettings[idx] = !fretSettings[idx];
+ private fun pickNote(): List<Int> {
+ // find all true values in settings
+ // randomly pick one of those
+ val notes = mutableSetOf<List<Int>>();
+ for (stringIdx in uiState.value.stringSettings.indices) {
+ if (uiState.value.stringSettings[stringIdx]) {
+ for (fretIdx in uiState.value.fretSettings.indices) {
+ if (uiState.value.fretSettings[fretIdx]) {
+ notes.add(listOf(fretIdx, stringIdx));
+ }
+ }
+ }
}
+ var newNote = notes.random();
+ while (newNote == uiState.value.note) {
+ newNote = notes.random();
+ }
+ return newNote
+ }
- var showName = false;
- var note: Array<Int>? = randomNote();
- var lastNote: Array<Int>? = null;
- fun updateNote() {
- showName = false;
- lastNote = note;
- do {
- note = randomNote();
- } while (lastNote.contentEquals(note))
+ fun updateNote() {
+ _uiState.update { currentState ->
+ currentState.copy(
+ lastNote = uiState.value.note,
+ note = pickNote()
+ )
}
+ }
+
+ private suspend fun updateSettings() {
+ getApplication<Application>().dataStore.edit { settings ->
+ settings[STRING_KEY] = encodeBoolList(uiState.value.stringSettings);
+ settings[FRET_KEY] = encodeBoolList(uiState.value.fretSettings);
+ }
+ }
- var showSettings = false;
+ private fun isValidNote(note: List<Int>?): Boolean {
+ return note != null && uiState.value.stringSettings[note[0]] &&
+ uiState.value.fretSettings[note[1]]
+ }
- fun toggleSettings() {
- showSettings = !showSettings;
- if (showSettings) {
- note = null;
- lastNote = null;
- } else {
- updateNote();
+ fun toggleSettings() {
+ val show = !uiState.value.showSettings
+ _uiState.update { currentState ->
+ // TODO: keep note if updated settings allow it?
+ var note = currentState.note
+ var lastNote = currentState.lastNote
+ if (!show && !isValidNote(note)) {
+ lastNote = note
+ note = pickNote()
}
+ currentState.copy(
+ lastNote = lastNote,
+ note = note,
+ showSettings = show
+ )
}
+ }
+}
- fun draw() {
- setContent {
- FretboardTrainerTheme {
- Scaffold(
- modifier = Modifier.fillMaxSize(),
- topBar = {
- TopAppBar(
- modifier = Modifier.displayCutoutPadding(),
- title = { Text("Fretfret") },
- actions = {
- IconButton(onClick = { toggleSettings(); draw() }) {
- Icon(
- imageVector = Icons.Filled.Settings,
- contentDescription = "Choose Notes"
- )
- }
- })
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
+ setContent {
+ Fretfret()
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun Fretfret(viewModel: FretfretViewModel = viewModel()) {
+ val uiState by viewModel.uiState.collectAsState()
+ FretboardTrainerTheme {
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ topBar = {
+ TopAppBar(
+ modifier = Modifier.displayCutoutPadding(),
+ title = { Text("Fretfret") },
+ actions = {
+ IconButton(onClick = { viewModel.toggleSettings() }) {
+ Icon(
+ imageVector = Icons.Filled.Settings,
+ contentDescription = "Choose Notes"
+ )
}
- ) { innerPadding ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(innerPadding)
- .padding(horizontal = 20.dp)
- .displayCutoutPadding(),
- verticalArrangement = Arrangement.SpaceAround
- ) {
- Row {
- if (showSettings) {
- StringSettings(
- settings = stringSettings.toList(),
- toggle = { n -> toggleString(n) });
- }
- Column {
- Fretboard(
- modifier = Modifier.fillMaxWidth(),
- note = note
- )
- if (showSettings) {
- Row(horizontalArrangement = Arrangement.SpaceBetween) {
- repeat(13) { n ->
- Column(
- modifier = Modifier
- .weight(weightForFret(n)),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Checkbox(modifier = Modifier
- .padding(end = 15.dp, start = 12.dp),
- checked = fretSettings[n],
- onCheckedChange = { toggleFret(n); draw() })
- Text(FRETS[n])
- }
- }
- }
- }
- }
- }
- if (!showSettings) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Row {
- Text("last answer: ")
- Text(lastNote?.let { noteName(it) }
- ?: "you're just getting started! B)")
- }
- Button(
- onClick = { updateNote(); draw() },
- shape = RoundedCornerShape(5.dp)
+ })
+ }
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(horizontal = 20.dp)
+ .displayCutoutPadding(),
+ verticalArrangement = Arrangement.SpaceAround
+ ) {
+ Row {
+ if (uiState.showSettings) {
+ StringSettings(
+ settings = uiState.stringSettings,
+ toggle = { n -> viewModel.toggleString(n) });
+ }
+ Column {
+ Fretboard(
+ modifier = Modifier.fillMaxWidth(),
+ note = uiState.note
+ )
+ if (uiState.showSettings) {
+ Row(horizontalArrangement = Arrangement.SpaceBetween) {
+ repeat(FRET_COUNT) { n ->
+ Column(
+ modifier = Modifier
+ .weight(weightForFret(n)),
+ horizontalAlignment = Alignment.CenterHorizontally
) {
- Text("next")
+ Checkbox(modifier = Modifier
+ .padding(end = 15.dp, start = 12.dp),
+ checked = uiState.fretSettings[n],
+ onCheckedChange = { viewModel.toggleFret(n) })
+ Text(FRETS[n])
}
}
}
}
}
}
+ if (!uiState.showSettings) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row {
+ if (uiState.lastNote != null) {
+ Text("last answer: ")
+ Text(noteName(uiState.lastNote!!))
+ }
+ }
+ Button(
+ onClick = { viewModel.updateNote() },
+ shape = RoundedCornerShape(5.dp)
+ ) {
+ if (uiState.lastNote != null) {
+ Text("next")
+ } else {
+ Text("start")
+ }
+ }
+ }
+ }
}
}
- draw();
}
}
-val FRET_COUNT = 13; // open + 12 frets
-val STRING_COUNT = 6;
-
@Composable
fun StringSettings(settings: List<Boolean>, toggle: (n: Int) -> Unit) {
Column {
- repeat(6) { n ->
+ repeat(STRING_COUNT) { n ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(STRINGS[n])
- Checkbox(modifier = Modifier.height((200 / 6).dp),
+ Checkbox(modifier = Modifier.height((200 / STRING_COUNT).dp),
checked = settings[n],
onCheckedChange = { toggle(n) })
}
@@ -235,7 +364,7 @@ fun StringSettings(settings: List<Boolean>, toggle: (n: Int) -> Unit) {
}
@Composable
-fun Fretboard(modifier: Modifier = Modifier, note: Array<Int>?) {
+fun Fretboard(modifier: Modifier = Modifier, note: List<Int>?) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = 0.dp,
@@ -289,7 +418,7 @@ fun TopBar() {
@Composable
fun Strings(fretIdx: Int, noteAt: Int?, color: Color, openStringColor: Color) {
Column(modifier = Modifier.fillMaxSize()) {
- repeat(6) { n: Int ->
+ repeat(STRING_COUNT) { n: Int ->
Box(
modifier = Modifier
.fillMaxWidth()
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
@@ -1,20 +1,23 @@
[versions]
-agp = "8.4.1"
+agp = "8.5.0"
kotlin = "1.9.0"
coreKtx = "1.13.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
-lifecycleRuntimeKtx = "2.8.0"
+lifecycle = "2.8.3"
activityCompose = "1.9.0"
composeBom = "2023.08.00"
+datastorePreferencesCore = "1.1.1"
+datastorePreferences = "1.1.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
-androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle"}
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
@@ -24,6 +27,8 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-datastore-preferences-core = { group = "androidx.datastore", name = "datastore-preferences-core", version.ref = "datastorePreferencesCore" }
+androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
#Tue Jun 25 12:14:45 PDT 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists