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 @@ + + + + + + + + + + \ 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 + +![Tabs Lite](docs/img/screenshot/Tabs-Lite-Feature-Graphic.png "Tabs Lite Featured Image") + +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, + override val autoscrollPaused: LiveData, + override val autoScrollSpeedSliderPosition: LiveData, + override val autoscrollDelay: LiveData, + override val chordDetailsActive: LiveData, + override val chordDetailsTitle: LiveData, + override val chordDetailsState: LiveData, + override val chordDetailsVariations: LiveData>, + override val shareUrl: LiveData, + override val allPlaylists: LiveData>, + override val artist: LiveData, + override val artistId: LiveData, + override val addToPlaylistDialogSelectedPlaylistTitle: LiveData, + override val addToPlaylistDialogConfirmButtonEnabled: LiveData, + override val fontSizeSp: LiveData, + override val chordInstrument: LiveData, + override val useFlats: LiveData + ) : ITabViewState { + constructor(tab: ITab): this( + songName = MutableLiveData(tab.songName), + isFavorite = MutableLiveData(true), + isPlaylistEntry = false, + playlistTitle = MutableLiveData("none"), + playlistNextSongButtonEnabled = MutableLiveData(false), + playlistPreviousSongButtonEnabled = MutableLiveData(false), + difficulty = MutableLiveData(tab.difficulty), + tuning = MutableLiveData(tab.tuning), + key = MutableLiveData(tab.tonalityName), + author = MutableLiveData(tab.artistName), + version = MutableLiveData(tab.version), + songVersions = MutableLiveData(listOf()), + transpose = MutableLiveData(tab.transpose), + content = MutableLiveData(AnnotatedString(tab.content)), + plainTextContent = MutableLiveData(tab.content), + state = MutableLiveData(LoadingState.Success), + autoscrollPaused = MutableLiveData(true), + fontSizeSp = MutableLiveData(FALLBACK_FONT_SIZE_SP), + autoScrollSpeedSliderPosition = MutableLiveData(0.5f), + autoscrollDelay = MutableLiveData(Float.POSITIVE_INFINITY), + chordDetailsActive = MutableLiveData(false), + chordDetailsTitle = MutableLiveData("A#m"), + chordDetailsState = MutableLiveData(LoadingState.Loading), + chordDetailsVariations = MutableLiveData(listOf()), + shareUrl = MutableLiveData("https://tabslite.com/tab/1234"), + allPlaylists = MutableLiveData(listOf()), + artist = MutableLiveData("Artist Name"), + artistId = MutableLiveData(1), + addToPlaylistDialogSelectedPlaylistTitle = MutableLiveData("Playlist1"), + addToPlaylistDialogConfirmButtonEnabled = MutableLiveData(false), + chordInstrument = MutableLiveData(Instrument.Guitar), + useFlats = MutableLiveData(false) + ) + + override fun getCapoText(context: Context): LiveData { + return MutableLiveData("4th fret") + } + + override fun getShareTitle(context: Context): LiveData { + return MutableLiveData("Song Name by Author (test)") + } + } + + 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) + + + AppTheme { + TabScreen( + viewState = TabViewStateForTest(tabForTest), + onNavigateBack = { }, + onPlaylistNextSongClick = { }, + onPlaylistPreviousSongClick = { }, + onTransposeUpClick = { }, + onTransposeDownClick = { }, + onTransposeResetClick = { }, + onTextClick = { _, _, _ -> }, + onScreenMeasured = { _, _, _ -> }, + onChordDetailsDismiss = { }, + onAutoscrollSliderValueChange = { }, + onAutoscrollButtonClick = { }, + onAutoscrollSliderValueChangeFinished = { }, + onReload = { }, + onFavoriteButtonClick = { }, + onAddPlaylistDialogPlaylistSelected = { }, + onAddToPlaylist = { }, + onCreatePlaylist = { _, _ -> }, + onZoom = { }, + onInstrumentSelected = { }, + onUseFlatsToggled = { }, + onArtistClicked = { }, + onExportToPdfClick = { _, _ -> }, + onNavigateToTabByTabId = { _ -> } + ) + } +} + +//#endregion diff --git a/app/src/main/java/com/gbros/tabslite/view/tabview/TabSummary.kt b/app/src/main/java/com/gbros/tabslite/view/tabview/TabSummary.kt new file mode 100644 index 0000000..5477dcb --- /dev/null +++ b/app/src/main/java/com/gbros/tabslite/view/tabview/TabSummary.kt @@ -0,0 +1,176 @@ +package com.gbros.tabslite.view.tabview + +import android.icu.text.CompactDecimalFormat +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxWidth +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.foundation.layout.windowInsetsPadding +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.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.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection +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 +import com.gbros.tabslite.view.ratingicon.ProportionallyFilledStar +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TabSummary( + difficulty: String, + tuning: String, + capo: String, + key: String, + author: String, + version: Int, + songVersions: List, + onNavigateToTabById: (Int) -> Unit) { + var versionDropdownExpanded by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .windowInsetsPadding( + WindowInsets( + left = WindowInsets.safeDrawing.asPaddingValues().calculateStartPadding( + LayoutDirection.Ltr + ), + right = WindowInsets.safeContent.asPaddingValues().calculateEndPadding( + LayoutDirection.Ltr + ) + ) + ) + .fillMaxWidth(), + ) { + Row ( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = stringResource(id = R.string.tab_difficulty, difficulty), + color = MaterialTheme.colorScheme.onBackground + ) + Text( + text = stringResource(id = R.string.tab_tuning, tuning), + color = MaterialTheme.colorScheme.onBackground + ) + Text( + text = stringResource(id = R.string.tab_capo, capo), + color = MaterialTheme.colorScheme.onBackground + ) + Text( + text = stringResource(id = R.string.tab_key, key), + color = MaterialTheme.colorScheme.onBackground + ) + } + // versions dropdown to switch versions of this song + ExposedDropdownMenuBox( + expanded = versionDropdownExpanded, + onExpandedChange = { versionDropdownExpanded = !versionDropdownExpanded }, + modifier = Modifier + .width(200.dp) + .padding(start = 8.dp) + ) { + TextField( + value = "Version $version", + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = versionDropdownExpanded) }, + colors = ExposedDropdownMenuDefaults.textFieldColors(), + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryEditable) + ) + ExposedDropdownMenu( + expanded = versionDropdownExpanded, + onDismissRequest = { versionDropdownExpanded = false } + ) { + songVersions.forEach { selectionOption -> + DropdownMenuItem( + text = { + Row ( + modifier = Modifier.height(24.dp) + ) { + Text(stringResource(R.string.tab_version_number, selectionOption.version)) + if (selectionOption.votes > 0) { + Spacer(Modifier.weight(1f)) + val numStars = String.format(Locale.getDefault(), "%.1f", selectionOption.rating) + Text(numStars, modifier = Modifier.padding(horizontal = 4.dp)) + ProportionallyFilledStar(fillPercentage = (selectionOption.rating / 5.0).toFloat().coerceIn(0f, 1f), modifier = Modifier.width(18.dp)) + Text("(${roundToThousands(selectionOption.votes)})", modifier = Modifier.padding(start = 4.dp)) + } + } + }, + onClick = { + onNavigateToTabById(selectionOption.tabId) + versionDropdownExpanded = false + } + ) + } + } + } + } + + // author can be long so don't size the version dropdown based on this content + Text( + text = stringResource(id = R.string.tab_author, author), + color = MaterialTheme.colorScheme.onBackground + ) + } +} + +fun roundToThousands(number: Int): String { + val formatter = CompactDecimalFormat.getInstance(Locale.getDefault(), CompactDecimalFormat.CompactStyle.SHORT) + + if (number < 1000) return formatter.format(number) + return formatter.format((number / 1000) * 1000) +} + +@Composable @Preview +private fun TabSummaryPreview() { + val tab = Tab( + tabId = 0, + songName = "Three Little Birds and a lot lot more long title", + artistName = "Bob Marley with a long artist name as well", + version = 1 + ) + + AppTheme { + TabSummary( + difficulty = "expert", + tuning = "E A D G B E", + capo = "2nd Fret", + key = "C", + author = "Joe Blow", + version = 1, + songVersions = listOf(tab), + onNavigateToTabById = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/gbros/tabslite/view/tabview/TabText.kt b/app/src/main/java/com/gbros/tabslite/view/tabview/TabText.kt new file mode 100644 index 0000000..b72918b --- /dev/null +++ b/app/src/main/java/com/gbros/tabslite/view/tabview/TabText.kt @@ -0,0 +1,210 @@ +package com.gbros.tabslite.view.tabview + +import android.os.Build +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +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.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.Clipboard +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +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.chorddisplay.ChordModalBottomSheet +import com.smarttoolfactory.gesture.detectTransformGestures + +@Composable +fun TabText( + modifier: Modifier = Modifier, + text: AnnotatedString, + fontSizeSp: Float, + onTextClick: (clickLocation: Int, uriHandler: UriHandler, clipboardManager: Clipboard) -> Unit, + onScreenMeasured: (screenWidth: Int, localDensity: Density, colorScheme: ColorScheme) -> Unit, + onZoom: (zoomFactor: Float) -> Unit +){ + val font = remember { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Only Android 8+ supports variable weight fonts + FontFamily(Font(R.font.roboto_mono_variable_weight)) + } else { + FontFamily(Font(R.font.roboto_mono_regular)) + } + } + val localDensity = LocalDensity.current + val colorScheme = MaterialTheme.colorScheme + val uriHandler = LocalUriHandler.current + val clipboardManager = LocalClipboard.current + + ClickableText( + text = text, + style = TextStyle( + fontFamily = font, + fontSize = TextUnit(fontSizeSp, TextUnitType.Sp), + color = MaterialTheme.colorScheme.onBackground + ), + modifier = modifier + .pointerInput(Unit) { + detectTransformGestures(consume = false, onGesture = { _, _, zoom, _, _, _ -> + if (zoom != 1.0f) { + onZoom(zoom) + } + }) + } + .onGloballyPositioned { layoutResult -> + onScreenMeasured(layoutResult.size.width, localDensity, colorScheme) + }, + onClick = { clickLocation -> + onTextClick(clickLocation, uriHandler, clipboardManager) + } + ) +} + +@Composable @Preview +fun TabTextTestCase1() { + AppTheme { + val oneLine = AnnotatedString(""" + [tab] [ch]C[/ch] [ch]Am[/ch] + That David played and it pleased the Lord[/tab] + """.trimIndent()) + var bottomSheetTrigger by remember { mutableStateOf(false) } + + TabText( + text = oneLine, + fontSizeSp = 14f, + onTextClick = { _, _, _ -> + bottomSheetTrigger = true + }, + onScreenMeasured = { _, _, _->}, + onZoom = {} + ) + 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 + ) + ) + + if (bottomSheetTrigger) { + ChordModalBottomSheet( + title = "Am", + chordVariations = chords, + loadingState = LoadingState.Success, + instrument = Instrument.Guitar, + useFlats = false, + onInstrumentSelected = { }, + onDismiss = { bottomSheetTrigger = false }, + onUseFlatsToggled = { } + ) + } + } +} + +@Composable @Preview +fun TabTextPreview() { + val hallelujahTabForTest = AnnotatedString(""" + [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()) + + AppTheme { + TabText( + text = hallelujahTabForTest, + fontSizeSp = 14f, + onTextClick = {_, _, _ ->}, + onScreenMeasured = { _, _, _->}, + onZoom = {} + ) + } +} diff --git a/app/src/main/java/com/gbros/tabslite/view/tabview/TabTopAppBar.kt b/app/src/main/java/com/gbros/tabslite/view/tabview/TabTopAppBar.kt new file mode 100644 index 0000000..8c35861 --- /dev/null +++ b/app/src/main/java/com/gbros/tabslite/view/tabview/TabTopAppBar.kt @@ -0,0 +1,244 @@ +package com.gbros.tabslite.view.tabview + +import android.app.Activity.RESULT_OK +import android.content.ClipData +import android.content.ContentResolver +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Row +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.material.icons.filled.Add +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +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.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +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.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +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.playlist.Playlist +import com.gbros.tabslite.ui.theme.AppTheme +import com.gbros.tabslite.view.addtoplaylistdialog.AddToPlaylistDialog + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TabTopAppBar(isFavorite: Boolean, + title: String, + shareUrl: String, + copyText: String, + allPlaylists: List, + selectedPlaylistTitle: String?, + selectPlaylistConfirmButtonEnabled: Boolean, + onNavigateBack: () -> Unit, + onReloadClick: () -> Unit, + onAddToPlaylist: () -> Unit, + onCreatePlaylist: (title: String, description: String) -> Unit, + onPlaylistSelectionChange: (Playlist) -> Unit, + onFavoriteButtonClick: () -> Unit, + onExportToPdfClick: (exportFile: Uri, contentResolver: ContentResolver) -> Unit +) { + val currentContext = LocalContext.current + + // remember whether three-dot menu is shown currently + var showMenu by remember { mutableStateOf(false) } + + // remember whether the Add To Playlist dialog is shown currently + var showAddToPlaylistDialog by remember { mutableStateOf(false) } + + // handle pdf export + val contentResolver = LocalContext.current.contentResolver + val exportDataFilePickerActivityLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK && result.data?.data != null) { + onExportToPdfClick(result.data!!.data!!, contentResolver) + } // else: user cancelled the action + } + + val topAppBarState = rememberTopAppBarState() + TopAppBar( + title = { + Text( + text = if (topAppBarState.overlappedFraction > 0) title else "", + style = MaterialTheme.typography.headlineSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + ) + }, + scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(state = topAppBarState,), + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.generic_action_back)) + } + }, + actions = { + IconButton(onClick = onFavoriteButtonClick) { + Icon( + imageVector = if (isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder, + contentDescription = stringResource(R.string.tab_favorite_button_accessibility_text), + tint = Color(4294925653) + ) + } + + IconButton(onClick = { + val sendIntent: Intent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, shareUrl) + putExtra(Intent.EXTRA_TITLE, title) + type = "text/plain" + } + val shareIntent = Intent.createChooser(sendIntent, null) + currentContext.startActivity(shareIntent) + }) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = stringResource(R.string.generic_action_share), + ) + } + + IconButton(onClick = { showMenu = !showMenu }) { + Icon(Icons.Default.MoreVert, stringResource(R.string.generic_action_more)) + } + + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + text = { + Row { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.title_add_to_playlist_dialog), + ) + Text(text = stringResource(R.string.title_add_to_playlist_dialog), modifier = Modifier.padding(top = 2.dp, start = 4.dp)) + } + }, + onClick = { + showMenu = false + showAddToPlaylistDialog = true + } + ) + DropdownMenuItem( + text = { + Row { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = stringResource(R.string.generic_action_reload), + ) + Text(text = stringResource(R.string.generic_action_reload), modifier = Modifier.padding(top = 2.dp, start = 4.dp)) + } + }, + onClick = { + showMenu = false + onReloadClick() + } + ) + val clipboardManager = LocalClipboard.current + DropdownMenuItem( + text = { + Row { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_content_copy), + contentDescription = stringResource(R.string.generic_action_copy), + ) + Text(text = stringResource(R.string.generic_action_copy), modifier = Modifier.padding(top = 2.dp, start = 4.dp)) + } + }, + onClick = { + clipboardManager.nativeClipboard.setPrimaryClip(ClipData.newPlainText(title, copyText)) + } + ) + DropdownMenuItem( + text = { + Row { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_picture_as_pdf), + contentDescription = stringResource(R.string.generic_action_export_to_pdf), + ) + Text(text = stringResource(R.string.generic_action_export_to_pdf), modifier = Modifier.padding(top = 2.dp, start = 4.dp)) + } + }, + onClick = { + showMenu = false + // handle pdf export + val exportFileIntent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/pdf" + putExtra(Intent.EXTRA_TITLE, "$title.pdf") + + } + exportDataFilePickerActivityLauncher.launch(exportFileIntent) + } + ) + } + } + ) + + if (showAddToPlaylistDialog) { + AddToPlaylistDialog( + playlists = allPlaylists, + selectedPlaylistDropdownText = selectedPlaylistTitle, + onSelectionChange = onPlaylistSelectionChange, + confirmButtonEnabled = selectPlaylistConfirmButtonEnabled, + onConfirm = { + showAddToPlaylistDialog = false + onAddToPlaylist() + }, + onCreatePlaylist = onCreatePlaylist, + onDismiss = { showAddToPlaylistDialog = false } + ) + } +} + +@Composable @Preview +private fun TabTopAppBarPreview() { + 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 { + TabTopAppBar( + isFavorite = true, + shareUrl = "https://tabslite.com/tab/1234", + allPlaylists = list, + selectedPlaylistTitle = "Test", + copyText = "", + selectPlaylistConfirmButtonEnabled = false, + onAddToPlaylist = {}, + onCreatePlaylist = {_, _->}, + onFavoriteButtonClick = {}, + onPlaylistSelectionChange = {}, + onNavigateBack = {}, + onReloadClick = {}, + onExportToPdfClick = {_, _ ->}, + title = "" + ) + } +} diff --git a/app/src/main/java/com/gbros/tabslite/view/tabview/TabTransposeSection.kt b/app/src/main/java/com/gbros/tabslite/view/tabview/TabTransposeSection.kt new file mode 100644 index 0000000..74bc4f2 --- /dev/null +++ b/app/src/main/java/com/gbros/tabslite/view/tabview/TabTransposeSection.kt @@ -0,0 +1,79 @@ +package com.gbros.tabslite.view.tabview + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material3.Button +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.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.gbros.tabslite.R +import com.gbros.tabslite.ui.theme.AppTheme + +@Composable +fun TabTransposeSection(currentTransposition: Int, onTransposeResetClick: () -> Unit, onTransposeDownClick: () -> Unit, onTransposeUpClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets( + left = WindowInsets.safeDrawing.asPaddingValues().calculateStartPadding( + LayoutDirection.Ltr), + right = WindowInsets.safeDrawing.asPaddingValues().calculateEndPadding( + LayoutDirection.Ltr) + )) + ) { + Text( + text = stringResource(id = R.string.tab_transpose, currentTransposition), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(vertical = 12.dp) + ) + IconButton( // reset transpose + onClick = onTransposeResetClick, + modifier = Modifier + ) { + Icon(imageVector = Icons.Default.Clear, contentDescription = "Reset Transposition", + tint = MaterialTheme.colorScheme.primary) + } + Spacer(modifier = Modifier.weight(1f)) + Button( // transpose down + onClick = onTransposeDownClick, + Modifier.padding(horizontal = 8.dp) + ) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_remove), + contentDescription = "Transpose down") + } + Button( // transpose up + onClick = onTransposeUpClick, + Modifier.padding(horizontal = 8.dp) + ) { + Icon(imageVector = Icons.Default.Add, contentDescription = "Transpose up") + } + } +} + +@Composable @Preview +private fun TabTransposeSectionPreview() { + AppTheme { + TabTransposeSection(currentTransposition = 0, {}, {}, {}) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/gbros/tabslite/viewmodel/HomeViewModel.kt b/app/src/main/java/com/gbros/tabslite/viewmodel/HomeViewModel.kt new file mode 100644 index 0000000..63e5918 --- /dev/null +++ b/app/src/main/java/com/gbros/tabslite/viewmodel/HomeViewModel.kt @@ -0,0 +1,260 @@ +package com.gbros.tabslite.viewmodel + +import android.content.ContentResolver +import android.net.Uri +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.map +import com.gbros.tabslite.LoadingState +import com.gbros.tabslite.R +import com.gbros.tabslite.data.DataAccess +import com.gbros.tabslite.data.Preference +import com.gbros.tabslite.data.ThemeSelection +import com.gbros.tabslite.data.playlist.Playlist +import com.gbros.tabslite.data.playlist.PlaylistFileExportType +import com.gbros.tabslite.utilities.TAG +import com.gbros.tabslite.utilities.UgApi +import com.gbros.tabslite.utilities.combine +import com.gbros.tabslite.view.homescreen.IHomeViewState +import com.gbros.tabslite.view.playlists.PlaylistsSortBy +import com.gbros.tabslite.view.songlist.SortBy +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json + +@HiltViewModel(assistedFactory = HomeViewModel.HomeViewModelFactory::class) +class HomeViewModel +@AssistedInject constructor( + @Assisted private val dataAccess: DataAccess +) : ViewModel(), IHomeViewState { + + //#region dependency injection factory + + @AssistedFactory + interface HomeViewModelFactory { + fun create(dataAccess: DataAccess): HomeViewModel + } + + //#endregion + + //#region view state + + /** + * The percent value (0 to 100) for any ongoing import/export operation + */ + override val playlistImportProgress: MutableLiveData = MutableLiveData(0f) + + /** + * The current state of any import/export operations + */ + override val playlistImportState: MutableLiveData = MutableLiveData() + + /** + * How the playlists are currently sorted + */ + override val playlistsSortBy: LiveData = dataAccess.getLivePreference(Preference.PLAYLIST_SORT).map { sortByPreference -> + sortByPreference?.let { PlaylistsSortBy.valueOf(sortByPreference.value) } ?: PlaylistsSortBy.Name + } + + /** + * The user's saved playlists, sorted by [playlistsSortBy] + */ + override val playlists: LiveData> = dataAccess.getLivePlaylists().combine(playlistsSortBy) { playlists, currentSortBy -> + when(currentSortBy) { + PlaylistsSortBy.Name -> playlists?.sortedBy { it.title } ?: listOf() + PlaylistsSortBy.DateAdded -> playlists?.sortedByDescending { it.dateCreated } ?: listOf() + PlaylistsSortBy.DateModified -> playlists?.sortedByDescending { it.dateModified } ?: listOf() + null -> playlists ?: listOf() + } + } + + /** + * The user's selected app-wide theme. Defaults to System, but can be forced to light or dark + */ + override val selectedAppTheme: LiveData = dataAccess.getLivePreference(Preference.APP_THEME).map { themePreference -> + themePreference?.let { ThemeSelection.valueOf(themePreference.value) } ?: ThemeSelection.System + } + + //#endregion + + //#region public data + + /** + * The view model for the TabSearchBar + */ + val tabSearchBarViewModel = TabSearchBarViewModel(dataAccess = dataAccess) + + /** + * The view model for the list of favorited songs ("Favorites" tab) + */ + val favoriteSongListViewModel = SongListViewModel( + playlistId = Playlist.FAVORITES_PLAYLIST_ID, + defaultSortBy = SortBy.DateAdded, + sortPreferenceName = Preference.FAVORITES_SORT, + dataAccess = dataAccess + ) + + /** + * The view model for the list of popular songs ("Popular" tab) + */ + val popularSongListViewModel = SongListViewModel( + playlistId = Playlist.TOP_TABS_PLAYLIST_ID, + defaultSortBy = SortBy.Popularity, + sortPreferenceName = Preference.POPULAR_SORT, + dataAccess = dataAccess + ) + + //#endregion + + //#region public methods + + /** + * Set the app-wide theme (light, dark, or system) by saving it to preferences + */ + fun setAppTheme(theme: ThemeSelection) { + CoroutineScope(Dispatchers.IO).launch { + dataAccess.upsert(Preference(Preference.APP_THEME, theme.name)) + } + } + + /** + * handle playlist sorting + */ + fun sortPlaylists(sortBy: PlaylistsSortBy){ + CoroutineScope(Dispatchers.IO).launch { + dataAccess.upsert(Preference(Preference.PLAYLIST_SORT, sortBy.name)) + } + } + + /** + * Export all the user's playlists (including Favorites) to the specified file + */ + fun exportPlaylists(destinationFile: Uri, contentResolver: ContentResolver) { + playlistImportState.postValue(LoadingState.Loading) + playlistImportProgress.postValue(0.2f) + + val exportJob = CoroutineScope(Dispatchers.IO).async { + val allUserPlaylists = dataAccess.getPlaylists() + .filter { playlist -> playlist.playlistId != Playlist.TOP_TABS_PLAYLIST_ID } + val allPlaylists = mutableListOf( + Playlist( + -1, + false, + "Favorites", + 0, + 0, + "" + ) + ) // add the Favorites playlist + allPlaylists.addAll(allUserPlaylists) + playlistImportProgress.postValue(0.6f) + val allSelfContainedPlaylists = dataAccess.getSelfContainedPlaylists(allPlaylists) + val playlistsAndEntries = + Json.encodeToString(PlaylistFileExportType(playlists = allSelfContainedPlaylists)) + playlistImportProgress.postValue(0.8f) + + contentResolver.openOutputStream(destinationFile).use { outputStream -> + outputStream?.write(playlistsAndEntries.toByteArray()) + outputStream?.flush() + } + + playlistImportProgress.postValue(1f) + delay(700) + } + exportJob.invokeOnCompletion { ex -> + if (ex != null) { + playlistImportState.postValue(LoadingState.Error(R.string.message_playlist_import_export_unexpected_error)) + Log.e(TAG, "Unexpected error during playlist export: ${ex.message}") + } else { + playlistImportState.postValue(LoadingState.Success) + } + + // reset for next export + playlistImportProgress.postValue(0f) + } + } + + /** + * Import user playlists (including Favorites) from the specified file. Also fetches each imported + * tab from the internet. + */ + fun importPlaylists(sourceFile: Uri, contentResolver: ContentResolver) { + + // a just-visible value to indicate that we've started the import + playlistImportProgress.postValue(.05f) + playlistImportState.postValue(LoadingState.Loading) + + val importJob = CoroutineScope(Dispatchers.IO).async { + // read file + var dataToImport: String? + contentResolver.openInputStream(sourceFile).use { + dataToImport = it?.reader()?.readText() + } + + if (!dataToImport.isNullOrBlank()) { + val importedData = Json.decodeFromString(dataToImport!!) + + // import all playlists (except Favorites and Top Tabs) + val totalEntriesToImport = importedData.playlists.sumOf { pl -> pl.entries.size }.toFloat() + + // track the amount of progress used by previous playlists, used to add current progress to + var progressFromPreviouslyImportedPlaylists = 0f + + for (playlist in importedData.playlists.filter { pl -> pl.playlistId != Playlist.TOP_TABS_PLAYLIST_ID }) { + val progressForThisPlaylist = + playlist.entries.size.toFloat() / totalEntriesToImport // available portion of 100% to use for this playlist + try { + playlist.importToDatabase( + dataAccess = dataAccess, + onProgressChange = { progress -> + playlistImportProgress.postValue(progressFromPreviouslyImportedPlaylists + (progress * progressForThisPlaylist)) + }) + } catch (ex: UgApi.NoInternetException) { + playlistImportState.postValue(LoadingState.Error(R.string.message_playlist_import_delayed_internet_access)) + Log.i(TAG, "Import of playlist ${playlist.title} (id: ${playlist.playlistId}) completed without internet access.") + } catch (ex: Exception) { + Log.e(TAG, "Import of playlist ${playlist.title} (id: ${playlist.playlistId}) failed: ${ex.message}", ex) + } + + progressFromPreviouslyImportedPlaylists += progressForThisPlaylist + } + } + + // pause at 100% progress for a moment before setting progress to 0 + playlistImportProgress.postValue(1f) + delay(700) + } + importJob.invokeOnCompletion { ex -> + if (ex != null) { + playlistImportState.postValue(LoadingState.Error(R.string.message_playlist_import_export_unexpected_error)) + Log.e(TAG, "Unexpected error during playlist import: ${ex.message}") + } else { + playlistImportState.postValue(LoadingState.Success) + } + + playlistImportProgress.postValue(0f) + } + } + + /** + * Create a new playlist and add it to the local database + */ + fun createPlaylist(title: String, description: String) { + CoroutineScope(Dispatchers.IO).launch { + val newPlaylist = Playlist(userCreated = true, title = title, description = description, dateCreated = System.currentTimeMillis(), dateModified = System.currentTimeMillis()) + dataAccess.upsert(newPlaylist) + } + } + + //#endregion + +} \ No newline at end of file diff --git a/app/src/main/java/com/gbros/tabslite/viewmodel/PlaylistViewModel.kt b/app/src/main/java/com/gbros/tabslite/viewmodel/PlaylistViewModel.kt new file mode 100644 index 0000000..712ee35 --- /dev/null +++ b/app/src/main/java/com/gbros/tabslite/viewmodel/PlaylistViewModel.kt @@ -0,0 +1,128 @@ +package com.gbros.tabslite.viewmodel + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.map +import com.gbros.tabslite.data.DataAccess +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.utilities.TAG +import com.gbros.tabslite.view.playlists.IPlaylistViewState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@HiltViewModel(assistedFactory = PlaylistViewModel.PlaylistViewModelFactory::class) +class PlaylistViewModel +@AssistedInject constructor( + @Assisted private val playlistId: Int, + @Assisted private val dataAccess: DataAccess +) : ViewModel(), IPlaylistViewState { + + //#region dependency injection factory + + @AssistedFactory + interface PlaylistViewModelFactory { + fun create(playlistId: Int, dataAccess: DataAccess): PlaylistViewModel + } + + //#endregion + + //#region private data + + private val playlist: LiveData = dataAccess.getLivePlaylist(playlistId) + + //#endregion + + //#region view state + + /** + * The title of the playlist to display + */ + override val title: LiveData = playlist.map { p -> p.title } + + /** + * The description of the playlist to display + */ + override val description: LiveData = playlist.map { p -> p.description } + + /** + * The ordered list of songs in the playlist + */ + override val songs: LiveData> = dataAccess.getSortedPlaylistTabs(playlistId) + + //#endregion + + //#region public methods + + /** + * Rearrange playlist entries + */ + fun reorderPlaylistEntry(fromIndex: Int, toIndex: Int) { + val currentSongs = songs.value + if (currentSongs != null) { + CoroutineScope(Dispatchers.IO).launch { + Log.d(TAG, "Moving ${currentSongs[fromIndex].songName} to ${if (toIndex > fromIndex) "after" else "before"} ${currentSongs[toIndex].songName}") + entryMoved(currentSongs[fromIndex], currentSongs[toIndex], toIndex > fromIndex) + } + + } else { + Log.e(TAG, "Attempting to reorder songs in an uninitialized song list") + } + } + + /** + * Rearrange playlist entries + */ + private suspend fun entryMoved(src: IDataPlaylistEntry, dest: IDataPlaylistEntry, moveAfter: Boolean) { + if (moveAfter) { + dataAccess.moveEntryAfter(src, dest) + } else { + dataAccess.moveEntryBefore(src, dest) + } + } + + /** + * Remove an entry from the playlist + */ + fun entryRemoved(entry: IDataPlaylistEntry) { + CoroutineScope(Dispatchers.IO).launch { + dataAccess.removeEntryFromPlaylist(entry) + } + } + + /** + * Delete the playlist and all playlist entries for this playlist from the database + */ + fun playlistDeleted() { + CoroutineScope(Dispatchers.IO).launch { + dataAccess.deletePlaylist(playlistId) + } + } + + /** + * Update playlist description + */ + fun descriptionChanged(newDescription: String) { + CoroutineScope(Dispatchers.IO).launch { + dataAccess.updateDescription(playlistId = playlistId, newDescription = newDescription) + } + } + + /** + * Update playlist title + */ + fun titleChanged(newTitle: String) { + CoroutineScope(Dispatchers.IO).launch { + dataAccess.updateTitle(playlistId = playlistId, newTitle = newTitle) + } + } + + //#endregion +} \ No newline at end of file diff --git a/app/src/main/java/com/gbros/tabslite/viewmodel/SearchViewModel.kt b/app/src/main/java/com/gbros/tabslite/viewmodel/SearchViewModel.kt new file mode 100644 index 0000000..ab51525 --- /dev/null +++ b/app/src/main/java/com/gbros/tabslite/viewmodel/SearchViewModel.kt @@ -0,0 +1,155 @@ +package com.gbros.tabslite.viewmodel + +import android.util.Log +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.gbros.tabslite.LoadingState +import com.gbros.tabslite.R +import com.gbros.tabslite.data.DataAccess +import com.gbros.tabslite.data.Search +import com.gbros.tabslite.data.tab.ITab +import com.gbros.tabslite.utilities.TAG +import com.gbros.tabslite.utilities.UgApi +import com.gbros.tabslite.view.searchresultsonglist.ISearchViewState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlin.time.Duration.Companion.seconds + +@HiltViewModel(assistedFactory = SearchViewModel.SearchViewModelFactory::class) +class SearchViewModel +@AssistedInject constructor( + @Assisted override val query: String, + @Assisted val artistId: Int?, + @Assisted dataAccess: DataAccess +) : ViewModel(), ISearchViewState { + + //#region dependency injection factory + + @AssistedFactory + interface SearchViewModelFactory { + fun create(query: String, artistId: Int?, dataAccess: DataAccess): SearchViewModel + } + + //#endregion + + //#region view state + + /** + * The search results returned by this query + */ + override val results: MutableLiveData> = MutableLiveData(listOf()) + + /** + * 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 + */ + override val searchState: MutableLiveData = MutableLiveData(LoadingState.Loading) + + /** + * Whether the complete set of search results has already been loaded. Used to disable trying to + * load more search results + */ + override val allResultsLoaded: MutableLiveData = MutableLiveData(false) + + //#endregion + + //#region private data + + /** + * The last page of search results that's been fetched from the server + */ + private var searchSession = Search(query, artistId, dataAccess) + + private var searchMutex = Mutex(locked = false) + + //#endregion + + //#region public data + + val tabSearchBarViewModel = TabSearchBarViewModel( + initialQuery = query, + dataAccess = dataAccess + ) + + //#endregion + + //#region public methods + + /** + * Load another page of search results. Uses a mutex lock to only fetch a single page of results + * at a time. On completion, sets [searchState] to [LoadingState.Success] (or [LoadingState.Error] + * on error) + */ + fun onMoreSearchResultsNeeded(retryOnTimeout: Boolean = true) { + if (searchMutex.tryLock()) { // only fetch one page of search results at a time + val fetchSearchResultsJob = CoroutineScope(Dispatchers.IO).async { + searchState.postValue(LoadingState.Loading) + val newSearchResults = searchSession.fetchNextSearchResults() + if (newSearchResults.isNotEmpty()) { + val updatedResults = results.value?.toMutableList() + updatedResults?.addAll(newSearchResults) + results.postValue(updatedResults?.distinct() ?: newSearchResults) + } else { + allResultsLoaded.postValue(true) + } + } + fetchSearchResultsJob.invokeOnCompletion { ex -> + when (ex) { + null -> { + // success + searchState.postValue(LoadingState.Success) + } + is UgApi.NoInternetException -> { + searchState.postValue(LoadingState.Error(R.string.message_search_no_internet)) + } + is CancellationException -> { + // probably job was cancelled due to timeout (see below) + searchState.postValue(LoadingState.Error(R.string.message_search_timeout)) + } + else -> { + searchState.postValue(LoadingState.Error(R.string.message_search_unexpected_error)) + Log.e(TAG, "Unexpected error loading search results: ${ex.message}", ex) + } + } + + searchMutex.unlock() + } + + // as a backup, if search takes more than 15 seconds to load, cancel and retry + val searchTimeoutJob = CoroutineScope(Dispatchers.Default).async { + // wait 15 seconds before cancelling the search job + delay(15.seconds) + } + searchTimeoutJob.invokeOnCompletion { + // the search job has been given 15 seconds. If it's still running, cancel. + if (!fetchSearchResultsJob.isCompleted) { + fetchSearchResultsJob.cancel("Timeout while waiting for search results.") + + if (retryOnTimeout) { + // retry once more + onMoreSearchResultsNeeded(retryOnTimeout = false) + } + } + } + } + } + + //#endregion + + //#region init + + init { + onMoreSearchResultsNeeded() // preload the first page of search results + } + + //#endregion +} \ No newline at end of file diff --git a/app/src/main/java/com/gbros/tabslite/viewmodel/SongListViewModel.kt b/app/src/main/java/com/gbros/tabslite/viewmodel/SongListViewModel.kt new file mode 100644 index 0000000..891d907 --- /dev/null +++ b/app/src/main/java/com/gbros/tabslite/viewmodel/SongListViewModel.kt @@ -0,0 +1,72 @@ +package com.gbros.tabslite.viewmodel + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.map +import com.gbros.tabslite.data.DataAccess +import com.gbros.tabslite.data.Preference +import com.gbros.tabslite.data.tab.TabWithDataPlaylistEntry +import com.gbros.tabslite.utilities.combine +import com.gbros.tabslite.view.songlist.ISongListViewState +import com.gbros.tabslite.view.songlist.SortBy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * The view model to handle business logic for [com.gbros.tabslite.view.songlist.SongListView]. Meant + * to be used as a sub-view under another view model + */ +class SongListViewModel( + playlistId: Int, + defaultSortBy: SortBy, + private val sortPreferenceName: String? = null, + private val dataAccess: DataAccess +): ISongListViewState { + + //#region private data + + private val backupSortBy: MutableLiveData = MutableLiveData(defaultSortBy) + + //#endregion + + //#region view state + + /** + * How these tabs are currently sorted + */ + override val sortBy: LiveData = sortPreferenceName?.let { notNullSortPreferenceName -> + dataAccess.getLivePreference(notNullSortPreferenceName).map { sortByPreference -> + sortByPreference?.let { SortBy.valueOf(sortByPreference.value) } ?: SortBy.Name + } + } ?: backupSortBy + + /** + * The tabs to display in this song list + */ + override val songs: LiveData> = dataAccess.getPlaylistTabs(playlistId).combine(sortBy) { playlistTabs, currentSortBy -> + when(currentSortBy) { + SortBy.Name -> playlistTabs?.sortedBy { it.songName } ?: listOf() + SortBy.Popularity -> playlistTabs?.sortedByDescending { it.votes } ?: listOf() + SortBy.ArtistName -> playlistTabs?.sortedBy { it.artistName } ?: listOf() + SortBy.DateAdded -> playlistTabs?.sortedByDescending { it.dateAdded } ?: listOf() + null -> playlistTabs ?: listOf() + } + } + + //#endregion + + //#region public methods + + fun onSortSelectionChange(sortSelection: SortBy) { + backupSortBy.postValue(sortSelection) + + if (sortPreferenceName != null) { + CoroutineScope(Dispatchers.IO).launch { + dataAccess.upsert(Preference(sortPreferenceName, sortSelection.name)) + } + } + } + + //#endregion +} \ No newline at end of file diff --git a/app/src/main/java/com/gbros/tabslite/viewmodel/SongVersionViewModel.kt b/app/src/main/java/com/gbros/tabslite/viewmodel/SongVersionViewModel.kt new file mode 100644 index 0000000..19095a7 --- /dev/null +++ b/app/src/main/java/com/gbros/tabslite/viewmodel/SongVersionViewModel.kt @@ -0,0 +1,62 @@ +package com.gbros.tabslite.viewmodel + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.map +import com.gbros.tabslite.data.DataAccess +import com.gbros.tabslite.data.tab.ITab +import com.gbros.tabslite.view.songversionlist.ISongVersionViewState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel + +@HiltViewModel(assistedFactory = SongVersionViewModel.SongVersionViewModelFactory::class) +class SongVersionViewModel +@AssistedInject constructor( + @Assisted songId: Int, + @Assisted dataAccess: DataAccess, +) : ViewModel(), ISongVersionViewState { + + //#region dependency injection factory + + @AssistedFactory + interface SongVersionViewModelFactory { + fun create(songId: Int, dataAccess: DataAccess): SongVersionViewModel + } + + //#endregion + + //#region view state + + /** + * The versions of the selected song to be displayed + */ + override val songVersions: LiveData> = dataAccess.getTabsBySongId(songId).map { tabList -> tabList } + + /** + * The search query to be displayed in the search bar + */ + override val songName: LiveData = songVersions.map { tabList -> tabList.firstOrNull()?.songName ?: "" } + + //#endregion + + //#region public data + + val tabSearchBarViewModel = TabSearchBarViewModel( + initialQuery = songName.value ?: "", + dataAccess = dataAccess + ) + + //#endregion + + //#region init + + init { + // this may cause a small memory leak, since observeForever doesn't get garbage collected automatically + songName.observeForever { name -> tabSearchBarViewModel.onQueryChange(name) } + } + + //#endregion + +} \ No newline at end of file diff --git a/app/src/main/java/com/gbros/tabslite/viewmodel/TabSearchBarViewModel.kt b/app/src/main/java/com/gbros/tabslite/viewmodel/TabSearchBarViewModel.kt new file mode 100644 index 0000000..f194c81 --- /dev/null +++ b/app/src/main/java/com/gbros/tabslite/viewmodel/TabSearchBarViewModel.kt @@ -0,0 +1,91 @@ +package com.gbros.tabslite.viewmodel + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.map +import androidx.lifecycle.switchMap +import com.gbros.tabslite.LoadingState +import com.gbros.tabslite.R +import com.gbros.tabslite.data.DataAccess +import com.gbros.tabslite.data.tab.ITab +import com.gbros.tabslite.utilities.TAG +import com.gbros.tabslite.utilities.UgApi +import com.gbros.tabslite.view.tabsearchbar.ITabSearchBarViewState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * The view model to handle business logic for the [com.gbros.tabslite.view.tabsearchbar.TabsSearchBar] + * view. Meant to be used as a sub-view underneath another view + */ +class TabSearchBarViewModel( + /** + * The initial query value to use + */ + initialQuery: String = "", + + /** + * The data access element, for fetching and putting search suggestions + */ + private val dataAccess: DataAccess +) : ITabSearchBarViewState { + + //#region view state + + /** + * The current query to be displayed in the search bar + */ + override val query: MutableLiveData = MutableLiveData(initialQuery) + + /** + * A couple suggested tabs already loaded in the database + */ + override val tabSuggestions: LiveData> = query.switchMap { currentQuery -> + dataAccess.findMatchingTabs(currentQuery).map { a -> a } + } + + /** + * The current search suggestions to be displayed + */ + override val searchSuggestions: LiveData> = query.switchMap { currentQuery -> + dataAccess.getSearchSuggestions(currentQuery) + } + + override val loadingState: MutableLiveData = MutableLiveData(LoadingState.Success) + + //#endregion + + //#region public methods + + /** + * To be called when the query changes. Updates the query display, and launches a fetch for the + * most recent search suggestions for that query + */ + fun onQueryChange(newQuery: String) { + query.value = newQuery + + CoroutineScope(Dispatchers.IO).launch { + try { + UgApi.searchSuggest(newQuery, dataAccess = dataAccess) + } catch (ex: UgApi.NoInternetException) { + // no internet access to fetch search results. + loadingState.postValue(LoadingState.Error(R.string.message_search_suggestion_no_internet)) + Log.i(TAG, "No internet connection: ${ex.message}", ex) + } catch (ex: Exception) { + loadingState.postValue(LoadingState.Error(R.string.message_search_suggestion_unexpected_error)) + } + } + } + + //#endregion + + //#region init + + init { + onQueryChange(initialQuery) // preload search suggestions for initial query + } + + //#endregion +} \ No newline at end of file diff --git a/app/src/main/java/com/gbros/tabslite/viewmodel/TabViewModel.kt b/app/src/main/java/com/gbros/tabslite/viewmodel/TabViewModel.kt new file mode 100644 index 0000000..de2bec7 --- /dev/null +++ b/app/src/main/java/com/gbros/tabslite/viewmodel/TabViewModel.kt @@ -0,0 +1,1153 @@ +package com.gbros.tabslite.viewmodel + +import android.content.ActivityNotFoundException +import android.content.ClipData +import android.content.ContentResolver +import android.content.Context +import android.content.res.Resources.NotFoundException +import android.graphics.pdf.PdfDocument +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.platform.Clipboard +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.UrlAnnotation +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withAnnotation +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.sp +import androidx.core.graphics.toColorInt +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.map +import androidx.lifecycle.switchMap +import com.gbros.tabslite.LoadingState +import com.gbros.tabslite.R +import com.gbros.tabslite.data.DataAccess +import com.gbros.tabslite.data.Preference +import com.gbros.tabslite.data.chord.Chord +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.utilities.TAG +import com.gbros.tabslite.utilities.UgApi +import com.gbros.tabslite.utilities.combine +import com.gbros.tabslite.view.tabview.ITabViewState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlin.math.floor + +// font size constraints, measured in sp +private const val MIN_FONT_SIZE_SP = 2f +private const val MAX_FONT_SIZE_SP = 36f + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel(assistedFactory = TabViewModel.TabViewModelFactory::class) +class TabViewModel +@AssistedInject constructor( + @Assisted private val id: Int, + @Assisted private val idIsPlaylistEntryId: Boolean, + @Assisted defaultFontSize: Float, + @Assisted private val dataAccess: DataAccess, + @Assisted private val onNavigateToPlaylistEntry: (Int) -> Unit +) : ViewModel(), ITabViewState { + //#region dependency injection factory + + @AssistedFactory + interface TabViewModelFactory { + fun create(id: Int, idIsPlaylistEntryId: Boolean, defaultFontSize: Float, dataAccess: DataAccess, navigateToPlaylistEntryById: (Int) -> Unit): TabViewModel + } + + //#endregion + + //#region private methods + + /** + * Handle transposition by a set number of steps. Transposes the tab content, saves the updated + * transpose value to the playlist if the tab is favorited or in a playlist, and ensures all the + * new chords are downloaded for quick access + */ + private fun transpose(numHalfSteps: Int) { + val currentTab = tab.value + val currentTranspose = transpose.value + if (currentTab != null && currentTranspose != null) { + + val newTranspose = currentTranspose + numHalfSteps // update tab.transpose variable + + // if tab is in a playlist or favorite, save the transposition preference to the db + CoroutineScope(Dispatchers.IO).launch { + if (currentTab is TabWithDataPlaylistEntry) { + dataAccess.updateEntryTransposition(currentTab.entryId, newTranspose) + } else if (dataAccess.tabExistsInFavorites(currentTab.tabId)) { + dataAccess.updateFavoriteTabTransposition( + currentTab.tabId, + newTranspose + ) + } + + // backup transposition in case this tab isn't in a playlist or favorited. This is only used when the tab.transpose value from the database is null + nonPlaylistTranspose.postValue(newTranspose) + } + + // preload all the new chords + CoroutineScope(Dispatchers.IO).launch { + fetchAllChords() + } + } else { + Log.e(TAG, "Transpose button clicked while tab was null.") + } + } + + private suspend fun fetchAllChords() { + val chordsUsedInThisTab = tab.value?.getAllChordNames() + val instrument = chordInstrument.value ?: Instrument.Guitar + if (!chordsUsedInThisTab.isNullOrEmpty()) { + Chord.ensureAllChordsDownloaded(chordsUsedInThisTab, instrument, dataAccess) + } + } + + /** + * Autoscroll slider midpoint (default starting speed). Should be between [minDelay] and [maxDelay] + */ + private val middleDelay: Float = 15f + /** + * Autoscroll shortest delay between 1px scrolls (fastest speed) + */ + private val minDelay: Float = 1f // fastest speed + /** + * Autoscroll longest delay between 1px scrolls (slowest speed) + */ + private val maxDelay: Float = 75f // slowest speed + /** + * Maps the autoscroll slider value to the delay between 1px scrolls for autoscroll + */ + private val mapAutoscrollSliderToScrollDelay = getValueMapperFunction(minOutput = minDelay, middleOutput = maxDelay - middleDelay, maxOutput = maxDelay) + + /** + * Creates a quadratic function that maps 0f..1f to [minOutput]..[maxOutput] where 0.5f maps to [middleOutput] + */ + private fun getValueMapperFunction(minOutput: Float, middleOutput: Float, maxOutput: Float): (x: Float) -> Float { + val coefficients = findQuadraticCoefficients(y1 = minOutput, y2 = middleOutput, y3 = maxOutput) + + val (a, b, c) = coefficients + return { + x: Float -> + val returnVal = (a * (x * x)) + (b * x) + c + (maxOutput - returnVal).coerceIn(minimumValue = minOutput, maximumValue = maxOutput) + } + } + private fun findQuadraticCoefficients(y1: Float, y2: Float, y3: Float): Triple { + val b = 4 * (y2 - y1) - y3 + val a = (2*y3) - (4 * (y2 - y1)) - (2*y1) + val c = y1 + + return Triple(a, b, c) + } + + private fun load(forceReload: Boolean = false) { + _state.postValue(LoadingState.Loading) + val reloadJob = CoroutineScope(Dispatchers.IO).async { + var currentTab = tab.value + if (!tab.isInitialized || currentTab == null) { + // tab hasn't loaded yet. try to load the tab via the passed ID + if (!idIsPlaylistEntryId) { + currentTab = Tab(id) + } else { + val tabId = dataAccess.getEntryById(id)?.tabId + if (tabId == null) { + Log.e(TAG, "Couldn't get tab from playlist entry $id") + _state.postValue(LoadingState.Error(R.string.message_tab_load_from_playlist_unexpected_error)) + } else { + currentTab = Tab(tabId) + } + } + } + + currentTab?.load(dataAccess, forceInternetFetch = forceReload) + } + reloadJob.invokeOnCompletion { ex -> + when (ex) { + null -> { + // success + _state.postValue(LoadingState.Success) + } + is UgApi.NoInternetException -> { + Log.i(TAG, "No internet while fetching tab $id (playlistEntryId: $idIsPlaylistEntryId)", ex) + _state.postValue(LoadingState.Error(R.string.message_tab_load_no_internet)) + } + is UgApi.UnavailableForLegalReasonsException -> { + Log.i(TAG, "Tab ${tab.value?.songName} (${tab.value?.tabId}) unavailable for legal reasons.") + _state.postValue(LoadingState.Error(R.string.message_tab_unavailable_for_legal_reasons)) + } + is NotFoundException -> { + // this shouldn't happen. We only get to this page through the app; it's strange to have a tab ID somewhere else, but not found here. + Log.e(TAG, "Tab $id (playlistEntry: $idIsPlaylistEntryId) not found.", ex) + if (idIsPlaylistEntryId) { + _state.postValue(LoadingState.Error(R.string.message_tab_playlist_entry_not_found)) + } else { + _state.postValue(LoadingState.Error(R.string.message_tab_not_found)) + } + } + else -> { + Log.e(TAG, "Unexpected error loading tab $id (playlistEntryId: $idIsPlaylistEntryId): ${ex.message}", ex) + _state.postValue(LoadingState.Error(R.string.message_tab_load_unexpected_error)) + } + } + } + } + + //region Process Tab Content + + /** + * Word wrap, style, and annotate a given tab. Does not add click functionality, but adds an annotation around + * every chord with tag "chord" + */ + @OptIn(ExperimentalTextApi::class) + private fun processTabContent(content: String, availableWidthInChars: UInt, colorScheme: ColorScheme): AnnotatedString { + val processedTab = buildAnnotatedString { + var indexOfEndOfTabBlock = 0 + while (content.indexOf("[tab]", indexOfEndOfTabBlock) != -1) { // loop through each [tab] line representing lyrics and the chords to go with them + val indexOfStartOfTabBlock = content.indexOf("[tab]", indexOfEndOfTabBlock) + // any content before the [tab] block starts (and after the last [/tab] block ended) should be added without custom word-wrapping. Default wrapping can take care of long lines here. + appendWrappedChordLine(content.subSequence(indexOfEndOfTabBlock, indexOfStartOfTabBlock), availableWidthInChars, this, colorScheme) + indexOfEndOfTabBlock = content.indexOf("[/tab]", indexOfStartOfTabBlock)+6 + if (indexOfEndOfTabBlock-6 == -1) indexOfEndOfTabBlock = content.length+6 + + if (availableWidthInChars != 0u) { // ignore [tab] block wrapping if availableWidth is 0 + // any content that *is* inside [tab] blocks should be custom word-wrapped (wrapped two lines at a time) + val tabBlock = content.subSequence(indexOfStartOfTabBlock+5, indexOfEndOfTabBlock-6) + appendTabBlock(tabBlock, availableWidthInChars, this, colorScheme) + } + } + // append anything after the last tab block + if (indexOfEndOfTabBlock < content.length) { + appendWrappedChordLine(content.subSequence(indexOfEndOfTabBlock, content.length), availableWidthInChars, this, colorScheme) + } + + // add active hyperlinks + val hyperlinks = getHyperLinks(this.toAnnotatedString().text) + for (hyperlink in hyperlinks) { + addUrlAnnotation( + UrlAnnotation(hyperlink.value), + hyperlink.range.first, + hyperlink.range.last+1 + ) + addStyle( + SpanStyle( + color = colorScheme.primary, + textDecoration = TextDecoration.Underline + ), hyperlink.range.first, hyperlink.range.last+1 + ) + } + } + + return processedTab + } + + /** + * Processes and wraps the lines for the tab block, then appends to the annotated string builder. + */ + private fun appendTabBlock(tabBlock: CharSequence, availableWidthInChars: UInt, builder: AnnotatedString.Builder, colorScheme: ColorScheme) { + val lines = tabBlock.split("\n") + + for (i in 0..< lines.count() step 2) { + val line1 = lines[i] + val line2: String? = if (i+1 < lines.count()) lines[i+1] else null + val wrappedLines = wrapLinePair(line1, line2, availableWidthInChars) + + for(wrappedLine in wrappedLines) { + appendChordLine(wrappedLine, builder, colorScheme) + } + } + } + + /** + * Processes and wraps the lines for the chord block, then appends to the annotated string builder. + */ + private fun appendWrappedChordLine(line: CharSequence, availableWidthInChars: UInt, builder: AnnotatedString.Builder, colorScheme: ColorScheme) { + val wrappedLines = wrapLine(line.toString(), availableWidthInChars) + for (wrappedLine in wrappedLines) { + appendChordLine(wrappedLine, builder, colorScheme) + } + } + + /** + * Annotate, style, and append a line with chords to the given annotated string builder + */ + @OptIn(ExperimentalTextApi::class) + private fun appendChordLine(line: CharSequence, builder: AnnotatedString.Builder, colorScheme: ColorScheme?) { + val text = line.trimEnd() + var lastIndex = 0 + + while (text.indexOf("[ch]", lastIndex) != -1) { + val firstIndex = text.indexOf("[ch]", lastIndex) // index of start of [ch] + builder.append(text.subSequence(lastIndex, firstIndex)) // append any non-chords + + lastIndex = text.indexOf("[/ch]", firstIndex)+5 // index of end of [/ch] + if (lastIndex-5 == -1) { + // couldn't find a closing tag for this chord. Handle gracefully and log warning + Log.w(TAG, "Couldn't find closing [/ch] tag for chord starting at position $firstIndex for tab ${tab.value?.tabId}") + lastIndex = firstIndex+4 // start the next loop after that [ch] tag + continue // skip this chord + } + val chordName = text.subSequence(firstIndex+4 until lastIndex-5) + + // append an annotated styled chord + if (colorScheme == null) { + builder.withAnnotation("chord", chordName.toString()) { + append(chordName) + } + } + else { + builder + .withStyle( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + SpanStyle( + // Only Android 8 and up support variable weight fonts + color = colorScheme.onPrimaryContainer, + fontWeight = FontWeight.Bold, + background = colorScheme.primaryContainer + ) + } else { + SpanStyle( + color = colorScheme.onPrimaryContainer, + fontFamily = FontFamily(Font(R.font.roboto_mono_bold)), + background = colorScheme.primaryContainer + ) + } + ) { + withAnnotation("chord", chordName.toString()) { + append(chordName) + } + } + } + } + + // append any remaining non-chords + builder.append(text.subSequence(lastIndex until text.length).trimEnd()) + builder.append("\n") + } + + /** + * Take a line and return a list of lines shorter than the available width + */ + private fun wrapLine(line: String, availableWidthInChars: UInt): List { + val wrappedLines = mutableListOf() + var remainingLine = line + + while (remainingLine != "") { + val wordBreakLocation = findSingleLineWordBreakIndex(availableWidthInChars, remainingLine) + remainingLine = if (wordBreakLocation < remainingLine.length) { + wrappedLines.add(remainingLine.substring(0, wordBreakLocation)) + remainingLine.substring(wordBreakLocation until remainingLine.length) + } else { + wrappedLines.add(remainingLine.trimEnd()) + "" + } + } + return wrappedLines + } + + /** + * Take a pair of lines and return a list of lines shorter than the available width, wrapped as a pair. + */ + private fun wrapLinePair(line1: String, line2: String?, availableWidthInChars: UInt): List { + val wrappedLines = mutableListOf() + if (line2 != null) { + var remainingLine1 = line1 + var remainingLine2 = line2 + + // append two lines + while (remainingLine1 != "" || remainingLine2 != "") { + val wordBreakLocation = findMultipleLineWordBreakIndex(availableWidthInChars, remainingLine1, remainingLine2!!) + + remainingLine1 = if (wordBreakLocation.first < remainingLine1.length) { + wrappedLines.add(remainingLine1.substring(0, wordBreakLocation.first)) + remainingLine1.substring(wordBreakLocation.first until remainingLine1.length) + } else { + wrappedLines.add(remainingLine1.trimEnd()) + "" + } + + remainingLine2 = if (wordBreakLocation.second < remainingLine2.length) { + wrappedLines.add(remainingLine2.substring(0, wordBreakLocation.second)) + remainingLine2.substring(wordBreakLocation.second until remainingLine2.length) + } else { + wrappedLines.add(remainingLine2.trimEnd()) + "" + } + } + } else { + // just line1; append + wrappedLines.add(line1) + } + return wrappedLines + } + + /** + * Finds a "nice" spot to break a single line. Ignores \[ch] and \[/ch] tags. To be used prior to processing chords. + * + * @param line The line to break. + * @param availableWidthInChars The available width in characters. + * @return The index of the character to break at. + */ + private fun findSingleLineWordBreakIndex(availableWidthInChars: UInt, line: String): Int { + // thanks @Andro https://stackoverflow.com/a/11498125 + val breakingChars = "‐–〜゠= \t\r\n" // all the chars that we'll break a line at + + // track fallback line break locations outside of chords (any character but a chord is included) + var fallbackLineBreak = 0 + var currentlyInChord = false + + // start from the start of the line and find each nice word break until the line's too long + var wordBreakLocation = 0 // track nice location separately to include ignored characters up to breakpoint but not past shared breakpoint + var numIgnoredCharacters = 0 // tags (e.g. [ch][/ch]) will be ignored in character counts since they'll be removed in processing. + for (i in 1 ..< availableWidthInChars.toInt()) { + // loop through each character and note shared word break locations + if (line.length <= i+numIgnoredCharacters) { + break + } + + // ignore any [ch] or [/ch] tags + if (line.length > i+numIgnoredCharacters) { + if (line[(i+numIgnoredCharacters)] == '[') { + if (line.length >= (i+numIgnoredCharacters+4) && line.subSequence((i+numIgnoredCharacters), (i+numIgnoredCharacters+4)) == "[ch]") { + numIgnoredCharacters += 4 + currentlyInChord = true + } + if (line.length >= (i+numIgnoredCharacters+5) && line.subSequence((i+numIgnoredCharacters), (i+numIgnoredCharacters+5)) == "[/ch]") { + numIgnoredCharacters += 5 + currentlyInChord = false + } + } + } + + if (!currentlyInChord) + fallbackLineBreak = i+numIgnoredCharacters // any character outside of a chord is a fallback linebreak location + + if ((line.length > i+numIgnoredCharacters && breakingChars.contains(line[i+numIgnoredCharacters]))) { + wordBreakLocation =i + numIgnoredCharacters + } + } + + // if no good word break location exists + if (wordBreakLocation < 1) { + // try to handle nicely by breaking at the last spot outside of a chord + wordBreakLocation = if (fallbackLineBreak > 0) { + fallbackLineBreak + } else { + // welp we tried. Just force the line break at the end of the line. [ch][/ch] artifacts will show up. + availableWidthInChars.toInt() + } + } + + return wordBreakLocation // give the actual character place the user can break at, prior to processing + } + + /** + * Finds a "nice" spot to break both lines. Ignores \[ch] and \[/ch] tags. To be used prior to processing chords. + */ + private fun findMultipleLineWordBreakIndex(availableWidthInChars: UInt, line1: String, line2: String): Pair { + // thanks @Andro https://stackoverflow.com/a/11498125 + val breakingChars = "‐–〜゠= \t\r\n" // all the chars that we'll break a line at + // Log.d(LOG_NAME, "Find word break index; available width: $availableWidthInChars chars. Lengths: ${line1.length}/${line2.length}") + // Log.d(LOG_NAME, "line1: $line1") + // Log.d(LOG_NAME, "line2: $line2") + + // track fallback line break locations outside of chords (any character but a chord is included) + var fallbackLineBreak = Pair(0,0) + var currentlyInChordLine1 = false + var currentlyInChordLine2 = false + + // start from the start of the line and find each shared word break until the line's too long + var sharedWordBreakLocation = Pair(0,0) // track shared location separately to include ignored characters up to breakpoint but not past shared breakpoint + var line1IgnoredCharacters = 0 // tags (e.g. [ch][/ch]) will be ignored in character counts since they'll be removed in processing. + var line2IgnoredCharacters = 0 + for (i in 1 ..< availableWidthInChars.toInt()) { + // loop through each character and note shared word break locations + + // ignore any [ch] or [/ch] tags + if (line1.length > i+line1IgnoredCharacters) { + if (line1[(i+line1IgnoredCharacters)] == '[') { + if (line1.length >= (i+line1IgnoredCharacters+4) && line1.subSequence((i+line1IgnoredCharacters), (i+line1IgnoredCharacters+4)) == "[ch]") { + // Log.d(LOG_NAME, "1: ignoring 4 starting at position $i + $line1IgnoredCharacters") + line1IgnoredCharacters += 4 + currentlyInChordLine1 = true + } + if (line1.length >= (i+line1IgnoredCharacters+5) && line1.subSequence((i+line1IgnoredCharacters), (i+line1IgnoredCharacters+5)) == "[/ch]") { + // Log.d(LOG_NAME, "1: ignoring 5 starting at position $i + $line1IgnoredCharacters") + line1IgnoredCharacters += 5 + currentlyInChordLine1 = false + } + } + } + + if (line2.length > (i+line2IgnoredCharacters)) { + if (line2[(i+line2IgnoredCharacters)] == '[') { + if (line2.length >= (i+line2IgnoredCharacters+4) && line2.subSequence((i+line2IgnoredCharacters), (i+line2IgnoredCharacters+4)) == "[ch]") { + // Log.d(LOG_NAME, "2: ignoring 4 starting at position $i + $line2IgnoredCharacters") + line2IgnoredCharacters += 4 + currentlyInChordLine2 = true + } + if (line2.length >= (i+line2IgnoredCharacters+5) && line2.subSequence((i+line2IgnoredCharacters), (i+line2IgnoredCharacters+5)) == "[/ch]") { + // Log.d(LOG_NAME, "2: ignoring 5 starting at position $i + $line2IgnoredCharacters") + line2IgnoredCharacters += 5 + currentlyInChordLine2 = false + } + } + } + if (!currentlyInChordLine1 && !currentlyInChordLine2) + fallbackLineBreak = Pair(i+line1IgnoredCharacters, i+line2IgnoredCharacters) // any character outside of a chord is a fallback linebreak location + + if ((line1.length <= i+line1IgnoredCharacters || breakingChars.contains(line1[i+line1IgnoredCharacters])) + && (line2.length <= i+line2IgnoredCharacters || breakingChars.contains(line2[(i+line2IgnoredCharacters)]))) { + sharedWordBreakLocation = Pair(i + line1IgnoredCharacters, i + line2IgnoredCharacters) + // Log.d(LOG_NAME, "break at $i plus $line1IgnoredCharacters/$line2IgnoredCharacters. Line1 end: ${line1.length <= i+line1IgnoredCharacters}. Line2 end: ${line2.length <= i+line2IgnoredCharacters}") + } + } + + // if no good word break location exists + if (sharedWordBreakLocation.first < 1 && sharedWordBreakLocation.second < 1) { + // try to handle nicely by breaking at the last spot outside of a chord + sharedWordBreakLocation = if (fallbackLineBreak.first > 0 && fallbackLineBreak.second > 0){ + fallbackLineBreak + } else{ + // welp we tried. Just force the line break at the end of the line. [ch][/ch] artifacts will show up. + Pair(availableWidthInChars.toInt(), availableWidthInChars.toInt()) + } + } + + // Log.d(LOG_NAME, "Return value: ${sharedWordBreakLocation.first}, ${sharedWordBreakLocation.second}") + return sharedWordBreakLocation // give the actual character place the user can break at, prior to processing + } + + private fun getHyperLinks(s: String): Sequence { + val urlPattern = Regex( + "(?:^|\\W)((ht|f)tp(s?)://|www\\.)" + + "(([\\w\\-]+\\.)+([\\w\\-.~]+/?)*" + + "[\\p{Alnum}.,%_=?&#\\-+()\\[\\]*$~@!:/{};']*)", + setOf(RegexOption.IGNORE_CASE, RegexOption.MULTILINE, RegexOption.DOT_MATCHES_ALL) + ) + + return urlPattern.findAll(s) + } + + /** + * Create a PDF document from the current tab + */ + private fun createPdf(): PdfDocument { + val currentColors = currentTheme.value ?: return PdfDocument() + val doc = PdfDocument() + val pageInfo = PdfDocument.PageInfo.Builder(595, 842, 1).create() // A4 page size + + // Margins + val leftMargin = 40f + val rightMargin = 40f + val topMargin = 50f + val bottomMargin = 50f + + var currentPageNumber = 1 + + fun startNewPage(pageNumber: Int): PdfDocument.Page { + val newPageInfo = PdfDocument.PageInfo.Builder(pageInfo.pageWidth, pageInfo.pageHeight, pageNumber).create() + return doc.startPage(newPageInfo) + } + + var page = startNewPage(currentPageNumber) + var canvas = page.canvas + var currentY = topMargin + + // draw title + val titlePaint = android.graphics.Paint().apply { + typeface = android.graphics.Typeface.create(android.graphics.Typeface.DEFAULT, android.graphics.Typeface.BOLD) + textSize = 18f + textAlign = android.graphics.Paint.Align.CENTER + } + val titleX = pageInfo.pageWidth / 2f // center the title on the page + val title = "${songName.value} - ${artist.value}" + canvas.drawText(title, titleX, currentY, titlePaint) + currentY += titlePaint.fontSpacing * 2 // Add some vertical space after the title + + // wrap content + val contentPaint = android.graphics.Paint().apply { + typeface = android.graphics.Typeface.create(android.graphics.Typeface.MONOSPACE, android.graphics.Typeface.NORMAL) + textSize = 12f + } + val availableWidthInPt = pageInfo.pageWidth - leftMargin - rightMargin + val ptPerChar = contentPaint.measureText("A") + val availableWidthInChars = (availableWidthInPt / ptPerChar).toUInt() + val wrappedContent: AnnotatedString = processTabContent(unformattedContent.value ?: "", availableWidthInChars, currentColors) + + // draw content + val chordTextPaint = android.graphics.Paint().apply { + typeface = android.graphics.Typeface.create(android.graphics.Typeface.MONOSPACE, android.graphics.Typeface.BOLD) + textSize = 12f + color = android.graphics.Color.BLACK // Chord text color is black + } + val chordBackgroundPaint = android.graphics.Paint().apply { + color = "#FFDEA0".toColorInt() // Use theme color for highlight, or yellow as a fallback + style = android.graphics.Paint.Style.FILL + } + val lineRegex = Regex(".*\\R?") // Regex to match a full line including its newline characters + lineRegex.findAll(wrappedContent.text).forEach { lineMatchResult -> + val line = lineMatchResult.value.trimEnd() // The actual line content without trailing newline + val lineStartOffset = lineMatchResult.range.first + + if (currentY + contentPaint.fontSpacing > pageInfo.pageHeight - bottomMargin) { + // Finish current page and start a new one + doc.finishPage(page) + currentPageNumber++ + page = startNewPage(currentPageNumber) + canvas = page.canvas + currentY = topMargin + } + + var currentX = leftMargin + val lineEndOffset = lineStartOffset + line.length + + // Get chord annotations for the current line + val lineAnnotations = wrappedContent.getStringAnnotations("chord", lineStartOffset, lineEndOffset) + + var lastCharIndexInLine = 0 + for (annotation in lineAnnotations) { + // Calculate the start and end of the annotation relative to the current line + val annotationStartInLine = annotation.start - lineStartOffset + val annotationEndInLine = annotation.end - lineStartOffset + + // Draw text before the chord + if (annotationStartInLine > lastCharIndexInLine) { + val textBefore = line.substring(lastCharIndexInLine, annotationStartInLine) + canvas.drawText(textBefore, currentX, currentY, contentPaint) + currentX += contentPaint.measureText(textBefore) + } + + // Draw the chord using the annotation's item + val chordText = annotation.item + val chordWidth = contentPaint.measureText(chordText) // Use contentPaint to measure for correct monospaced width + + // Draw the highlight background + val backgroundRect = android.graphics.RectF(currentX - chordTextPaint.letterSpacing, currentY - chordTextPaint.textSize, currentX + chordWidth + chordTextPaint.letterSpacing, currentY + chordTextPaint.descent()) + canvas.drawRect(backgroundRect, chordBackgroundPaint) + + // Draw the chord text over the highlight + canvas.drawText(chordText, currentX, currentY, chordTextPaint) + currentX += chordWidth // Advance currentX by the width of the chord + + lastCharIndexInLine = annotationEndInLine + } + + // Draw any remaining text after the last annotation + if (lastCharIndexInLine < line.length) { + val remainingText = line.substring(lastCharIndexInLine) + canvas.drawText(remainingText, currentX, currentY, contentPaint) + } + + currentY += contentPaint.fontSpacing + } + + doc.finishPage(page) + return doc + } + + /** + * Calculates the number of characters that can fit in the screen. + * + * @param availableWidthInPx The width of the screen in pixels + * @param fontSizeSp The font size in sp + * @param currentDensity The current density of the screen + * + * @return The number of characters that can fit in the screen + */ + private fun getAvailableWidthInChars(availableWidthInPx: Int, fontSizeSp: Float, currentDensity: Density):UInt { + val characterHeightInPixels = with (currentDensity) { fontSizeSp.sp.toPx() } + val characterWidthInPixels = characterHeightInPixels * ROBOTO_ASPECT_RATIO + val charsPerLine = floor(availableWidthInPx / characterWidthInPixels).toUInt() + return charsPerLine + } + +//endregion + +//#endregion + + //#region private data + + private val tab: LiveData = if (idIsPlaylistEntryId) dataAccess.getTabFromPlaylistEntryId(id) else dataAccess.getTab(id) + + /** + * The chord name to look up in the database and display + */ + private var currentChordToDisplay: MutableLiveData = MutableLiveData("") + + override val chordInstrument: LiveData = dataAccess.getLivePreference(Preference.INSTRUMENT).map { p -> if (p != null) Instrument.valueOf(p.value) else Instrument.Guitar } + + private val currentChordInstrumentCombo: LiveData> = + currentChordToDisplay.combine(chordInstrument) { chord, instrument -> + Pair(chord, instrument) + } as MutableLiveData> + + /** + * To calculate the aspect ratio of a ttf font, run this in python (after pip install fonttools): + * aspect_ratio = ttLib.TTFont(r'path\to\font.ttf')['hmtx']['space'][0] / ttLib.TTFont(r'path\to\font.ttf')['head'].unitsPerEm + */ + private val ROBOTO_ASPECT_RATIO = 0.60009765625 // the empirical width-to-height ratio of roboto mono Regular. + + private val screenDensity: MutableLiveData = MutableLiveData() + + /** + * The last measured screen width in pixels, used for calculating how many characters can fit in the screen for custom word wrapping + */ + private val screenWidthInPx: MutableLiveData = MutableLiveData() + + override val fontSizeSp: MutableLiveData = MutableLiveData(defaultFontSize) + + private val availableWidthInChars: LiveData = screenWidthInPx.combine(fontSizeSp, screenDensity) { currentWidthPx, currentFontSizeSp, currentDensity -> + if (currentWidthPx == null || currentFontSizeSp == null || currentDensity == null) { + return@combine 0u + } + return@combine getAvailableWidthInChars(currentWidthPx, currentFontSizeSp, currentDensity) + } + + private val currentTheme: MutableLiveData = MutableLiveData() + + override val allPlaylists: LiveData> = dataAccess.getLivePlaylists() + + private val _addToPlaylistDialogSelectedPlaylist: MutableLiveData = MutableLiveData() + private var addToPlaylistDialogSelectedPlaylist: LiveData = _addToPlaylistDialogSelectedPlaylist.combine(allPlaylists) { currentSelection, playlistList -> + currentSelection // use the current selection if there is one + ?: if (!playlistList.isNullOrEmpty()) { + playlistList.first() // default to the first element in the list of playlists if the list of playlists is populated + } else { + null // fallback to a null selection to let the UI handle the nothing-is-selected case + } + } + + //#endregion + + //#region view state + + override val artistId: LiveData = tab.map { t -> t.artistId } + + override val useFlats: LiveData = dataAccess.getLivePreference(Preference.USE_FLATS).map { p -> p?.value?.toBoolean() == true } + + override val songName: LiveData = tab.map { t -> t?.songName ?: "" } + + override val version: LiveData = tab.map { t -> t.version } + + override val songVersions: LiveData> = tab.switchMap { t -> dataAccess.getTabsBySongId(t.songId).map { t -> t } } + + override val isFavorite: LiveData = if (idIsPlaylistEntryId) dataAccess.playlistEntryExistsInFavorites(id) else dataAccess.tabExistsInFavoritesLive(id) + + /** + * Whether to display the playlist navigation bar + */ + override val isPlaylistEntry: Boolean + get() { + val t = tab.value + return t is TabWithDataPlaylistEntry && t.playlistId > 0 // return false if this is the favorites or popular tabs playlists (<=0) + } + + override val playlistTitle: LiveData = tab.map { t -> + if (t is TabWithDataPlaylistEntry && t.playlistTitle != null) t.playlistTitle!! else "" + } + + override val playlistNextSongButtonEnabled = tab.map { t -> t is TabWithDataPlaylistEntry && t.nextEntryId != null } + + override val playlistPreviousSongButtonEnabled = tab.map { t -> t is TabWithDataPlaylistEntry && t.prevEntryId != null } + + override val difficulty: LiveData = tab.map { t -> t?.difficulty ?: "" } + + override val tuning: LiveData = tab.map { t -> t?.tuning ?: "" } + + override fun getCapoText(context: Context): LiveData = tab.map { t -> t?.getCapoText(context) ?: "" } + + override val key: LiveData = tab.map { t -> t?.tonalityName ?: "" } + + override val author: LiveData = tab.map { t -> t?.contributorUserName ?: "" } + + override val artist: LiveData = tab.map { t -> t?.artistName ?: "" } + + /** + * Fallback method of saving transposition while this tab is open, if this tab is not in a playlist or favorites. This is only used when the tab.transpose value from the database is null + */ + private val nonPlaylistTranspose: MutableLiveData = MutableLiveData(0) + override val transpose: LiveData = tab.combine(nonPlaylistTranspose) { t, nonPlaylistTranspose -> + t?.transpose ?: nonPlaylistTranspose ?: 0 + } + + private val unformattedContent: LiveData = tab.combine(transpose, useFlats) { t, tr, f -> + val currentDbContent = t?.content ?: "" + val currentTranspose = tr ?: 0 + val useFlats = f == true + + val chordPattern = Regex("\\[ch](.*?)\\[/ch]") + val transposedContent = chordPattern.replace(currentDbContent) { + val chord = it.groupValues[1] + "[ch]" + Chord.transposeChord(chord, currentTranspose, useFlats) + "[/ch]" + } + + return@combine transposedContent + } + + override val plainTextContent: LiveData = unformattedContent.map { txt -> + txt.replace("[tab]", "").replace("[/tab]", "").replace("[ch]", "").replace("[/ch]", "") + } + + override val content: LiveData = unformattedContent.combine(availableWidthInChars, currentTheme) { unformatted, availableWidth, theme -> + if (unformatted != null && availableWidth != null && availableWidth > 0u && theme != null) { + processTabContent(unformatted, availableWidth, theme) + } else { + Log.d(TAG, "No content yet") + AnnotatedString("") + } + } + + private val _state: MutableLiveData = MutableLiveData(LoadingState.Loading) + override val state: LiveData = content.combine(_state) { c, _ -> + // check for an update in status if we're still in Loading (or Failure) state before returning + if (c != null) { + if (_state.value != LoadingState.Success && c.isNotEmpty()) { + _state.postValue(LoadingState.Success) // content successfully loaded and formatted + } + } + _state.value ?: LoadingState.Loading + } + + /** + * Whether we're currently autoscrolling + */ + private val _autoscrollPaused: MutableLiveData = MutableLiveData(true) + override val autoscrollPaused: LiveData = _autoscrollPaused + + private var _autoscrollSpeedSliderPosition: MutableLiveData = MutableLiveData(.5f) + override val autoScrollSpeedSliderPosition: LiveData = _autoscrollSpeedSliderPosition + + /** + * Whether to display the chord fingerings for the current chord + */ + private val _chordDetailsActive: MutableLiveData = MutableLiveData(false) + override val chordDetailsActive: LiveData = _chordDetailsActive + + /** + * The title for the chord details section (usually the name of the active chord being displayed) + */ + override val chordDetailsTitle: LiveData = currentChordToDisplay + + /** + * A list of chord fingerings to be displayed in the chord details section + */ + override val chordDetailsVariations: LiveData> = currentChordInstrumentCombo.switchMap { (chord, instrument) -> + if (chord == null || instrument == null) { + MutableLiveData(listOf()) + } else { + CoroutineScope(Dispatchers.IO).launch { + // double check that the chord is downloaded + Chord.getChord(chord, instrument, dataAccess) + } + dataAccess.chordVariations(chord, instrument) + } + } + + /** + * The state of the chord details section (loading until the details have been fetched successfully) + */ + private val _chordDetailsState: MutableLiveData = MutableLiveData(LoadingState.Loading) + override val chordDetailsState: LiveData = chordDetailsVariations.switchMap { c -> + // check for an update in status if we're still in Loading (or Failure) state before returning + if (_chordDetailsState.value != LoadingState.Success && c.isNotEmpty()) { + _chordDetailsState.postValue(LoadingState.Success) // chords successfully loaded + } + _chordDetailsState + } + + override val autoscrollDelay: LiveData = autoScrollSpeedSliderPosition.map { sliderPosition -> mapAutoscrollSliderToScrollDelay(sliderPosition) } + + override val shareUrl: LiveData = tab.map { t -> "https://tabslite.com/tab/${t?.tabId}" } + + override fun getShareTitle(context: Context): LiveData = tab.map { t -> t?.let { String.format(format = context.getString(R.string.tab_title), t.songName, it.artistName) } ?: "" } + + override val addToPlaylistDialogSelectedPlaylistTitle: LiveData = addToPlaylistDialogSelectedPlaylist.map { p -> p?.title } + + override val addToPlaylistDialogConfirmButtonEnabled: LiveData = addToPlaylistDialogSelectedPlaylist.map { p -> p != null } + + //#endregion + + //#region event handling + + fun onExportToPdfClick(exportFile: Uri, contentResolver: ContentResolver) { + val exportJob = CoroutineScope(Dispatchers.IO).async { + val pdfDoc = createPdf() + contentResolver.openOutputStream(exportFile).use { outputStream -> + pdfDoc.writeTo(outputStream) + pdfDoc.close() + outputStream?.flush() + } + } + + exportJob.invokeOnCompletion { ex -> + if (ex != null) { + Log.e(TAG, "Unexpected error during playlist export: ${ex.message}") + } + } + } + + fun onPlaylistNextSongClick() { + val currentTab = tab.value + if (currentTab != null && currentTab is TabWithDataPlaylistEntry) { + val entryIdToNavigateTo = currentTab.nextEntryId + if (entryIdToNavigateTo != null) { + onNavigateToPlaylistEntry(entryIdToNavigateTo) + } else { + Log.w(TAG, "Playlist next song click event triggered while next entry id is null") + } + } else { + Log.w(TAG, "Playlist next song clicked while tab (id: $id, playlist: $idIsPlaylistEntryId) is null or not playlist entry: ${tab.value?.toString()}") + } + } + + fun onPlaylistPreviousSongClick() { + val currentTab = tab.value + if (currentTab != null && currentTab is TabWithDataPlaylistEntry) { + val entryIdToNavigateTo = currentTab.prevEntryId + if (entryIdToNavigateTo != null) { + onNavigateToPlaylistEntry(entryIdToNavigateTo) + } else { + Log.w(TAG, "Playlist previous song click event triggered while previous entry id is null") + } + } else { + Log.w(TAG, "Playlist previous song clicked while tab (id: $id, playlist: $idIsPlaylistEntryId) is null or not playlist entry: ${tab.value?.toString()}") + } + } + + fun onTransposeResetClick() { + transpose(-(transpose.value ?: 0)) + } + + fun onTransposeUpClick() { + transpose(1) + } + + fun onTransposeDownClick() { + transpose(-1) + } + + /** + * Callback to be called when the user triggers a tab refresh. Tries to retrieve a tab ID if the + * initial load failed, and re-fetches the tab from the internet + */ + fun onReload() { + load(true) + } + + /** + * Callback for when a chord is clicked, to display the chord fingering diagram + */ + @OptIn(ExperimentalTextApi::class) + fun onContentClick(clickLocation: Int, uriHandler: UriHandler, clipboardManager: Clipboard) { + val lineEndChars = "\r\n\t" + val clickedChar = content.value?.getOrNull(clickLocation) + val clickedOnNewline = clickedChar == null || lineEndChars.contains(clickedChar, true) + var start = clickLocation + var end = clickLocation + if (!clickedOnNewline) + start--; end++ + + content.value?.getStringAnnotations(tag = "chord", start = start, end = end) + ?.firstOrNull()?.item?.let { chord -> + _chordDetailsState.postValue(LoadingState.Loading) + _chordDetailsActive.postValue(true) + currentChordToDisplay.postValue(chord) + } + + // handle link clicks + content.value?.getUrlAnnotations(clickLocation, clickLocation)?.firstOrNull()?.item?.let { + urlAnnotation -> + try { + uriHandler.openUri(urlAnnotation.url.trim()) + } catch (ex: ActivityNotFoundException) { + Log.i(TAG, "Couldn't launch URL, copying to clipboard instead") + clipboardManager.nativeClipboard.setPrimaryClip(ClipData.newPlainText(urlAnnotation.url.trim(), urlAnnotation.url.trim())) + } + } + } + + fun onChordDetailsDismiss() { + _chordDetailsActive.postValue(false) + } + + fun onAutoscrollSliderValueChange(newValue: Float) { + _autoscrollSpeedSliderPosition.postValue(newValue) + } + + /** + * Callback for when the user lifts their finger after adjusting the autoscroll speed. Save the new + * autoscroll slider position to the database here rather than in [onAutoscrollSliderValueChange] + * to prevent too many database calls + */ + fun onAutoscrollSliderValueChangeFinished() { + CoroutineScope(Dispatchers.IO).launch { + dataAccess.upsert(Preference(Preference.AUTOSCROLL_DELAY, autoScrollSpeedSliderPosition.value.toString())) + } + } + + /** + * Callback for when the Play/Pause button is pressed, to enabled or disable autoscroll + */ + fun onAutoscrollButtonClick() { + _autoscrollPaused.postValue(autoscrollPaused.value == false) // toggle the pause state of autoscroll + } + + fun onFavoriteButtonClick() { + CoroutineScope(Dispatchers.IO).launch { + if(isFavorite.value == true) { + tab.value?.let { dataAccess.deleteTabFromFavorites(it.tabId) } + } else { + val transpose = transpose.value ?: 0 + tab.value?.let { dataAccess.insertToFavorites(it.tabId, transpose) } + } + } + } + + fun onAddPlaylistDialogPlaylistSelected(selection: Playlist) { + _addToPlaylistDialogSelectedPlaylist.postValue(selection) + } + + /** + * Callback for when the AddToPlaylist dialog Confirm button is pressed. Add the current tab + * to the selected playlist + */ + fun onAddToPlaylist() { + val selectedPlaylist = addToPlaylistDialogSelectedPlaylist.value + val currentTab = tab.value + val currentTranspose = transpose.value + + if (selectedPlaylist != null && currentTab != null && currentTranspose != null) { + CoroutineScope(Dispatchers.IO).launch { + dataAccess.appendToPlaylist( + selectedPlaylist.playlistId, + currentTab.tabId, + currentTranspose + ) + } + } else { + Log.e(TAG, "Couldn't add the requested tab $currentTab to playlist ${selectedPlaylist?.playlistId} at transpose $currentTranspose. All of the values need to be non-null.") + } + } + + fun onCreatePlaylist(title: String, description: String) { + val playlistToSave = Playlist(playlistId = 0, userCreated = true, title = title, description = description, dateCreated = System.currentTimeMillis(), dateModified = System.currentTimeMillis()) + CoroutineScope(Dispatchers.IO).launch { + val newPlaylistId = dataAccess.upsert(playlistToSave) + val newPlaylist = dataAccess.getPlaylist(newPlaylistId.toInt()) + _addToPlaylistDialogSelectedPlaylist.postValue(newPlaylist) + } + } + + /** + * Save the current screen details to enable custom wrapping + */ + fun onScreenMeasured(screenWidth: Int, localDensity: Density, colorScheme: ColorScheme) { + if (screenDensity.value != localDensity){ + screenDensity.value = localDensity + } + + if (screenWidthInPx.value != screenWidth) { + screenWidthInPx.value = screenWidth + } + + if (currentTheme.value != colorScheme) { + currentTheme.value = colorScheme + } + } + + /** + * Handle user zooming in and out + */ + fun onZoom(zoomFactor: Float) { + val currentFontSize = fontSizeSp.value + if (currentFontSize != null) { + fontSizeSp.value = + currentFontSize.times(zoomFactor).coerceIn( // Add checks for maximum and minimum font size + MIN_FONT_SIZE_SP, + MAX_FONT_SIZE_SP + ) + } + } + + /** + * Handle user selecting a different instrument to display tabs for + */ + fun onInstrumentSelected(instrument: Instrument) { + _chordDetailsState.value = LoadingState.Loading + CoroutineScope(Dispatchers.IO).launch { + dataAccess.upsert(Preference(Preference.INSTRUMENT, instrument.name)) + fetchAllChords() + _chordDetailsState.postValue(LoadingState.Success) + } + } + + /** + * Handle user toggling between flats and sharps by converting all chords to use flats or sharps + * depending on the passed parameter + */ + fun onUseFlatsToggled(useFlats: Boolean) { + _chordDetailsState.value = LoadingState.Loading + // use the transpose function to force the correct flat/sharp + currentChordToDisplay.value = Chord.transposeChord(currentChordToDisplay.value.toString(), 0, useFlats) + + CoroutineScope(Dispatchers.IO).launch { + dataAccess.upsert(Preference(Preference.USE_FLATS, useFlats.toString())) + fetchAllChords() + } + } + + //#endregion + + //#region init + + init { + // load the tab content from the database (or the internet if no cached database value) + val scope = CoroutineScope(Dispatchers.IO) + load() + + // set our initial autoscroll slider position to the user preference value + val autoscrollPreferenceJob = scope.async { + return@async dataAccess.getPreferenceValue(Preference.AUTOSCROLL_DELAY)?.toFloat() + } + autoscrollPreferenceJob.invokeOnCompletion { err -> + if (err != null) { + Log.e(TAG, "Couldn't load autoscroll user preference: ${err.message}", err) + _autoscrollSpeedSliderPosition.postValue(0.5f) // have a fallback value in case of exception or database errors + } else { + val result = autoscrollPreferenceJob.getCompleted() + _autoscrollSpeedSliderPosition.postValue(result ?: 0.5f) + } + } + + // preload all chords for fast access on click + scope.launch { + fetchAllChords() + } + } + + //#endregion +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_banner_foreground.xml b/app/src/main/res/drawable/ic_banner_foreground.xml new file mode 100644 index 0000000..9c7c4b6 --- /dev/null +++ b/app/src/main/res/drawable/ic_banner_foreground.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_content_copy.xml b/app/src/main/res/drawable/ic_content_copy.xml new file mode 100644 index 0000000..72ee0e2 --- /dev/null +++ b/app/src/main/res/drawable/ic_content_copy.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_download.xml b/app/src/main/res/drawable/ic_download.xml new file mode 100644 index 0000000..dba4601 --- /dev/null +++ b/app/src/main/res/drawable/ic_download.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_drag_handle.xml b/app/src/main/res/drawable/ic_drag_handle.xml new file mode 100644 index 0000000..1072b3d --- /dev/null +++ b/app/src/main/res/drawable/ic_drag_handle.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..c7bb87c --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml new file mode 100644 index 0000000..bd69573 --- /dev/null +++ b/app/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_picture_as_pdf.xml b/app/src/main/res/drawable/ic_picture_as_pdf.xml new file mode 100644 index 0000000..86be6ef --- /dev/null +++ b/app/src/main/res/drawable/ic_picture_as_pdf.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_playlist_add.xml b/app/src/main/res/drawable/ic_playlist_add.xml new file mode 100644 index 0000000..c605d05 --- /dev/null +++ b/app/src/main/res/drawable/ic_playlist_add.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_playlist_play.xml b/app/src/main/res/drawable/ic_playlist_play.xml new file mode 100644 index 0000000..e6f6b7c --- /dev/null +++ b/app/src/main/res/drawable/ic_playlist_play.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_playlist_play_light.xml b/app/src/main/res/drawable/ic_playlist_play_light.xml new file mode 100644 index 0000000..4cc89d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_playlist_play_light.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_rating_star_left_half.xml b/app/src/main/res/drawable/ic_rating_star_left_half.xml new file mode 100644 index 0000000..9ea3a22 --- /dev/null +++ b/app/src/main/res/drawable/ic_rating_star_left_half.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_rating_star_right_half.xml b/app/src/main/res/drawable/ic_rating_star_right_half.xml new file mode 100644 index 0000000..af812eb --- /dev/null +++ b/app/src/main/res/drawable/ic_rating_star_right_half.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_remove.xml b/app/src/main/res/drawable/ic_remove.xml new file mode 100644 index 0000000..791a2f8 --- /dev/null +++ b/app/src/main/res/drawable/ic_remove.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_search_activity.xml b/app/src/main/res/drawable/ic_search_activity.xml new file mode 100644 index 0000000..40f13a5 --- /dev/null +++ b/app/src/main/res/drawable/ic_search_activity.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_skip_back.xml b/app/src/main/res/drawable/ic_skip_back.xml new file mode 100644 index 0000000..a2e76cf --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_back.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_skip_forward.xml b/app/src/main/res/drawable/ic_skip_forward.xml new file mode 100644 index 0000000..bf4e3c1 --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_forward.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_tabslite_guitar.xml b/app/src/main/res/drawable/ic_tabslite_guitar.xml new file mode 100644 index 0000000..6614789 --- /dev/null +++ b/app/src/main/res/drawable/ic_tabslite_guitar.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_ukulele.xml b/app/src/main/res/drawable/ic_ukulele.xml new file mode 100644 index 0000000..6db4cbe --- /dev/null +++ b/app/src/main/res/drawable/ic_ukulele.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_upload.xml b/app/src/main/res/drawable/ic_upload.xml new file mode 100644 index 0000000..203884b --- /dev/null +++ b/app/src/main/res/drawable/ic_upload.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/font/roboto_light.ttf b/app/src/main/res/font/roboto_light.ttf new file mode 100644 index 0000000..3526798 Binary files /dev/null and b/app/src/main/res/font/roboto_light.ttf differ diff --git a/app/src/main/res/font/roboto_mono_bold.ttf b/app/src/main/res/font/roboto_mono_bold.ttf new file mode 100644 index 0000000..d884128 Binary files /dev/null and b/app/src/main/res/font/roboto_mono_bold.ttf differ diff --git a/app/src/main/res/font/roboto_mono_regular.ttf b/app/src/main/res/font/roboto_mono_regular.ttf new file mode 100644 index 0000000..6df2b25 Binary files /dev/null and b/app/src/main/res/font/roboto_mono_regular.ttf differ diff --git a/app/src/main/res/font/roboto_mono_variable_weight.ttf b/app/src/main/res/font/roboto_mono_variable_weight.ttf new file mode 100644 index 0000000..fc02de4 Binary files /dev/null and b/app/src/main/res/font/roboto_mono_variable_weight.ttf differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml b/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml new file mode 100644 index 0000000..a0a0dec --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..ef49c99 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..34dfc11 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/app/src/main/res/mipmap-hdpi/ic_launcher_background.png new file mode 100644 index 0000000..ea58faa Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..0a1151e Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..21d90e3 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..79b5ca9 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/app/src/main/res/mipmap-mdpi/ic_launcher_background.png new file mode 100644 index 0000000..3847f87 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..c09b2de Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..12e9479 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_banner.png b/app/src/main/res/mipmap-xhdpi/ic_banner.png new file mode 100644 index 0000000..7425472 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_banner.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..6c8463a Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 0000000..f63dc13 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..854d916 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..83debd8 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..7e90bc9 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..6f8418c Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..95159c0 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..5427caf Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..e196f04 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..7696a8a Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..5572eca Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..0248098 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-it/plurals.xml b/app/src/main/res/values-it/plurals.xml new file mode 100644 index 0000000..a12e2e6 --- /dev/null +++ b/app/src/main/res/values-it/plurals.xml @@ -0,0 +1,7 @@ + + + + %d versione + %d versioni + + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..dae5bfe --- /dev/null +++ b/app/src/main/res/values-it/strings.xml @@ -0,0 +1,55 @@ + + + + + TabsLite è un\'app gratuita ed open source per accordi di chitarra con oltre un milione di canzoni disponibili, che utilizza i popolari database di accordi esistenti. È costruita per essere veloce e semplice, 100% gratis e senza pubblicità. Gestito da More Than Solitaire, Inc, a 501(c)(3), un ente senza scopo di lucro dedicato all\'apprendimento del software. + Esporta preferiti e playlist + Importa preferiti e playlist + Lascia una recensione + Dona + Cerca su TabsLite + Cerca + Nuova playlist + Mezza stella + Stella piena + Preferiti + Popolari + Playlist + Aggiungi alla playlist + Crea playlist + Titolo della Playlist + Descrizione Playlist + Chiudi + Conferma + Cancella + Indietro + Elimina + Trascina per riordinare + Errore + Info + Vuoto + %s° Tasto + %s° Tasto + %s° Tasto + %s° Tasto + %s° Tasto + %s° Tasto + %s° Tasto + Ordina per: %s + Data inserimento + Popolarità + Nome Artista + Titolo + Impossibile caricare gli accordi \'%s\'. Controlla la tua connessione internet. + Nessun risultato di ricerca. Controlla la tua ricerca o la tua connessione internet. + Qui non c\'è niente! + %1$s di %2$s + Difficoltà: %s + Sintonizzazione: %s + Capo: %s + Chiave: %s + Autore Accordi: %s + Trasposizione: %d + Versione %d + vers. %s + diff --git a/app/src/main/res/values/ic_banner_background.xml b/app/src/main/res/values/ic_banner_background.xml new file mode 100644 index 0000000..573bada --- /dev/null +++ b/app/src/main/res/values/ic_banner_background.xml @@ -0,0 +1,4 @@ + + + #F8BD2A + \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..e78c2e4 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #F8BD2A + \ No newline at end of file diff --git a/app/src/main/res/values/plurals.xml b/app/src/main/res/values/plurals.xml new file mode 100644 index 0000000..bb8f36e --- /dev/null +++ b/app/src/main/res/values/plurals.xml @@ -0,0 +1,7 @@ + + + + %d version + %d versions + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..56f1b14 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,103 @@ + + + Tabs Lite + + + TabsLite is a free, open source guitar tablature app with over a million songs available using existing popular tabs databases. It\'s built for speed and simplicity, 100% free with no ads. Managed by More Than Solitaire, Inc, a 501(c)(3) nonprofit dedicated to software learning. + Export favorites and playlists + Import favorites and playlists + Leave review + Donate + Search TabsLite + + Search + New playlist + Half star + Filled star + + Use system + Force light + Force dark + Theming + + Favorites + Popular + Playlists + Add to playlist + Create playlist + + Playlist Title + Playlist Description + 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. + Select a playlist… + Select the heart icon on any song to save it offline in this list. + Today\'s popular songs will load when you\'re connected to the internet. + + Close + Confirm + Dismiss + Back + Delete + Drag to reorder + Play + Pause + Share + More + Reload + Menu + Copy + Export to PDF + Clear + Error + Info + + Guitar + Ukulele + Banjo + Piano + + None + %sth Fret + %sth Fret + %sth Fret + %sst Fret + %snd Fret + %srd Fret + %sth Fret + + Sort by: %s + Date Added + Popularity + Date Modified + Artist Name + Title + + Couldn\'t load chord \'%s\'. Please check your internet connection. + No search results. Revise your query or check your internet connection. + Nothing here! + Unexpected error loading tab: %s + Unexpected error loading tab from playlist. + No internet access. This tab is not yet saved offline. Please reconnect to the internet and try again. + Tab not found. Please report this issue to the developer. + Playlist entry not found. Please report this issue to the developer. + This tab has been taken down and is no longer available. This may occur when the copyright holder requests that it be removed. + Unexpected error importing/exporting playlists. Please report this to the developer. + No internet connection. Playlist tabs have been added, but won\'t be downloaded until next time you restart the app with internet access. + You\'re not connected to the internet. + Timeout while waiting for search results. + Unexpected error loading search results. + You\'re not connected to the internet. + Unexpected error fetching more search suggestions. + + %1$s by %2$s + Difficulty: %s + Tuning: %s + Capo: %s + Key: %s + Tab Author: %s + Transpose: %d + Version %d + ver. %s + Favorite + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..6dc930d --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.navigationSafeargs) apply false + alias(libs.plugins.daggerHilt) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.kotlinSerialization) apply false + alias(libs.plugins.kotlinParcelize) apply false + alias(libs.plugins.compose.compiler) apply false + + alias(libs.plugins.spotless) +} + +spotless { + kotlin { + target("**/*.kt") + } +} diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 0000000..2bbbf2c --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,8 @@ +files: + - source: /app/src/main/res/values/*.xml + ignore: + - /app/src/main/res/values/ic_* + translation: /app/src/main/res/values-%two_letters_code%/%original_file_name% + translatable_elements: + - /resources/string + - resources/plurals/item diff --git a/docs/.well-known/assetlinks.json b/docs/.well-known/assetlinks.json new file mode 100644 index 0000000..a5220dc --- /dev/null +++ b/docs/.well-known/assetlinks.json @@ -0,0 +1,17 @@ +[ + { + "relation": [ + "delegate_permission/common.handle_all_urls" + ], + "target": { + "namespace": "android_app", + "package_name": "com.gbros.tabslite", + "sha256_cert_fingerprints": [ + "CC:43:DF:EA:70:C2:87:9B:51:69:F1:18:C6:98:C1:2B:4F:03:6A:ED:7F:57:E1:23:0D:E9:EA:F8:2F:AA:C8:3B", + "12:A6:6F:5C:72:95:FA:22:8D:89:1B:3C:2C:B8:13:DE:98:EA:21:7F:69:77:64:BE:D8:0F:2B:9B:0B:DE:10:82", + "6B:93:68:65:59:FA:EC:21:A0:50:CF:BA:8D:59:C3:E9:EA:C9:6D:4D:6D:5E:D3:6C:0C:DB:42:E0:95:98:6C:8F", + "12:A6:6F:5C:72:95:FA:22:8D:89:1B:3C:2C:B8:13:DE:98:EA:21:7F:69:77:64:BE:D8:0F:2B:9B:0B:DE:10:82" + ] + } + } +] diff --git a/docs/Acknowledgements.md b/docs/Acknowledgements.md new file mode 100644 index 0000000..b031ef0 --- /dev/null +++ b/docs/Acknowledgements.md @@ -0,0 +1,13 @@ +--- +title: Acknowledgements +--- + +This software wouldn't have been possible without the existing open source resources available. Additionally, in the creation of this app, many hours were spent on stackoverflow.com, so a special thanks to the members of that community that helped out. View our full source code [on Github](https://github.com/cullub/Tabs-Lite) + +## [Android/Sunflower](https://github.com/android/sunflower) + +Google's Sunflower demo app was used as a boilerplate as development commenced on this project. + +## [chRyNaN/Chords](https://github.com/chRyNaN/chords) + +chRyNaN's Chords library was used to display visual representations of each chord. A special thanks to the developer for working with me on a bug that was found. diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..04ef501 --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +tabslite.com \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..d186a72 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,25 @@ +An [open source](https://github.com/More-Than-Solitaire/Tabs-Lite) 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! + +## Download + +Our main download site is directly from Google Play here: + +[](https://play.google.com/store/apps/details?id=com.gbros.tabslite) + +## About + +Find your favorites out of over a million available chords and tabs! Play along at your own speed with built-in auto scroll and speed adjustment, and save to your favorites or a playlist to enable offline access. + +![Tabs Lite](img/screenshot/Tabs-Lite-Feature-Graphic.png "Tabs Lite Featured Image") + +Jam day or night with system Dark Mode support. When your device is set to Light Mode, you'll see all your tabs against a bright, easy-to-see background. But when you switch your device to Dark Mode, TabsLite will follow along. + +Tap the heart on the top of any tab to add it to your favorites. This enables offline access for this tab, and pins it to your home page for easy access. You can sort your favorite tabs by date added, name, artist, or popularity. + +Quickly find the content you're looking for with a beautiful Material Design built for speed and simplicity. Search hundereds of thousands of available songs by title or author name, 100% free with no ads! This app wasn't built to make a profit; instead, all the time spent on development was donated with the goal of making a good app. This app is significantly faster than its competition, and provides as many or more songs than any other app on the market. + +If you want to play a song in a different key, you're in luck! 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! When you click the share button, your current key is saved and sent along too! + +## iOS Support + +We don't support iOS at this time, and don't have any plans to in the future. However, the share link is designed to fall back to a web app should the native app not be installed, so feel free to share with your friends on iOS anyway. If you know an iOS dev that would like to work on this, have them get in touch! diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..0537b2a --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,6 @@ +theme: minima +title: Tabs Lite +description: Guitar tabs for Android +include: [".well-known"] +header_pages: + - Acknowledgements.md diff --git a/docs/_includes/custom-head.html b/docs/_includes/custom-head.html new file mode 100644 index 0000000..986f733 --- /dev/null +++ b/docs/_includes/custom-head.html @@ -0,0 +1,19 @@ +{% comment %} + Placeholder to allow defining custom head, in principle, you can add anything here, e.g. favicons: + + 1. Head over to https://realfavicongenerator.net/ to add your own favicons. + 2. Customize default _includes/custom-head.html in your source directory and insert the given code snippet. +{% endcomment %} + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/_includes/footer.html b/docs/_includes/footer.html new file mode 100644 index 0000000..707d523 --- /dev/null +++ b/docs/_includes/footer.html @@ -0,0 +1,37 @@ +
+ + +
+ + + + + +
+ +
diff --git a/docs/_includes/head.html b/docs/_includes/head.html new file mode 100644 index 0000000..9ee139f --- /dev/null +++ b/docs/_includes/head.html @@ -0,0 +1,14 @@ + + + + + {%- seo -%} + + {%- feed_meta -%} + {%- if jekyll.environment == 'production' and site.google_analytics -%} + {%- include google-analytics.html -%} + {%- endif -%} + + {%- include custom-head.html -%} + + diff --git a/docs/_includes/header.html b/docs/_includes/header.html new file mode 100644 index 0000000..ed5a382 --- /dev/null +++ b/docs/_includes/header.html @@ -0,0 +1,31 @@ + diff --git a/docs/_includes/social.html b/docs/_includes/social.html new file mode 100644 index 0000000..1334fc0 --- /dev/null +++ b/docs/_includes/social.html @@ -0,0 +1,21 @@ +{%- assign social = site.minima.social_links -%} + + diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html new file mode 100644 index 0000000..58e141b --- /dev/null +++ b/docs/_layouts/default.html @@ -0,0 +1,20 @@ + + + + {%- include head.html -%} + + + + {%- include header.html -%} + +
+
+ {{ content }} +
+
+ + {%- include footer.html -%} + + + + diff --git a/docs/_sass/minima/_base.scss b/docs/_sass/minima/_base.scss new file mode 100644 index 0000000..a6f104e --- /dev/null +++ b/docs/_sass/minima/_base.scss @@ -0,0 +1,282 @@ +html { + font-size: $base-font-size; +} + +/** + * Reset some basic elements + */ +body, h1, h2, h3, h4, h5, h6, +p, blockquote, pre, hr, +dl, dd, ol, ul, figure { + margin: 0; + padding: 0; + +} + + + +/** + * Basic styling + */ +body { + font: $base-font-weight #{$base-font-size}/#{$base-line-height} $base-font-family; + color: $text-color; + background-color: $background-color; + -webkit-text-size-adjust: 100%; + -webkit-font-feature-settings: "kern" 1; + -moz-font-feature-settings: "kern" 1; + -o-font-feature-settings: "kern" 1; + font-feature-settings: "kern" 1; + font-kerning: normal; + display: flex; + min-height: 100vh; + flex-direction: column; + overflow-wrap: break-word; +} + + + +/** + * Set `margin-bottom` to maintain vertical rhythm + */ +h1, h2, h3, h4, h5, h6, +p, blockquote, pre, +ul, ol, dl, figure, +%vertical-rhythm { + margin-bottom: $spacing-unit / 2; +} + +hr { + margin-top: $spacing-unit; + margin-bottom: $spacing-unit; +} + +/** + * `main` element + */ +main { + display: block; /* Default value of `display` of `main` element is 'inline' in IE 11. */ +} + + + +/** + * Images + */ +img { + max-width: 100%; + vertical-align: middle; +} + + + +/** + * Figures + */ +figure > img { + display: block; +} + +figcaption { + font-size: $small-font-size; +} + + + +/** + * Lists + */ +ul, ol { + margin-left: $spacing-unit; +} + +li { + > ul, + > ol { + margin-bottom: 0; + } +} + + + +/** + * Headings + */ +h1, h2, h3, h4, h5, h6 { + font-weight: $base-font-weight; +} + + + +/** + * Links + */ +a { + color: $link-base-color; + text-decoration: none; + + &:visited { + color: $link-visited-color; + } + + &:hover { + color: $link-hover-color; + text-decoration: underline; + } + + .social-media-list &:hover { + text-decoration: none; + + .username { + text-decoration: underline; + } + } +} + + +/** + * Blockquotes + */ +blockquote { + color: $brand-color; + border-left: 4px solid $border-color-01; + padding-left: $spacing-unit / 2; + @include relative-font-size(1.125); + font-style: italic; + + > :last-child { + margin-bottom: 0; + } + + i, em { + font-style: normal; + } +} + + + +/** + * Code formatting + */ +pre, +code { + font-family: $code-font-family; + font-size: 0.9375em; + border: 1px solid $border-color-01; + border-radius: 3px; + background-color: $code-background-color; +} + +code { + padding: 1px 5px; +} + +pre { + padding: 8px 12px; + overflow-x: auto; + + > code { + border: 0; + padding-right: 0; + padding-left: 0; + } +} + +.highlight { + border-radius: 3px; + background: $code-background-color; + @extend %vertical-rhythm; + + .highlighter-rouge & { + background: $code-background-color; + } +} + + + +/** + * Wrapper + */ +.wrapper { + max-width: calc(#{$content-width} - (#{$spacing-unit})); + margin-right: auto; + margin-left: auto; + padding-right: $spacing-unit / 2; + padding-left: $spacing-unit / 2; + @extend %clearfix; + + @media screen and (min-width: $on-large) { + max-width: calc(#{$content-width} - (#{$spacing-unit} * 2)); + padding-right: $spacing-unit; + padding-left: $spacing-unit; + } +} + + + +/** + * Clearfix + */ +%clearfix:after { + content: ""; + display: table; + clear: both; +} + + + +/** + * Icons + */ + +.orange { + color: #f66a0a; +} + +.grey { + color: #828282; +} + +.svg-icon { + width: 16px; + height: 16px; + display: inline-block; + fill: currentColor; + padding: 5px 3px 2px 5px; + vertical-align: text-bottom; +} + + +/** + * Tables + */ +table { + margin-bottom: $spacing-unit; + width: 100%; + text-align: $table-text-align; + color: $table-text-color; + border-collapse: collapse; + border: 1px solid $table-border-color; + tr { + &:nth-child(even) { + background-color: $table-zebra-color; + } + } + th, td { + padding: ($spacing-unit / 3) ($spacing-unit / 2); + } + th { + background-color: $table-header-bg-color; + border: 1px solid $table-header-border; + } + td { + border: 1px solid $table-border-color; + } + + @include media-query($on-laptop) { + display: block; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + -ms-overflow-style: -ms-autohiding-scrollbar; + } +} diff --git a/docs/_sass/minima/_layout.scss b/docs/_sass/minima/_layout.scss new file mode 100644 index 0000000..40d2cd1 --- /dev/null +++ b/docs/_sass/minima/_layout.scss @@ -0,0 +1,342 @@ +/** + * Site header + */ +.site-header { + border-top: 5px solid $border-color-03; + border-bottom: 1px solid $border-color-01; + min-height: $spacing-unit * 1.865; + line-height: $base-line-height * $base-font-size * 2.25; + + // Positioning context for the mobile navigation icon + position: relative; +} + +.site-title { + @include relative-font-size(1.625); + font-weight: 300; + letter-spacing: -1px; + margin-bottom: 0; + float: left; + + @include media-query($on-palm) { + padding-right: 45px; + } + + &, + &:visited { + color: $site-title-color; + } +} + +.site-nav { + position: absolute; + top: 9px; + right: $spacing-unit / 2; + background-color: $background-color; + border: 1px solid $border-color-01; + border-radius: 5px; + text-align: right; + + .nav-trigger { + display: none; + } + + .menu-icon { + float: right; + width: 36px; + height: 26px; + line-height: 0; + padding-top: 10px; + text-align: center; + + > svg path { + fill: $border-color-03; + } + } + + label[for="nav-trigger"] { + display: block; + float: right; + width: 36px; + height: 36px; + z-index: 2; + cursor: pointer; + } + + input ~ .trigger { + clear: both; + display: none; + } + + input:checked ~ .trigger { + display: block; + padding-bottom: 5px; + } + + .page-link { + color: $text-color; + line-height: $base-line-height; + display: block; + padding: 5px 10px; + + // Gaps between nav items, but not on the last one + &:not(:last-child) { + margin-right: 0; + } + margin-left: 20px; + } + + @media screen and (min-width: $on-medium) { + position: static; + float: right; + border: none; + background-color: inherit; + + label[for="nav-trigger"] { + display: none; + } + + .menu-icon { + display: none; + } + + input ~ .trigger { + display: block; + } + + .page-link { + display: inline; + padding: 0; + + &:not(:last-child) { + margin-right: 20px; + } + margin-left: auto; + } + } +} + + + +/** + * Site footer + */ +.site-footer { + border-top: 1px solid $border-color-01; + padding: $spacing-unit 0; +} + +.footer-heading { + @include relative-font-size(1.125); + margin-bottom: $spacing-unit / 2; +} + +.feed-subscribe .svg-icon { + padding: 5px 5px 2px 0 +} + +.contact-list, +.social-media-list { + list-style: none; + margin-left: 0; +} + +.footer-col-wrapper, +.social-links { + @include relative-font-size(0.9375); + color: $brand-color; +} + +.footer-col { + margin-bottom: $spacing-unit / 2; +} + +.footer-col-1, +.footer-col-2 { + width: calc(50% - (#{$spacing-unit} / 2)); +} + +.footer-col-3 { + width: calc(100% - (#{$spacing-unit} / 2)); +} + +@media screen and (min-width: $on-large) { + .footer-col-1 { + width: calc(35% - (#{$spacing-unit} / 2)); + } + + .footer-col-2 { + width: calc(20% - (#{$spacing-unit} / 2)); + } + + .footer-col-3 { + width: calc(45% - (#{$spacing-unit} / 2)); + } +} + +@media screen and (min-width: $on-medium) { + .footer-col-wrapper { + display: flex + } + + .footer-col { + width: calc(100% - (#{$spacing-unit} / 2)); + padding: 0 ($spacing-unit / 2); + + &:first-child { + padding-right: $spacing-unit / 2; + padding-left: 0; + } + + &:last-child { + padding-right: 0; + padding-left: $spacing-unit / 2; + } + } +} + + + +/** + * Page content + */ +.page-content { + padding: $spacing-unit 0; + flex: 1 0 auto; +} + +.page-heading { + @include relative-font-size(2); +} + +.post-list-heading { + @include relative-font-size(1.75); +} + +.post-list { + margin-left: 0; + list-style: none; + + > li { + margin-bottom: $spacing-unit; + } +} + +.post-meta { + font-size: $small-font-size; + color: $brand-color; +} + +.post-link { + display: block; + @include relative-font-size(1.5); +} + + + +/** + * Posts + */ +.post-header { + margin-bottom: $spacing-unit; +} + +.post-title, +.post-content h1 { + @include relative-font-size(2.625); + letter-spacing: -1px; + line-height: 1.15; + + @media screen and (min-width: $on-large) { + @include relative-font-size(2.625); + } +} + +.post-content { + margin-bottom: $spacing-unit; + + h1, h2, h3 { margin-top: $spacing-unit * 2 } + h4, h5, h6 { margin-top: $spacing-unit } + + h2 { + @include relative-font-size(1.75); + + @media screen and (min-width: $on-large) { + @include relative-font-size(2); + } + } + + h3 { + @include relative-font-size(1.375); + + @media screen and (min-width: $on-large) { + @include relative-font-size(1.625); + } + } + + h4 { + @include relative-font-size(1.25); + } + + h5 { + @include relative-font-size(1.125); + } + h6 { + @include relative-font-size(1.0625); + } +} + + +.social-media-list { + display: table; + margin: 0 auto; + li { + float: left; + margin: 5px 10px 5px 0; + &:last-of-type { margin-right: 0 } + a { + display: block; + padding: $spacing-unit / 4; + border: 1px solid $border-color-01; + &:hover { border-color: $border-color-02 } + } + } +} + + + +/** + * Pagination navbar + */ +.pagination { + margin-bottom: $spacing-unit; + @extend .social-media-list; + li { + a, div { + min-width: 41px; + text-align: center; + box-sizing: border-box; + } + div { + display: block; + padding: $spacing-unit / 4; + border: 1px solid transparent; + + &.pager-edge { + color: $border-color-01; + border: 1px dashed; + } + } + } +} + + + +/** + * Grid helpers + */ +@media screen and (min-width: $on-large) { + .one-half { + width: calc(50% - (#{$spacing-unit} / 2)); + } +} diff --git a/docs/_sass/minima/custom-styles.scss b/docs/_sass/minima/custom-styles.scss new file mode 100644 index 0000000..7c1417f --- /dev/null +++ b/docs/_sass/minima/custom-styles.scss @@ -0,0 +1,2 @@ +// Placeholder to allow defining custom styles that override everything else. +// (Use `_sass/minima/custom-variables.scss` to override variable defaults) diff --git a/docs/_sass/minima/custom-variables.scss b/docs/_sass/minima/custom-variables.scss new file mode 100644 index 0000000..2a2d0fa --- /dev/null +++ b/docs/_sass/minima/custom-variables.scss @@ -0,0 +1 @@ +// Placeholder to allow overriding predefined variables smoothly. diff --git a/docs/_sass/minima/initialize.scss b/docs/_sass/minima/initialize.scss new file mode 100644 index 0000000..b7f5eda --- /dev/null +++ b/docs/_sass/minima/initialize.scss @@ -0,0 +1,50 @@ +@charset "utf-8"; + +// Define defaults for each variable. + +$base-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Segoe UI Symbol", "Segoe UI Emoji", "Apple Color Emoji", Roboto, Helvetica, Arial, sans-serif !default; +$code-font-family: "Menlo", "Inconsolata", "Consolas", "Roboto Mono", "Ubuntu Mono", "Liberation Mono", "Courier New", monospace; +$base-font-size: 16px !default; +$base-font-weight: 400 !default; +$small-font-size: $base-font-size * 0.875 !default; +$base-line-height: 1.5 !default; + +$spacing-unit: 30px !default; + +$table-text-align: left !default; + +// Width of the content area +$content-width: 800px !default; + +$on-palm: 600px !default; +$on-laptop: 800px !default; + +$on-medium: $on-palm !default; +$on-large: $on-laptop !default; + +// Use media queries like this: +// @include media-query($on-palm) { +// .wrapper { +// padding-right: $spacing-unit / 2; +// padding-left: $spacing-unit / 2; +// } +// } +// Notice the following mixin uses max-width, in a deprecated, desktop-first +// approach, whereas media queries used elsewhere now use min-width. +@mixin media-query($device) { + @media screen and (max-width: $device) { + @content; + } +} + +@mixin relative-font-size($ratio) { + font-size: #{$ratio}rem; +} + +// Import pre-styling-overrides hook and style-partials. +@import + "minima/custom-variables", // Hook to override predefined variables. + "minima/base", // Defines element resets. + "minima/layout", // Defines structure and style based on CSS selectors. + "minima/custom-styles" // Hook to override existing styles. +; diff --git a/docs/_sass/minima/skins/classic.scss b/docs/_sass/minima/skins/classic.scss new file mode 100644 index 0000000..c6dc292 --- /dev/null +++ b/docs/_sass/minima/skins/classic.scss @@ -0,0 +1,91 @@ +@charset "utf-8"; + +$brand-color: #828282 !default; +$brand-color-light: lighten($brand-color, 40%) !default; +$brand-color-dark: darken($brand-color, 25%) !default; + +$site-title-color: $brand-color-dark !default; + +$text-color: #111111 !default; +$background-color: #fdfdfd !default; +$code-background-color: #eeeeff !default; + +$link-base-color: #2a7ae2 !default; +$link-visited-color: darken($link-base-color, 15%) !default; +$link-hover-color: $text-color !default; + +$border-color-01: $brand-color-light !default; +$border-color-02: lighten($brand-color, 35%) !default; +$border-color-03: $brand-color-dark !default; + +$table-text-color: lighten($text-color, 18%) !default; +$table-zebra-color: lighten($brand-color, 46%) !default; +$table-header-bg-color: lighten($brand-color, 43%) !default; +$table-header-border: lighten($brand-color, 37%) !default; +$table-border-color: $border-color-01 !default; + + +// Syntax highlighting styles should be adjusted appropriately for every "skin" +// ---------------------------------------------------------------------------- + +.highlight { + .c { color: #998; font-style: italic } // Comment + .err { color: #a61717; background-color: #e3d2d2 } // Error + .k { font-weight: bold } // Keyword + .o { font-weight: bold } // Operator + .cm { color: #998; font-style: italic } // Comment.Multiline + .cp { color: #999; font-weight: bold } // Comment.Preproc + .c1 { color: #998; font-style: italic } // Comment.Single + .cs { color: #999; font-weight: bold; font-style: italic } // Comment.Special + .gd { color: #000; background-color: #fdd } // Generic.Deleted + .gd .x { color: #000; background-color: #faa } // Generic.Deleted.Specific + .ge { font-style: italic } // Generic.Emph + .gr { color: #a00 } // Generic.Error + .gh { color: #999 } // Generic.Heading + .gi { color: #000; background-color: #dfd } // Generic.Inserted + .gi .x { color: #000; background-color: #afa } // Generic.Inserted.Specific + .go { color: #888 } // Generic.Output + .gp { color: #555 } // Generic.Prompt + .gs { font-weight: bold } // Generic.Strong + .gu { color: #aaa } // Generic.Subheading + .gt { color: #a00 } // Generic.Traceback + .kc { font-weight: bold } // Keyword.Constant + .kd { font-weight: bold } // Keyword.Declaration + .kp { font-weight: bold } // Keyword.Pseudo + .kr { font-weight: bold } // Keyword.Reserved + .kt { color: #458; font-weight: bold } // Keyword.Type + .m { color: #099 } // Literal.Number + .s { color: #d14 } // Literal.String + .na { color: #008080 } // Name.Attribute + .nb { color: #0086B3 } // Name.Builtin + .nc { color: #458; font-weight: bold } // Name.Class + .no { color: #008080 } // Name.Constant + .ni { color: #800080 } // Name.Entity + .ne { color: #900; font-weight: bold } // Name.Exception + .nf { color: #900; font-weight: bold } // Name.Function + .nn { color: #555 } // Name.Namespace + .nt { color: #000080 } // Name.Tag + .nv { color: #008080 } // Name.Variable + .ow { font-weight: bold } // Operator.Word + .w { color: #bbb } // Text.Whitespace + .mf { color: #099 } // Literal.Number.Float + .mh { color: #099 } // Literal.Number.Hex + .mi { color: #099 } // Literal.Number.Integer + .mo { color: #099 } // Literal.Number.Oct + .sb { color: #d14 } // Literal.String.Backtick + .sc { color: #d14 } // Literal.String.Char + .sd { color: #d14 } // Literal.String.Doc + .s2 { color: #d14 } // Literal.String.Double + .se { color: #d14 } // Literal.String.Escape + .sh { color: #d14 } // Literal.String.Heredoc + .si { color: #d14 } // Literal.String.Interpol + .sx { color: #d14 } // Literal.String.Other + .sr { color: #009926 } // Literal.String.Regex + .s1 { color: #d14 } // Literal.String.Single + .ss { color: #990073 } // Literal.String.Symbol + .bp { color: #999 } // Name.Builtin.Pseudo + .vc { color: #008080 } // Name.Variable.Class + .vg { color: #008080 } // Name.Variable.Global + .vi { color: #008080 } // Name.Variable.Instance + .il { color: #099 } // Literal.Number.Integer.Long +} diff --git a/docs/_sass/minima/skins/dark.scss b/docs/_sass/minima/skins/dark.scss new file mode 100644 index 0000000..39b893f --- /dev/null +++ b/docs/_sass/minima/skins/dark.scss @@ -0,0 +1,95 @@ +@charset "utf-8"; + +$brand-color: #999999 !default; +$brand-color-light: lighten($brand-color, 5%) !default; +$brand-color-dark: darken($brand-color, 35%) !default; + +$site-title-color: $brand-color-light !default; + +$text-color: #bbbbbb !default; +$background-color: #181818 !default; +$code-background-color: #212121 !default; + +$link-base-color: #79b8ff !default; +$link-visited-color: $link-base-color !default; +$link-hover-color: $text-color !default; + +$border-color-01: $brand-color-dark !default; +$border-color-02: $brand-color-light !default; +$border-color-03: $brand-color !default; + +$table-text-color: $text-color !default; +$table-zebra-color: lighten($background-color, 4%) !default; +$table-header-bg-color: lighten($background-color, 10%) !default; +$table-header-border: lighten($background-color, 21%) !default; +$table-border-color: $border-color-01 !default; + + +// Syntax highlighting styles should be adjusted appropriately for every "skin" +// List of tokens: https://github.com/rouge-ruby/rouge/wiki/List-of-tokens +// Some colors come from Material Theme Darker: +// https://github.com/material-theme/vsc-material-theme/blob/master/scripts/generator/settings/specific/darker-hc.ts +// https://github.com/material-theme/vsc-material-theme/blob/master/scripts/generator/color-set.ts +// ---------------------------------------------------------------------------- + +.highlight { + .c { color: #545454; font-style: italic } // Comment + .err { color: #f07178; background-color: #e3d2d2 } // Error + .k { color: #89DDFF; font-weight: bold } // Keyword + .o { font-weight: bold } // Operator + .cm { color: #545454; font-style: italic } // Comment.Multiline + .cp { color: #545454; font-weight: bold } // Comment.Preproc + .c1 { color: #545454; font-style: italic } // Comment.Single + .cs { color: #545454; font-weight: bold; font-style: italic } // Comment.Special + .gd { color: #000; background-color: #fdd } // Generic.Deleted + .gd .x { color: #000; background-color: #faa } // Generic.Deleted.Specific + .ge { font-style: italic } // Generic.Emph + .gr { color: #f07178 } // Generic.Error + .gh { color: #999 } // Generic.Heading + .gi { color: #000; background-color: #dfd } // Generic.Inserted + .gi .x { color: #000; background-color: #afa } // Generic.Inserted.Specific + .go { color: #888 } // Generic.Output + .gp { color: #555 } // Generic.Prompt + .gs { font-weight: bold } // Generic.Strong + .gu { color: #aaa } // Generic.Subheading + .gt { color: #f07178 } // Generic.Traceback + .kc { font-weight: bold } // Keyword.Constant + .kd { font-weight: bold } // Keyword.Declaration + .kp { font-weight: bold } // Keyword.Pseudo + .kr { font-weight: bold } // Keyword.Reserved + .kt { color: #FFCB6B; font-weight: bold } // Keyword.Type + .m { color: #F78C6C } // Literal.Number + .s { color: #C3E88D } // Literal.String + .na { color: #008080 } // Name.Attribute + .nb { color: #EEFFFF } // Name.Builtin + .nc { color: #FFCB6B; font-weight: bold } // Name.Class + .no { color: #008080 } // Name.Constant + .ni { color: #800080 } // Name.Entity + .ne { color: #900; font-weight: bold } // Name.Exception + .nf { color: #82AAFF; font-weight: bold } // Name.Function + .nn { color: #555 } // Name.Namespace + .nt { color: #FFCB6B } // Name.Tag + .nv { color: #EEFFFF } // Name.Variable + .ow { font-weight: bold } // Operator.Word + .w { color: #EEFFFF } // Text.Whitespace + .mf { color: #F78C6C } // Literal.Number.Float + .mh { color: #F78C6C } // Literal.Number.Hex + .mi { color: #F78C6C } // Literal.Number.Integer + .mo { color: #F78C6C } // Literal.Number.Oct + .sb { color: #C3E88D } // Literal.String.Backtick + .sc { color: #C3E88D } // Literal.String.Char + .sd { color: #C3E88D } // Literal.String.Doc + .s2 { color: #C3E88D } // Literal.String.Double + .se { color: #EEFFFF } // Literal.String.Escape + .sh { color: #C3E88D } // Literal.String.Heredoc + .si { color: #C3E88D } // Literal.String.Interpol + .sx { color: #C3E88D } // Literal.String.Other + .sr { color: #C3E88D } // Literal.String.Regex + .s1 { color: #C3E88D } // Literal.String.Single + .ss { color: #C3E88D } // Literal.String.Symbol + .bp { color: #999 } // Name.Builtin.Pseudo + .vc { color: #FFCB6B } // Name.Variable.Class + .vg { color: #EEFFFF } // Name.Variable.Global + .vi { color: #EEFFFF } // Name.Variable.Instance + .il { color: #F78C6C } // Literal.Number.Integer.Long +} diff --git a/docs/_sass/minima/skins/solarized-dark.scss b/docs/_sass/minima/skins/solarized-dark.scss new file mode 100644 index 0000000..f3b1f38 --- /dev/null +++ b/docs/_sass/minima/skins/solarized-dark.scss @@ -0,0 +1,4 @@ +@charset "utf-8"; + +$sol-is-dark: true; +@import "minima/skins/solarized"; diff --git a/docs/_sass/minima/skins/solarized.scss b/docs/_sass/minima/skins/solarized.scss new file mode 100644 index 0000000..6253d69 --- /dev/null +++ b/docs/_sass/minima/skins/solarized.scss @@ -0,0 +1,140 @@ +@charset "utf-8"; + +// Solarized skin +// ============== +// Created by Sander Voerman using the Solarized +// color scheme by Ethan Schoonover . + +// This style sheet implements two options for the minima.skin setting: +// "solarized" for light mode and "solarized-dark" for dark mode. +$sol-is-dark: false !default; + + +// Color scheme +// ------------ +// The inline comments show the canonical L*a*b values for each color. + +$sol-base03: #002b36; // 15 -12 -12 +$sol-base02: #073642; // 20 -12 -12 +$sol-base01: #586e75; // 45 -07 -07 +$sol-base00: #657b83; // 50 -07 -07 +$sol-base0: #839496; // 60 -06 -03 +$sol-base1: #93a1a1; // 65 -05 -02 +$sol-base2: #eee8d5; // 92 -00 10 +$sol-base3: #fdf6e3; // 97 00 10 +$sol-yellow: #b58900; // 60 10 65 +$sol-orange: #cb4b16; // 50 50 55 +$sol-red: #dc322f; // 50 65 45 +$sol-magenta: #d33682; // 50 65 -05 +$sol-violet: #6c71c4; // 50 15 -45 +$sol-blue: #268bd2; // 55 -10 -45 +$sol-cyan: #2aa198; // 60 -35 -05 +$sol-green: #859900; // 60 -20 65 + +$sol-mono3: $sol-base3; +$sol-mono2: $sol-base2; +$sol-mono1: $sol-base1; +$sol-mono00: $sol-base00; +$sol-mono01: $sol-base01; + +@if $sol-is-dark { + $sol-mono3: $sol-base03; + $sol-mono2: $sol-base02; + $sol-mono1: $sol-base01; + $sol-mono00: $sol-base0; + $sol-mono01: $sol-base1; +} + + +// Minima color variables +// ---------------------- + +$brand-color: $sol-mono1 !default; +$brand-color-light: mix($sol-mono1, $sol-mono3) !default; +$brand-color-dark: $sol-mono00 !default; + +$site-title-color: $sol-mono00 !default; + +$text-color: $sol-mono01 !default; +$background-color: $sol-mono3 !default; +$code-background-color: $sol-mono2 !default; + +$link-base-color: $sol-blue !default; +$link-visited-color: mix($sol-blue, $sol-mono00) !default; +$link-hover-color: $sol-mono00 !default; + +$border-color-01: $brand-color-light !default; +$border-color-02: $sol-mono1 !default; +$border-color-03: $sol-mono00 !default; + +$table-text-color: $sol-mono00 !default; +$table-zebra-color: mix($sol-mono2, $sol-mono3) !default; +$table-header-bg-color: $sol-mono2 !default; +$table-header-border: $sol-mono1 !default; +$table-border-color: $sol-mono1 !default; + + +// Syntax highlighting styles +// -------------------------- + +.highlight { + .c { color: $sol-mono1; font-style: italic } // Comment + .err { color: $sol-red } // Error + .k { color: $sol-mono01; font-weight: bold } // Keyword + .o { color: $sol-mono01; font-weight: bold } // Operator + .cm { color: $sol-mono1; font-style: italic } // Comment.Multiline + .cp { color: $sol-mono1; font-weight: bold } // Comment.Preproc + .c1 { color: $sol-mono1; font-style: italic } // Comment.Single + .cs { color: $sol-mono1; font-weight: bold; font-style: italic } // Comment.Special + .gd { color: $sol-red } // Generic.Deleted + .gd .x { color: $sol-red } // Generic.Deleted.Specific + .ge { color: $sol-mono00; font-style: italic } // Generic.Emph + .gr { color: $sol-red } // Generic.Error + .gh { color: $sol-mono1 } // Generic.Heading + .gi { color: $sol-green } // Generic.Inserted + .gi .x { color: $sol-green } // Generic.Inserted.Specific + .go { color: $sol-mono00 } // Generic.Output + .gp { color: $sol-mono00 } // Generic.Prompt + .gs { color: $sol-mono01; font-weight: bold } // Generic.Strong + .gu { color: $sol-mono1 } // Generic.Subheading + .gt { color: $sol-red } // Generic.Traceback + .kc { color: $sol-mono01; font-weight: bold } // Keyword.Constant + .kd { color: $sol-mono01; font-weight: bold } // Keyword.Declaration + .kp { color: $sol-mono01; font-weight: bold } // Keyword.Pseudo + .kr { color: $sol-mono01; font-weight: bold } // Keyword.Reserved + .kt { color: $sol-violet; font-weight: bold } // Keyword.Type + .m { color: $sol-cyan } // Literal.Number + .s { color: $sol-magenta } // Literal.String + .na { color: $sol-cyan } // Name.Attribute + .nb { color: $sol-blue } // Name.Builtin + .nc { color: $sol-violet; font-weight: bold } // Name.Class + .no { color: $sol-cyan } // Name.Constant + .ni { color: $sol-violet } // Name.Entity + .ne { color: $sol-violet; font-weight: bold } // Name.Exception + .nf { color: $sol-blue; font-weight: bold } // Name.Function + .nn { color: $sol-mono00 } // Name.Namespace + .nt { color: $sol-blue } // Name.Tag + .nv { color: $sol-cyan } // Name.Variable + .ow { color: $sol-mono01; font-weight: bold } // Operator.Word + .w { color: $sol-mono1 } // Text.Whitespace + .mf { color: $sol-cyan } // Literal.Number.Float + .mh { color: $sol-cyan } // Literal.Number.Hex + .mi { color: $sol-cyan } // Literal.Number.Integer + .mo { color: $sol-cyan } // Literal.Number.Oct + .sb { color: $sol-magenta } // Literal.String.Backtick + .sc { color: $sol-magenta } // Literal.String.Char + .sd { color: $sol-magenta } // Literal.String.Doc + .s2 { color: $sol-magenta } // Literal.String.Double + .se { color: $sol-magenta } // Literal.String.Escape + .sh { color: $sol-magenta } // Literal.String.Heredoc + .si { color: $sol-magenta } // Literal.String.Interpol + .sx { color: $sol-magenta } // Literal.String.Other + .sr { color: $sol-green } // Literal.String.Regex + .s1 { color: $sol-magenta } // Literal.String.Single + .ss { color: $sol-magenta } // Literal.String.Symbol + .bp { color: $sol-mono1 } // Name.Builtin.Pseudo + .vc { color: $sol-cyan } // Name.Variable.Class + .vg { color: $sol-cyan } // Name.Variable.Global + .vi { color: $sol-cyan } // Name.Variable.Instance + .il { color: $sol-cyan } // Literal.Number.Integer.Long +} diff --git a/docs/assets/css/style.scss b/docs/assets/css/style.scss new file mode 100644 index 0000000..0d1fce9 --- /dev/null +++ b/docs/assets/css/style.scss @@ -0,0 +1,7 @@ +--- +# Only the main Sass file needs front matter (the dashes are enough) +--- + +@import + "minima/skins/{{ site.minima.skin | default: 'classic' }}", + "minima/initialize"; diff --git a/docs/assets/minima-social-icons.svg b/docs/assets/minima-social-icons.svg new file mode 100644 index 0000000..ff02f3e --- /dev/null +++ b/docs/assets/minima-social-icons.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/img/android-chrome-192x192.png b/docs/img/android-chrome-192x192.png new file mode 100644 index 0000000..bc9c1fb Binary files /dev/null and b/docs/img/android-chrome-192x192.png differ diff --git a/docs/img/android-chrome-512x512.png b/docs/img/android-chrome-512x512.png new file mode 100644 index 0000000..7e0606c Binary files /dev/null and b/docs/img/android-chrome-512x512.png differ diff --git a/docs/img/apple-touch-icon.png b/docs/img/apple-touch-icon.png new file mode 100644 index 0000000..1cb0217 Binary files /dev/null and b/docs/img/apple-touch-icon.png differ diff --git a/docs/img/browserconfig.xml b/docs/img/browserconfig.xml new file mode 100644 index 0000000..0be6c59 --- /dev/null +++ b/docs/img/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #ffc40d + + + diff --git a/docs/img/favicon-16x16.png b/docs/img/favicon-16x16.png new file mode 100644 index 0000000..779ad0a Binary files /dev/null and b/docs/img/favicon-16x16.png differ diff --git a/docs/img/favicon-32x32.png b/docs/img/favicon-32x32.png new file mode 100644 index 0000000..04777a4 Binary files /dev/null and b/docs/img/favicon-32x32.png differ diff --git a/docs/img/favicon.ico b/docs/img/favicon.ico new file mode 100644 index 0000000..3718506 Binary files /dev/null and b/docs/img/favicon.ico differ diff --git a/docs/img/icon.png b/docs/img/icon.png new file mode 100644 index 0000000..77d38e8 Binary files /dev/null and b/docs/img/icon.png differ diff --git a/docs/img/icon.svg b/docs/img/icon.svg new file mode 100644 index 0000000..7cd947b --- /dev/null +++ b/docs/img/icon.svg @@ -0,0 +1,8 @@ + + + \ No newline at end of file diff --git a/docs/img/mstile-144x144.png b/docs/img/mstile-144x144.png new file mode 100644 index 0000000..4287986 Binary files /dev/null and b/docs/img/mstile-144x144.png differ diff --git a/docs/img/mstile-150x150.png b/docs/img/mstile-150x150.png new file mode 100644 index 0000000..e738b43 Binary files /dev/null and b/docs/img/mstile-150x150.png differ diff --git a/docs/img/mstile-310x150.png b/docs/img/mstile-310x150.png new file mode 100644 index 0000000..8aa38e3 Binary files /dev/null and b/docs/img/mstile-310x150.png differ diff --git a/docs/img/mstile-310x310.png b/docs/img/mstile-310x310.png new file mode 100644 index 0000000..fa60b92 Binary files /dev/null and b/docs/img/mstile-310x310.png differ diff --git a/docs/img/mstile-70x70.png b/docs/img/mstile-70x70.png new file mode 100644 index 0000000..f5ffe2b Binary files /dev/null and b/docs/img/mstile-70x70.png differ diff --git a/docs/img/safari-pinned-tab.svg b/docs/img/safari-pinned-tab.svg new file mode 100644 index 0000000..ef6f181 --- /dev/null +++ b/docs/img/safari-pinned-tab.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/img/screenshot/Tabs Lite Feature Graphic.sketch b/docs/img/screenshot/Tabs Lite Feature Graphic.sketch new file mode 100644 index 0000000..5a0e3c6 Binary files /dev/null and b/docs/img/screenshot/Tabs Lite Feature Graphic.sketch differ diff --git a/docs/img/screenshot/Tabs-Lite-Feature-Graphic-Full.png b/docs/img/screenshot/Tabs-Lite-Feature-Graphic-Full.png new file mode 100644 index 0000000..b7b6f10 Binary files /dev/null and b/docs/img/screenshot/Tabs-Lite-Feature-Graphic-Full.png differ diff --git a/docs/img/screenshot/Tabs-Lite-Feature-Graphic-Small.png b/docs/img/screenshot/Tabs-Lite-Feature-Graphic-Small.png new file mode 100644 index 0000000..3912d38 Binary files /dev/null and b/docs/img/screenshot/Tabs-Lite-Feature-Graphic-Small.png differ diff --git a/docs/img/screenshot/Tabs-Lite-Feature-Graphic.png b/docs/img/screenshot/Tabs-Lite-Feature-Graphic.png new file mode 100644 index 0000000..4ca9ebe Binary files /dev/null and b/docs/img/screenshot/Tabs-Lite-Feature-Graphic.png differ diff --git a/docs/img/screenshot/phone/Am_chord.png b/docs/img/screenshot/phone/Am_chord.png new file mode 100644 index 0000000..53c30a4 Binary files /dev/null and b/docs/img/screenshot/phone/Am_chord.png differ diff --git a/docs/img/screenshot/phone/Split.sketch b/docs/img/screenshot/phone/Split.sketch new file mode 100644 index 0000000..46bb7cd Binary files /dev/null and b/docs/img/screenshot/phone/Split.sketch differ diff --git a/docs/img/screenshot/phone/backup.png b/docs/img/screenshot/phone/backup.png new file mode 100644 index 0000000..33033e8 Binary files /dev/null and b/docs/img/screenshot/phone/backup.png differ diff --git a/docs/img/screenshot/phone/custom-playlists.png b/docs/img/screenshot/phone/custom-playlists.png new file mode 100644 index 0000000..9c05a71 Binary files /dev/null and b/docs/img/screenshot/phone/custom-playlists.png differ diff --git a/docs/img/screenshot/phone/dark-mode-combined.png b/docs/img/screenshot/phone/dark-mode-combined.png new file mode 100644 index 0000000..43df28d Binary files /dev/null and b/docs/img/screenshot/phone/dark-mode-combined.png differ diff --git a/docs/img/screenshot/phone/favorite_tabs_dark.png b/docs/img/screenshot/phone/favorite_tabs_dark.png new file mode 100644 index 0000000..df6ae41 Binary files /dev/null and b/docs/img/screenshot/phone/favorite_tabs_dark.png differ diff --git a/docs/img/screenshot/phone/favorite_tabs_light.png b/docs/img/screenshot/phone/favorite_tabs_light.png new file mode 100644 index 0000000..097990f Binary files /dev/null and b/docs/img/screenshot/phone/favorite_tabs_light.png differ diff --git a/docs/img/screenshot/phone/hallelujah_tab_transpose_0.png b/docs/img/screenshot/phone/hallelujah_tab_transpose_0.png new file mode 100644 index 0000000..8f24d65 Binary files /dev/null and b/docs/img/screenshot/phone/hallelujah_tab_transpose_0.png differ diff --git a/docs/img/screenshot/phone/hallelujah_tab_transpose_1.png b/docs/img/screenshot/phone/hallelujah_tab_transpose_1.png new file mode 100644 index 0000000..52a1fc8 Binary files /dev/null and b/docs/img/screenshot/phone/hallelujah_tab_transpose_1.png differ diff --git a/docs/img/screenshot/phone/playstore/Chord-Fingerings-Graphic.png b/docs/img/screenshot/phone/playstore/Chord-Fingerings-Graphic.png new file mode 100644 index 0000000..a0620b8 Binary files /dev/null and b/docs/img/screenshot/phone/playstore/Chord-Fingerings-Graphic.png differ diff --git a/docs/img/screenshot/phone/playstore/Dark-Mode-Graphic.png b/docs/img/screenshot/phone/playstore/Dark-Mode-Graphic.png new file mode 100644 index 0000000..1830913 Binary files /dev/null and b/docs/img/screenshot/phone/playstore/Dark-Mode-Graphic.png differ diff --git a/docs/img/screenshot/phone/playstore/Offline-Tabs-Graphic.png b/docs/img/screenshot/phone/playstore/Offline-Tabs-Graphic.png new file mode 100644 index 0000000..aed3619 Binary files /dev/null and b/docs/img/screenshot/phone/playstore/Offline-Tabs-Graphic.png differ diff --git a/docs/img/screenshot/phone/playstore/Playlists-Graphic.png b/docs/img/screenshot/phone/playstore/Playlists-Graphic.png new file mode 100644 index 0000000..1af2abe Binary files /dev/null and b/docs/img/screenshot/phone/playstore/Playlists-Graphic.png differ diff --git a/docs/img/screenshot/phone/playstore/TabsLite_ Ultimate chords.mockup b/docs/img/screenshot/phone/playstore/TabsLite_ Ultimate chords.mockup new file mode 100644 index 0000000..b1f32e7 Binary files /dev/null and b/docs/img/screenshot/phone/playstore/TabsLite_ Ultimate chords.mockup differ diff --git a/docs/img/screenshot/phone/playstore/Transpose-Graphic.png b/docs/img/screenshot/phone/playstore/Transpose-Graphic.png new file mode 100644 index 0000000..b0ae029 Binary files /dev/null and b/docs/img/screenshot/phone/playstore/Transpose-Graphic.png differ diff --git a/docs/img/screenshot/phone/playstore/Zoom-Graphic.png b/docs/img/screenshot/phone/playstore/Zoom-Graphic.png new file mode 100644 index 0000000..ec3c000 Binary files /dev/null and b/docs/img/screenshot/phone/playstore/Zoom-Graphic.png differ diff --git a/docs/img/screenshot/phone/playstore/readme.md b/docs/img/screenshot/phone/playstore/readme.md new file mode 100644 index 0000000..e215571 --- /dev/null +++ b/docs/img/screenshot/phone/playstore/readme.md @@ -0,0 +1 @@ +You can edit these files on app-mockup.com \ No newline at end of file diff --git a/docs/img/screenshot/phone/popular_tabs.png b/docs/img/screenshot/phone/popular_tabs.png new file mode 100644 index 0000000..5d83377 Binary files /dev/null and b/docs/img/screenshot/phone/popular_tabs.png differ diff --git a/docs/img/screenshot/phone/search_over_the.png b/docs/img/screenshot/phone/search_over_the.png new file mode 100644 index 0000000..c579b5d Binary files /dev/null and b/docs/img/screenshot/phone/search_over_the.png differ diff --git a/docs/img/screenshot/phone/transpose-combined.png b/docs/img/screenshot/phone/transpose-combined.png new file mode 100644 index 0000000..1dd2250 Binary files /dev/null and b/docs/img/screenshot/phone/transpose-combined.png differ diff --git a/docs/img/screenshot/phone/yesterday_tab_dark.png b/docs/img/screenshot/phone/yesterday_tab_dark.png new file mode 100644 index 0000000..f15f6a5 Binary files /dev/null and b/docs/img/screenshot/phone/yesterday_tab_dark.png differ diff --git a/docs/img/screenshot/phone/yesterday_tab_light.png b/docs/img/screenshot/phone/yesterday_tab_light.png new file mode 100644 index 0000000..dda30e1 Binary files /dev/null and b/docs/img/screenshot/phone/yesterday_tab_light.png differ diff --git a/docs/img/screenshot/phone/yesterday_tab_zoom_big.png b/docs/img/screenshot/phone/yesterday_tab_zoom_big.png new file mode 100644 index 0000000..b6df5af Binary files /dev/null and b/docs/img/screenshot/phone/yesterday_tab_zoom_big.png differ diff --git a/docs/img/screenshot/phone/yesterday_tab_zoom_small.png b/docs/img/screenshot/phone/yesterday_tab_zoom_small.png new file mode 100644 index 0000000..b63e651 Binary files /dev/null and b/docs/img/screenshot/phone/yesterday_tab_zoom_small.png differ diff --git a/docs/img/screenshot/phone/zoom-combined.png b/docs/img/screenshot/phone/zoom-combined.png new file mode 100644 index 0000000..e3e13dc Binary files /dev/null and b/docs/img/screenshot/phone/zoom-combined.png differ diff --git a/docs/img/screenshot/tablet/Am_chord.png b/docs/img/screenshot/tablet/Am_chord.png new file mode 100644 index 0000000..7dac13e Binary files /dev/null and b/docs/img/screenshot/tablet/Am_chord.png differ diff --git a/docs/img/screenshot/tablet/favorite_tabs_light.png b/docs/img/screenshot/tablet/favorite_tabs_light.png new file mode 100644 index 0000000..77f599d Binary files /dev/null and b/docs/img/screenshot/tablet/favorite_tabs_light.png differ diff --git a/docs/img/screenshot/tablet/hotel_california_big.png b/docs/img/screenshot/tablet/hotel_california_big.png new file mode 100644 index 0000000..58eb04d Binary files /dev/null and b/docs/img/screenshot/tablet/hotel_california_big.png differ diff --git a/docs/img/screenshot/tablet/hotel_california_small.png b/docs/img/screenshot/tablet/hotel_california_small.png new file mode 100644 index 0000000..344e967 Binary files /dev/null and b/docs/img/screenshot/tablet/hotel_california_small.png differ diff --git a/docs/img/screenshot/tablet/let_it_be_tab_transposed_0.png b/docs/img/screenshot/tablet/let_it_be_tab_transposed_0.png new file mode 100644 index 0000000..7c2a374 Binary files /dev/null and b/docs/img/screenshot/tablet/let_it_be_tab_transposed_0.png differ diff --git a/docs/img/screenshot/tablet/let_it_be_tab_transposed_1.png b/docs/img/screenshot/tablet/let_it_be_tab_transposed_1.png new file mode 100644 index 0000000..5823ab6 Binary files /dev/null and b/docs/img/screenshot/tablet/let_it_be_tab_transposed_1.png differ diff --git a/docs/img/screenshot/tablet/playstore/Chord-Fingerings-Graphic.png b/docs/img/screenshot/tablet/playstore/Chord-Fingerings-Graphic.png new file mode 100644 index 0000000..63c0801 Binary files /dev/null and b/docs/img/screenshot/tablet/playstore/Chord-Fingerings-Graphic.png differ diff --git a/docs/img/screenshot/tablet/playstore/Dark-Mode-Graphic.png b/docs/img/screenshot/tablet/playstore/Dark-Mode-Graphic.png new file mode 100644 index 0000000..a5ca164 Binary files /dev/null and b/docs/img/screenshot/tablet/playstore/Dark-Mode-Graphic.png differ diff --git a/docs/img/screenshot/tablet/playstore/Offline-Tabs-Graphic.png b/docs/img/screenshot/tablet/playstore/Offline-Tabs-Graphic.png new file mode 100644 index 0000000..c2d0426 Binary files /dev/null and b/docs/img/screenshot/tablet/playstore/Offline-Tabs-Graphic.png differ diff --git a/docs/img/screenshot/tablet/playstore/Playstore Graphics.sketch b/docs/img/screenshot/tablet/playstore/Playstore Graphics.sketch new file mode 100644 index 0000000..e17a036 Binary files /dev/null and b/docs/img/screenshot/tablet/playstore/Playstore Graphics.sketch differ diff --git a/docs/img/screenshot/tablet/playstore/Transpose-Graphic.png b/docs/img/screenshot/tablet/playstore/Transpose-Graphic.png new file mode 100644 index 0000000..35a9cf7 Binary files /dev/null and b/docs/img/screenshot/tablet/playstore/Transpose-Graphic.png differ diff --git a/docs/img/screenshot/tablet/playstore/Zoom-Graphic.png b/docs/img/screenshot/tablet/playstore/Zoom-Graphic.png new file mode 100644 index 0000000..389a0e0 Binary files /dev/null and b/docs/img/screenshot/tablet/playstore/Zoom-Graphic.png differ diff --git a/docs/img/screenshot/tablet/popular_tabs.png b/docs/img/screenshot/tablet/popular_tabs.png new file mode 100644 index 0000000..dd72a56 Binary files /dev/null and b/docs/img/screenshot/tablet/popular_tabs.png differ diff --git a/docs/img/screenshot/tablet/yesterday_dark.png b/docs/img/screenshot/tablet/yesterday_dark.png new file mode 100644 index 0000000..4c3365e Binary files /dev/null and b/docs/img/screenshot/tablet/yesterday_dark.png differ diff --git a/docs/img/screenshot/tablet/yesterday_light.png b/docs/img/screenshot/tablet/yesterday_light.png new file mode 100644 index 0000000..6146bc8 Binary files /dev/null and b/docs/img/screenshot/tablet/yesterday_light.png differ diff --git a/docs/img/site.webmanifest b/docs/img/site.webmanifest new file mode 100644 index 0000000..e6c60ac --- /dev/null +++ b/docs/img/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "Tabs Lite", + "short_name": "Tabs Lite", + "icons": [ + { + "src": "https://github.com/cullub/Tabs-Lite/raw/master/docs/img/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "https://github.com/cullub/Tabs-Lite/raw/master/docs/img/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#fbc02d", + "background_color": "#fbc02d", + "display": "standalone" +} diff --git a/docs/script/bootstrap b/docs/script/bootstrap new file mode 100644 index 0000000..492e553 --- /dev/null +++ b/docs/script/bootstrap @@ -0,0 +1,6 @@ +#!/bin/sh + +set -e + +gem install bundler +bundle install diff --git a/docs/script/cibuild b/docs/script/cibuild new file mode 100644 index 0000000..c3c0e99 --- /dev/null +++ b/docs/script/cibuild @@ -0,0 +1,15 @@ +#!/bin/sh + +set -e + +script/build + +if test -e "./_site/index.html";then + echo "It builds!" + rm -Rf _site +else + echo "Huh. That's odd. The example site doesn't seem to build." + exit 1 +fi + +gem build minima.gemspec diff --git a/docs/script/server b/docs/script/server new file mode 100644 index 0000000..d8c3e15 --- /dev/null +++ b/docs/script/server @@ -0,0 +1,3 @@ +#!/bin/sh + +bundle exec jekyll serve diff --git a/fastlane/metadata/android/de/full_description.txt b/fastlane/metadata/android/de/full_description.txt new file mode 100644 index 0000000..eee8caf --- /dev/null +++ b/fastlane/metadata/android/de/full_description.txt @@ -0,0 +1 @@ +

Finden Sie Ihre Favoriten unter über einer Million verfügbaren Community-gesteuerten Akkorden und Tabs! Spielen Sie mit Ihrer eigenen Geschwindigkeit mit integriertem automatischen Bildlauf und Geschwindigkeitsanpassung.

Tastenänderungen sind so einfach wie ein Knopfdruck mit integrierter Umsetzung. Oder finden Sie den Fingersatz für Ihren Akkord, indem Sie einfach auf den Akkordnamen tippen! Jam zu jeder Tages- und Nachtzeit im Dark Mode.

Speichern Sie Songs für den Offline-Zugriff, indem Sie sie zu Ihren Favoriten hinzufügen. Finden Sie schnell den gewünschten Inhalt mit einem schönen Materialdesign, das auf Geschwindigkeit und Einfachheit ausgelegt ist. Durchsuchen Sie Hunderttausende verfügbarer Songs nach Titel oder Autorennamen, 100% kostenlos ohne Werbung!

Sortieren Sie Ihre Lieblingssongs in Wiedergabelisten, die automatisch offline verfügbar sind. Ordnen Sie die Songs neu an, speichern Sie benutzerdefinierte Transpositionsebenen und fügen Sie denselben Song mehrmals hinzu!

\ No newline at end of file diff --git a/fastlane/metadata/android/de/short_description.txt b/fastlane/metadata/android/de/short_description.txt new file mode 100644 index 0000000..1e197ba --- /dev/null +++ b/fastlane/metadata/android/de/short_description.txt @@ -0,0 +1 @@ +Lieblingssongs in jeder beliebigen Tonart abspielen. Millionen Songs verfügbar! \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/3430.txt b/fastlane/metadata/android/en-US/changelogs/3430.txt new file mode 100644 index 0000000..d10e636 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3430.txt @@ -0,0 +1,15 @@ +signing key was changed, so to update from a previous version you will need to uninstall the app, the reinstall it freshly. + +New in this release: + +- Favorite tabs autoload on launch if not loaded +- Chords preload on tab open +- Many offline access and search related crashes fixed +- Autoscroll stays enabled at the end of a song +- Chord view bottom sheet dims status bar too now +- Deleting songs from playlists is now less easy to do on accident +- Screen stays on anytime a tab is visible +- Sort order for Favorites is now saved as a user preference +- Scroll speed is saved as a user preference +- Transposition remembered for Favorite songs +- Home page tab switching bugs fixed \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/3520.txt b/fastlane/metadata/android/en-US/changelogs/3520.txt new file mode 100644 index 0000000..99c494e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3520.txt @@ -0,0 +1,4 @@ +- Add About dialog with Import/Export functionality +- Make app translatable +- Remove Google dependency blob from .apk build +- Bugfixes \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/3600.txt b/fastlane/metadata/android/en-US/changelogs/3600.txt new file mode 100644 index 0000000..e69de29 diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..ae960d8 --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1 @@ +

Find your favorites among over a million available community driven chords and tabs! Play along at your own speed with built-in auto scroll and speed adjustment.

Key changes are as simple as a touch of a button with built in transposition. Or find the fingering for your chord by simply tapping the chord name! Jam at any time of day or night with Dark Mode.

Save songs for offline access by adding them to your Favorites. 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!

Sort your favorite songs into Playlists, available offline automatically. Rearrange songs, save custom transposition levels, and add the same song more than once!

\ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.jpg b/fastlane/metadata/android/en-US/images/featureGraphic.jpg new file mode 100644 index 0000000..d767bea Binary files /dev/null and b/fastlane/metadata/android/en-US/images/featureGraphic.jpg differ diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 0000000..1e8ae6a Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/01.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/01.jpg new file mode 100644 index 0000000..0f7f968 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/01.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/02.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/02.jpg new file mode 100644 index 0000000..c7d9f2b Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/02.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/03.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/03.jpg new file mode 100644 index 0000000..a089f4f Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/03.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/04.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/04.jpg new file mode 100644 index 0000000..b08d6ca Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/04.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/05.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/05.jpg new file mode 100644 index 0000000..e855da5 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/05.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/06.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/06.jpg new file mode 100644 index 0000000..3e92cc1 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/06.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/07.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/07.jpg new file mode 100644 index 0000000..04eb485 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/07.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/08.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/08.jpg new file mode 100644 index 0000000..7bba2ee Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/08.jpg differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000..73e02fd --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Play your favorite song in any key with millions to choose from! \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..a8d86fd --- /dev/null +++ b/gradle.properties @@ -0,0 +1,26 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +android.useAndroidX=true +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +kapt.use.worker.api=true +kapt.include.compile.classpath=false +android.lifecycleProcessor.incremental=true +room.incremental=true +kapt.incremental.apt=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..f242439 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,83 @@ +[versions] +activityCompose = "1.11.0" +agp = "8.13.0" +appcompat = "1.7.1" +composeMaterial = "1.7.8" +composeMaterial3 = "1.4.0" +composeReorderable = "3.0.0" +composeRuntimeLivedata = "1.9.3" +composeUiTest = "1.9.3" +constraintlayout = "2.2.1" +coreKtx = "1.17.0" +daggerHilt = "2.57.2" +fragmentKtx = "1.8.9" +gson = "2.13.2" +hiltNavigationCompose = "1.3.0" +kotlin = "2.2.20" +kotlinCoroutines = "1.10.2" +kotlinSerializationJson = "1.9.0" +kotlinStdlibJdk8 = "2.2.0" +ksp = "2.2.20-2.0.4" +legacySupport = "1.0.0" +lifecycleExtensions = "2.2.0" +lifecycleKtx = "2.9.4" +material = "1.13.0" +navigation = "2.9.5" +navigationSafeArgs = "2.9.5" +recyclerview = "1.4.0" +room = "2.8.2" +spotless = "8.0.0" +viewpager2 = "1.1.0" +workRuntime = "2.10.5" +chrynanChords = "2.4.1" +composeExtendedGestures = "3.1" + +[libraries] +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +androidx-compose-material = { group = "androidx.compose.material", name = "material-icons-core", version.ref = "composeMaterial" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "composeMaterial3" } +androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "composeRuntimeLivedata" } +androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "composeUiTest" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "composeUiTest" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "composeUiTest" } +androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" } +androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +androidx-legacy-support-v4 = { group = "androidx.legacy", name = "legacy-support-v4", version.ref = "legacySupport" } +androidx-lifecycle-extensions = { group = "androidx.lifecycle", name = "lifecycle-extensions", version.ref = "lifecycleExtensions" } +androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleKtx" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleKtx" } +androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleKtx" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } +androidx-navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigation" } +androidx-navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigation" } +androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-viewpager2 = { group = "androidx.viewpager2", name = "viewpager2", version.ref = "viewpager2" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntime" } +chrynan-chords-compose = { group = "com.chrynan.chords", name = "chords-compose", version.ref = "chrynanChords" } +compose-extended-gestures = { group = "com.github.SmartToolFactory", name = "Compose-Extended-Gestures", version.ref = "composeExtendedGestures" } +compose-reorderable = { group = "sh.calvin.reorderable", name = "reorderable", version.ref = "composeReorderable" } +google-android-material = { group = "com.google.android.material", name = "material", version.ref = "material" } +google-code-gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +google-dagger-hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "daggerHilt" } +google-dagger-hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "daggerHilt" } +org-jetbrains-kotlin-stdlib-jdk8 = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib-jdk8", version.ref = "kotlinStdlibJdk8" } +org-jetbrains-kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinCoroutines" } +org-jetbrains-kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinCoroutines" } +org-jetbrains-kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinSerializationJson" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +daggerHilt = { id = "com.google.dagger.hilt.android", version.ref = "daggerHilt" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlinParcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin"} +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +navigationSafeargs = { id = "androidx.navigation.safeargs.kotlin", version.ref = "navigationSafeArgs" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..87b738c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..aaf9ada --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Mon Dec 28 00:03:15 CST 2020 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..af6708f --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..6d57edc --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..93e60e5 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,29 @@ +pluginManagement { + repositories { + google{ + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + maven(url = "https://repo.repsy.io/mvn/chrynan/public") + maven(url = "https://jitpack.io") + + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven(url = "https://repo.repsy.io/mvn/chrynan/public") + maven(url = "https://jitpack.io") + } +} + +rootProject.name = "TabsLite" +include(":app")