Repo created
This commit is contained in:
parent
92216c1ae2
commit
6e051b9cd4
280 changed files with 19204 additions and 2 deletions
34
.circleci/config.yml
Normal file
34
.circleci/config.yml
Normal 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
145
.gitignore
vendored
Normal 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
|
||||||
22
.idea/androidTestResultsUserPreferences.xml
generated
Normal file
22
.idea/androidTestResultsUserPreferences.xml
generated
Normal 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
123
.idea/codeStyles/Project.xml
generated
Normal 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
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal 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
38
.idea/deploymentTargetDropDown.xml
generated
Normal 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
6
.idea/kotlinc.xml
generated
Normal 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
10
.idea/migrations.xml
generated
Normal 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
17
.idea/runConfigurations.xml
generated
Normal 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
11
CONTRIBUTING.md
Normal 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
201
LICENSE
Normal 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.
|
||||||
20
README.md
20
README.md
|
|
@ -1,3 +1,19 @@
|
||||||
# tabs-lite
|
An open source guitar tablature application built for Android. Over a million songs available using an existing popular tabs database. Built for speed and simplicity, 100% free with no ads!
|
||||||
|
|
||||||
Ad & Account Free Music Tabulatur for Android
|
# Download
|
||||||
|
|
||||||
|
[Get the app](https://play.google.com/store/apps/details?id=com.gbros.tabslite) on Google Play, or download it from GitHub releases!
|
||||||
|
|
||||||
|
# About
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Find your favorites among thousands of available community driven chords and tabs! Play along at your own speed with built-in auto scroll and speed adjustment.
|
||||||
|
|
||||||
|
Jam at any time of day or night with system dark mode support.
|
||||||
|
|
||||||
|
Save songs for offline access by adding them to your Favorites or a playlist. The Favorites page is shown immediately on startup, allowing for easy, efficient access to your favorite tabs.
|
||||||
|
|
||||||
|
Quickly find the content you're looking for with a beautiful Material Design built for speed and simplicity. Search hundreds of thousands of available songs by title or author name, 100% free with no ads!
|
||||||
|
|
||||||
|
Key changes are as simple as a touch of a button with built in transposition. Or find the fingering for any chord by simply tapping the chord name!
|
||||||
|
|
|
||||||
1
app/.gitignore
vendored
Normal file
1
app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/build
|
||||||
103
app/build.gradle.kts
Normal file
103
app/build.gradle.kts
Normal 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
52
app/proguard-rules.pro
vendored
Normal 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
|
||||||
40
app/src/main/AndroidManifest.xml
Normal file
40
app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
3928
app/src/main/assets/EvenIfFullTab.json
Normal file
3928
app/src/main/assets/EvenIfFullTab.json
Normal file
File diff suppressed because it is too large
Load diff
1276
app/src/main/assets/EvenIfSearchRequestResult.json
Normal file
1276
app/src/main/assets/EvenIfSearchRequestResult.json
Normal file
File diff suppressed because it is too large
Load diff
19
app/src/main/assets/EvenSearchSuggestions.json
Normal file
19
app/src/main/assets/EvenSearchSuggestions.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
BIN
app/src/main/ic_launcher-playstore.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
143
app/src/main/java/com/gbros/tabslite/HomeActivity.kt
Normal file
143
app/src/main/java/com/gbros/tabslite/HomeActivity.kt
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
7
app/src/main/java/com/gbros/tabslite/LoadingState.kt
Normal file
7
app/src/main/java/com/gbros/tabslite/LoadingState.kt
Normal 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()
|
||||||
|
}
|
||||||
75
app/src/main/java/com/gbros/tabslite/RootNavHost.kt
Normal file
75
app/src/main/java/com/gbros/tabslite/RootNavHost.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.gbros.tabslite
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class TabsLiteApplication: Application() {
|
||||||
|
|
||||||
|
}
|
||||||
188
app/src/main/java/com/gbros/tabslite/data/AppDatabase.kt
Normal file
188
app/src/main/java/com/gbros/tabslite/data/AppDatabase.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/src/main/java/com/gbros/tabslite/data/Converters.kt
Normal file
58
app/src/main/java/com/gbros/tabslite/data/Converters.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
335
app/src/main/java/com/gbros/tabslite/data/DataAccess.kt
Normal file
335
app/src/main/java/com/gbros/tabslite/data/DataAccess.kt
Normal 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
|
||||||
|
}
|
||||||
4
app/src/main/java/com/gbros/tabslite/data/ISortBy.kt
Normal file
4
app/src/main/java/com/gbros/tabslite/data/ISortBy.kt
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
package com.gbros.tabslite.data
|
||||||
|
|
||||||
|
interface ISortBy {
|
||||||
|
}
|
||||||
63
app/src/main/java/com/gbros/tabslite/data/Preference.kt
Normal file
63
app/src/main/java/com/gbros/tabslite/data/Preference.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
108
app/src/main/java/com/gbros/tabslite/data/Search.kt
Normal file
108
app/src/main/java/com/gbros/tabslite/data/Search.kt
Normal 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()
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package com.gbros.tabslite.data
|
||||||
|
|
||||||
|
enum class ThemeSelection {
|
||||||
|
System,
|
||||||
|
ForceDark,
|
||||||
|
ForceLight
|
||||||
|
}
|
||||||
156
app/src/main/java/com/gbros/tabslite/data/chord/Chord.kt
Normal file
156
app/src/main/java/com/gbros/tabslite/data/chord/Chord.kt
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package com.gbros.tabslite.data.chord
|
||||||
|
|
||||||
|
enum class Instrument {
|
||||||
|
Guitar,
|
||||||
|
Ukulele
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package com.gbros.tabslite.data.playlist
|
||||||
|
|
||||||
|
interface IPlaylist {
|
||||||
|
val playlistId: Int
|
||||||
|
val title: String
|
||||||
|
val description: String
|
||||||
|
val userCreated: Boolean
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package com.gbros.tabslite.data.playlist
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PlaylistFileExportType(val playlists: List<SelfContainedPlaylist>)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package com.gbros.tabslite.data.servertypes
|
||||||
|
|
||||||
|
class SearchSuggestionType(var suggestions: List<String>)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
102
app/src/main/java/com/gbros/tabslite/data/tab/ITab.kt
Normal file
102
app/src/main/java/com/gbros/tabslite/data/tab/ITab.kt
Normal 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
|
||||||
|
}
|
||||||
173
app/src/main/java/com/gbros/tabslite/data/tab/Tab.kt
Normal file
173
app/src/main/java/com/gbros/tabslite/data/tab/Tab.kt
Normal 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
|
||||||
|
}
|
||||||
53
app/src/main/java/com/gbros/tabslite/data/tab/TabDataType.kt
Normal file
53
app/src/main/java/com/gbros/tabslite/data/tab/TabDataType.kt
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
}
|
||||||
68
app/src/main/java/com/gbros/tabslite/ui/theme/Color.kt
Normal file
68
app/src/main/java/com/gbros/tabslite/ui/theme/Color.kt
Normal 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)
|
||||||
|
|
||||||
96
app/src/main/java/com/gbros/tabslite/ui/theme/Theme.kt
Normal file
96
app/src/main/java/com/gbros/tabslite/ui/theme/Theme.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
554
app/src/main/java/com/gbros/tabslite/utilities/UgApi.kt
Normal file
554
app/src/main/java/com/gbros/tabslite/utilities/UgApi.kt
Normal 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
|
||||||
|
}
|
||||||
14
app/src/main/java/com/gbros/tabslite/utilities/tag.kt
Normal file
14
app/src/main/java/com/gbros/tabslite/utilities/tag.kt
Normal 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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 = { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 = { })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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...",
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/src/main/java/com/gbros/tabslite/view/card/ErrorCard.kt
Normal file
28
app/src/main/java/com/gbros/tabslite/view/card/ErrorCard.kt
Normal 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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/src/main/java/com/gbros/tabslite/view/card/InfoCard.kt
Normal file
28
app/src/main/java/com/gbros/tabslite/view/card/InfoCard.kt
Normal 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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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, {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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, {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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, {}, {}, {}, {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -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 = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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({}, {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>>
|
||||||
|
}
|
||||||
|
|
@ -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"), {}, {}, {}, {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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({}, {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -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) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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, What’s it like in New York City?[/tab]
|
||||||
|
[tab] [ch]C[/ch] [ch]Em[/ch] [ch]Am[/ch] [ch]G[/ch]
|
||||||
|
I’m a thousand miles away, But girl tonight you look so pretty, Yes you do, [/tab]
|
||||||
|
|
||||||
|
[tab]F [ch]G[/ch] [ch]Am[/ch]
|
||||||
|
Time Square can’t shine as bright as you, [/tab]
|
||||||
|
[tab] [ch]G[/ch]
|
||||||
|
I swear it’s true. [/tab]
|
||||||
|
[tab][ch]C[/ch]
|
||||||
|
Hey there Delilah, [/tab]
|
||||||
|
[tab] [ch]Em[/ch]
|
||||||
|
Don’t you worry about the distance, [/tab]
|
||||||
|
[tab] [ch]C[/ch]
|
||||||
|
I’m right there if you get lonely, [/tab]
|
||||||
|
[tab] [ch]Em[/ch]
|
||||||
|
[ch]G[/ch]ive this song another listen, [/tab]
|
||||||
|
[tab] [ch]Am[/ch] [ch]G[/ch]
|
||||||
|
Close your eyes, [/tab]
|
||||||
|
[tab]F [ch]G[/ch] [ch]Am[/ch]
|
||||||
|
Listen to my voice it’s my disguise, [/tab]
|
||||||
|
[tab] [ch]G[/ch]
|
||||||
|
I’m by your side.[/tab] """.trimIndent()
|
||||||
|
val tabForTest = TabWithDataPlaylistEntry(1, 1, 1, 1, 1, 1234, 0, "Long Time Ago", "CoolGuyz", 1, false, 5, "Chords", "", 1, 4, 3.6, 1234, "" , 123, "public", 1, "C", "description", false, "asdf", "", ArrayList(), ArrayList(), 4, "expert", playlistDateCreated = 12345, playlistDateModified = 12345, playlistDescription = "Description of our awesome playlist", playlistTitle = "My Playlist", playlistUserCreated = true, capo = 2, contributorUserName = "Joe Blow", content = hallelujahTabForTest)
|
||||||
|
val state = SearchViewStateForTest("my song", MutableLiveData(listOf(tabForTest, tabForTest, tabForTest)), MutableLiveData(LoadingState.Loading), MutableLiveData(false))
|
||||||
|
val tabSearchBarViewState = TabSearchBarViewStateForTest(
|
||||||
|
query = MutableLiveData("my song"),
|
||||||
|
searchSuggestions = MutableLiveData(listOf()),
|
||||||
|
tabSuggestions = MutableLiveData(listOf(Tab(0))),
|
||||||
|
loadingState = MutableLiveData()
|
||||||
|
)
|
||||||
|
|
||||||
|
AppTheme {
|
||||||
|
SearchScreen(
|
||||||
|
viewState = state,
|
||||||
|
tabSearchBarViewState = tabSearchBarViewState,
|
||||||
|
onMoreSearchResultsNeeded = {},
|
||||||
|
onNavigateToSongVersionsBySongId = {},
|
||||||
|
onNavigateBack = {},
|
||||||
|
onNavigateToSearch = {},
|
||||||
|
onTabSearchBarQueryChange = {},
|
||||||
|
onNavigateToTabByTabId = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@Preview
|
||||||
|
private fun SearchScreenPreviewError() {
|
||||||
|
val hallelujahTabForTest = """
|
||||||
|
[Intro]
|
||||||
|
[ch]C[/ch] [ch]Em[/ch] [ch]C[/ch] [ch]Em[/ch]
|
||||||
|
|
||||||
|
[Verse]
|
||||||
|
[tab][ch]C[/ch] [ch]Em[/ch]
|
||||||
|
Hey there Delilah, What’s it like in New York City?[/tab]
|
||||||
|
[tab] [ch]C[/ch] [ch]Em[/ch] [ch]Am[/ch] [ch]G[/ch]
|
||||||
|
I’m a thousand miles away, But girl tonight you look so pretty, Yes you do, [/tab]
|
||||||
|
|
||||||
|
[tab]F [ch]G[/ch] [ch]Am[/ch]
|
||||||
|
Time Square can’t shine as bright as you, [/tab]
|
||||||
|
[tab] [ch]G[/ch]
|
||||||
|
I swear it’s true. [/tab]
|
||||||
|
[tab][ch]C[/ch]
|
||||||
|
Hey there Delilah, [/tab]
|
||||||
|
[tab] [ch]Em[/ch]
|
||||||
|
Don’t you worry about the distance, [/tab]
|
||||||
|
[tab] [ch]C[/ch]
|
||||||
|
I’m right there if you get lonely, [/tab]
|
||||||
|
[tab] [ch]Em[/ch]
|
||||||
|
[ch]G[/ch]ive this song another listen, [/tab]
|
||||||
|
[tab] [ch]Am[/ch] [ch]G[/ch]
|
||||||
|
Close your eyes, [/tab]
|
||||||
|
[tab]F [ch]G[/ch] [ch]Am[/ch]
|
||||||
|
Listen to my voice it’s my disguise, [/tab]
|
||||||
|
[tab] [ch]G[/ch]
|
||||||
|
I’m by your side.[/tab] """.trimIndent()
|
||||||
|
val tabForTest = TabWithDataPlaylistEntry(1, 1, 1, 1, 1, 1234, 0, "Long Time Ago", "CoolGuyz", 1, false, 5, "Chords", "", 1, 4, 3.6, 1234, "" , 123, "public", 1, "C", "description", false, "asdf", "", ArrayList(), ArrayList(), 4, "expert", playlistDateCreated = 12345, playlistDateModified = 12345, playlistDescription = "Description of our awesome playlist", playlistTitle = "My Playlist", playlistUserCreated = true, capo = 2, contributorUserName = "Joe Blow", content = hallelujahTabForTest)
|
||||||
|
val state = SearchViewStateForTest("my song", MutableLiveData(listOf(tabForTest, tabForTest, tabForTest)), MutableLiveData(LoadingState.Error(R.string.error)), MutableLiveData(false))
|
||||||
|
|
||||||
|
val tabSearchBarViewState = TabSearchBarViewStateForTest(
|
||||||
|
query = MutableLiveData("my song"),
|
||||||
|
searchSuggestions = MutableLiveData(listOf()),
|
||||||
|
tabSuggestions = MutableLiveData(listOf(Tab(0))),
|
||||||
|
loadingState = MutableLiveData()
|
||||||
|
)
|
||||||
|
|
||||||
|
AppTheme {
|
||||||
|
SearchScreen(
|
||||||
|
viewState = state,
|
||||||
|
tabSearchBarViewState = tabSearchBarViewState,
|
||||||
|
onMoreSearchResultsNeeded = {},
|
||||||
|
onNavigateToSongVersionsBySongId = {},
|
||||||
|
onNavigateBack = {},
|
||||||
|
onNavigateToSearch = {},
|
||||||
|
onTabSearchBarQueryChange = {},
|
||||||
|
onNavigateToTabByTabId = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
27
app/src/main/java/com/gbros/tabslite/view/songlist/SortBy.kt
Normal file
27
app/src/main/java/com/gbros/tabslite/view/songlist/SortBy.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>>
|
||||||
|
}
|
||||||
|
|
@ -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, {})
|
||||||
|
}
|
||||||
|
|
@ -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) {}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -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 = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue