diff --git a/README.md b/README.md index 681cddd..2589fec 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,28 @@ # OpenC25k + Open source couch to 5k run tracker app Inspired by [this project.](https://github.com/roelb/Simple-C25K) + +## Features + +- C25k run list of which the user selects one individual run to track on each running session + +- Tap on a run to track it + +- Hold on a run to check/uncheck the completed checkbox + +- The run is broken into intervals, e.g. Walk 90 seconds, run 60 seconds which are tracked + +- Beeps and vibrates: + - Once for walk + - Twice for jog + - 4 times for completed run + +- Sound and vibration enable and disable settings + +- Tracks which runs have been completed and displayed on checkboxes + +- Progress and settings store persistently between app runs. Can be reset by deleting app storage in android settings. + +- Material you colors which change depending on which theme the user has set for their device diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..98a6e3d --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("kotlin-parcelize") +} + +android { + namespace = "se.wmuth.openc25k" + compileSdk = 34 + + defaultConfig { + applicationId = "se.wmuth.openc25k" + minSdk = 31 + targetSdk = 33 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { + viewBinding = true + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1") + implementation("androidx.core:core-ktx:1.10.1") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.9.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.datastore:datastore-preferences:1.0.0") + implementation("androidx.cardview:cardview:1.0.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") +} \ No newline at end of file diff --git a/app/debug/output-metadata.json b/app/debug/output-metadata.json new file mode 100644 index 0000000..7b20ef8 --- /dev/null +++ b/app/debug/output-metadata.json @@ -0,0 +1,20 @@ +{ + "version": 3, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "se.wmuth.openc25k", + "variantName": "debug", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 1, + "versionName": "1.0", + "outputFile": "app-debug.apk" + } + ], + "elementType": "File" +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json new file mode 100644 index 0000000..1405dda --- /dev/null +++ b/app/release/output-metadata.json @@ -0,0 +1,20 @@ +{ + "version": 3, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "se.wmuth.openc25k", + "variantName": "release", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 1, + "versionName": "1.0", + "outputFile": "app-release.apk" + } + ], + "elementType": "File" +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..985d745 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_green-playstore.png b/app/src/main/ic_green-playstore.png new file mode 100644 index 0000000..518b769 Binary files /dev/null and b/app/src/main/ic_green-playstore.png differ diff --git a/app/src/main/java/se/wmuth/openc25k/MainActivity.kt b/app/src/main/java/se/wmuth/openc25k/MainActivity.kt new file mode 100644 index 0000000..41404cd --- /dev/null +++ b/app/src/main/java/se/wmuth/openc25k/MainActivity.kt @@ -0,0 +1,132 @@ +package se.wmuth.openc25k + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import androidx.recyclerview.widget.LinearLayoutManager +import se.wmuth.openc25k.both.Beeper +import se.wmuth.openc25k.data.Run +import se.wmuth.openc25k.databinding.ActivityMainBinding +import se.wmuth.openc25k.main.DataHandler +import se.wmuth.openc25k.main.RunAdapter +import se.wmuth.openc25k.main.SettingsMenu +import se.wmuth.openc25k.main.VolumeDialog + +// Get the datastore for the app +val Context.datastore: DataStore by preferencesDataStore(name = "settings") + +/** + * The main activity, ties together everything on the apps 'home' page + */ +class MainActivity : AppCompatActivity(), RunAdapter.RunAdapterClickListener, + SettingsMenu.SettingsMenuListener, VolumeDialog.VolumeDialogListener { + private lateinit var menu: SettingsMenu + private lateinit var runs: Array + private lateinit var volDialog: VolumeDialog + private lateinit var handler: DataHandler + private lateinit var adapter: RunAdapter + private lateinit var launcher: ActivityResultLauncher + private var sound: Boolean = true + private var vibrate: Boolean = true + private var volume: Float = 0.5f + + override fun onCreate(savedInstanceState: Bundle?) { + handler = DataHandler(this, datastore) + sound = handler.getSound() + vibrate = handler.getVibrate() + volume = handler.getVolume() + runs = handler.getRuns() + + super.onCreate(savedInstanceState) + val binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + volDialog = VolumeDialog(this, this, layoutInflater) + menu = SettingsMenu(this, binding.materialToolbar.menu) + + val runRV = binding.recyclerView + adapter = RunAdapter(this, runs, this) + runRV.adapter = adapter + runRV.layoutManager = LinearLayoutManager(this) + + binding.materialToolbar.setOnMenuItemClickListener(menu) + binding.materialToolbar.menu.findItem(R.id.vibrate).isChecked = vibrate + binding.materialToolbar.menu.findItem(R.id.sound).isChecked = sound + + launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + handleActivityResult(it.resultCode, it.data) + } + } + + override fun onRunItemClick(position: Int) { + // Run was clicked, launch TrackActivity with extras + val intent = Intent(this, TrackActivity::class.java) + intent.putExtra("run", runs[position]) + intent.putExtra("id", position) + intent.putExtra("sound", sound) + intent.putExtra("vibrate", vibrate) + intent.putExtra("volume", volume) + launcher.launch(intent) + } + + override fun onRunItemLongClick(position: Int) { + // Run held, toggle isComplete + runs[position].isComplete = !runs[position].isComplete + adapter.notifyItemChanged(position) + handler.setRuns(runs) + } + + /** + * Handle the result of the TrackActivity + * + * @param resultCode if RESULT_OK, run was finished + * @param data the data sent back from the activity, which run was completed + */ + private fun handleActivityResult(resultCode: Int, data: Intent?) { + if (resultCode == RESULT_OK && data != null) { + runs[data.getIntExtra("id", 0)].isComplete = true + adapter.notifyItemChanged(data.getIntExtra("id", 0)) + handler.setRuns(runs) + } + } + + override fun createVolumeDialog() { + volDialog.createAlertDialog(volume) + } + + override fun shouldMakeSound(): Boolean { + return sound + } + + override fun shouldVibrate(): Boolean { + return vibrate + } + + override fun testVolume() { + val beeper = Beeper(applicationContext, volume) + if (sound) { + beeper.beep() + } + } + + override fun toggleSound() { + sound = !sound + handler.setSound(sound) + } + + override fun toggleVibration() { + vibrate = !vibrate + handler.setVibrate(vibrate) + } + + override fun setVolume(nV: Float) { + volume = nV + handler.setVolume(volume) + } +} \ No newline at end of file diff --git a/app/src/main/java/se/wmuth/openc25k/TrackActivity.kt b/app/src/main/java/se/wmuth/openc25k/TrackActivity.kt new file mode 100644 index 0000000..46d99da --- /dev/null +++ b/app/src/main/java/se/wmuth/openc25k/TrackActivity.kt @@ -0,0 +1,118 @@ +package se.wmuth.openc25k + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.VibratorManager +import androidx.appcompat.app.AppCompatActivity +import se.wmuth.openc25k.both.Beeper +import se.wmuth.openc25k.data.Interval +import se.wmuth.openc25k.data.Run +import se.wmuth.openc25k.databinding.ActivityTrackBinding +import se.wmuth.openc25k.track.RunTimer +import se.wmuth.openc25k.track.Shaker + +/** + * Combines all the tracking parts of the app to one cohesive whole + * Should be sent intent with keys + * "id" -> index of the run in the recyclerview: Int + * "run" -> run object to track: Run + * "sound" -> if sound is enabled: Boolean + * "vibrate" -> if vibration is enabled: Boolean + * "volume" -> what the volume is: Float + */ +class TrackActivity : AppCompatActivity(), RunTimer.RunTimerListener { + private lateinit var beeper: Beeper + private lateinit var binding: ActivityTrackBinding + private lateinit var intentReturn: Intent + private lateinit var intervals: Iterator + private lateinit var shaker: Shaker + private lateinit var timer: RunTimer + private var sound: Boolean = true + private var vibrate: Boolean = true + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityTrackBinding.inflate(layoutInflater) + setContentView(binding.root) + + val run = if (Build.VERSION.SDK_INT >= 33) { + intent.getParcelableExtra("run", Run::class.java) ?: return + } else { + // Above is more typesafe but only works on newer SDK + @Suppress("DEPRECATION") + (intent.getParcelableExtra("run") ?: return) + } + + intervals = run.intervals.iterator() + sound = intent.getBooleanExtra("sound", true) + vibrate = intent.getBooleanExtra("vibrate", true) + + beeper = Beeper(this, intent.getFloatExtra("volume", 0.5f)) + shaker = Shaker(getSystemService(VIBRATOR_MANAGER_SERVICE) as VibratorManager) + timer = RunTimer(run.intervals, this) + + binding.twRemainTimer.text = timer.getTotalRemaining() + binding.twStatus.text = intervals.next().title + binding.twSummary.text = run.description + binding.twTimer.text = timer.getIntervalRemaining() + binding.twTitle.text = run.name + + binding.btnPause.setOnClickListener { timer.pause() } + binding.btnSkip.setOnClickListener { timer.skip() } + binding.btnStart.setOnClickListener { timer.start() } + + intentReturn = Intent() + intentReturn.putExtra("id", intent.getIntExtra("id", 0)) + setResult(RESULT_CANCELED, intentReturn) + } + + override fun tick() { + runOnUiThread { + binding.twRemainTimer.text = timer.getTotalRemaining() + binding.twTimer.text = timer.getIntervalRemaining() + } + } + + override fun nextInterval() { + val next = intervals.next() + runOnUiThread { + binding.twStatus.text = next.title + binding.twTimer.text = next.time.toString() + } + + if (next.title == getString(R.string.walk)) { + if (sound) { + beeper.beep() + } + if (vibrate) { + shaker.walkShake() + } + } else { + if (sound) { + beeper.beepMultiple(2u) + } + if (vibrate) { + shaker.jogShake() + } + } + } + + override fun finishRun() { + runOnUiThread { + binding.twStatus.text = getString(R.string.runComplete) + } + setResult(RESULT_OK, intentReturn) + if (sound) { + beeper.beepMultiple(4u) + } + if (vibrate) { + shaker.completeShake() + } + } + + override fun onDestroy() { + super.onDestroy() + timer.pause() + } +} \ No newline at end of file diff --git a/app/src/main/java/se/wmuth/openc25k/both/Beeper.kt b/app/src/main/java/se/wmuth/openc25k/both/Beeper.kt new file mode 100644 index 0000000..0932e49 --- /dev/null +++ b/app/src/main/java/se/wmuth/openc25k/both/Beeper.kt @@ -0,0 +1,62 @@ +package se.wmuth.openc25k.both + +import android.content.Context +import android.content.res.AssetFileDescriptor +import android.media.AudioAttributes +import android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION +import android.media.AudioAttributes.USAGE_ALARM +import android.media.MediaPlayer +import se.wmuth.openc25k.R + +/** + * Used to create the beeping noise in the application + * Uses MediaPlayer to play the raw mp3-file in the project + * + * @param pCon the context of the parent of the beeper + * @param vol the initial volume of the beeper, 0.0 to 1.0 + * @constructor Creates beeper with standard attributes + */ +class Beeper(pCon: Context, vol: Float) : MediaPlayer.OnCompletionListener { + private val mp: MediaPlayer + private var playCount: UInt = 0u + + init { + val file: AssetFileDescriptor = pCon.resources.openRawResourceFd(R.raw.beep) + mp = MediaPlayer().apply { + setAudioAttributes( + AudioAttributes + .Builder() + .setContentType(CONTENT_TYPE_SONIFICATION) + .setUsage(USAGE_ALARM) + .build() + ) + setVolume(vol, vol) + setDataSource(file.fileDescriptor, file.startOffset, file.length) + } + mp.prepare() + mp.setOnCompletionListener(this) + file.close() + } + + /** + * Makes the Beeper beep + */ + fun beep() { + mp.start() + } + + /** + * Makes the Beeper beep a [number] of times + */ + fun beepMultiple(number: UInt) { + playCount = number - 1u + beep() + } + + override fun onCompletion(p0: MediaPlayer?) { + if (playCount > 0u) { + playCount-- + beep() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/se/wmuth/openc25k/data/Interval.kt b/app/src/main/java/se/wmuth/openc25k/data/Interval.kt new file mode 100644 index 0000000..613224f --- /dev/null +++ b/app/src/main/java/se/wmuth/openc25k/data/Interval.kt @@ -0,0 +1,14 @@ +package se.wmuth.openc25k.data + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Stores the data for one interval e.g. title: "Walk", time: 300 (seconds) + * + * @param time the integer seconds the interval lasts for + * @param title the title of what the user should do during, walk, run, warmup, etc + * @constructor creates a single interval containing a title and time + */ +@Parcelize +data class Interval(val time: Int, val title: String) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/se/wmuth/openc25k/data/Run.kt b/app/src/main/java/se/wmuth/openc25k/data/Run.kt new file mode 100644 index 0000000..63c4896 --- /dev/null +++ b/app/src/main/java/se/wmuth/openc25k/data/Run.kt @@ -0,0 +1,44 @@ +package se.wmuth.openc25k.data + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Keeps the data for an entire running session + * + * @param name the name of the run e.g. Week 1 Day 1 + * @param description the description of the run, any extra info the user needs + * @param isComplete whether the user has completed this run + * @param intervals an array of intervals which the run consists of + * @constructor creates a running session containing all needed info + */ +@Parcelize +data class Run( + val name: String, + val description: String, + var isComplete: Boolean, + val intervals: Array +) : Parcelable { + // Autogenerated comparison functions + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Run + + if (name != other.name) return false + if (description != other.description) return false + if (isComplete != other.isComplete) return false + if (!intervals.contentEquals(other.intervals)) return false + + return true + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + description.hashCode() + result = 31 * result + isComplete.hashCode() + result = 31 * result + intervals.contentHashCode() + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/se/wmuth/openc25k/main/DataHandler.kt b/app/src/main/java/se/wmuth/openc25k/main/DataHandler.kt new file mode 100644 index 0000000..77b6c07 --- /dev/null +++ b/app/src/main/java/se/wmuth/openc25k/main/DataHandler.kt @@ -0,0 +1,651 @@ +package se.wmuth.openc25k.main + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.runBlocking +import se.wmuth.openc25k.R +import se.wmuth.openc25k.data.Interval +import se.wmuth.openc25k.data.Run + +/** + * Allows for easy editing of persistent data on the android device for the app + * + * Each data key we want to save has it's own getting and setting function + * since this more easily allows me to be more typesafe, however there is + * a decent amount of repeated code which could be excluded using e.g. + * a enum class for which data you want and one getter function + * + * @param pCon the parent context + * @param datastore the datastore to edit + * @constructor Creates DataHandler of context which edits the datastore + */ +class DataHandler(pCon: Context, datastore: DataStore) { + private val con: Context = pCon + private val ds: DataStore = datastore + + /** + * Gets the vibration enabled setting + * @return the saved setting for vibration, default true + */ + fun getVibrate(): Boolean { + val v = booleanPreferencesKey("vibrate") + var d: Boolean? = null + runBlocking { + ds.edit { settings -> + d = settings[v] + } + } + return d ?: true + } + + /** + * Gets the sound enabled setting + * @return the saved setting for sound, default true + */ + fun getSound(): Boolean { + val s = booleanPreferencesKey("sound") + var d: Boolean? = null + runBlocking { + ds.edit { settings -> + d = settings[s] + } + } + return d ?: true + } + + /** + * Gets the volume setting + * @return the saved float value between 0 and 1.0, default 0.5 + */ + fun getVolume(): Float { + val vol = floatPreferencesKey("volume") + var d: Float? = null + runBlocking { + ds.edit { settings -> + d = settings[vol] + } + } + return d ?: 0.5f + } + + /** + * Gets the saved runs array + * @return the saved runs with isComplete progress or default + */ + fun getRuns(): Array { + val r = stringPreferencesKey("runs") + var d: String? = null + runBlocking { + ds.edit { settings -> + d = settings[r] + } + } + return deserialize(d) ?: defaultRuns() + } + + /** + * Persistently stores the vibration setting + * @param vibrate whether vibration should be enabled or not + */ + fun setVibrate(vibrate: Boolean) { + val v = booleanPreferencesKey("vibrate") + runBlocking { + ds.edit { settings -> + settings[v] = vibrate + } + } + } + + /** + * Persistently stores the sound setting + * @param sound whether sound should be enabled or not + */ + fun setSound(sound: Boolean) { + val s = booleanPreferencesKey("sound") + runBlocking { + ds.edit { settings -> + settings[s] = sound + } + } + } + + /** + * Persistently stores the volume setting + * @param volume how loud the sound should be, 0.0 to 1.0 + */ + fun setVolume(volume: Float) { + val vol = floatPreferencesKey("volume") + runBlocking { + ds.edit { settings -> + settings[vol] = volume + } + } + } + + /** + * Persistently stores the runs array + * @param runs the array of runs with new isComplete progress + */ + fun setRuns(runs: Array) { + val r = stringPreferencesKey("runs") + runBlocking { + ds.edit { settings -> + settings[r] = serialize(runs) + } + } + } + + /** + * Extremely basic serialization function, could be improved + * @param r Array of runs to serialize + * @return the serialized string + */ + private fun serialize(r: Array): String { + var s = "" + + // For each run, destruct the run and add values with separator + r.forEach { (name, description, isComplete, intervals) -> + s += "$name|" + s += "$description|" + s += "$isComplete|" + // Since intervals differs in length, for each append these too + intervals.forEach { (time, title) -> + s += "$time|" + s += "$title|" + } + // Make sure each run ends with || for deserialization + s += "|" + } + + // Drop ending || so .split() doesn't result in empty index at the end + s = s.dropLast(2) + + return s + } + + + /** + * Extremely basic deserialization function, could be improved + * @param d the data to deserialize + * @return the deserialized array or null if invalid data + */ + private fun deserialize(d: String?): Array? { + if (d == null) { + return null + } + + // Create empty list and split string on || which separates each run + val runs: MutableList = mutableListOf() + val split = d.split("||") + // For each run from the split + split.forEach { severalRuns -> + // Create an iterator which yields each field in the run + val run = severalRuns.split("|").iterator() + // First yields name + val name = run.next() + // Then description + val desc = run.next() + val isComplete = run.next().toBoolean() + val intervals: MutableList = mutableListOf() + // Since intervals is a list we do a for each on the remaining items in the iterator + run.forEach { + intervals.add(Interval(it.toInt(), run.next())) + } + // Add all the values extracted + runs.add(Run(name, desc, isComplete, intervals.toTypedArray())) + } + return runs.toTypedArray() + } + + /** + * Returns the default runs, hardcoded since it follows a real world regimen. + * This whole structure could be massively improved, I have just done + * the most basic implementation for this simple app. + * Should be fast enough for the purposes + * @return the default state of the runs array + */ + private fun defaultRuns(): Array { + val r = con.resources + return arrayOf( + Run( + name = String.format( + "%s 1 %s 1", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w1d1), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + ) + ), + Run( + name = String.format( + "%s 1 %s 2", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w1d2), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + ) + ), + Run( + name = String.format( + "%s 1 %s 3", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w1d3), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(60, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + ) + ), + + Run( + name = String.format( + "%s 2 %s 1", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w2d1), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(90, r.getString(R.string.jog)), + Interval(120, r.getString(R.string.walk)), + Interval(90, r.getString(R.string.jog)), + Interval(120, r.getString(R.string.walk)), + Interval(90, r.getString(R.string.jog)), + Interval(120, r.getString(R.string.walk)), + Interval(90, r.getString(R.string.jog)), + Interval(120, r.getString(R.string.walk)), + Interval(90, r.getString(R.string.jog)), + Interval(120, r.getString(R.string.walk)), + Interval(90, r.getString(R.string.jog)), + Interval(60, r.getString(R.string.walk)), + ) + ), + Run( + name = String.format( + "%s 2 %s 2", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w2d2), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(90, r.getString(R.string.jog)), + Interval(120, r.getString(R.string.walk)), + Interval(90, r.getString(R.string.jog)), + Interval(120, r.getString(R.string.walk)), + Interval(90, r.getString(R.string.jog)), + Interval(120, r.getString(R.string.walk)), + Interval(90, r.getString(R.string.jog)), + Interval(120, r.getString(R.string.walk)), + Interval(90, r.getString(R.string.jog)), + Interval(120, r.getString(R.string.walk)), + Interval(90, r.getString(R.string.jog)), + Interval(60, r.getString(R.string.walk)), + ) + ), + Run( + name = String.format( + "%s 2 %s 3", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w2d3), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(90, r.getString(R.string.jog)), + Interval(120, r.getString(R.string.walk)), + Interval(90, r.getString(R.string.jog)), + Interval(120, r.getString(R.string.walk)), + Interval(90, r.getString(R.string.jog)), + Interval(120, r.getString(R.string.walk)), + Interval(90, r.getString(R.string.jog)), + Interval(120, r.getString(R.string.walk)), + Interval(90, r.getString(R.string.jog)), + Interval(120, r.getString(R.string.walk)), + Interval(90, r.getString(R.string.jog)), + Interval(60, r.getString(R.string.walk)), + ) + ), + + Run( + name = String.format( + "%s 3 %s 1", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w3d1), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(90, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(180, r.getString(R.string.jog)), + Interval(180, r.getString(R.string.walk)), + Interval(90, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(180, r.getString(R.string.jog)), + Interval(180, r.getString(R.string.walk)), + ) + ), + Run( + name = String.format( + "%s 3 %s 2", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w3d2), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(90, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(180, r.getString(R.string.jog)), + Interval(180, r.getString(R.string.walk)), + Interval(90, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(180, r.getString(R.string.jog)), + Interval(180, r.getString(R.string.walk)), + ) + ), + Run( + name = String.format( + "%s 3 %s 3", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w3d3), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(90, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(180, r.getString(R.string.jog)), + Interval(180, r.getString(R.string.walk)), + Interval(90, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(180, r.getString(R.string.jog)), + Interval(180, r.getString(R.string.walk)), + ) + ), + + Run( + name = String.format( + "%s 4 %s 1", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w4d1), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(180, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(300, r.getString(R.string.jog)), + Interval(150, r.getString(R.string.walk)), + Interval(180, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(300, r.getString(R.string.jog)), + ) + ), + Run( + name = String.format( + "%s 4 %s 2", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w4d2), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(180, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(300, r.getString(R.string.jog)), + Interval(150, r.getString(R.string.walk)), + Interval(180, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(300, r.getString(R.string.jog)), + ) + ), + Run( + name = String.format( + "%s 4 %s 3", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w4d3), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(180, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(300, r.getString(R.string.jog)), + Interval(150, r.getString(R.string.walk)), + Interval(180, r.getString(R.string.jog)), + Interval(90, r.getString(R.string.walk)), + Interval(300, r.getString(R.string.jog)), + ) + ), + + Run( + name = String.format( + "%s 5 %s 1", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w5d1), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(300, r.getString(R.string.jog)), + Interval(180, r.getString(R.string.walk)), + Interval(300, r.getString(R.string.jog)), + Interval(180, r.getString(R.string.walk)), + Interval(300, r.getString(R.string.jog)), + ) + ), + Run( + name = String.format( + "%s 5 %s 2", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w5d2), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(480, r.getString(R.string.jog)), + Interval(300, r.getString(R.string.walk)), + Interval(480, r.getString(R.string.jog)), + ) + ), + Run( + name = String.format( + "%s 5 %s 3", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w5d3), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(1200, r.getString(R.string.jog)), + ) + ), + + Run( + name = String.format( + "%s 6 %s 1", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w6d1), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(300, r.getString(R.string.jog)), + Interval(180, r.getString(R.string.walk)), + Interval(480, r.getString(R.string.jog)), + Interval(180, r.getString(R.string.walk)), + Interval(300, r.getString(R.string.jog)), + ) + ), + Run( + name = String.format( + "%s 6 %s 2", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w6d2), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(600, r.getString(R.string.jog)), + Interval(180, r.getString(R.string.walk)), + Interval(600, r.getString(R.string.jog)), + ) + ), + Run( + name = String.format( + "%s 6 %s 3", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w6d3), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(1320, r.getString(R.string.jog)), + ) + ), + + Run( + name = String.format( + "%s 7 %s 1", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w7d1), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(1500, r.getString(R.string.jog)), + ) + ), + Run( + name = String.format( + "%s 7 %s 2", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w7d2), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(1500, r.getString(R.string.jog)), + ) + ), + Run( + name = String.format( + "%s 7 %s 3", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w7d3), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(1500, r.getString(R.string.jog)), + ) + ), + + Run( + name = String.format( + "%s 8 %s 1", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w8d1), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(1680, r.getString(R.string.jog)), + ) + ), + Run( + name = String.format( + "%s 8 %s 2", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w8d2), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(1680, r.getString(R.string.jog)), + ) + ), + Run( + name = String.format( + "%s 8 %s 3", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w8d3), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(1680, r.getString(R.string.jog)), + ) + ), + + Run( + name = String.format( + "%s 9 %s 1", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w9d1), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(1800, r.getString(R.string.jog)), + ) + ), + Run( + name = String.format( + "%s 9 %s 2", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w9d2), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(1800, r.getString(R.string.jog)), + ) + ), + Run( + name = String.format( + "%s 9 %s 3", r.getString(R.string.week), r.getString(R.string.day) + ), + description = r.getString(R.string.w9d3), + isComplete = false, + intervals = arrayOf( + Interval(300, r.getString(R.string.warmup)), + Interval(1800, r.getString(R.string.jog)), + ) + ), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/se/wmuth/openc25k/main/RunAdapter.kt b/app/src/main/java/se/wmuth/openc25k/main/RunAdapter.kt new file mode 100644 index 0000000..4b2d9c7 --- /dev/null +++ b/app/src/main/java/se/wmuth/openc25k/main/RunAdapter.kt @@ -0,0 +1,108 @@ +@file:Suppress("CyclicClassDependency") + +package se.wmuth.openc25k.main + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.appcompat.content.res.AppCompatResources +import androidx.recyclerview.widget.RecyclerView +import se.wmuth.openc25k.R +import se.wmuth.openc25k.data.Run + +/** + * Used to adapt an array of runs for display in a RecyclerView + * + * @param parentContext the context for the adapter + * @param runsArr the array of runs to display + * @param toListen a listener which listens to click events on the adapter and it's items + * @constructor returns the adapter based on the input variables + */ +class RunAdapter(parentContext: Context, runsArr: Array, toListen: RunAdapterClickListener) : + RecyclerView.Adapter() { + private val context: Context = parentContext + private val listener: RunAdapterClickListener = toListen + private val runs: Array = runsArr + + interface RunAdapterClickListener { + /** + * Whenever a run in the adapter is clicked + * this is called with the position in the adapter + * a.k.a. index in array of the clicked item + */ + fun onRunItemClick(position: Int) + + /** + * Whenever a run in the adapter is long clicked (held) + * this is called with the position in the adapter + * a.k.a. index in array of the clicked item + */ + fun onRunItemLongClick(position: Int) + } + + /** + * A class which holds each view for the adapter and detects clicks + * @param itemView the view to hold + * @constructor creates the standard implementation with onclick listeners for the itemView + */ + inner class RunViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), + View.OnClickListener, View.OnLongClickListener { + val iw: ImageView + val tw: TextView + + init { + itemView.setOnClickListener(this) + itemView.setOnLongClickListener(this) + iw = itemView.findViewById(R.id.imageView) + tw = itemView.findViewById(R.id.textView) + } + + override fun onClick(p0: View?) { + val position = adapterPosition + if (position != RecyclerView.NO_POSITION) { + listener.onRunItemClick(position) + } + } + + override fun onLongClick(v: View?): Boolean { + val position = adapterPosition + if (position != RecyclerView.NO_POSITION) { + listener.onRunItemLongClick(position) + return true + } + return false + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RunViewHolder { + return RunViewHolder(LayoutInflater.from(context).inflate(R.layout.run_row, parent, false)) + } + + override fun getItemCount(): Int { + return runs.size + } + + override fun onBindViewHolder(holder: RunViewHolder, position: Int) { + val curRun = runs[position] + holder.apply { + tw.text = curRun.name + + val img = if (curRun.isComplete) { + AppCompatResources.getDrawable( + context, + com.google.android.material.R.drawable.btn_checkbox_checked_mtrl + ) + } else { + AppCompatResources.getDrawable( + context, + com.google.android.material.R.drawable.btn_checkbox_unchecked_mtrl + ) + } + + iw.setImageDrawable(img) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/se/wmuth/openc25k/main/SettingsMenu.kt b/app/src/main/java/se/wmuth/openc25k/main/SettingsMenu.kt new file mode 100644 index 0000000..232024d --- /dev/null +++ b/app/src/main/java/se/wmuth/openc25k/main/SettingsMenu.kt @@ -0,0 +1,75 @@ +package se.wmuth.openc25k.main + +import android.view.Menu +import android.view.MenuItem +import androidx.appcompat.widget.Toolbar +import se.wmuth.openc25k.R + +/** + * Handles click events on the toolbar and calls the according + * functions in the [SettingsMenuListener] interface + * + * @param p the parent, or listener for this class + * @param s: the self, or menu that this class handles + * @constructor Creates default object with passed values + */ +class SettingsMenu(p: SettingsMenuListener, s: Menu) : Toolbar.OnMenuItemClickListener { + private val parent: SettingsMenuListener = p + private val self: Menu = s + + interface SettingsMenuListener { + /** + * Creates the volume setting dialog + */ + fun createVolumeDialog() + + /** + * Returns the current sound setting + * @return true if enabled, false if disabled + */ + fun shouldMakeSound(): Boolean + + /** + * Returns the current vibration setting + * @return true if enabled, false if disabled + */ + fun shouldVibrate(): Boolean + + /** + * Plays a beep to allow the user to test their volume setting + */ + fun testVolume() + + /** + * Toggles the sound setting, true if was false and vice versa + */ + fun toggleSound() + + /** + * Toggles the vibration setting, true if was false and vice versa + */ + fun toggleVibration() + } + + override fun onMenuItemClick(p0: MenuItem?): Boolean { + if (p0 == null) { + return false + } + when (p0.itemId) { + R.id.vibrate -> { + parent.toggleVibration() + self.findItem(R.id.vibrate).isChecked = parent.shouldVibrate() + } + + R.id.sound -> { + parent.toggleSound() + self.findItem(R.id.sound).isChecked = parent.shouldMakeSound() + } + + R.id.setVol -> parent.createVolumeDialog() + R.id.testVol -> parent.testVolume() + else -> return false + } + return true + } +} \ No newline at end of file diff --git a/app/src/main/java/se/wmuth/openc25k/main/VolumeDialog.kt b/app/src/main/java/se/wmuth/openc25k/main/VolumeDialog.kt new file mode 100644 index 0000000..ddf2bfa --- /dev/null +++ b/app/src/main/java/se/wmuth/openc25k/main/VolumeDialog.kt @@ -0,0 +1,73 @@ +package se.wmuth.openc25k.main + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.widget.SeekBar +import android.widget.SeekBar.OnSeekBarChangeListener +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import se.wmuth.openc25k.databinding.VolumeDialogBinding + +/** + * Creates the volume selection dialog and updates the listener + * when the user selects a new volume + * + * @param p the parent or listener to send the update volume event to + * @param pCon the context of the parent + * @param pLIf the layout inflater used in the parent so we can inflate the dialog + */ +class VolumeDialog(p: VolumeDialogListener, pCon: Context, pLIf: LayoutInflater) : + OnSeekBarChangeListener, View.OnClickListener { + private lateinit var dialog: AlertDialog + private lateinit var twProgress: TextView + private val parentContext: Context = pCon + private val parentInflater: LayoutInflater = pLIf + private val parent = p + private var newVolume: Int = 0 + + /** + * Creates the volume alert dialog + * + * @param initialVol the initial volume to display, probably current selected volume + */ + fun createAlertDialog(initialVol: Float) { + newVolume = (initialVol * 100).toInt() + + val builder = AlertDialog.Builder(parentContext) + val binding = VolumeDialogBinding.inflate(parentInflater) + + binding.seekbarVolume.progress = newVolume + binding.seekbarVolume.setOnSeekBarChangeListener(this) + + binding.btnConfirm.setOnClickListener(this) + + twProgress = binding.twProgress + twProgress.text = newVolume.toString() + + builder.setView(binding.root) + dialog = builder.show() + } + + interface VolumeDialogListener { + /** + * Set the volume in the listener to newVolume, [nV] + */ + fun setVolume(nV: Float) + } + + override fun onProgressChanged(p0: SeekBar?, p1: Int, p2: Boolean) { + if (p0 != null && p2) { + newVolume = p1 + twProgress.text = p1.toString() + } + } + + override fun onClick(p0: View?) { + parent.setVolume(newVolume.toFloat() / 100.0f) + dialog.dismiss() + } + + override fun onStartTrackingTouch(p0: SeekBar?) {} + override fun onStopTrackingTouch(p0: SeekBar?) {} +} \ No newline at end of file diff --git a/app/src/main/java/se/wmuth/openc25k/track/RunTimer.kt b/app/src/main/java/se/wmuth/openc25k/track/RunTimer.kt new file mode 100644 index 0000000..9f9d6b5 --- /dev/null +++ b/app/src/main/java/se/wmuth/openc25k/track/RunTimer.kt @@ -0,0 +1,118 @@ +package se.wmuth.openc25k.track + +import android.os.CountDownTimer +import se.wmuth.openc25k.data.Interval + +/** + * Times an array of intervals, sending events throughout + * + * @param iVal the interval array to time + * @param listener the listener which gets notified of the timer's events + * @constructor Creates default implementation of timer to track the intervals + */ +class RunTimer(iVal: Array, listener: RunTimerListener) { + private val intervals: Iterator = iVal.iterator() + private val parent: RunTimerListener = listener + private val totSeconds: Int = iVal.fold(0) { acc, (time) -> acc + time } + private var curInterval: Interval = intervals.next() + private var intervalSeconds: Int = 0 + private var timer: CountDownTimer? = null + private var secondsPassed: Int = 0 + private var finished: Boolean = false + private var thereIsTimer: Boolean = false + + interface RunTimerListener { + /** + * When the timer runs out of intervals to track this is called + */ + fun finishRun() + + /** + * When the current interval finishes and we move onto the next one, + * this method is called + */ + fun nextInterval() + + /** + * On each tick, for now always 1 second long, this method is called + */ + fun tick() + } + + /** + * Start the timer, period 1 second + * Will resumed if paused earlier + */ + fun start() { + if (!finished && !thereIsTimer) { + timer = object : + CountDownTimer((((curInterval.time - intervalSeconds) * 1000).toLong()), 1000) { + override fun onTick(millisUntilFinished: Long) { + tick() + } + + override fun onFinish() {} + }.start() + + thereIsTimer = true + } + } + + /** + * Pauses the timer + */ + fun pause() { + timer?.cancel() + thereIsTimer = false + } + + /** + * Skips the rest of the current interval and moves to the next one + */ + fun skip() { + pause() + secondsPassed += (curInterval.time - intervalSeconds) + intervalSeconds = 0 + if (intervals.hasNext()) { + curInterval = intervals.next() + parent.nextInterval() + start() + } else if (!finished) { + parent.finishRun() + finished = true + } + } + + /** + * Gets the remaining time in the current interval + * Format is MM:SS + */ + fun getIntervalRemaining(): String { + return String.format( + "%02d:%02d", + ((curInterval.time - intervalSeconds) / 60), + ((curInterval.time - intervalSeconds) % 60) + ) + } + + /** + * Gets the remaining time in total for the entire run + * Format is MM:SS + */ + fun getTotalRemaining(): String { + return String.format( + "%02d:%02d", ((totSeconds - secondsPassed) / 60), ((totSeconds - secondsPassed) % 60) + ) + } + + private fun tick() { + intervalSeconds++ + secondsPassed++ + + if (intervalSeconds >= curInterval.time) { + skip() + } else { + parent.tick() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/se/wmuth/openc25k/track/Shaker.kt b/app/src/main/java/se/wmuth/openc25k/track/Shaker.kt new file mode 100644 index 0000000..7419512 --- /dev/null +++ b/app/src/main/java/se/wmuth/openc25k/track/Shaker.kt @@ -0,0 +1,64 @@ +package se.wmuth.openc25k.track + +import android.os.Build +import android.os.CombinedVibration +import android.os.VibrationAttributes +import android.os.VibrationEffect +import android.os.VibratorManager + +/** + * Handles vibration, or shaking, in the app + * + * @param manager the vibration manager to use when telling the device to vibrate + * @constructor Creates default object and effects used later on + */ +class Shaker(manager: VibratorManager) { + private val man: VibratorManager + private val completeEffect: CombinedVibration + private val walkEffect: CombinedVibration + private val jogEffect: CombinedVibration + private val delay: Long = 100L // default delay + private val vib: Long = 350L // default time to vibrate + + init { + man = manager + + val w = longArrayOf(0L, vib) + walkEffect = CombinedVibration.createParallel(VibrationEffect.createWaveform(w, -1)) + + val j = longArrayOf(0L, vib, delay, vib) + jogEffect = CombinedVibration.createParallel(VibrationEffect.createWaveform(j, -1)) + + val c = longArrayOf(0L, vib, delay, vib, delay, vib, delay, vib) + completeEffect = CombinedVibration.createParallel(VibrationEffect.createWaveform(c, -1)) + } + + /** + * Creates the shaking effect when switching to walking + */ + fun walkShake() { + shake(walkEffect) + } + + /** + * Creates the shaking effect when switching to jogging + */ + fun jogShake() { + shake(jogEffect) + } + + /** + * Creates the shaking effect when completing the run + */ + fun completeShake() { + shake(completeEffect) + } + + private fun shake(e: CombinedVibration) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + man.vibrate(e, VibrationAttributes.createForUsage(VibrationAttributes.USAGE_ALARM)) + } else { + man.vibrate(e) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_green_background.xml b/app/src/main/res/drawable/ic_green_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_green_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..721ed0b --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,27 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_track.xml b/app/src/main/res/layout/activity_track.xml new file mode 100644 index 0000000..7b3af29 --- /dev/null +++ b/app/src/main/res/layout/activity_track.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + +