Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-24 08:58:44 +01:00
parent 92216c1ae2
commit 6e051b9cd4
280 changed files with 19204 additions and 2 deletions

34
.circleci/config.yml Normal file
View file

@ -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

145
.gitignore vendored Normal file
View file

@ -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

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidTestResultsUserPreferences">
<option name="androidTestResultsTableState">
<map>
<entry key="113585394">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_7_Pro_API_34_2" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
</map>
</option>
</component>
</project>

123
.idea/codeStyles/Project.xml generated Normal file
View file

@ -0,0 +1,123 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View file

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

38
.idea/deploymentTargetDropDown.xml generated Normal file
View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<value>
<entry key="ChordModalBottomSheetPreview">
<State />
</entry>
<entry key="ChordPagerPreview">
<State />
</entry>
<entry key="HorizontalIndicatorPagerPreview">
<State />
</entry>
<entry key="PlaylistSongListPreview">
<State />
</entry>
<entry key="PlaylistViewPreview">
<State />
</entry>
<entry key="app">
<State>
<targetSelectedWithDropDown>
<Target>
<type value="QUICK_BOOT_TARGET" />
<deviceKey>
<Key>
<type value="VIRTUAL_DEVICE_PATH" />
<value value="C:\Users\Caleb\.android\avd\Copy_of_Pixel_7_Pro_API_34.avd" />
</Key>
</deviceKey>
</Target>
</targetSelectedWithDropDown>
<timeTargetWasSelectedWithDropDown value="2024-04-23T00:31:54.624165600Z" />
</State>
</entry>
</value>
</component>
</project>

6
.idea/kotlinc.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.1.0" />
</component>
</project>

10
.idea/migrations.xml generated Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

17
.idea/runConfigurations.xml generated Normal file
View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

11
CONTRIBUTING.md Normal file
View file

@ -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.

201
LICENSE Normal file
View file

@ -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.

View file

@ -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
# 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!

1
app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

103
app/build.gradle.kts Normal file
View file

@ -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)
}

52
app/proguard-rules.pro vendored Normal file
View file

@ -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 <fields>;
}
-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

View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:dist="http://schemas.android.com/apk/distribution"
android:targetSandboxVersion="2">
<dist:module dist:instant="false" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".TabsLiteApplication"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:allowBackup="true"
android:fullBackupOnly="true">
<activity
android:name=".HomeActivity"
android:exported="true"
android:theme="@style/Theme.Design.NoActionBar">
<!-- Intent filter for opening app normally -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Intent filter for opening web links -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="tabslite.com" />
</intent-filter>
</activity>
</application>
</manifest>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -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)
}
}
}

View file

@ -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()
}

View file

@ -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
)
}
}

View file

@ -0,0 +1,9 @@
package com.gbros.tabslite
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class TabsLiteApplication: Application() {
}

View file

@ -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()
}
}
}

View file

@ -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>?): String = gson.toJson(value)
@TypeConverter
fun jsonToArrayList(value: String) = ArrayList(gson.fromJson(value, Array<String>::class.java).toList())
// thanks https://stackoverflow.com/a/44634283/3437608
@TypeConverter
fun fromNoteMarkerSet(markers: ArrayList<ChordMarker.Note>): String = gson.toJson(markers)
@TypeConverter
fun fromOpenMarkerSet(markers: ArrayList<ChordMarker.Open>): String = gson.toJson(markers)
@TypeConverter
fun fromMutedMarkerSet(markers: ArrayList<ChordMarker.Muted>): String = gson.toJson(markers)
@TypeConverter
fun fromBarMarkerSet(markers: ArrayList<ChordMarker.Bar>): String = gson.toJson(markers)
@TypeConverter
fun toNoteMarkerList(value: String) = ArrayList(gson.fromJson(value, Array<ChordMarker.Note>::class.java).toList())
@TypeConverter
fun toOpenMarkerList(value: String) = ArrayList(gson.fromJson(value, Array<ChordMarker.Open>::class.java).toList())
@TypeConverter
fun toMutedMarkerList(value: String) = ArrayList(gson.fromJson(value, Array<ChordMarker.Muted>::class.java).toList())
@TypeConverter
fun toBarMarkerList(value: String) = ArrayList(gson.fromJson(value, Array<ChordMarker.Bar>::class.java).toList())
@TypeConverter
fun fromList(value : List<String>?) = Json.encodeToString(value)
@TypeConverter
fun toList(value: String) = Json.decodeFromString<List<String>>(value)
companion object {
private val gson = Gson()
}
}

