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