diff --git a/.circleci/config.yml b/.circleci/config.yml
new file mode 100644
index 0000000..20e7114
--- /dev/null
+++ b/.circleci/config.yml
@@ -0,0 +1,34 @@
+version: 2
+
+references:
+ cache_key: &cache_key
+ key: jars-{{ checksum "build.gradle.kts" }}-{{ checksum "app/build.gradle.kts" }}-{{ checksum "gradle/wrapper/gradle-wrapper.properties" }}
+
+jobs:
+ build:
+ docker:
+ - image: circleci/android:api-28-alpha
+ environment:
+ JAVA_TOOL_OPTIONS: "-Xmx1024m"
+ GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.workers.max=2"
+ TERM: dumb
+ steps:
+ - checkout
+ - restore_cache:
+ <<: *cache_key
+ - run:
+ name: Download Dependencies
+ command: ./gradlew dependencies
+ - save_cache:
+ <<: *cache_key
+ paths:
+ - ~/.gradle/caches
+ - ~/.gradle/wrapper
+ - run:
+ name: Run JVM Tests & Lint
+ command: ./gradlew check
+ - store_artifacts:
+ path: app/build/reports
+ destination: reports
+ - store_test_results:
+ path: app/build/test-results
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..259169d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,145 @@
+# Created by https://www.toptal.com/developers/gitignore/api/androidstudio
+# Edit at https://www.toptal.com/developers/gitignore?templates=androidstudio
+
+### AndroidStudio ###
+# Covers files to be ignored for android development using Android Studio.
+
+# Built application files
+*.apk
+*.ap_
+*.aab
+
+# Files for the ART/Dalvik VM
+*.dex
+
+# Java class files
+*.class
+
+# Generated files
+bin/
+gen/
+out/
+
+# Gradle files
+.gradle
+.gradle/
+build/
+
+# Signing files
+.signing/
+
+# Local configuration file (sdk path, etc)
+local.properties
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+*.log
+
+# Android Studio
+/*/build/
+/*/local.properties
+/*/out
+/*/*/build
+/*/*/production
+captures/
+.navigation/
+*.ipr
+*~
+*.swp
+
+# Keystore files
+*.jks
+*.keystore
+
+# Google Services (e.g. APIs or Firebase)
+# google-services.json
+
+# Android Patch
+gen-external-apklibs
+
+# External native build folder generated in Android Studio 2.2 and later
+.externalNativeBuild
+
+# NDK
+obj/
+
+# IntelliJ IDEA
+*.iml
+*.iws
+/out/
+
+# User-specific configurations
+.idea/caches/
+.idea/libraries/
+.idea/shelf/
+.idea/workspace.xml
+.idea/tasks.xml
+.idea/.name
+.idea/compiler.xml
+.idea/copyright/profiles_settings.xml
+.idea/encodings.xml
+.idea/misc.xml
+.idea/modules.xml
+.idea/scopes/scope_settings.xml
+.idea/dictionaries
+.idea/vcs.xml
+.idea/jsLibraryMappings.xml
+.idea/datasources.xml
+.idea/dataSources.ids
+.idea/sqlDataSources.xml
+.idea/dynamic.xml
+.idea/uiDesigner.xml
+.idea/assetWizardSettings.xml
+.idea/gradle.xml
+.idea/jarRepositories.xml
+.idea/navEditor.xml
+.idea/AndroidProjectSystem.xml
+.idea/inspectionProfiles/Project_Default.xml
+
+# Legacy Eclipse project files
+.classpath
+.project
+.cproject
+.settings/
+
+# Mobile Tools for Java (J2ME)
+.mtj.tmp/
+
+# Package Files #
+*.war
+*.ear
+
+# virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml)
+hs_err_pid*
+
+## Plugin-specific files:
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Mongo Explorer plugin
+.idea/mongoSettings.xml
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+### AndroidStudio Patch ###
+
+!/gradle/wrapper/gradle-wrapper.jar
+
+# End of https://www.toptal.com/developers/gitignore/api/androidstudio
+
+# fastlane
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots
+fastlane/test_output
+fastlane/readme.md
\ No newline at end of file
diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml
new file mode 100644
index 0000000..17b07f8
--- /dev/null
+++ b/.idea/androidTestResultsUserPreferences.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
new file mode 100644
index 0000000..7643783
--- /dev/null
+++ b/.idea/codeStyles/Project.xml
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ xmlns:android
+
+ ^$
+
+
+
+
+
+
+
+
+ xmlns:.*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*:id
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ .*:name
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ name
+
+ ^$
+
+
+
+
+
+
+
+
+ style
+
+ ^$
+
+
+
+
+
+
+
+
+ .*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*
+
+ http://schemas.android.com/apk/res/android
+
+
+ ANDROID_ATTRIBUTE_ORDER
+
+
+
+
+
+
+ .*
+
+ .*
+
+
+ BY_NAME
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
new file mode 100644
index 0000000..a55e7a1
--- /dev/null
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml
new file mode 100644
index 0000000..8352933
--- /dev/null
+++ b/.idea/deploymentTargetDropDown.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
new file mode 100644
index 0000000..bb44937
--- /dev/null
+++ b/.idea/kotlinc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/migrations.xml b/.idea/migrations.xml
new file mode 100644
index 0000000..f8051a6
--- /dev/null
+++ b/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
new file mode 100644
index 0000000..16660f1
--- /dev/null
+++ b/.idea/runConfigurations.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..c725819
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,11 @@
+# How to Contribute
+
+We'd love to accept your patches and contributions to this project. There are
+just a few small guidelines you need to follow.
+
+## Code reviews
+
+All submissions, including submissions by project members, require review. We
+use GitHub pull requests for this purpose. Consult
+[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
+information on using pull requests.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..261eeb9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/README.md b/README.md
index 2c0c329..0012a3a 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,19 @@
-# tabs-lite
+An open source guitar tablature application built for Android. Over a million songs available using an existing popular tabs database. Built for speed and simplicity, 100% free with no ads!
-Ad & Account Free Music Tabulatur for Android
\ No newline at end of file
+# Download
+
+[Get the app](https://play.google.com/store/apps/details?id=com.gbros.tabslite) on Google Play, or download it from GitHub releases!
+
+# About
+
+
+
+Find your favorites among thousands of available community driven chords and tabs! Play along at your own speed with built-in auto scroll and speed adjustment.
+
+Jam at any time of day or night with system dark mode support.
+
+Save songs for offline access by adding them to your Favorites or a playlist. The Favorites page is shown immediately on startup, allowing for easy, efficient access to your favorite tabs.
+
+Quickly find the content you're looking for with a beautiful Material Design built for speed and simplicity. Search hundreds of thousands of available songs by title or author name, 100% free with no ads!
+
+Key changes are as simple as a touch of a button with built in transposition. Or find the fingering for any chord by simply tapping the chord name!
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..796b96d
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..c890d5e
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,103 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.kotlinSerialization)
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlinParcelize)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.daggerHilt)
+ alias(libs.plugins.navigationSafeargs)
+ alias(libs.plugins.compose.compiler)
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_21)
+ }
+}
+
+android {
+ signingConfigs {
+ create("release") {
+ storeFile = file("D:\\Code\\Android Development\\gbrosLLC-keystore.jks" )
+ }
+ }
+ compileSdk = 36
+
+ buildFeatures {
+ compose = true
+ }
+
+ defaultConfig {
+ applicationId = "com.gbros.tabslite"
+ minSdk = 24
+ targetSdk = 36
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ versionCode = 3840
+ versionName = "3.8.4"
+ }
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ signingConfig = signingConfigs.getByName("debug")
+ ndk.debugSymbolLevel = "FULL"
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+ }
+
+ dependenciesInfo {
+ includeInApk = false // don"t include Google signed dependency tree in APK to allow the app to be compatible with FDroid
+ includeInBundle = true
+ }
+ namespace = "com.gbros.tabslite"
+}
+
+dependencies {
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.androidx.compose.material3)
+ implementation(libs.androidx.compose.material)
+ implementation(libs.androidx.compose.runtime.livedata)
+ implementation(libs.androidx.compose.ui.tooling.preview)
+ implementation(libs.androidx.constraintlayout)
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.fragment.ktx)
+ implementation(libs.androidx.hilt.navigation.compose)
+ implementation(libs.androidx.legacy.support.v4)
+ implementation(libs.androidx.lifecycle.extensions)
+ implementation(libs.androidx.lifecycle.livedata.ktx)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.lifecycle.viewmodel.ktx)
+ implementation(libs.androidx.navigation.compose)
+ implementation(libs.androidx.navigation.fragment.ktx)
+ implementation(libs.androidx.navigation.ui.ktx)
+ implementation(libs.androidx.recyclerview)
+ ksp(libs.androidx.room.compiler)
+ implementation(libs.androidx.room.runtime)
+ implementation(libs.androidx.room.ktx)
+ implementation(libs.androidx.viewpager2)
+ implementation(libs.androidx.work.runtime.ktx)
+ implementation(libs.compose.extended.gestures)
+ implementation(libs.google.android.material)
+ implementation(libs.google.code.gson)
+ implementation(libs.google.dagger.hilt.android)
+ ksp(libs.google.dagger.hilt.android.compiler)
+ implementation(libs.org.jetbrains.kotlin.stdlib.jdk8)
+ implementation(libs.org.jetbrains.kotlinx.coroutines.android)
+ implementation(libs.org.jetbrains.kotlinx.coroutines.core)
+ implementation(libs.org.jetbrains.kotlinx.serialization.json)
+ implementation(libs.compose.reorderable)
+
+ implementation(libs.chrynan.chords.compose)
+
+ // Debug dependencies
+ debugImplementation(libs.androidx.compose.ui.tooling)
+ debugImplementation(libs.androidx.compose.ui.test.manifest)
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..cc1e10f
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,52 @@
+# Add project specific ProGuard rules here.
+# By default, the flags in this file are appended to flags specified
+# in /usr/local/google/home/tiem/Android/Sdk/tools/proguard/proguard-android.txt
+# You can edit the include path and order by changing the proguardFiles
+# directive in build.gradle.kts.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# Add any project specific keep options here:
+
+# 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 *;
+#}
+
+# SourceFile,LineNumberTable preserve the line number information for
+# debugging stack traces. Signature helps with types for UgApi server handshake
+-keepattributes Exceptions, Signature, InnerClasses, SourceFile, LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
+
+# ServiceLoader support
+-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
+-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
+
+# Most of volatile fields are updated with AFU and should not be mangled
+-keepclassmembernames class kotlinx.** {
+ volatile ;
+}
+
+-keep class androidx.navigation.fragment.NavHostFragment { *; }
+
+# classes that will be serialized or deserialized must be kept for TypeToken use
+-keep class com.gbros.tabslite.data.servertypes.** { *; }
+-keep class com.gbros.tabslite.data.playlist.SelfContainedPlaylist { *; }
+-keep class com.gbros.tabslite.data.playlist.IPlaylist { *; }
+-keep class com.gbros.tabslite.data.playlist.IPlaylistEntry { *; }
+-keep public class com.chrynan.chords.** { *; }
+-keep public class * extends com.chrynan.chords.model.ChordMarker { *; }
+
+# For UgApi.kt, to allow handshake response auto-typing, thanks https://stackoverflow.com/a/76224937/3437608
+# This is also needed for R8 in compat mode since multiple
+# optimizations will remove the generic signature such as class
+# merging and argument removal. See:
+# https://r8.googlesource.com/r8/+/refs/heads/main/compatibility-faq.md#troubleshooting-gson-gson
+-keep class com.google.gson.reflect.TypeToken { *; }
+-keep class * extends com.google.gson.reflect.TypeToken
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..56cbd9d
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/EvenIfFullTab.json b/app/src/main/assets/EvenIfFullTab.json
new file mode 100644
index 0000000..ea2f629
--- /dev/null
+++ b/app/src/main/assets/EvenIfFullTab.json
@@ -0,0 +1,3928 @@
+{
+ "id": 597757,
+ "song_id": 2645413,
+ "song_name": "Even If",
+ "artist_name": "Sam Concepcion",
+ "type": "Chords",
+ "part": "",
+ "version": 1,
+ "votes": 0,
+ "rating": 0,
+ "date": "1194220801",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": "",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ },
+ "versions": [
+ {
+ "id": 2249713,
+ "song_id": 2645413,
+ "song_name": "Even If",
+ "artist_name": "Sam Concepcion",
+ "type": "Chords",
+ "part": "",
+ "version": 2,
+ "votes": 0,
+ "rating": 0,
+ "date": "1512409160",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": "",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ }
+ ],
+ "recommended": [],
+ "userRating": 0,
+ "difficulty": "",
+ "tuning": "E A D G B E",
+ "capo": 0,
+ "urlWeb": "https://tabs.ultimate-guitar.com/tab/sam-concepcion/even-if-chords-597757",
+ "strumming": [],
+ "videosCount": 0,
+ "pro_brother": null,
+ "contributor": {
+ "user_id": 722414,
+ "username": "markchordz"
+ },
+ "applicature": [
+ {
+ "chord": "Bb",
+ "variations": [
+ {
+ "id": "x13331",
+ "listCapos": [
+ {
+ "fret": 1,
+ "startString": 0,
+ "lastString": 4,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 10,
+ "notes": [
+ 53,
+ 50,
+ 46,
+ 41,
+ 34,
+ -1
+ ],
+ "frets": [
+ 1,
+ 3,
+ 3,
+ 3,
+ 1,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 4,
+ 3,
+ 2,
+ 0,
+ 0
+ ],
+ "fret": 0
+ },
+ {
+ "id": "688766",
+ "listCapos": [
+ {
+ "fret": 6,
+ "startString": 0,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 10,
+ "notes": [
+ 58,
+ 53,
+ 50,
+ 46,
+ 41,
+ 34
+ ],
+ "frets": [
+ 6,
+ 6,
+ 7,
+ 8,
+ 8,
+ 6
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 2,
+ 4,
+ 3,
+ 0
+ ],
+ "fret": 6
+ },
+ {
+ "id": "xx8766",
+ "listCapos": [
+ {
+ "fret": 6,
+ "startString": 0,
+ "lastString": 1,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 10,
+ "notes": [
+ 58,
+ 53,
+ 50,
+ 46,
+ -1,
+ -1
+ ],
+ "frets": [
+ 6,
+ 6,
+ 7,
+ 8,
+ -1,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 2,
+ 3,
+ 0,
+ 0
+ ],
+ "fret": 6
+ },
+ {
+ "id": "xx8101110",
+ "listCapos": [],
+ "noteIndex": 10,
+ "notes": [
+ 62,
+ 58,
+ 53,
+ 46,
+ -1,
+ -1
+ ],
+ "frets": [
+ 10,
+ 11,
+ 10,
+ 8,
+ -1,
+ -1
+ ],
+ "fingers": [
+ 3,
+ 4,
+ 2,
+ 1,
+ 0,
+ 0
+ ],
+ "fret": 8
+ },
+ {
+ "id": "x1312101110",
+ "listCapos": [
+ {
+ "fret": 10,
+ "startString": 0,
+ "lastString": 2,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 10,
+ "notes": [
+ 62,
+ 58,
+ 53,
+ 50,
+ 46,
+ -1
+ ],
+ "frets": [
+ 10,
+ 11,
+ 10,
+ 12,
+ 13,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 2,
+ 0,
+ 3,
+ 4,
+ 0
+ ],
+ "fret": 10
+ },
+ {
+ "id": "x1333x",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 1,
+ "lastString": 3,
+ "finger": 3
+ }
+ ],
+ "noteIndex": 10,
+ "notes": [
+ -1,
+ 50,
+ 46,
+ 41,
+ 34,
+ -1
+ ],
+ "frets": [
+ -1,
+ 3,
+ 3,
+ 3,
+ 1,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0
+ ],
+ "fret": 0
+ },
+ {
+ "id": "65333x",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 1,
+ "lastString": 3,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 10,
+ "notes": [
+ -1,
+ 50,
+ 46,
+ 41,
+ 38,
+ 34
+ ],
+ "frets": [
+ -1,
+ 3,
+ 3,
+ 3,
+ 5,
+ 6
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 3,
+ 4
+ ],
+ "fret": 3
+ },
+ {
+ "id": "68876x",
+ "listCapos": [
+ {
+ "fret": 6,
+ "startString": 1,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 10,
+ "notes": [
+ -1,
+ 53,
+ 50,
+ 46,
+ 41,
+ 34
+ ],
+ "frets": [
+ -1,
+ 6,
+ 7,
+ 8,
+ 8,
+ 6
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 2,
+ 4,
+ 3,
+ 0
+ ],
+ "fret": 6
+ },
+ {
+ "id": "x13121011x",
+ "listCapos": [],
+ "noteIndex": 10,
+ "notes": [
+ -1,
+ 58,
+ 53,
+ 50,
+ 46,
+ -1
+ ],
+ "frets": [
+ -1,
+ 11,
+ 10,
+ 12,
+ 13,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 2,
+ 1,
+ 3,
+ 4,
+ 0
+ ],
+ "fret": 10
+ },
+ {
+ "id": "6533xx",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 2,
+ "lastString": 3,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 10,
+ "notes": [
+ -1,
+ -1,
+ 46,
+ 41,
+ 38,
+ 34
+ ],
+ "frets": [
+ -1,
+ -1,
+ 3,
+ 3,
+ 5,
+ 6
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 3,
+ 4
+ ],
+ "fret": 3
+ },
+ {
+ "id": "6887xx",
+ "listCapos": [],
+ "noteIndex": 10,
+ "notes": [
+ -1,
+ -1,
+ 50,
+ 46,
+ 41,
+ 34
+ ],
+ "frets": [
+ -1,
+ -1,
+ 7,
+ 8,
+ 8,
+ 6
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 2,
+ 4,
+ 3,
+ 1
+ ],
+ "fret": 6
+ },
+ {
+ "id": "x13x101110",
+ "listCapos": [
+ {
+ "fret": 10,
+ "startString": 0,
+ "lastString": 2,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 10,
+ "notes": [
+ 62,
+ 58,
+ 53,
+ -1,
+ 46,
+ -1
+ ],
+ "frets": [
+ 10,
+ 11,
+ 10,
+ -1,
+ 13,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 2,
+ 0,
+ 0,
+ 4,
+ 0
+ ],
+ "fret": 10
+ },
+ {
+ "id": "6x333x",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 1,
+ "lastString": 3,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 10,
+ "notes": [
+ -1,
+ 50,
+ 46,
+ 41,
+ -1,
+ 34
+ ],
+ "frets": [
+ -1,
+ 3,
+ 3,
+ 3,
+ -1,
+ 6
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 4
+ ],
+ "fret": 3
+ },
+ {
+ "id": "6x876x",
+ "listCapos": [],
+ "noteIndex": 10,
+ "notes": [
+ -1,
+ 53,
+ 50,
+ 46,
+ -1,
+ 34
+ ],
+ "frets": [
+ -1,
+ 6,
+ 7,
+ 8,
+ -1,
+ 6
+ ],
+ "fingers": [
+ 0,
+ 2,
+ 3,
+ 4,
+ 0,
+ 1
+ ],
+ "fret": 6
+ }
+ ]
+ },
+ {
+ "chord": "F",
+ "variations": [
+ {
+ "id": "133211",
+ "listCapos": [
+ {
+ "fret": 1,
+ "startString": 0,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 5,
+ "notes": [
+ 53,
+ 48,
+ 45,
+ 41,
+ 36,
+ 29
+ ],
+ "frets": [
+ 1,
+ 1,
+ 2,
+ 3,
+ 3,
+ 1
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 2,
+ 4,
+ 3,
+ 0
+ ],
+ "fret": 0
+ },
+ {
+ "id": "xx3211",
+ "listCapos": [
+ {
+ "fret": 1,
+ "startString": 0,
+ "lastString": 1,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 5,
+ "notes": [
+ 53,
+ 48,
+ 45,
+ 41,
+ -1,
+ -1
+ ],
+ "frets": [
+ 1,
+ 1,
+ 2,
+ 3,
+ -1,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 2,
+ 3,
+ 0,
+ 0
+ ],
+ "fret": 0
+ },
+ {
+ "id": "xx3565",
+ "listCapos": [],
+ "noteIndex": 5,
+ "notes": [
+ 57,
+ 53,
+ 48,
+ 41,
+ -1,
+ -1
+ ],
+ "frets": [
+ 5,
+ 6,
+ 5,
+ 3,
+ -1,
+ -1
+ ],
+ "fingers": [
+ 3,
+ 4,
+ 2,
+ 1,
+ 0,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "x87565",
+ "listCapos": [
+ {
+ "fret": 5,
+ "startString": 0,
+ "lastString": 2,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 5,
+ "notes": [
+ 57,
+ 53,
+ 48,
+ 45,
+ 41,
+ -1
+ ],
+ "frets": [
+ 5,
+ 6,
+ 5,
+ 7,
+ 8,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 2,
+ 0,
+ 3,
+ 4,
+ 0
+ ],
+ "fret": 5
+ },
+ {
+ "id": "x81010108",
+ "listCapos": [
+ {
+ "fret": 8,
+ "startString": 0,
+ "lastString": 4,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 5,
+ "notes": [
+ 60,
+ 57,
+ 53,
+ 48,
+ 41,
+ -1
+ ],
+ "frets": [
+ 8,
+ 10,
+ 10,
+ 10,
+ 8,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 4,
+ 3,
+ 2,
+ 0,
+ 0
+ ],
+ "fret": 8
+ },
+ {
+ "id": "13321x",
+ "listCapos": [
+ {
+ "fret": 1,
+ "startString": 1,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 5,
+ "notes": [
+ -1,
+ 48,
+ 45,
+ 41,
+ 36,
+ 29
+ ],
+ "frets": [
+ -1,
+ 1,
+ 2,
+ 3,
+ 3,
+ 1
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 2,
+ 4,
+ 3,
+ 0
+ ],
+ "fret": 0
+ },
+ {
+ "id": "x8756x",
+ "listCapos": [],
+ "noteIndex": 5,
+ "notes": [
+ -1,
+ 53,
+ 48,
+ 45,
+ 41,
+ -1
+ ],
+ "frets": [
+ -1,
+ 6,
+ 5,
+ 7,
+ 8,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 2,
+ 1,
+ 3,
+ 4,
+ 0
+ ],
+ "fret": 5
+ },
+ {
+ "id": "x8101010x",
+ "listCapos": [
+ {
+ "fret": 10,
+ "startString": 1,
+ "lastString": 3,
+ "finger": 3
+ }
+ ],
+ "noteIndex": 5,
+ "notes": [
+ -1,
+ 57,
+ 53,
+ 48,
+ 41,
+ -1
+ ],
+ "frets": [
+ -1,
+ 10,
+ 10,
+ 10,
+ 8,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0
+ ],
+ "fret": 8
+ },
+ {
+ "id": "1312101010x",
+ "listCapos": [
+ {
+ "fret": 10,
+ "startString": 1,
+ "lastString": 3,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 5,
+ "notes": [
+ -1,
+ 57,
+ 53,
+ 48,
+ 45,
+ 41
+ ],
+ "frets": [
+ -1,
+ 10,
+ 10,
+ 10,
+ 12,
+ 13
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 3,
+ 4
+ ],
+ "fret": 10
+ },
+ {
+ "id": "1332xx",
+ "listCapos": [],
+ "noteIndex": 5,
+ "notes": [
+ -1,
+ -1,
+ 45,
+ 41,
+ 36,
+ 29
+ ],
+ "frets": [
+ -1,
+ -1,
+ 2,
+ 3,
+ 3,
+ 1
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 2,
+ 4,
+ 3,
+ 1
+ ],
+ "fret": 0
+ },
+ {
+ "id": "13121010xx",
+ "listCapos": [
+ {
+ "fret": 10,
+ "startString": 2,
+ "lastString": 3,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 5,
+ "notes": [
+ -1,
+ -1,
+ 53,
+ 48,
+ 45,
+ 41
+ ],
+ "frets": [
+ -1,
+ -1,
+ 10,
+ 10,
+ 12,
+ 13
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 3,
+ 4
+ ],
+ "fret": 10
+ },
+ {
+ "id": "x8x565",
+ "listCapos": [
+ {
+ "fret": 5,
+ "startString": 0,
+ "lastString": 2,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 5,
+ "notes": [
+ 57,
+ 53,
+ 48,
+ -1,
+ 41,
+ -1
+ ],
+ "frets": [
+ 5,
+ 6,
+ 5,
+ -1,
+ 8,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 2,
+ 0,
+ 0,
+ 4,
+ 0
+ ],
+ "fret": 5
+ },
+ {
+ "id": "1x321x",
+ "listCapos": [],
+ "noteIndex": 5,
+ "notes": [
+ -1,
+ 48,
+ 45,
+ 41,
+ -1,
+ 29
+ ],
+ "frets": [
+ -1,
+ 1,
+ 2,
+ 3,
+ -1,
+ 1
+ ],
+ "fingers": [
+ 0,
+ 2,
+ 3,
+ 4,
+ 0,
+ 1
+ ],
+ "fret": 0
+ },
+ {
+ "id": "13x101010x",
+ "listCapos": [
+ {
+ "fret": 10,
+ "startString": 1,
+ "lastString": 3,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 5,
+ "notes": [
+ -1,
+ 57,
+ 53,
+ 48,
+ -1,
+ 41
+ ],
+ "frets": [
+ -1,
+ 10,
+ 10,
+ 10,
+ -1,
+ 13
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 4
+ ],
+ "fret": 10
+ }
+ ]
+ },
+ {
+ "chord": "Gm",
+ "variations": [
+ {
+ "id": "355333",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 0,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ 55,
+ 50,
+ 46,
+ 43,
+ 38,
+ 31
+ ],
+ "frets": [
+ 3,
+ 3,
+ 3,
+ 5,
+ 5,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 4,
+ 3,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "355336",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 1,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ 58,
+ 50,
+ 46,
+ 43,
+ 38,
+ 31
+ ],
+ "frets": [
+ 6,
+ 3,
+ 3,
+ 5,
+ 5,
+ 3
+ ],
+ "fingers": [
+ 4,
+ 0,
+ 0,
+ 3,
+ 2,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "xx5333",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 0,
+ "lastString": 2,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ 55,
+ 50,
+ 46,
+ 43,
+ -1,
+ -1
+ ],
+ "frets": [
+ 3,
+ 3,
+ 3,
+ 5,
+ -1,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 3,
+ 0,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "xx5336",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 1,
+ "lastString": 2,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ 58,
+ 50,
+ 46,
+ 43,
+ -1,
+ -1
+ ],
+ "frets": [
+ 6,
+ 3,
+ 3,
+ 5,
+ -1,
+ -1
+ ],
+ "fingers": [
+ 4,
+ 0,
+ 0,
+ 3,
+ 0,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "xx5786",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ 58,
+ 55,
+ 50,
+ 43,
+ -1,
+ -1
+ ],
+ "frets": [
+ 6,
+ 8,
+ 7,
+ 5,
+ -1,
+ -1
+ ],
+ "fingers": [
+ 2,
+ 4,
+ 3,
+ 1,
+ 0,
+ 0
+ ],
+ "fret": 5
+ },
+ {
+ "id": "x1012121110",
+ "listCapos": [
+ {
+ "fret": 10,
+ "startString": 0,
+ "lastString": 4,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ 62,
+ 58,
+ 55,
+ 50,
+ 43,
+ -1
+ ],
+ "frets": [
+ 10,
+ 11,
+ 12,
+ 12,
+ 10,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 2,
+ 4,
+ 3,
+ 0,
+ 0
+ ],
+ "fret": 10
+ },
+ {
+ "id": "31003x",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ -1,
+ 50,
+ 43,
+ 38,
+ 34,
+ 31
+ ],
+ "frets": [
+ -1,
+ 3,
+ 0,
+ 0,
+ 1,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 4,
+ 0,
+ 0,
+ 1,
+ 3
+ ],
+ "fret": 0
+ },
+ {
+ "id": "35533x",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 1,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ -1,
+ 50,
+ 46,
+ 43,
+ 38,
+ 31
+ ],
+ "frets": [
+ -1,
+ 3,
+ 3,
+ 5,
+ 5,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 4,
+ 3,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "x10121211x",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ -1,
+ 58,
+ 55,
+ 50,
+ 43,
+ -1
+ ],
+ "frets": [
+ -1,
+ 11,
+ 12,
+ 12,
+ 10,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 2,
+ 4,
+ 3,
+ 1,
+ 0
+ ],
+ "fret": 10
+ },
+ {
+ "id": "3100xx",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ -1,
+ -1,
+ 43,
+ 38,
+ 34,
+ 31
+ ],
+ "frets": [
+ -1,
+ -1,
+ 0,
+ 0,
+ 1,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 3
+ ],
+ "fret": 0
+ },
+ {
+ "id": "3103xx",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ -1,
+ -1,
+ 46,
+ 38,
+ 34,
+ 31
+ ],
+ "frets": [
+ -1,
+ -1,
+ 3,
+ 0,
+ 1,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 4,
+ 0,
+ 1,
+ 3
+ ],
+ "fret": 0
+ },
+ {
+ "id": "3553xx",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 2,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ -1,
+ -1,
+ 46,
+ 43,
+ 38,
+ 31
+ ],
+ "frets": [
+ -1,
+ -1,
+ 3,
+ 5,
+ 5,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 4,
+ 3,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "x10x121110",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ 62,
+ 58,
+ 55,
+ -1,
+ 43,
+ -1
+ ],
+ "frets": [
+ 10,
+ 11,
+ 12,
+ -1,
+ 10,
+ -1
+ ],
+ "fingers": [
+ 2,
+ 3,
+ 4,
+ 0,
+ 1,
+ 0
+ ],
+ "fret": 10
+ },
+ {
+ "id": "3x033x",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 1,
+ "lastString": 2,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ -1,
+ 50,
+ 46,
+ 38,
+ -1,
+ 31
+ ],
+ "frets": [
+ -1,
+ 3,
+ 3,
+ 0,
+ -1,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 2
+ ],
+ "fret": 3
+ }
+ ]
+ },
+ {
+ "chord": "Eb",
+ "variations": [
+ {
+ "id": "xx1343",
+ "listCapos": [],
+ "noteIndex": 3,
+ "notes": [
+ 55,
+ 51,
+ 46,
+ 39,
+ -1,
+ -1
+ ],
+ "frets": [
+ 3,
+ 4,
+ 3,
+ 1,
+ -1,
+ -1
+ ],
+ "fingers": [
+ 3,
+ 4,
+ 2,
+ 1,
+ 0,
+ 0
+ ],
+ "fret": 0
+ },
+ {
+ "id": "x65343",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 0,
+ "lastString": 2,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 3,
+ "notes": [
+ 55,
+ 51,
+ 46,
+ 43,
+ 39,
+ -1
+ ],
+ "frets": [
+ 3,
+ 4,
+ 3,
+ 5,
+ 6,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 2,
+ 0,
+ 3,
+ 4,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "x68886",
+ "listCapos": [
+ {
+ "fret": 6,
+ "startString": 0,
+ "lastString": 4,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 3,
+ "notes": [
+ 58,
+ 55,
+ 51,
+ 46,
+ 39,
+ -1
+ ],
+ "frets": [
+ 6,
+ 8,
+ 8,
+ 8,
+ 6,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 4,
+ 3,
+ 2,
+ 0,
+ 0
+ ],
+ "fret": 6
+ },
+ {
+ "id": "111313121111",
+ "listCapos": [
+ {
+ "fret": 11,
+ "startString": 0,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 3,
+ "notes": [
+ 63,
+ 58,
+ 55,
+ 51,
+ 46,
+ 39
+ ],
+ "frets": [
+ 11,
+ 11,
+ 12,
+ 13,
+ 13,
+ 11
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 2,
+ 4,
+ 3,
+ 0
+ ],
+ "fret": 11
+ },
+ {
+ "id": "xx13121111",
+ "listCapos": [
+ {
+ "fret": 11,
+ "startString": 0,
+ "lastString": 1,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 3,
+ "notes": [
+ 63,
+ 58,
+ 55,
+ 51,
+ -1,
+ -1
+ ],
+ "frets": [
+ 11,
+ 11,
+ 12,
+ 13,
+ -1,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 2,
+ 3,
+ 0,
+ 0
+ ],
+ "fret": 11
+ },
+ {
+ "id": "x6534x",
+ "listCapos": [],
+ "noteIndex": 3,
+ "notes": [
+ -1,
+ 51,
+ 46,
+ 43,
+ 39,
+ -1
+ ],
+ "frets": [
+ -1,
+ 4,
+ 3,
+ 5,
+ 6,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 2,
+ 1,
+ 3,
+ 4,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "x6888x",
+ "listCapos": [
+ {
+ "fret": 8,
+ "startString": 1,
+ "lastString": 3,
+ "finger": 3
+ }
+ ],
+ "noteIndex": 3,
+ "notes": [
+ -1,
+ 55,
+ 51,
+ 46,
+ 39,
+ -1
+ ],
+ "frets": [
+ -1,
+ 8,
+ 8,
+ 8,
+ 6,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0
+ ],
+ "fret": 6
+ },
+ {
+ "id": "1110888x",
+ "listCapos": [
+ {
+ "fret": 8,
+ "startString": 1,
+ "lastString": 3,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 3,
+ "notes": [
+ -1,
+ 55,
+ 51,
+ 46,
+ 43,
+ 39
+ ],
+ "frets": [
+ -1,
+ 8,
+ 8,
+ 8,
+ 10,
+ 11
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 3,
+ 4
+ ],
+ "fret": 8
+ },
+ {
+ "id": "1113131211x",
+ "listCapos": [
+ {
+ "fret": 11,
+ "startString": 1,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 3,
+ "notes": [
+ -1,
+ 58,
+ 55,
+ 51,
+ 46,
+ 39
+ ],
+ "frets": [
+ -1,
+ 11,
+ 12,
+ 13,
+ 13,
+ 11
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 2,
+ 4,
+ 3,
+ 0
+ ],
+ "fret": 11
+ },
+ {
+ "id": "111088xx",
+ "listCapos": [
+ {
+ "fret": 8,
+ "startString": 2,
+ "lastString": 3,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 3,
+ "notes": [
+ -1,
+ -1,
+ 51,
+ 46,
+ 43,
+ 39
+ ],
+ "frets": [
+ -1,
+ -1,
+ 8,
+ 8,
+ 10,
+ 11
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 3,
+ 4
+ ],
+ "fret": 8
+ },
+ {
+ "id": "11131312xx",
+ "listCapos": [],
+ "noteIndex": 3,
+ "notes": [
+ -1,
+ -1,
+ 55,
+ 51,
+ 46,
+ 39
+ ],
+ "frets": [
+ -1,
+ -1,
+ 12,
+ 13,
+ 13,
+ 11
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 2,
+ 4,
+ 3,
+ 1
+ ],
+ "fret": 11
+ },
+ {
+ "id": "x6x343",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 0,
+ "lastString": 2,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 3,
+ "notes": [
+ 55,
+ 51,
+ 46,
+ -1,
+ 39,
+ -1
+ ],
+ "frets": [
+ 3,
+ 4,
+ 3,
+ -1,
+ 6,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 2,
+ 0,
+ 0,
+ 4,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "11x888x",
+ "listCapos": [
+ {
+ "fret": 8,
+ "startString": 1,
+ "lastString": 3,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 3,
+ "notes": [
+ -1,
+ 55,
+ 51,
+ 46,
+ -1,
+ 39
+ ],
+ "frets": [
+ -1,
+ 8,
+ 8,
+ 8,
+ -1,
+ 11
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 4
+ ],
+ "fret": 8
+ },
+ {
+ "id": "11x131211x",
+ "listCapos": [],
+ "noteIndex": 3,
+ "notes": [
+ -1,
+ 58,
+ 55,
+ 51,
+ -1,
+ 39
+ ],
+ "frets": [
+ -1,
+ 11,
+ 12,
+ 13,
+ -1,
+ 11
+ ],
+ "fingers": [
+ 0,
+ 2,
+ 3,
+ 4,
+ 0,
+ 1
+ ],
+ "fret": 11
+ }
+ ]
+ },
+ {
+ "chord": "Cm",
+ "variations": [
+ {
+ "id": "x35543",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 0,
+ "lastString": 4,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 0,
+ "notes": [
+ 55,
+ 51,
+ 48,
+ 43,
+ 36,
+ -1
+ ],
+ "frets": [
+ 3,
+ 4,
+ 5,
+ 5,
+ 3,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 2,
+ 4,
+ 3,
+ 0,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "81010888",
+ "listCapos": [
+ {
+ "fret": 8,
+ "startString": 0,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 0,
+ "notes": [
+ 60,
+ 55,
+ 51,
+ 48,
+ 43,
+ 36
+ ],
+ "frets": [
+ 8,
+ 8,
+ 8,
+ 10,
+ 10,
+ 8
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 4,
+ 3,
+ 0
+ ],
+ "fret": 8
+ },
+ {
+ "id": "810108811",
+ "listCapos": [
+ {
+ "fret": 8,
+ "startString": 1,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 0,
+ "notes": [
+ 63,
+ 55,
+ 51,
+ 48,
+ 43,
+ 36
+ ],
+ "frets": [
+ 11,
+ 8,
+ 8,
+ 10,
+ 10,
+ 8
+ ],
+ "fingers": [
+ 4,
+ 0,
+ 0,
+ 3,
+ 2,
+ 0
+ ],
+ "fret": 8
+ },
+ {
+ "id": "xx10888",
+ "listCapos": [
+ {
+ "fret": 8,
+ "startString": 0,
+ "lastString": 2,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 0,
+ "notes": [
+ 60,
+ 55,
+ 51,
+ 48,
+ -1,
+ -1
+ ],
+ "frets": [
+ 8,
+ 8,
+ 8,
+ 10,
+ -1,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 3,
+ 0,
+ 0
+ ],
+ "fret": 8
+ },
+ {
+ "id": "xx108811",
+ "listCapos": [
+ {
+ "fret": 8,
+ "startString": 1,
+ "lastString": 2,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 0,
+ "notes": [
+ 63,
+ 55,
+ 51,
+ 48,
+ -1,
+ -1
+ ],
+ "frets": [
+ 11,
+ 8,
+ 8,
+ 10,
+ -1,
+ -1
+ ],
+ "fingers": [
+ 4,
+ 0,
+ 0,
+ 3,
+ 0,
+ 0
+ ],
+ "fret": 8
+ },
+ {
+ "id": "xx10121311",
+ "listCapos": [],
+ "noteIndex": 0,
+ "notes": [
+ 63,
+ 60,
+ 55,
+ 48,
+ -1,
+ -1
+ ],
+ "frets": [
+ 11,
+ 13,
+ 12,
+ 10,
+ -1,
+ -1
+ ],
+ "fingers": [
+ 2,
+ 4,
+ 3,
+ 1,
+ 0,
+ 0
+ ],
+ "fret": 10
+ },
+ {
+ "id": "x3101x",
+ "listCapos": [],
+ "noteIndex": 0,
+ "notes": [
+ -1,
+ 48,
+ 43,
+ 39,
+ 36,
+ -1
+ ],
+ "frets": [
+ -1,
+ 1,
+ 0,
+ 1,
+ 3,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 2,
+ 0,
+ 1,
+ 4,
+ 0
+ ],
+ "fret": 0
+ },
+ {
+ "id": "x3104x",
+ "listCapos": [],
+ "noteIndex": 0,
+ "notes": [
+ -1,
+ 51,
+ 43,
+ 39,
+ 36,
+ -1
+ ],
+ "frets": [
+ -1,
+ 4,
+ 0,
+ 1,
+ 3,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 4,
+ 0,
+ 1,
+ 3,
+ 0
+ ],
+ "fret": 0
+ },
+ {
+ "id": "x3554x",
+ "listCapos": [],
+ "noteIndex": 0,
+ "notes": [
+ -1,
+ 51,
+ 48,
+ 43,
+ 36,
+ -1
+ ],
+ "frets": [
+ -1,
+ 4,
+ 5,
+ 5,
+ 3,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 2,
+ 4,
+ 3,
+ 1,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "8101088x",
+ "listCapos": [
+ {
+ "fret": 8,
+ "startString": 1,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 0,
+ "notes": [
+ -1,
+ 55,
+ 51,
+ 48,
+ 43,
+ 36
+ ],
+ "frets": [
+ -1,
+ 8,
+ 8,
+ 10,
+ 10,
+ 8
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 4,
+ 3,
+ 0
+ ],
+ "fret": 8
+ },
+ {
+ "id": "8655xx",
+ "listCapos": [
+ {
+ "fret": 5,
+ "startString": 2,
+ "lastString": 3,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 0,
+ "notes": [
+ -1,
+ -1,
+ 48,
+ 43,
+ 39,
+ 36
+ ],
+ "frets": [
+ -1,
+ -1,
+ 5,
+ 5,
+ 6,
+ 8
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 2,
+ 4
+ ],
+ "fret": 5
+ },
+ {
+ "id": "810108xx",
+ "listCapos": [
+ {
+ "fret": 8,
+ "startString": 2,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 0,
+ "notes": [
+ -1,
+ -1,
+ 51,
+ 48,
+ 43,
+ 36
+ ],
+ "frets": [
+ -1,
+ -1,
+ 8,
+ 10,
+ 10,
+ 8
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 4,
+ 3,
+ 0
+ ],
+ "fret": 8
+ },
+ {
+ "id": "x3x043",
+ "listCapos": [],
+ "noteIndex": 0,
+ "notes": [
+ 55,
+ 51,
+ 43,
+ -1,
+ 36,
+ -1
+ ],
+ "frets": [
+ 3,
+ 4,
+ 0,
+ -1,
+ 3,
+ -1
+ ],
+ "fingers": [
+ 2,
+ 3,
+ 0,
+ 0,
+ 1,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "x3x543",
+ "listCapos": [],
+ "noteIndex": 0,
+ "notes": [
+ 55,
+ 51,
+ 48,
+ -1,
+ 36,
+ -1
+ ],
+ "frets": [
+ 3,
+ 4,
+ 5,
+ -1,
+ 3,
+ -1
+ ],
+ "fingers": [
+ 2,
+ 3,
+ 4,
+ 0,
+ 1,
+ 0
+ ],
+ "fret": 3
+ }
+ ]
+ },
+ {
+ "chord": "Gm7",
+ "variations": [
+ {
+ "id": "353333",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 0,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ 55,
+ 50,
+ 46,
+ 41,
+ 38,
+ 31
+ ],
+ "frets": [
+ 3,
+ 3,
+ 3,
+ 3,
+ 5,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 3,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "353336",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 1,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ 58,
+ 50,
+ 46,
+ 41,
+ 38,
+ 31
+ ],
+ "frets": [
+ 6,
+ 3,
+ 3,
+ 3,
+ 5,
+ 3
+ ],
+ "fingers": [
+ 4,
+ 0,
+ 0,
+ 0,
+ 3,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "353363",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 0,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ 55,
+ 53,
+ 46,
+ 41,
+ 38,
+ 31
+ ],
+ "frets": [
+ 3,
+ 6,
+ 3,
+ 3,
+ 5,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 4,
+ 0,
+ 0,
+ 3,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "353366",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 2,
+ "lastString": 5,
+ "finger": 1
+ },
+ {
+ "fret": 6,
+ "startString": 0,
+ "lastString": 1,
+ "finger": 4
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ 58,
+ 53,
+ 46,
+ 41,
+ 38,
+ 31
+ ],
+ "frets": [
+ 6,
+ 6,
+ 3,
+ 3,
+ 5,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 3,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "355363",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 0,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ 55,
+ 53,
+ 46,
+ 43,
+ 38,
+ 31
+ ],
+ "frets": [
+ 3,
+ 6,
+ 3,
+ 5,
+ 5,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 4,
+ 0,
+ 3,
+ 2,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "355366",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 2,
+ "lastString": 5,
+ "finger": 1
+ },
+ {
+ "fret": 6,
+ "startString": 0,
+ "lastString": 1,
+ "finger": 4
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ 58,
+ 53,
+ 46,
+ 43,
+ 38,
+ 31
+ ],
+ "frets": [
+ 6,
+ 6,
+ 3,
+ 5,
+ 5,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 3,
+ 2,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "xx5363",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 0,
+ "lastString": 2,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ 55,
+ 53,
+ 46,
+ 43,
+ -1,
+ -1
+ ],
+ "frets": [
+ 3,
+ 6,
+ 3,
+ 5,
+ -1,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 4,
+ 0,
+ 3,
+ 0,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "xx5366",
+ "listCapos": [
+ {
+ "fret": 6,
+ "startString": 0,
+ "lastString": 1,
+ "finger": 4
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ 58,
+ 53,
+ 46,
+ 43,
+ -1,
+ -1
+ ],
+ "frets": [
+ 6,
+ 6,
+ 3,
+ 5,
+ -1,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 1,
+ 3,
+ 0,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "xx5766",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ 58,
+ 53,
+ 50,
+ 43,
+ -1,
+ -1
+ ],
+ "frets": [
+ 6,
+ 6,
+ 7,
+ 5,
+ -1,
+ -1
+ ],
+ "fingers": [
+ 3,
+ 2,
+ 4,
+ 1,
+ 0,
+ 0
+ ],
+ "fret": 5
+ },
+ {
+ "id": "x1012101110",
+ "listCapos": [
+ {
+ "fret": 10,
+ "startString": 0,
+ "lastString": 4,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ 62,
+ 58,
+ 53,
+ 50,
+ 43,
+ -1
+ ],
+ "frets": [
+ 10,
+ 11,
+ 10,
+ 12,
+ 10,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 2,
+ 0,
+ 3,
+ 0,
+ 0
+ ],
+ "fret": 10
+ },
+ {
+ "id": "x1012101113",
+ "listCapos": [
+ {
+ "fret": 10,
+ "startString": 2,
+ "lastString": 4,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ 65,
+ 58,
+ 53,
+ 50,
+ 43,
+ -1
+ ],
+ "frets": [
+ 13,
+ 11,
+ 10,
+ 12,
+ 10,
+ -1
+ ],
+ "fingers": [
+ 4,
+ 2,
+ 0,
+ 3,
+ 0,
+ 0
+ ],
+ "fret": 10
+ },
+ {
+ "id": "35333x",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 1,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ -1,
+ 50,
+ 46,
+ 41,
+ 38,
+ 31
+ ],
+ "frets": [
+ -1,
+ 3,
+ 3,
+ 3,
+ 5,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 3,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "35336x",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 2,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ -1,
+ 53,
+ 46,
+ 41,
+ 38,
+ 31
+ ],
+ "frets": [
+ -1,
+ 6,
+ 3,
+ 3,
+ 5,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 4,
+ 0,
+ 0,
+ 3,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "35536x",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 2,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ -1,
+ 53,
+ 46,
+ 43,
+ 38,
+ 31
+ ],
+ "frets": [
+ -1,
+ 6,
+ 3,
+ 5,
+ 5,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 4,
+ 0,
+ 3,
+ 2,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "x108108x",
+ "listCapos": [
+ {
+ "fret": 8,
+ "startString": 1,
+ "lastString": 3,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ -1,
+ 55,
+ 53,
+ 46,
+ 43,
+ -1
+ ],
+ "frets": [
+ -1,
+ 8,
+ 10,
+ 8,
+ 10,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 4,
+ 0,
+ 3,
+ 0
+ ],
+ "fret": 8
+ },
+ {
+ "id": "x1081011x",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ -1,
+ 58,
+ 53,
+ 46,
+ 43,
+ -1
+ ],
+ "frets": [
+ -1,
+ 11,
+ 10,
+ 8,
+ 10,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 4,
+ 3,
+ 1,
+ 2,
+ 0
+ ],
+ "fret": 8
+ },
+ {
+ "id": "x10121011x",
+ "listCapos": [
+ {
+ "fret": 10,
+ "startString": 2,
+ "lastString": 4,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ -1,
+ 58,
+ 53,
+ 50,
+ 43,
+ -1
+ ],
+ "frets": [
+ -1,
+ 11,
+ 10,
+ 12,
+ 10,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 2,
+ 0,
+ 3,
+ 0,
+ 0
+ ],
+ "fret": 10
+ },
+ {
+ "id": "3130xx",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ -1,
+ -1,
+ 43,
+ 41,
+ 34,
+ 31
+ ],
+ "frets": [
+ -1,
+ -1,
+ 0,
+ 3,
+ 1,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 4,
+ 1,
+ 3
+ ],
+ "fret": 0
+ },
+ {
+ "id": "3533xx",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 2,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ -1,
+ -1,
+ 46,
+ 41,
+ 38,
+ 31
+ ],
+ "frets": [
+ -1,
+ -1,
+ 3,
+ 3,
+ 5,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 3,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "3x0331",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ 53,
+ 50,
+ 46,
+ 38,
+ -1,
+ 31
+ ],
+ "frets": [
+ 1,
+ 3,
+ 3,
+ 0,
+ -1,
+ 3
+ ],
+ "fingers": [
+ 1,
+ 4,
+ 3,
+ 0,
+ 0,
+ 2
+ ],
+ "fret": 0
+ },
+ {
+ "id": "x10x121113",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ 65,
+ 58,
+ 55,
+ -1,
+ 43,
+ -1
+ ],
+ "frets": [
+ 13,
+ 11,
+ 12,
+ -1,
+ 10,
+ -1
+ ],
+ "fingers": [
+ 4,
+ 2,
+ 3,
+ 0,
+ 1,
+ 0
+ ],
+ "fret": 10
+ }
+ ]
+ },
+ {
+ "chord": "Gm6",
+ "variations": [
+ {
+ "id": "310030",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ 52,
+ 50,
+ 43,
+ 38,
+ 34,
+ 31
+ ],
+ "frets": [
+ 0,
+ 3,
+ 0,
+ 0,
+ 1,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 4,
+ 0,
+ 0,
+ 1,
+ 3
+ ],
+ "fret": 0
+ },
+ {
+ "id": "312030",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ 52,
+ 50,
+ 43,
+ 40,
+ 34,
+ 31
+ ],
+ "frets": [
+ 0,
+ 3,
+ 0,
+ 2,
+ 1,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 4,
+ 0,
+ 2,
+ 1,
+ 3
+ ],
+ "fret": 0
+ },
+ {
+ "id": "375333",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 0,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ 55,
+ 50,
+ 46,
+ 43,
+ 40,
+ 31
+ ],
+ "frets": [
+ 3,
+ 3,
+ 3,
+ 5,
+ 7,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 3,
+ 1,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "355353",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 0,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ 55,
+ 52,
+ 46,
+ 43,
+ 38,
+ 31
+ ],
+ "frets": [
+ 3,
+ 5,
+ 3,
+ 5,
+ 5,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 4,
+ 0,
+ 3,
+ 2,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "xx5353",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 0,
+ "lastString": 2,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ 55,
+ 52,
+ 46,
+ 43,
+ -1,
+ -1
+ ],
+ "frets": [
+ 3,
+ 5,
+ 3,
+ 5,
+ -1,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 4,
+ 0,
+ 3,
+ 0,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "xx5356",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ 58,
+ 52,
+ 46,
+ 43,
+ -1,
+ -1
+ ],
+ "frets": [
+ 6,
+ 5,
+ 3,
+ 5,
+ -1,
+ -1
+ ],
+ "fingers": [
+ 4,
+ 3,
+ 1,
+ 2,
+ 0,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "xx5756",
+ "listCapos": [
+ {
+ "fret": 5,
+ "startString": 1,
+ "lastString": 3,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ 58,
+ 52,
+ 50,
+ 43,
+ -1,
+ -1
+ ],
+ "frets": [
+ 6,
+ 5,
+ 7,
+ 5,
+ -1,
+ -1
+ ],
+ "fingers": [
+ 2,
+ 0,
+ 3,
+ 0,
+ 0,
+ 0
+ ],
+ "fret": 5
+ },
+ {
+ "id": "x1089810",
+ "listCapos": [
+ {
+ "fret": 8,
+ "startString": 1,
+ "lastString": 3,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ 62,
+ 55,
+ 52,
+ 46,
+ 43,
+ -1
+ ],
+ "frets": [
+ 10,
+ 8,
+ 9,
+ 8,
+ 10,
+ -1
+ ],
+ "fingers": [
+ 4,
+ 0,
+ 2,
+ 0,
+ 3,
+ 0
+ ],
+ "fret": 8
+ },
+ {
+ "id": "31203x",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ -1,
+ 50,
+ 43,
+ 40,
+ 34,
+ 31
+ ],
+ "frets": [
+ -1,
+ 3,
+ 0,
+ 2,
+ 1,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 4,
+ 0,
+ 2,
+ 1,
+ 3
+ ],
+ "fret": 0
+ },
+ {
+ "id": "35535x",
+ "listCapos": [
+ {
+ "fret": 3,
+ "startString": 2,
+ "lastString": 5,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ -1,
+ 52,
+ 46,
+ 43,
+ 38,
+ 31
+ ],
+ "frets": [
+ -1,
+ 5,
+ 3,
+ 5,
+ 5,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 4,
+ 0,
+ 3,
+ 2,
+ 0
+ ],
+ "fret": 3
+ },
+ {
+ "id": "x10898x",
+ "listCapos": [
+ {
+ "fret": 8,
+ "startString": 1,
+ "lastString": 3,
+ "finger": 1
+ }
+ ],
+ "noteIndex": 7,
+ "notes": [
+ -1,
+ 55,
+ 52,
+ 46,
+ 43,
+ -1
+ ],
+ "frets": [
+ -1,
+ 8,
+ 9,
+ 8,
+ 10,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 2,
+ 0,
+ 3,
+ 0
+ ],
+ "fret": 8
+ },
+ {
+ "id": "x108911x",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ -1,
+ 58,
+ 52,
+ 46,
+ 43,
+ -1
+ ],
+ "frets": [
+ -1,
+ 11,
+ 9,
+ 8,
+ 10,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 4,
+ 2,
+ 1,
+ 3,
+ 0
+ ],
+ "fret": 8
+ },
+ {
+ "id": "x1012911x",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ -1,
+ 58,
+ 52,
+ 50,
+ 43,
+ -1
+ ],
+ "frets": [
+ -1,
+ 11,
+ 9,
+ 12,
+ 10,
+ -1
+ ],
+ "fingers": [
+ 0,
+ 3,
+ 1,
+ 4,
+ 2,
+ 0
+ ],
+ "fret": 9
+ },
+ {
+ "id": "3120xx",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ -1,
+ -1,
+ 43,
+ 40,
+ 34,
+ 31
+ ],
+ "frets": [
+ -1,
+ -1,
+ 0,
+ 2,
+ 1,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 0,
+ 2,
+ 1,
+ 3
+ ],
+ "fret": 0
+ },
+ {
+ "id": "3123xx",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ -1,
+ -1,
+ 46,
+ 40,
+ 34,
+ 31
+ ],
+ "frets": [
+ -1,
+ -1,
+ 3,
+ 2,
+ 1,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 0,
+ 4,
+ 2,
+ 1,
+ 3
+ ],
+ "fret": 0
+ },
+ {
+ "id": "3x2330",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ 52,
+ 50,
+ 46,
+ 40,
+ -1,
+ 31
+ ],
+ "frets": [
+ 0,
+ 3,
+ 3,
+ 2,
+ -1,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 4,
+ 3,
+ 1,
+ 0,
+ 2
+ ],
+ "fret": 0
+ },
+ {
+ "id": "3x0330",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ 52,
+ 50,
+ 46,
+ 38,
+ -1,
+ 31
+ ],
+ "frets": [
+ 0,
+ 3,
+ 3,
+ 0,
+ -1,
+ 3
+ ],
+ "fingers": [
+ 0,
+ 3,
+ 2,
+ 0,
+ 0,
+ 1
+ ],
+ "fret": 3
+ },
+ {
+ "id": "x10x91110",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ 62,
+ 58,
+ 52,
+ -1,
+ 43,
+ -1
+ ],
+ "frets": [
+ 10,
+ 11,
+ 9,
+ -1,
+ 10,
+ -1
+ ],
+ "fingers": [
+ 3,
+ 4,
+ 1,
+ 0,
+ 2,
+ 0
+ ],
+ "fret": 9
+ },
+ {
+ "id": "x10x91112",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ 64,
+ 58,
+ 52,
+ -1,
+ 43,
+ -1
+ ],
+ "frets": [
+ 12,
+ 11,
+ 9,
+ -1,
+ 10,
+ -1
+ ],
+ "fingers": [
+ 4,
+ 3,
+ 1,
+ 0,
+ 2,
+ 0
+ ],
+ "fret": 9
+ },
+ {
+ "id": "x10x121112",
+ "listCapos": [],
+ "noteIndex": 7,
+ "notes": [
+ 64,
+ 58,
+ 55,
+ -1,
+ 43,
+ -1
+ ],
+ "frets": [
+ 12,
+ 11,
+ 12,
+ -1,
+ 10,
+ -1
+ ],
+ "fingers": [
+ 4,
+ 2,
+ 3,
+ 0,
+ 1,
+ 0
+ ],
+ "fret": 10
+ }
+ ]
+ }
+ ],
+ "content": "Title: Even If\r\nArtist: Sam Concepcion\r\nAlbum:\r\nTabbed by: Mark Jesson Galera\r\n\r\nFor any comments and Suggestion:\r\n\r\nText Me: 09198408399\r\nFriendster: boybassista@yahoo.com\r\n\r\nThis A new Song of Sam Concepcion to his album..Im 90% sure in this chords..\r\n Please Support all my tabs!..\r\n\r\nIntro: [ch]Bb[/ch]-[ch]F[/ch]-[ch]Gm[/ch]-[ch]F[/ch]\r\n [ch]Eb[/ch]-[ch]Bb[/ch]-[ch]Cm[/ch]-[ch]F[/ch]\r\n\r\nChorus 1:\r\n[tab] [ch]Bb[/ch] [ch]F[/ch]\r\nEven if the sun refused to shine[/tab]\r\n[tab] [ch]Gm[/ch] [ch]F[/ch]\r\nEven if we lived in different times[/tab]\r\n[tab] [ch]Eb[/ch] [ch]Bb[/ch]\r\nEven if the ocean left the sea[/tab]\r\n[tab] [ch]Cm[/ch] [ch]F[/ch]\r\nThere would still be you and me[/tab]\r\n[tab] [ch]Bb[/ch] [ch]F[/ch]\r\nEver since the start of time[/tab]\r\n[tab] [ch]Gm[/ch] [ch]Eb[/ch]\r\nYou've had my love (oh yeah)[/tab]\r\n[tab] [ch]Bb[/ch] [ch]F[/ch]\r\nEven before I knew your name[/tab]\r\n[tab] [ch]Gm[/ch] [ch]Eb[/ch]\r\nI knew your heart (oh girl)[/tab]\r\n[tab] [ch]Gm[/ch] [ch]Gm[/ch]+M7\r\nIn the dark of the darkest night[/tab]\r\n[tab] [ch]Gm7[/ch] [ch]Gm6[/ch]\r\nI can see your face (yeah)[/tab]\r\n[tab] [ch]Cm[/ch] [ch]Bb[/ch]\r\nI always knew from the very start [/tab]\r\n[tab] [ch]Cm[/ch]\r\nI would find a way[/tab]\r\n\r\nChorus 2:\r\n[tab] [ch]Bb[/ch] [ch]F[/ch]\r\nEven if the world would disappear[/tab]\r\n[tab] [ch]Gm[/ch] [ch]F[/ch]\r\nEven if the clouds would shed no tears[/tab]\r\n[tab] [ch]Eb[/ch] [ch]Bb[/ch]\r\nEven if tonight was just a dream[/tab]\r\n[tab] [ch]Cm[/ch] [ch]F[/ch]\r\nThere would be still be you and me[/tab]\r\n\r\n[tab] [ch]Bb[/ch] \r\nYou've always been there and [/tab]\r\n[tab] [ch]F[/ch]\r\nYou'll be always be[/tab]\r\n[tab] [ch]Gm[/ch] [ch]Eb[/ch]\r\nThe only one (oh yeah)[/tab]\r\n[tab] [ch]Bb[/ch] [ch]F[/ch]\r\nUntil forever you hold me girl[/tab]\r\n(until forever)\r\n[tab] [ch]Gm[/ch] [ch]Eb[/ch]\r\nI'll never knew (ohh)[/tab]\r\n[tab] [ch]Gm[/ch] [ch]Gm[/ch]+M7\r\nIn the cold of a winter's chill[/tab]\r\n[tab] [ch]Gm7[/ch] [ch]Gm6[/ch]\r\nI'll be there tomorrow( oh..)[/tab]\r\n[tab] [ch]Cm[/ch] \r\nOh girl, and here you are with me[/tab]\r\n[tab] [ch]Bb[/ch]\r\nfor all of time[/tab]\r\n[tab] [ch]Eb[/ch] [ch]F[/ch] \r\nNo matter what[/tab]\r\n(repeat chorus 1&2)\r\n\r\nBridge:\r\n[tab] [ch]Eb[/ch]\r\nTwo hearts that belong together[/tab]\r\n[tab] [ch]F[/ch]\r\nFrom the very start[/tab]\r\n[tab] [ch]Bb[/ch] [ch]F[/ch] [ch]Gm[/ch]\r\nOne love, now and forever[/tab]\r\n[tab] [ch]Eb[/ch]\r\nNothing can tear us apart[/tab]\r\n\r\n(repeat chorus 1&2)\r\n(repeat chorus 2)"
+}
\ No newline at end of file
diff --git a/app/src/main/assets/EvenIfSearchRequestResult.json b/app/src/main/assets/EvenIfSearchRequestResult.json
new file mode 100644
index 0000000..26e6161
--- /dev/null
+++ b/app/src/main/assets/EvenIfSearchRequestResult.json
@@ -0,0 +1,1276 @@
+{
+ "tabs": [
+ {
+ "id": 2456778,
+ "song_id": 1705631,
+ "song_name": "Even If",
+ "artist_name": "MercyMe",
+ "type": "Official",
+ "part": "",
+ "version": 1,
+ "votes": 40,
+ "rating": 4.86986000000000007759126674500294029712677001953125,
+ "date": "1535208303",
+ "status": "approved",
+ "preset_id": 28992,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "F",
+ "version_description": "",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1947925,
+ "song_id": 1705631,
+ "song_name": "Even If",
+ "artist_name": "MercyMe",
+ "type": "Chords",
+ "part": "",
+ "version": 1,
+ "votes": 292,
+ "rating": 4.87253999999999987124965628026984632015228271484375,
+ "date": "1487077078",
+ "status": "approved",
+ "preset_id": 28992,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": "",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1950497,
+ "song_id": 1705631,
+ "song_name": "Even If",
+ "artist_name": "MercyMe",
+ "type": "Chords",
+ "part": "",
+ "version": 2,
+ "votes": 444,
+ "rating": 4.88459000000000020946799850207753479480743408203125,
+ "date": "1487600448",
+ "status": "approved",
+ "preset_id": 28992,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": "",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1975529,
+ "song_id": 1705631,
+ "song_name": "Even If",
+ "artist_name": "MercyMe",
+ "type": "Chords",
+ "part": "",
+ "version": 3,
+ "votes": 193,
+ "rating": 4.9102300000000003166178430547006428241729736328125,
+ "date": "1491648681",
+ "status": "approved",
+ "preset_id": 28992,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": "",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 2151711,
+ "song_id": 1705631,
+ "song_name": "Even If",
+ "artist_name": "MercyMe",
+ "type": "Chords",
+ "part": "",
+ "version": 4,
+ "votes": 0,
+ "rating": 0,
+ "date": "1505554747",
+ "status": "approved",
+ "preset_id": 28992,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": "Changed chords, using Bb as a quick passthrough to see in a few places.",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 2380223,
+ "song_id": 1705631,
+ "song_name": "Even If",
+ "artist_name": "MercyMe",
+ "type": "Chords",
+ "part": "",
+ "version": 5,
+ "votes": 0,
+ "rating": 0,
+ "date": "1525788188",
+ "status": "approved",
+ "preset_id": 28992,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "F",
+ "version_description": "Played this song via piano, so there is no capo.",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 2956520,
+ "song_id": 1705631,
+ "song_name": "Even If",
+ "artist_name": "MercyMe",
+ "type": "Chords",
+ "part": "",
+ "version": 6,
+ "votes": 0,
+ "rating": 0,
+ "date": "1578934357",
+ "status": "approved",
+ "preset_id": 28992,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "F",
+ "version_description": "",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 2084479,
+ "song_id": 1705631,
+ "song_name": "Even If",
+ "artist_name": "MercyMe",
+ "type": "Tabs",
+ "part": "intro",
+ "version": 1,
+ "votes": 10,
+ "rating": 4.72337999999999968991915011429227888584136962890625,
+ "date": "1500458782",
+ "status": "approved",
+ "preset_id": 28992,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": "",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 2382779,
+ "song_id": 1705631,
+ "song_name": "Even If",
+ "artist_name": "MercyMe",
+ "type": "Tabs",
+ "part": "",
+ "version": 1,
+ "votes": 3,
+ "rating": 4.7339999999999999857891452847979962825775146484375,
+ "date": "1526298994",
+ "status": "approved",
+ "preset_id": 28992,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": "The chorus follows the strumming pattern shown. The Bridge and Outro are simply single strum.",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1778295,
+ "song_id": 1628004,
+ "song_name": "Even If",
+ "artist_name": "Ella Eyre",
+ "type": "Chords",
+ "part": "",
+ "version": 1,
+ "votes": 14,
+ "rating": 4.61204000000000036152414395473897457122802734375,
+ "date": "1446402554",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": "",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 2678646,
+ "song_id": 1628004,
+ "song_name": "Even If",
+ "artist_name": "Ella Eyre",
+ "type": "Chords",
+ "part": "",
+ "version": 2,
+ "votes": 0,
+ "rating": 0,
+ "date": "1556480931",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "F",
+ "version_description": "As close to the original I could get.",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1145340,
+ "song_id": 301818,
+ "song_name": "Even If",
+ "artist_name": "Kutless",
+ "type": "Chords",
+ "part": "",
+ "version": 1,
+ "votes": 16,
+ "rating": 3.863370000000000192841298485291190445423126220703125,
+ "date": "1334102401",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": "",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1373397,
+ "song_id": 301818,
+ "song_name": "Even If",
+ "artist_name": "Kutless",
+ "type": "Ukulele Chords",
+ "part": "",
+ "version": 1,
+ "votes": 0,
+ "rating": 0,
+ "date": "1334102401",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 137298,
+ "song_id": 49676,
+ "song_name": "Even If",
+ "artist_name": "The Corrs",
+ "type": "Chords",
+ "part": "",
+ "version": 1,
+ "votes": 0,
+ "rating": 0,
+ "date": "1095292800",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 177681,
+ "song_id": 49676,
+ "song_name": "Even If",
+ "artist_name": "The Corrs",
+ "type": "Chords",
+ "part": "",
+ "version": 2,
+ "votes": 0,
+ "rating": 0,
+ "date": "1113177600",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1276134,
+ "song_id": 49676,
+ "song_name": "Even If",
+ "artist_name": "The Corrs",
+ "type": "Ukulele Chords",
+ "part": "",
+ "version": 1,
+ "votes": 0,
+ "rating": 0,
+ "date": "1113177600",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 639362,
+ "song_id": 203686,
+ "song_name": "Even If",
+ "artist_name": "2BE3",
+ "type": "Chords",
+ "part": "",
+ "version": 1,
+ "votes": 0,
+ "rating": 0,
+ "date": "1202860801",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1304474,
+ "song_id": 203686,
+ "song_name": "Even If",
+ "artist_name": "2BE3",
+ "type": "Ukulele Chords",
+ "part": "",
+ "version": 1,
+ "votes": 0,
+ "rating": 0,
+ "date": "1202860801",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 979773,
+ "song_id": 231929,
+ "song_name": "Even If",
+ "artist_name": "Crowded House",
+ "type": "Chords",
+ "part": "",
+ "version": 1,
+ "votes": 0,
+ "rating": 0,
+ "date": "1282176001",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1012057,
+ "song_id": 231929,
+ "song_name": "Even If",
+ "artist_name": "Crowded House",
+ "type": "Chords",
+ "part": "",
+ "version": 2,
+ "votes": 0,
+ "rating": 0,
+ "date": "1292371201",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1342638,
+ "song_id": 231929,
+ "song_name": "Even If",
+ "artist_name": "Crowded House",
+ "type": "Ukulele Chords",
+ "part": "",
+ "version": 1,
+ "votes": 0,
+ "rating": 0,
+ "date": "1282176001",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 2247699,
+ "song_id": 2644469,
+ "song_name": "Even If",
+ "artist_name": "Jocelyn Enriquez",
+ "type": "Chords",
+ "part": "",
+ "version": 1,
+ "votes": 0,
+ "rating": 0,
+ "date": "1512122489",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": "",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 138662,
+ "song_id": 154354,
+ "song_name": "Even If",
+ "artist_name": "Lea Salonga",
+ "type": "Chords",
+ "part": "",
+ "version": 1,
+ "votes": 0,
+ "rating": 0,
+ "date": "1096156800",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1273303,
+ "song_id": 154354,
+ "song_name": "Even If",
+ "artist_name": "Lea Salonga",
+ "type": "Ukulele Chords",
+ "part": "",
+ "version": 1,
+ "votes": 0,
+ "rating": 0,
+ "date": "1096156800",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1192668,
+ "song_id": 322284,
+ "song_name": "Even If",
+ "artist_name": "Lewis Watson",
+ "type": "Chords",
+ "part": "",
+ "version": 1,
+ "votes": 17,
+ "rating": 4.4702099999999997947952579124830663204193115234375,
+ "date": "1352246401",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1427024,
+ "song_id": 322284,
+ "song_name": "Even If",
+ "artist_name": "Lewis Watson",
+ "type": "Chords",
+ "part": "",
+ "version": 2,
+ "votes": 3,
+ "rating": 4.2339999999999999857891452847979962825775146484375,
+ "date": "1382572801",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1382752,
+ "song_id": 322284,
+ "song_name": "Even If",
+ "artist_name": "Lewis Watson",
+ "type": "Ukulele Chords",
+ "part": "",
+ "version": 1,
+ "votes": 0,
+ "rating": 0,
+ "date": "1352246401",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 2248339,
+ "song_id": 89306,
+ "song_name": "Even If",
+ "artist_name": "Mojofly",
+ "type": "Chords",
+ "part": "",
+ "version": 1,
+ "votes": 0,
+ "rating": 0,
+ "date": "1512130113",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": "",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 203338,
+ "song_id": 89306,
+ "song_name": "Even If",
+ "artist_name": "Mojofly",
+ "type": "Tabs",
+ "part": "",
+ "version": 1,
+ "votes": 0,
+ "rating": 0,
+ "date": "1123200000",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": "",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1010276,
+ "song_id": 243685,
+ "song_name": "Even If",
+ "artist_name": "Psycho Trigger",
+ "type": "Pro",
+ "part": "",
+ "version": 1,
+ "votes": 0,
+ "rating": 0,
+ "date": "1291939201",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 1,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 597757,
+ "song_id": 2645413,
+ "song_name": "Even If",
+ "artist_name": "Sam Concepcion",
+ "type": "Chords",
+ "part": "",
+ "version": 1,
+ "votes": 0,
+ "rating": 0,
+ "date": "1194220801",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": "",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 2249713,
+ "song_id": 2645413,
+ "song_name": "Even If",
+ "artist_name": "Sam Concepcion",
+ "type": "Chords",
+ "part": "",
+ "version": 2,
+ "votes": 0,
+ "rating": 0,
+ "date": "1512409160",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": "",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1301974,
+ "song_id": 2645413,
+ "song_name": "Even If",
+ "artist_name": "Sam Concepcion",
+ "type": "Ukulele Chords",
+ "part": "",
+ "version": 1,
+ "votes": 0,
+ "rating": 0,
+ "date": "1194220801",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": "",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1500695,
+ "song_id": 1452355,
+ "song_name": "Even If",
+ "artist_name": "Screaming Trees",
+ "type": "Tabs",
+ "part": "intro",
+ "version": 1,
+ "votes": 0,
+ "rating": 0,
+ "date": "1404899442",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 811072,
+ "song_id": 123631,
+ "song_name": "Even If",
+ "artist_name": "The Honorary Title",
+ "type": "Chords",
+ "part": "",
+ "version": 1,
+ "votes": 0,
+ "rating": 0,
+ "date": "1239235201",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1319596,
+ "song_id": 123631,
+ "song_name": "Even If",
+ "artist_name": "The Honorary Title",
+ "type": "Ukulele Chords",
+ "part": "",
+ "version": 1,
+ "votes": 0,
+ "rating": 0,
+ "date": "1233100801",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 548809,
+ "song_id": 186347,
+ "song_name": "Even If",
+ "artist_name": "Yeng Constantino",
+ "type": "Chords",
+ "part": "",
+ "version": 1,
+ "votes": 0,
+ "rating": 0,
+ "date": "1184544001",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1298039,
+ "song_id": 186347,
+ "song_name": "Even If",
+ "artist_name": "Yeng Constantino",
+ "type": "Ukulele Chords",
+ "part": "",
+ "version": 1,
+ "votes": 0,
+ "rating": 0,
+ "date": "1184544001",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1125958,
+ "song_id": 293249,
+ "song_name": "Even If It Breaks Your Heart",
+ "artist_name": "Eli Young Band",
+ "type": "Chords",
+ "part": "",
+ "version": 1,
+ "votes": 51,
+ "rating": 4.748219999999999885176293901167809963226318359375,
+ "date": "1327449601",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1129396,
+ "song_id": 293249,
+ "song_name": "Even If It Breaks Your Heart",
+ "artist_name": "Eli Young Band",
+ "type": "Chords",
+ "part": "",
+ "version": 2,
+ "votes": 327,
+ "rating": 4.85573999999999994514610079932026565074920654296875,
+ "date": "1328659201",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1129824,
+ "song_id": 293249,
+ "song_name": "Even If It Breaks Your Heart",
+ "artist_name": "Eli Young Band",
+ "type": "Chords",
+ "part": "",
+ "version": 3,
+ "votes": 90,
+ "rating": 4.74631999999999987238652465748600661754608154296875,
+ "date": "1328832001",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": "",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1138229,
+ "song_id": 293249,
+ "song_name": "Even If It Breaks Your Heart",
+ "artist_name": "Eli Young Band",
+ "type": "Chords",
+ "part": "",
+ "version": 4,
+ "votes": 7,
+ "rating": 4.440419999999999589590515824966132640838623046875,
+ "date": "1331856001",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1214761,
+ "song_id": 293249,
+ "song_name": "Even If It Breaks Your Heart",
+ "artist_name": "Eli Young Band",
+ "type": "Chords",
+ "part": "",
+ "version": 5,
+ "votes": 6,
+ "rating": 4.37819999999999964757080306299030780792236328125,
+ "date": "1360800001",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1418034,
+ "song_id": 293249,
+ "song_name": "Even If It Breaks Your Heart",
+ "artist_name": "Eli Young Band",
+ "type": "Chords",
+ "part": "",
+ "version": 6,
+ "votes": 31,
+ "rating": 4.8354400000000001824673745431937277317047119140625,
+ "date": "1378944001",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1955507,
+ "song_id": 293249,
+ "song_name": "Even If It Breaks Your Heart",
+ "artist_name": "Eli Young Band",
+ "type": "Chords",
+ "part": "",
+ "version": 7,
+ "votes": 0,
+ "rating": 0,
+ "date": "1488329485",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": "Simplified for novice players the Fadd9 can be used in place of the F chord for easier playing if needed. The chords are placed at the start of each strum.",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1161252,
+ "song_id": 293249,
+ "song_name": "Even If It Breaks Your Heart",
+ "artist_name": "Eli Young Band",
+ "type": "Tabs",
+ "part": "solo",
+ "version": 1,
+ "votes": 5,
+ "rating": 3.925499999999999989341858963598497211933135986328125,
+ "date": "1339632001",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1235461,
+ "song_id": 293249,
+ "song_name": "Even If It Breaks Your Heart",
+ "artist_name": "Eli Young Band",
+ "type": "Tabs",
+ "part": "",
+ "version": 1,
+ "votes": 3,
+ "rating": 4.40064999999999972857267493964172899723052978515625,
+ "date": "1366848001",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": null,
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 2140579,
+ "song_id": 293249,
+ "song_name": "Even If It Breaks Your Heart",
+ "artist_name": "Eli Young Band",
+ "type": "Ukulele Chords",
+ "part": "",
+ "version": 1,
+ "votes": 4,
+ "rating": 4.7720000000000002415845301584340631961822509765625,
+ "date": "1504709439",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "",
+ "version_description": "",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 1824643,
+ "song_id": 1653206,
+ "song_name": "Even If Its A Lie",
+ "artist_name": "Matt Maltese",
+ "type": "Chords",
+ "part": "",
+ "version": 1,
+ "votes": 323,
+ "rating": 4.9245599999999996043698047287762165069580078125,
+ "date": "1458060995",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "Em",
+ "version_description": "",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ },
+ {
+ "id": 2137191,
+ "song_id": 1653206,
+ "song_name": "Even If Its A Lie",
+ "artist_name": "Matt Maltese",
+ "type": "Ukulele Chords",
+ "part": "",
+ "version": 1,
+ "votes": 20,
+ "rating": 4.71321999999999974306774674914777278900146484375,
+ "date": "1504537663",
+ "status": "approved",
+ "preset_id": 0,
+ "tab_access_type": "public",
+ "tp_version": 0,
+ "tonality_name": "D",
+ "version_description": "This version uses 7 and maj7 chords to be more true to the original. Worked out for ukulele but transferable to guitar or piano.",
+ "verified": 0,
+ "recording": {
+ "is_acoustic": 0,
+ "tonality_name": "",
+ "performance": null,
+ "recording_artists": []
+ }
+ }
+ ],
+ "artists": [
+ "MercyMe",
+ "Eli Young Band",
+ "Matt Maltese",
+ "dodie",
+ "Blink-182",
+ "Ween",
+ "Ella Eyre",
+ "Rodney Atkins",
+ "The Mamas & The Papas",
+ "Kutless",
+ "The Corrs",
+ "Bonnie 'Prince' Billy",
+ "Mike Cowart",
+ "Misc Unsigned Bands",
+ "Misc Television",
+ "Papa Roach",
+ "Yeng Constantino",
+ "Jason Aldean",
+ "We the Kings",
+ "Crowded House"
+ ]
+}
\ No newline at end of file
diff --git a/app/src/main/assets/EvenSearchSuggestions.json b/app/src/main/assets/EvenSearchSuggestions.json
new file mode 100644
index 0000000..0fdf358
--- /dev/null
+++ b/app/src/main/assets/EvenSearchSuggestions.json
@@ -0,0 +1,19 @@
+{
+ "suggestions": [
+ "even though im leaving",
+ "even flow",
+ "even if",
+ "even when it hurts",
+ "even if it breaks your heart",
+ "even so come",
+ "even if its a lie",
+ "eventually",
+ "even the losers",
+ "even flow pearl jam",
+ "even my dad does sometimes",
+ "even then",
+ "even if mercy me",
+ "even the darkness has arms",
+ "even the nights are better"
+ ]
+}
\ No newline at end of file
diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000..db6cccf
Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ
diff --git a/app/src/main/java/com/gbros/tabslite/HomeActivity.kt b/app/src/main/java/com/gbros/tabslite/HomeActivity.kt
new file mode 100644
index 0000000..282d32d
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/HomeActivity.kt
@@ -0,0 +1,143 @@
+package com.gbros.tabslite
+
+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.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.map
+import com.gbros.tabslite.data.AppDatabase
+import com.gbros.tabslite.data.DataAccess
+import com.gbros.tabslite.data.Preference
+import com.gbros.tabslite.data.ThemeSelection
+import com.gbros.tabslite.data.chord.Instrument
+import com.gbros.tabslite.data.playlist.Playlist
+import com.gbros.tabslite.data.tab.Tab
+import com.gbros.tabslite.ui.theme.AppTheme
+import com.gbros.tabslite.utilities.TAG
+import com.gbros.tabslite.utilities.UgApi
+import com.gbros.tabslite.view.playlists.PlaylistsSortBy
+import com.gbros.tabslite.view.songlist.SortBy
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+@AndroidEntryPoint
+class HomeActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge() // enabled by default on Android 15+ (API 35+), but this is for lower Android versions
+
+ val dataAccess = AppDatabase.getInstance(applicationContext).dataAccess()
+ launchInitialFetchAndSetupJobs(dataAccess)
+ val darkModePref = dataAccess.getLivePreference(Preference.APP_THEME).map { themePref ->
+ return@map ThemeSelection.valueOf(themePref?.value ?: ThemeSelection.System.name)
+ }
+
+ setContent {
+ AppTheme(theme = darkModePref.observeAsState(ThemeSelection.System).value) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(color = MaterialTheme.colorScheme.background)
+ ) {
+ TabsLiteNavGraph()
+ }
+ }
+ }
+ }
+
+ /**
+ * Launch the startup jobs for TabsLite, including pre-loading top tabs, ensuring preferences
+ * are created, and loading any tabs that the user favorited or added to a playlist, but weren't
+ * downloaded successfully at the time
+ */
+ private fun launchInitialFetchAndSetupJobs(dataAccess: DataAccess) {
+ CoroutineScope(Dispatchers.IO).launch {
+ fetchTopTabs(dataAccess)
+ }
+
+ CoroutineScope(Dispatchers.IO).launch {
+ initializeUserPreferences(dataAccess)
+ }
+
+ CoroutineScope(Dispatchers.IO).launch {
+ initializeDefaultPlaylists(dataAccess)
+ }
+
+ CoroutineScope(Dispatchers.IO).launch {
+ fetchEmptyTabsFromInternet(dataAccess)
+ }
+ }
+
+ /**
+ * fetch the most popular tabs
+ */
+ private suspend fun fetchTopTabs(dataAccess: DataAccess) {
+ try {
+ UgApi.fetchTopTabs(dataAccess)
+ Log.i(TAG, "Initial top tabs fetched successfully.")
+ } catch (ex: UgApi.NoInternetException) {
+ Log.i(TAG, "Initial top tabs fetch failed due to no internet connection.", ex)
+ } catch (ex: Exception) {
+ Log.e(TAG, "Unexpected exception during initial top tabs fetch: ${ex.message}", ex)
+ }
+ }
+
+ /**
+ * set default preferences if they aren't already set
+ */
+ private suspend fun initializeUserPreferences(dataAccess: DataAccess) {
+ dataAccess.insert(Preference(Preference.FAVORITES_SORT, SortBy.DateAdded.name))
+ dataAccess.insert(Preference(Preference.POPULAR_SORT, SortBy.Popularity.name))
+ dataAccess.insert(Preference(Preference.PLAYLIST_SORT, PlaylistsSortBy.Name.name))
+ dataAccess.insert(Preference(Preference.AUTOSCROLL_DELAY, .5f.toString()))
+ dataAccess.insert(Preference(Preference.INSTRUMENT, Instrument.Guitar.name))
+ dataAccess.insert(Preference(Preference.USE_FLATS, false.toString()))
+ dataAccess.insert(Preference(Preference.APP_THEME, ThemeSelection.System.name))
+ }
+
+ /**
+ * create favorites and popular tabs playlists if they don't exist
+ */
+ private suspend fun initializeDefaultPlaylists(dataAccess: DataAccess) {
+ dataAccess.insert(Playlist(
+ playlistId = Playlist.TOP_TABS_PLAYLIST_ID,
+ userCreated = false,
+ title = "Popular",
+ dateCreated = System.currentTimeMillis(),
+ dateModified = System.currentTimeMillis(),
+ description = "Popular tabs amongst users globally"
+ ))
+ dataAccess.insert(Playlist(
+ playlistId = Playlist.FAVORITES_PLAYLIST_ID,
+ userCreated = true,
+ title = "Favorites",
+ dateCreated = System.currentTimeMillis(),
+ dateModified = System.currentTimeMillis(),
+ description = "Your favorite tabs, stored offline for easy access"
+ ))
+ }
+
+ /**
+ * load any tabs that were added without internet connection
+ */
+ private suspend fun fetchEmptyTabsFromInternet(dataAccess: DataAccess) {
+ try {
+ Tab.fetchAllEmptyPlaylistTabsFromInternet(dataAccess)
+ } catch (ex: UgApi.NoInternetException) {
+ Log.i(TAG, "Initial empty-playlist-tab fetch failed: no internet connection", ex)
+ } catch (ex: Exception) {
+ Log.e(TAG, "Unexpected exception during inital empty-playlist-tab fetch: ${ex.message}", ex)
+ }
+
+ }
+}
+
diff --git a/app/src/main/java/com/gbros/tabslite/LoadingState.kt b/app/src/main/java/com/gbros/tabslite/LoadingState.kt
new file mode 100644
index 0000000..7e9a776
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/LoadingState.kt
@@ -0,0 +1,7 @@
+package com.gbros.tabslite
+
+sealed class LoadingState {
+ data object Loading : LoadingState()
+ data object Success : LoadingState()
+ data class Error(val messageStringRef: Int) : LoadingState()
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/RootNavHost.kt b/app/src/main/java/com/gbros/tabslite/RootNavHost.kt
new file mode 100644
index 0000000..5c4ab2a
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/RootNavHost.kt
@@ -0,0 +1,75 @@
+package com.gbros.tabslite
+
+import androidx.compose.runtime.Composable
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.rememberNavController
+import com.gbros.tabslite.view.homescreen.HOME_ROUTE
+import com.gbros.tabslite.view.homescreen.homeScreen
+import com.gbros.tabslite.view.homescreen.popUpToHome
+import com.gbros.tabslite.view.playlists.navigateToPlaylistDetail
+import com.gbros.tabslite.view.playlists.playlistDetailScreen
+import com.gbros.tabslite.view.searchresultsonglist.listSongsByArtistIdScreen
+import com.gbros.tabslite.view.searchresultsonglist.navigateToArtistIdSongList
+import com.gbros.tabslite.view.searchresultsonglist.navigateToSearch
+import com.gbros.tabslite.view.searchresultsonglist.searchByTitleScreen
+import com.gbros.tabslite.view.songversionlist.navigateToSongVersion
+import com.gbros.tabslite.view.songversionlist.songVersionScreen
+import com.gbros.tabslite.view.tabview.navigateToPlaylistEntry
+import com.gbros.tabslite.view.tabview.navigateToTab
+import com.gbros.tabslite.view.tabview.playlistEntryScreen
+import com.gbros.tabslite.view.tabview.swapToTab
+import com.gbros.tabslite.view.tabview.tabScreen
+
+/**
+ * This nav graph is a collection of all pages in the app, and has the responsibility of passing nav
+ * args between screens to keep each screen definition modular and decoupled
+ */
+@Composable
+fun TabsLiteNavGraph() {
+ val navController = rememberNavController()
+ NavHost(navController = navController, startDestination = HOME_ROUTE) {
+ homeScreen(
+ onNavigateToSearch = navController::navigateToSearch,
+ onNavigateToTab = navController::navigateToTab,
+ onNavigateToPlaylist = navController::navigateToPlaylistDetail,
+ )
+
+ tabScreen (
+ onNavigateBack = navController::popBackStack,
+ onNavigateToArtistIdSongList = navController::navigateToArtistIdSongList,
+ onNavigateToTabVersionById = navController::swapToTab
+ )
+
+ playlistEntryScreen (
+ onNavigateToPlaylistEntry = navController::navigateToPlaylistEntry,
+ onNavigateBack = navController::popBackStack,
+ onNavigateToArtistIdSongList = navController::navigateToArtistIdSongList,
+ onNavigateToTabVersionById = navController::swapToTab
+ )
+
+ playlistDetailScreen(
+ onNavigateToTabByPlaylistEntryId = navController::navigateToPlaylistEntry,
+ onNavigateBack = navController::popBackStack
+ )
+
+ searchByTitleScreen(
+ onNavigateToSongId = navController::navigateToSongVersion,
+ onNavigateToSearch = navController::navigateToSearch,
+ onNavigateToTabByTabId = navController::navigateToTab,
+ onNavigateBack = navController::popUpToHome
+ )
+
+ listSongsByArtistIdScreen(
+ onNavigateToSongId = navController::navigateToSongVersion,
+ onNavigateToSearch = navController::navigateToSearch,
+ onNavigateToTabByTabId = navController::navigateToTab,
+ onNavigateBack = navController::popUpToHome
+ )
+
+ songVersionScreen(
+ onNavigateToTabByTabId = navController::navigateToTab,
+ onNavigateToSearch = navController::navigateToSearch,
+ onNavigateBack = navController::popBackStack
+ )
+ }
+}
diff --git a/app/src/main/java/com/gbros/tabslite/TabsLiteApplication.kt b/app/src/main/java/com/gbros/tabslite/TabsLiteApplication.kt
new file mode 100644
index 0000000..ddd7a98
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/TabsLiteApplication.kt
@@ -0,0 +1,9 @@
+package com.gbros.tabslite
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class TabsLiteApplication: Application() {
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/data/AppDatabase.kt b/app/src/main/java/com/gbros/tabslite/data/AppDatabase.kt
new file mode 100644
index 0000000..63b2bee
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/AppDatabase.kt
@@ -0,0 +1,188 @@
+package com.gbros.tabslite.data
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
+import com.gbros.tabslite.data.chord.ChordVariation
+import com.gbros.tabslite.data.playlist.DataPlaylistEntry
+import com.gbros.tabslite.data.playlist.Playlist
+import com.gbros.tabslite.data.tab.TabDataType
+
+const val DATABASE_NAME = "local-tabs-db"
+
+/**
+ * The Room database for this app
+ */
+@Database(entities = [TabDataType::class, ChordVariation::class, Playlist::class, DataPlaylistEntry::class, Preference::class, SearchSuggestions::class], version = 14)
+@TypeConverters(Converters::class)
+abstract class AppDatabase : RoomDatabase() {
+ abstract fun dataAccess(): DataAccess
+
+ companion object {
+
+ // For Singleton instantiation
+ @Volatile private var instance: AppDatabase? = null
+
+ fun getInstance(context: Context): AppDatabase {
+ return instance ?: synchronized(this) {
+ instance ?: buildDatabase(context).also { instance = it }
+ }
+ }
+
+ private val MIGRATION_1_2 = object : Migration(1, 2) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE tabs ADD COLUMN transposed INTEGER NOT NULL DEFAULT 0")
+ }
+ }
+ private val MIGRATION_2_3 = object : Migration(2, 3) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("DROP TABLE garden_plantings")
+ db.execSQL("DROP TABLE plants")
+ }
+ }
+ private val MIGRATION_3_4 = object : Migration(3, 4) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("DROP TABLE chord_variation")
+ db.execSQL("CREATE TABLE IF NOT EXISTS chord_variation (id TEXT NOT NULL, chord_id TEXT NOT NULL, chord_markers TEXT NOT NULL, PRIMARY KEY(id))")
+ }
+ }
+ private val MIGRATION_4_5 = object : Migration(4, 5) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("DROP TABLE chord_variation")
+ db.execSQL("CREATE TABLE IF NOT EXISTS chord_variation (id TEXT NOT NULL, chord_id TEXT NOT NULL, note_chord_markers TEXT NOT NULL, open_chord_markers TEXT NOT NULL, muted_chord_markers TEXT NOT NULL, bar_chord_markers TEXT NOT NULL, PRIMARY KEY(id))")
+ }
+ }
+ private val MIGRATION_5_6 = object : Migration(5, 6) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE tabs ADD COLUMN favorite_time INTEGER DEFAULT NULL")
+ }
+ }
+ private val MIGRATION_6_7 = object : Migration(6, 7) {
+ // add the playlist functionality / data
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("CREATE TABLE IF NOT EXISTS playlist (id INTEGER NOT NULL, user_created INTEGER NOT NULL, title TEXT NOT NULL, date_created INTEGER NOT NULL, date_modified INTEGER NOT NULL, description TEXT NOT NULL, PRIMARY KEY(id))")
+ db.execSQL("CREATE TABLE IF NOT EXISTS playlist_entry (id INTEGER NOT NULL, playlist_id INTEGER NOT NULL, tab_id INTEGER NOT NULL, next_entry_id INTEGER, prev_entry_id INTEGER, date_added INTEGER NOT NULL, transpose INTEGER NOT NULL, PRIMARY KEY(id))")
+ }
+ }
+ private val MIGRATION_7_8 = object : Migration(7, 8) {
+ // migrate favorites over to the playlist with special ID -1
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("INSERT INTO playlist_entry (playlist_id, tab_id, next_entry_id, prev_entry_id, date_added, transpose) SELECT -1, id, NULL, NULL, favorite_time, transposed FROM tabs WHERE favorite IS 1")
+ }
+ }
+ private val MIGRATION_8_9 = object : Migration(8, 9) {
+ // rename playlist_entry.id to playlist_entry.entry_id
+ // remove unused columns from tabs table
+ override fun migrate(db: SupportSQLiteDatabase) {
+ // create new temp table
+ db.execSQL("CREATE TABLE IF NOT EXISTS playlist_entry_new (entry_id INTEGER NOT NULL, playlist_id INTEGER NOT NULL, tab_id INTEGER NOT NULL, next_entry_id INTEGER, prev_entry_id INTEGER, date_added INTEGER NOT NULL, transpose INTEGER NOT NULL, PRIMARY KEY(entry_id))")
+
+ // copy data from old table to new
+ db.execSQL("INSERT INTO playlist_entry_new (entry_id, playlist_id, tab_id, next_entry_id, prev_entry_id, date_added, transpose) SELECT id, playlist_id, tab_id, next_entry_id, prev_entry_id, date_added, transpose FROM playlist_entry")
+
+ // delete old playlist_entry table
+ db.execSQL("DROP TABLE playlist_entry")
+
+ // rename new table to playlist_entry
+ db.execSQL("ALTER TABLE playlist_entry_new RENAME TO playlist_entry")
+ }
+ }
+ private val MIGRATION_9_10 = object : Migration(9, 10) {
+ // rename playlist_entry.id to playlist_entry.entry_id
+ // remove unused columns from tabs table
+ override fun migrate(db: SupportSQLiteDatabase) {
+ // ***** drop favorite, favorite_time, and transposed columns from 'tabs' table *****
+ // Create new table with columns removed
+ db.execSQL("CREATE TABLE tabs_new (" +
+ "id INTEGER PRIMARY KEY NOT NULL," +
+ "song_id INTEGER NOT NULL DEFAULT -1," +
+ "song_name TEXT NOT NULL DEFAULT ''," +
+ "artist_name TEXT NOT NULL DEFAULT ''," +
+ "type TEXT NOT NULL DEFAULT ''," +
+ "part TEXT NOT NULL DEFAULT ''," +
+ "version INTEGER NOT NULL DEFAULT 0," +
+ "votes INTEGER NOT NULL DEFAULT 0," +
+ "rating REAL NOT NULL DEFAULT 0.0," +
+ "date INTEGER NOT NULL DEFAULT 0," +
+ "status TEXT NOT NULL DEFAULT ''," +
+ "preset_id INTEGER NOT NULL DEFAULT 0," +
+ "tab_access_type TEXT NOT NULL DEFAULT 'public'," +
+ "tp_version INTEGER NOT NULL DEFAULT 0," +
+ "tonality_name TEXT NOT NULL DEFAULT ''," +
+ "version_description TEXT NOT NULL DEFAULT ''," +
+ "verified INTEGER NOT NULL DEFAULT 0," +
+ "recording_is_acoustic INTEGER NOT NULL DEFAULT 0," +
+ "recording_tonality_name TEXT NOT NULL DEFAULT ''," +
+ "recording_performance TEXT NOT NULL DEFAULT ''," +
+ "recording_artists TEXT NOT NULL DEFAULT ''," +
+ "num_versions INTEGER NOT NULL DEFAULT 1," +
+ "recommended TEXT NOT NULL DEFAULT ''," +
+ "user_rating INTEGER NOT NULL DEFAULT 0," +
+ "difficulty TEXT NOT NULL DEFAULT 'novice'," +
+ "tuning TEXT NOT NULL DEFAULT 'E A D G B E'," +
+ "capo INTEGER NOT NULL DEFAULT 0," +
+ "url_web TEXT NOT NULL DEFAULT ''," +
+ "strumming TEXT NOT NULL DEFAULT ''," +
+ "videos_count INTEGER NOT NULL DEFAULT 0," +
+ "pro_brother INTEGER NOT NULL DEFAULT 0," +
+ "contributor_user_id INTEGER NOT NULL DEFAULT -1," +
+ "contributor_user_name TEXT NOT NULL DEFAULT ''," +
+ "content TEXT NOT NULL DEFAULT ''" +
+ ")"
+ )
+
+ // Copy the data from the old table to the new table
+ db.execSQL("INSERT INTO tabs_new SELECT " +
+ "id, song_id, song_name, artist_name, type, part, version, votes, rating, date, status, " +
+ "preset_id, tab_access_type, tp_version, tonality_name, version_description, verified, " +
+ "recording_is_acoustic, recording_tonality_name, recording_performance, recording_artists, " +
+ "num_versions, recommended, user_rating, difficulty, tuning, capo, url_web, strumming, " +
+ "videos_count, pro_brother, contributor_user_id, contributor_user_name, content " +
+ "FROM tabs"
+ )
+
+ // Drop the old table
+ db.execSQL("DROP TABLE tabs")
+
+ // Rename the new table to the original table name
+ db.execSQL("ALTER TABLE tabs_new RENAME TO tabs")
+ }
+ }
+ private val MIGRATION_10_11 = object : Migration(10, 11) {
+ // add empty user preferences table
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("CREATE TABLE preferences (name TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL)")
+ }
+ }
+ private val MIGRATION_11_12 = object : Migration(11, 12) {
+ // add empty user preferences table
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("CREATE TABLE search_suggestions (query TEXT PRIMARY KEY NOT NULL, suggested_searches TEXT NOT NULL)")
+ }
+ }
+ private val MIGRATION_12_13 = object : Migration(12, 13) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE chord_variation ADD COLUMN instrument TEXT NOT NULL DEFAULT 'Guitar'")
+ }
+ }
+ private val MIGRATION_13_14 = object : Migration(13, 14) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE tabs ADD COLUMN artist_id INTEGER NOT NULL DEFAULT 0")
+ }
+ }
+
+ // Create and pre-populate the database. See this article for more details:
+ // https://medium.com/google-developers/7-pro-tips-for-room-fbadea4bfbd1#4785
+ private fun buildDatabase(context: Context): AppDatabase {
+ return Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME)
+ .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5,
+ MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10,
+ MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14)
+ .build()
+ }
+ }
+}
diff --git a/app/src/main/java/com/gbros/tabslite/data/Converters.kt b/app/src/main/java/com/gbros/tabslite/data/Converters.kt
new file mode 100644
index 0000000..fd493db
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/Converters.kt
@@ -0,0 +1,58 @@
+package com.gbros.tabslite.data
+
+import androidx.room.TypeConverter
+import com.chrynan.chords.model.ChordMarker
+import com.google.gson.Gson
+import kotlinx.serialization.json.Json
+import java.util.*
+
+/**
+ * Type converters to allow Room to reference complex data types.
+ */
+class Converters {
+ @TypeConverter fun calendarToDatestamp(calendar: Calendar): Long = calendar.timeInMillis
+
+ @TypeConverter fun datestampToCalendar(value: Long): Calendar =
+ Calendar.getInstance().apply { timeInMillis = value }
+
+ @TypeConverter
+ fun arrayListToJson(value: ArrayList?): String = gson.toJson(value)
+
+ @TypeConverter
+ fun jsonToArrayList(value: String) = ArrayList(gson.fromJson(value, Array::class.java).toList())
+
+ // thanks https://stackoverflow.com/a/44634283/3437608
+ @TypeConverter
+ fun fromNoteMarkerSet(markers: ArrayList): String = gson.toJson(markers)
+
+ @TypeConverter
+ fun fromOpenMarkerSet(markers: ArrayList): String = gson.toJson(markers)
+
+ @TypeConverter
+ fun fromMutedMarkerSet(markers: ArrayList): String = gson.toJson(markers)
+
+ @TypeConverter
+ fun fromBarMarkerSet(markers: ArrayList): String = gson.toJson(markers)
+
+ @TypeConverter
+ fun toNoteMarkerList(value: String) = ArrayList(gson.fromJson(value, Array::class.java).toList())
+
+ @TypeConverter
+ fun toOpenMarkerList(value: String) = ArrayList(gson.fromJson(value, Array::class.java).toList())
+
+ @TypeConverter
+ fun toMutedMarkerList(value: String) = ArrayList(gson.fromJson(value, Array::class.java).toList())
+
+ @TypeConverter
+ fun toBarMarkerList(value: String) = ArrayList(gson.fromJson(value, Array::class.java).toList())
+
+ @TypeConverter
+ fun fromList(value : List?) = Json.encodeToString(value)
+
+ @TypeConverter
+ fun toList(value: String) = Json.decodeFromString>(value)
+
+ companion object {
+ private val gson = Gson()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/data/DataAccess.kt b/app/src/main/java/com/gbros/tabslite/data/DataAccess.kt
new file mode 100644
index 0000000..a93ef06
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/DataAccess.kt
@@ -0,0 +1,335 @@
+package com.gbros.tabslite.data
+
+import android.util.Log
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.map
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.RewriteQueriesToDropUnusedColumns
+import androidx.room.Transaction
+import androidx.room.Update
+import androidx.room.Upsert
+import com.gbros.tabslite.data.chord.ChordVariation
+import com.gbros.tabslite.data.chord.Instrument
+import com.gbros.tabslite.data.playlist.BrokenLinkedListException
+import com.gbros.tabslite.data.playlist.DataPlaylistEntry
+import com.gbros.tabslite.data.playlist.IDataPlaylistEntry
+import com.gbros.tabslite.data.playlist.IPlaylist
+import com.gbros.tabslite.data.playlist.IPlaylistEntry
+import com.gbros.tabslite.data.playlist.Playlist
+import com.gbros.tabslite.data.playlist.Playlist.Companion.FAVORITES_PLAYLIST_ID
+import com.gbros.tabslite.data.playlist.Playlist.Companion.TOP_TABS_PLAYLIST_ID
+import com.gbros.tabslite.data.playlist.SelfContainedPlaylist
+import com.gbros.tabslite.data.tab.Tab
+import com.gbros.tabslite.data.tab.TabDataType
+import com.gbros.tabslite.data.tab.TabWithDataPlaylistEntry
+import com.gbros.tabslite.utilities.TAG
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+/**
+ * The Data Access Object for the Tab Full class.
+ */
+@Dao
+interface DataAccess {
+ //#region tab table
+
+ @RewriteQueriesToDropUnusedColumns
+ @Query("SELECT * FROM tabs LEFT JOIN (SELECT IFNULL(transpose, null) as transpose, tab_id FROM playlist_entry WHERE playlist_id = $FAVORITES_PLAYLIST_ID) ON tab_id = id WHERE id = :tabId")
+ fun getTab(tabId: Int): LiveData
+
+ @Query("SELECT * FROM tabs WHERE id = :tabId")
+ suspend fun getTabInstance(tabId: Int): TabDataType
+
+ @RewriteQueriesToDropUnusedColumns
+ @Query("SELECT * FROM tabs INNER JOIN playlist_entry ON tabs.id = playlist_entry.tab_id LEFT JOIN (SELECT id AS playlist_id, user_created, title, date_created, date_modified, description FROM playlist ) AS playlist ON playlist_entry.playlist_id = playlist.playlist_id WHERE playlist_entry.entry_id = :playlistEntryId")
+ fun getTabFromPlaylistEntryId(playlistEntryId: Int): LiveData
+
+ @Query("SELECT DISTINCT tab_id FROM playlist_entry LEFT JOIN tabs ON tabs.id = playlist_entry.tab_id WHERE tabs.content is NULL OR tabs.content is ''")
+ suspend fun getEmptyPlaylistTabIds(): List
+
+ @Query("SELECT DISTINCT tab_id FROM playlist_entry LEFT JOIN tabs ON tabs.id = playlist_entry.tab_id WHERE playlist_entry.playlist_id = :playlistId AND (tabs.content is NULL OR tabs.content is '')")
+ suspend fun getEmptyPlaylistTabIds(playlistId: Int): List
+
+ @RewriteQueriesToDropUnusedColumns
+ @Query("SELECT * FROM tabs INNER JOIN playlist_entry ON tabs.id = playlist_entry.tab_id INNER JOIN playlist ON playlist_entry.playlist_id = playlist.id WHERE playlist_entry.playlist_id = :playlistId")
+ fun getPlaylistTabs(playlistId: Int): LiveData>
+
+ @RewriteQueriesToDropUnusedColumns
+ fun getSortedPlaylistTabs(playlistId: Int): LiveData> = getPlaylistTabs(playlistId).map { unsorted ->
+ try {
+ DataPlaylistEntry.sortLinkedList(unsorted)
+ }
+ catch (ex: BrokenLinkedListException) {
+ Log.w(TAG, "Caught broken linked list sorting playlist ${playlistId}. Attempting to recover")
+ CoroutineScope(Dispatchers.IO).launch {
+ // attempt to fix the broken linked list: clear and re-add all tabs
+ clearPlaylist(playlistId)
+ appendAll(ex.list)
+ }
+
+ // return the broken list in whatever order it's in, in an attempt to recover from the exception
+ if (ex.list.isNotEmpty() && ex.list[0] is TabWithDataPlaylistEntry) {
+ ex.list as List
+ } else {
+ listOf()
+ }
+ }
+ }
+
+ @Query("SELECT EXISTS(SELECT 1 FROM tabs WHERE id = :tabId AND content != '' LIMIT 1)")
+ suspend fun existsWithContent(tabId: Int): Boolean
+
+ @Query("SELECT *, 0 as transpose FROM tabs WHERE song_id = :songId")
+ fun getTabsBySongId(songId: Int): LiveData>
+
+ @Upsert
+ suspend fun upsert(tab: TabDataType)
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insert(tab: TabDataType)
+
+ /**
+ * Get top 7 downloaded tabs whose id, title, or artist matches the provided query
+ */
+ @Query("SELECT *, 0 as transpose FROM tabs WHERE content != '' AND (id = :query OR song_name LIKE '%' || :query || '%' OR artist_name LIKE '%' || :query || '%') LIMIT 7")
+ fun findMatchingTabs(query: String): LiveData>
+
+ //#endregion
+
+ //#region playlist table
+
+ @Query("SELECT * FROM playlist WHERE id != $FAVORITES_PLAYLIST_ID AND id != $TOP_TABS_PLAYLIST_ID")
+ fun getLivePlaylists(): LiveData>
+
+ @Query("SELECT * FROM playlist WHERE id != $FAVORITES_PLAYLIST_ID AND id != $TOP_TABS_PLAYLIST_ID")
+ suspend fun getPlaylists(): List
+
+ @Query("UPDATE playlist SET title = :newTitle WHERE id = :playlistId")
+ suspend fun updateTitle(playlistId: Int, newTitle: String)
+
+ @Query("UPDATE playlist SET description = :newDescription WHERE id = :playlistId")
+ suspend fun updateDescription(playlistId: Int, newDescription: String)
+
+ @Query("SELECT * FROM playlist WHERE id = :playlistId")
+ fun getLivePlaylist(playlistId: Int): LiveData
+
+ @Query("SELECT * FROM playlist WHERE id = :playlistId")
+ suspend fun getPlaylist(playlistId: Int): Playlist
+
+ @Upsert
+ suspend fun upsert(playlist: Playlist): Long
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insert(playlist: Playlist): Long
+
+ @Query("DELETE FROM playlist WHERE id = :playlistId")
+ suspend fun deletePlaylist(playlistId: Int)
+
+ //#endregion
+
+ //#region playlist entry table
+
+ @Query("SELECT * FROM playlist_entry WHERE playlist_id = :playlistId AND next_entry_id IS NULL")
+ suspend fun getLastEntryInPlaylist(playlistId: Int): DataPlaylistEntry?
+
+ @Query("SELECT * FROM playlist_entry WHERE entry_id = :entryId")
+ suspend fun getEntryById(entryId: Int): DataPlaylistEntry?
+
+ @Query("UPDATE playlist_entry SET next_entry_id = :nextEntryId WHERE entry_id = :thisEntryId")
+ suspend fun setNextEntryId(thisEntryId: Int?, nextEntryId: Int?)
+
+ @Query("UPDATE playlist_entry SET prev_entry_id = :prevEntryId WHERE entry_id = :thisEntryId")
+ suspend fun setPrevEntryId(thisEntryId: Int?, prevEntryId: Int?)
+
+ @Query("""
+ UPDATE playlist_entry SET next_entry_id = (CASE entry_id
+ when :srcPrv then :srcNxt
+ when :src then :destNxt
+ when :destPrv then :src
+ else next_entry_id
+ END),
+ prev_entry_id = (CASE entry_id
+ when :srcNxt then :srcPrv
+ when :src then :destPrv
+ when :destNxt then :src
+ else prev_entry_id
+ END)
+ """)
+ suspend fun moveEntry(srcPrv: Int?, srcNxt: Int?, src: Int, destPrv: Int?, destNxt: Int?)
+
+ /**
+ * Move an entry to before another entry
+ */
+ suspend fun moveEntryBefore(entry: IDataPlaylistEntry, beforeEntry: IDataPlaylistEntry) {
+ moveEntry(entry.prevEntryId, entry.nextEntryId, entry.entryId, beforeEntry.prevEntryId, beforeEntry.entryId)
+ }
+
+ /**
+ * Move an entry to after another entry
+ */
+ suspend fun moveEntryAfter(entry: IDataPlaylistEntry, afterEntry: IDataPlaylistEntry) {
+ moveEntry(entry.prevEntryId, entry.nextEntryId, entry.entryId, afterEntry.entryId, afterEntry.nextEntryId)
+ }
+
+ @Transaction
+ suspend fun removeEntryFromPlaylist(entry: IDataPlaylistEntry) {
+ if (entry.prevEntryId != null) {
+ // Update the next entry ID of the previous entry to skip the removed entry
+ setNextEntryId(entry.prevEntryId, entry.nextEntryId)
+ }
+
+ if (entry.nextEntryId != null) {
+ // Update the previous entry ID of the next entry to skip the removed entry
+ setPrevEntryId(entry.nextEntryId, entry.prevEntryId)
+ }
+
+ // Remove the entry itself
+ deleteEntry(entry.entryId)
+ }
+
+ @Update
+ fun update(entry: DataPlaylistEntry)
+
+ @Query("INSERT INTO playlist_entry (playlist_id, tab_id, next_entry_id, prev_entry_id, date_added, transpose) VALUES (:playlistId, :tabId, :nextEntryId, :prevEntryId, :dateAdded, :transpose)")
+ suspend fun insert(playlistId: Int, tabId: Int, nextEntryId: Int?, prevEntryId: Int?, dateAdded: Long, transpose: Int)
+
+ suspend fun insertToFavorites(tabId: Int, transpose: Int)
+ = insert(FAVORITES_PLAYLIST_ID, tabId, null, null, System.currentTimeMillis(), transpose)
+
+ @Transaction
+ suspend fun appendToPlaylist(playlistId: Int, tabId: Int, transpose: Int) {
+ val lastEntry = getLastEntryInPlaylist(playlistId = playlistId)
+ val newEntry = DataPlaylistEntry(entryId = 0, playlistId = playlistId, tabId = tabId, nextEntryId = null, prevEntryId = lastEntry?.entryId, dateAdded = System.currentTimeMillis(), transpose = transpose )
+ val newEntryId = insert(newEntry).toInt()
+
+ if (lastEntry != null) {
+ val updatedLastEntry = DataPlaylistEntry(entryId = lastEntry.entryId, playlistId = lastEntry.playlistId, tabId = lastEntry.tabId, nextEntryId = newEntryId, prevEntryId = lastEntry.prevEntryId, dateAdded = lastEntry.dateAdded, transpose = lastEntry.transpose)
+ update(updatedLastEntry)
+ }
+ }
+
+ @Insert(onConflict = OnConflictStrategy.ABORT)
+ suspend fun insert(entry: DataPlaylistEntry): Long
+
+ @Query("DELETE FROM playlist_entry WHERE entry_id = :entryId")
+ suspend fun deleteEntry(entryId: Int)
+
+ @Query("DELETE FROM playlist_entry WHERE playlist_id = :playlistId AND tab_id = :tabId")
+ suspend fun deleteTabFromPlaylist(tabId: Int, playlistId: Int)
+
+ suspend fun deleteTabFromFavorites(tabId: Int) = deleteTabFromPlaylist(tabId, FAVORITES_PLAYLIST_ID)
+
+ @Query("DELETE FROM playlist_entry WHERE playlist_id = :playlistId")
+ suspend fun clearPlaylist(playlistId: Int)
+
+ /**
+ * Append the tabs in the passed list to their playlist(s). Does not respect passed ordering.
+ */
+ suspend fun appendAll(playlistEntries: List) {
+ for (entry in playlistEntries) {
+ appendToPlaylist(entry.playlistId, entry.tabId, entry.transpose)
+ }
+ }
+
+ suspend fun clearTopTabsPlaylist() = clearPlaylist(TOP_TABS_PLAYLIST_ID)
+
+ @Query("SELECT * FROM playlist_entry WHERE playlist_id = :playlistId")
+ suspend fun getAllEntriesInPlaylist(playlistId: Int): List
+
+ suspend fun getSortedEntriesInPlaylist(playlistId: Int): List {
+ val allEntries = getAllEntriesInPlaylist(playlistId = playlistId)
+ try {
+ return DataPlaylistEntry.sortLinkedList(allEntries)
+ }
+ catch (ex: BrokenLinkedListException) {
+ Log.w(TAG, "Caught broken linked list getting sorted entries for playlist ${playlistId}. Attempting to recover")
+ // attempt to fix the broken linked list: clear and re-add all tabs
+ clearPlaylist(playlistId)
+ appendAll(ex.list)
+
+ // return the broken list in whatever order it's in, in an attempt to recover from the exception
+ return ex.list
+ }
+ }
+
+ suspend fun getSelfContainedPlaylists(playlists: List): List {
+ val selfContainedPlaylists: MutableList = mutableListOf()
+ for (playlist in playlists) {
+ selfContainedPlaylists.add(SelfContainedPlaylist(playlist, getSortedEntriesInPlaylist(playlist.playlistId)))
+ }
+
+ return selfContainedPlaylists
+ }
+
+ @Query("SELECT EXISTS(SELECT * FROM playlist_entry WHERE playlist_id = $FAVORITES_PLAYLIST_ID AND tab_id = :tabId)")
+ fun tabExistsInFavoritesLive(tabId: Int): LiveData
+
+ @Query("SELECT EXISTS(SELECT * FROM playlist_entry as favorites INNER JOIN (SELECT * FROM playlist_entry WHERE entry_id = :entryId) AS source ON source.tab_id = favorites.tab_id WHERE favorites.playlist_id = $FAVORITES_PLAYLIST_ID)")
+ fun playlistEntryExistsInFavorites(entryId: Int): LiveData
+
+ @Query("SELECT EXISTS(SELECT * FROM playlist_entry WHERE playlist_id = $FAVORITES_PLAYLIST_ID AND tab_id = :tabId)")
+ suspend fun tabExistsInFavorites(tabId: Int): Boolean
+
+ @Query("UPDATE playlist_entry SET transpose = :transpose WHERE playlist_id = $FAVORITES_PLAYLIST_ID AND tab_id = :tabId")
+ suspend fun updateFavoriteTabTransposition(tabId: Int, transpose: Int)
+
+ @Query("UPDATE playlist_entry SET transpose = :transpose WHERE entry_id = :entryId")
+ suspend fun updateEntryTransposition(entryId: Int, transpose: Int)
+
+ //#endregion
+
+ //#region chord variation table
+
+ @Query("SELECT * FROM chord_variation WHERE chord_id = :chordId AND instrument = :instrument")
+ suspend fun getChordVariations(chordId: String, instrument: Instrument): List
+
+ @Query("SELECT * FROM chord_variation WHERE chord_id = :chordId AND instrument = :instrument")
+ fun chordVariations(chordId: String, instrument: Instrument): LiveData>
+
+ @Query("SELECT DISTINCT chord_id FROM chord_variation WHERE chord_id IN (:chordIds) AND instrument = :instrument")
+ suspend fun findAll(chordIds: List, instrument: Instrument): List
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertAll(chords: List)
+
+ //#endregion
+
+ //#region preference table
+
+ @Query("SELECT * FROM preferences WHERE name = :name")
+ fun getLivePreference(name: String): LiveData
+
+ @Query("SELECT value FROM preferences WHERE name = :name")
+ suspend fun getPreferenceValue(name: String): String?
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insert(pref: Preference)
+
+ @Upsert
+ suspend fun upsert(preference: Preference)
+
+ //#endregion
+
+ //#region search suggestions table
+
+ @Upsert
+ suspend fun upsert(searchSuggestions: SearchSuggestions)
+
+ /**
+ * Gets raw search suggestion data from the database. Note that the query string must be 5
+ * characters or fewer - no search suggestions. You should probably use [getSearchSuggestions]
+ * unless you specifically need this function
+ */
+ @Query("SELECT * FROM search_suggestions WHERE `query` = :query")
+ fun getRawSearchSuggestions(query: String): LiveData
+
+ fun getSearchSuggestions(query: String): LiveData> = getRawSearchSuggestions(query.take(5)).map { s ->
+ s?.suggestedSearches?.filter { suggestion -> suggestion.contains(other = query, ignoreCase = true) } ?: listOf()
+ }
+
+ //#endregion
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/data/ISortBy.kt b/app/src/main/java/com/gbros/tabslite/data/ISortBy.kt
new file mode 100644
index 0000000..15114c6
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/ISortBy.kt
@@ -0,0 +1,4 @@
+package com.gbros.tabslite.data
+
+interface ISortBy {
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/data/Preference.kt b/app/src/main/java/com/gbros/tabslite/data/Preference.kt
new file mode 100644
index 0000000..12de5ac
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/Preference.kt
@@ -0,0 +1,63 @@
+package com.gbros.tabslite.data
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(
+ tableName = "preferences"
+)
+
+/**
+ * Store user preferences by name in the local database.
+ */
+data class Preference(
+ /**
+ * The name of the preference. Usually stored in the Constants class.
+ */
+ @PrimaryKey @ColumnInfo(name = "name") var name: String,
+
+ /**
+ * The preference value (e.g. "true" or "a-z")
+ */
+ @ColumnInfo(name = "value") var value: String = "",
+) {
+
+ companion object {
+ /**
+ * The preference name for the user preference of which order the favorites playlist should
+ * be ordered in
+ */
+ const val FAVORITES_SORT: String = "FAVORITES_SORT"
+
+ /**
+ * The preference name for which order the popular tabs playlist should be ordered in
+ */
+ const val POPULAR_SORT: String = "POPULAR_SORT"
+
+ /**
+ * The preference name for which order the user-created playlists should be sorted in
+ */
+ const val PLAYLIST_SORT: String = "PLAYLIST_SORT"
+
+ /**
+ * The preference name for the delay in ms between 1px scrolls during autoscroll
+ */
+ const val AUTOSCROLL_DELAY: String = "AUTOSCROLL_DELAY"
+
+ /**
+ * The preference name for which instrument to display chords for
+ */
+ const val INSTRUMENT: String = "INSTRUMENT"
+
+ /**
+ * The preference name for whether to use the flats forms of chords vs sharps
+ */
+ const val USE_FLATS: String = "USE_FLATS"
+
+ /**
+ * The preference name for the [ThemeSelection] to use
+ */
+ const val APP_THEME: String = "APP_THEME"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/data/Search.kt b/app/src/main/java/com/gbros/tabslite/data/Search.kt
new file mode 100644
index 0000000..7d6a209
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/Search.kt
@@ -0,0 +1,108 @@
+package com.gbros.tabslite.data
+
+import android.util.Log
+import com.gbros.tabslite.data.tab.ITab
+import com.gbros.tabslite.data.tab.Tab
+import com.gbros.tabslite.utilities.TAG
+import com.gbros.tabslite.utilities.UgApi
+
+/**
+ * Represents a search session with one search query. Gets search results and provides a method to
+ * retrieve more search results if the first page isn't enough.
+ */
+class Search(
+ /**
+ * The query currently being searched for (in the title field)
+ */
+ private var query: String,
+
+ /**
+ * (Optional) the ID of the artist to filter by. Can be paired with an empty [query] to do an artist song list. Ignored if null or 0.
+ */
+ private var artistId: Int?,
+
+ /**
+ * The data access object interface into the data layer, for caching results and returning cached results
+ */
+ private val dataAccess: DataAccess
+) {
+
+ //#region private data
+
+ /**
+ * The most recently fetched search page
+ */
+ private var currentSearchPage = 0
+
+ //#endregion
+
+ //#region private methods
+
+ /**
+ * Perform a search using UgApi, and update the class variables with the results. Always searches
+ * for the query set in the class (but updates that query if we run out of results and have a Did
+ * You Mean option). Always searches for the next page of values (multiple calls does not mess
+ * this up). Only performs one search at a time; multiple calls to this function will load multiple
+ * pages of search results.
+ *
+ * @param [page] The page of results to fetch
+ * @param [query] The query to search for
+ * @param [artistId] (Optional) Filter results by artist ID
+ *
+ * @return A list of search results, or an empty list if there are no search results
+ *
+ * @throws [SearchDidYouMeanException] if no results, but there's a suggested query
+ */
+ private suspend fun getSearchResults(page: Int, query: String, artistId: Int?): List {
+ Log.d(TAG, "starting search '$query' page $page artist $artistId")
+ val searchResult = UgApi.search(query, artistId, page) // always search the next page that hasn't been loaded yet
+
+ return if (!searchResult.didYouMean.isNullOrBlank()) {
+ throw SearchDidYouMeanException(searchResult.didYouMean!!)
+ } else if (searchResult.getSongs().isEmpty()) {
+ listOf() // all search results have been fetched
+ } else {
+ // add this data to the database so we can display the individual song versions without fully loading all of them
+ for (tab in searchResult.getAllTabs()) {
+ dataAccess.insert(tab)
+ }
+
+ Log.d(TAG, "Successful search for $query page $page. Results: ${searchResult.getSongs().size}")
+ Tab.fromTabDataType(searchResult.getSongs())
+ }
+ }
+
+ //#endregion
+
+ //#region public methods
+
+ /**
+ * Get the next page of search results for this query. Automatically follows through to "Did You
+ * Mean" suggested search queries for misspelled, etc. queries.
+ *
+ * @return The next page of results, or an empty list if no further results exist, even in suggested Did You Mean queries.
+ */
+ suspend fun fetchNextSearchResults(): List {
+
+ var retriesLeft = 3
+ while (retriesLeft-- > 0) {
+ try {
+ val results = getSearchResults(page = ++currentSearchPage, artistId = artistId, query = query)
+ if (results.isEmpty()) {
+ currentSearchPage--
+ }
+ return results
+ } catch (ex: SearchDidYouMeanException) {
+ // no results, but a suggested alternate query available; automatically try that
+ currentSearchPage = 0
+ query = ex.didYouMean
+ }
+ }
+
+ // fallback to empty result list. Normally we shouldn't get here
+ Log.e(TAG, "Empty search result fallback after 3 Did You Mean tries. Shouldn't happen normally.")
+ return listOf()
+ }
+}
+
+class SearchDidYouMeanException(val didYouMean: String): Exception()
diff --git a/app/src/main/java/com/gbros/tabslite/data/SearchSuggestions.kt b/app/src/main/java/com/gbros/tabslite/data/SearchSuggestions.kt
new file mode 100644
index 0000000..2af9f6b
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/SearchSuggestions.kt
@@ -0,0 +1,24 @@
+package com.gbros.tabslite.data
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(
+ tableName = "search_suggestions"
+)
+
+/**
+ * Store suggested searches by query in the local database
+ */
+data class SearchSuggestions (
+ /**
+ * The search query that these suggestions are for
+ */
+ @PrimaryKey val query: String,
+
+ /**
+ * The list of search suggestions for this query
+ */
+ @ColumnInfo(name = "suggested_searches") val suggestedSearches: List
+)
diff --git a/app/src/main/java/com/gbros/tabslite/data/ThemeSelection.kt b/app/src/main/java/com/gbros/tabslite/data/ThemeSelection.kt
new file mode 100644
index 0000000..cd6b4c4
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/ThemeSelection.kt
@@ -0,0 +1,7 @@
+package com.gbros.tabslite.data
+
+enum class ThemeSelection {
+ System,
+ ForceDark,
+ ForceLight
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/data/chord/Chord.kt b/app/src/main/java/com/gbros/tabslite/data/chord/Chord.kt
new file mode 100644
index 0000000..897e8d3
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/chord/Chord.kt
@@ -0,0 +1,156 @@
+package com.gbros.tabslite.data.chord
+
+import android.util.Log
+import com.gbros.tabslite.data.DataAccess
+import com.gbros.tabslite.data.chord.Chord.useFlats
+import com.gbros.tabslite.utilities.TAG
+import com.gbros.tabslite.utilities.UgApi
+import kotlin.math.abs
+
+object Chord {
+ // region public methods
+
+ suspend fun ensureAllChordsDownloaded(chords: List, instrument: Instrument, dataAccess: DataAccess) {
+ // find chords that aren't in the database
+ val alreadyDownloadedChords = dataAccess.findAll(chords, instrument)
+ val chordsToDownload = chords.filter { usedChord -> !alreadyDownloadedChords.contains(usedChord) }
+
+ // download
+ if (chordsToDownload.isNotEmpty()) {
+ UgApi.updateChordVariations(chordsToDownload, dataAccess, Instrument.Guitar)
+ UgApi.updateChordVariations(chordsToDownload, dataAccess, Instrument.Ukulele)
+ }
+ }
+
+ /**
+ * Transpose one chord a specified number of steps up or down. Also converts to the correct form
+ * (flats vs sharps)
+ */
+ fun transposeChord(chord: CharSequence, halfSteps: Int, useFlats: Boolean): String {
+ val numSteps = abs(halfSteps)
+ val up = halfSteps > 0
+
+ val chordParts = chord.split('/').toTypedArray() // handle chords with a base note like G/B
+ for (i in chordParts.indices) {
+ if (chordParts[i] != "") {
+ if (up) {
+ // transpose up
+ for (j in 0 until numSteps) {
+ chordParts[i] = transposeUp(chordParts[i])
+ }
+ } else {
+ // transpose down
+ for (j in 0 until numSteps) {
+ chordParts[i] = transposeDown(chordParts[i])
+ }
+ }
+ chordParts[i] = useFlats(chordParts[i], useFlats)
+ }
+ }
+
+ return chordParts.joinToString("/")
+ }
+
+ suspend fun getChord(chord: String, instrument: Instrument, dataAccess: DataAccess) {
+ dataAccess.getChordVariations(chord, instrument).ifEmpty {
+ UgApi.updateChordVariations(listOf(chord), dataAccess, Instrument.Guitar)
+ UgApi.updateChordVariations(listOf(chord), dataAccess, Instrument.Ukulele)
+ }
+ }
+
+ // endregion
+
+ // region private methods
+
+ /**
+ * Helper function to convert chords to the correct form (flats or sharps), depending on user
+ * preference
+ *
+ * @param chordName: The chord name (e.g. A#m7 but not A#m7/G) to convert (e.g. Bbm7)
+ * @param useFlats: Whether to convert sharps to flats (true) or flats to sharps (false)
+ */
+ fun useFlats(chordName: String, useFlats: Boolean): String {
+ return when {
+ useFlats && chordName.startsWith("A#", true) -> "Bb" + chordName.substring(2)
+ !useFlats && chordName.startsWith("Bb", true) -> "A#" + chordName.substring(2)
+
+ useFlats && chordName.startsWith("C#", true) -> "Db" + chordName.substring(2)
+ !useFlats && chordName.startsWith("Db", true) -> "C#" + chordName.substring(2)
+
+ useFlats && chordName.startsWith("D#", true) -> "Eb" + chordName.substring(2)
+ !useFlats && chordName.startsWith("Eb", true) -> "D#" + chordName.substring(2)
+
+ useFlats && chordName.startsWith("F#", true) -> "Gb" + chordName.substring(2)
+ !useFlats && chordName.startsWith("Gb", true) -> "F#" + chordName.substring(2)
+
+ useFlats && chordName.startsWith("G#", true) -> "Ab" + chordName.substring(2)
+ !useFlats && chordName.startsWith("Ab", true) -> "G#" + chordName.substring(2)
+
+ else -> chordName // no change needed
+ }
+ }
+
+ /**
+ * Helper function to transpose a chord name up by one half step
+ *
+ * @param text: The chord name (e.g. A#m7) to transpose (e.g. Bm7)
+ */
+ private fun transposeUp(text: String): String {
+ return when {
+ text.startsWith("A#", true) -> "B" + text.substring(2)
+ text.startsWith("Ab", true) -> "A" + text.substring(2)
+ text.startsWith("A", true) -> "A#" + text.substring(1)
+ text.startsWith("Bb", true) -> "B" + text.substring(2)
+ text.startsWith("B", true) -> "C" + text.substring(1)
+ text.startsWith("C#", true) -> "D" + text.substring(2)
+ text.startsWith("C", true) -> "C#" + text.substring(1)
+ text.startsWith("D#", true) -> "E" + text.substring(2)
+ text.startsWith("Db", true) -> "D" + text.substring(2)
+ text.startsWith("D", true) -> "D#" + text.substring(1)
+ text.startsWith("Eb", true) -> "E" + text.substring(2)
+ text.startsWith("E", true) -> "F" + text.substring(1)
+ text.startsWith("F#", true) -> "G" + text.substring(2)
+ text.startsWith("F", true) -> "F#" + text.substring(1)
+ text.startsWith("G#", true) -> "A" + text.substring(2)
+ text.startsWith("Gb", true) -> "G" + text.substring(2)
+ text.startsWith("G", true) -> "G#" + text.substring(1)
+ else -> {
+ Log.e(TAG, "Weird Chord not transposed: $text")
+ text
+ }
+ }
+ }
+
+ /**
+ * Helper function to transpose a chord name down by one half step
+ *
+ * @param text: The chord name (e.g. A#m7) to transpose (e.g. Am7)
+ */
+ private fun transposeDown(text: String): String {
+ return when {
+ text.startsWith("A#", true) -> "A" + text.substring(2)
+ text.startsWith("Ab", true) -> "G" + text.substring(2)
+ text.startsWith("A", true) -> "G#" + text.substring(1)
+ text.startsWith("Bb", true) -> "A" + text.substring(2)
+ text.startsWith("B", true) -> "A#" + text.substring(1)
+ text.startsWith("C#", true) -> "C" + text.substring(2)
+ text.startsWith("C", true) -> "B" + text.substring(1)
+ text.startsWith("D#", true) -> "D" + text.substring(2)
+ text.startsWith("Db", true) -> "C" + text.substring(2)
+ text.startsWith("D", true) -> "C#" + text.substring(1)
+ text.startsWith("Eb", true) -> "D" + text.substring(2)
+ text.startsWith("E", true) -> "D#" + text.substring(1)
+ text.startsWith("F#", true) -> "F" + text.substring(2)
+ text.startsWith("F", true) -> "E" + text.substring(1)
+ text.startsWith("G#", true) -> "G" + text.substring(2)
+ text.startsWith("Gb", true) -> "F" + text.substring(2)
+ text.startsWith("G", true) -> "F#" + text.substring(1)
+ else -> {
+ Log.e(TAG, "Weird Chord not transposed: $text")
+ text
+ }
+ }
+ }
+
+ // endregion
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/data/chord/ChordVariation.kt b/app/src/main/java/com/gbros/tabslite/data/chord/ChordVariation.kt
new file mode 100644
index 0000000..1516421
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/chord/ChordVariation.kt
@@ -0,0 +1,44 @@
+package com.gbros.tabslite.data.chord
+
+import android.os.Parcelable
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import com.chrynan.chords.model.Chord
+import com.chrynan.chords.model.ChordMarker
+import kotlinx.parcelize.Parcelize
+import kotlinx.parcelize.RawValue
+
+/**
+ * [ChordVariation] is how to play an instance of this particular chord ([chordId]).
+ */
+@Entity(
+ tableName = "chord_variation"
+)
+
+@Parcelize
+data class ChordVariation(
+ @PrimaryKey @ColumnInfo(name = "id") val varId: String,
+ @ColumnInfo(name = "chord_id") val chordId: String,
+ @ColumnInfo(name = "note_chord_markers") val noteChordMarkers: @RawValue ArrayList,
+ @ColumnInfo(name = "open_chord_markers") val openChordMarkers: @RawValue ArrayList,
+ @ColumnInfo(name = "muted_chord_markers") val mutedChordMarkers: @RawValue ArrayList,
+ @ColumnInfo(name = "bar_chord_markers") val barChordMarkers: @RawValue ArrayList,
+ @ColumnInfo(name = "instrument") val instrument: Instrument
+) : Parcelable {
+
+ override fun toString() = varId
+
+ /**
+ * Converts this [ChordVariation] to a [com.chrynan.chords.model.Chord]
+ */
+ fun toChrynanChord(): Chord {
+ val markerSet = HashSet()
+ markerSet.addAll(noteChordMarkers)
+ markerSet.addAll(openChordMarkers)
+ markerSet.addAll(mutedChordMarkers)
+ markerSet.addAll(barChordMarkers)
+
+ return Chord(chordId, markerSet)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/data/chord/Instrument.kt b/app/src/main/java/com/gbros/tabslite/data/chord/Instrument.kt
new file mode 100644
index 0000000..df31663
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/chord/Instrument.kt
@@ -0,0 +1,6 @@
+package com.gbros.tabslite.data.chord
+
+enum class Instrument {
+ Guitar,
+ Ukulele
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/data/playlist/DataPlaylistEntry.kt b/app/src/main/java/com/gbros/tabslite/data/playlist/DataPlaylistEntry.kt
new file mode 100644
index 0000000..2402d84
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/playlist/DataPlaylistEntry.kt
@@ -0,0 +1,87 @@
+package com.gbros.tabslite.data.playlist
+
+import android.util.Log
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import com.gbros.tabslite.utilities.TAG
+
+/**
+ * [DataPlaylistEntry] represents a song in a playlist (or more than once in a playlist). Playlist ID -1
+ * is a special playlist for Favorites.
+ */
+@Entity(tableName = "playlist_entry")
+data class DataPlaylistEntry(
+ @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "entry_id") override val entryId: Int = 0,
+ @ColumnInfo(name = "playlist_id") override val playlistId: Int, // what playlist this entry is in
+ @ColumnInfo(name = "tab_id") override val tabId: Int, // which tab we added to the playlist (references TabFull tabId)
+ @ColumnInfo(name = "next_entry_id") override val nextEntryId: Int?, // the id of the next entry in this playlist
+ @ColumnInfo(name = "prev_entry_id") override val prevEntryId: Int?, // the id of the previous entry in this playlist
+ @ColumnInfo(name = "date_added") override val dateAdded: Long, // when this entry was added to the playlist
+ @ColumnInfo(name = "transpose") override var transpose: Int // each entry gets its own saved transpose number so changing the number from the favorites menu won't change every entry in every playlist.
+) : IDataPlaylistEntry(tabId, transpose, entryId, playlistId, nextEntryId, prevEntryId, dateAdded) {
+ constructor(playlistId: Int, tabId: Int, next_entry_id: Int?, prev_entry_id: Int?, dateAdded: Long, transpose: Int) : this(0, playlistId, tabId, next_entry_id, prev_entry_id, dateAdded, transpose)
+
+ constructor(playlistEntry: IDataPlaylistEntry) : this(playlistEntry.entryId, playlistEntry.playlistId, playlistEntry.tabId, playlistEntry.nextEntryId, playlistEntry.prevEntryId, playlistEntry.dateAdded, playlistEntry.transpose)
+
+ companion object {
+ fun sortLinkedList(entries: List): List {
+ val entryMap = entries.associateBy { it.entryId }
+ val sortedEntries = mutableListOf()
+
+ var currentEntry = entries.firstOrNull { it.prevEntryId == null }
+ try {
+ while (currentEntry != null) {
+ sortedEntries.add(currentEntry)
+
+ if (sortedEntries.all { usedEntry -> usedEntry.entryId != currentEntry!!.nextEntryId }) { // next entry hasn't been used yet; no circular reference
+ // set up for next iteration
+ currentEntry = entryMap[currentEntry.nextEntryId]
+ } else {
+ val errorMessage = "Error! Playlist ${currentEntry.playlistId} linked list is broken: circular reference"
+ Log.e(TAG, errorMessage)
+ throw BrokenLinkedListException(errorMessage, entries)
+ }
+ }
+ } catch (ex: OutOfMemoryError) {
+ val errorMessage = "Error! Playlist linked list is likely broken: circular reference"
+ Log.e(TAG, errorMessage, ex)
+ throw BrokenLinkedListException(errorMessage, ex, entries)
+ }
+
+ // add any remaining elements
+ if (sortedEntries.size < entries.size) {
+ val remainingEntries =
+ entries.filter { entry -> sortedEntries.all { usedEntry -> usedEntry.entryId != entry.entryId } }
+ var errorString =
+ "Error! Playlist ${entries[0].playlistId} linked list is broken. Elements remaining after list traversal:\n"
+ for (e in remainingEntries) {
+ errorString += "{playlistId: ${e.playlistId}, entryId: ${e.entryId}, nextEntryId: ${e.nextEntryId}, prevEntryId: ${e.prevEntryId}},\n"
+ }
+ Log.e(TAG, errorString)
+ sortedEntries.addAll(remainingEntries)
+ throw BrokenLinkedListException(errorString, sortedEntries)
+ }
+
+ return sortedEntries
+ }
+ }
+}
+
+/**
+ * Exception thrown when Linked List traversal fails. This could be due to circular references, a lack
+ * of a starting point, or the list being in an invalid state
+ */
+class BrokenLinkedListException : Exception {
+ /**
+ * The broken linked list in question
+ */
+ val list: List
+
+ constructor(message: String, list: List) : super(message) {
+ this.list = list
+ }
+ constructor(message: String, cause: Throwable, list: List) : super(message, cause) {
+ this.list = list
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/data/playlist/IDataPlaylistEntry.kt b/app/src/main/java/com/gbros/tabslite/data/playlist/IDataPlaylistEntry.kt
new file mode 100644
index 0000000..7e244ae
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/playlist/IDataPlaylistEntry.kt
@@ -0,0 +1,4 @@
+package com.gbros.tabslite.data.playlist
+
+open class IDataPlaylistEntry(override val tabId: Int, override val transpose: Int, open val entryId: Int, open val playlistId: Int, open val nextEntryId: Int?, open val prevEntryId: Int?, open val dateAdded: Long):
+ IPlaylistEntry(tabId, transpose)
diff --git a/app/src/main/java/com/gbros/tabslite/data/playlist/IPlaylist.kt b/app/src/main/java/com/gbros/tabslite/data/playlist/IPlaylist.kt
new file mode 100644
index 0000000..2d9e5af
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/playlist/IPlaylist.kt
@@ -0,0 +1,8 @@
+package com.gbros.tabslite.data.playlist
+
+interface IPlaylist {
+ val playlistId: Int
+ val title: String
+ val description: String
+ val userCreated: Boolean
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/data/playlist/IPlaylistEntry.kt b/app/src/main/java/com/gbros/tabslite/data/playlist/IPlaylistEntry.kt
new file mode 100644
index 0000000..49e88f5
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/playlist/IPlaylistEntry.kt
@@ -0,0 +1,9 @@
+package com.gbros.tabslite.data.playlist
+
+import kotlinx.serialization.Serializable
+
+/**
+ * A playlist entry with enough information to reference the tab, but no ordering information. Used for data import and export.
+ */
+@Serializable
+open class IPlaylistEntry(open val tabId: Int, open val transpose: Int)
diff --git a/app/src/main/java/com/gbros/tabslite/data/playlist/Playlist.kt b/app/src/main/java/com/gbros/tabslite/data/playlist/Playlist.kt
new file mode 100644
index 0000000..58172f0
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/playlist/Playlist.kt
@@ -0,0 +1,63 @@
+package com.gbros.tabslite.data.playlist
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import com.gbros.tabslite.data.playlist.Playlist.Companion.FAVORITES_PLAYLIST_ID
+import com.gbros.tabslite.data.playlist.Playlist.Companion.TOP_TABS_PLAYLIST_ID
+import kotlinx.serialization.Serializable
+
+/**
+ * [Playlist] represents any playlists the user may have on the device. Playlist ID -1
+ * ([FAVORITES_PLAYLIST_ID]) and -2 ([TOP_TABS_PLAYLIST_ID]) are reserved special playlists
+ * for favorite/popular tabs. That playlist doesn't have an entry in the playlist table so that it
+ * doesn't show up in the playlists view, however entries are still found by ID in the
+ * playlist_entry database.
+ */
+@Serializable
+@Entity(tableName = "playlist")
+data class Playlist(
+ /**
+ * The identifier for this playlist
+ */
+ @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") override val playlistId: Int = 0,
+
+ /**
+ * Whether this playlist was system generated (false, e.g. the Favorites playlist) or user created (true).
+ */
+ @ColumnInfo(name = "user_created") override val userCreated: Boolean,
+
+ /**
+ * The human-readable title of this playlist
+ */
+ @ColumnInfo(name = "title") override val title: String,
+
+ /**
+ * The date/time this playlist was created in milliseconds ([System.currentTimeMillis])
+ */
+ @ColumnInfo(name = "date_created") val dateCreated: Long,
+
+ /**
+ * The date/time this playlist was last modified in milliseconds ([System.currentTimeMillis]). Can be used for sorting
+ */
+ @ColumnInfo(name = "date_modified") val dateModified: Long,
+
+ /**
+ * The human-readable description of this playlist
+ */
+ @ColumnInfo(name = "description") override val description: String
+): IPlaylist {
+ override fun toString() = title
+
+ companion object {
+ /**
+ * The reserved playlist ID for the Favorites system playlist
+ */
+ const val FAVORITES_PLAYLIST_ID = -1
+
+ /**
+ * The reserved playlist ID for the Popular/Top Tabs system playlist
+ */
+ const val TOP_TABS_PLAYLIST_ID = -2
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/data/playlist/PlaylistFileExportType.kt b/app/src/main/java/com/gbros/tabslite/data/playlist/PlaylistFileExportType.kt
new file mode 100644
index 0000000..69c7b4f
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/playlist/PlaylistFileExportType.kt
@@ -0,0 +1,6 @@
+package com.gbros.tabslite.data.playlist
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class PlaylistFileExportType(val playlists: List)
diff --git a/app/src/main/java/com/gbros/tabslite/data/playlist/SelfContainedPlaylist.kt b/app/src/main/java/com/gbros/tabslite/data/playlist/SelfContainedPlaylist.kt
new file mode 100644
index 0000000..51a6adf
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/playlist/SelfContainedPlaylist.kt
@@ -0,0 +1,44 @@
+package com.gbros.tabslite.data.playlist
+
+import com.gbros.tabslite.data.DataAccess
+import com.gbros.tabslite.data.tab.Tab
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SelfContainedPlaylist(
+ override val playlistId: Int,
+ override val title: String,
+ override val description: String,
+ override val userCreated: Boolean,
+ val entries: List
+): IPlaylist {
+ constructor(playlist: IPlaylist, entries: List): this(playlistId = playlist.playlistId, title = playlist.title, description = playlist.description, userCreated = playlist.userCreated, entries = entries)
+
+ /**
+ * Imports this instance of [SelfContainedPlaylist] to the database. If this [playlistId] is equal to [Playlist.FAVORITES_PLAYLIST_ID], skips any duplicate entries
+ */
+ suspend fun importToDatabase(dataAccess: DataAccess, onProgressChange: (progress: Float) -> Unit = {}) {
+ var currentlyImportedEntries = 0f
+ if (playlistId == Playlist.FAVORITES_PLAYLIST_ID) {
+ // get current favorite tabs (to not reimport tabs that are already favorite tabs)
+ val currentFavorites = dataAccess.getAllEntriesInPlaylist(Playlist.FAVORITES_PLAYLIST_ID)
+ val entriesToImport = entries.filter { e -> currentFavorites.all { currentFav -> e.tabId != currentFav.tabId } }
+ for (entry in entriesToImport) { // don't double-import favorites
+ currentlyImportedEntries++
+ onProgressChange((currentlyImportedEntries / entriesToImport.size.toFloat()) * 0.4f) // the 0.4f constant makes the import from file part take 40% of the progress, leaving 60% for the fetch from internet below
+ dataAccess.appendToPlaylist(playlistId, entry.tabId, entry.transpose)
+ }
+ } else {
+ val newPlaylistID = dataAccess.upsert(
+ Playlist(userCreated = userCreated, title = title, dateCreated = System.currentTimeMillis(), dateModified = System.currentTimeMillis(), description = description))
+ for (entry in entries) {
+ currentlyImportedEntries++
+ onProgressChange((currentlyImportedEntries / entries.size.toFloat()) * 0.4f) // the 0.4f constant makes the import from file part take 40% of the progress, leaving 60% for the fetch from internet below
+ dataAccess.appendToPlaylist(newPlaylistID.toInt(), entry.tabId, entry.transpose)
+ }
+ }
+
+ // ensure all entries are downloaded locally
+ Tab.fetchAllEmptyPlaylistTabsFromInternet(dataAccess, playlistId) { progress -> onProgressChange(0.4f + (progress * 0.6f)) } // 0.4f is the progress already taken above, 0.6f makes this step take 60% of the progress
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/data/servertypes/SearchRequestType.kt b/app/src/main/java/com/gbros/tabslite/data/servertypes/SearchRequestType.kt
new file mode 100644
index 0000000..4d902d9
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/servertypes/SearchRequestType.kt
@@ -0,0 +1,135 @@
+package com.gbros.tabslite.data.servertypes
+
+import com.gbros.tabslite.data.tab.TabDataType
+
+class SearchRequestType(private var tabs: List, private var artists: List){
+ class SearchResultTab(var id: Int, var song_id: Int, var song_name: String, val artist_id: Int, var artist_name: String,
+ var type: String = "", var part: String = "", var version: Int = 0, var votes: Int = 0,
+ var rating: Double = 0.0, var date: String = "", var status: String = "", var preset_id: Int = 0,
+ var tab_access_type: String = "", var tp_version: Int = 0, var tonality_name: String = "",
+ val version_description: String? = "", var verified: Int = 0,
+ val recording: TabRequestType.RecordingInfo?
+ ) {
+ fun tabFull(): TabDataType {
+ val dateToUse = if (date.isNullOrEmpty()) 0 else date.toInt()
+ val versionDscToUse = if (version_description.isNullOrEmpty()) "" else version_description
+ val recordingAcoustic = if (recording != null) recording.is_acoustic == 1 else false
+ val recordingTonality = recording?.tonality_name ?: ""
+ val recordingPerformance = recording?.performance.toString()
+ val recordingArtists = recording?.getArtists() ?: ArrayList()
+
+ return TabDataType(
+ tabId = id,
+ songId = song_id,
+ songName = song_name,
+ artistName = artist_name,
+ artistId = artist_id,
+ type = type,
+ part = part,
+ version = version,
+ votes = votes,
+ rating = rating,
+ date = dateToUse,
+ status = status,
+ presetId = preset_id,
+ tabAccessType = tab_access_type,
+ tpVersion = tp_version,
+ tonalityName = tonality_name,
+ versionDescription = versionDscToUse,
+ isVerified = verified == 1,
+ recordingIsAcoustic = recordingAcoustic,
+ recordingTonalityName = recordingTonality,
+ recordingPerformance = recordingPerformance,
+ recordingArtists = recordingArtists
+ )
+ }
+ }
+ // region public data
+
+ var didYouMean: String? = null
+
+ // endregion
+
+ constructor(didYouMean: String = "") : this(ArrayList(), ArrayList()) {
+ this.didYouMean = didYouMean
+ }
+
+ // region private data
+
+ private lateinit var songs: LinkedHashMap> // songId, List
+ private lateinit var tabFulls: HashMap // tabId, TabBasic
+
+ // endregion
+
+ // region public methods
+
+ fun getAllTabs(): List {
+ return tabFulls.values.toList()
+ }
+
+ fun getSongs(): List {
+ initTabs()
+ val result: ArrayList = ArrayList()
+
+ for(tabIdList in songs.values){
+ result.add(tabFulls[tabIdList.first()]!!) // add the first tab for each song
+ }
+ return result
+ }
+
+ // endregion
+
+ // region private methods
+
+ private fun initSongs() {
+ if(::songs.isInitialized) {
+ return
+ }
+
+ songs = LinkedHashMap()
+ indexNewSongs(tabs)
+ }
+ private fun initTabs() {
+ if(::tabFulls.isInitialized) {
+ return
+ }
+
+ tabFulls = HashMap()
+ indexNewTabs(tabs)
+ }
+
+ private fun indexNewSongs(newTabs: List) {
+ for (tab: SearchResultTab in newTabs) {
+ if(!songs.containsKey(tab.song_id)){
+ songs.put(tab.song_id, mutableListOf())
+ }
+
+ songs[tab.song_id]!!.add(tab.id)
+ }
+ }
+ private fun indexNewTabs(newTabs: List){
+ initSongs()
+ indexNewSongs(newTabs)
+
+ val tabs: ArrayList = ArrayList()
+ for (srTab in newTabs) {
+ tabs.add(srTab.tabFull())
+ }
+
+ for (tb in tabs) {
+ if (songs[tb.songId]?.size != null){
+ tb.numVersions = songs[tb.songId]?.size!!
+ }
+ tabFulls[tb.tabId] = tb
+ }
+ }
+
+ private fun getTabIds(songId: Int): IntArray {
+ initSongs()
+
+ songs[songId]?.let { return it.toIntArray() }
+ return intArrayOf()
+ }
+
+ // endregion
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/data/servertypes/SearchSuggestionType.kt b/app/src/main/java/com/gbros/tabslite/data/servertypes/SearchSuggestionType.kt
new file mode 100644
index 0000000..e0e2f1f
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/servertypes/SearchSuggestionType.kt
@@ -0,0 +1,3 @@
+package com.gbros.tabslite.data.servertypes
+
+class SearchSuggestionType(var suggestions: List)
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/data/servertypes/ServerTimestampType.kt b/app/src/main/java/com/gbros/tabslite/data/servertypes/ServerTimestampType.kt
new file mode 100644
index 0000000..3e89b7c
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/servertypes/ServerTimestampType.kt
@@ -0,0 +1,13 @@
+package com.gbros.tabslite.data.servertypes
+
+import java.util.*
+
+class ServerTimestampType(var timestamp: Long) {
+ fun getServerTime(): Calendar {
+ val date = Date(timestamp * 1000L)
+ val gregorianCalendar = GregorianCalendar()
+ gregorianCalendar.time = date
+ gregorianCalendar.timeZone = TimeZone.getTimeZone("UTC")
+ return gregorianCalendar
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/data/servertypes/TabRequestType.kt b/app/src/main/java/com/gbros/tabslite/data/servertypes/TabRequestType.kt
new file mode 100644
index 0000000..ec04592
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/servertypes/TabRequestType.kt
@@ -0,0 +1,193 @@
+package com.gbros.tabslite.data.servertypes
+
+import android.util.Log
+import com.chrynan.chords.model.ChordMarker
+import com.chrynan.chords.model.Finger
+import com.chrynan.chords.model.FretNumber
+import com.chrynan.chords.model.StringNumber
+import com.gbros.tabslite.data.chord.ChordVariation
+import com.gbros.tabslite.data.chord.Instrument
+import com.gbros.tabslite.data.tab.TabDataType
+
+class TabRequestType(var id: Int, var song_id: Int, var song_name: String, var artist_id: Int, var artist_name: String, var type: String, var part: String, var version: Int, var votes: Int, var rating: Double, var date: String,
+ var status: String, var preset_id: Int, var tab_access_type: String, var tp_version: Int, var tonality_name: String, val version_description: String?, var verified: Int, val recording: RecordingInfo?,
+ var versions: List, var user_rating: Int, var difficulty: String, var tuning: String, var capo: Int, var urlWeb: String, var strumming: List, var videosCount: Int,
+ var contributor: ContributorInfo, var pros_brother: String?, var recommended: List, var applicature: List, val content: String?) {
+ class RecordingInfo(var is_acoustic: Int, var tonality_name: String, var performance: PerformanceInfo?, var recording_artists: List) {
+ class RecordingArtistsInfo(var join_field: String, var artist: ContributorInfo) {
+ override fun toString(): String {
+ return artist.username
+ }
+ }
+
+ class PerformanceInfo(var name: String, var serie: SerieInfo?, var venue: VenueInfo?, var date_start: Long, var cancelled: Int, var type: String, var comment: String, var video_urls: List) {
+ class VenueInfo(name: String, area: AreaInfo) {
+ class AreaInfo(name: String, country: CountryInfo) {
+ class CountryInfo(name_english: String)
+ }
+ }
+
+ class SerieInfo(name: String, type: String)
+
+ override fun toString(): String {
+ return "$name; $comment"
+ }
+ }
+
+ fun getArtists(): ArrayList {
+ val result = ArrayList()
+ for (artist in recording_artists) {
+ result.add(artist.toString())
+ }
+ return result
+ }
+ }
+
+ class VersionInfo(
+ var id: Int, var song_id: Int, var song_name: String, var artist_name: String, var type: String, var part: String, var version: Int, var votes: Int, var rating: Double, var date: String, var status: String, var preset_id: Int,
+ var tab_access_type: String, var tp_version: Int, var tonality_name: String, var version_description: String, var verified: Int, var recording: RecordingInfo)
+
+ class ContributorInfo(var user_id: Int, var username: String)
+ class ChordInfo(var chord: String, var variations: List) {
+ class VarInfo(
+ var id: String, var listCapos: List, var noteIndex: Int, var notes: List, var frets: List, var fingers: List, var fret: Int) {
+ class CapoInfo(var fret: Int, var startString: Int, var lastString: Int, var finger: Int)
+
+ private fun Int.toFinger(): Finger {
+ return when (this) {
+ 1 -> Finger.INDEX
+ 2 -> Finger.MIDDLE
+ 3 -> Finger.RING
+ 4 -> Finger.PINKY
+ 5 -> Finger.THUMB
+ else -> Finger.UNKNOWN
+ }
+ }
+
+ fun toChordVariation(chordName: String, instrument: Instrument): ChordVariation {
+ val noteMarkerSet = ArrayList()
+ val openMarkerSet = ArrayList()
+ val mutedMarkerSet = ArrayList()
+ val barMarkerSet = ArrayList()
+
+ for ((string, fretNumber) in frets.withIndex()) {
+ when {
+ fretNumber > 0 -> {
+ val finger = fingers[string]
+ if (finger.toFinger() != Finger.UNKNOWN) {
+ noteMarkerSet.add(
+ ChordMarker.Note(
+ fret = FretNumber(fretNumber),
+ string = StringNumber(string + 1),
+ finger = finger.toFinger()
+ )
+ )
+ } else {
+ //Log.e(javaClass.simpleName, "Chord variation with fret number > 0 (fret= $fretNumber), but no finger (finger= $finger). This shouldn't happen. String= $string, chordName= $chordName")
+ // this is all the barred notes. We can ignore it since we take care of bars below.
+ }
+ }
+
+ fretNumber == 0 -> {
+ openMarkerSet.add(ChordMarker.Open(StringNumber(string + 1)))
+ } // open string
+ else -> {
+ mutedMarkerSet.add(ChordMarker.Muted(StringNumber(string + 1)))
+ } // muted string
+ }
+ }
+
+ for (bar in listCapos) {
+ val myMarker = ChordMarker.Bar(
+ fret = FretNumber(bar.fret),
+ startString = StringNumber(bar.startString + 1),
+ endString = StringNumber(bar.lastString + 1),
+ finger = bar.finger.toFinger()
+ )
+ barMarkerSet.add(myMarker)
+ }
+
+ return ChordVariation(
+ varId = id.lowercase(), chordId = chordName,
+ noteChordMarkers = noteMarkerSet, openChordMarkers = openMarkerSet,
+ mutedChordMarkers = mutedMarkerSet, barChordMarkers = barMarkerSet,
+ instrument = instrument
+ )
+ }
+ }
+
+ fun getChordVariations(instrument: Instrument): List {
+ val result = ArrayList()
+ for (variation in variations) {
+ result.add(variation.toChordVariation(chord, instrument))
+ }
+
+ return result
+ }
+ }
+
+ class StrummingInfo(
+ var part: String,
+ var denuminator: Int,
+ var bpm: Int,
+ var is_triplet: Int,
+ var measures: List
+ ) {
+ class MeasureInfo(var measure: Int)
+ }
+
+ fun getTabFull(): TabDataType {
+ val tab = TabDataType(
+ tabId = id,
+ songId = song_id,
+ songName = song_name,
+ artistName = artist_name,
+ artistId = artist_id,
+ type = type,
+ part = part,
+ version = version,
+ votes = votes,
+ rating = rating.toDouble(),
+ date = date.toInt(),
+ status = status,
+ presetId = preset_id,
+ tabAccessType = tab_access_type,
+ tpVersion = tp_version,
+ tonalityName = tonality_name,
+ isVerified = (verified != 0),
+ contributorUserId = contributor.user_id,
+ contributorUserName = contributor.username,
+ capo = capo
+ )
+
+ if (version_description != null) {
+ tab.versionDescription = version_description
+ } else {
+ tab.versionDescription = ""
+ }
+
+ if (recording != null) {
+ tab.recordingIsAcoustic = (recording.is_acoustic != 0)
+ tab.recordingPerformance = recording.performance.toString()
+ tab.recordingTonalityName = recording.tonality_name
+ tab.recordingArtists = recording.getArtists()
+ } else {
+ tab.recordingIsAcoustic = false
+ tab.recordingPerformance = ""
+ tab.recordingTonalityName = ""
+ tab.recordingArtists = ArrayList(emptyList())
+ }
+
+ if (content != null) {
+ tab.content = content
+ } else {
+ tab.content = "NO TAB CONTENT - Official tab?"
+ Log.w(
+ javaClass.simpleName,
+ "Warning: tab content is empty for id $id. This is strange. Could be an official tab."
+ )
+ }
+
+ return tab
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/data/tab/ITab.kt b/app/src/main/java/com/gbros/tabslite/data/tab/ITab.kt
new file mode 100644
index 0000000..6313064
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/tab/ITab.kt
@@ -0,0 +1,102 @@
+package com.gbros.tabslite.data.tab
+
+import android.content.Context
+import com.gbros.tabslite.R
+import com.gbros.tabslite.data.DataAccess
+
+private const val LOG_NAME = "tabslite.ITab "
+
+interface ITab {
+ val tabId: Int
+ val type: String
+ val part: String
+ val version: Int
+ val votes: Int
+ val rating: Double
+ val date: Int
+ val status: String
+ val presetId: Int
+ val tabAccessType: String
+ val tpVersion: Int
+
+ /**
+ * The key of the song (e.g. key of 'Am')
+ */
+ var tonalityName: String
+ val versionDescription: String
+
+ val songId: Int
+ val songName: String
+
+ /**
+ * The author of the original song (not the person who wrote up these chords, that's [contributorUserName])
+ */
+ val artistName: String
+ val artistId: Int
+ val isVerified: Boolean
+ val numVersions: Int
+
+ // in JSON these are in a separate sublevel "recording"
+ val recordingIsAcoustic: Boolean
+ val recordingTonalityName: String
+ val recordingPerformance: String
+ val recordingArtists: ArrayList
+
+ var recommended: ArrayList
+ var userRating: Int
+ var difficulty: String
+ var tuning: String
+ var capo: Int
+ var urlWeb: String
+ var strumming: ArrayList
+ var videosCount: Int
+ var proBrother: Int
+ var contributorUserId: Int
+
+ /**
+ * The author of the chord sheet (not the author of the song - that's [artistName])
+ */
+ var contributorUserName: String
+ var content: String
+
+ val transpose: Int?
+
+ /**
+ * Get the human-readable capo number (ordinal numbers, i.e. 2nd Fret)
+ */
+ fun getCapoText(context: Context): String {
+ return when {
+ capo == 0 -> "None"
+ capo == 11 -> String.format(context.getString(R.string.capo_11), capo.toString()) // 11th, 12th, 13th are exceptions
+ capo == 12 -> String.format(context.getString(R.string.capo_12), capo.toString()) // 11th, 12th, 13th are exceptions
+ capo == 13 -> String.format(context.getString(R.string.capo_13), capo.toString()) // 11th, 12th, 13th are exceptions
+ capo % 10 == 1 -> String.format(context.getString(R.string.capo_number_ending_in_1), capo.toString())
+ capo % 10 == 2 -> String.format(context.getString(R.string.capo_number_ending_in_2), capo.toString())
+ capo % 10 == 3 -> String.format(context.getString(R.string.capo_number_ending_in_3), capo.toString())
+ else -> String.format(context.getString(R.string.capo_generic), capo.toString())
+ }
+ }
+
+ /**
+ * Get all the chords used in this tab. Can be used to download all the chords.
+ */
+ fun getAllChordNames(): List {
+ val chordPattern = Regex("\\[ch](.*?)\\[/ch]")
+ val allMatches = chordPattern.findAll(content)
+ val allChords = allMatches.map { matchResult -> matchResult.groupValues[1] }
+ val uniqueChords = allChords.distinct()
+ return uniqueChords.toList()
+ }
+
+ /**
+ * Ensures that the full tab (not just the partial tab loaded in the search results) is stored
+ * in the local database. Checks if [content] is empty, and if so triggers an API call to download
+ * the tab content from the internet and load it into the database.
+ *
+ * @param dataAccess: The database to load the updated tab into
+ * @param forceInternetFetch: If true, load from the internet regardless of whether we already have the tab. If false, load only if [content] is empty
+ *
+ * @return The resulting ITab, either from the local database or from the internet
+ */
+ suspend fun load(dataAccess: DataAccess, forceInternetFetch: Boolean = false): ITab
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/data/tab/Tab.kt b/app/src/main/java/com/gbros/tabslite/data/tab/Tab.kt
new file mode 100644
index 0000000..b9893af
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/tab/Tab.kt
@@ -0,0 +1,173 @@
+package com.gbros.tabslite.data.tab
+
+import android.content.res.Resources.NotFoundException
+import android.util.Log
+import androidx.room.ColumnInfo
+import androidx.room.PrimaryKey
+import com.gbros.tabslite.data.DataAccess
+import com.gbros.tabslite.utilities.TAG
+import com.gbros.tabslite.utilities.UgApi
+
+data class Tab(
+ @PrimaryKey @ColumnInfo(name = "id") override var tabId: Int,
+ @ColumnInfo(name = "song_id") override var songId: Int = -1,
+ @ColumnInfo(name = "song_name") override var songName: String = "",
+ @ColumnInfo(name = "artist_name") override var artistName: String = "",
+ @ColumnInfo(name = "artist_id") override val artistId: Int = -1,
+ @ColumnInfo(name = "type") override var type: String = "",
+ @ColumnInfo(name = "part") override var part: String = "",
+ @ColumnInfo(name = "version") override var version: Int = 0,
+ @ColumnInfo(name = "votes") override var votes: Int = 0,
+ @ColumnInfo(name = "rating") override var rating: Double = 0.0,
+ @ColumnInfo(name = "date") override var date: Int = 0,
+ @ColumnInfo(name = "status") override var status: String = "",
+ @ColumnInfo(name = "preset_id") override var presetId: Int = 0,
+ @ColumnInfo(name = "tab_access_type") override var tabAccessType: String = "public",
+ @ColumnInfo(name = "tp_version") override var tpVersion: Int = 0,
+ @ColumnInfo(name = "tonality_name") override var tonalityName: String = "",
+ @ColumnInfo(name = "version_description") override var versionDescription: String = "",
+ @ColumnInfo(name = "verified") override var isVerified: Boolean = false,
+
+ @ColumnInfo(name = "recording_is_acoustic") override var recordingIsAcoustic: Boolean = false,
+ @ColumnInfo(name = "recording_tonality_name") override var recordingTonalityName: String = "",
+ @ColumnInfo(name = "recording_performance") override var recordingPerformance: String = "",
+ @ColumnInfo(name = "recording_artists") override var recordingArtists: ArrayList = ArrayList(),
+
+ @ColumnInfo(name = "num_versions") override var numVersions: Int = 1,
+
+ @ColumnInfo(name = "recommended") override var recommended: ArrayList = ArrayList(0),
+ @ColumnInfo(name = "user_rating") override var userRating: Int = 0,
+ @ColumnInfo(name = "difficulty") override var difficulty: String = "novice",
+ @ColumnInfo(name = "tuning") override var tuning: String = "E A D G B E",
+ @ColumnInfo(name = "capo") override var capo: Int = 0,
+ @ColumnInfo(name = "url_web") override var urlWeb: String = "",
+ @ColumnInfo(name = "strumming") override var strumming: ArrayList = ArrayList(),
+ @ColumnInfo(name = "videos_count") override var videosCount: Int = 0,
+ @ColumnInfo(name = "pro_brother") override var proBrother: Int = 0,
+ @ColumnInfo(name = "contributor_user_id") override var contributorUserId: Int = -1,
+ @ColumnInfo(name = "contributor_user_name") override var contributorUserName: String = "",
+ @ColumnInfo(name = "content") override var content: String = "",
+ @ColumnInfo(name = "transpose") override var transpose: Int? = null
+): ITab {
+ //#region "static" functions
+ companion object {
+ fun fromTabDataType(dataTabs: List): List {
+ return dataTabs.map { Tab(it) }
+ }
+
+ suspend fun fetchAllEmptyPlaylistTabsFromInternet(dataAccess: DataAccess, playlistId: Int? = null, onProgressChange: (progress: Float) -> Unit = {}) {
+ val emptyTabs: List = if (playlistId == null) dataAccess.getEmptyPlaylistTabIds() else dataAccess.getEmptyPlaylistTabIds(playlistId)
+ Log.d(TAG, "Found ${emptyTabs.size} empty playlist tabs to fetch")
+ var numFetchedTabs = 0f
+ emptyTabs.forEach { tabId ->
+ try {
+ onProgressChange(++numFetchedTabs / emptyTabs.size.toFloat())
+ UgApi.fetchTabFromInternet(tabId, dataAccess)
+ } catch (ex: UgApi.NoInternetException) {
+ Log.i(TAG, "Not connected to the internet during empty tab fetch for tab $tabId for playlist $playlistId: ${ex.message}. Skipping the rest of the tabs in this playlist.")
+ throw ex // exit the fetch if we're not connected to the internet
+ } catch (ex: UgApi.UnavailableForLegalReasonsException) { // must be before catch for NotFoundException since this is a type of NotFoundException
+ Log.i(TAG, "Tab $tabId unavailable for legal reasons.")
+ } catch (ex: NotFoundException) {
+ Log.e(TAG, "Tab NOT FOUND during fetch of empty tab $tabId for playlist $playlistId")
+ } catch (ex: Exception) {
+ Log.w(TAG, "Fetch of empty tab $tabId for playlist $playlistId failed: ${ex.message}", ex)
+ }
+ }
+ onProgressChange(1f)
+ Log.i(TAG, "Done fetching ${emptyTabs.size} empty tabs")
+ }
+ }
+ //#endregion
+
+ //#region constructors
+
+ constructor(tabId: Int? = 0) : this(tabId = tabId ?: 0, songId = 0, songName = "", artistName = "", artistId = 0, isVerified = false, numVersions = 0,
+ type = "", part = "", version = 0, votes = 0, rating = 0.0, date = 0, status = "", presetId = 0, tabAccessType = "",
+ tpVersion = 0, tonalityName = "", versionDescription = "", recordingIsAcoustic = false, recordingTonalityName = "",
+ recordingPerformance = "", recordingArtists = arrayListOf(), recommended = arrayListOf(), userRating = 0, difficulty = "", tuning = "",
+ capo = 0, urlWeb = "", strumming = arrayListOf(), videosCount = 0, proBrother = 0, contributorUserId = 0, contributorUserName = "",
+ content = "")
+
+ constructor(tabFromDatabase: TabDataType) : this(tabId = tabFromDatabase.tabId, songId = tabFromDatabase.songId, songName = tabFromDatabase.songName, artistName = tabFromDatabase.artistName, artistId = tabFromDatabase.artistId, isVerified = tabFromDatabase.isVerified, numVersions = tabFromDatabase.numVersions,
+ type = tabFromDatabase.type, part = tabFromDatabase.part, version = tabFromDatabase.version, votes = tabFromDatabase.votes, rating = tabFromDatabase.rating, date = tabFromDatabase.date, status = tabFromDatabase.status, presetId = tabFromDatabase.presetId, tabAccessType = tabFromDatabase.tabAccessType,
+ tpVersion = tabFromDatabase.tpVersion, tonalityName = tabFromDatabase.tonalityName, versionDescription = tabFromDatabase.versionDescription, recordingIsAcoustic = tabFromDatabase.recordingIsAcoustic, recordingTonalityName = tabFromDatabase.recordingTonalityName,
+ recordingPerformance = tabFromDatabase.recordingPerformance, recordingArtists = tabFromDatabase.recordingArtists, recommended = tabFromDatabase.recommended, userRating = tabFromDatabase.userRating, difficulty = tabFromDatabase.difficulty, tuning = tabFromDatabase.tuning,
+ capo = tabFromDatabase.capo, urlWeb = tabFromDatabase.urlWeb, strumming = tabFromDatabase.strumming, videosCount = tabFromDatabase.videosCount, proBrother = tabFromDatabase.proBrother, contributorUserId = tabFromDatabase.contributorUserId, contributorUserName = tabFromDatabase.contributorUserName,
+ content = tabFromDatabase.content)
+
+ //#endregion
+
+ override fun toString() = "$songName by $artistName"
+
+ /**
+ * Ensures that the full tab (not just the partial tab loaded in the search results) is stored
+ * in the local database. Checks if [Tab.content] is empty, and if so triggers an API call to download
+ * the tab content from the internet and load it into the database.
+ *
+ * @param dataAccess: The database to load the updated tab into (or fetch the already downloaded tab from)
+ * @param forceInternetFetch: If true, load from the internet regardless of whether we already have the tab. If false, load only if [content] is empty
+ */
+ override suspend fun load(dataAccess: DataAccess, forceInternetFetch: Boolean): Tab {
+ val loadedTab = if (forceInternetFetch || !dataAccess.existsWithContent(tabId)) {
+ Log.d(TAG, "Fetching tab $tabId from internet (force = $forceInternetFetch)")
+ Tab(UgApi.fetchTabFromInternet(tabId = tabId, dataAccess = dataAccess))
+ } else {
+ // Cache hit for tab. Not fetching from internet.
+ Tab(dataAccess.getTabInstance(tabId))
+ }
+
+ // set our content to match the freshly loaded tab
+ set(loadedTab)
+ return this
+ }
+
+ //#region private functions
+
+ /**
+ * Set all variables of this tab to match the provided tab
+ */
+ private fun set(tab: Tab) {
+ // tab metadata
+ tabId = tab.tabId
+ songId = tab.songId
+ songName = tab.songName
+ artistName = tab.artistName
+ isVerified = tab.isVerified
+ numVersions = tab.numVersions
+ type = tab.type
+ part = tab.part
+ version = tab.version
+ versionDescription = tab.versionDescription
+ votes = tab.votes
+ rating = tab.rating
+ date = tab.date
+ status = tab.status
+ presetId = tab.presetId
+ tabAccessType = tab.tabAccessType
+ tpVersion = tab.tpVersion
+ urlWeb = tab.urlWeb
+ userRating = tab.userRating
+ difficulty = tab.difficulty
+ contributorUserId = tab.contributorUserId
+ contributorUserName = tab.contributorUserName
+
+ // tab play data
+ tonalityName = tab.tonalityName
+ tuning = tab.tuning
+ capo = tab.capo
+ content = tab.content
+ strumming = tab.strumming
+
+ // tab recording data
+ recommended = tab.recommended
+ recordingIsAcoustic = tab.recordingIsAcoustic
+ recordingTonalityName = tab.recordingTonalityName
+ recordingPerformance = tab.recordingPerformance
+ recordingArtists = tab.recordingArtists
+ videosCount = tab.videosCount
+ proBrother = tab.proBrother
+ }
+
+ //#endregion
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/data/tab/TabDataType.kt b/app/src/main/java/com/gbros/tabslite/data/tab/TabDataType.kt
new file mode 100644
index 0000000..56a591f
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/tab/TabDataType.kt
@@ -0,0 +1,53 @@
+package com.gbros.tabslite.data.tab
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+// todo: implement bpm or switch entirely over to TabRequestType
+@Entity(
+ tableName = "tabs"
+)
+
+data class TabDataType(
+ @PrimaryKey @ColumnInfo(name = "id") var tabId: Int,
+ @ColumnInfo(name = "song_id") var songId: Int = -1,
+ @ColumnInfo(name = "song_name") var songName: String = "",
+ @ColumnInfo(name = "artist_name") var artistName: String = "",
+ @ColumnInfo(name = "artist_id") var artistId: Int = -1,
+ @ColumnInfo(name = "type") var type: String = "",
+ @ColumnInfo(name = "part") var part: String = "",
+ @ColumnInfo(name = "version") var version: Int = 0,
+ @ColumnInfo(name = "votes") var votes: Int = 0,
+ @ColumnInfo(name = "rating") var rating: Double = 0.0,
+ @ColumnInfo(name = "date") var date: Int = 0,
+ @ColumnInfo(name = "status") var status: String = "",
+ @ColumnInfo(name = "preset_id") var presetId: Int = 0,
+ @ColumnInfo(name = "tab_access_type") var tabAccessType: String = "public",
+ @ColumnInfo(name = "tp_version") var tpVersion: Int = 0,
+ @ColumnInfo(name = "tonality_name") var tonalityName: String = "",
+ @ColumnInfo(name = "version_description") var versionDescription: String = "",
+ @ColumnInfo(name = "verified") var isVerified: Boolean = false,
+
+ @ColumnInfo(name = "recording_is_acoustic") var recordingIsAcoustic: Boolean = false,
+ @ColumnInfo(name = "recording_tonality_name") var recordingTonalityName: String = "",
+ @ColumnInfo(name = "recording_performance") var recordingPerformance: String = "",
+ @ColumnInfo(name = "recording_artists") var recordingArtists: ArrayList = ArrayList(),
+
+ @ColumnInfo(name = "num_versions") var numVersions: Int = 1,
+
+ @ColumnInfo(name = "recommended") var recommended: ArrayList = ArrayList(0),
+ @ColumnInfo(name = "user_rating") var userRating: Int = 0,
+ @ColumnInfo(name = "difficulty") var difficulty: String = "novice",
+ @ColumnInfo(name = "tuning") var tuning: String = "E A D G B E",
+ @ColumnInfo(name = "capo") var capo: Int = 0,
+ @ColumnInfo(name = "url_web") var urlWeb: String = "",
+ @ColumnInfo(name = "strumming") var strumming: ArrayList = ArrayList(),
+ @ColumnInfo(name = "videos_count") var videosCount: Int = 0,
+ @ColumnInfo(name = "pro_brother") var proBrother: Int = 0,
+ @ColumnInfo(name = "contributor_user_id") var contributorUserId: Int = -1,
+ @ColumnInfo(name = "contributor_user_name") var contributorUserName: String = "",
+ @ColumnInfo(name = "content") var content: String = "",
+ ) {
+ override fun toString() = "$songName by $artistName"
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/data/tab/TabWithDataPlaylistEntry.kt b/app/src/main/java/com/gbros/tabslite/data/tab/TabWithDataPlaylistEntry.kt
new file mode 100644
index 0000000..be858da
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/data/tab/TabWithDataPlaylistEntry.kt
@@ -0,0 +1,186 @@
+package com.gbros.tabslite.data.tab
+
+import android.os.Parcelable
+import androidx.room.ColumnInfo
+import com.gbros.tabslite.data.DataAccess
+import com.gbros.tabslite.data.playlist.DataPlaylistEntry
+import com.gbros.tabslite.data.playlist.IDataPlaylistEntry
+import com.gbros.tabslite.data.playlist.Playlist
+import kotlinx.parcelize.Parcelize
+
+@Parcelize // used for playlist reordering
+data class TabWithDataPlaylistEntry(
+ /**
+ * The ID of the playlist entry that represents this tab/playlist combo
+ */
+ @ColumnInfo(name = "entry_id") override var entryId: Int,
+
+ /**
+ * The ID of the playlist that this tab/playlist combo belongs to
+ */
+ @ColumnInfo(name = "playlist_id") override var playlistId: Int = 0,
+
+ /**
+ * The ID of the tab in this tab/playlist combo
+ */
+ @ColumnInfo(name = "tab_id") override var tabId: Int = 0,
+
+ /**
+ * The next entry in this playlist (if one exists, else null)
+ */
+ @ColumnInfo(name = "next_entry_id") override var nextEntryId: Int? = null,
+
+ /**
+ * The previous entry in this playlist (if one exists, else null)
+ */
+ @ColumnInfo(name = "prev_entry_id") override var prevEntryId: Int? = null,
+ @ColumnInfo(name = "date_added") override var dateAdded: Long = 0,
+ @ColumnInfo(name = "song_id") override var songId: Int = 0,
+ @ColumnInfo(name = "song_name") override var songName: String = "",
+ @ColumnInfo(name = "artist_name") override var artistName: String = "",
+ @ColumnInfo(name = "artist_id") override var artistId: Int = 0,
+ @ColumnInfo(name = "verified") override var isVerified: Boolean = false,
+ @ColumnInfo(name = "num_versions") override var numVersions: Int = 0,
+ @ColumnInfo(name = "type") override var type: String = "",
+ @ColumnInfo(name = "part") override var part: String = "",
+ @ColumnInfo(name = "version") override var version: Int = 0,
+ @ColumnInfo(name = "votes") override var votes: Int = 0,
+ @ColumnInfo(name = "rating") override var rating: Double = 0.0,
+ @ColumnInfo(name = "date") override var date: Int = 0,
+ @ColumnInfo(name = "status") override var status: String = "",
+ @ColumnInfo(name = "preset_id") override var presetId: Int = 0,
+ @ColumnInfo(name = "tab_access_type") override var tabAccessType: String = "",
+ @ColumnInfo(name = "tp_version") override var tpVersion: Int = 0,
+ @ColumnInfo(name = "tonality_name") override var tonalityName: String = "",
+ @ColumnInfo(name = "version_description") override var versionDescription: String = "",
+ @ColumnInfo(name = "recording_is_acoustic") override var recordingIsAcoustic: Boolean = false,
+ @ColumnInfo(name = "recording_tonality_name") override var recordingTonalityName: String = "",
+ @ColumnInfo(name = "recording_performance") override var recordingPerformance: String = "",
+ @ColumnInfo(name = "recording_artists") override var recordingArtists: ArrayList = arrayListOf(),
+
+ @ColumnInfo(name = "recommended") override var recommended: ArrayList = ArrayList(0),
+ @ColumnInfo(name = "user_rating") override var userRating: Int = 0,
+ @ColumnInfo(name = "difficulty") override var difficulty: String = "novice",
+ @ColumnInfo(name = "tuning") override var tuning: String = "E A D G B E",
+ @ColumnInfo(name = "capo") override var capo: Int = 0,
+ @ColumnInfo(name = "url_web") override var urlWeb: String = "",
+ @ColumnInfo(name = "strumming") override var strumming: ArrayList = ArrayList(),
+ @ColumnInfo(name = "videos_count") override var videosCount: Int = 0,
+ @ColumnInfo(name = "pro_brother") override var proBrother: Int = 0,
+ @ColumnInfo(name = "contributor_user_id") override var contributorUserId: Int = -1,
+ @ColumnInfo(name = "contributor_user_name") override var contributorUserName: String = "",
+ @ColumnInfo(name = "content") override var content: String = "",
+
+ // columns from Playlist
+ @ColumnInfo(name = "user_created") var playlistUserCreated: Boolean? = true,
+ @ColumnInfo(name = "title") var playlistTitle: String? = "",
+ @ColumnInfo(name = "date_created") var playlistDateCreated: Long? = 0,
+ @ColumnInfo(name = "date_modified") var playlistDateModified: Long? = 0,
+ @ColumnInfo(name = "description") var playlistDescription: String? = "",
+ @ColumnInfo(name = "transpose") override var transpose: Int = 0
+) : ITab, IDataPlaylistEntry(tabId = tabId, transpose = 0, entryId = entryId, playlistId = playlistId, nextEntryId = nextEntryId, prevEntryId = prevEntryId, dateAdded = dateAdded), Parcelable {
+
+ /**
+ * Ensures that the full [TabWithDataPlaylistEntry] (not just the partial tab loaded in the search results) is stored
+ * in the local database. Checks if [content] is empty, and if so triggers an API call to download
+ * the tab content from the internet and load it into the database.
+ *
+ * @param dataAccess: The database to load the updated tab into
+ * @param forceInternetFetch: If true, load from the internet regardless of whether we already have the tab. If false, load only if [content] is empty
+ *
+ * @return this object, for joining calls together
+ */
+ override suspend fun load(dataAccess: DataAccess, forceInternetFetch: Boolean): TabWithDataPlaylistEntry {
+ // fetch playlist entry
+ val loadedPlaylistEntry = dataAccess.getEntryById(entryId)
+ if (loadedPlaylistEntry == null) {
+ throw NoSuchElementException("Attempted to load a playlist entry that could not be found in the database.")
+ } else {
+ set(loadedPlaylistEntry)
+ }
+
+ // fetch playlist
+ val loadedPlaylistDetail = dataAccess.getPlaylist(playlistId)
+ set(loadedPlaylistDetail)
+
+ // fetch tab
+ val loadedTab = Tab(tabId).load(dataAccess, forceInternetFetch)
+ set(loadedTab)
+ return this
+ }
+
+ //#region private methods
+
+ /**
+ * Set all variables of this playlist entry to match the provided [playlistEntry]
+ */
+ private fun set(playlistEntry: DataPlaylistEntry) {
+ playlistId = playlistEntry.playlistId
+ entryId = playlistEntry.entryId
+ nextEntryId = playlistEntry.nextEntryId
+ prevEntryId = playlistEntry.prevEntryId
+ tabId = playlistEntry.tabId
+ dateAdded = playlistEntry.dateAdded
+ transpose = playlistEntry.transpose
+ }
+
+ /**
+ * Set all variables of this playlist to match the provided [playlistDetail]
+ */
+ private fun set(playlistDetail: Playlist) {
+ playlistId = playlistDetail.playlistId
+ playlistTitle = playlistDetail.title
+ playlistDateCreated = playlistDetail.dateCreated
+ playlistDateModified = playlistDetail.dateModified
+ playlistDescription = playlistDetail.description
+ playlistUserCreated = playlistDetail.userCreated
+ }
+
+ /**
+ * Set all variables of this tab to match the provided [tab]
+ */
+ private fun set(tab: Tab) {
+ // tab metadata
+ tabId = tab.tabId
+ songId = tab.songId
+ songName = tab.songName
+ artistName = tab.artistName
+ isVerified = tab.isVerified
+ numVersions = tab.numVersions
+ type = tab.type
+ part = tab.part
+ version = tab.version
+ versionDescription = tab.versionDescription
+ votes = tab.votes
+ rating = tab.rating
+ date = tab.date
+ status = tab.status
+ presetId = tab.presetId
+ tabAccessType = tab.tabAccessType
+ tpVersion = tab.tpVersion
+ urlWeb = tab.urlWeb
+ userRating = tab.userRating
+ difficulty = tab.difficulty
+ contributorUserId = tab.contributorUserId
+ contributorUserName = tab.contributorUserName
+
+ // tab play data
+ tonalityName = tab.tonalityName
+ tuning = tab.tuning
+ capo = tab.capo
+ content = tab.content
+ strumming = tab.strumming
+
+ // tab recording data
+ recommended = tab.recommended
+ recordingIsAcoustic = tab.recordingIsAcoustic
+ recordingTonalityName = tab.recordingTonalityName
+ recordingPerformance = tab.recordingPerformance
+ recordingArtists = tab.recordingArtists
+ videosCount = tab.videosCount
+ proBrother = tab.proBrother
+ }
+
+ //#endregion
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/ui/theme/Color.kt b/app/src/main/java/com/gbros/tabslite/ui/theme/Color.kt
new file mode 100644
index 0000000..a1aaaf8
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/ui/theme/Color.kt
@@ -0,0 +1,68 @@
+package com.gbros.tabslite.ui.theme
+import androidx.compose.ui.graphics.Color
+
+val md_theme_light_primary = Color(0xFF795900)
+val md_theme_light_onPrimary = Color(0xFFFFFFFF)
+val md_theme_light_primaryContainer = Color(0xFFFFDEA0)
+val md_theme_light_onPrimaryContainer = Color(0xFF261A00)
+val md_theme_light_secondary = Color(0xFF6C5C3F)
+val md_theme_light_onSecondary = Color(0xFFFFFFFF)
+val md_theme_light_secondaryContainer = Color(0xFFF5E0BB)
+val md_theme_light_onSecondaryContainer = Color(0xFF241A04)
+val md_theme_light_tertiary = Color(0xFF4B6546)
+val md_theme_light_onTertiary = Color(0xFFFFFFFF)
+val md_theme_light_tertiaryContainer = Color(0xFFCCEBC4)
+val md_theme_light_onTertiaryContainer = Color(0xFF082008)
+val md_theme_light_error = Color(0xFFBA1A1A)
+val md_theme_light_errorContainer = Color(0xFFFFDAD6)
+val md_theme_light_onError = Color(0xFFFFFFFF)
+val md_theme_light_onErrorContainer = Color(0xFF410002)
+val md_theme_light_background = Color(0xFFFFFBFF)
+val md_theme_light_onBackground = Color(0xFF1E1B16)
+val md_theme_light_surface = Color(0xFFFFFBFF)
+val md_theme_light_onSurface = Color(0xFF1E1B16)
+val md_theme_light_surfaceVariant = Color(0xFFEDE1CF)
+val md_theme_light_onSurfaceVariant = Color(0xFF4D4639)
+val md_theme_light_outline = Color(0xFF7F7667)
+val md_theme_light_inverseOnSurface = Color(0xFFF8EFE7)
+val md_theme_light_inverseSurface = Color(0xFF34302A)
+val md_theme_light_inversePrimary = Color(0xFFF8BD2A)
+val md_theme_light_shadow = Color(0xFF000000)
+val md_theme_light_surfaceTint = Color(0xFF795900)
+val md_theme_light_outlineVariant = Color(0xFFD0C5B4)
+val md_theme_light_scrim = Color(0xFF000000)
+
+val md_theme_dark_primary = Color(0xFFF8BD2A)
+val md_theme_dark_onPrimary = Color(0xFF402D00)
+val md_theme_dark_primaryContainer = Color(0xFF5C4300)
+val md_theme_dark_onPrimaryContainer = Color(0xFFFFDEA0)
+val md_theme_dark_secondary = Color(0xFFD8C4A0)
+val md_theme_dark_onSecondary = Color(0xFF3B2F15)
+val md_theme_dark_secondaryContainer = Color(0xFF53452A)
+val md_theme_dark_onSecondaryContainer = Color(0xFFF5E0BB)
+val md_theme_dark_tertiary = Color(0xFFB1CFA9)
+val md_theme_dark_onTertiary = Color(0xFF1D361B)
+val md_theme_dark_tertiaryContainer = Color(0xFF334D30)
+val md_theme_dark_onTertiaryContainer = Color(0xFFCCEBC4)
+val md_theme_dark_error = Color(0xFFFFB4AB)
+val md_theme_dark_errorContainer = Color(0xFF93000A)
+val md_theme_dark_onError = Color(0xFF690005)
+val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
+val md_theme_dark_background = Color(0xFF1E1B16)
+val md_theme_dark_onBackground = Color(0xFFE9E1D8)
+val md_theme_dark_surface = Color(0xFF1E1B16)
+val md_theme_dark_onSurface = Color(0xFFE9E1D8)
+val md_theme_dark_surfaceVariant = Color(0xFF4D4639)
+val md_theme_dark_onSurfaceVariant = Color(0xFFD0C5B4)
+val md_theme_dark_outline = Color(0xFF998F80)
+val md_theme_dark_inverseOnSurface = Color(0xFF1E1B16)
+val md_theme_dark_inverseSurface = Color(0xFFE9E1D8)
+val md_theme_dark_inversePrimary = Color(0xFF795900)
+val md_theme_dark_shadow = Color(0xFF000000)
+val md_theme_dark_surfaceTint = Color(0xFFF8BD2A)
+val md_theme_dark_outlineVariant = Color(0xFF4D4639)
+val md_theme_dark_scrim = Color(0xFF000000)
+
+
+val seed = Color(0xFFF8BD2A)
+
diff --git a/app/src/main/java/com/gbros/tabslite/ui/theme/Theme.kt b/app/src/main/java/com/gbros/tabslite/ui/theme/Theme.kt
new file mode 100644
index 0000000..0e906b7
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/ui/theme/Theme.kt
@@ -0,0 +1,96 @@
+package com.gbros.tabslite.ui.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import com.gbros.tabslite.data.ThemeSelection
+
+
+private val LightColors = lightColorScheme(
+ primary = md_theme_light_primary,
+ onPrimary = md_theme_light_onPrimary,
+ primaryContainer = md_theme_light_primaryContainer,
+ onPrimaryContainer = md_theme_light_onPrimaryContainer,
+ secondary = md_theme_light_secondary,
+ onSecondary = md_theme_light_onSecondary,
+ secondaryContainer = md_theme_light_secondaryContainer,
+ onSecondaryContainer = md_theme_light_onSecondaryContainer,
+ tertiary = md_theme_light_tertiary,
+ onTertiary = md_theme_light_onTertiary,
+ tertiaryContainer = md_theme_light_tertiaryContainer,
+ onTertiaryContainer = md_theme_light_onTertiaryContainer,
+ error = md_theme_light_error,
+ errorContainer = md_theme_light_errorContainer,
+ onError = md_theme_light_onError,
+ onErrorContainer = md_theme_light_onErrorContainer,
+ background = md_theme_light_background,
+ onBackground = md_theme_light_onBackground,
+ surface = md_theme_light_surface,
+ onSurface = md_theme_light_onSurface,
+ surfaceVariant = md_theme_light_surfaceVariant,
+ onSurfaceVariant = md_theme_light_onSurfaceVariant,
+ outline = md_theme_light_outline,
+ inverseOnSurface = md_theme_light_inverseOnSurface,
+ inverseSurface = md_theme_light_inverseSurface,
+ inversePrimary = md_theme_light_inversePrimary,
+ surfaceTint = md_theme_light_surfaceTint,
+ outlineVariant = md_theme_light_outlineVariant,
+ scrim = md_theme_light_scrim,
+)
+
+
+private val DarkColors = darkColorScheme(
+ primary = md_theme_dark_primary,
+ onPrimary = md_theme_dark_onPrimary,
+ primaryContainer = md_theme_dark_primaryContainer,
+ onPrimaryContainer = md_theme_dark_onPrimaryContainer,
+ secondary = md_theme_dark_secondary,
+ onSecondary = md_theme_dark_onSecondary,
+ secondaryContainer = md_theme_dark_secondaryContainer,
+ onSecondaryContainer = md_theme_dark_onSecondaryContainer,
+ tertiary = md_theme_dark_tertiary,
+ onTertiary = md_theme_dark_onTertiary,
+ tertiaryContainer = md_theme_dark_tertiaryContainer,
+ onTertiaryContainer = md_theme_dark_onTertiaryContainer,
+ error = md_theme_dark_error,
+ errorContainer = md_theme_dark_errorContainer,
+ onError = md_theme_dark_onError,
+ onErrorContainer = md_theme_dark_onErrorContainer,
+ background = md_theme_dark_background,
+ onBackground = md_theme_dark_onBackground,
+ surface = md_theme_dark_surface,
+ onSurface = md_theme_dark_onSurface,
+ surfaceVariant = md_theme_dark_surfaceVariant,
+ onSurfaceVariant = md_theme_dark_onSurfaceVariant,
+ outline = md_theme_dark_outline,
+ inverseOnSurface = md_theme_dark_inverseOnSurface,
+ inverseSurface = md_theme_dark_inverseSurface,
+ inversePrimary = md_theme_dark_inversePrimary,
+ surfaceTint = md_theme_dark_surfaceTint,
+ outlineVariant = md_theme_dark_outlineVariant,
+ scrim = md_theme_dark_scrim,
+)
+
+@Composable
+fun AppTheme(
+ theme: ThemeSelection = ThemeSelection.System,
+ content: @Composable() () -> Unit
+) {
+ val useDarkTheme = when (theme) {
+ ThemeSelection.ForceLight -> false
+ ThemeSelection.ForceDark -> true
+ ThemeSelection.System -> isSystemInDarkTheme()
+ }
+ val colors = if (!useDarkTheme) {
+ LightColors
+ } else {
+ DarkColors
+ }
+
+ MaterialTheme(
+ colorScheme = colors,
+ content = content
+ )
+}
diff --git a/app/src/main/java/com/gbros/tabslite/utilities/KeepScreenOn.kt b/app/src/main/java/com/gbros/tabslite/utilities/KeepScreenOn.kt
new file mode 100644
index 0000000..3957f54
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/utilities/KeepScreenOn.kt
@@ -0,0 +1,48 @@
+package com.gbros.tabslite.utilities
+
+import android.util.Log
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.platform.LocalView
+import kotlin.uuid.ExperimentalUuidApi
+import kotlin.uuid.Uuid
+
+/**
+ * Keep screen on for the current view
+ */
+@OptIn(ExperimentalUuidApi::class)
+@Composable
+fun KeepScreenOn() {
+ val currentView = LocalView.current
+ val myUserId = Uuid.random() // the ID of *this* instance of this composable
+
+ DisposableEffect(Unit) {
+ ScreenOnHelper.screenOnUsers.putIfAbsent(currentView.id, mutableListOf())
+ ScreenOnHelper.screenOnUsers[currentView.id]!!.add(myUserId)
+ currentView.keepScreenOn = true
+ Log.d(TAG, "enabled keepScreenOn for ${currentView.id}")
+
+ onDispose {
+ ScreenOnHelper.screenOnUsers[currentView.id]!!.remove(myUserId)
+ if (ScreenOnHelper.screenOnUsers[currentView.id]!!.isEmpty()) {
+ // we were the last ones needing this screen kept on; disable
+ currentView.keepScreenOn = false
+ Log.d(TAG, "disabling keepScreenOn for ${currentView.id}")
+ }
+ }
+ }
+}
+
+/**
+ * Singleton object to keep track across views which users need the screen on
+ */
+private object ScreenOnHelper {
+ /**
+ * A list of all the people currently requiring the screen to be kept on. When this list empties
+ * the screenOn requirement is no longer needed
+ *
+ * This list represents >
+ */
+ @OptIn(ExperimentalUuidApi::class)
+ val screenOnUsers: MutableMap> = mutableMapOf()
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/utilities/LiveDataExtensions.kt b/app/src/main/java/com/gbros/tabslite/utilities/LiveDataExtensions.kt
new file mode 100644
index 0000000..bf33749
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/utilities/LiveDataExtensions.kt
@@ -0,0 +1,39 @@
+package com.gbros.tabslite.utilities
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MediatorLiveData
+
+
+/**
+ * Combine multiple livedata sources into a new livedata source
+ */
+fun LiveData.combine(
+ liveData2: LiveData,
+ combineFn: (value1: T1?, value2: T2?) -> R
+): LiveData = MediatorLiveData().apply {
+ addSource(this@combine) {
+ value = combineFn(it, liveData2.value)
+ }
+ addSource(liveData2) {
+ value = combineFn(this@combine.value, it)
+ }
+}
+
+/**
+ * Combine multiple livedata sources into a new livedata source
+ */
+fun LiveData.combine(
+ liveData2: LiveData,
+ liveData3: LiveData,
+ combineFn: (value1: T1?, value2: T2?, value3: T3?) -> R
+): LiveData = MediatorLiveData().apply {
+ addSource(this@combine) {
+ value = combineFn(it, liveData2.value, liveData3.value)
+ }
+ addSource(liveData2) {
+ value = combineFn(this@combine.value, it, liveData3.value)
+ }
+ addSource(liveData3) {
+ value = combineFn(this@combine.value, liveData2.value, it)
+ }
+}
diff --git a/app/src/main/java/com/gbros/tabslite/utilities/UgApi.kt b/app/src/main/java/com/gbros/tabslite/utilities/UgApi.kt
new file mode 100644
index 0000000..21b0b55
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/utilities/UgApi.kt
@@ -0,0 +1,554 @@
+package com.gbros.tabslite.utilities
+
+import android.accounts.AuthenticatorException
+import android.content.res.Resources.NotFoundException
+import android.os.Build
+import android.util.Log
+import com.gbros.tabslite.data.DataAccess
+import com.gbros.tabslite.data.SearchSuggestions
+import com.gbros.tabslite.data.chord.ChordVariation
+import com.gbros.tabslite.data.chord.Instrument
+import com.gbros.tabslite.data.playlist.Playlist.Companion.TOP_TABS_PLAYLIST_ID
+import com.gbros.tabslite.data.servertypes.SearchRequestType
+import com.gbros.tabslite.data.servertypes.SearchSuggestionType
+import com.gbros.tabslite.data.servertypes.ServerTimestampType
+import com.gbros.tabslite.data.servertypes.TabRequestType
+import com.gbros.tabslite.data.tab.TabDataType
+import com.gbros.tabslite.utilities.UgApi.apiKey
+import com.gbros.tabslite.utilities.UgApi.deviceId
+import com.google.gson.Gson
+import com.google.gson.JsonSyntaxException
+import com.google.gson.reflect.TypeToken
+import com.google.gson.stream.JsonReader
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.withContext
+import java.io.FileNotFoundException
+import java.io.IOException
+import java.io.InputStream
+import java.math.BigInteger
+import java.net.ConnectException
+import java.net.HttpURLConnection
+import java.net.SocketTimeoutException
+import java.net.URL
+import java.net.URLEncoder
+import java.net.UnknownHostException
+import java.security.MessageDigest
+import java.security.NoSuchAlgorithmException
+import java.text.SimpleDateFormat
+import java.util.Locale
+import java.util.TimeZone
+import kotlin.random.Random
+
+/**
+ * The API interface handling all API-specific logic to get data from the server (or send to the server)
+ */
+object UgApi {
+ //#region private data
+
+ private val gson = Gson()
+
+ private var apiKey: String? = null
+
+ private val apiKeyFetchLock: Mutex = Mutex(locked = false)
+
+ //#endregion
+
+ //#region public data
+
+ private var storeDeviceId: String? = null
+ private val deviceId: String
+ get() = fetchDeviceId()
+
+ //#endregion
+
+ //#region public methods
+
+ /**
+ * Get search suggestions for the given query. Stores search suggestions to the local database,
+ * overwriting any previous search suggestions for the specified query
+ *
+ * @param [q]: The query to fetch search suggestions for
+ *
+ * @return A string list of suggested searches, or an empty list if no suggestions could be found.
+ */
+ suspend fun searchSuggest(q: String, dataAccess: DataAccess) = withContext(Dispatchers.IO) {
+ // fetch search suggestions from the internet
+ try {
+ var query = q
+ if (q.length > 5) { // ug api only allows a max of 5 chars for search suggestion requests. rest of processing is done in app
+ query = q.slice(0 until 5)
+ }
+
+ val connection = URL("https://api.ultimate-guitar.com/api/v1/tab/suggestion?q=$query").openConnection() as HttpURLConnection
+ val suggestions = connection.inputStream.use {inputStream ->
+ val jsonReader = JsonReader(inputStream.reader())
+ val searchSuggestionTypeToken = object : TypeToken() {}.type
+ gson.fromJson(jsonReader, searchSuggestionTypeToken).suggestions
+ }
+
+ if (suggestions.isNotEmpty()) {
+ dataAccess.upsert(SearchSuggestions(query = query, suggestions))
+ }
+ return@withContext
+ } catch (ex: FileNotFoundException) {
+ // no search suggestions for this query
+ return@withContext
+ } catch (ex: UnknownHostException) {
+ // no internet access
+ throw NoInternetException("No internet access to fetch search suggestions for query $q", ex)
+ } catch (ex: Exception) {
+ val message = "SearchSuggest ${ex.javaClass.canonicalName} while finding search suggestions. Probably no internet; no search suggestions added"
+ Log.e(TAG, message, ex)
+ throw SearchException(message, ex)
+ }
+ }
+
+ /**
+ * Perform a search for the given query, and get the tabs that match that search query.
+ *
+ * @param [title] The search term to find
+ * @param [artistId] (Optional) The ID of the artist to filter search results to. Can be paired with the [title] set to an empty string to perform an artist song list. If null or 0, this parameter will be ignored
+ * @param [page] The 1-indexed page of the search results to fetch
+ *
+ * @return A [SearchRequestType] with the search results, or an empty [SearchRequestType] if there are no search results on that page
+ */
+ suspend fun search(title: String, artistId: Int? = null, page: Int): SearchRequestType = withContext(Dispatchers.IO) {
+ val url =
+ "https://api.ultimate-guitar.com/api/v1/tab/search?title=$title&page=$page&artist_id=$artistId&type[]=300&official[]=0"
+
+ val inputStream: InputStream?
+ try {
+ inputStream = authenticatedStream(url)
+ } catch (ex: NotFoundException) {
+ // end of search results
+ return@withContext SearchRequestType()
+ } catch (ex: NoInternetException) {
+ throw ex // pass through NoInternetExceptions
+ } catch (ex: Exception) {
+ Log.e(TAG, "Unexpected exception reading search results for page $page of query '$title': ${ex.message}", ex)
+ throw SearchException("Couldn't fetch search results for page $page of query '$title': ${ex.message}", ex)
+ }
+
+ var result: SearchRequestType
+ val jsonReader = JsonReader(inputStream.reader())
+
+ try {
+ val searchResultTypeToken = object : TypeToken() {}.type
+ result = gson.fromJson(jsonReader, searchResultTypeToken)
+ Log.v(TAG, "Search for $title page $page success.")
+ } catch (syntaxException: JsonSyntaxException) {
+ // usually this block happens when the end of the exact query is reached and a 'did you mean' suggestion is available
+ try {
+ val stringTypeToken = object : TypeToken() {}.type
+ val suggestedSearch: String = gson.fromJson(jsonReader, stringTypeToken)
+
+ result = SearchRequestType(suggestedSearch)
+ } catch (ex: IllegalStateException) {
+ inputStream.close()
+ val message = "Search illegal state exception! Check SearchRequestType for consistency with data. Query: $title, page $page"
+ Log.e(TAG, message, syntaxException)
+ throw SearchException(message, syntaxException)
+ }
+ } finally {
+ inputStream.close()
+ }
+
+ return@withContext result
+ }
+
+ /**
+ * Retrieves updated chord charts for the passed list of chords from the internet API, saves them
+ * to the database, and returns a map from each chord name passed to the list of chord charts
+ *
+ * @param chordIds: List of chord names to fetch. E.g. A#m7, Gsus, A
+ * @param dataAccess: Database to save the updated chords to
+ * @param instrument: The instrument to fetch chords for.
+ *
+ * @return Map from chord ID to the list of [ChordVariation] for that chord
+ */
+ suspend fun updateChordVariations(
+ chordIds: List,
+ dataAccess: DataAccess,
+ instrument: Instrument,
+ ): Map> = withContext(Dispatchers.IO) {
+ if (chordIds.isEmpty()) {
+ return@withContext mapOf()
+ }
+ val resultMap: MutableMap> = mutableMapOf()
+
+ var chordParam = ""
+ for (chord in chordIds) {
+ val uChord = URLEncoder.encode(chord.toString(), "utf-8")
+ chordParam += "&chords[]=$uChord"
+ }
+
+ var uTuning = ""
+ var uInstrument = ""
+ if (instrument == Instrument.Guitar) {
+ uTuning = URLEncoder.encode("E A D G B E", "utf-8")
+ uInstrument = URLEncoder.encode("guitar", "utf-8")
+ } else if (instrument == Instrument.Ukulele) {
+ uTuning = URLEncoder.encode("g C E A", "utf-8")
+ uInstrument = URLEncoder.encode("ukulele", "utf-8")
+ } else {
+ throw IllegalArgumentException("Invalid instrument selection $instrument; couldn't update chords")
+ }
+
+ val url = "https://api.ultimate-guitar.com/api/v1/tab/applicature?instrument=$uInstrument&tuning=$uTuning$chordParam"
+ try {
+ val results: List = authenticatedStream(url).use { inputStream ->
+ val jsonReader = JsonReader(inputStream.reader())
+ val chordRequestTypeToken =
+ object : TypeToken>() {}.type
+ gson.fromJson(jsonReader, chordRequestTypeToken)
+ }
+ for (result in results) {
+ resultMap[result.chord] = result.getChordVariations(instrument)
+ dataAccess.insertAll(result.getChordVariations(instrument))
+ }
+ } catch (ex: Exception) {
+ val chordCount = chordIds.size
+ Log.i(TAG, "Couldn't fetch chords: '$chordParam'. Chord count that we're looking for: $chordCount. ${ex.message}", ex)
+ cancel("Error fetching chord(s).")
+ }
+
+ return@withContext resultMap
+ }
+
+ /**
+ * Add today's most popular tabs to the database
+ */
+ suspend fun fetchTopTabs(dataAccess: DataAccess) = withContext(Dispatchers.IO) {
+ // 'type[]=300' means just chords (all instruments? use 300, 400, 700, and 800)
+ // 'order=hits_daily' means get top tabs today not overall. For overall use 'hits'
+ val topTabSearchResults = authenticatedStream("https://api.ultimate-guitar.com/api/v1/tab/explore?date=0&genre=0&level=0&order=hits_daily&page=1&type=0&official=0").use { inputStream ->
+ val jsonReader = JsonReader(inputStream.reader())
+ val typeToken = object : TypeToken>() {}.type
+
+ return@use (gson.fromJson(
+ jsonReader,
+ typeToken
+ ) as List)
+ }
+ val topTabs: List = topTabSearchResults.map { t -> t.tabFull() }
+
+ if (topTabs.isEmpty()) {
+ // don't overwrite with an empty list
+ throw NotFoundException("Top tabs result was empty: ${topTabSearchResults.size} results")
+ }
+
+ // clear top tabs playlist, then add all these to the top tabs playlist
+ dataAccess.clearTopTabsPlaylist()
+ for (tab in topTabs) {
+ // add playlist entry
+ dataAccess.appendToPlaylist(
+ playlistId = TOP_TABS_PLAYLIST_ID,
+ tabId = tab.tabId,
+ transpose = 0
+ )
+
+ // add empty tab so it'll show up in the Popular list
+ dataAccess.insert(tab)
+ }
+ return@withContext
+ }
+
+ /**
+ * Gets tab based on tabId. Loads tab from internet and caches the result automatically in the
+ * app database.
+ *
+ * @param tabId The ID of the tab to load
+ * @param dataAccess The database instance to load a tab from (or into)
+ * @param tabAccessType (Optional) string parameter for internet tab load request
+ */
+ suspend fun fetchTabFromInternet(
+ tabId: Int,
+ dataAccess: DataAccess,
+ tabAccessType: String = "public"
+ ): TabDataType = withContext(Dispatchers.IO) {
+ // get the tab and put it in the database, then return true
+ Log.v(TAG, "Loading tab $tabId.")
+ val url =
+ "https://api.ultimate-guitar.com/api/v1/tab/info?tab_id=$tabId&tab_access_type=$tabAccessType"
+ val requestResponse: TabRequestType = with(authenticatedStream(url)) {
+ val jsonReader = JsonReader(reader())
+ val tabRequestTypeToken = object : TypeToken() {}.type
+ Gson().fromJson(jsonReader, tabRequestTypeToken)
+ }
+
+ Log.v(
+ TAG,
+ "Parsed response for tab $tabId. Name: ${requestResponse.song_name}, capo ${requestResponse.capo}"
+ )
+
+ val result = requestResponse.getTabFull()
+ if (result.content.isNotBlank()) {
+ dataAccess.upsert(result)
+ Log.v(TAG, "Successfully inserted tab ${result.songName} (${result.tabId})")
+ } else {
+ val message = "Tab $tabId fetch completed successfully but had no content! This shouldn't happen."
+ Log.e(TAG, message)
+ throw TabFetchException(message)
+ }
+ return@withContext result
+ }
+
+ //#endregion
+
+ //#region private methods
+
+ /**
+ * Gets an authenticated input stream for the passed API URL, updating the API key if needed
+ *
+ * @param url: The UG API url to start an authenticated InputStream with
+ *
+ * @return An [InputStream], authenticated with a valid API key
+ *
+ * @throws NoInternetException if no internet access
+ * @throws Exception if an unknown error occurs (could still be an internet access issue)
+ */
+ private suspend fun authenticatedStream(url: String): InputStream = withContext(Dispatchers.IO) {
+ Log.v(TAG, "Getting authenticated stream for url: $url.")
+ try {
+ apiKeyFetchLock.lock()
+
+ if (apiKey == null) {
+ updateApiKey()
+ }
+ } catch (ex: NoInternetException) {
+ throw NoInternetException("Can't fetch $url. No internet access.", ex)
+ } catch (ex: Exception) {
+ throw Exception("Unexpected API Key initialization failure while fetching $url! Maybe an internet issue?", ex)
+ } finally {
+ apiKeyFetchLock.unlock()
+ }
+
+ // api key is not null
+ Log.v(TAG, "Api key: $apiKey, device id: $deviceId.")
+
+ var responseCode = 0
+ try {
+ var numTries = 0
+ do {
+ numTries++
+ val conn = URL(url).openConnection() as HttpURLConnection
+ conn.setRequestProperty("Accept-Charset", "utf-8")
+ conn.setRequestProperty("Accept", "application/json")
+ conn.setRequestProperty(
+ "User-Agent",
+ "UGT_ANDROID/5.10.12 ("
+ ) // actual value UGT_ANDROID/5.10.11 (ONEPLUS A3000; Android 10)
+ conn.setRequestProperty(
+ "x-ug-client-id",
+ deviceId
+ ) // stays constant over time; api key and client id are related to each other.
+ conn.setRequestProperty(
+ "x-ug-api-key",
+ apiKey
+ ) // updates periodically.
+ conn.connectTimeout = (5000) // timeout of 5 seconds
+ conn.readTimeout = 6000
+ responseCode = conn.responseCode
+ Log.v(TAG, "Retrieved URL with response code $responseCode.")
+
+ if (responseCode == 498 && numTries == 1) { // don't bother the second time through
+ Log.i(
+ TAG,
+ "498 response code for old api key $apiKey and device id $deviceId. Refreshing api key"
+ )
+ conn.disconnect()
+
+ try {
+ updateApiKey()
+ Log.v(TAG, "Got new api key ($apiKey)")
+ } catch (ex: Exception) {
+ // we don't have an internet connection. Strange, because we shouldn't have gotten a 498 error code if we had no internet.
+ val msg =
+ "498 response code, but api key update returned null! Generally this means we don't have an internet connection. Strange, because we shouldn't have gotten a 498 error code if we had no internet. Either precisely perfect timing or something's wrong."
+ throw Exception(msg, ex)
+ }
+ } else {
+ Log.v(TAG, "Fetch attempt $numTries - valid token or max retries reached.")
+ Log.v(TAG, "Response code $responseCode on try $numTries for url $url (${conn.requestMethod}).")
+
+ if (responseCode == 498) {
+ // read response content if our api level includes the function
+ var content = ""
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ try {
+ content = conn.inputStream.readAllBytes().toString()
+ } catch (_: Exception) { }
+ }
+ throw AuthenticatorException("Couldn't fetch authenticated stream (498: bad token). Response code: $responseCode, content: \n$content")
+ } else if (responseCode == 451) {
+ // read response content if our api level includes the function
+ var content = ""
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ try {
+ content = conn.inputStream.readAllBytes().toString()
+ } catch (_: Exception) { }
+ }
+
+ Log.i(TAG, "Url not available (451: unavailable for legal reasons). content: \n$content")
+ throw UnavailableForLegalReasonsException("Url '$url' unavailable for legal reasons: 451.\n$content")
+ } else {
+ return@withContext conn.inputStream
+ }
+ }
+ } while (true)
+
+ throw Exception("Unreachable: Could not create authenticated stream.") // shouldn't get here
+ } catch (ex: UnavailableForLegalReasonsException) {
+ throw ex // pass through UnavailableForLegalReasonsExceptions
+ }
+ catch (ex: FileNotFoundException) {
+ throw NotFoundException("NOT FOUND during fetch of url $url. Response code $responseCode.", ex)
+ } catch (ex: ConnectException) {
+ throw NoInternetException("Could not fetch $url. ConnectException (no internet access)", ex)
+ } catch (ex: NoInternetException) {
+ throw NoInternetException("Could not fetch $url. No internet.", ex)
+ } catch (ex: SocketTimeoutException) {
+ throw NoInternetException("Could not fetch $url. Socket timeout (no internet access).", ex)
+ } catch (ex: IOException) {
+ throw NoInternetException("Could not fetch $url. IOException (no internet access). ${ex.message}", ex)
+ } catch (ex: Exception) {
+ throw Exception("Unexpected exception during fetch of url $url with parameters apiKey: " +
+ "$apiKey and deviceId: $deviceId. Response code $responseCode", ex)
+ }
+ }
+
+ /**
+ * Sets an updated [apiKey], based on the most recent server time. This needs to be called
+ * whenever we get a 498 response code
+ *
+ * @throws NoInternetException if no internet access
+ * @throws Exception if api key could not be updated for an unknown reason
+ */
+ private suspend fun updateApiKey() {
+ apiKey = null
+ val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd:H", Locale.US)
+ simpleDateFormat.timeZone = TimeZone.getTimeZone("UTC")
+ val stringBuilder = StringBuilder(deviceId)
+
+ val serverTime = fetchServerTime()
+ stringBuilder.append(serverTime)
+ stringBuilder.append("createLog()")
+ apiKey = getMd5(stringBuilder.toString())
+
+ if (apiKey.isNullOrBlank()) {
+ throw Exception("API key update completed without fetching API key. Server time: $serverTime. API key: $apiKey")
+ }
+ }
+
+ /**
+ * Gets the current server time, for use in API calls
+ *
+ * @return The current time according to the server
+ *
+ * @throws NoInternetException if not connected to the internet
+ * @throws Exception if time fetch could not be completed for an unknown reason
+ */
+ private suspend fun fetchServerTime(): String = withContext(Dispatchers.IO) {
+ val devId = deviceId
+ val lastResult: ServerTimestampType
+ val conn = URL("https://api.ultimate-guitar.com/api/v1/common/hello").openConnection() as HttpURLConnection
+ conn.setRequestProperty("Accept", "application/json")
+ conn.setRequestProperty("User-Agent", "UGT_ANDROID/5.10.12 (") // actual value "UGT_ANDROID/5.10.11 (ONEPLUS A3000; Android 10)". 5.10.11 is the app version.
+ conn.setRequestProperty("x-ug-client-id", devId) // stays constant over time; api key and client id are related to each other.
+
+ val serverTimestamp = try {
+ conn.inputStream.use {inputStream ->
+ val jsonReader = JsonReader(inputStream.reader())
+ val serverTimestampTypeToken = object : TypeToken() {}.type
+ lastResult = Gson().fromJson(jsonReader, serverTimestampTypeToken)
+ lastResult
+ }
+ } catch (ex: IllegalStateException) {
+ throw IllegalStateException("Error converting types while performing hello handshake. Check proguard rules.", ex)
+ } catch (ex: UnknownHostException) {
+ throw NoInternetException("Unknown host while performing hello handshake. Probably not connected to the internet.", ex)
+ } catch (ex: Exception) {
+ throw Exception( "Unexpected error getting hello handshake (server time). We may not be connected to the internet.", ex)
+ }
+
+ // read server time into our date type of choice
+ val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd:H", Locale.US)
+ simpleDateFormat.timeZone = TimeZone.getTimeZone("UTC")
+ val formattedDateString = simpleDateFormat.format(serverTimestamp.getServerTime().time)
+
+ Log.i(TAG, "Fetched server time: $formattedDateString}")
+ return@withContext formattedDateString
+ }
+
+ /**
+ * Hash a string using the MD5 algorithm
+ *
+ * @param [stringToHash]: The string to hash using the MD5 algorithm
+ *
+ * @return The MD5-hashed version of [stringToHash]
+ *
+ * @throws NoSuchAlgorithmException if the MD5 algorithm doesn't exist on this device
+ */
+ private fun getMd5(stringToHash: String): String {
+ var ret = stringToHash
+
+ ret = BigInteger(1, MessageDigest.getInstance("MD5").digest(ret.toByteArray())).toString(16)
+ while (ret.length < 32) {
+ val stringBuilder = java.lang.StringBuilder()
+ stringBuilder.append("0")
+ stringBuilder.append(ret)
+ ret = stringBuilder.toString()
+ }
+ return ret
+ }
+
+ /**
+ * Ensures that we have a current deviceId stored. Creates new ID if needed. Shouldn't be called
+ * directly; use [deviceId] instead.
+ *
+ * @return The current deviceId (setting it if need be)
+ */
+ private fun fetchDeviceId(): String {
+ val copyOfCurrentDeviceId = storeDeviceId
+ return if (copyOfCurrentDeviceId != null) {
+ copyOfCurrentDeviceId
+ } else {
+ // generate a new device id
+ var newId = ""
+ val charList = charArrayOf('1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f')
+ while(newId.length < 16) {
+ newId += charList[Random.nextInt(0, 15)]
+ }
+ storeDeviceId = newId
+ newId
+ }
+ }
+
+ //#endregion
+
+ //#region Custom exceptions
+
+ class NoInternetException : Exception {
+ constructor() : super()
+ constructor(message: String) : super(message)
+ constructor(message: String, cause: Throwable) : super(message, cause)
+ constructor(cause: Throwable) : super(cause)
+ }
+
+ class SearchException : Exception {
+ constructor(message: String, cause: Throwable) : super(message, cause)
+ }
+
+ open class TabFetchException : Exception {
+ constructor(message: String) : super(message)
+ constructor(message: String, cause: Throwable) : super(message, cause)
+ }
+
+ class UnavailableForLegalReasonsException : NotFoundException {
+ constructor(message: String) : super(message)
+ }
+
+ //#endregion
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/utilities/tag.kt b/app/src/main/java/com/gbros/tabslite/utilities/tag.kt
new file mode 100644
index 0000000..7bc11df
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/utilities/tag.kt
@@ -0,0 +1,14 @@
+package com.gbros.tabslite.utilities
+
+/**
+ * Gets the class name for use as a tag for logs. Since API 24 there's no length restriction on log
+ * tags, so this returns the full name.
+ */
+val Any.TAG: String
+ get() {
+ return if (!javaClass.isAnonymousClass) {
+ "tabslite.${javaClass.simpleName}"
+ } else {
+ "tabslite.${javaClass.name}"
+ }
+ }
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/addtoplaylistdialog/AddToPlaylistDialog.kt b/app/src/main/java/com/gbros/tabslite/view/addtoplaylistdialog/AddToPlaylistDialog.kt
new file mode 100644
index 0000000..db563ea
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/addtoplaylistdialog/AddToPlaylistDialog.kt
@@ -0,0 +1,112 @@
+package com.gbros.tabslite.view.addtoplaylistdialog
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.gbros.tabslite.R
+import com.gbros.tabslite.data.playlist.Playlist
+import com.gbros.tabslite.ui.theme.AppTheme
+
+@Composable
+fun AddToPlaylistDialog(
+ playlists: List,
+ selectedPlaylistDropdownText: String?,
+ onSelectionChange: (Playlist) -> Unit,
+ confirmButtonEnabled: Boolean,
+ onCreatePlaylist: (title: String, description: String) -> Unit,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit
+) {
+ var showCreatePlaylistDialog by remember { mutableStateOf(false) }
+
+ AlertDialog(
+ icon = {
+ Icon(ImageVector.vectorResource(R.drawable.ic_playlist_add), contentDescription = stringResource(id = R.string.title_add_to_playlist_dialog))
+ },
+ title = {
+ Text(text = stringResource(id = R.string.title_add_to_playlist_dialog))
+ },
+ text = {
+ Row {
+ Column(
+ Modifier.weight(1f)
+ ) {
+ PlaylistDropdown(playlists = playlists, title = selectedPlaylistDropdownText ?: stringResource(R.string.select_playlist_dialog_no_selection), onSelectionChange = onSelectionChange)
+ }
+ Column(
+
+ ) {
+ Button(
+ modifier = Modifier
+ .padding(start = 8.dp, top = 8.dp, bottom = 8.dp),
+ onClick = {
+ showCreatePlaylistDialog = true
+ },
+ ) {
+ Icon(imageVector = Icons.Default.Add, contentDescription = stringResource(id = R.string.app_action_description_create_playlist))
+ }
+ }
+ }
+ },
+ onDismissRequest = onDismiss,
+ confirmButton = {
+ TextButton(
+ onClick = onConfirm,
+ enabled = confirmButtonEnabled
+ ) {
+ Text(stringResource(R.string.generic_action_confirm))
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = onDismiss
+ ) {
+ Text(stringResource(R.string.generic_action_dismiss))
+ }
+ }
+ )
+
+ if (showCreatePlaylistDialog) {
+ CreatePlaylistDialog(
+ onConfirm = { title, description ->
+ onCreatePlaylist(title, description)
+ showCreatePlaylistDialog = false
+ },
+ onDismiss = { showCreatePlaylistDialog = false })
+ }
+}
+
+@Composable @Preview
+private fun AddToPlaylistDialogPreview() {
+ val playlistForTest = Playlist(1, true, "My amazing playlist 1.0.1", 12345, 12345, "The playlist that I'm going to use to test this playlist entry item thing with lots of text.")
+ val list = listOf(playlistForTest, playlistForTest, playlistForTest ,playlistForTest, playlistForTest)
+ AppTheme {
+ AddToPlaylistDialog(
+ playlists = list,
+ selectedPlaylistDropdownText = "Select a playlist...",
+ confirmButtonEnabled = false,
+ onSelectionChange = { },
+ onCreatePlaylist = { _, _ -> },
+ onConfirm = { },
+ onDismiss = { },
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/addtoplaylistdialog/CreatePlaylistDialog.kt b/app/src/main/java/com/gbros/tabslite/view/addtoplaylistdialog/CreatePlaylistDialog.kt
new file mode 100644
index 0000000..0c1384d
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/addtoplaylistdialog/CreatePlaylistDialog.kt
@@ -0,0 +1,84 @@
+package com.gbros.tabslite.view.addtoplaylistdialog
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Create
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.gbros.tabslite.R
+import com.gbros.tabslite.ui.theme.AppTheme
+
+@Composable
+fun CreatePlaylistDialog(onConfirm: (newPlaylistTitle: String, newPlaylistDescription: String) -> Unit, onDismiss: () -> Unit) {
+ var title by remember { mutableStateOf("") }
+ var description by remember { mutableStateOf("") }
+
+ AlertDialog(
+ icon = {
+ Icon(Icons.Default.Create, contentDescription = null)
+ },
+ title = {
+ Text(text = stringResource(id = R.string.title_create_playlist_dialog))
+ },
+ text = {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ TextField(
+ value = title,
+ onValueChange = {title = it },
+ placeholder = { Text(stringResource(id = R.string.placeholder_playlist_title)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words)
+ )
+ TextField(
+ value = description,
+ onValueChange = {description = it},
+ placeholder = { Text(stringResource(id = R.string.placeholder_playlist_description)) },
+ modifier = Modifier
+ )
+ }
+ },
+ onDismissRequest = onDismiss,
+ confirmButton = {
+ TextButton(
+ onClick = {
+ onConfirm(title, description)
+ },
+ enabled = title.isNotBlank()
+ ) {
+ Text(stringResource(id = R.string.generic_action_confirm))
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = onDismiss
+ ) {
+ Text(stringResource(id = R.string.generic_action_dismiss))
+ }
+ }
+ )
+}
+
+@Composable
+@Preview
+private fun CreatePlaylistDialogPreview() {
+ AppTheme {
+ CreatePlaylistDialog(onConfirm = {_, _ -> }, onDismiss = { })
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/addtoplaylistdialog/PlaylistDropdown.kt b/app/src/main/java/com/gbros/tabslite/view/addtoplaylistdialog/PlaylistDropdown.kt
new file mode 100644
index 0000000..eadb2dd
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/addtoplaylistdialog/PlaylistDropdown.kt
@@ -0,0 +1,68 @@
+package com.gbros.tabslite.view.addtoplaylistdialog
+
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.MenuAnchorType
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import com.gbros.tabslite.data.playlist.Playlist
+import com.gbros.tabslite.ui.theme.AppTheme
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PlaylistDropdown(playlists: List, title: String, onSelectionChange: (selectedPlaylist: Playlist) -> Unit) {
+ var expanded by remember { mutableStateOf(false) }
+
+ ExposedDropdownMenuBox(
+ expanded = expanded,
+ onExpandedChange = { nowExpanded -> expanded = nowExpanded }
+ ) {
+ TextField(
+ value = title,
+ onValueChange = {},
+ readOnly = true,
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
+ modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryEditable, playlists.isNotEmpty()),
+ enabled = playlists.isNotEmpty()
+ )
+
+ ExposedDropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false }
+ ) {
+ playlists.forEach { playlist: Playlist ->
+ DropdownMenuItem(
+ text = {
+ Text(text = playlist.title)
+ },
+ onClick = {
+ expanded = false
+ onSelectionChange(playlist)
+ }
+ )
+ }
+ }
+ }
+}
+
+@Composable @Preview
+private fun PlaylistDropdownPreview() {
+ val playlistForTest = Playlist(1, true, "My amazing playlist 1.0.1", 12345, 12345, "The playlist that I'm going to use to test this playlist entry item thing with lots of text.")
+ val list = listOf(playlistForTest, playlistForTest, playlistForTest ,playlistForTest, playlistForTest)
+
+ AppTheme {
+ PlaylistDropdown(
+ list,
+ "Select a playlist...",
+ ) {}
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/card/ErrorCard.kt b/app/src/main/java/com/gbros/tabslite/view/card/ErrorCard.kt
new file mode 100644
index 0000000..b3204f2
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/card/ErrorCard.kt
@@ -0,0 +1,28 @@
+package com.gbros.tabslite.view.card
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import com.gbros.tabslite.R
+import com.gbros.tabslite.ui.theme.AppTheme
+
+@Composable
+fun ErrorCard(text: String) {
+ GenericInformationCard(
+ text = text,
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
+ icon = Icons.Default.Warning,
+ iconContentDescription = stringResource(id = R.string.error)
+ )
+}
+
+@Composable @Preview
+private fun ErrorCardPreview() {
+ AppTheme {
+ ErrorCard(text = "Error! Something bad happened and now we need to show this message.")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/card/GenericInformationCard.kt b/app/src/main/java/com/gbros/tabslite/view/card/GenericInformationCard.kt
new file mode 100644
index 0000000..227fd6c
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/card/GenericInformationCard.kt
@@ -0,0 +1,61 @@
+package com.gbros.tabslite.view.card
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardColors
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.gbros.tabslite.ui.theme.AppTheme
+
+@Composable
+fun GenericInformationCard(
+ text: String,
+ colors: CardColors,
+ icon: ImageVector,
+ iconContentDescription: String? = null,
+ textColor: Color = Color.Unspecified
+) {
+ Card(
+ colors = colors
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .padding(all = 8.dp)
+
+ ) {
+ Icon(imageVector = icon, contentDescription = iconContentDescription, modifier = Modifier.padding(all = 8.dp))
+ Text(
+ text = text,
+ color = textColor,
+ modifier = Modifier
+ .padding(all = 4.dp)
+ )
+ }
+ }
+}
+
+@Composable
+@Preview
+private fun GenericInformationCardPreview() {
+ AppTheme {
+ GenericInformationCard(
+ text = "Add songs to your playlist by finding the song you'd like and selecting the three dot menu at the top right of the screen.",
+ colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surfaceVariant),
+ icon = Icons.Default.Info,
+ iconContentDescription = "Info"
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/card/InfoCard.kt b/app/src/main/java/com/gbros/tabslite/view/card/InfoCard.kt
new file mode 100644
index 0000000..ebc96cd
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/card/InfoCard.kt
@@ -0,0 +1,28 @@
+package com.gbros.tabslite.view.card
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import com.gbros.tabslite.R
+import com.gbros.tabslite.ui.theme.AppTheme
+
+@Composable
+fun InfoCard(text: String) {
+ GenericInformationCard(
+ text = text,
+ colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surfaceVariant),
+ icon = Icons.Default.Info,
+ iconContentDescription = stringResource(id = R.string.info)
+ )
+}
+
+@Composable @Preview
+private fun InfoCardPreview() {
+ AppTheme {
+ InfoCard(text = "Add songs to your playlist by finding the song you'd like and selecting the three dot menu at the top right of the screen.")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/chorddisplay/ChordModalBottomSheet.kt b/app/src/main/java/com/gbros/tabslite/view/chorddisplay/ChordModalBottomSheet.kt
new file mode 100644
index 0000000..63c247c
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/chorddisplay/ChordModalBottomSheet.kt
@@ -0,0 +1,202 @@
+package com.gbros.tabslite.view.chorddisplay
+
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.chrynan.chords.model.ChordMarker
+import com.chrynan.chords.model.Finger
+import com.chrynan.chords.model.FretNumber
+import com.chrynan.chords.model.StringNumber
+import com.gbros.tabslite.LoadingState
+import com.gbros.tabslite.R
+import com.gbros.tabslite.data.chord.ChordVariation
+import com.gbros.tabslite.data.chord.Instrument
+import com.gbros.tabslite.ui.theme.AppTheme
+import com.gbros.tabslite.view.card.ErrorCard
+import com.gbros.tabslite.view.tabview.TabText
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ChordModalBottomSheet(
+ title: String,
+ chordVariations: List,
+ instrument: Instrument,
+ useFlats: Boolean,
+ loadingState: LoadingState,
+ onDismiss: () -> Unit,
+ onInstrumentSelected: (Instrument) -> Unit,
+ onUseFlatsToggled: (Boolean) -> Unit
+){
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+
+ val screenWidth = LocalConfiguration.current.smallestScreenWidthDp
+ val screenHeight = LocalConfiguration.current.screenWidthDp
+ val startPadding = if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE) (screenHeight - screenWidth - 16).dp else 0.dp
+
+ ModalBottomSheet(
+ modifier = Modifier.padding(start = startPadding),
+ sheetState = sheetState,
+ onDismissRequest = onDismiss,
+ sheetMaxWidth = screenWidth.dp
+ ) {
+ Column {
+ Row (
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ InstrumentSelector(instrument, onInstrumentSelected)
+ UseFlatsToggle(useFlats, onUseFlatsToggled)
+ }
+ if (loadingState is LoadingState.Success) {
+
+ ChordPager(
+ title = title,
+ chordVariations = chordVariations,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+ } else {
+ // show loading progress indicator
+ Box(
+ modifier = Modifier
+ .height(344.dp) // this is the size of the components above added together, minus the text
+ .fillMaxWidth()
+ .padding(all = 16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ if (loadingState is LoadingState.Error) {
+ ErrorCard(
+ text = String.format(
+ stringResource(id = R.string.message_chord_load_failed),
+ title
+ )
+ )
+ } else {
+ CircularProgressIndicator()
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ChordModalBottomSheetPreview (showModal: Boolean) {
+ AppTheme {
+ val testCase1 = AnnotatedString("""
+ [tab] [ch]C[/ch] [ch]Am[/ch]
+ That David played and it pleased the Lord[/tab]
+ """.trimIndent())
+ var bottomSheetTrigger by remember { mutableStateOf(showModal) }
+ var chordToShow by remember { mutableStateOf("Am") }
+ val chords = listOf(
+ ChordVariation("varid1234", "Am",
+ arrayListOf(
+ ChordMarker.Note(FretNumber(1), Finger.INDEX, StringNumber(4)),
+ ChordMarker.Note(FretNumber(2), Finger.MIDDLE, StringNumber(3)),
+ ChordMarker.Note(FretNumber(2), Finger.RING, StringNumber(2))
+ ),
+ arrayListOf(
+ ChordMarker.Open(StringNumber(1)),
+ ChordMarker.Open(StringNumber(5))
+ ),
+ arrayListOf(
+ ChordMarker.Muted(StringNumber(6))
+ ),
+ arrayListOf(),
+ Instrument.Guitar
+ ),
+ ChordVariation("varid1234", "Am",
+ arrayListOf(
+ ChordMarker.Note(FretNumber(1), Finger.INDEX, StringNumber(4)),
+ ChordMarker.Note(FretNumber(2), Finger.MIDDLE, StringNumber(3)),
+ ChordMarker.Note(FretNumber(2), Finger.RING, StringNumber(2))
+ ),
+ arrayListOf(
+ ChordMarker.Open(StringNumber(1)),
+ ChordMarker.Open(StringNumber(5))
+ ),
+ arrayListOf(
+ ChordMarker.Muted(StringNumber(6))
+ ),
+ arrayListOf(),
+ Instrument.Guitar
+ ),
+ ChordVariation("varid1234", "Am",
+ arrayListOf(
+ ChordMarker.Note(FretNumber(1), Finger.INDEX, StringNumber(4)),
+ ChordMarker.Note(FretNumber(2), Finger.MIDDLE, StringNumber(3)),
+ ChordMarker.Note(FretNumber(2), Finger.RING, StringNumber(2))
+ ),
+ arrayListOf(
+ ChordMarker.Open(StringNumber(1)),
+ ChordMarker.Open(StringNumber(5))
+ ),
+ arrayListOf(
+ ChordMarker.Muted(StringNumber(6))
+ ),
+ arrayListOf(),
+ Instrument.Guitar
+ )
+ )
+
+ TabText(
+ text = testCase1,
+ fontSizeSp = 14f,
+ onTextClick = { _, _, _ ->
+ chordToShow = "Am"
+ bottomSheetTrigger = true
+ },
+ onScreenMeasured = {_, _, _->},
+ onZoom = {},
+ modifier = Modifier.fillMaxSize()
+ )
+
+ if (bottomSheetTrigger) {
+ ChordModalBottomSheet(
+ title = chordToShow,
+ chordVariations = chords,
+ instrument = Instrument.Guitar,
+ useFlats = false,
+ loadingState = LoadingState.Success,
+ onDismiss = { },
+ onInstrumentSelected = { },
+ onUseFlatsToggled = { }
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun ChordModalBottomSheetExpandedPreview() {
+ ChordModalBottomSheetPreview(true)
+}
+
+@Preview
+@Composable
+private fun ChordModalBottomSheetClosedPreview() {
+ ChordModalBottomSheetPreview(false)
+}
+
diff --git a/app/src/main/java/com/gbros/tabslite/view/chorddisplay/ChordPager.kt b/app/src/main/java/com/gbros/tabslite/view/chorddisplay/ChordPager.kt
new file mode 100644
index 0000000..79c6add
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/chorddisplay/ChordPager.kt
@@ -0,0 +1,301 @@
+package com.gbros.tabslite.view.chorddisplay
+
+import android.util.Log
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.chrynan.chords.compose.ChordWidget
+import com.chrynan.chords.model.ChordChart
+import com.chrynan.chords.model.ChordMarker
+import com.chrynan.chords.model.ChordViewData
+import com.chrynan.chords.model.Finger
+import com.chrynan.chords.model.FretNumber
+import com.chrynan.chords.model.StringLabelState
+import com.chrynan.chords.model.StringNumber
+import com.chrynan.chords.util.maxFret
+import com.chrynan.chords.util.minFret
+import com.chrynan.colors.RgbaColor
+import com.gbros.tabslite.data.chord.ChordVariation
+import com.gbros.tabslite.data.chord.Instrument
+import com.gbros.tabslite.ui.theme.AppTheme
+import com.gbros.tabslite.utilities.TAG
+import kotlin.math.max
+import kotlin.math.min
+
+@OptIn(ExperimentalFoundationApi::class, ExperimentalUnsignedTypes::class)
+@Composable
+fun ChordPager(
+ title: String,
+ modifier: Modifier = Modifier,
+ chordVariations: List
+) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = title,
+ fontSize = MaterialTheme.typography.headlineLarge.fontSize,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier
+ .fillMaxWidth(),
+ )
+
+ HorizontalIndicatorPager(
+ modifier = modifier,
+ pageCount = chordVariations.size
+ ) { page ->
+
+ val chord = chordVariations[page].toChrynanChord()
+ // set which frets are shown for this chord
+ val defaultMaxFret = 3
+ val defaultMinFret = 1
+ val endFret = max(
+ chord.maxFret,
+ defaultMaxFret
+ ) // last fret shown
+ val startFret = min(
+ chord.minFret,
+ max(chord.maxFret - 2, defaultMinFret)
+ ) // first fret shown
+
+ // get the chart layout based on the selected instrument
+ val chartLayout = if (chordVariations[page].instrument == Instrument.Guitar) {
+ ChordChart.STANDARD_TUNING_GUITAR_CHART.copy(
+ fretStart = FretNumber(startFret),
+ fretEnd = FretNumber(endFret)
+ )
+ } else if (chordVariations[page].instrument == Instrument.Ukulele) {
+ ChordChart.STANDARD_TUNING_UKELELE.copy(
+ fretStart = FretNumber(startFret),
+ fretEnd = FretNumber(endFret)
+ )
+ } else {
+ Log.e(TAG, "Invalid instrument selection: ${chordVariations[page].instrument}, defaulting to guitar")
+ ChordChart.STANDARD_TUNING_GUITAR_CHART.copy(
+ fretStart = FretNumber(startFret),
+ fretEnd = FretNumber(endFret)
+ )
+ }
+
+ ChordWidget(
+ chord = chord,
+ chart = chartLayout,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(240.dp),
+ viewData = ChordViewData(
+ noteColor = MaterialTheme.colorScheme.primary.toChrynanRgba(),
+ noteLabelTextColor = MaterialTheme.colorScheme.onPrimary.toChrynanRgba(),
+ fretColor = MaterialTheme.colorScheme.onBackground.toChrynanRgba(),
+ fretLabelTextColor = MaterialTheme.colorScheme.onBackground.toChrynanRgba(),
+ stringColor = MaterialTheme.colorScheme.onBackground.toChrynanRgba(),
+ stringLabelTextColor = MaterialTheme.colorScheme.onBackground.toChrynanRgba(),
+ stringLabelState = StringLabelState.SHOW_LABEL,
+ fitToHeight = true
+ )
+ )
+ }
+ }
+}
+
+fun Color.toChrynanRgba() : RgbaColor {
+ return RgbaColor(red, green, blue, alpha)
+}
+
+//region preview
+
+@Preview
+@Composable
+fun UkulelePreview() {
+ /**
+ * Automatically add these chords to an empty constructor
+ */
+ val chords = listOf(
+ ChordVariation("varid1234", "Am",
+ arrayListOf(
+ ChordMarker.Note(FretNumber(1), Finger.INDEX, StringNumber(4)),
+ ChordMarker.Note(FretNumber(2), Finger.MIDDLE, StringNumber(3)),
+ ChordMarker.Note(FretNumber(2), Finger.RING, StringNumber(2))
+ ),
+ arrayListOf(
+ ChordMarker.Open(StringNumber(1)),
+ ),
+ arrayListOf(),
+ arrayListOf(),
+ Instrument.Ukulele
+ ),
+ ChordVariation("varid1234", "Am",
+ arrayListOf(
+ ChordMarker.Note(FretNumber(1), Finger.INDEX, StringNumber(4)),
+ ChordMarker.Note(FretNumber(2), Finger.MIDDLE, StringNumber(3)),
+ ChordMarker.Note(FretNumber(2), Finger.RING, StringNumber(2))
+ ),
+ arrayListOf(
+ ChordMarker.Open(StringNumber(1)),
+ ChordMarker.Open(StringNumber(5))
+ ),
+ arrayListOf(
+ ChordMarker.Muted(StringNumber(6))
+ ),
+ arrayListOf(),
+ Instrument.Ukulele
+ ),
+ ChordVariation("varid1234", "Am",
+ arrayListOf(
+ ChordMarker.Note(FretNumber(1), Finger.INDEX, StringNumber(4)),
+ ChordMarker.Note(FretNumber(2), Finger.MIDDLE, StringNumber(3)),
+ ChordMarker.Note(FretNumber(2), Finger.RING, StringNumber(2))
+ ),
+ arrayListOf(
+ ChordMarker.Open(StringNumber(1)),
+ ChordMarker.Open(StringNumber(5))
+ ),
+ arrayListOf(
+ ChordMarker.Muted(StringNumber(6))
+ ),
+ arrayListOf(),
+ Instrument.Ukulele
+ )
+ )
+
+ AppTheme {
+ ChordPager(title = "Am", chordVariations = chords)
+ }
+}
+
+@Composable @Preview
+fun ChordPagerPreview() {
+ /**
+ * Automatically add these chords to an empty constructor
+ */
+ val chords = listOf(
+ ChordVariation("varid1234", "Am",
+ arrayListOf(
+ ChordMarker.Note(FretNumber(1), Finger.INDEX, StringNumber(4)),
+ ChordMarker.Note(FretNumber(2), Finger.MIDDLE, StringNumber(3)),
+ ChordMarker.Note(FretNumber(2), Finger.RING, StringNumber(2))
+ ),
+ arrayListOf(
+ ChordMarker.Open(StringNumber(1)),
+ ChordMarker.Open(StringNumber(5))
+ ),
+ arrayListOf(
+ ChordMarker.Muted(StringNumber(6))
+ ),
+ arrayListOf(),
+ Instrument.Guitar
+ ),
+ ChordVariation("varid1234", "Am",
+ arrayListOf(
+ ChordMarker.Note(FretNumber(1), Finger.INDEX, StringNumber(4)),
+ ChordMarker.Note(FretNumber(2), Finger.MIDDLE, StringNumber(3)),
+ ChordMarker.Note(FretNumber(2), Finger.RING, StringNumber(2))
+ ),
+ arrayListOf(
+ ChordMarker.Open(StringNumber(1)),
+ ChordMarker.Open(StringNumber(5))
+ ),
+ arrayListOf(
+ ChordMarker.Muted(StringNumber(6))
+ ),
+ arrayListOf(),
+ Instrument.Guitar
+ ),
+ ChordVariation("varid1234", "Am",
+ arrayListOf(
+ ChordMarker.Note(FretNumber(1), Finger.INDEX, StringNumber(4)),
+ ChordMarker.Note(FretNumber(2), Finger.MIDDLE, StringNumber(3)),
+ ChordMarker.Note(FretNumber(2), Finger.RING, StringNumber(2))
+ ),
+ arrayListOf(
+ ChordMarker.Open(StringNumber(1)),
+ ChordMarker.Open(StringNumber(5))
+ ),
+ arrayListOf(
+ ChordMarker.Muted(StringNumber(6))
+ ),
+ arrayListOf(),
+ Instrument.Guitar
+ )
+ )
+
+ AppTheme {
+ ChordPager(title = "Am", chordVariations = chords)
+ }
+}
+
+
+@Composable @Preview
+fun ChordPagerBarredChordsPreview() {
+ /**
+ * Automatically add these chords to an empty constructor
+ */
+ val chords = listOf(
+ ChordVariation("varid1234", "Am",
+ noteChordMarkers = arrayListOf(
+ ChordMarker.Note(FretNumber(4), Finger.MIDDLE, StringNumber(2)),
+ ChordMarker.Note(FretNumber(5), Finger.RING, StringNumber(3)),
+ ChordMarker.Note(FretNumber(5), Finger.PINKY, StringNumber(4))
+ ),
+ openChordMarkers = arrayListOf(),
+ mutedChordMarkers = arrayListOf(
+ ChordMarker.Muted(StringNumber(6))
+ ),
+ barChordMarkers = arrayListOf(
+ ChordMarker.Bar(FretNumber(3), Finger.INDEX, StringNumber(1), StringNumber(5))
+ ),
+ Instrument.Guitar
+ ),
+ ChordVariation("varid1234", "Am",
+ arrayListOf(
+ ChordMarker.Note(FretNumber(1), Finger.INDEX, StringNumber(4)),
+ ChordMarker.Note(FretNumber(2), Finger.MIDDLE, StringNumber(3)),
+ ChordMarker.Note(FretNumber(2), Finger.RING, StringNumber(2))
+ ),
+ arrayListOf(
+ ChordMarker.Open(StringNumber(1)),
+ ChordMarker.Open(StringNumber(5))
+ ),
+ arrayListOf(
+ ChordMarker.Muted(StringNumber(6))
+ ),
+ arrayListOf(),
+ Instrument.Guitar
+ ),
+ ChordVariation("varid1234", "Am",
+ arrayListOf(
+ ChordMarker.Note(FretNumber(1), Finger.INDEX, StringNumber(4)),
+ ChordMarker.Note(FretNumber(2), Finger.MIDDLE, StringNumber(3)),
+ ChordMarker.Note(FretNumber(2), Finger.RING, StringNumber(2))
+ ),
+ arrayListOf(
+ ChordMarker.Open(StringNumber(1)),
+ ChordMarker.Open(StringNumber(5))
+ ),
+ arrayListOf(
+ ChordMarker.Muted(StringNumber(6))
+ ),
+ arrayListOf(),
+ Instrument.Guitar
+ )
+ )
+
+ AppTheme {
+ ChordPager(title = "Am", chordVariations = chords)
+ }
+}
+
+//endregion
diff --git a/app/src/main/java/com/gbros/tabslite/view/chorddisplay/HorizontalIndicatorPager.kt b/app/src/main/java/com/gbros/tabslite/view/chorddisplay/HorizontalIndicatorPager.kt
new file mode 100644
index 0000000..d6fb556
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/chorddisplay/HorizontalIndicatorPager.kt
@@ -0,0 +1,126 @@
+package com.gbros.tabslite.view.chorddisplay
+
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.PagerScope
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.gbros.tabslite.ui.theme.AppTheme
+
+/**
+ * HorizontalPager with automatic indicators at the bottom. Automatically tracks pager state.
+ *
+ * Thanks https://bootcamp.uxdesign.cc/improving-compose-horizontal-pager-indicator-bcf3b67835a
+ *
+ * @param pageCount: The number of pages to display
+ * @param content: A content generator given the page to display
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun HorizontalIndicatorPager(modifier: Modifier = Modifier, pageCount: Int, content: @Composable PagerScope.(page: Int) -> Unit) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ val pagerState = rememberPagerState(pageCount = { pageCount })
+ val indicatorScrollState = rememberLazyListState()
+
+ LaunchedEffect(key1 = pagerState.currentPage, block = {
+ // Make sure the page indicator representing this page is visible
+ val size = indicatorScrollState.layoutInfo.visibleItemsInfo.size
+ if (size > 1) {
+ val currentPage = pagerState.currentPage
+ val lastVisibleIndex =
+ indicatorScrollState.layoutInfo.visibleItemsInfo.last().index // don't run with empty lists to prevent crashes
+ val firstVisibleItemIndex = indicatorScrollState.firstVisibleItemIndex
+
+ if (currentPage > lastVisibleIndex - 1) {
+ indicatorScrollState.animateScrollToItem(currentPage - size + 2)
+ } else if (currentPage <= firstVisibleItemIndex + 1) {
+ indicatorScrollState.animateScrollToItem((currentPage - 1).coerceAtLeast(0))
+ }
+ }
+ })
+ HorizontalPager(
+ state = pagerState,
+ pageContent = content
+ )
+
+ val activeColor = MaterialTheme.colorScheme.outline
+ val inactiveColor = MaterialTheme.colorScheme.outlineVariant
+
+ // scroll state
+ LazyRow(
+ state = indicatorScrollState,
+ userScrollEnabled = false,
+ modifier = Modifier
+ .width(((6 + 16) * 2 + 3 * (10 + 16)).dp), // I'm hard computing it to simplify
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ repeat(pageCount) { iteration ->
+ val color = if (pagerState.currentPage == iteration) activeColor else inactiveColor
+ item(key = "item$iteration") {
+ val currentPage = pagerState.currentPage
+ val firstVisibleIndex by remember { derivedStateOf { indicatorScrollState.firstVisibleItemIndex } }
+ val lastVisibleIndex = indicatorScrollState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
+ val size by animateDpAsState(
+ targetValue = when (iteration) {
+ currentPage -> 10.dp
+ in (firstVisibleIndex + 1) until lastVisibleIndex -> 10.dp
+ else -> 6.dp
+ },
+ label = "horizontal indicator size"
+ )
+ Box(
+ modifier = Modifier
+ .padding(8.dp)
+ .background(color, CircleShape)
+ .size(size)
+ )
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable @Preview
+fun HorizontalIndicatorPagerPreview() {
+ AppTheme {
+ Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) {
+ HorizontalIndicatorPager(pageCount = 100) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(text = "Page $it", color = MaterialTheme.colorScheme.onBackground)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/gbros/tabslite/view/chorddisplay/InstrumentSelector.kt b/app/src/main/java/com/gbros/tabslite/view/chorddisplay/InstrumentSelector.kt
new file mode 100644
index 0000000..f35266a
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/chorddisplay/InstrumentSelector.kt
@@ -0,0 +1,85 @@
+package com.gbros.tabslite.view.chorddisplay
+
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.OutlinedIconButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.tooling.preview.Preview
+import com.gbros.tabslite.R
+import com.gbros.tabslite.data.chord.Instrument
+import com.gbros.tabslite.ui.theme.AppTheme
+
+@Composable
+fun InstrumentSelector(selectedInstrument: Instrument, onInstrumentSelected: (Instrument) -> Unit) {
+ Row (
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ SelectableIconButton(
+ onClick = { onInstrumentSelected(Instrument.Guitar) },
+ selected = selectedInstrument == Instrument.Guitar,
+ iconId = R.drawable.ic_tabslite_guitar,
+ contentDescription = stringResource(R.string.instrument_title_guitar),
+ )
+ SelectableIconButton(
+ onClick = { onInstrumentSelected(Instrument.Ukulele) },
+ selected = selectedInstrument == Instrument.Ukulele,
+ iconId = R.drawable.ic_ukulele,
+ contentDescription = stringResource(R.string.instrument_title_ukulele),
+ )
+ }
+}
+
+@Composable
+private fun SelectableIconButton(
+ onClick: () -> Unit,
+ selected: Boolean,
+ @DrawableRes iconId: Int,
+ contentDescription: String,
+ enabled: Boolean = true,
+) {
+ val icon = @Composable {
+ Icon(
+ imageVector = ImageVector.vectorResource(id = iconId),
+ contentDescription = contentDescription
+ )
+ }
+
+ if (selected) {
+ FilledTonalButton(
+ onClick = { }, // this is already selected; ignore the tap
+ enabled = enabled,
+ content = {
+ icon()
+ Text(
+ text = contentDescription
+ )
+ },
+ )
+ }
+ else {
+ OutlinedIconButton(
+ onClick = onClick,
+ enabled = enabled,
+ content = icon,
+ )
+ }
+}
+
+
+
+@Preview
+@Composable
+private fun InstrumentSelectorPreview() {
+ AppTheme {
+ InstrumentSelector(selectedInstrument = Instrument.Guitar, {})
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/chorddisplay/UseFlatsToggle.kt b/app/src/main/java/com/gbros/tabslite/view/chorddisplay/UseFlatsToggle.kt
new file mode 100644
index 0000000..451edbb
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/chorddisplay/UseFlatsToggle.kt
@@ -0,0 +1,28 @@
+package com.gbros.tabslite.view.chorddisplay
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.OutlinedIconToggleButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.tooling.preview.Preview
+import com.gbros.tabslite.ui.theme.AppTheme
+
+@Composable
+fun UseFlatsToggle(checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
+ OutlinedIconToggleButton (checked = checked, onCheckedChange = onCheckedChange) {
+ Text(text = "b", fontStyle = FontStyle.Italic)
+ }
+}
+
+
+@Preview
+@Composable
+private fun UseFlatsTogglePreview() {
+ AppTheme {
+ Column {
+ UseFlatsToggle(true, {})
+ UseFlatsToggle(false, {})
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/homescreen/AboutDialog.kt b/app/src/main/java/com/gbros/tabslite/view/homescreen/AboutDialog.kt
new file mode 100644
index 0000000..bb31aa4
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/homescreen/AboutDialog.kt
@@ -0,0 +1,217 @@
+package com.gbros.tabslite.view.homescreen
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuAnchorType
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import com.gbros.tabslite.R
+import com.gbros.tabslite.data.ThemeSelection
+import com.gbros.tabslite.ui.theme.AppTheme
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AboutDialog(
+ modifier: Modifier = Modifier,
+ selectedTheme: ThemeSelection,
+ onDismissRequest: () -> Unit,
+ onExportPlaylistsClicked: () -> Unit,
+ onImportPlaylistsClicked: () -> Unit,
+ onSwitchThemeMode: (ThemeSelection) -> Unit,
+) {
+ Dialog(onDismissRequest = onDismissRequest) {
+ Card(
+ modifier = modifier,
+ shape = MaterialTheme.shapes.extraLarge
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Start
+ ) {
+ IconButton(modifier = Modifier.padding(all = 4.dp), onClick = onDismissRequest) {
+ Icon(imageVector = Icons.Default.Close, contentDescription = stringResource(id = R.string.generic_action_close))
+ }
+ }
+ Row(
+ modifier = Modifier
+ .matchParentSize(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Text(text = stringResource(id = R.string.app_name), style = MaterialTheme.typography.titleLarge)
+ }
+ }
+
+ Card(
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .fillMaxWidth(),
+ colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surfaceContainer),
+ shape = MaterialTheme.shapes.extraLarge.copy(bottomStart = MaterialTheme.shapes.extraSmall.bottomStart, bottomEnd = MaterialTheme.shapes.extraSmall.bottomEnd)
+ ) {
+ Text(modifier = Modifier.padding(all = 16.dp), text = stringResource(id = R.string.app_about))
+ }
+ Spacer(modifier = Modifier.height(4.dp))
+ Card(
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .fillMaxWidth(),
+ colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surfaceContainer),
+ shape = MaterialTheme.shapes.extraSmall
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .padding(all = 8.dp)
+ .fillMaxWidth()
+ ) {
+ Text(modifier = Modifier.padding(all = 8.dp), text = stringResource(R.string.theme_selection_title))
+ Spacer(modifier = Modifier.weight(1f))
+ // versions dropdown to switch versions of this song
+ var themeDropdownExpanded by remember { mutableStateOf(false) }
+ val currentDarkModePreference = when (selectedTheme) {
+ ThemeSelection.ForceDark -> {
+ stringResource(id = R.string.theme_selection_dark)
+ }
+ ThemeSelection.ForceLight -> {
+ stringResource(id = R.string.theme_selection_light)
+ }
+ else -> {
+ stringResource(id = R.string.theme_selection_system)
+ }
+ }
+ ExposedDropdownMenuBox(
+ expanded = themeDropdownExpanded,
+ onExpandedChange = { themeDropdownExpanded = !themeDropdownExpanded },
+ modifier = Modifier
+ .width(200.dp)
+ .padding(start = 8.dp)
+ ) {
+ TextField(
+ value = currentDarkModePreference,
+ onValueChange = {},
+ readOnly = true,
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = themeDropdownExpanded) },
+ colors = ExposedDropdownMenuDefaults.textFieldColors(),
+ modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable)
+ )
+ ExposedDropdownMenu(
+ expanded = themeDropdownExpanded,
+ onDismissRequest = { themeDropdownExpanded = false }
+ ) {
+ DropdownMenuItem(
+ text = { Text(stringResource(id = R.string.theme_selection_system)) },
+ onClick = {
+ onSwitchThemeMode(ThemeSelection.System)
+ themeDropdownExpanded = false
+ }
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(id = R.string.theme_selection_light)) },
+ onClick = {
+ onSwitchThemeMode(ThemeSelection.ForceLight)
+ themeDropdownExpanded = false
+ }
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(id = R.string.theme_selection_dark)) },
+ onClick = {
+ onSwitchThemeMode(ThemeSelection.ForceDark)
+ themeDropdownExpanded = false
+ }
+ )
+ }
+ }
+
+ }
+ }
+ Spacer(modifier = Modifier.height(4.dp))
+ Card(
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .fillMaxWidth(),
+ colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surfaceContainer),
+ shape = MaterialTheme.shapes.extraLarge.copy(topStart = MaterialTheme.shapes.extraSmall.topStart, topEnd = MaterialTheme.shapes.extraSmall.topEnd)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .padding(all = 8.dp)
+ .fillMaxWidth()
+ .clickable { onImportPlaylistsClicked() }
+ ) {
+ Icon(modifier = Modifier.padding(all = 8.dp), imageVector = ImageVector.vectorResource(id = R.drawable.ic_download), contentDescription = "")
+ Text(modifier = Modifier.padding(all = 8.dp), text = stringResource(id = R.string.app_action_import_playlists))
+ }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .padding(all = 8.dp)
+ .fillMaxWidth()
+ .clickable { onExportPlaylistsClicked() }
+ ) {
+ Icon(modifier = Modifier.padding(all = 8.dp), imageVector = ImageVector.vectorResource(id = R.drawable.ic_upload), contentDescription = "")
+ Text(modifier = Modifier.padding(all = 8.dp), text = stringResource(id = R.string.app_action_export_playlists))
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ val uriHandler = LocalUriHandler.current
+ TextButton(onClick = { uriHandler.openUri("https://play.google.com/store/apps/details?id=com.gbros.tabslite") }) {
+ Text(text = stringResource(id = R.string.app_action_leave_review))
+ }
+ TextButton(onClick = { uriHandler.openUri("https://github.com/sponsors/More-Than-Solitaire") }) {
+ Text(text = stringResource(id = R.string.app_action_donate))
+ }
+ }
+ }
+ }
+}
+
+@Composable @Preview
+private fun AboutDialogPreview() {
+ AppTheme {
+ AboutDialog(Modifier, ThemeSelection.System, {}, {}, {}, {})
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/homescreen/HomeScreen.kt b/app/src/main/java/com/gbros/tabslite/view/homescreen/HomeScreen.kt
new file mode 100644
index 0000000..ebaf238
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/homescreen/HomeScreen.kt
@@ -0,0 +1,446 @@
+package com.gbros.tabslite.view.homescreen
+
+import android.app.Activity.RESULT_OK
+import android.content.ContentResolver
+import android.content.Intent
+import android.content.res.Configuration
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material.icons.filled.FavoriteBorder
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.outlined.Person
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.PrimaryTabRow
+import androidx.compose.material3.Tab
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.compose.composable
+import com.gbros.tabslite.LoadingState
+import com.gbros.tabslite.R
+import com.gbros.tabslite.data.AppDatabase
+import com.gbros.tabslite.data.ThemeSelection
+import com.gbros.tabslite.data.playlist.Playlist
+import com.gbros.tabslite.data.tab.ITab
+import com.gbros.tabslite.data.tab.TabWithDataPlaylistEntry
+import com.gbros.tabslite.ui.theme.AppTheme
+import com.gbros.tabslite.view.playlists.PlaylistsSortBy
+import com.gbros.tabslite.view.songlist.ISongListViewState
+import com.gbros.tabslite.view.songlist.SongListView
+import com.gbros.tabslite.view.songlist.SortBy
+import com.gbros.tabslite.view.songlist.SortByDropdown
+import com.gbros.tabslite.view.tabsearchbar.ITabSearchBarViewState
+import com.gbros.tabslite.view.tabsearchbar.TabsSearchBar
+import com.gbros.tabslite.viewmodel.HomeViewModel
+
+const val HOME_ROUTE = "home"
+
+fun NavController.popUpToHome() {
+ if (!popBackStack(route = HOME_ROUTE, inclusive = false)) {
+ // fallback if HOME_ROUTE wasn't on the back stack
+ navigate(HOME_ROUTE)
+ }
+}
+
+fun NavGraphBuilder.homeScreen(
+ onNavigateToSearch: (String) -> Unit,
+ onNavigateToTab: (Int) -> Unit,
+ onNavigateToPlaylist: (Int) -> Unit
+) {
+ composable(HOME_ROUTE) {
+ val db = AppDatabase.getInstance(LocalContext.current)
+ val viewModel: HomeViewModel = hiltViewModel { factory -> factory.create(dataAccess = db.dataAccess()) }
+ HomeScreen(
+ viewState = viewModel,
+ favoriteSongListViewState = viewModel.favoriteSongListViewModel,
+ onFavoriteSongListSortByChange = viewModel.favoriteSongListViewModel::onSortSelectionChange,
+ popularSongListViewState = viewModel.popularSongListViewModel,
+ onPopularSongListSortByChange = viewModel.popularSongListViewModel::onSortSelectionChange,
+ onPlaylistsSortByChange = viewModel::sortPlaylists,
+ tabSearchBarViewState = viewModel.tabSearchBarViewModel,
+ onTabSearchBarQueryChange = viewModel.tabSearchBarViewModel::onQueryChange,
+ onNavigateToSearch = onNavigateToSearch,
+ onExportPlaylists = viewModel::exportPlaylists,
+ onImportPlaylists = viewModel::importPlaylists,
+ onCreatePlaylist = viewModel::createPlaylist,
+ onThemeSelectionChange = viewModel::setAppTheme,
+ navigateToPlaylistById = onNavigateToPlaylist,
+ navigateToTabByTabId = onNavigateToTab
+ )
+ }
+}
+
+@Composable
+fun HomeScreen(
+ viewState: IHomeViewState,
+ favoriteSongListViewState: ISongListViewState,
+ onFavoriteSongListSortByChange: (SortBy) -> Unit,
+ popularSongListViewState: ISongListViewState,
+ onPopularSongListSortByChange: (SortBy) -> Unit,
+ onPlaylistsSortByChange: (PlaylistsSortBy) -> Unit,
+ tabSearchBarViewState: ITabSearchBarViewState,
+ onTabSearchBarQueryChange: (query: String) -> Unit,
+ onNavigateToSearch: (query: String) -> Unit,
+ onExportPlaylists: (destinationFile: Uri, contentResolver: ContentResolver) -> Unit,
+ onImportPlaylists: (sourceFile: Uri, contentResolver: ContentResolver) -> Unit,
+ onCreatePlaylist: (title: String, description: String) -> Unit,
+ onThemeSelectionChange: (ThemeSelection) -> Unit,
+ navigateToTabByTabId: (id: Int) -> Unit,
+ navigateToPlaylistById: (id: Int) -> Unit
+) {
+ val pagerState = rememberPagerState(initialPage = 0, pageCount = { 3 })
+ val secondaryPagerState = rememberPagerState(initialPage = 0, pageCount = { 3 })
+ val scrollingFollowingPair by remember { // handle the sort by dropdown being in a separate pager
+ derivedStateOf {
+ if (pagerState.isScrollInProgress) {
+ pagerState to secondaryPagerState
+ } else if (secondaryPagerState.isScrollInProgress) {
+ secondaryPagerState to pagerState
+ } else null
+ }
+ }
+ var pagerNav by remember { mutableIntStateOf(-1) }
+
+ var showAboutDialog by remember { mutableStateOf(false) }
+ val contentResolver = LocalContext.current.contentResolver
+
+ // handle playlist data export
+ val exportDataFilePickerActivityLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == RESULT_OK && result.data?.data != null) {
+ onExportPlaylists(result.data!!.data!!, contentResolver)
+ } // else: user cancelled the action
+ }
+
+ // handle playlist data import
+ val importPlaylistsPickerLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { fileToImport ->
+ if (fileToImport != null) {
+ onImportPlaylists(fileToImport, contentResolver)
+ } // else: user cancelled the action
+ }
+
+ if (showAboutDialog) {
+ AboutDialog(
+ selectedTheme = viewState.selectedAppTheme.observeAsState(ThemeSelection.System).value,
+ onDismissRequest = { showAboutDialog = false },
+ onExportPlaylistsClicked = {
+ showAboutDialog = false
+
+ // launch a file picker to find where to export the playlist data to
+ val filePickerEvent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = "application/json"
+ putExtra(Intent.EXTRA_TITLE, "tabslite_backup.json")
+ }
+ exportDataFilePickerActivityLauncher.launch(filePickerEvent)
+ },
+ onImportPlaylistsClicked = {
+ showAboutDialog = false
+
+ // launch a file picker to choose the file to import
+ importPlaylistsPickerLauncher.launch("application/json")
+ },
+ onSwitchThemeMode = onThemeSelectionChange
+ )
+ }
+
+
+ val content = @Composable {
+ val columnModifier = if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ Modifier
+ .fillMaxWidth(0.4f)
+ } else {
+ Modifier
+ }
+
+ Column(
+ modifier = columnModifier
+ ) {
+ TabsSearchBar(
+ modifier = Modifier
+ .fillMaxWidth(),
+ leadingIcon = {
+ IconButton(onClick = { showAboutDialog = true }) {
+ Box(modifier = Modifier) {
+ val importProgress = viewState.playlistImportProgress.observeAsState(0f)
+ CircularProgressIndicator(progress = { importProgress.value })
+ }
+ Icon(
+ imageVector = ImageVector.vectorResource(id = R.drawable.ic_launcher_foreground),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ },
+ viewState = tabSearchBarViewState,
+ onSearch = onNavigateToSearch,
+ onQueryChange = onTabSearchBarQueryChange,
+ onNavigateToTabById = navigateToTabByTabId
+ )
+ PrimaryTabRow (
+ selectedTabIndex = pagerState.currentPage,
+ containerColor = Color.Unspecified
+ ) {
+ TabRowItem(
+ selected = pagerState.currentPage == 0,
+ inactiveIcon = Icons.Default.FavoriteBorder,
+ activeIcon = Icons.Filled.Favorite,
+ title = stringResource(id = R.string.title_favorites_playlist)
+ ) {
+ pagerNav = if (pagerNav != 0) 0 else -1
+ }
+ TabRowItem(
+ selected = pagerState.currentPage == 1,
+ inactiveIcon = Icons.Outlined.Person,
+ activeIcon = Icons.Filled.Person,
+ title = stringResource(id = R.string.title_popular_playlist)
+ ) {
+ pagerNav = if (pagerNav != 1) 1 else -1
+ }
+ TabRowItem(
+ selected = pagerState.currentPage == 2,
+ inactiveIcon = ImageVector.vectorResource(R.drawable.ic_playlist_play_light),
+ activeIcon = ImageVector.vectorResource(R.drawable.ic_playlist_play),
+ title = stringResource(id = R.string.title_playlists_page)
+ ) {
+ pagerNav = if (pagerNav != 2) 2 else -1
+ }
+ }
+
+ // Sort By dropdowns
+ HorizontalPager(
+ state = secondaryPagerState,
+ verticalAlignment = Alignment.Top,
+ beyondViewportPageCount = 3,
+ contentPadding = PaddingValues(start = 8.dp, end = 8.dp, top = 8.dp),
+ pageSpacing = 8.dp,
+ modifier = Modifier
+ ) { page ->
+ when (page) {
+ // Favorites page
+ 0 -> SortByDropdown(
+ selectedSort = favoriteSongListViewState.sortBy.observeAsState().value,
+ onOptionSelected = onFavoriteSongListSortByChange
+ )
+
+ // Popular page
+ 1 -> SortByDropdown(
+ selectedSort = popularSongListViewState.sortBy.observeAsState().value,
+ onOptionSelected = onPopularSongListSortByChange
+ )
+
+ // Playlists page
+ 2 -> SortByDropdown(
+ selectedSort = viewState.playlistsSortBy.observeAsState().value,
+ onOptionSelected = onPlaylistsSortByChange
+ )
+ }
+ }
+ }
+
+ HorizontalPager(
+ state = pagerState,
+ verticalAlignment = Alignment.Top,
+ beyondViewportPageCount = 3,
+ contentPadding = PaddingValues(horizontal = 8.dp),
+ pageSpacing = 8.dp,
+ modifier = Modifier
+ .fillMaxHeight()
+ ) { page ->
+ when (page) {
+ // Favorites page
+ 0 -> SongListView(
+ viewState = favoriteSongListViewState,
+ emptyListText = stringResource(R.string.empty_favorites),
+ navigateToTabById = navigateToTabByTabId,
+ navigateByPlaylistEntryId = false,
+ )
+
+ // Popular page
+ 1 -> SongListView(
+ viewState = popularSongListViewState,
+ emptyListText = stringResource(R.string.empty_popular),
+ navigateToTabById = navigateToTabByTabId,
+ navigateByPlaylistEntryId = false, // can't navigate by playlisty entry because the playlist entries get cleared and refreshed each time the activity starts (e.g. when device is rotated or dark mode is enabled)
+ )
+
+ // Playlists page
+ 2 -> PlaylistListView(
+ livePlaylists = viewState.playlists,
+ onCreatePlaylist = onCreatePlaylist,
+ navigateToPlaylistById = navigateToPlaylistById
+ )
+ }
+ }
+ }
+
+ // adjust view based on device orientation
+ if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ Row (
+ modifier = Modifier
+ .windowInsetsPadding(WindowInsets(
+ left = WindowInsets.safeDrawing.getLeft(LocalDensity.current, LocalLayoutDirection.current),
+ right = WindowInsets.safeDrawing.getRight(LocalDensity.current, LocalLayoutDirection.current)
+ )),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ content = {
+ content()
+ }
+ )
+ } else {
+ Column(
+ modifier = Modifier
+ .windowInsetsPadding(WindowInsets(
+ left = WindowInsets.safeDrawing.getLeft(LocalDensity.current, LocalLayoutDirection.current),
+ right = WindowInsets.safeDrawing.getRight(LocalDensity.current, LocalLayoutDirection.current),
+ top = WindowInsets.safeDrawing.getTop(LocalDensity.current)
+ )),
+ content = {
+ content()
+ }
+ )
+ }
+
+ // scroll to page when that page's tab is clicked
+ LaunchedEffect(pagerNav) {
+ if (pagerNav >= 0 && pagerNav != pagerState.currentPage) {
+ pagerState.animateScrollToPage(pagerNav)
+ }
+ pagerNav = -1
+ }
+
+ // sync secondary horizontal pager for sort by dropdown to primary (and vice versa)
+ LaunchedEffect(scrollingFollowingPair) {
+ val (scrollingState, followingState) = scrollingFollowingPair ?: return@LaunchedEffect
+ snapshotFlow { Pair(scrollingState.currentPage, scrollingState.currentPageOffsetFraction) }
+ .collect { (currentPage, currentPageOffsetFraction) ->
+ followingState.scrollToPage(
+ page = currentPage,
+ pageOffsetFraction = currentPageOffsetFraction
+ )
+ }
+ }
+}
+
+@Composable
+fun TabRowItem(selected: Boolean, inactiveIcon: ImageVector, activeIcon: ImageVector, title: String, onClick: () -> Unit) {
+ Tab(
+ icon = { Icon(imageVector = if(selected) activeIcon else inactiveIcon, null) },
+ text = { Text(title) },
+ selected = selected,
+ onClick = onClick
+ )
+}
+
+//#region preview / classes for test
+
+@Preview(
+ device = "spec:width=411dp,height=891dp,dpi=420,isRound=false,chinSize=0dp,orientation=landscape"
+)
+@Preview
+@Composable
+private fun HomeScreenPreview() {
+ val viewState = HomeViewStateForTest(
+ playlistImportState = MutableLiveData(LoadingState.Loading),
+ playlistImportProgress = MutableLiveData(0.6f),
+ playlists = MutableLiveData(listOf()),
+ playlistsSortBy = MutableLiveData(PlaylistsSortBy.Name),
+ selectedAppTheme = MutableLiveData(ThemeSelection.System)
+ )
+
+ val songListState = SongListViewStateForTest(
+ songs = MutableLiveData(listOf()),
+ sortBy = MutableLiveData(SortBy.DateAdded)
+ )
+
+ val tabSearchBarViewState = TabSearchBarViewStateForTest(
+ query = MutableLiveData(""),
+ searchSuggestions = MutableLiveData(listOf()),
+ tabSuggestions = MutableLiveData(listOf()),
+ loadingState = MutableLiveData(LoadingState.Loading)
+ )
+
+ AppTheme {
+ HomeScreen(
+ viewState = viewState,
+ favoriteSongListViewState = songListState,
+ onFavoriteSongListSortByChange = {},
+ popularSongListViewState = songListState,
+ onPopularSongListSortByChange = {},
+ onPlaylistsSortByChange = {},
+ tabSearchBarViewState = tabSearchBarViewState,
+ onTabSearchBarQueryChange = {},
+ onNavigateToSearch = {},
+ onExportPlaylists = {_,_->},
+ onImportPlaylists = {_,_->},
+ onCreatePlaylist = {_,_->},
+ onThemeSelectionChange = {},
+ navigateToTabByTabId = {},
+ navigateToPlaylistById = {}
+ )
+ }
+}
+
+private class HomeViewStateForTest(
+ override val playlistImportProgress: LiveData,
+ override val playlistImportState: LiveData,
+ override val playlists: LiveData>,
+ override val playlistsSortBy: LiveData,
+ override val selectedAppTheme: LiveData
+) : IHomeViewState
+
+private class SongListViewStateForTest(
+ override val songs: LiveData>,
+ override val sortBy: LiveData
+) : ISongListViewState
+
+private class TabSearchBarViewStateForTest(
+ override val query: LiveData,
+ override val searchSuggestions: LiveData>,
+ override val tabSuggestions: LiveData>,
+ override val loadingState: LiveData
+) : ITabSearchBarViewState
+
+//#endregion
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/homescreen/IHomeViewState.kt b/app/src/main/java/com/gbros/tabslite/view/homescreen/IHomeViewState.kt
new file mode 100644
index 0000000..f42a20f
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/homescreen/IHomeViewState.kt
@@ -0,0 +1,34 @@
+package com.gbros.tabslite.view.homescreen
+
+import androidx.lifecycle.LiveData
+import com.gbros.tabslite.LoadingState
+import com.gbros.tabslite.data.ThemeSelection
+import com.gbros.tabslite.data.playlist.Playlist
+import com.gbros.tabslite.view.playlists.PlaylistsSortBy
+
+interface IHomeViewState {
+ /**
+ * The percent value (0 to 100) for any ongoing import/export operation
+ */
+ val playlistImportProgress: LiveData
+
+ /**
+ * The current state of any import/export operations
+ */
+ val playlistImportState: LiveData
+
+ /**
+ * The user's saved playlists
+ */
+ val playlists: LiveData>
+
+ /**
+ * The selected sort option for playlists
+ */
+ val playlistsSortBy: LiveData
+
+ /**
+ * The selected theme (system, dark, or light)
+ */
+ val selectedAppTheme: LiveData
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/homescreen/PlaylistListView.kt b/app/src/main/java/com/gbros/tabslite/view/homescreen/PlaylistListView.kt
new file mode 100644
index 0000000..26e6143
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/homescreen/PlaylistListView.kt
@@ -0,0 +1,77 @@
+package com.gbros.tabslite.view.homescreen
+
+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.Add
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.gbros.tabslite.data.playlist.Playlist
+import com.gbros.tabslite.ui.theme.AppTheme
+import com.gbros.tabslite.view.addtoplaylistdialog.CreatePlaylistDialog
+import com.gbros.tabslite.view.playlists.PlaylistList
+
+@Composable
+fun PlaylistListView(
+ livePlaylists: LiveData>,
+ onCreatePlaylist: (title: String, description: String) -> Unit,
+ navigateToPlaylistById: (id: Int) -> Unit
+) {
+ var showCreatePlaylistDialog by remember { mutableStateOf(false) }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ PlaylistList(livePlaylists = livePlaylists, navigateToPlaylistById = navigateToPlaylistById)
+
+ FloatingActionButton(
+ onClick = {
+ showCreatePlaylistDialog = true
+ },
+ modifier = Modifier
+ .padding(16.dp)
+ .align(Alignment.BottomEnd)
+ ) {
+ Icon(imageVector = Icons.Default.Add, contentDescription = "Create Playlist")
+ }
+ }
+
+ if (showCreatePlaylistDialog) {
+ CreatePlaylistDialog(
+ onConfirm = { newPlaylistTitle, newPlaylistDescription ->
+ onCreatePlaylist(newPlaylistTitle, newPlaylistDescription)
+ showCreatePlaylistDialog = false
+ },
+ onDismiss = { showCreatePlaylistDialog = false }
+ )
+ }
+}
+
+@Composable @Preview
+private fun PlaylistPagePreview() {
+ val playlistForTest = Playlist(0, true, "Playlist Title", 0, 0, "Playlist description")
+ AppTheme {
+ PlaylistListView(
+ livePlaylists = MutableLiveData(
+ listOf(
+ playlistForTest,
+ playlistForTest,
+ playlistForTest,
+ playlistForTest
+ )
+ ),
+ onCreatePlaylist = { _, _ -> },
+ navigateToPlaylistById = {}
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/playlists/DeletePlaylistConfirmationDialog.kt b/app/src/main/java/com/gbros/tabslite/view/playlists/DeletePlaylistConfirmationDialog.kt
new file mode 100644
index 0000000..317d9fc
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/playlists/DeletePlaylistConfirmationDialog.kt
@@ -0,0 +1,49 @@
+package com.gbros.tabslite.view.playlists
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.Preview
+import com.gbros.tabslite.ui.theme.AppTheme
+
+@Composable
+fun DeletePlaylistConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
+ AlertDialog(
+ icon = {
+ Icon(Icons.Default.Delete, contentDescription = null)
+ },
+ title = {
+ Text(text = "Delete playlist?")
+ },
+ text = {
+ Text(text = "Deleting a playlist cannot be undone.")
+ },
+ onDismissRequest = onDismiss,
+ confirmButton = {
+ TextButton(
+ onClick = onConfirm,
+ ) {
+ Text("Delete", color = MaterialTheme.colorScheme.error)
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = onDismiss
+ ) {
+ Text("Cancel")
+ }
+ }
+ )
+}
+
+@Composable @Preview
+private fun DeletePlaylistConfirmationDialogPreview() {
+ AppTheme {
+ RemovePlaylistEntryConfirmationDialog({}, {})
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/playlists/IPlaylistViewState.kt b/app/src/main/java/com/gbros/tabslite/view/playlists/IPlaylistViewState.kt
new file mode 100644
index 0000000..50ccd72
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/playlists/IPlaylistViewState.kt
@@ -0,0 +1,21 @@
+package com.gbros.tabslite.view.playlists
+
+import androidx.lifecycle.LiveData
+import com.gbros.tabslite.data.tab.TabWithDataPlaylistEntry
+
+interface IPlaylistViewState {
+ /**
+ * The title of the playlist to display
+ */
+ val title: LiveData
+
+ /**
+ * The description of the playlist to display
+ */
+ val description: LiveData
+
+ /**
+ * The ordered list of songs in the playlist
+ */
+ val songs: LiveData>
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/playlists/PlaylistHeader.kt b/app/src/main/java/com/gbros/tabslite/view/playlists/PlaylistHeader.kt
new file mode 100644
index 0000000..57f3d59
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/playlists/PlaylistHeader.kt
@@ -0,0 +1,98 @@
+package com.gbros.tabslite.view.playlists
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.gbros.tabslite.ui.theme.AppTheme
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PlaylistHeader(
+ title: LiveData,
+ description: LiveData,
+ titleChanged: (title: String) -> Unit,
+ descriptionChanged: (description: String) -> Unit,
+ navigateBack: () -> Unit,
+ deletePlaylist: () -> Unit
+) {
+ var titleToDisplay: String by remember(key1 = title.observeAsState().value) { mutableStateOf(title.value ?: "") }
+ var descriptionToDisplay: String by remember(key1 = description.observeAsState().value) { mutableStateOf(description.value ?: "") }
+
+ var titleWasFocused: Boolean by remember { mutableStateOf(false) }
+ var descriptionWasFocused: Boolean by remember { mutableStateOf(false) }
+
+ Column {
+ TopAppBar(
+ title = {
+ TextField(
+ value = titleToDisplay,
+ onValueChange = {newTitle: String -> titleToDisplay = newTitle},
+ singleLine = true,
+ placeholder = { Text("Playlist Name") },
+ colors = TextFieldDefaults.colors(unfocusedContainerColor = MaterialTheme.colorScheme.background),
+ modifier = Modifier
+ .fillMaxWidth()
+ .onFocusChanged {
+ if (titleWasFocused && !it.isFocused && titleToDisplay != title.value) {
+ titleChanged(titleToDisplay)
+ }
+ titleWasFocused = it.isFocused
+ }
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = navigateBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
+ }
+ },
+ actions = {
+ IconButton(onClick = deletePlaylist) {
+ Icon(Icons.Default.Delete, "Delete")
+ }
+ }
+ )
+
+ TextField(
+ value = descriptionToDisplay,
+ onValueChange = { newDescription: String -> descriptionToDisplay = newDescription },
+ placeholder = { Text("Playlist Description") },
+ colors = TextFieldDefaults.colors(unfocusedContainerColor = MaterialTheme.colorScheme.background),
+ modifier = Modifier
+ .fillMaxWidth()
+ .onFocusChanged {
+ if (descriptionWasFocused && !it.isFocused && descriptionToDisplay != description.value) {
+ descriptionChanged(descriptionToDisplay)
+ }
+ descriptionWasFocused = it.isFocused
+ }
+ )
+ }
+}
+
+@Composable @Preview
+private fun PlaylistHeaderPreview() {
+ AppTheme {
+ PlaylistHeader(MutableLiveData("Playlist title"), MutableLiveData("playlist description"), {}, {}, {}, {})
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/playlists/PlaylistList.kt b/app/src/main/java/com/gbros/tabslite/view/playlists/PlaylistList.kt
new file mode 100644
index 0000000..a263295
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/playlists/PlaylistList.kt
@@ -0,0 +1,77 @@
+package com.gbros.tabslite.view.playlists
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.gbros.tabslite.data.playlist.Playlist
+import com.gbros.tabslite.ui.theme.AppTheme
+import com.gbros.tabslite.view.card.InfoCard
+
+@Composable
+fun PlaylistList(modifier: Modifier = Modifier, livePlaylists: LiveData>, navigateToPlaylistById: (Int) -> Unit, verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(4.dp)){
+ val playlists by livePlaylists.observeAsState(listOf())
+
+ if (playlists.isEmpty()) {
+ // no playlists
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(all = 16.dp)
+ ) {
+ InfoCard(text = "Create your first playlist by clicking the + button here, or find a song to start and then select the three dot menu at the top right of the screen")
+ }
+ } else {
+
+ LazyColumn(
+ verticalArrangement = verticalArrangement,
+ modifier = modifier
+ ) {
+ item {
+ Spacer(modifier = Modifier.height(height = 6.dp))
+ Spacer(modifier = Modifier.windowInsetsPadding(WindowInsets(
+ top = WindowInsets.safeDrawing.getTop(LocalDensity.current)
+ )))
+ }
+ items(playlists) { playlist ->
+ PlaylistListItem(playlist = playlist) {
+ navigateToPlaylistById(playlist.playlistId)
+ }
+ }
+ item {
+ Spacer(modifier = Modifier.height(height = 24.dp))
+ Spacer(modifier = Modifier.windowInsetsPadding(WindowInsets(
+ bottom = WindowInsets.safeDrawing.getBottom(LocalDensity.current)
+ )))
+ }
+ }
+ }
+}
+
+@Composable @Preview
+private fun PlaylistListPreview() {
+ val playlistForTest = Playlist(1, true, "My amazing playlist 1.0.1", 12345, 12345, "The playlist that I'm going to use to test this playlist entry item thing with lots of text.")
+ val list = MutableLiveData(listOf(playlistForTest, playlistForTest, playlistForTest ,playlistForTest, playlistForTest))
+
+ AppTheme {
+ PlaylistList(livePlaylists = list, navigateToPlaylistById = {})
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/playlists/PlaylistListItem.kt b/app/src/main/java/com/gbros/tabslite/view/playlists/PlaylistListItem.kt
new file mode 100644
index 0000000..fe03f85
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/playlists/PlaylistListItem.kt
@@ -0,0 +1,63 @@
+package com.gbros.tabslite.view.playlists
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.absolutePadding
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.Card
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.gbros.tabslite.data.playlist.Playlist
+import com.gbros.tabslite.ui.theme.AppTheme
+
+/**
+ * Single list item representing one playlist
+ */
+@Composable
+fun PlaylistListItem(playlist: Playlist, onClick: () -> Unit) {
+ Card(
+ modifier = Modifier
+ .clickable(onClick = onClick)
+ .focusable()
+ .fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier
+ .absolutePadding(5.dp, 5.dp, 5.dp, 5.dp)
+ .fillMaxWidth()
+ ) {
+ Column (
+ modifier = Modifier
+ .weight(1f)
+ ){
+ Text(
+ text = playlist.title,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = playlist.description,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ }
+}
+
+@Composable
+@Preview
+fun PlaylistListItemPreview(){
+ val playlistForTest = Playlist(1, true, "My amazing playlist 1.0.1", 12345, 12345, "The playlist that I'm going to use to test this playlist entry item thing with lots of text.")
+ AppTheme {
+ PlaylistListItem(playlist = playlistForTest) {}
+ }
+}
diff --git a/app/src/main/java/com/gbros/tabslite/view/playlists/PlaylistScreen.kt b/app/src/main/java/com/gbros/tabslite/view/playlists/PlaylistScreen.kt
new file mode 100644
index 0000000..7b8cd5e
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/playlists/PlaylistScreen.kt
@@ -0,0 +1,159 @@
+package com.gbros.tabslite.view.playlists
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Column
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavType
+import androidx.navigation.compose.composable
+import androidx.navigation.navArgument
+import com.gbros.tabslite.data.AppDatabase
+import com.gbros.tabslite.data.playlist.IDataPlaylistEntry
+import com.gbros.tabslite.data.playlist.Playlist
+import com.gbros.tabslite.data.tab.TabWithDataPlaylistEntry
+import com.gbros.tabslite.ui.theme.AppTheme
+import com.gbros.tabslite.viewmodel.PlaylistViewModel
+
+private const val PLAYLIST_NAV_ARG = "playlistId"
+private const val PLAYLIST_DETAIL_ROUTE_TEMPLATE = "playlist/%s"
+
+fun NavController.navigateToPlaylistDetail(playlistId: Int) {
+ navigate(PLAYLIST_DETAIL_ROUTE_TEMPLATE.format(playlistId.toString()))
+}
+
+fun NavGraphBuilder.playlistDetailScreen(
+ onNavigateToTabByPlaylistEntryId: (Int) -> Unit,
+ onNavigateBack: () -> Unit
+) {
+ composable(
+ route = PLAYLIST_DETAIL_ROUTE_TEMPLATE.format("{$PLAYLIST_NAV_ARG}"),
+ arguments = listOf(navArgument(PLAYLIST_NAV_ARG) { type = NavType.IntType })
+ ) { navBackStackEntry ->
+ val playlistId = navBackStackEntry.arguments!!.getInt(PLAYLIST_NAV_ARG)
+ val db = AppDatabase.getInstance(LocalContext.current)
+ val viewModel: PlaylistViewModel = hiltViewModel { factory -> factory.create(playlistId, db.dataAccess()) }
+
+ PlaylistScreen(
+ viewState = viewModel,
+ titleChanged = viewModel::titleChanged,
+ descriptionChanged = viewModel::descriptionChanged,
+ entryMoved = viewModel::reorderPlaylistEntry,
+ entryRemoved = viewModel::entryRemoved,
+ playlistDeleted = viewModel::playlistDeleted,
+ navigateToTabByPlaylistEntryId = onNavigateToTabByPlaylistEntryId,
+ navigateBack = onNavigateBack
+ )
+ }
+}
+
+@Composable
+fun PlaylistScreen(
+ viewState: IPlaylistViewState,
+ titleChanged: (newTitle: String) -> Unit,
+ descriptionChanged: (newDescription: String) -> Unit,
+ entryMoved: (fromIndex: Int, toIndex: Int) -> Unit,
+ entryRemoved: (entry: IDataPlaylistEntry) -> Unit,
+ playlistDeleted: () -> Unit,
+ navigateToTabByPlaylistEntryId: (Int) -> Unit,
+ navigateBack: () -> Unit
+) {
+ var deletePlaylistConfirmationDialogShowing by remember { mutableStateOf(false) }
+ val focusManager = LocalFocusManager.current
+
+ Column(
+ modifier = Modifier
+ ) {
+ PlaylistHeader(
+ title = viewState.title,
+ description = viewState.description,
+ titleChanged = titleChanged,
+ descriptionChanged = descriptionChanged,
+ navigateBack = navigateBack,
+ deletePlaylist = {
+ deletePlaylistConfirmationDialogShowing = true
+ }
+ )
+
+ PlaylistSongList(
+ songs = viewState.songs.observeAsState(listOf()).value,
+ navigateToTabByPlaylistEntryId = {entryId ->
+ focusManager.clearFocus() // this will trigger saving the playlist title and description if changed
+ navigateToTabByPlaylistEntryId(entryId)
+ },
+ onReorder = entryMoved,
+ onRemove = entryRemoved
+ )
+ }
+
+ if (deletePlaylistConfirmationDialogShowing) {
+ DeletePlaylistConfirmationDialog(
+ onConfirm = { deletePlaylistConfirmationDialogShowing = false; playlistDeleted(); navigateBack() },
+ onDismiss = { deletePlaylistConfirmationDialogShowing = false }
+ )
+ }
+
+ BackHandler {
+ navigateBack()
+ }
+}
+
+@Composable @Preview
+private fun PlaylistViewPreview() {
+ AppTheme {
+ val playlistForTest = MutableLiveData(Playlist(1, true, "My amazing playlist 1.0.1", 12345, 12345, "The playlist that I'm going to use to test this playlist entry item thing with lots of text."))
+
+ val playlistState = PlaylistViewStateForTest(
+ title = MutableLiveData("Playlist title"),
+ description = MutableLiveData("Playlist description"),
+ songs = MutableLiveData(createListOfTabWithPlaylistEntry(3))
+ )
+
+ PlaylistScreen(
+ viewState = playlistState,
+ navigateToTabByPlaylistEntryId = {},
+ titleChanged = {},
+ descriptionChanged = {},
+ entryMoved = {_, _ -> },
+ entryRemoved = {},
+ navigateBack = {},
+ playlistDeleted = {}
+ )
+ }
+}
+
+private class PlaylistViewStateForTest(
+ override val title: LiveData,
+ override val description: LiveData,
+ override val songs: LiveData>
+) : IPlaylistViewState
+
+private fun createListOfTabWithPlaylistEntry(size: Int): List {
+ val listOfEntries = mutableListOf()
+ for (id in 0..size) {
+ listOfEntries.add(
+ TabWithDataPlaylistEntry(entryId = id, playlistId = 1, tabId = id * 20, nextEntryId = if(id0) id-1 else null, dateAdded = 0, songId = 12, songName = "Song $id", artistName ="Artist name",
+ isVerified = false, numVersions = 4, type = "Chords", part = "part", version = 2, votes = 0,
+ rating = 0.0, date = 0, status = "", presetId = 0, tabAccessType = "public", tpVersion = 0,
+ tonalityName = "D", versionDescription = "version desc", recordingIsAcoustic = false, recordingTonalityName = "",
+ recordingPerformance = "", recordingArtists = arrayListOf(), recommended = arrayListOf(), userRating = 0,
+ playlistUserCreated = false, playlistTitle = "playlist title", playlistDateCreated = 0, playlistDescription = "playlist desc",
+ playlistDateModified = 0)
+ )
+ }
+
+ return listOfEntries
+}
diff --git a/app/src/main/java/com/gbros/tabslite/view/playlists/PlaylistSongList.kt b/app/src/main/java/com/gbros/tabslite/view/playlists/PlaylistSongList.kt
new file mode 100644
index 0000000..b6a7a5d
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/playlists/PlaylistSongList.kt
@@ -0,0 +1,191 @@
+package com.gbros.tabslite.view.playlists
+
+import android.util.Log
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material3.Card
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.gbros.tabslite.R
+import com.gbros.tabslite.data.tab.TabWithDataPlaylistEntry
+import com.gbros.tabslite.ui.theme.AppTheme
+import com.gbros.tabslite.utilities.TAG
+import com.gbros.tabslite.view.card.InfoCard
+import com.gbros.tabslite.view.songlist.SongListItem
+import com.gbros.tabslite.view.swipetodismiss.MaterialSwipeToDismiss
+import sh.calvin.reorderable.ReorderableItem
+import sh.calvin.reorderable.rememberReorderableLazyListState
+
+/**
+ * Represents a playlist of songs. Handles reordering of songs.
+ */
+@Composable
+fun PlaylistSongList(
+ songs: List,
+ navigateToTabByPlaylistEntryId: (entryId: Int) -> Unit,
+ onReorder: (fromIndex: Int, toIndex: Int) -> Unit,
+ onRemove: (tabToRemove: TabWithDataPlaylistEntry) -> Unit
+) {
+ // Use remember to create a MutableState object with a mutable collection type
+ var reorderedSongsForDisplay by remember { mutableStateOf(songs) }
+
+ // Observe changes in songs and update current songs accordingly
+ DisposableEffect(songs) {
+ // normally this effect will run when the list is reordered, in which case reorderedSongsForDisplay
+ // should already match the incoming list. Avoiding reassigning reorderedSongsForDisplay prevents
+ // the need for a redraw with a new list, allowing reorder animations to complete.
+ if (!equals(songs, reorderedSongsForDisplay)) {
+ Log.d(TAG, "Reassigning reorderedSongsForDisplay due to list inequality")
+ reorderedSongsForDisplay = songs.toMutableList()
+ }
+ onDispose { } // only run this effect once per update to songs
+ }
+
+ var reorderFrom: Int? by remember { mutableStateOf(null) }
+ var reorderTo: Int? by remember { mutableStateOf(null) }
+ val lazyListState = rememberLazyListState()
+ val reorderableLazyListState = rememberReorderableLazyListState(lazyListState) { from, to ->
+ reorderedSongsForDisplay = reorderedSongsForDisplay.toMutableList().apply {
+ add(to.index, removeAt(from.index))
+
+ // save the initial from value for updating the database after the reorder is finished
+ if (reorderFrom == null) {
+ reorderFrom = from.index
+ }
+ reorderTo = to.index // save the most recent to value for updating the database after the reorder is finished
+ }
+ }
+
+ if (songs.isEmpty()) {
+ // empty playlist
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(all = 16.dp)
+ ) {
+ InfoCard(text = stringResource(id = R.string.playlist_empty_description))
+ }
+ }
+ else {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ state = lazyListState,
+ contentPadding = PaddingValues(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(reorderedSongsForDisplay, key = { it }) {
+ ReorderableItem(reorderableLazyListState, key = it) { isDragging ->
+ val interactionSource = remember { MutableInteractionSource() }
+ MaterialSwipeToDismiss(
+ onRemove = { onRemove(it) },
+ enable = !isDragging,
+ content = {
+ Card(
+ onClick = { navigateToTabByPlaylistEntryId(it.entryId) },
+ interactionSource = interactionSource
+ ) {
+ Row {
+ IconButton(
+ modifier = Modifier.draggableHandle(
+ onDragStopped = {
+ if (reorderFrom != null && reorderTo != null) {
+ Log.d(TAG, "reordering $reorderFrom to $reorderTo")
+ onReorder(reorderFrom!!, reorderTo!!)
+ }
+ // reset saved reorder for next move
+ reorderFrom = null
+ reorderTo = null
+ },
+ interactionSource = interactionSource
+ ),
+ onClick = {},
+ ) {
+ Icon(
+ imageVector = ImageVector.vectorResource(R.drawable.ic_drag_handle),
+ contentDescription = stringResource(R.string.generic_action_drag_to_reorder)
+ )
+ }
+ SongListItem(song = it)
+ }
+ }
+ }
+ )
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Check equality of two playlists. Checks that the entries are the same entries, not just the contents
+ */
+private fun equals (playlist1: List, playlist2: List): Boolean {
+ if (playlist1.size != playlist2.size) {
+ return false
+ }
+
+ for (i in playlist1.indices) {
+ if (playlist1[i].entryId != playlist2[i].entryId) {
+ return false
+ }
+ }
+
+ return true
+}
+
+@Composable @Preview
+private fun PlaylistSongListPreview() {
+ AppTheme {
+ PlaylistSongList(songs = createListOfTabWithPlaylistEntry(20), navigateToTabByPlaylistEntryId = {}, onReorder = { _, _->}, onRemove = {})
+ }
+}
+
+@Composable @Preview
+private fun EmptyPlaylistSongListPreview() {
+ AppTheme {
+ PlaylistSongList(
+ songs = listOf(),
+ navigateToTabByPlaylistEntryId = {},
+ onReorder = { _, _ -> },
+ onRemove = {}
+ )
+ }
+}
+
+private fun createListOfTabWithPlaylistEntry(size: Int): List {
+ val listOfEntries = mutableListOf()
+ for (id in 0..size) {
+ listOfEntries.add(TabWithDataPlaylistEntry(entryId = id, playlistId = 1, tabId = id * 20, nextEntryId = if(id0) id-1 else null, dateAdded = 0, songId = 12, songName = "Song $id", artistName ="Artist name",
+ isVerified = false, numVersions = 4, type = "Chords", part = "part", version = 2, votes = 0,
+ rating = 0.0, date = 0, status = "", presetId = 0, tabAccessType = "public", tpVersion = 0,
+ tonalityName = "D", versionDescription = "version desc", recordingIsAcoustic = false, recordingTonalityName = "",
+ recordingPerformance = "", recordingArtists = arrayListOf(), recommended = arrayListOf(), userRating = 0,
+ playlistUserCreated = false, playlistTitle = "playlist title", playlistDateCreated = 0, playlistDescription = "playlist desc",
+ playlistDateModified = 0))
+ }
+
+ return listOfEntries
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/playlists/PlaylistsSortBy.kt b/app/src/main/java/com/gbros/tabslite/view/playlists/PlaylistsSortBy.kt
new file mode 100644
index 0000000..95238c2
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/playlists/PlaylistsSortBy.kt
@@ -0,0 +1,23 @@
+package com.gbros.tabslite.view.playlists
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import com.gbros.tabslite.R
+
+enum class PlaylistsSortBy {
+ Name,
+ DateAdded,
+ DateModified;
+
+
+ companion object {
+ @Composable
+ fun getString(entry: PlaylistsSortBy): String {
+ return when(entry) {
+ Name -> stringResource(id = R.string.sort_by_title)
+ DateAdded -> stringResource(id = R.string.sort_by_date_added)
+ DateModified -> stringResource(id = R.string.sort_by_date_modified)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/playlists/RemovePlaylistEntryConfirmationDialog.kt b/app/src/main/java/com/gbros/tabslite/view/playlists/RemovePlaylistEntryConfirmationDialog.kt
new file mode 100644
index 0000000..d37b1d5
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/playlists/RemovePlaylistEntryConfirmationDialog.kt
@@ -0,0 +1,49 @@
+package com.gbros.tabslite.view.playlists
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.Preview
+import com.gbros.tabslite.ui.theme.AppTheme
+
+@Composable
+fun RemovePlaylistEntryConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
+ AlertDialog(
+ icon = {
+ Icon(Icons.Default.Delete, contentDescription = null)
+ },
+ title = {
+ Text(text = "Remove from playlist?")
+ },
+ text = {
+ Text(text = "You'll have to go find the song again if you want to add it back to the playlist.")
+ },
+ onDismissRequest = onDismiss,
+ confirmButton = {
+ TextButton(
+ onClick = onConfirm,
+ ) {
+ Text("Delete", color = MaterialTheme.colorScheme.error)
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = onDismiss
+ ) {
+ Text("Cancel")
+ }
+ }
+ )
+}
+
+@Composable @Preview
+private fun RemovePlaylistEntryConfirmationDialogPreview() {
+ AppTheme {
+ RemovePlaylistEntryConfirmationDialog({}, {})
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/ratingicon/HalfStarIcon.kt b/app/src/main/java/com/gbros/tabslite/view/ratingicon/HalfStarIcon.kt
new file mode 100644
index 0000000..2398d60
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/ratingicon/HalfStarIcon.kt
@@ -0,0 +1,40 @@
+package com.gbros.tabslite.view.ratingicon
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.tooling.preview.Preview
+import com.gbros.tabslite.R
+import com.gbros.tabslite.ui.theme.AppTheme
+
+@Composable
+fun HalfStarIcon(filledColor: Color = MaterialTheme.colorScheme.primary, emptyColor: Color = MaterialTheme.colorScheme.background) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = ImageVector.vectorResource(id = R.drawable.ic_rating_star_left_half),
+ contentDescription = stringResource(id = R.string.app_icon_description_half_star),
+ tint = filledColor,
+ )
+ Icon(
+ imageVector = ImageVector.vectorResource(id = R.drawable.ic_rating_star_right_half),
+ contentDescription = null,
+ tint = emptyColor,
+
+ )
+ }
+}
+
+@Composable @Preview
+private fun HalfStarIconPreview() {
+ AppTheme {
+ HalfStarIcon()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/ratingicon/ProportionallyFilledStar.kt b/app/src/main/java/com/gbros/tabslite/view/ratingicon/ProportionallyFilledStar.kt
new file mode 100644
index 0000000..e8e8d5b
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/ratingicon/ProportionallyFilledStar.kt
@@ -0,0 +1,78 @@
+package com.gbros.tabslite.view.ratingicon
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Outline
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.LayoutDirection
+import com.gbros.tabslite.ui.theme.AppTheme
+
+@Composable
+fun ProportionallyFilledStar(
+ fillPercentage: Float,
+ modifier: Modifier = Modifier
+) {
+ Box(modifier = modifier) {
+ // Unfilled star (background)
+ Icon(
+ imageVector = Icons.Default.Star,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.background,
+ )
+
+ // Filled portion of the star
+ Icon(
+ imageVector = Icons.Default.Star,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier
+ .clip(RectShape(fillPercentage))
+ )
+ }
+}
+
+private class RectShape(private val fillPercentage: Float) : Shape {
+ override fun createOutline(
+ size: Size,
+ layoutDirection: LayoutDirection,
+ density: Density
+ ): Outline {
+ return Outline.Rectangle(
+ Rect(
+ left = 0f,
+ top = 0f,
+ right = size.width * fillPercentage,
+ bottom = size.height
+ )
+ )
+ }
+}
+
+@Composable @Preview
+private fun ProportionallyFilledStarPreview(){
+ AppTheme {
+ Column {
+ RatingIcon(5.0)
+ RatingIcon(4.9)
+ RatingIcon(4.7)
+ RatingIcon(4.1)
+ RatingIcon(3.5)
+ RatingIcon(2.5)
+ RatingIcon(0.9)
+ RatingIcon(0.5)
+ RatingIcon(0.1)
+ RatingIcon(0.0)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/ratingicon/RatingIcon.kt b/app/src/main/java/com/gbros/tabslite/view/ratingicon/RatingIcon.kt
new file mode 100644
index 0000000..8514204
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/ratingicon/RatingIcon.kt
@@ -0,0 +1,70 @@
+package com.gbros.tabslite.view.ratingicon
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.gbros.tabslite.R
+import com.gbros.tabslite.ui.theme.AppTheme
+import kotlin.math.ceil
+import kotlin.math.floor
+
+@Composable
+fun RatingIcon(rating: Double){
+ var filledStars = floor(rating).toInt()
+ var unfilledStars = (5 - ceil(rating)).toInt()
+ var halfStar = false
+ val remainder = rating.rem(1)
+
+ // round to the nearest half star
+ if (remainder > 0) {
+ if (remainder >= .8) filledStars++
+ else if (remainder < .25) unfilledStars++
+ else halfStar = true
+ }
+
+ Row(
+ modifier = Modifier
+ .padding(horizontal = 4.dp)
+ ){
+ repeat(filledStars) {
+ Icon(imageVector = Icons.Default.Star, contentDescription = stringResource(id = R.string.app_icon_description_filled_star), tint = MaterialTheme.colorScheme.primary)
+ }
+
+ if (halfStar) {
+ HalfStarIcon()
+ }
+
+ repeat(unfilledStars) {
+ Icon(
+ imageVector = Icons.Default.Star,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.background,
+ )
+ }
+ }
+}
+
+@Composable @Preview
+private fun RatingIconPreview(){
+ AppTheme {
+ Column {
+ RatingIcon(5.0)
+ RatingIcon(4.9)
+ RatingIcon(4.7)
+ RatingIcon(4.1)
+ RatingIcon(0.9)
+ RatingIcon(0.5)
+ RatingIcon(0.1)
+ RatingIcon(0.0)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/searchresultsonglist/ISearchViewState.kt b/app/src/main/java/com/gbros/tabslite/view/searchresultsonglist/ISearchViewState.kt
new file mode 100644
index 0000000..8cec4bf
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/searchresultsonglist/ISearchViewState.kt
@@ -0,0 +1,29 @@
+package com.gbros.tabslite.view.searchresultsonglist
+
+import androidx.lifecycle.LiveData
+import com.gbros.tabslite.LoadingState
+import com.gbros.tabslite.data.tab.ITab
+
+interface ISearchViewState {
+ /**
+ * The search query being searched
+ */
+ val query: String
+
+ /**
+ * The search results returned by this query
+ */
+ val results: LiveData>
+
+ /**
+ * The current state of this search. Will be [LoadingState.Loading] if more search results are
+ * being fetched, [LoadingState.Success] if the load process is complete
+ */
+ val searchState: LiveData
+
+ /**
+ * Whether the complete set of search results has already been loaded. Used to disable trying to
+ * load more search results
+ */
+ val allResultsLoaded: LiveData
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/searchresultsonglist/SearchResultCard.kt b/app/src/main/java/com/gbros/tabslite/view/searchresultsonglist/SearchResultCard.kt
new file mode 100644
index 0000000..d814a2d
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/searchresultsonglist/SearchResultCard.kt
@@ -0,0 +1,62 @@
+package com.gbros.tabslite.view.searchresultsonglist
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.absolutePadding
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.Card
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.gbros.tabslite.R
+import com.gbros.tabslite.data.tab.ITab
+import com.gbros.tabslite.data.tab.TabWithDataPlaylistEntry
+import com.gbros.tabslite.ui.theme.AppTheme
+
+@Composable
+fun SearchResultCard(song: ITab, onClick: () -> Unit){
+ Card(
+ modifier = Modifier
+ .clickable(onClick = onClick)
+ .focusable()
+ .fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier
+ .absolutePadding(5.dp, 5.dp, 5.dp, 5.dp)
+ .fillMaxWidth()
+ ) {
+ Column (
+ modifier = Modifier
+ .weight(1f)
+ ){
+ Text(
+ text = song.songName,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = song.artistName,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ Text(
+ text = pluralStringResource(id = R.plurals.num_song_versions, song.numVersions / 2, song.numVersions / 2)
+ )
+ }
+ }
+}
+
+@Composable @Preview
+private fun SearchResultCardPreview() {
+ val tabForTest = TabWithDataPlaylistEntry(1, 1, 1, 1, 1, 1234, 0, "Long Time Ago", "CoolGuyz", 1, false, 5, "Chords", "", 1, 4, 3.6, 1234, "" , 123, "public", 1, "E A D G B E", "description", false, "asdf", "", ArrayList(), ArrayList(), 4, "expert", playlistDateCreated = 12345, playlistDateModified = 12345, playlistDescription = "Description of our awesome playlist", playlistTitle = "My Playlist", playlistUserCreated = true, capo = 2, contributorUserName = "Joe Blow")
+ AppTheme {
+ SearchResultCard(song = tabForTest) {}
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/searchresultsonglist/SearchScreen.kt b/app/src/main/java/com/gbros/tabslite/view/searchresultsonglist/SearchScreen.kt
new file mode 100644
index 0000000..8d1c334
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/searchresultsonglist/SearchScreen.kt
@@ -0,0 +1,357 @@
+package com.gbros.tabslite.view.searchresultsonglist
+
+import android.util.Log
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavType
+import androidx.navigation.compose.composable
+import androidx.navigation.navArgument
+import com.gbros.tabslite.LoadingState
+import com.gbros.tabslite.R
+import com.gbros.tabslite.data.AppDatabase
+import com.gbros.tabslite.data.tab.ITab
+import com.gbros.tabslite.data.tab.Tab
+import com.gbros.tabslite.data.tab.TabWithDataPlaylistEntry
+import com.gbros.tabslite.ui.theme.AppTheme
+import com.gbros.tabslite.utilities.TAG
+import com.gbros.tabslite.view.card.ErrorCard
+import com.gbros.tabslite.view.card.InfoCard
+import com.gbros.tabslite.view.tabsearchbar.ITabSearchBarViewState
+import com.gbros.tabslite.view.tabsearchbar.TabsSearchBar
+import com.gbros.tabslite.viewmodel.SearchViewModel
+
+private const val TITLE_SEARCH_NAV_ARG = "query"
+private const val TITLE_SEARCH_ROUTE_TEMPLATE = "search/%s"
+private const val ARTIST_SONG_LIST_TEMPLATE = "artist/%s"
+private const val ARTIST_SONG_LIST_NAV_ARG = "artistId"
+
+/**
+ * NavController extension to allow navigation to the search screen based on a query
+ */
+fun NavController.navigateToSearch(query: String) {
+ navigate(TITLE_SEARCH_ROUTE_TEMPLATE.format(query))
+}
+
+/**
+ * NavController extension to allow navigation to a list of songs by a specified artist ID
+ */
+fun NavController.navigateToArtistIdSongList(artistId: Int) {
+ Log.d(TAG, "navigating to artist $artistId song list")
+ navigate(ARTIST_SONG_LIST_TEMPLATE.format(artistId.toString()))
+}
+
+/**
+ * NavGraphBuilder extension to build the search by title screen for when a user searches using text
+ * (normal search)
+ */
+fun NavGraphBuilder.searchByTitleScreen(
+ onNavigateToSongId: (Int) -> Unit,
+ onNavigateToSearch: (String) -> Unit,
+ onNavigateToTabByTabId: (tabId: Int) -> Unit,
+ onNavigateBack: () -> Unit,
+) {
+ composable(
+ route = TITLE_SEARCH_ROUTE_TEMPLATE.format("{$TITLE_SEARCH_NAV_ARG}")
+ ) { navBackStackEntry ->
+ val query = navBackStackEntry.arguments!!.getString(TITLE_SEARCH_NAV_ARG, "")
+ val db = AppDatabase.getInstance(LocalContext.current)
+ val viewModel: SearchViewModel = hiltViewModel { factory -> factory.create(query, null, db.dataAccess()) }
+ SearchScreen(
+ viewState = viewModel,
+ tabSearchBarViewState = viewModel.tabSearchBarViewModel,
+ onMoreSearchResultsNeeded = viewModel::onMoreSearchResultsNeeded,
+ onTabSearchBarQueryChange = viewModel.tabSearchBarViewModel::onQueryChange,
+ onNavigateToSongVersionsBySongId = onNavigateToSongId,
+ onNavigateBack = onNavigateBack,
+ onNavigateToSearch = onNavigateToSearch,
+ onNavigateToTabByTabId = onNavigateToTabByTabId
+ )
+ }
+}
+
+/**
+ * NavGraphBuilder extension to build the search by artist ID screen for when a user clicks an
+ * artist name to see all songs by that artist
+ */
+fun NavGraphBuilder.listSongsByArtistIdScreen(
+ onNavigateToSongId: (Int) -> Unit,
+ onNavigateToSearch: (String) -> Unit,
+ onNavigateToTabByTabId: (tabId: Int) -> Unit,
+ onNavigateBack: () -> Unit
+) {
+ composable(
+ route = ARTIST_SONG_LIST_TEMPLATE.format("{$ARTIST_SONG_LIST_NAV_ARG}"),
+ arguments = listOf(navArgument(ARTIST_SONG_LIST_NAV_ARG) { type = NavType.IntType } )
+ ) { navBackStackEntry ->
+ val artistId = navBackStackEntry.arguments!!.getInt(ARTIST_SONG_LIST_NAV_ARG)
+ val db = AppDatabase.getInstance(LocalContext.current)
+ val viewModel: SearchViewModel = hiltViewModel { factory -> factory.create("", artistId, db.dataAccess()) }
+ SearchScreen(
+ viewState = viewModel,
+ tabSearchBarViewState = viewModel.tabSearchBarViewModel,
+ onMoreSearchResultsNeeded = viewModel::onMoreSearchResultsNeeded,
+ onTabSearchBarQueryChange = viewModel.tabSearchBarViewModel::onQueryChange,
+ onNavigateToSongVersionsBySongId = onNavigateToSongId,
+ onNavigateBack = onNavigateBack,
+ onNavigateToSearch = onNavigateToSearch,
+ onNavigateToTabByTabId = onNavigateToTabByTabId
+ )
+ }
+}
+
+@Composable
+fun SearchScreen(
+ viewState: ISearchViewState,
+ tabSearchBarViewState: ITabSearchBarViewState,
+ onMoreSearchResultsNeeded: suspend () -> Unit,
+ onTabSearchBarQueryChange: (newQuery: String) -> Unit,
+ onNavigateToSongVersionsBySongId: (songId: Int) -> Unit,
+ onNavigateBack: () -> Unit,
+ onNavigateToSearch: (query: String) -> Unit,
+ onNavigateToTabByTabId: (tabId: Int) -> Unit
+) {
+ val lazyColumnState = rememberLazyListState()
+ var needMoreSearchResults by remember { mutableStateOf(true) }
+ val searchResults = viewState.results.observeAsState(listOf())
+ val searchState = viewState.searchState.observeAsState(LoadingState.Loading)
+
+ // remember that we bumped into the end until we get more results
+ needMoreSearchResults = needMoreSearchResults || !lazyColumnState.canScrollForward
+
+ Column(
+ modifier = Modifier
+ .background(color = MaterialTheme.colorScheme.background)
+ ) {
+ TabsSearchBar(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 8.dp),
+ leadingIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(id = R.string.generic_action_back))
+ }
+ },
+ viewState = tabSearchBarViewState,
+ onSearch = onNavigateToSearch,
+ onQueryChange = onTabSearchBarQueryChange,
+ onNavigateToTabById = onNavigateToTabByTabId
+ )
+
+ if (searchState.value is LoadingState.Error) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(all = 24.dp), contentAlignment = Alignment.Center
+ ) {
+ ErrorCard(text = stringResource((searchState.value as LoadingState.Error).messageStringRef))
+ }
+ } else if (searchState.value is LoadingState.Success && searchResults.value.isEmpty()) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(all = 24.dp), contentAlignment = Alignment.Center
+ ) {
+ InfoCard(text = stringResource(id = R.string.message_no_search_results))
+ }
+ } else {
+ LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp), state = lazyColumnState) {
+ items(items = searchResults.value) { song ->
+ SearchResultCard(song) {
+ onNavigateToSongVersionsBySongId(song.songId)
+ }
+ }
+
+ // extra item at the end to display the circular progress indicator if we're still loading
+ item {
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.Top,
+ modifier = Modifier
+ .defaultMinSize(minHeight = 48.dp)
+ .fillMaxWidth()
+ .padding(top = 4.dp)
+ ) {
+ if (searchState.value is LoadingState.Loading) {
+ CircularProgressIndicator()
+ }
+ }
+ }
+ }
+ }
+
+ }
+
+ LaunchedEffect(key1 = lazyColumnState.canScrollForward, key2 = searchState.value, key3 = searchResults.value) {
+ if (!lazyColumnState.canScrollForward && (viewState.allResultsLoaded.value != true)){
+ onMoreSearchResultsNeeded()
+ }
+ }
+
+ BackHandler {
+ onNavigateBack()
+ }
+}
+
+
+//#region test/preview
+
+private class SearchViewStateForTest(
+ override val query: String,
+ override val results: LiveData>,
+ override val searchState: LiveData,
+ override val allResultsLoaded: LiveData
+) : ISearchViewState
+
+private class TabSearchBarViewStateForTest(
+ override val query: LiveData,
+ override val searchSuggestions: LiveData>,
+ override val tabSuggestions: LiveData>,
+ override val loadingState: LiveData
+): ITabSearchBarViewState
+
+@Composable
+@Preview
+private fun SearchScreenPreview() {
+ val hallelujahTabForTest = """
+ [Intro]
+ [ch]C[/ch] [ch]Em[/ch] [ch]C[/ch] [ch]Em[/ch]
+
+ [Verse]
+ [tab][ch]C[/ch] [ch]Em[/ch]
+ Hey there Delilah, What’s it like in New York City?[/tab]
+ [tab] [ch]C[/ch] [ch]Em[/ch] [ch]Am[/ch] [ch]G[/ch]
+ I’m a thousand miles away, But girl tonight you look so pretty, Yes you do, [/tab]
+
+ [tab]F [ch]G[/ch] [ch]Am[/ch]
+ Time Square can’t shine as bright as you, [/tab]
+ [tab] [ch]G[/ch]
+ I swear it’s true. [/tab]
+ [tab][ch]C[/ch]
+ Hey there Delilah, [/tab]
+ [tab] [ch]Em[/ch]
+ Don’t you worry about the distance, [/tab]
+ [tab] [ch]C[/ch]
+ I’m right there if you get lonely, [/tab]
+ [tab] [ch]Em[/ch]
+ [ch]G[/ch]ive this song another listen, [/tab]
+ [tab] [ch]Am[/ch] [ch]G[/ch]
+ Close your eyes, [/tab]
+ [tab]F [ch]G[/ch] [ch]Am[/ch]
+ Listen to my voice it’s my disguise, [/tab]
+ [tab] [ch]G[/ch]
+ I’m by your side.[/tab] """.trimIndent()
+ val tabForTest = TabWithDataPlaylistEntry(1, 1, 1, 1, 1, 1234, 0, "Long Time Ago", "CoolGuyz", 1, false, 5, "Chords", "", 1, 4, 3.6, 1234, "" , 123, "public", 1, "C", "description", false, "asdf", "", ArrayList(), ArrayList(), 4, "expert", playlistDateCreated = 12345, playlistDateModified = 12345, playlistDescription = "Description of our awesome playlist", playlistTitle = "My Playlist", playlistUserCreated = true, capo = 2, contributorUserName = "Joe Blow", content = hallelujahTabForTest)
+ val state = SearchViewStateForTest("my song", MutableLiveData(listOf(tabForTest, tabForTest, tabForTest)), MutableLiveData(LoadingState.Loading), MutableLiveData(false))
+ val tabSearchBarViewState = TabSearchBarViewStateForTest(
+ query = MutableLiveData("my song"),
+ searchSuggestions = MutableLiveData(listOf()),
+ tabSuggestions = MutableLiveData(listOf(Tab(0))),
+ loadingState = MutableLiveData()
+ )
+
+ AppTheme {
+ SearchScreen(
+ viewState = state,
+ tabSearchBarViewState = tabSearchBarViewState,
+ onMoreSearchResultsNeeded = {},
+ onNavigateToSongVersionsBySongId = {},
+ onNavigateBack = {},
+ onNavigateToSearch = {},
+ onTabSearchBarQueryChange = {},
+ onNavigateToTabByTabId = {}
+ )
+ }
+}
+
+@Composable
+@Preview
+private fun SearchScreenPreviewError() {
+ val hallelujahTabForTest = """
+ [Intro]
+ [ch]C[/ch] [ch]Em[/ch] [ch]C[/ch] [ch]Em[/ch]
+
+ [Verse]
+ [tab][ch]C[/ch] [ch]Em[/ch]
+ Hey there Delilah, What’s it like in New York City?[/tab]
+ [tab] [ch]C[/ch] [ch]Em[/ch] [ch]Am[/ch] [ch]G[/ch]
+ I’m a thousand miles away, But girl tonight you look so pretty, Yes you do, [/tab]
+
+ [tab]F [ch]G[/ch] [ch]Am[/ch]
+ Time Square can’t shine as bright as you, [/tab]
+ [tab] [ch]G[/ch]
+ I swear it’s true. [/tab]
+ [tab][ch]C[/ch]
+ Hey there Delilah, [/tab]
+ [tab] [ch]Em[/ch]
+ Don’t you worry about the distance, [/tab]
+ [tab] [ch]C[/ch]
+ I’m right there if you get lonely, [/tab]
+ [tab] [ch]Em[/ch]
+ [ch]G[/ch]ive this song another listen, [/tab]
+ [tab] [ch]Am[/ch] [ch]G[/ch]
+ Close your eyes, [/tab]
+ [tab]F [ch]G[/ch] [ch]Am[/ch]
+ Listen to my voice it’s my disguise, [/tab]
+ [tab] [ch]G[/ch]
+ I’m by your side.[/tab] """.trimIndent()
+ val tabForTest = TabWithDataPlaylistEntry(1, 1, 1, 1, 1, 1234, 0, "Long Time Ago", "CoolGuyz", 1, false, 5, "Chords", "", 1, 4, 3.6, 1234, "" , 123, "public", 1, "C", "description", false, "asdf", "", ArrayList(), ArrayList(), 4, "expert", playlistDateCreated = 12345, playlistDateModified = 12345, playlistDescription = "Description of our awesome playlist", playlistTitle = "My Playlist", playlistUserCreated = true, capo = 2, contributorUserName = "Joe Blow", content = hallelujahTabForTest)
+ val state = SearchViewStateForTest("my song", MutableLiveData(listOf(tabForTest, tabForTest, tabForTest)), MutableLiveData(LoadingState.Error(R.string.error)), MutableLiveData(false))
+
+ val tabSearchBarViewState = TabSearchBarViewStateForTest(
+ query = MutableLiveData("my song"),
+ searchSuggestions = MutableLiveData(listOf()),
+ tabSuggestions = MutableLiveData(listOf(Tab(0))),
+ loadingState = MutableLiveData()
+ )
+
+ AppTheme {
+ SearchScreen(
+ viewState = state,
+ tabSearchBarViewState = tabSearchBarViewState,
+ onMoreSearchResultsNeeded = {},
+ onNavigateToSongVersionsBySongId = {},
+ onNavigateBack = {},
+ onNavigateToSearch = {},
+ onTabSearchBarQueryChange = {},
+ onNavigateToTabByTabId = {}
+ )
+ }
+}
+
+
+//#endregion
diff --git a/app/src/main/java/com/gbros/tabslite/view/songlist/ISongListViewState.kt b/app/src/main/java/com/gbros/tabslite/view/songlist/ISongListViewState.kt
new file mode 100644
index 0000000..8bdc278
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/songlist/ISongListViewState.kt
@@ -0,0 +1,16 @@
+package com.gbros.tabslite.view.songlist
+
+import androidx.lifecycle.LiveData
+import com.gbros.tabslite.data.tab.TabWithDataPlaylistEntry
+
+interface ISongListViewState {
+ /**
+ * The tabs to display in this song list
+ */
+ val songs: LiveData>
+
+ /**
+ * How these tabs are currently sorted
+ */
+ val sortBy: LiveData
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/songlist/SongListItem.kt b/app/src/main/java/com/gbros/tabslite/view/songlist/SongListItem.kt
new file mode 100644
index 0000000..6a05d00
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/songlist/SongListItem.kt
@@ -0,0 +1,67 @@
+package com.gbros.tabslite.view.songlist
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.absolutePadding
+import androidx.compose.material3.Card
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.gbros.tabslite.R
+import com.gbros.tabslite.data.tab.ITab
+import com.gbros.tabslite.data.tab.TabWithDataPlaylistEntry
+import com.gbros.tabslite.ui.theme.AppTheme
+
+/**
+ * Single list item representing one song
+ */
+@Composable
+fun SongListItem(
+ modifier: Modifier = Modifier,
+ song: ITab,
+) {
+ Card {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier
+ .absolutePadding(5.dp, 5.dp, 5.dp, 5.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ ) {
+ Text(
+ text = song.songName,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = song.artistName,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ Column {
+ Text(text = song.type)
+ Text(
+ text = String.format(
+ stringResource(id = R.string.tab_version_abbreviation),
+ song.version
+ )
+ )
+ }
+ }
+ }
+}
+
+@Composable @Preview
+fun SongListItemPreview(){
+ val tabForTest = TabWithDataPlaylistEntry(1, 1, 1, 1, 1, 1234, 0, "Long Time Ago", "CoolGuyz", 1, false, 5, "Chords", "", 1, 4, 3.6, 1234, "" , 123, "public", 1, "E A D G B E", "description", false, "asdf", "", ArrayList(), ArrayList(), 4, "expert", playlistDateCreated = 12345, playlistDateModified = 12345, playlistDescription = "Description of our awesome playlist", playlistTitle = "My Playlist", playlistUserCreated = true, capo = 2, contributorUserName = "Joe Blow")
+ AppTheme {
+ SongListItem(song = tabForTest)
+ }
+}
diff --git a/app/src/main/java/com/gbros/tabslite/view/songlist/SongListView.kt b/app/src/main/java/com/gbros/tabslite/view/songlist/SongListView.kt
new file mode 100644
index 0000000..fca86f0
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/songlist/SongListView.kt
@@ -0,0 +1,108 @@
+package com.gbros.tabslite.view.songlist
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeContent
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.windowInsetsBottomHeight
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.gbros.tabslite.R
+import com.gbros.tabslite.data.tab.TabWithDataPlaylistEntry
+import com.gbros.tabslite.ui.theme.AppTheme
+import com.gbros.tabslite.view.card.InfoCard
+
+/**
+ * The view including both the list of songs and the dropdown for sorting them
+ */
+@Composable
+fun SongListView(
+ modifier: Modifier = Modifier,
+ viewState: ISongListViewState,
+ navigateByPlaylistEntryId: Boolean,
+ navigateToTabById: (id: Int) -> Unit,
+ verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(4.dp),
+ emptyListText: String = stringResource(id = R.string.message_empty_list),
+){
+ Column {
+ val songs = viewState.songs.observeAsState(listOf())
+ if (songs.value.isEmpty()) {
+ // no songs
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(all = 16.dp)
+ ) {
+ InfoCard(text = emptyListText)
+ }
+ } else {
+ LazyColumn(
+ verticalArrangement = verticalArrangement,
+ modifier = modifier
+ ) {
+ item {
+ Spacer(modifier = Modifier.height(height = 6.dp))
+ Spacer(modifier = Modifier.windowInsetsPadding(WindowInsets(
+ top = WindowInsets.safeDrawing.getTop(LocalDensity.current),
+ )))
+ }
+ items(songs.value) { song ->
+ SongListItem(
+ modifier = Modifier.clickable {
+ navigateToTabById(if (navigateByPlaylistEntryId) song.entryId else song.tabId)
+ },
+ song = song,
+ )
+ }
+ item {
+ Spacer(modifier = Modifier.height(height = 24.dp))
+ Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.safeContent))
+ }
+ }
+ }
+ }
+}
+
+@Composable @Preview
+private fun SongListViewPreview(){
+ val tabForTest1 = TabWithDataPlaylistEntry(1, 1, 1, 1, 1, 1234, 0, "Long Time Ago", "CoolGuyz", 1, false, 5, "Chords", "", 1, 4, 3.6, 1234, "" , 123, "public", 1, "E A D G B E", "description", false, "asdf", "", ArrayList(), ArrayList(), 4, "expert", playlistDateCreated = 12345, playlistDateModified = 12345, playlistDescription = "Description of our awesome playlist", playlistTitle = "My Playlist", playlistUserCreated = true, capo = 2, contributorUserName = "Joe Blow")
+ val tabForTest2 = TabWithDataPlaylistEntry(1, 1, 1, 1, 1, 1234, 0, "Long Time Ago", "CoolGuyz", 1, false, 5, "Chords", "", 1, 4, 3.6, 1234, "" , 123, "public", 1, "E A D G B E", "description", false, "asdf", "", ArrayList(), ArrayList(), 4, "expert", playlistDateCreated = 12345, playlistDateModified = 12345, playlistDescription = "Description of our awesome playlist", playlistTitle = "My Playlist", playlistUserCreated = true, capo = 2, contributorUserName = "Joe Blow")
+ val tabListForTest = MutableLiveData(listOf(tabForTest1, tabForTest2))
+
+ val viewState = SongListViewStateForTest(
+ songs = tabListForTest,
+ sortBy = MutableLiveData(SortBy.Name)
+ )
+
+ AppTheme {
+ SongListView(
+ viewState = viewState,
+ navigateToTabById = {},
+ navigateByPlaylistEntryId = false
+ )
+ }
+}
+
+private class SongListViewStateForTest(
+ override val songs: LiveData>,
+ override val sortBy: LiveData
+) : ISongListViewState
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/songlist/SortBy.kt b/app/src/main/java/com/gbros/tabslite/view/songlist/SortBy.kt
new file mode 100644
index 0000000..0783045
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/songlist/SortBy.kt
@@ -0,0 +1,27 @@
+package com.gbros.tabslite.view.songlist
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import com.gbros.tabslite.R
+
+/**
+ * The ways a song list can be sorted, and their string representations
+ */
+enum class SortBy {
+ DateAdded,
+ Name,
+ ArtistName,
+ Popularity;
+
+ companion object {
+ @Composable
+ fun getString(entry: SortBy): String {
+ return when(entry) {
+ DateAdded -> stringResource(id = R.string.sort_by_date_added)
+ Popularity -> stringResource(id = R.string.sort_by_popularity)
+ ArtistName -> stringResource(id = R.string.sort_by_artist_name)
+ Name -> stringResource(id = R.string.sort_by_title)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/gbros/tabslite/view/songlist/SortByDropdown.kt b/app/src/main/java/com/gbros/tabslite/view/songlist/SortByDropdown.kt
new file mode 100644
index 0000000..ee9a070
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/songlist/SortByDropdown.kt
@@ -0,0 +1,81 @@
+package com.gbros.tabslite.view.songlist
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MenuAnchorType
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import com.gbros.tabslite.R
+import com.gbros.tabslite.view.playlists.PlaylistsSortBy
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SortByDropdown(selectedSort: SortBy?, onOptionSelected: (SortBy) -> Unit) {
+ var expanded by remember { mutableStateOf(false) }
+ ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { }, modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ Button(
+ onClick = { expanded = !expanded},
+ modifier = Modifier
+ .menuAnchor(MenuAnchorType.PrimaryNotEditable)
+ .fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.secondary)
+ ) {
+ Text(String.format(stringResource(id = R.string.sort_by),
+ selectedSort?.let { SortBy.getString(it) } ?: ""))
+ ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
+ }
+
+ ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
+ for (sortOption in SortBy.entries) {
+ DropdownMenuItem(
+ text = { Text(text = SortBy.getString(sortOption)) },
+ onClick = { expanded = false; onOptionSelected(sortOption) }
+ )
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SortByDropdown(selectedSort: PlaylistsSortBy?, onOptionSelected: (PlaylistsSortBy) -> Unit) {
+ var expanded by remember { mutableStateOf(false) }
+ ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { }, modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ Button(
+ onClick = { expanded = !expanded},
+ modifier = Modifier
+ .menuAnchor(MenuAnchorType.PrimaryNotEditable)
+ .fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.secondary)
+ ) {
+ Text(String.format(stringResource(id = R.string.sort_by),
+ selectedSort?.let { PlaylistsSortBy.getString(it) } ?: ""))
+ ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
+ }
+
+ ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
+ for (sortOption in PlaylistsSortBy.entries) {
+ DropdownMenuItem(
+ text = { Text(text = PlaylistsSortBy.getString(sortOption)) },
+ onClick = { expanded = false; onOptionSelected(sortOption) }
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/gbros/tabslite/view/songversionlist/ISongVersionViewState.kt b/app/src/main/java/com/gbros/tabslite/view/songversionlist/ISongVersionViewState.kt
new file mode 100644
index 0000000..3261c9d
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/songversionlist/ISongVersionViewState.kt
@@ -0,0 +1,16 @@
+package com.gbros.tabslite.view.songversionlist
+
+import androidx.lifecycle.LiveData
+import com.gbros.tabslite.data.tab.ITab
+
+interface ISongVersionViewState {
+ /**
+ * The search query to be displayed in the search bar
+ */
+ val songName: LiveData
+
+ /**
+ * The versions of the selected song to be displayed
+ */
+ val songVersions: LiveData>
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/songversionlist/SongVersionList.kt b/app/src/main/java/com/gbros/tabslite/view/songversionlist/SongVersionList.kt
new file mode 100644
index 0000000..8ba31bb
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/songversionlist/SongVersionList.kt
@@ -0,0 +1,28 @@
+package com.gbros.tabslite.view.songversionlist
+
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.Preview
+import com.gbros.tabslite.data.tab.ITab
+import com.gbros.tabslite.data.tab.TabWithDataPlaylistEntry
+
+@Composable
+fun SongVersionList(songVersions: List, navigateToTabByTabId: (id: Int) -> Unit){
+ LazyColumn{
+ items(songVersions) { version ->
+ SongVersionListItem(song = version) {
+ navigateToTabByTabId(version.tabId)
+ }
+ }
+ }
+}
+
+@Composable @Preview
+private fun SongVersionListPreview() {
+ val tabForTest1 = TabWithDataPlaylistEntry(1, 1, 1, 1, 1, 1234, 0, "Long Time Ago", "CoolGuyz", 1, false, 5, "Chords", "", 1, 4, 3.6, 1234, "" , 123, "public", 1, "E A D G B E", "description", false, "asdf", "", ArrayList(), ArrayList(), 4, "expert", playlistDateCreated = 12345, playlistDateModified = 12345, playlistDescription = "Description of our awesome playlist", playlistTitle = "My Playlist", playlistUserCreated = true, capo = 2, contributorUserName = "Joe Blow")
+ val tabForTest2 = TabWithDataPlaylistEntry(1, 1, 1, 1, 1, 1234, 0, "Long Time Ago", "CoolGuyz", 1, false, 5, "Chords", "", 2, 8, 4.1, 1234, "" , 123, "public", 1, "E A D G B E", "description", false, "asdf", "", ArrayList(), ArrayList(), 4, "expert", playlistDateCreated = 12345, playlistDateModified = 12345, playlistDescription = "Description of our awesome playlist", playlistTitle = "My Playlist", playlistUserCreated = true, capo = 2, contributorUserName = "Joe Blow")
+ val tabListForTest = listOf(tabForTest1, tabForTest2)
+
+ SongVersionList(tabListForTest, {})
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/songversionlist/SongVersionListItem.kt b/app/src/main/java/com/gbros/tabslite/view/songversionlist/SongVersionListItem.kt
new file mode 100644
index 0000000..996f9c0
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/songversionlist/SongVersionListItem.kt
@@ -0,0 +1,64 @@
+package com.gbros.tabslite.view.songversionlist
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Card
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.gbros.tabslite.R
+import com.gbros.tabslite.data.tab.ITab
+import com.gbros.tabslite.data.tab.TabWithDataPlaylistEntry
+import com.gbros.tabslite.view.ratingicon.RatingIcon
+
+@Composable
+fun SongVersionListItem(song: ITab, onClick: () -> Unit){
+ Card(
+ modifier = Modifier
+ .clickable(onClick = onClick)
+ .focusable()
+ .fillMaxWidth()
+ .padding(vertical = 2.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .padding(all = 5.dp)
+ ){
+ Text(
+ text = stringResource(R.string.tab_version_number, song.version),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier
+ .weight(1f)
+ )
+ RatingIcon(rating = song.rating)
+ Row(
+ horizontalArrangement = Arrangement.End,
+ modifier = Modifier
+ .width(48.dp)
+ ) {
+ Text(
+ text = song.votes.toString(),
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .padding(top = 1.dp)
+ )
+ }
+ }
+ }
+}
+
+@Composable @Preview
+private fun SongVersionListItemPreview() {
+ val tabForTest = TabWithDataPlaylistEntry(1, 1, 1, 1, 1, 1234, 0, "Long Time Ago", "CoolGuyz", 1, false, 5, "Chords", "", 1, 4, 3.6, 1234, "" , 123, "public", 1, "E A D G B E", "description", false, "asdf", "", ArrayList(), ArrayList(), 4, "expert", playlistDateCreated = 12345, playlistDateModified = 12345, playlistDescription = "Description of our awesome playlist", playlistTitle = "My Playlist", playlistUserCreated = true, capo = 2, contributorUserName = "Joe Blow")
+ SongVersionListItem(song = tabForTest) {}
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/songversionlist/SongVersionScreen.kt b/app/src/main/java/com/gbros/tabslite/view/songversionlist/SongVersionScreen.kt
new file mode 100644
index 0000000..7c49a35
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/songversionlist/SongVersionScreen.kt
@@ -0,0 +1,100 @@
+package com.gbros.tabslite.view.songversionlist
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavType
+import androidx.navigation.compose.composable
+import androidx.navigation.navArgument
+import com.gbros.tabslite.R
+import com.gbros.tabslite.data.AppDatabase
+import com.gbros.tabslite.view.tabsearchbar.ITabSearchBarViewState
+import com.gbros.tabslite.view.tabsearchbar.TabsSearchBar
+import com.gbros.tabslite.viewmodel.SongVersionViewModel
+
+private const val SONG_VERSION_NAV_ARG = "songId"
+const val SONG_VERSION_ROUTE_TEMPLATE = "song/%s"
+
+fun NavController.navigateToSongVersion(songId: Int) {
+ navigate(SONG_VERSION_ROUTE_TEMPLATE.format(songId.toString()))
+}
+
+fun NavGraphBuilder.songVersionScreen(
+ onNavigateToTabByTabId: (Int) -> Unit,
+ onNavigateToSearch: (String) -> Unit,
+ onNavigateBack: () -> Unit
+) {
+ composable(
+ SONG_VERSION_ROUTE_TEMPLATE.format("{$SONG_VERSION_NAV_ARG}"),
+ arguments = listOf(navArgument(SONG_VERSION_NAV_ARG) { type = NavType.IntType })
+ ) { navBackStackEntry ->
+ val songId = navBackStackEntry.arguments!!.getInt(SONG_VERSION_NAV_ARG)
+ val db = AppDatabase.getInstance(LocalContext.current)
+ val viewModel: SongVersionViewModel = hiltViewModel { factory -> factory.create(songId, db.dataAccess()) }
+
+ SongVersionScreen(
+ viewState = viewModel,
+ tabSearchBarViewState = viewModel.tabSearchBarViewModel,
+ onTabSearchBarQueryChange = viewModel.tabSearchBarViewModel::onQueryChange,
+ onNavigateToTabByTabId = onNavigateToTabByTabId,
+ onNavigateBack = onNavigateBack,
+ onNavigateToSearch = onNavigateToSearch
+ )
+ }
+}
+
+@Composable
+fun SongVersionScreen(
+ viewState: ISongVersionViewState,
+ tabSearchBarViewState: ITabSearchBarViewState,
+ onTabSearchBarQueryChange: (newQuery: String) -> Unit,
+ onNavigateToTabByTabId: (id: Int) -> Unit,
+ onNavigateBack: () -> Unit,
+ onNavigateToSearch: (query: String) -> Unit,
+) {
+ val songVersions = viewState.songVersions.observeAsState(listOf()).value.sortedByDescending { song -> song.votes }
+
+ Column(
+ modifier = Modifier
+ .fillMaxHeight()
+ .background(color = MaterialTheme.colorScheme.background)
+ ) {
+ TabsSearchBar(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 8.dp),
+ leadingIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(id = R.string.generic_action_back))
+ }
+ },
+ viewState = tabSearchBarViewState,
+ onSearch = onNavigateToSearch,
+ onQueryChange = onTabSearchBarQueryChange,
+ onNavigateToTabById = onNavigateToTabByTabId
+ )
+
+ SongVersionList(songVersions = songVersions, navigateToTabByTabId = onNavigateToTabByTabId)
+ }
+
+ BackHandler {
+ onNavigateBack()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/swipetodismiss/DismissBackground.kt b/app/src/main/java/com/gbros/tabslite/view/swipetodismiss/DismissBackground.kt
new file mode 100644
index 0000000..c4ca0d7
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/swipetodismiss/DismissBackground.kt
@@ -0,0 +1,70 @@
+package com.gbros.tabslite.view.swipetodismiss
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SwipeToDismissBoxState
+import androidx.compose.material3.SwipeToDismissBoxValue
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.gbros.tabslite.R
+
+
+/**
+ * The background for a swipe-to-dismiss element. Thanks https://www.geeksforgeeks.org/android-jetpack-compose-swipe-to-dismiss-with-material-3/
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DismissBackground(
+ dismissState: SwipeToDismissBoxState,
+ colors: DismissBackgroundColors = DismissBackgroundColors(MaterialTheme.colorScheme.error, MaterialTheme.colorScheme.error, MaterialTheme.colorScheme.onError, MaterialTheme.colorScheme.onError),
+ icons: DismissBackgroundIcons = DismissBackgroundIcons(Icons.Default.Delete, Icons.Default.Delete),
+ contentDescriptions: DismissBackgroundContentDescriptions = DismissBackgroundContentDescriptions(stringResource(id = R.string.generic_action_delete), stringResource(id = R.string.generic_action_delete))
+) {
+ val color = when (dismissState.dismissDirection) {
+ SwipeToDismissBoxValue.StartToEnd -> colors.startToEndBackgroundColor
+ SwipeToDismissBoxValue.EndToStart -> colors.endToStartBackgroundColor
+ SwipeToDismissBoxValue.Settled -> Color.Transparent
+ }
+ val direction = dismissState.dismissDirection
+
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(color)
+ .padding(12.dp, 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ if (direction == SwipeToDismissBoxValue.StartToEnd) Icon(
+ icons.startToEndIcon,
+ tint = colors.startToEndIconColor,
+ contentDescription = contentDescriptions.startToEndContentDescription
+ )
+ Spacer(modifier = Modifier)
+ if (direction == SwipeToDismissBoxValue.EndToStart) Icon(
+ icons.endToStartIcon,
+ tint = colors.endToStartIconColor,
+ contentDescription = contentDescriptions.endToStartContentDescription
+ )
+ }
+}
+
+class DismissBackgroundColors(val startToEndBackgroundColor: Color, val endToStartBackgroundColor: Color, val startToEndIconColor: Color, val endToStartIconColor: Color)
+
+class DismissBackgroundIcons(val startToEndIcon: ImageVector, val endToStartIcon: ImageVector)
+
+class DismissBackgroundContentDescriptions(val startToEndContentDescription: String, val endToStartContentDescription: String)
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/swipetodismiss/MaterialSwipeToDismiss.kt b/app/src/main/java/com/gbros/tabslite/view/swipetodismiss/MaterialSwipeToDismiss.kt
new file mode 100644
index 0000000..4eb87ad
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/swipetodismiss/MaterialSwipeToDismiss.kt
@@ -0,0 +1,77 @@
+package com.gbros.tabslite.view.swipetodismiss
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.material3.SwipeToDismissBox
+import androidx.compose.material3.SwipeToDismissBoxValue
+import androidx.compose.material3.rememberSwipeToDismissBoxState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import com.gbros.tabslite.view.playlists.RemovePlaylistEntryConfirmationDialog
+
+/**
+ * Composable representing swipe-to-dismiss functionality. Thanks https://www.geeksforgeeks.org/android-jetpack-compose-swipe-to-dismiss-with-material-3/
+ *
+ * @param content The content to include in the SwipeToDismiss.
+ * @param onRemove Callback invoked when the email item is dismissed.
+ */
+@Composable
+fun MaterialSwipeToDismiss(
+ onRemove: () -> Unit,
+ content: @Composable RowScope.() -> Unit,
+ enable: Boolean
+) {
+ var show by remember { mutableStateOf(true) } // whether to show the row at all
+ var resetEntryRemoval by remember { mutableStateOf(false) } // trigger a reset of the removal state
+ var showEntryConfirmationDialog by remember { mutableStateOf(false) } // trigger the entry removal confirmation dialog
+ val dismissState = rememberSwipeToDismissBoxState(
+ confirmValueChange = {dismissValue ->
+ if (dismissValue == SwipeToDismissBoxValue.StartToEnd || dismissValue == SwipeToDismissBoxValue.EndToStart) {
+ showEntryConfirmationDialog = true // trigger entry removal confirmation dialog
+ }
+ // since the confirmation isn't synchronous, always confirm the value change, and just reset if the user doesn't confirm
+ true // this must be outside the if block so that the reset() action gets automatically confirmed if the user doesn't confirm the dismiss
+ }
+ )
+ AnimatedVisibility(
+ show, exit = fadeOut(spring())
+ ) {
+ setOf(SwipeToDismissBoxValue.EndToStart,
+ SwipeToDismissBoxValue.StartToEnd
+ )
+ SwipeToDismissBox(
+ state = dismissState,
+ backgroundContent = {
+ DismissBackground(dismissState)
+ },
+ modifier = Modifier,
+ enableDismissFromStartToEnd = false,
+ enableDismissFromEndToStart = true,
+ gesturesEnabled = enable,
+ content = content
+ )
+ }
+
+ // confirm entry removal
+ if (showEntryConfirmationDialog) {
+ RemovePlaylistEntryConfirmationDialog(
+ onConfirm = { onRemove(); showEntryConfirmationDialog = false; show = false },
+ onDismiss = { showEntryConfirmationDialog = false; resetEntryRemoval = true }
+ )
+ }
+
+ // handle removal cancelled
+ LaunchedEffect(key1 = resetEntryRemoval) {
+ if(resetEntryRemoval) {
+ dismissState.reset() // undo a removal
+ resetEntryRemoval = false;
+ }
+ }
+}
diff --git a/app/src/main/java/com/gbros/tabslite/view/tabsearchbar/ITabSearchBarViewState.kt b/app/src/main/java/com/gbros/tabslite/view/tabsearchbar/ITabSearchBarViewState.kt
new file mode 100644
index 0000000..5cc8de1
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/tabsearchbar/ITabSearchBarViewState.kt
@@ -0,0 +1,23 @@
+package com.gbros.tabslite.view.tabsearchbar
+
+import androidx.lifecycle.LiveData
+import com.gbros.tabslite.LoadingState
+import com.gbros.tabslite.data.tab.ITab
+
+interface ITabSearchBarViewState {
+ /**
+ * The current query to be displayed in the search bar
+ */
+ val query: LiveData
+
+ /**
+ * A couple suggested tabs already loaded in the database
+ */
+ val tabSuggestions: LiveData>
+
+ /**
+ * The current search suggestions to be displayed
+ */
+ val searchSuggestions: LiveData>
+ val loadingState: LiveData
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/tabsearchbar/SearchSuggestion.kt b/app/src/main/java/com/gbros/tabslite/view/tabsearchbar/SearchSuggestion.kt
new file mode 100644
index 0000000..a0100cb
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/tabsearchbar/SearchSuggestion.kt
@@ -0,0 +1,31 @@
+package com.gbros.tabslite.view.tabsearchbar
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Card
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.gbros.tabslite.ui.theme.AppTheme
+
+@Composable
+fun SearchSuggestion(modifier: Modifier = Modifier, suggestionText: String, onClick: () -> Unit) {
+ Card(
+ modifier = modifier
+ .clickable(onClick = onClick)
+ ) {
+ Text(
+ text = suggestionText,
+ modifier = Modifier.padding(all = 4.dp)
+ )
+ }
+}
+
+@Composable @Preview
+private fun SearchSuggestionPreview() {
+ AppTheme {
+ SearchSuggestion(suggestionText = "This is an example suggested search (clickable)", onClick = {})
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/tabsearchbar/SuggestedTab.kt b/app/src/main/java/com/gbros/tabslite/view/tabsearchbar/SuggestedTab.kt
new file mode 100644
index 0000000..ea5f954
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/tabsearchbar/SuggestedTab.kt
@@ -0,0 +1,123 @@
+package com.gbros.tabslite.view.tabsearchbar
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Card
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.gbros.tabslite.R
+import com.gbros.tabslite.data.tab.ITab
+import com.gbros.tabslite.data.tab.Tab
+import com.gbros.tabslite.ui.theme.AppTheme
+
+@Composable
+fun SuggestedTab(modifier: Modifier = Modifier, tab: ITab, onClick: (tabId: Int) -> Unit) {
+ Card(
+ modifier = modifier
+ .clickable(onClick = {onClick(tab.tabId)})
+ ) {
+ Row(
+ modifier = Modifier
+ .padding(all = 4.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Icon(
+ modifier = Modifier
+ .padding(end = 4.dp),
+ imageVector = ImageVector.vectorResource(id = R.drawable.ic_search_activity),
+ contentDescription = null
+ )
+ Text(
+ text = tab.songName,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1
+ )
+
+ Spacer(modifier = Modifier.weight(1f, fill=true))
+
+ Text(
+ text = tab.artistName,
+ fontStyle = FontStyle.Italic,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ modifier = Modifier
+ .padding(start = 6.dp)
+ )
+ }
+ }
+}
+
+@Composable
+@Preview
+private fun SuggestedTabPreview() {
+ val suggestion = Tab(
+ tabId = 0,
+ songName = "Three Little Birds",
+ artistName = "Bob Marley"
+ )
+ AppTheme {
+ SuggestedTab(
+ tab = suggestion,
+ onClick = {}
+ )
+ }
+}
+
+@Composable
+@Preview
+private fun SuggestedTabPreviewTextOverflow() {
+ val suggestion = Tab(
+ tabId = 0,
+ songName = "Three Little Birds and a lot lot more long title",
+ artistName = "Bob Marley with a long artist name as well"
+ )
+ AppTheme {
+ SuggestedTab(
+ tab = suggestion,
+ onClick = {}
+ )
+ }
+}
+
+@Composable
+@Preview
+private fun SuggestedTabPreviewTextOverflowTitleOnly() {
+ val suggestion = Tab(
+ tabId = 0,
+ songName = "Three Little Birds and a lot lot more long title",
+ artistName = "Bob"
+ )
+ AppTheme {
+ SuggestedTab(
+ tab = suggestion,
+ onClick = {}
+ )
+ }
+}
+
+@Composable
+@Preview
+private fun SuggestedTabPreviewTextOverflowArtistOnly() {
+ val suggestion = Tab(
+ tabId = 0,
+ songName = "Birds",
+ artistName = "Bob with a very very long artist name that should overflow"
+ )
+ AppTheme {
+ SuggestedTab(
+ tab = suggestion,
+ onClick = {}
+ )
+ }
+}
diff --git a/app/src/main/java/com/gbros/tabslite/view/tabsearchbar/TabsSearchBar.kt b/app/src/main/java/com/gbros/tabslite/view/tabsearchbar/TabsSearchBar.kt
new file mode 100644
index 0000000..9b25191
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/tabsearchbar/TabsSearchBar.kt
@@ -0,0 +1,149 @@
+package com.gbros.tabslite.view.tabsearchbar
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SearchBar
+import androidx.compose.material3.SearchBarDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.gbros.tabslite.LoadingState
+import com.gbros.tabslite.R
+import com.gbros.tabslite.data.tab.ITab
+import com.gbros.tabslite.data.tab.Tab
+import com.gbros.tabslite.ui.theme.AppTheme
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@Composable
+fun TabsSearchBar(
+ modifier: Modifier = Modifier,
+ leadingIcon: @Composable () -> Unit = { Icon(
+ imageVector = ImageVector.vectorResource(id = R.drawable.ic_launcher_foreground),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )},
+ viewState: ITabSearchBarViewState,
+ onQueryChange: (newQuery: String) -> Unit,
+ onSearch: (query: String) -> Unit,
+ onNavigateToTabById: (tabId: Int) -> Unit
+) {
+ val query = viewState.query.observeAsState("")
+ var active by remember { mutableStateOf(false) }
+ val lazyColumnState = rememberLazyListState()
+ val searchSuggestions = viewState.searchSuggestions.observeAsState(listOf())
+ val suggestedTabs = viewState.tabSuggestions.observeAsState(listOf())
+
+ val onActiveChange = {expanded: Boolean -> active = expanded}
+ SearchBar(
+ modifier = modifier,
+ expanded = active,
+ onExpandedChange = onActiveChange,
+ windowInsets = WindowInsets.safeDrawing,
+ inputField = {
+ SearchBarDefaults.InputField(
+ query = query.value,
+ onQueryChange = onQueryChange,
+ onSearch = {q -> if(q.isNotBlank()) {onSearch(q)}},
+ expanded = active,
+ onExpandedChange = onActiveChange,
+ enabled = true,
+ placeholder = {
+ Text(text = stringResource(id = R.string.app_action_search))
+ },
+ leadingIcon = leadingIcon,
+ trailingIcon = {
+ IconButton(onClick = {
+ onQueryChange("")
+ active = true // focus input on searchbar
+ }) {
+ if (query.value.isNotEmpty()) {
+ Icon(Icons.Filled.Clear, stringResource(R.string.generic_action_clear))
+ } else {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = stringResource(id = R.string.app_action_description_search)
+ )
+ }
+ }
+ }
+ )
+ },
+ content = {
+ LazyColumn(verticalArrangement = Arrangement.spacedBy(2.dp), state = lazyColumnState) {
+ if (query.value.isNotBlank()) {
+ items(items = suggestedTabs.value) { suggestedTab ->
+ SuggestedTab(
+ modifier = Modifier
+ .fillMaxWidth(),
+ tab = suggestedTab,
+ onClick = onNavigateToTabById
+ )
+ }
+ items(items = searchSuggestions.value) { searchSuggestion ->
+ SearchSuggestion(
+ suggestionText = searchSuggestion,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ if (searchSuggestion.isNotBlank())
+ onSearch(searchSuggestion)
+ }
+ }
+ }
+ }
+ }
+ )
+}
+
+@Composable @Preview
+fun TabsSearchBarPreview() {
+ class TabSearchBarViewStateForTest(
+ override val query: LiveData,
+ override val searchSuggestions: LiveData>,
+ override val tabSuggestions: LiveData>,
+ override val loadingState: LiveData,
+ ) : ITabSearchBarViewState
+
+ AppTheme {
+ TabsSearchBar(
+ viewState = TabSearchBarViewStateForTest(
+ query = MutableLiveData("Test query"),
+ searchSuggestions = MutableLiveData(listOf("suggestion1", "suggestion 2")),
+ tabSuggestions = MutableLiveData(listOf(Tab(0))),
+ loadingState = MutableLiveData()
+ ),
+ leadingIcon = { Icon(
+ imageVector = ImageVector.vectorResource(id = R.drawable.ic_launcher_foreground),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )},
+ onQueryChange = {},
+ onSearch = {},
+ onNavigateToTabById = {}
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/tabview/AutoscrollFloatingActionButton.kt b/app/src/main/java/com/gbros/tabslite/view/tabview/AutoscrollFloatingActionButton.kt
new file mode 100644
index 0000000..b41a10c
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/tabview/AutoscrollFloatingActionButton.kt
@@ -0,0 +1,151 @@
+package com.gbros.tabslite.view.tabview
+
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeContent
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Slider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.TransformOrigin
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.max
+import com.gbros.tabslite.R
+import com.gbros.tabslite.ui.theme.AppTheme
+
+/**
+ * AutoScroll floating action button. Play button shows when paused, and speed slider disappears. Pause
+ * button shows when playing, and speed slider appears. When play button is clicked, onPlay is called
+ * with the current delay. When speed is changed while playing, [onValueChange] is called with the
+ * new delay. When pause button is clicked, onPause is called. Up on the screen will be a lower value.
+ *
+ */
+@Composable
+fun AutoscrollFloatingActionButton(
+ sliderValue: Float,
+ paused: Boolean,
+ onValueChange: (sliderPosition: Float) -> Unit,
+ onValueChangeFinished: () -> Unit,
+ onButtonClick: () -> Unit,
+ alignment: Alignment = Alignment.BottomEnd,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val buttonIsTouched by interactionSource.collectIsPressedAsState()
+ var sliderIsTouched by remember { mutableStateOf(false) }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier
+ .alpha(if (buttonIsTouched || sliderIsTouched || paused) 1f else 0.5f)
+ .align(alignment)
+ .padding(
+ start = max(16.dp, WindowInsets.safeContent.asPaddingValues().calculateStartPadding(
+ LocalLayoutDirection.current)),
+ end = max(16.dp, WindowInsets.safeContent.asPaddingValues().calculateEndPadding(
+ LocalLayoutDirection.current)),
+ top = max(16.dp, WindowInsets.safeContent.asPaddingValues().calculateTopPadding()),
+ bottom = max(WindowInsets.safeDrawing.asPaddingValues().calculateBottomPadding() + 16.dp, // leave room between the navigation bar
+ WindowInsets.safeContent.asPaddingValues().calculateBottomPadding()) // if we're just leaving room for gestures, that's fine
+ )
+
+ ) {
+ if (!paused) {
+ // vertical slider thanks https://stackoverflow.com/a/71129399/3437608
+ Slider(
+ value = sliderValue,
+ valueRange = 0f..1f,
+ onValueChange = { newValue ->
+ sliderIsTouched = true
+ onValueChange(newValue)
+ },
+ onValueChangeFinished = {
+ sliderIsTouched = false
+ onValueChangeFinished()
+ },
+ modifier = Modifier
+ .graphicsLayer {
+ rotationZ = 270f
+ transformOrigin = TransformOrigin(0f, 0f)
+ }
+ .layout { measurable, constraints ->
+ val placeable = measurable.measure(
+ Constraints(
+ minWidth = constraints.minHeight,
+ maxWidth = constraints.maxHeight,
+ minHeight = constraints.minWidth,
+ maxHeight = constraints.maxHeight,
+ )
+ )
+ layout(placeable.height, placeable.width) {
+ placeable.place(-placeable.width, 0)
+ }
+ }
+ .width(200.dp)
+ .height(54.dp)
+
+ )
+ }
+
+ FloatingActionButton(
+ onClick = onButtonClick,
+ interactionSource = interactionSource
+ ) {
+ if (paused) {
+ Icon(imageVector = Icons.Default.PlayArrow, contentDescription = stringResource(R.string.generic_action_play))
+ } else {
+ Icon(
+ imageVector = ImageVector.vectorResource(R.drawable.ic_pause),
+ contentDescription = stringResource(R.string.generic_action_pause)
+ )
+ }
+ }
+ }
+ }
+}
+
+
+@Composable @Preview
+private fun AutoscrollFloatingActionButtonPreview() {
+ AppTheme {
+ AutoscrollFloatingActionButton(
+ sliderValue = 0.5f,
+ paused = false,
+ onValueChange = {},
+ onButtonClick = {},
+ onValueChangeFinished = {}
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/tabview/ITabViewState.kt b/app/src/main/java/com/gbros/tabslite/view/tabview/ITabViewState.kt
new file mode 100644
index 0000000..98346b1
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/tabview/ITabViewState.kt
@@ -0,0 +1,138 @@
+package com.gbros.tabslite.view.tabview
+
+import android.content.Context
+import androidx.compose.ui.text.AnnotatedString
+import androidx.lifecycle.LiveData
+import com.gbros.tabslite.LoadingState
+import com.gbros.tabslite.data.chord.ChordVariation
+import com.gbros.tabslite.data.chord.Instrument
+import com.gbros.tabslite.data.playlist.Playlist
+import com.gbros.tabslite.data.tab.ITab
+
+/**
+ * The view state for [TabScreen] to control the state of each UI element in the view
+ */
+interface ITabViewState {
+
+ /**
+ * The name of the song being displayed
+ */
+ val songName: LiveData
+
+ val isFavorite: LiveData
+
+ /**
+ * Whether to display the playlist navigation bar
+ */
+ val isPlaylistEntry: Boolean
+
+ val playlistTitle: LiveData
+
+ val playlistNextSongButtonEnabled: LiveData
+
+ val playlistPreviousSongButtonEnabled: LiveData
+
+ val difficulty: LiveData
+
+ val tuning: LiveData
+
+ fun getCapoText(context: Context): LiveData
+
+ val key: LiveData
+
+ /**
+ * The author of the tab not the song
+ */
+ val author: LiveData
+
+ /**
+ * The author of the song, not the tab
+ */
+ val artist: LiveData
+
+ /**
+ * The ID of the song author
+ */
+ val artistId: LiveData
+
+ val version: LiveData
+
+ val songVersions: LiveData>
+
+ /**
+ * How many steps up or down this tab's content is transposed
+ */
+ val transpose: LiveData
+
+ /**
+ * The wrapped, transposed tab content to display
+ */
+ val content: LiveData
+
+ /**
+ * The unwrapped, plaintext tab content, for copying to clipboard
+ */
+ val plainTextContent: LiveData
+
+ /**
+ * The font size that should be used to support the custom wrapping for [content]
+ */
+ val fontSizeSp: LiveData
+
+ /**
+ * The current status of this tab's load process
+ */
+ val state: LiveData
+
+ /**
+ * Whether we're currently autoscrolling (the Play button has been pressed)
+ */
+ val autoscrollPaused: LiveData
+
+ val autoScrollSpeedSliderPosition: LiveData
+
+ /**
+ * The delay between 1px scrolls during autoscroll if not [autoscrollPaused]
+ */
+ val autoscrollDelay: LiveData
+
+ /**
+ * Whether to display the chord fingerings for the current chord
+ */
+ val chordDetailsActive: LiveData
+
+ /**
+ * The title for the chord details section (usually the name of the active chord being displayed)
+ */
+ val chordDetailsTitle: LiveData
+
+ /**
+ * The state of the chord details section (loading until the details have been fetched successfully)
+ */
+ val chordDetailsState: LiveData
+
+ /**
+ * A list of chord fingerings to be displayed in the chord details section
+ */
+ val chordDetailsVariations: LiveData>
+
+ val shareUrl: LiveData
+
+ fun getShareTitle(context: Context): LiveData
+
+ val allPlaylists: LiveData>
+
+ val addToPlaylistDialogSelectedPlaylistTitle: LiveData
+
+ val addToPlaylistDialogConfirmButtonEnabled: LiveData
+
+ /**
+ * The selected instrument to display chords for
+ */
+ val chordInstrument: LiveData
+
+ /**
+ * Whether to display chords as flats or sharps
+ */
+ val useFlats: LiveData
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/tabview/TabPlaylistNavigation.kt b/app/src/main/java/com/gbros/tabslite/view/tabview/TabPlaylistNavigation.kt
new file mode 100644
index 0000000..3c78f62
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/tabview/TabPlaylistNavigation.kt
@@ -0,0 +1,63 @@
+package com.gbros.tabslite.view.tabview
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Card
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.gbros.tabslite.R
+import com.gbros.tabslite.ui.theme.AppTheme
+
+@Composable
+fun TabPlaylistNavigation(modifier: Modifier = Modifier, title: String, nextSongButtonEnabled: Boolean, previousSongButtonEnabled: Boolean, onNextSongClick: () -> Unit, onPreviousSongClick: () -> Unit) {
+ Row(modifier = modifier) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 6.dp)
+ ) {
+ Row {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.headlineSmall,
+ modifier = Modifier
+ .padding(all = 6.dp)
+ .weight(1f)
+ )
+ IconButton(enabled = previousSongButtonEnabled, onClick = onPreviousSongClick) {
+ Icon(
+ imageVector = ImageVector.vectorResource(R.drawable.ic_skip_back) ,
+ contentDescription = "Previous"
+ )
+ }
+ IconButton(enabled = nextSongButtonEnabled, onClick = onNextSongClick) {
+ Icon(
+ imageVector = ImageVector.vectorResource(R.drawable.ic_skip_forward),
+ contentDescription = "Next"
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable @Preview
+private fun TabPlaylistNavigationPreview() {
+ AppTheme {
+ TabPlaylistNavigation(
+ title = "My Playlist",
+ nextSongButtonEnabled = true,
+ previousSongButtonEnabled = false,
+ onPreviousSongClick = {},
+ onNextSongClick = {})
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/gbros/tabslite/view/tabview/TabScreen.kt b/app/src/main/java/com/gbros/tabslite/view/tabview/TabScreen.kt
new file mode 100644
index 0000000..9d9b537
--- /dev/null
+++ b/app/src/main/java/com/gbros/tabslite/view/tabview/TabScreen.kt
@@ -0,0 +1,601 @@
+package com.gbros.tabslite.view.tabview
+
+import android.content.ContentResolver
+import android.content.Context
+import android.net.Uri
+import android.text.Annotation
+import android.text.SpannedString
+import android.util.Log
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeContent
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.Clipboard
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.UriHandler
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.LinkAnnotation
+import androidx.compose.ui.text.LinkInteractionListener
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.text.withLink
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.max
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavType
+import androidx.navigation.compose.composable
+import androidx.navigation.navArgument
+import com.gbros.tabslite.LoadingState
+import com.gbros.tabslite.R
+import com.gbros.tabslite.data.AppDatabase
+import com.gbros.tabslite.data.chord.ChordVariation
+import com.gbros.tabslite.data.chord.Instrument
+import com.gbros.tabslite.data.playlist.Playlist
+import com.gbros.tabslite.data.tab.ITab
+import com.gbros.tabslite.data.tab.Tab
+import com.gbros.tabslite.data.tab.TabWithDataPlaylistEntry
+import com.gbros.tabslite.ui.theme.AppTheme
+import com.gbros.tabslite.utilities.KeepScreenOn
+import com.gbros.tabslite.utilities.TAG
+import com.gbros.tabslite.view.card.ErrorCard
+import com.gbros.tabslite.view.chorddisplay.ChordModalBottomSheet
+import com.gbros.tabslite.viewmodel.TabViewModel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+
+private const val FALLBACK_FONT_SIZE_SP = 14f // fall back to a font size of 14.sp if the system font size can't be read
+
+//#region use case tab screen
+
+private const val TAB_NAV_ARG = "tabId"
+const val TAB_ROUTE_TEMPLATE = "tab/%s"
+
+fun NavController.navigateToTab(tabId: Int) {
+ navigate(TAB_ROUTE_TEMPLATE.format(tabId.toString()))
+}
+
+/**
+ * Navigate to a tab by tab ID, but replace the current item in the back stack.
+ */
+fun NavController.swapToTab(tabId: Int) {
+ navigate(TAB_ROUTE_TEMPLATE.format(tabId.toString())) {
+ popUpTo(route = TAB_ROUTE_TEMPLATE.format("{$TAB_NAV_ARG}")) { inclusive = true }
+ }
+}
+
+fun NavGraphBuilder.tabScreen(
+ onNavigateBack: () -> Unit,
+ onNavigateToArtistIdSongList: (artistId: Int) -> Unit,
+ onNavigateToTabVersionById: (id: Int) -> Unit
+) {
+ composable(
+ route = TAB_ROUTE_TEMPLATE.format("{$TAB_NAV_ARG}"),
+ arguments = listOf(navArgument(TAB_NAV_ARG) { type = NavType.IntType } )
+ ) { navBackStackEntry ->
+ val id = navBackStackEntry.arguments!!.getInt(TAB_NAV_ARG)
+ val db = AppDatabase.getInstance(LocalContext.current)
+
+ // default the font size to whatever the user default font size is. This respects system font settings.
+ val defaultFontSize = MaterialTheme.typography.bodyMedium.fontSize
+ val defaultFontSizeInSp = if (defaultFontSize.isSp) {
+ defaultFontSize.value
+ } else if (defaultFontSize.isEm) {
+ defaultFontSize.value / LocalDensity.current.density
+ } else {
+ FALLBACK_FONT_SIZE_SP
+ }
+
+ val viewModel: TabViewModel = hiltViewModel { factory -> factory.create(
+ id = id,
+ idIsPlaylistEntryId = false,
+ defaultFontSize = defaultFontSizeInSp,
+ dataAccess = db.dataAccess(),
+ navigateToPlaylistEntryById = { /* ignore playlist navigation because we're not in a playlist */ }
+ )}
+
+ TabScreen(
+ viewState = viewModel,
+ onNavigateBack = onNavigateBack,
+ onNavigateToTabByTabId = onNavigateToTabVersionById,
+ onArtistClicked = onNavigateToArtistIdSongList,
+ onPlaylistNextSongClick = viewModel::onPlaylistNextSongClick,
+ onPlaylistPreviousSongClick = viewModel::onPlaylistPreviousSongClick,
+ onTransposeUpClick = viewModel::onTransposeUpClick,
+ onTransposeDownClick = viewModel::onTransposeDownClick,
+ onTransposeResetClick = viewModel::onTransposeResetClick,
+ onTextClick = viewModel::onContentClick,
+ onScreenMeasured = viewModel::onScreenMeasured,
+ onZoom = viewModel::onZoom,
+ onChordDetailsDismiss = viewModel::onChordDetailsDismiss,
+ onAutoscrollButtonClick = viewModel::onAutoscrollButtonClick,
+ onAutoscrollSliderValueChange = viewModel::onAutoscrollSliderValueChange,
+ onAutoscrollSliderValueChangeFinished = viewModel::onAutoscrollSliderValueChangeFinished,
+ onReload = viewModel::onReload,
+ onFavoriteButtonClick = viewModel::onFavoriteButtonClick,
+ onAddPlaylistDialogPlaylistSelected = viewModel::onAddPlaylistDialogPlaylistSelected,
+ onAddToPlaylist = viewModel::onAddToPlaylist,
+ onCreatePlaylist = viewModel::onCreatePlaylist,
+ onInstrumentSelected = viewModel::onInstrumentSelected,
+ onUseFlatsToggled = viewModel::onUseFlatsToggled,
+ onExportToPdfClick = viewModel::onExportToPdfClick
+ )
+ }
+}
+
+//#endregion
+
+//#region use case playlist entry
+
+private const val PLAYLIST_ENTRY_NAV_ARG = "playlistEntryId"
+private const val PLAYLIST_ENTRY_ROUTE_TEMPLATE = "playlist/entry/%s"
+private val PLAYLIST_ENTRY_ROUTE = PLAYLIST_ENTRY_ROUTE_TEMPLATE.format("{$PLAYLIST_ENTRY_NAV_ARG}")
+
+fun NavController.navigateToPlaylistEntry(playlistEntryId: Int) {
+ navigate(PLAYLIST_ENTRY_ROUTE_TEMPLATE.format(playlistEntryId.toString())) {
+ popUpTo(route = PLAYLIST_ENTRY_ROUTE) { inclusive = true }
+ }
+}
+
+fun NavGraphBuilder.playlistEntryScreen(
+ onNavigateToPlaylistEntry: (Int) -> Unit,
+ onNavigateBack: () -> Unit,
+ onNavigateToArtistIdSongList: (artistId: Int) -> Unit,
+ onNavigateToTabVersionById: (id: Int) -> Unit
+) {
+ composable(
+ route = PLAYLIST_ENTRY_ROUTE,
+ arguments = listOf(navArgument(PLAYLIST_ENTRY_NAV_ARG) { type = NavType.IntType } )
+ ) { navBackStackEntry ->
+ val id = navBackStackEntry.arguments!!.getInt(PLAYLIST_ENTRY_NAV_ARG)
+ val db = AppDatabase.getInstance(LocalContext.current)
+
+ // default the font size to whatever the user default font size is. This respects system font settings.
+ val defaultFontSize = MaterialTheme.typography.bodyMedium.fontSize
+ val defaultFontSizeInSp = if (defaultFontSize.isSp) {
+ defaultFontSize.value
+ } else if (defaultFontSize.isEm) {
+ defaultFontSize.value / LocalDensity.current.density
+ } else {
+ FALLBACK_FONT_SIZE_SP
+ }
+
+ val viewModel: TabViewModel = hiltViewModel { factory -> factory.create(
+ id = id,
+ idIsPlaylistEntryId = true,
+ defaultFontSize = defaultFontSizeInSp,
+ dataAccess = db.dataAccess(),
+ navigateToPlaylistEntryById = onNavigateToPlaylistEntry
+ )}
+ TabScreen(
+ viewState = viewModel,
+ onNavigateBack = onNavigateBack,
+ onNavigateToTabByTabId = onNavigateToTabVersionById,
+ onArtistClicked = onNavigateToArtistIdSongList,
+ onPlaylistNextSongClick = viewModel::onPlaylistNextSongClick,
+ onPlaylistPreviousSongClick = viewModel::onPlaylistPreviousSongClick,
+ onTransposeUpClick = viewModel::onTransposeUpClick,
+ onTransposeDownClick = viewModel::onTransposeDownClick,
+ onTransposeResetClick = viewModel::onTransposeResetClick,
+ onTextClick = viewModel::onContentClick,
+ onScreenMeasured = viewModel::onScreenMeasured,
+ onZoom = viewModel::onZoom,
+ onChordDetailsDismiss = viewModel::onChordDetailsDismiss,
+ onAutoscrollButtonClick = viewModel::onAutoscrollButtonClick,
+ onAutoscrollSliderValueChange = viewModel::onAutoscrollSliderValueChange,
+ onAutoscrollSliderValueChangeFinished = viewModel::onAutoscrollSliderValueChangeFinished,
+ onReload = viewModel::onReload,
+ onFavoriteButtonClick = viewModel::onFavoriteButtonClick,
+ onAddPlaylistDialogPlaylistSelected = viewModel::onAddPlaylistDialogPlaylistSelected,
+ onAddToPlaylist = viewModel::onAddToPlaylist,
+ onCreatePlaylist = viewModel::onCreatePlaylist,
+ onInstrumentSelected = viewModel::onInstrumentSelected,
+ onUseFlatsToggled = viewModel::onUseFlatsToggled,
+ onExportToPdfClick = viewModel::onExportToPdfClick
+ )
+ }
+}
+
+//#endregion
+
+@Composable
+fun TabScreen(
+ viewState: ITabViewState,
+ onNavigateBack: () -> Unit,
+ onNavigateToTabByTabId: (id: Int) -> Unit,
+ onArtistClicked: (artistId: Int) -> Unit,
+ onPlaylistNextSongClick: () -> Unit,
+ onPlaylistPreviousSongClick: () -> Unit,
+ onTransposeUpClick: () -> Unit,
+ onTransposeDownClick: () -> Unit,
+ onTransposeResetClick: () -> Unit,
+ onTextClick: (Int, UriHandler, Clipboard) -> Unit,
+ onScreenMeasured: (screenWidth: Int, localDensity: Density, colorScheme: ColorScheme) -> Unit,
+ onZoom: (zoomFactor: Float) -> Unit,
+ onChordDetailsDismiss: () -> Unit,
+ onAutoscrollSliderValueChange: (Float) -> Unit,
+ onAutoscrollButtonClick: () -> Unit,
+ onAutoscrollSliderValueChangeFinished: () -> Unit,
+ onReload: () -> Unit,
+ onFavoriteButtonClick: () -> Unit,
+ onAddPlaylistDialogPlaylistSelected: (Playlist) -> Unit,
+ onAddToPlaylist: () -> Unit,
+ onCreatePlaylist: (title: String, description: String) -> Unit,
+ onInstrumentSelected: (instrument: Instrument) -> Unit,
+ onUseFlatsToggled: (useFlats: Boolean) -> Unit,
+ onExportToPdfClick: (exportFile: Uri, contentResolver: ContentResolver) -> Unit
+) {
+ // handle autoscroll
+ val scrollState = rememberScrollState()
+
+ KeepScreenOn()
+
+ Column(
+ modifier = Modifier
+ .verticalScroll(scrollState)
+ .windowInsetsPadding(WindowInsets(
+ left = max(4.dp, WindowInsets.safeDrawing.asPaddingValues().calculateLeftPadding(LocalLayoutDirection.current)),
+ right = max(4.dp, WindowInsets.safeDrawing.asPaddingValues().calculateRightPadding(LocalLayoutDirection.current))
+ ))
+ ) {
+ // create clickable title
+ val songName = viewState.songName.observeAsState("...").value
+ val artistName = viewState.artist.observeAsState("...").value
+ val currentContext = LocalContext.current
+ val artistId = viewState.artistId.observeAsState(0).value
+ val titleText = remember { currentContext.getText(R.string.tab_title) as SpannedString }
+ val annotations = remember { titleText.getSpans(0, titleText.length, Annotation::class.java) }
+ val titleBuilder = buildAnnotatedString {
+ annotations.forEach { annotation ->
+ if (annotation.key == "arg") {
+ when (annotation.value) {
+ "songName" -> {
+ append(songName)
+ } // do nothing to the song name
+
+ "artistName" -> {
+ // make the artist name clickable
+ withLink(
+ link = LinkAnnotation.Clickable(
+ tag = "artistId",
+ linkInteractionListener = LinkInteractionListener {
+ Log.d(TAG, "artist $artistId ($artistName) clicked")
+ onArtistClicked(artistId)
+ }
+ )) {
+ append(artistName)
+ }
+ }
+
+ "plainText" -> {
+ append(
+ titleText.subSequence(
+ titleText.getSpanStart(annotation),
+ titleText.getSpanEnd(annotation)
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+
+ TabTopAppBar(
+ title = titleBuilder.toString(),
+ allPlaylists = viewState.allPlaylists.observeAsState(listOf()).value,
+ selectedPlaylistTitle = viewState.addToPlaylistDialogSelectedPlaylistTitle.observeAsState(null).value,
+ shareUrl = viewState.shareUrl.observeAsState("https://tabslite.com/").value,
+ isFavorite = viewState.isFavorite.observeAsState(false).value,
+ copyText = viewState.plainTextContent.observeAsState("").value,
+ onNavigateBack = onNavigateBack,
+ onReloadClick = onReload,
+ onFavoriteButtonClick = onFavoriteButtonClick,
+ onAddToPlaylist = onAddToPlaylist,
+ onCreatePlaylist = onCreatePlaylist,
+ onPlaylistSelectionChange = onAddPlaylistDialogPlaylistSelected,
+ selectPlaylistConfirmButtonEnabled = viewState.addToPlaylistDialogConfirmButtonEnabled.observeAsState(false).value,
+ onExportToPdfClick = onExportToPdfClick
+ )
+
+ Column {
+ Text( // Tab title
+ text = titleBuilder,
+ style = MaterialTheme.typography.headlineMedium,
+ overflow = TextOverflow.Ellipsis,
+ color = MaterialTheme.colorScheme.onBackground,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 4.dp,)
+ )
+ if (viewState.isPlaylistEntry) {
+ TabPlaylistNavigation(
+ title = viewState.playlistTitle.observeAsState("").value,
+ nextSongButtonEnabled = viewState.playlistNextSongButtonEnabled.observeAsState(false).value,
+ previousSongButtonEnabled = viewState.playlistPreviousSongButtonEnabled.observeAsState(false).value,
+ onNextSongClick = onPlaylistNextSongClick,
+ onPreviousSongClick = onPlaylistPreviousSongClick
+ )
+ }
+
+ TabSummary(
+ difficulty = viewState.difficulty.observeAsState("").value,
+ tuning = viewState.tuning.observeAsState("").value,
+ capo = viewState.getCapoText(context = LocalContext.current).observeAsState("").value,
+ key = viewState.key.observeAsState("").value,
+ author = viewState.author.observeAsState("").value,
+ version = viewState.version.observeAsState(-1).value,
+ songVersions = viewState.songVersions.observeAsState(listOf(Tab(tabId = 198052, version = 3))).value,
+ onNavigateToTabById = onNavigateToTabByTabId
+ )
+
+ TabTransposeSection(
+ currentTransposition = viewState.transpose.observeAsState(0).value,
+ onTransposeResetClick = onTransposeResetClick,
+ onTransposeUpClick = onTransposeUpClick,
+ onTransposeDownClick = onTransposeDownClick
+ )
+
+ // content
+ if (viewState.state.observeAsState(LoadingState.Loading).value is LoadingState.Success) {
+ TabText(
+ modifier = Modifier.fillMaxWidth(),
+ text = viewState.content.observeAsState(AnnotatedString("")).value,
+ fontSizeSp = viewState.fontSizeSp.observeAsState(FALLBACK_FONT_SIZE_SP).value,
+ onTextClick = onTextClick,
+ onScreenMeasured = onScreenMeasured,
+ onZoom = onZoom
+ )
+ Spacer(modifier = Modifier.padding(vertical = 24.dp))
+
+ if (viewState.isPlaylistEntry) {
+ TabPlaylistNavigation(
+ modifier = Modifier.padding(end = 96.dp), // extra for the autoscroll button
+ title = viewState.playlistTitle.observeAsState("").value,
+ nextSongButtonEnabled = viewState.playlistNextSongButtonEnabled.observeAsState(false).value,
+ previousSongButtonEnabled = viewState.playlistPreviousSongButtonEnabled.observeAsState(false).value,
+ onNextSongClick = onPlaylistNextSongClick,
+ onPreviousSongClick = onPlaylistPreviousSongClick,
+ )
+ } else {
+ Spacer(Modifier.padding(vertical = 16.dp))
+ }
+
+ Spacer(Modifier.windowInsetsPadding(WindowInsets(
+ bottom = max(WindowInsets.safeDrawing.asPaddingValues().calculateBottomPadding() + 16.dp, // leave room between the navigation bar
+ WindowInsets.safeContent.asPaddingValues().calculateBottomPadding()) // if we're just leaving room for gestures, that's fine
+ )))
+ } else {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(all = 24.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ if (viewState.state.value is LoadingState.Error) {
+ ErrorCard(text = stringResource((viewState.state.value as LoadingState.Error).messageStringRef))
+ } else {
+ CircularProgressIndicator() // still loading
+ }
+ }
+ }
+ }
+
+ // chord bottom sheet display if a chord was clicked
+ if (viewState.chordDetailsActive.observeAsState(false).value) {
+ ChordModalBottomSheet(
+ title = viewState.chordDetailsTitle.observeAsState("").value,
+ chordVariations = viewState.chordDetailsVariations.observeAsState(emptyList()).value,
+ instrument = viewState.chordInstrument.observeAsState(Instrument.Guitar).value,
+ useFlats = viewState.useFlats.observeAsState(false).value,
+ loadingState = viewState.chordDetailsState.observeAsState(LoadingState.Loading).value,
+ onDismiss = onChordDetailsDismiss,
+ onInstrumentSelected = onInstrumentSelected,
+ onUseFlatsToggled = onUseFlatsToggled
+ )
+ }
+ }
+
+ AutoscrollFloatingActionButton(
+ sliderValue = viewState.autoScrollSpeedSliderPosition.observeAsState(0.5f).value,
+ onButtonClick = onAutoscrollButtonClick,
+ onValueChange = onAutoscrollSliderValueChange,
+ paused = viewState.autoscrollPaused.observeAsState(false).value,
+ onValueChangeFinished = onAutoscrollSliderValueChangeFinished,
+
+ )
+
+ // scroll if autoscroll isn't paused
+ if (!viewState.autoscrollPaused.observeAsState(true).value) {
+ val autoscrollDelay = viewState.autoscrollDelay.observeAsState(Float.POSITIVE_INFINITY)
+ LaunchedEffect(key1 = autoscrollDelay.value) {
+ val maxScrollValue = scrollState.maxValue
+ while (isActive) {
+ delay(autoscrollDelay.value.toLong())
+ if (!scrollState.isScrollInProgress) { // pause autoscroll while user is manually scrolling
+ val newScrollPosition = scrollState.value + 1
+
+ if (newScrollPosition > maxScrollValue) {
+ // we got to the end of the song; skip scrolling to minimize jitters
+ continue
+ }
+ scrollState.scrollTo(newScrollPosition)
+ }
+ }
+ }
+ }
+}
+
+//#region previews
+
+@Composable @Preview
+private fun TabViewPreview() {
+ data class TabViewStateForTest(
+ override val songName: LiveData,
+ override val isFavorite: LiveData,
+ override val isPlaylistEntry: Boolean,
+ override val playlistTitle: LiveData,
+ override val playlistNextSongButtonEnabled: LiveData,
+ override val playlistPreviousSongButtonEnabled: LiveData,
+ override val difficulty: LiveData,
+ override val tuning: LiveData,
+ override val key: LiveData,
+ override val author: LiveData,
+ override val version: LiveData,
+ override val songVersions: LiveData>,
+ override val transpose: LiveData,
+ override val content: LiveData,
+ override val plainTextContent: LiveData,
+ override val state: LiveData