View file

@ -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<Tab>
@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<TabWithDataPlaylistEntry?>
@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<Int>
@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<Int>
@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<List<TabWithDataPlaylistEntry>>
@RewriteQueriesToDropUnusedColumns
fun getSortedPlaylistTabs(playlistId: Int): LiveData<List<TabWithDataPlaylistEntry>> = 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<TabWithDataPlaylistEntry>
} 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<List<Tab>>
@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<List<Tab>>
//#endregion
//#region playlist table
@Query("SELECT * FROM playlist WHERE id != $FAVORITES_PLAYLIST_ID AND id != $TOP_TABS_PLAYLIST_ID")
fun getLivePlaylists(): LiveData<List<Playlist>>
@Query("SELECT * FROM playlist WHERE id != $FAVORITES_PLAYLIST_ID AND id != $TOP_TABS_PLAYLIST_ID")
suspend fun getPlaylists(): List<Playlist>
@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<Playlist>
@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<IDataPlaylistEntry>) {
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<DataPlaylistEntry>
suspend fun getSortedEntriesInPlaylist(playlistId: Int): List<IPlaylistEntry> {
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<IPlaylist>): List<SelfContainedPlaylist> {
val selfContainedPlaylists: MutableList<SelfContainedPlaylist> = 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<Boolean>
@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<Boolean>
@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<ChordVariation>
@Query("SELECT * FROM chord_variation WHERE chord_id = :chordId AND instrument = :instrument")
fun chordVariations(chordId: String, instrument: Instrument): LiveData<List<ChordVariation>>
@Query("SELECT DISTINCT chord_id FROM chord_variation WHERE chord_id IN (:chordIds) AND instrument = :instrument")
suspend fun findAll(chordIds: List<String>, instrument: Instrument): List<String>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(chords: List<ChordVariation>)
//#endregion
//#region preference table
@Query("SELECT * FROM preferences WHERE name = :name")
fun getLivePreference(name: String): LiveData<Preference?>
@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<SearchSuggestions?>
fun getSearchSuggestions(query: String): LiveData<List<String>> = getRawSearchSuggestions(query.take(5)).map { s ->
s?.suggestedSearches?.filter { suggestion -> suggestion.contains(other = query, ignoreCase = true) } ?: listOf()
}
//#endregion
}

View file

@ -0,0 +1,4 @@
package com.gbros.tabslite.data
interface ISortBy {
}

View file

@ -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"
}
}

View file

@ -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<ITab> {
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<ITab> {
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()

View file

@ -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<String>
)

View file

@ -0,0 +1,7 @@
package com.gbros.tabslite.data
enum class ThemeSelection {
System,
ForceDark,
ForceLight
}

View file

@ -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<String>, 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
}

View file

@ -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<ChordMarker.Note>,
@ColumnInfo(name = "open_chord_markers") val openChordMarkers: @RawValue ArrayList<ChordMarker.Open>,
@ColumnInfo(name = "muted_chord_markers") val mutedChordMarkers: @RawValue ArrayList<ChordMarker.Muted>,
@ColumnInfo(name = "bar_chord_markers") val barChordMarkers: @RawValue ArrayList<ChordMarker.Bar>,
@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<ChordMarker>()
markerSet.addAll(noteChordMarkers)
markerSet.addAll(openChordMarkers)
markerSet.addAll(mutedChordMarkers)
markerSet.addAll(barChordMarkers)
return Chord(chordId, markerSet)
}
}

View file

@ -0,0 +1,6 @@
package com.gbros.tabslite.data.chord
enum class Instrument {
Guitar,
Ukulele
}

View file

@ -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 <T : IDataPlaylistEntry> sortLinkedList(entries: List<T>): List<T> {
val entryMap = entries.associateBy { it.entryId }
val sortedEntries = mutableListOf<T>()
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<IDataPlaylistEntry>
constructor(message: String, list: List<IDataPlaylistEntry>) : super(message) {
this.list = list
}
constructor(message: String, cause: Throwable, list: List<IDataPlaylistEntry>) : super(message, cause) {
this.list = list
}
}

View file

@ -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)

View file

@ -0,0 +1,8 @@
package com.gbros.tabslite.data.playlist
interface IPlaylist {
val playlistId: Int
val title: String
val description: String
val userCreated: Boolean
}

View file

@ -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)

View file

@ -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
}
}

View file

@ -0,0 +1,6 @@
package com.gbros.tabslite.data.playlist
import kotlinx.serialization.Serializable
@Serializable
data class PlaylistFileExportType(val playlists: List<SelfContainedPlaylist>)

View file

@ -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<IPlaylistEntry>
): IPlaylist {
constructor(playlist: IPlaylist, entries: List<IPlaylistEntry>): 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
}
}

View file

@ -0,0 +1,135 @@
package com.gbros.tabslite.data.servertypes
import com.gbros.tabslite.data.tab.TabDataType
class SearchRequestType(private var tabs: List<SearchResultTab>, private var artists: List<String>){
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<Int, MutableList<Int>> // songId, List<tabId>
private lateinit var tabFulls: HashMap<Int, TabDataType> // tabId, TabBasic
// endregion
// region public methods
fun getAllTabs(): List<TabDataType> {
return tabFulls.values.toList()
}
fun getSongs(): List<TabDataType> {
initTabs()
val result: ArrayList<TabDataType> = 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<SearchResultTab>) {
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<SearchResultTab>){
initSongs()
indexNewSongs(newTabs)
val tabs: ArrayList<TabDataType> = 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
}

View file

@ -0,0 +1,3 @@
package com.gbros.tabslite.data.servertypes
class SearchSuggestionType(var suggestions: List<String>)

View file

@ -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
}
}

View file

@ -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<VersionInfo>, var user_rating: Int, var difficulty: String, var tuning: String, var capo: Int, var urlWeb: String, var strumming: List<StrummingInfo>, var videosCount: Int,
var contributor: ContributorInfo, var pros_brother: String?, var recommended: List<VersionInfo>, var applicature: List<ChordInfo>, val content: String?) {
class RecordingInfo(var is_acoustic: Int, var tonality_name: String, var performance: PerformanceInfo?, var recording_artists: List<RecordingArtistsInfo>) {
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<String>) {
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<String> {
val result = ArrayList<String>()
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<VarInfo>) {
class VarInfo(
var id: String, var listCapos: List<CapoInfo>, var noteIndex: Int, var notes: List<Int>, var frets: List<Int>, var fingers: List<Int>, 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<ChordMarker.Note>()
val openMarkerSet = ArrayList<ChordMarker.Open>()
val mutedMarkerSet = ArrayList<ChordMarker.Muted>()
val barMarkerSet = ArrayList<ChordMarker.Bar>()
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<ChordVariation> {
val result = ArrayList<ChordVariation>()
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<MeasureInfo>
) {
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<String>())
}
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
}
}

View file

@ -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<String>
var recommended: ArrayList<String>
var userRating: Int
var difficulty: String
var tuning: String
var capo: Int
var urlWeb: String
var strumming: ArrayList<String>
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<String> {
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
}

View file

@ -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<String> = ArrayList(),
@ColumnInfo(name = "num_versions") override var numVersions: Int = 1,
@ColumnInfo(name = "recommended") override var recommended: ArrayList<String> = 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<String> = 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<TabDataType>): List<Tab> {
return dataTabs.map { Tab(it) }
}
suspend fun fetchAllEmptyPlaylistTabsFromInternet(dataAccess: DataAccess, playlistId: Int? = null, onProgressChange: (progress: Float) -> Unit = {}) {
val emptyTabs: List<Int> = 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
}

View file

@ -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<String> = ArrayList(),
@ColumnInfo(name = "num_versions") var numVersions: Int = 1,
@ColumnInfo(name = "recommended") var recommended: ArrayList<String> = 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<String> = 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"
}

View file

@ -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<String> = arrayListOf(),
@ColumnInfo(name = "recommended") override var recommended: ArrayList<String> = 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<String> = 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
}

View file

@ -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)

View file

@ -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
)
}

View file

@ -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 <ViewId, <list of users for that view>>
*/
@OptIn(ExperimentalUuidApi::class)
val screenOnUsers: MutableMap<Int, MutableList<Uuid>> = mutableMapOf()
}

View file

@ -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 <T1, T2, R> LiveData<T1>.combine(
liveData2: LiveData<T2>,
combineFn: (value1: T1?, value2: T2?) -> R
): LiveData<R> = MediatorLiveData<R>().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 <T1, T2, T3, R> LiveData<T1>.combine(
liveData2: LiveData<T2>,
liveData3: LiveData<T3>,
combineFn: (value1: T1?, value2: T2?, value3: T3?) -> R
): LiveData<R> = MediatorLiveData<R>().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)
}
}

View file

@ -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<SearchSuggestionType>() {}.type
gson.fromJson<SearchSuggestionType?>(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<SearchRequestType>() {}.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<String>() {}.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<CharSequence>,
dataAccess: DataAccess,
instrument: Instrument,
): Map<String, List<ChordVariation>> = withContext(Dispatchers.IO) {
if (chordIds.isEmpty()) {
return@withContext mapOf()
}
val resultMap: MutableMap<String, List<ChordVariation>> = 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<TabRequestType.ChordInfo> = authenticatedStream(url).use { inputStream ->
val jsonReader = JsonReader(inputStream.reader())
val chordRequestTypeToken =
object : TypeToken<List<TabRequestType.ChordInfo>>() {}.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<List<SearchRequestType.SearchResultTab>>() {}.type
return@use (gson.fromJson(
jsonReader,
typeToken
) as List<SearchRequestType.SearchResultTab>)
}
val topTabs: List<TabDataType> = 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<TabRequestType>() {}.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<ServerTimestampType>() {}.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
}

View file

@ -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}"
}
}

View file

@ -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<Playlist>,
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 = { },
)
}
}

View file

@ -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 = { })
}
}

View file

@ -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<Playlist>, 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...",
) {}
}
}

View file

@ -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.")
}
}

View file

@ -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"
)
}
}

View file

@ -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.")
}
}

View file

@ -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<ChordVariation>,
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)
}

View file

@ -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<ChordVariation>
) {
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

View file

@ -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)
}
}
}
}
}

View file

@ -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, {})
}
}

View file

@ -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, {})
}
}
}

View file

@ -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, {}, {}, {}, {})
}
}

View file

@ -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<HomeViewModel, HomeViewModel.HomeViewModelFactory> { 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<Float>,
override val playlistImportState: LiveData<LoadingState>,
override val playlists: LiveData<List<Playlist>>,
override val playlistsSortBy: LiveData<PlaylistsSortBy>,
override val selectedAppTheme: LiveData<ThemeSelection>
) : IHomeViewState
private class SongListViewStateForTest(
override val songs: LiveData<List<TabWithDataPlaylistEntry>>,
override val sortBy: LiveData<SortBy>
) : ISongListViewState
private class TabSearchBarViewStateForTest(
override val query: LiveData<String>,
override val searchSuggestions: LiveData<List<String>>,
override val tabSuggestions: LiveData<List<ITab>>,
override val loadingState: LiveData<LoadingState>
) : ITabSearchBarViewState
//#endregion

View file

@ -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<Float>
/**
* The current state of any import/export operations
*/
val playlistImportState: LiveData<LoadingState>
/**
* The user's saved playlists
*/
val playlists: LiveData<List<Playlist>>
/**
* The selected sort option for playlists
*/
val playlistsSortBy: LiveData<PlaylistsSortBy>
/**
* The selected theme (system, dark, or light)
*/
val selectedAppTheme: LiveData<ThemeSelection>
}

View file

@ -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<List<Playlist>>,
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 = {}
)
}
}

View file

@ -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({}, {})
}
}

View file

@ -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<String>
/**
* The description of the playlist to display
*/
val description: LiveData<String>
/**
* The ordered list of songs in the playlist
*/
val songs: LiveData<List<TabWithDataPlaylistEntry>>
}

View file

@ -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<String>,
description: LiveData<String>,
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"), {}, {}, {}, {})
}
}

View file

@ -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<List<Playlist>>, 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 = {})
}
}

View file

@ -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) {}
}
}

View file

