fretfret

learn the notes on a guitar from the comfort of your android phone
Log | Files | Refs | README | LICENSE

commit 21fde2f5b922dccee1a784a438d75d17206633c9
parent a038d506273a827e95ff15ca454f88ded98a6b96
Author: massi <mdsiboldi@gmail.com>
Date:   Tue,  2 Jul 2024 00:07:32 -0700

state management

Diffstat:
Mapp/build.gradle.kts | 3++-
Mapp/src/main/java/com/example/fretboardtrainer/MainActivity.kt | 399++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mgradle/libs.versions.toml | 11++++++++---
Mgradle/wrapper/gradle-wrapper.properties | 2+-
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