@ -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<PlaylistViewModel, PlaylistViewModel.PlaylistViewModelFactory> { 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<String>,
override val description: LiveData<String>,
override val songs: LiveData<List<TabWithDataPlaylistEntry>>
) : IPlaylistViewState
private fun createListOfTabWithPlaylistEntry(size: Int): List<TabWithDataPlaylistEntry> {
val listOfEntries = mutableListOf<TabWithDataPlaylistEntry>()
for (id in 0..size) {
listOfEntries.add(
TabWithDataPlaylistEntry(entryId = id, playlistId = 1, tabId = id * 20, nextEntryId = if(id<size) id+1 else null,
prevEntryId = if(id>0) 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
}

View file

@ -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<TabWithDataPlaylistEntry>,
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<TabWithDataPlaylistEntry>, playlist2: List<TabWithDataPlaylistEntry>): 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<TabWithDataPlaylistEntry> {
val listOfEntries = mutableListOf<TabWithDataPlaylistEntry>()
for (id in 0..size) {
listOfEntries.add(TabWithDataPlaylistEntry(entryId = id, playlistId = 1, tabId = id * 20, nextEntryId = if(id<size) id+1 else null,
prevEntryId = if(id>0) 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
}

View file

@ -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)
}
}
}
}

View file

@ -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({}, {})
}
}

View file

@ -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()
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}
}

View file

@ -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<List<ITab>>
/**
* 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<LoadingState>
/**
* Whether the complete set of search results has already been loaded. Used to disable trying to
* load more search results
*/
val allResultsLoaded: LiveData<Boolean>
}

View file

@ -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) {}
}
}

View file

@ -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<SearchViewModel, SearchViewModel.SearchViewModelFactory> { 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<SearchViewModel, SearchViewModel.SearchViewModelFactory> { 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<List<ITab>>,
override val searchState: LiveData<LoadingState>,
override val allResultsLoaded: LiveData<Boolean>
) : ISearchViewState
private class TabSearchBarViewStateForTest(
override val query: LiveData<String>,
override val searchSuggestions: LiveData<List<String>>,
override val tabSuggestions: LiveData<List<ITab>>,
override val loadingState: LiveData<LoadingState>
): 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, Whats it like in New York City?[/tab]
[tab] [ch]C[/ch] [ch]Em[/ch] [ch]Am[/ch] [ch]G[/ch]
Im 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 cant shine as bright as you, [/tab]
[tab] [ch]G[/ch]
I swear its true. [/tab]
[tab][ch]C[/ch]
Hey there Delilah, [/tab]
[tab] [ch]Em[/ch]
Dont you worry about the distance, [/tab]
[tab] [ch]C[/ch]
Im 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 its my disguise, [/tab]
[tab] [ch]G[/ch]
Im 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, Whats it like in New York City?[/tab]
[tab] [ch]C[/ch] [ch]Em[/ch] [ch]Am[/ch] [ch]G[/ch]
Im 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 cant shine as bright as you, [/tab]
[tab] [ch]G[/ch]
I swear its true. [/tab]
[tab][ch]C[/ch]
Hey there Delilah, [/tab]
[tab] [ch]Em[/ch]
Dont you worry about the distance, [/tab]
[tab] [ch]C[/ch]
Im 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 its my disguise, [/tab]
[tab] [ch]G[/ch]
Im 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

View file

@ -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<List<TabWithDataPlaylistEntry>>
/**
* How these tabs are currently sorted
*/
val sortBy: LiveData<SortBy>
}

View file

@ -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)
}
}

View file

@ -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<List<TabWithDataPlaylistEntry>>,
override val sortBy: LiveData<SortBy>
) : ISongListViewState

View file

@ -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)
}
}
}
}

View file

@ -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) }
)
}
}
}
}

View file

@ -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<String>
/**
* The versions of the selected song to be displayed
*/
val songVersions: LiveData<List<ITab>>
}

View file

@ -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<ITab>, 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, {})
}

View file

@ -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) {}
}

View file

@ -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<SongVersionViewModel, SongVersionViewModel.SongVersionViewModelFactory> { 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()
}
}

View file

@ -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)

View file

@ -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;
}
}
}

View file

@ -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<String>
/**
* A couple suggested tabs already loaded in the database
*/
val tabSuggestions: LiveData<List<ITab>>
/**
* The current search suggestions to be displayed
*/
val searchSuggestions: LiveData<List<String>>
val loadingState: LiveData<LoadingState>
}

View file

@ -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 = {})
}
}

View file

@ -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 = {}
)
}
}

Some files were not shown because too many files have changed in this diff Show more