commit 0a244128e4a1ee1bf763959634db7466a11aa64b Author: Syer10 Date: Mon Mar 22 21:23:25 2021 -0400 Initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..91a5614a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,25 @@ +* text=auto +* text eol=lf + +# Windows forced line-endings +/.idea/* text eol=crlf + +# Gradle wrapper +*.jar binary + +# Images +*.webp binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.gz binary +*.zip binary +*.7z binary +*.ttf binary +*.eot binary +*.woff binary +*.pyc binary +*.swp binary +*.pdf binary \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..dea6d9d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +gradle-local.properties +gpg/ +.gradle/ +build/ +*.class +*.log +*.settings.xml + +# Eclipse +.classpath +.project +.settings/ +bin/ + +# IDEA +*.iml +*.ipr +*.iws +/.idea/* +!/.idea/runConfigurations +out/ +workspace.xml \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..d0a1fa14 --- /dev/null +++ b/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 00000000..c01616d5 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,85 @@ +import org.jetbrains.compose.compose +import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("jvm") version "1.4.31" + kotlin("plugin.serialization") version "1.4.31" + id("org.jetbrains.compose") version "0.4.0-build174" +} + +group = "ca.gosyer" +version = "1.0.0" + +repositories { + jcenter() + mavenCentral() + maven { url = uri("https://maven.pkg.jetbrains.space/public/p/compose/dev") } +} + +dependencies { + // UI (Compose) + implementation(compose.desktop.currentOs) + + // Threading + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.3") + + // Json + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.1.0") + + // Dependency Injection + implementation("io.insert-koin:koin-core-ext:3.0.1-beta-1") + + // Http client + val ktorVersion = "1.5.2" + implementation("io.ktor:ktor-client-core:$ktorVersion") + implementation("io.ktor:ktor-client-okhttp:$ktorVersion") + implementation("io.ktor:ktor-client-serialization:$ktorVersion") + implementation("io.ktor:ktor-client-logging:$ktorVersion") + + // Logging + implementation("ch.qos.logback:logback-classic:1.2.3") + //implementation("org.fusesource.jansi:jansi:1.18") + implementation("io.github.microutils:kotlin-logging:2.0.5") + + // Preferences + val multiplatformSettingsVersion = "0.7.4" + implementation("com.russhwolf:multiplatform-settings-jvm:$multiplatformSettingsVersion") + implementation("com.russhwolf:multiplatform-settings-serialization-jvm:$multiplatformSettingsVersion") + implementation("com.russhwolf:multiplatform-settings-coroutines-jvm:$multiplatformSettingsVersion") + + // Testing + testImplementation(kotlin("test-junit5")) + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.3") +} + +tasks { + withType { + kotlinOptions { + jvmTarget = "15" + freeCompilerArgs = listOf( + "-Xopt-in=kotlin.RequiresOptIn", + "-Xopt-in=kotlin.time.ExperimentalTime", + "-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-Xopt-in=androidx.compose.foundation.ExperimentalFoundationApi", + "-Xopt-in=kotlinx.serialization.ExperimentalSerializationApi", + "-Xopt-in=com.russhwolf.settings.ExperimentalSettingsApi", + "-Xopt-in=com.russhwolf.settings.ExperimentalSettingsImplementation" + ) + } + } + test { + useJUnit() + } +} + + +compose.desktop { + application { + mainClass = "ca.gosyer.ui.main.MainKt" + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "TachideskJUI" + } + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..7fc6f1ff --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..e708b1c0 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..442d9132 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 00000000..4f906e0c --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# 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 +# +# https://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. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..107acd32 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 00000000..f451cfdf --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,11 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + maven { url = uri("https://maven.pkg.jetbrains.space/public/p/compose/dev") } + } + +} +rootProject.name = "TachideskJUI" + + diff --git a/src/main/kotlin/ca/gosyer/backend/models/Category.kt b/src/main/kotlin/ca/gosyer/backend/models/Category.kt new file mode 100644 index 00000000..6c1df4e3 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/models/Category.kt @@ -0,0 +1,17 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.models + +import kotlinx.serialization.Serializable + +@Serializable +data class Category( + val id: Long, + val order: Int, + val name: String, + val landing: Boolean +) \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/models/Chapter.kt b/src/main/kotlin/ca/gosyer/backend/models/Chapter.kt new file mode 100644 index 00000000..0c8419e2 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/models/Chapter.kt @@ -0,0 +1,24 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Chapter( + val id: Long, + val url: String, + val name: String, + @SerialName("date_upload") + val dateUpload: Long, + @SerialName("chapter_number") + val chapterNumber: Float, + val scanlator: String?, + val mangaId: Long, + val pageCount: Int? = null, +) diff --git a/src/main/kotlin/ca/gosyer/backend/models/Extension.kt b/src/main/kotlin/ca/gosyer/backend/models/Extension.kt new file mode 100644 index 00000000..a6e981f2 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/models/Extension.kt @@ -0,0 +1,25 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.models + +import kotlinx.serialization.Serializable + +@Serializable +data class Extension( + val name: String, + val pkgName: String, + val versionName: String, + val versionCode: Int, + val lang: String, + val apkName: String, + val iconUrl: String, + val installed: Boolean, + val classFQName: String, + val nsfw: Boolean +) { + fun iconUrl(serverUrl: String) = serverUrl + iconUrl +} diff --git a/src/main/kotlin/ca/gosyer/backend/models/Manga.kt b/src/main/kotlin/ca/gosyer/backend/models/Manga.kt new file mode 100644 index 00000000..934cbd1f --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/models/Manga.kt @@ -0,0 +1,28 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.models + +import kotlinx.serialization.Serializable + +@Serializable +data class Manga( + val id: Long, + val sourceId: Long, + val url: String, + val title: String, + val thumbnailUrl: String? = null, + val initialized: Boolean = false, + val artist: String? = null, + val author: String? = null, + val description: String? = null, + val genre: String? = null, + val status: String, + val inLibrary: Boolean = false, + val source: Source? +) { + fun cover(serverUrl: String) = thumbnailUrl?.let { serverUrl + it } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/models/MangaPage.kt b/src/main/kotlin/ca/gosyer/backend/models/MangaPage.kt new file mode 100644 index 00000000..3b879848 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/models/MangaPage.kt @@ -0,0 +1,15 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.models + +import kotlinx.serialization.Serializable + +@Serializable +data class MangaPage( + val mangaList: List, + val hasNextPage: Boolean +) diff --git a/src/main/kotlin/ca/gosyer/backend/models/Page.kt b/src/main/kotlin/ca/gosyer/backend/models/Page.kt new file mode 100644 index 00000000..bfa705ce --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/models/Page.kt @@ -0,0 +1,15 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.models + +import kotlinx.serialization.Serializable + +@Serializable +data class Page( + val index: Int, + var imageUrl: String, +) diff --git a/src/main/kotlin/ca/gosyer/backend/models/Source.kt b/src/main/kotlin/ca/gosyer/backend/models/Source.kt new file mode 100644 index 00000000..65368800 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/models/Source.kt @@ -0,0 +1,20 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.models + +import kotlinx.serialization.Serializable + +@Serializable +data class Source( + val id: Long, + val name: String, + val lang: String, + val iconUrl: String, + val supportsLatest: Boolean +) { + fun iconUrl(serverUrl: String) = serverUrl + iconUrl +} diff --git a/src/main/kotlin/ca/gosyer/backend/network/HttpClient.kt b/src/main/kotlin/ca/gosyer/backend/network/HttpClient.kt new file mode 100644 index 00000000..0f200114 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/network/HttpClient.kt @@ -0,0 +1,25 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.network + +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.features.json.JsonFeature +import io.ktor.client.features.logging.LogLevel +import io.ktor.client.features.logging.Logging +import org.koin.dsl.module + +val networkModule = module { + single { + HttpClient(OkHttp) { + install(JsonFeature) + install(Logging) { + level = LogLevel.INFO + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/network/interactions/BaseInteractionHandler.kt b/src/main/kotlin/ca/gosyer/backend/network/interactions/BaseInteractionHandler.kt new file mode 100644 index 00000000..10647e2c --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/network/interactions/BaseInteractionHandler.kt @@ -0,0 +1,132 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.network.interactions + +import androidx.compose.ui.graphics.ImageBitmap +import ca.gosyer.backend.preferences.PreferenceHelper +import ca.gosyer.util.system.inject +import io.ktor.client.HttpClient +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.delete +import io.ktor.client.request.forms.submitForm +import io.ktor.client.request.get +import io.ktor.client.request.patch +import io.ktor.client.request.post +import io.ktor.http.Parameters +import kotlinx.coroutines.CancellationException + +open class BaseInteractionHandler { + val preferences: PreferenceHelper by inject() + val serverUrl get() = preferences.serverUrl.get() + + protected suspend inline fun HttpClient.getRepeat( + urlString: String, + block: HttpRequestBuilder.() -> Unit = {} + ): T { + var attempt = 1 + var lastException: Exception + do { + try { + return get(urlString, block) + } catch (e: Exception) { + if (e is CancellationException) throw e + lastException = e + } + attempt++ + } while (attempt <= 3) + throw lastException + } + + protected suspend inline fun HttpClient.deleteRepeat( + urlString: String, + block: HttpRequestBuilder.() -> Unit = {} + ): T { + var attempt = 1 + var lastException: Exception + do { + try { + return delete(urlString, block) + } catch (e: Exception) { + if (e is CancellationException) throw e + lastException = e + } + attempt++ + } while (attempt <= 3) + throw lastException + } + + protected suspend inline fun HttpClient.patchRepeat( + urlString: String, + block: HttpRequestBuilder.() -> Unit = {} + ): T { + var attempt = 1 + var lastException: Exception + do { + try { + return patch(urlString, block) + } catch (e: Exception) { + if (e is CancellationException) throw e + lastException = e + } + attempt++ + } while (attempt <= 3) + throw lastException + } + + protected suspend inline fun HttpClient.postRepeat( + urlString: String, + block: HttpRequestBuilder.() -> Unit = {} + ): T { + var attempt = 1 + var lastException: Exception + do { + try { + return post(urlString, block) + } catch (e: Exception) { + if (e is CancellationException) throw e + lastException = e + } + attempt++ + } while (attempt <= 3) + throw lastException + } + + protected suspend inline fun HttpClient.submitFormRepeat( + urlString: String, + formParameters: Parameters = Parameters.Empty, + encodeInQuery: Boolean = false, + block: HttpRequestBuilder.() -> Unit = {} + ): T { + var attempt = 1 + var lastException: Exception + do { + try { + return submitForm(urlString, formParameters, encodeInQuery, block) + } catch (e: Exception) { + if (e is CancellationException) throw e + lastException = e + } + attempt++ + } while (attempt <= 3) + throw lastException + } + + suspend fun imageFromUrl(client: HttpClient, imageUrl: String): ImageBitmap { + var attempt = 1 + var lastException: Exception + do { + try { + return ca.gosyer.util.compose.imageFromUrl(client, imageUrl) + } catch (e: Exception) { + if (e is CancellationException) throw e + lastException = e + } + attempt++ + } while (attempt <= 3) + throw lastException + } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/network/interactions/CategoryInteractionHandler.kt b/src/main/kotlin/ca/gosyer/backend/network/interactions/CategoryInteractionHandler.kt new file mode 100644 index 00000000..08cb133c --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/network/interactions/CategoryInteractionHandler.kt @@ -0,0 +1,110 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.network.interactions + +import ca.gosyer.backend.models.Category +import ca.gosyer.backend.models.Manga +import ca.gosyer.backend.network.requests.addMangaToCategoryQuery +import ca.gosyer.backend.network.requests.categoryDeleteRequest +import ca.gosyer.backend.network.requests.categoryModifyRequest +import ca.gosyer.backend.network.requests.categoryReorderRequest +import ca.gosyer.backend.network.requests.createCategoryRequest +import ca.gosyer.backend.network.requests.getCategoriesQuery +import ca.gosyer.backend.network.requests.getMangaCategoriesQuery +import ca.gosyer.backend.network.requests.getMangaInCategoryQuery +import ca.gosyer.backend.network.requests.removeMangaFromCategoryRequest +import io.ktor.client.HttpClient +import io.ktor.client.request.forms.formData +import io.ktor.client.statement.HttpResponse +import io.ktor.http.HttpMethod +import io.ktor.http.Parameters +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class CategoryInteractionHandler(private val client: HttpClient): BaseInteractionHandler() { + + suspend fun getMangaCategories(mangaId: Long) = withContext(Dispatchers.IO) { + client.getRepeat>( + serverUrl + getMangaCategoriesQuery(mangaId) + ) + } + + suspend fun addMangaToCategory(mangaId: Long, categoryId: Long) = withContext(Dispatchers.IO) { + client.getRepeat( + serverUrl + addMangaToCategoryQuery(mangaId, categoryId) + ) + } + + suspend fun removeMangaFromCategory(mangaId: Long, categoryId: Long) = withContext(Dispatchers.IO) { + client.deleteRepeat( + serverUrl + removeMangaFromCategoryRequest(mangaId, categoryId) + ) + } + + suspend fun getCategories() = withContext(Dispatchers.IO) { + client.getRepeat>( + serverUrl + getCategoriesQuery() + ) + } + + suspend fun createCategory(name: String) = withContext(Dispatchers.IO) { + client.submitFormRepeat( + serverUrl + createCategoryRequest(), + formParameters = Parameters.build { + append("name", name) + } + ) + } + + suspend fun modifyCategory(categoryId: Long, name: String? = null, isLanding: Boolean? = null) = withContext(Dispatchers.IO) { + client.submitFormRepeat( + serverUrl + categoryModifyRequest(categoryId), + formParameters = Parameters.build { + if (name != null) { + append("name", name) + } + if (isLanding != null) { + append("isLanding", isLanding.toString()) + } + } + ) { + method = HttpMethod.Patch + formData { + if (name != null) { + append("name", name) + } + if (isLanding != null) { + append("isLanding", isLanding.toString()) + } + } + } + } + + suspend fun reorderCategory(categoryId: Long, to: Int, from: Int) = withContext(Dispatchers.IO) { + client.submitFormRepeat( + serverUrl + categoryReorderRequest(categoryId), + formParameters = Parameters.build { + append("to", to.toString()) + append("from", from.toString()) + } + ) { + method = HttpMethod.Patch + } + } + + suspend fun deleteCategory(categoryId: Long) = withContext(Dispatchers.IO) { + client.deleteRepeat( + serverUrl + categoryDeleteRequest(categoryId) + ) + } + + suspend fun getMangaFromCategory(categoryId: Long) = withContext(Dispatchers.IO) { + client.getRepeat>( + serverUrl + getMangaInCategoryQuery(categoryId) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/network/interactions/ChapterInteractionHandler.kt b/src/main/kotlin/ca/gosyer/backend/network/interactions/ChapterInteractionHandler.kt new file mode 100644 index 00000000..39571cf3 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/network/interactions/ChapterInteractionHandler.kt @@ -0,0 +1,56 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.network.interactions + +import ca.gosyer.backend.models.Chapter +import ca.gosyer.backend.models.Manga +import ca.gosyer.backend.network.requests.getChapterQuery +import ca.gosyer.backend.network.requests.getMangaChaptersQuery +import ca.gosyer.backend.network.requests.getPageQuery +import io.ktor.client.HttpClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class ChapterInteractionHandler(private val client: HttpClient): BaseInteractionHandler() { + + suspend fun getChapters(mangaId: Long) = withContext(Dispatchers.IO) { + client.getRepeat>( + serverUrl + getMangaChaptersQuery(mangaId) + ) + } + + suspend fun getChapters(manga: Manga) = withContext(Dispatchers.IO) { + client.getRepeat( + serverUrl + getMangaChaptersQuery(manga.id) + ) + } + + suspend fun getChapter(mangaId: Long, chapterId: Long) = withContext(Dispatchers.IO) { + client.getRepeat( + serverUrl + getChapterQuery(mangaId, chapterId) + ) + } + + suspend fun getChapter(chapter: Chapter) = getChapter(chapter.mangaId, chapter.id) + + suspend fun getChapter(manga: Manga, chapterId: Long) = getChapter(manga.id, chapterId) + + suspend fun getChapter(manga: Manga, chapter: Chapter) = getChapter(manga.id, chapter.id) + + suspend fun getPage(mangaId: Long, chapterId: Long, pageNum: Int) = withContext(Dispatchers.IO) { + imageFromUrl( + client, + serverUrl + getPageQuery(mangaId, chapterId, pageNum) + ) + } + + suspend fun getPage(chapter: Chapter, pageNum: Int) = getPage(chapter.mangaId, chapter.id, pageNum) + + suspend fun getPage(manga: Manga, chapterId: Long, pageNum: Int) = getPage(manga.id, chapterId, pageNum) + + suspend fun getPage(manga: Manga, chapter: Chapter, pageNum: Int) = getPage(manga.id, chapter.id, pageNum) +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/network/interactions/ExtensionInteractionHandler.kt b/src/main/kotlin/ca/gosyer/backend/network/interactions/ExtensionInteractionHandler.kt new file mode 100644 index 00000000..659fa184 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/network/interactions/ExtensionInteractionHandler.kt @@ -0,0 +1,45 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.network.interactions + +import ca.gosyer.backend.models.Extension +import ca.gosyer.backend.network.requests.apkIconQuery +import ca.gosyer.backend.network.requests.apkInstallQuery +import ca.gosyer.backend.network.requests.apkUninstallQuery +import ca.gosyer.backend.network.requests.extensionListQuery +import io.ktor.client.HttpClient +import io.ktor.client.statement.HttpResponse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class ExtensionInteractionHandler(private val client: HttpClient): BaseInteractionHandler() { + + suspend fun getExtensionList() = withContext(Dispatchers.IO) { + client.getRepeat>( + serverUrl + extensionListQuery() + ) + } + + suspend fun installExtension(extension: Extension) = withContext(Dispatchers.IO) { + client.getRepeat( + serverUrl + apkInstallQuery(extension.apkName) + ) + } + + suspend fun uninstallExtension(extension: Extension) = withContext(Dispatchers.IO) { + client.getRepeat( + serverUrl + apkUninstallQuery(extension.apkName) + ) + } + + suspend fun getApkIcon(extension: Extension) = withContext(Dispatchers.IO) { + imageFromUrl( + client, + serverUrl + apkIconQuery(extension.apkName) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/network/interactions/LibraryInteractionHandler.kt b/src/main/kotlin/ca/gosyer/backend/network/interactions/LibraryInteractionHandler.kt new file mode 100644 index 00000000..1e36aa6b --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/network/interactions/LibraryInteractionHandler.kt @@ -0,0 +1,41 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.network.interactions + +import ca.gosyer.backend.models.Manga +import ca.gosyer.backend.network.requests.addMangaToLibraryQuery +import ca.gosyer.backend.network.requests.getLibraryQuery +import ca.gosyer.backend.network.requests.removeMangaFromLibraryRequest +import io.ktor.client.HttpClient +import io.ktor.client.statement.HttpResponse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class LibraryInteractionHandler(private val client: HttpClient): BaseInteractionHandler() { + + suspend fun getLibraryManga() = withContext(Dispatchers.IO) { + client.getRepeat>( + serverUrl + getLibraryQuery() + ) + } + + suspend fun addMangaToLibrary(mangaId: Long) = withContext(Dispatchers.IO) { + client.getRepeat( + serverUrl + addMangaToLibraryQuery(mangaId) + ) + } + + suspend fun addMangaToLibrary(manga: Manga) = addMangaToLibrary(manga.id) + + suspend fun removeMangaFromLibrary(mangaId: Long) = withContext(Dispatchers.IO) { + client.deleteRepeat( + serverUrl + removeMangaFromLibraryRequest(mangaId) + ) + } + + suspend fun removeMangaFromLibrary(manga: Manga) = removeMangaFromLibrary(manga.id) +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/network/interactions/MangaInteractionHandler.kt b/src/main/kotlin/ca/gosyer/backend/network/interactions/MangaInteractionHandler.kt new file mode 100644 index 00000000..4976c71f --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/network/interactions/MangaInteractionHandler.kt @@ -0,0 +1,31 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.network.interactions + +import ca.gosyer.backend.models.Manga +import ca.gosyer.backend.network.requests.mangaQuery +import ca.gosyer.backend.network.requests.mangaThumbnailQuery +import io.ktor.client.HttpClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class MangaInteractionHandler(private val client: HttpClient): BaseInteractionHandler() { + + suspend fun getManga(mangaId: Long) = withContext(Dispatchers.IO) { + client.getRepeat( + serverUrl + mangaQuery(mangaId) + ) + } + + suspend fun getMangaThumbnail(mangaId: Long) = withContext(Dispatchers.IO) { + imageFromUrl( + client, + serverUrl + mangaThumbnailQuery(mangaId) + ) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/network/interactions/SourceInteractionHandler.kt b/src/main/kotlin/ca/gosyer/backend/network/interactions/SourceInteractionHandler.kt new file mode 100644 index 00000000..9d234014 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/network/interactions/SourceInteractionHandler.kt @@ -0,0 +1,84 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.network.interactions + +import ca.gosyer.backend.models.MangaPage +import ca.gosyer.backend.models.Source +import ca.gosyer.backend.network.requests.getFilterListQuery +import ca.gosyer.backend.network.requests.globalSearchQuery +import ca.gosyer.backend.network.requests.sourceInfoQuery +import ca.gosyer.backend.network.requests.sourceLatestQuery +import ca.gosyer.backend.network.requests.sourceListQuery +import ca.gosyer.backend.network.requests.sourcePopularQuery +import ca.gosyer.backend.network.requests.sourceSearchQuery +import io.ktor.client.HttpClient +import io.ktor.client.statement.HttpResponse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class SourceInteractionHandler(private val client: HttpClient): BaseInteractionHandler() { + + suspend fun getSourceList() = withContext(Dispatchers.IO) { + client.getRepeat>( + serverUrl + sourceListQuery() + ) + } + + suspend fun getSourceInfo(sourceId: Long) = withContext(Dispatchers.IO) { + client.getRepeat( + serverUrl + sourceInfoQuery(sourceId) + ) + } + + suspend fun getSourceInfo(source: Source) = getSourceInfo(source.id) + + suspend fun getPopularManga(sourceId: Long, pageNum: Int) = withContext(Dispatchers.IO) { + client.getRepeat( + serverUrl + sourcePopularQuery(sourceId, pageNum) + ) + } + + suspend fun getPopularManga(source: Source, pageNum: Int) = getPopularManga( + source.id, pageNum + ) + + suspend fun getLatestManga(sourceId: Long, pageNum: Int) = withContext(Dispatchers.IO) { + client.getRepeat( + serverUrl + sourceLatestQuery(sourceId, pageNum) + ) + } + + suspend fun getLatestManga(source: Source, pageNum: Int) = getLatestManga( + source.id, pageNum + ) + + // TODO: 2021-03-14 + suspend fun getGlobalSearchResults(searchTerm: String) = withContext(Dispatchers.IO) { + client.getRepeat( + serverUrl + globalSearchQuery(searchTerm) + ) + } + + suspend fun getSearchResults(sourceId: Long, searchTerm: String, pageNum: Int) = withContext(Dispatchers.IO) { + client.getRepeat( + serverUrl + sourceSearchQuery(sourceId, searchTerm, pageNum) + ) + } + + suspend fun getSearchResults(source: Source, searchTerm: String, pageNum: Int) = getSearchResults( + source.id, searchTerm, pageNum + ) + + // TODO: 2021-03-14 + suspend fun getFilterList(sourceId: Long) = withContext(Dispatchers.IO) { + client.getRepeat( + serverUrl + getFilterListQuery(sourceId) + ) + } + + suspend fun getFilterList(source: Source) = getFilterList(source.id) +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/network/requests/Category.kt b/src/main/kotlin/ca/gosyer/backend/network/requests/Category.kt new file mode 100644 index 00000000..9a9171c1 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/network/requests/Category.kt @@ -0,0 +1,46 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.network.requests + +@Get +fun getMangaCategoriesQuery(mangaId: Long) = + "/api/v1/manga/$mangaId/category/" + +@Get +fun addMangaToCategoryQuery(mangaId: Long, categoryId: Long) = + "/api/v1/manga/$mangaId/category/$categoryId" + +@Delete +fun removeMangaFromCategoryRequest(mangaId: Long, categoryId: Long) = + "/api/v1/manga/$mangaId/category/$categoryId" + +@Get +fun getCategoriesQuery() = + "/api/v1/category/" + +/** + * Post a formbody with the param {name} for creation of a category + */ +@Post +fun createCategoryRequest() = + "/api/v1/category/" + +@Patch +fun categoryModifyRequest(categoryId: Long) = + "/api/v1/category/$categoryId" + +@Patch +fun categoryReorderRequest(categoryId: Long) = + "/api/v1/category/$categoryId/reorder" + +@Delete +fun categoryDeleteRequest(categoryId: Long) = + "/api/v1/category/$categoryId" + +@Get +fun getMangaInCategoryQuery(categoryId: Long) = + "/api/v1/category/$categoryId" \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/network/requests/Chapters.kt b/src/main/kotlin/ca/gosyer/backend/network/requests/Chapters.kt new file mode 100644 index 00000000..a2d4322d --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/network/requests/Chapters.kt @@ -0,0 +1,19 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.network.requests + +@Get +fun getMangaChaptersQuery(mangaId: Long) = + "/api/v1/manga/$mangaId/chapters" + +@Get +fun getChapterQuery(mangaId: Long, chapterId: Long) = + "/api/v1/manga/$mangaId/chapter/$chapterId" + +@Get +fun getPageQuery(mangaId: Long, chapterId: Long, index: Int) = + "/api/v1/manga/$mangaId/chapter/$chapterId/page/$index" \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/network/requests/Extensions.kt b/src/main/kotlin/ca/gosyer/backend/network/requests/Extensions.kt new file mode 100644 index 00000000..df7af2da --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/network/requests/Extensions.kt @@ -0,0 +1,23 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.network.requests + +@Get +fun extensionListQuery() = + "/api/v1/extension/list" + +@Get +fun apkInstallQuery(apkName: String) = + "/api/v1/extension/install/$apkName" + +@Get +fun apkUninstallQuery(apkName: String) = + "/api/v1/extension/uninstall/$apkName" + +@Get +fun apkIconQuery(apkName: String) = + "/api/v1/extension/icon/$apkName" \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/network/requests/Library.kt b/src/main/kotlin/ca/gosyer/backend/network/requests/Library.kt new file mode 100644 index 00000000..95eb7989 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/network/requests/Library.kt @@ -0,0 +1,19 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.network.requests + +@Get +fun addMangaToLibraryQuery(mangaId: Long) = + "/api/v1/manga/$mangaId/library" + +@Delete +fun removeMangaFromLibraryRequest(mangaId: Long) = + "/api/v1/manga/$mangaId/library" + +@Get +fun getLibraryQuery() = + "/api/v1/library/" \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/network/requests/Manga.kt b/src/main/kotlin/ca/gosyer/backend/network/requests/Manga.kt new file mode 100644 index 00000000..169f5be1 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/network/requests/Manga.kt @@ -0,0 +1,15 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.network.requests + +@Get +fun mangaQuery(mangaId: Long) = + "/api/v1/manga/$mangaId/" + +@Get +fun mangaThumbnailQuery(mangaId: Long) = + "/api/v1/manga/$mangaId/thumbnail" diff --git a/src/main/kotlin/ca/gosyer/backend/network/requests/RestRequests.kt b/src/main/kotlin/ca/gosyer/backend/network/requests/RestRequests.kt new file mode 100644 index 00000000..53289bf5 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/network/requests/RestRequests.kt @@ -0,0 +1,15 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.network.requests + +annotation class Get + +annotation class Post + +annotation class Delete + +annotation class Patch \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/network/requests/Sources.kt b/src/main/kotlin/ca/gosyer/backend/network/requests/Sources.kt new file mode 100644 index 00000000..fc3e0013 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/network/requests/Sources.kt @@ -0,0 +1,35 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.network.requests + +@Get +fun sourceListQuery() = + "/api/v1/source/list" + +@Get +fun sourceInfoQuery(sourceId: Long) = + "/api/v1/source/$sourceId" + +@Get +fun sourcePopularQuery(sourceId: Long, pageNum: Int) = + "/api/v1/source/$sourceId/popular/$pageNum" + +@Get +fun sourceLatestQuery(sourceId: Long, pageNum: Int) = + "/api/v1/source/$sourceId/latest/$pageNum" + +@Get +fun globalSearchQuery(searchTerm: String) = + "/api/v1/search/$searchTerm" + +@Get +fun sourceSearchQuery(sourceId: Long, searchTerm: String, pageNum: Int) = + "/api/v1/source/$sourceId/search/$searchTerm/$pageNum" + +@Get +fun getFilterListQuery(sourceId: Long) = + "/api/v1/source/$sourceId/filters/" \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/preferences/Preference.kt b/src/main/kotlin/ca/gosyer/backend/preferences/Preference.kt new file mode 100644 index 00000000..530c1e61 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/preferences/Preference.kt @@ -0,0 +1,49 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.preferences + +import androidx.compose.material.darkColors +import androidx.compose.material.lightColors +import ca.gosyer.backend.preferences.impl.getBooleanPreference +import ca.gosyer.backend.preferences.impl.getJsonPreference +import ca.gosyer.backend.preferences.impl.getLongPreference +import ca.gosyer.backend.preferences.impl.getStringPreference +import ca.gosyer.ui.library.DisplayMode +import ca.gosyer.util.compose.color +import com.russhwolf.settings.JvmPreferencesSettings +import com.russhwolf.settings.ObservableSettings +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import org.koin.dsl.module +import java.util.prefs.Preferences + +class PreferenceHelper { + private val settings = JvmPreferencesSettings(Preferences.userRoot()) as ObservableSettings + val serverUrl = settings.getStringPreference("server_url", "http://localhost:4567") + val enabledLangs = settings.getJsonPreference("server_langs", listOf("all", "en"), ListSerializer(String.serializer())) + val libraryDisplay = settings.getJsonPreference("library_display", DisplayMode.CompactGrid, DisplayMode.serializer()) + + val lightTheme = settings.getBooleanPreference("light_theme", true) + val lightPrimary = settings.getLongPreference("light_color_primary", 0xFF00a2ff) + val lightPrimaryVariant = settings.getLongPreference("light_color_primary_variant", 0xFF0091EA) + val lightSecondary = settings.getLongPreference("light_color_secondary", 0xFFF44336) + val lightSecondaryVaraint = settings.getLongPreference("light_color_secondary_variant", 0xFFE53935) + + fun getTheme() = when (lightTheme.get()) { + true -> lightColors( + lightPrimary.get().color, + lightPrimaryVariant.get().color, + lightSecondary.get().color, + lightSecondaryVaraint.get().color + ) + false -> darkColors() + } +} + +val preferencesModule = module { + single { PreferenceHelper() } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/preferences/impl/BooleanPreference.kt b/src/main/kotlin/ca/gosyer/backend/preferences/impl/BooleanPreference.kt new file mode 100644 index 00000000..db30d014 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/preferences/impl/BooleanPreference.kt @@ -0,0 +1,46 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.preferences.impl + +import com.russhwolf.settings.ObservableSettings +import com.russhwolf.settings.coroutines.getBooleanFlow +import com.russhwolf.settings.coroutines.getBooleanOrNullFlow +import com.russhwolf.settings.set + +class BooleanPreference( + override val settings: ObservableSettings, + override val key: String, + override val default: Boolean +): DefaultPreference { + override fun get() = settings.getBoolean(key, default) + override fun asFLow() = settings.getBooleanFlow(key, default) + override fun set(value: Boolean) { + settings[key] = value + } +} + +class BooleanNullPreference( + override val settings: ObservableSettings, + override val key: String, +): NullPreference { + override fun get() = settings.getBooleanOrNull(key) + override fun asFLow() = settings.getBooleanOrNullFlow(key) + override fun set(value: Boolean?) { + settings[key] = value + } +} + +fun ObservableSettings.getBooleanPreference(key: String, default: Boolean) = BooleanPreference( + this, + key, + default +) + +fun ObservableSettings.getBooleanPreference(key: String) = BooleanNullPreference( + this, + key +) \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/preferences/impl/DoublePreference.kt b/src/main/kotlin/ca/gosyer/backend/preferences/impl/DoublePreference.kt new file mode 100644 index 00000000..eb8137e7 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/preferences/impl/DoublePreference.kt @@ -0,0 +1,46 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.preferences.impl + +import com.russhwolf.settings.ObservableSettings +import com.russhwolf.settings.coroutines.getDoubleFlow +import com.russhwolf.settings.coroutines.getDoubleOrNullFlow +import com.russhwolf.settings.set + +class DoublePreference( + override val settings: ObservableSettings, + override val key: String, + override val default: Double +): DefaultPreference { + override fun get() = settings.getDouble(key, default) + override fun asFLow() = settings.getDoubleFlow(key, default) + override fun set(value: Double) { + settings[key] = value + } +} + +class DoubleNullPreference( + override val settings: ObservableSettings, + override val key: String, +): NullPreference { + override fun get() = settings.getDoubleOrNull(key) + override fun asFLow() = settings.getDoubleOrNullFlow(key) + override fun set(value: Double?) { + settings[key] = value + } +} + +fun ObservableSettings.getDoublePreference(key: String, default: Double) = DoublePreference( + this, + key, + default +) + +fun ObservableSettings.getDoublePreference(key: String) = DoubleNullPreference( + this, + key +) \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/preferences/impl/FloatPreference.kt b/src/main/kotlin/ca/gosyer/backend/preferences/impl/FloatPreference.kt new file mode 100644 index 00000000..817ed8a5 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/preferences/impl/FloatPreference.kt @@ -0,0 +1,46 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.preferences.impl + +import com.russhwolf.settings.ObservableSettings +import com.russhwolf.settings.coroutines.getFloatFlow +import com.russhwolf.settings.coroutines.getFloatOrNullFlow +import com.russhwolf.settings.set + +class FloatPreference( + override val settings: ObservableSettings, + override val key: String, + override val default: Float +): DefaultPreference { + override fun get() = settings.getFloat(key, default) + override fun asFLow() = settings.getFloatFlow(key, default) + override fun set(value: Float) { + settings[key] = value + } +} + +class FloatNullPreference( + override val settings: ObservableSettings, + override val key: String, +): NullPreference { + override fun get() = settings.getFloatOrNull(key) + override fun asFLow() = settings.getFloatOrNullFlow(key) + override fun set(value: Float?) { + settings[key] = value + } +} + +fun ObservableSettings.getFloatPreference(key: String, default: Float) = FloatPreference( + this, + key, + default +) + +fun ObservableSettings.getFloatPreference(key: String) = FloatNullPreference( + this, + key +) \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/preferences/impl/IntPreference.kt b/src/main/kotlin/ca/gosyer/backend/preferences/impl/IntPreference.kt new file mode 100644 index 00000000..af7bcd1f --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/preferences/impl/IntPreference.kt @@ -0,0 +1,46 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.preferences.impl + +import com.russhwolf.settings.ObservableSettings +import com.russhwolf.settings.coroutines.getIntFlow +import com.russhwolf.settings.coroutines.getIntOrNullFlow +import com.russhwolf.settings.set + +class IntPreference( + override val settings: ObservableSettings, + override val key: String, + override val default: Int +): DefaultPreference { + override fun get() = settings.getInt(key, default) + override fun asFLow() = settings.getIntFlow(key, default) + override fun set(value: Int) { + settings[key] = value + } +} + +class IntNullPreference( + override val settings: ObservableSettings, + override val key: String, +): NullPreference { + override fun get() = settings.getIntOrNull(key) + override fun asFLow() = settings.getIntOrNullFlow(key) + override fun set(value: Int?) { + settings[key] = value + } +} + +fun ObservableSettings.getIntPreference(key: String, default: Int) = IntPreference( + this, + key, + default +) + +fun ObservableSettings.getIntPreference(key: String) = IntNullPreference( + this, + key +) \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/preferences/impl/JsonPreference.kt b/src/main/kotlin/ca/gosyer/backend/preferences/impl/JsonPreference.kt new file mode 100644 index 00000000..67a25b70 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/preferences/impl/JsonPreference.kt @@ -0,0 +1,86 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.preferences.impl + +import com.russhwolf.settings.ObservableSettings +import com.russhwolf.settings.Settings +import com.russhwolf.settings.get +import com.russhwolf.settings.serialization.decodeValue +import com.russhwolf.settings.serialization.decodeValueOrNull +import com.russhwolf.settings.serialization.encodeValue +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.serialization.KSerializer + +class JsonPreference( + override val settings: ObservableSettings, + override val key: String, + override val default: T, + private val serializer: KSerializer +): DefaultPreference { + override fun get() = settings.decodeValue(serializer, key, default) + override fun asFLow() = settings.createFlow(key, default) { key, default -> + decodeValue(serializer, key, default) + } + override fun set(value: T) { + settings.encodeValue(serializer, key, value) + } + + fun getJson(): String? { + return settings[key] + } +} + +class JsonNullPreference( + override val settings: ObservableSettings, + override val key: String, + private val serializer: KSerializer +): NullPreference { + override fun get() = settings.decodeValueOrNull(serializer, key) + override fun asFLow() = settings.createFlow(key, null) { key, _ -> + decodeValueOrNull(serializer, key) + } + override fun set(value: T?) { + if (value != null) { + settings.encodeValue(serializer, key, value) + } else { + settings.remove(key) + } + } + + fun getJson(): String? { + return settings[key] + } +} + +fun ObservableSettings.getJsonPreference(key: String, default: T, serializer: KSerializer) = JsonPreference( + this, + key, + default, + serializer +) + +fun ObservableSettings.getJsonPreference(key: String, serializer: KSerializer) = JsonNullPreference( + this, + key, + serializer +) + +private inline fun ObservableSettings.createFlow( + key: String, + defaultValue: T, + crossinline getter: Settings.(String, T) -> T +): Flow = callbackFlow { + offer(getter(key, defaultValue)) + val listener = addListener(key) { + offer(getter(key, defaultValue)) + } + awaitClose { + listener.deactivate() + } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/preferences/impl/LongPreference.kt b/src/main/kotlin/ca/gosyer/backend/preferences/impl/LongPreference.kt new file mode 100644 index 00000000..37c7f129 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/preferences/impl/LongPreference.kt @@ -0,0 +1,46 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.preferences.impl + +import com.russhwolf.settings.ObservableSettings +import com.russhwolf.settings.coroutines.getLongFlow +import com.russhwolf.settings.coroutines.getLongOrNullFlow +import com.russhwolf.settings.set + +class LongPreference( + override val settings: ObservableSettings, + override val key: String, + override val default: Long +): DefaultPreference { + override fun get() = settings.getLong(key, default) + override fun asFLow() = settings.getLongFlow(key, default) + override fun set(value: Long) { + settings[key] = value + } +} + +class LongNullPreference( + override val settings: ObservableSettings, + override val key: String, +): NullPreference { + override fun get() = settings.getLongOrNull(key) + override fun asFLow() = settings.getLongOrNullFlow(key) + override fun set(value: Long?) { + settings[key] = value + } +} + +fun ObservableSettings.getLongPreference(key: String, default: Long) = LongPreference( + this, + key, + default +) + +fun ObservableSettings.getLongPreference(key: String) = LongNullPreference( + this, + key +) \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/preferences/impl/Preference.kt b/src/main/kotlin/ca/gosyer/backend/preferences/impl/Preference.kt new file mode 100644 index 00000000..36cfd12e --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/preferences/impl/Preference.kt @@ -0,0 +1,78 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.preferences.impl + +import ca.gosyer.util.system.asStateFlow +import com.russhwolf.settings.ObservableSettings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +interface Preference { + /** + * The settings to watch for this preference. Must be a instance of [ObservableSettings] + * so that we can watch changes + */ + val settings: ObservableSettings + + /** + * The key for this preference + */ + val key: String +} + + +interface NullPreference : Preference { + /** + * Returns the value stored at [key] as [T], or `null` if no value was stored. If a value of a different + * type was stored at `key`, the behavior is not defined. + */ + fun get(): T? + + /** + * Create a new [Flow], based on observing the given [key]. This flow will immediately emit the + * current value and then emit any subsequent values when the underlying `Settings` changes. When no value is present, + * `null` will be emitted instead. + */ + fun asFLow(): Flow + + /** + * See [asFLow], this function is equilivent to that except in that it stores the latest value instead of emitting + */ + fun asStateFlow(scope: CoroutineScope): StateFlow = asFLow().asStateFlow(get(), scope, true) + + /** + * Stores a [T] value at [key], or remove what's there if [value] is null. + */ + fun set(value: T?) +} + +interface DefaultPreference : Preference { + val default: T + + /** + * Returns the value stored at [key] as [T], or [default] if no value was stored. If a value of a different + * type was stored at `key`, the behavior is not defined. + */ + fun get(): T + + /** + * Create a new [Flow], based on observing the given [key]. This flow will immediately emit the + * current value and then emit any subsequent values when the underlying `Settings` changes. When no value is present, + * [default] will be emitted instead. + */ + fun asFLow(): Flow + + /** + * See [asFLow], this function is equilivent to that except in that it stores the latest value instead of emitting + */ + fun asStateFlow(scope: CoroutineScope): StateFlow = asFLow().asStateFlow(get(), scope, true) + /** + * Stores the [T] [value] at [key]. + */ + fun set(value: T) +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/backend/preferences/impl/StringPreference.kt b/src/main/kotlin/ca/gosyer/backend/preferences/impl/StringPreference.kt new file mode 100644 index 00000000..49e26364 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/backend/preferences/impl/StringPreference.kt @@ -0,0 +1,46 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.backend.preferences.impl + +import com.russhwolf.settings.ObservableSettings +import com.russhwolf.settings.coroutines.getStringFlow +import com.russhwolf.settings.coroutines.getStringOrNullFlow +import com.russhwolf.settings.set + +class StringPreference( + override val settings: ObservableSettings, + override val key: String, + override val default: String +): DefaultPreference { + override fun get() = settings.getString(key, default) + override fun asFLow() = settings.getStringFlow(key, default) + override fun set(value: String) { + settings[key] = value + } +} + +class StringNullPreference( + override val settings: ObservableSettings, + override val key: String, +): NullPreference { + override fun get() = settings.getStringOrNull(key) + override fun asFLow() = settings.getStringOrNullFlow(key) + override fun set(value: String?) { + settings[key] = value + } +} + +fun ObservableSettings.getStringPreference(key: String, default: String) = StringPreference( + this, + key, + default +) + +fun ObservableSettings.getStringPreference(key: String) = StringNullPreference( + this, + key +) \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/base/WindowDialog.kt b/src/main/kotlin/ca/gosyer/ui/base/WindowDialog.kt new file mode 100644 index 00000000..a721352d --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/base/WindowDialog.kt @@ -0,0 +1,89 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base + +import androidx.compose.desktop.AppWindow +import androidx.compose.desktop.WindowEvents +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import javax.swing.SwingUtilities + +@Suppress("FunctionName") +fun WindowDialog( + title: String = "Dialog", + size: IntSize = IntSize(400, 200), + onDismissRequest: (() -> Unit)? = null, + forceFocus: Boolean = true, + showNegativeButton: Boolean = true, + negativeButtonText: String = "Cancel", + onNegativeButton: (() -> Unit)? = null, + positiveButtonText: String = "OK", + onPositiveButton: (() -> Unit)? = null, + row: @Composable (RowScope.() -> Unit) +) = SwingUtilities.invokeLater { + val window = AppWindow( + title = title, + size = size, + location = IntOffset.Zero, + centered = true, + icon = null, + menuBar = null, + undecorated = false, + events = WindowEvents(), + onDismissRequest = onDismissRequest + ) + + if (forceFocus) { + window.events.onFocusLost = { + window.window.requestFocus() + } + } + + fun (() -> Unit)?.plusClose(): (() -> Unit) = { + this?.invoke() + window.close() + } + + window.keyboard.setShortcut(Key.Enter, onPositiveButton.plusClose()) + window.keyboard.setShortcut(Key.Escape, onNegativeButton.plusClose()) + + window.show { + MaterialTheme { + Surface { + Column(verticalArrangement = Arrangement.Bottom, modifier = Modifier.fillMaxSize()) { + Row(content = row, modifier = Modifier.fillMaxSize().weight(1F)) + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxSize().weight(2F)) { + if (showNegativeButton) { + OutlinedButton(onNegativeButton.plusClose(), modifier = Modifier.padding(end = 8.dp, bottom = 8.dp)) { + Text(negativeButtonText) + } + } + + OutlinedButton(onPositiveButton.plusClose(), modifier = Modifier.padding(end = 8.dp, bottom = 8.dp)) { + Text(positiveButtonText) + } + } + } + } + } + } +} diff --git a/src/main/kotlin/ca/gosyer/ui/base/components/ErrorScreen.kt b/src/main/kotlin/ca/gosyer/ui/base/components/ErrorScreen.kt new file mode 100644 index 00000000..265ee280 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/base/components/ErrorScreen.kt @@ -0,0 +1,44 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.sp +import kotlin.random.Random + + +@Composable +fun ErrorScreen(errorMessage: String? = null) { + Box(Modifier.fillMaxSize()) { + Column(modifier = Modifier.align(Alignment.Center)) { + Text(getRandomErrorFace(), fontSize = 36.sp, color = MaterialTheme.colors.onBackground) + if (errorMessage != null) { + Text(errorMessage, color = MaterialTheme.colors.onBackground) + } + } + } +} + +private val ERROR_FACES = arrayOf( + "(・o・;)", + "Σ(ಠ_ಠ)", + "ಥ_ಥ", + "(˘・_・˘)", + "(; ̄Д ̄)", + "(・Д・。" +) + +fun getRandomErrorFace(): String { + return ERROR_FACES[Random.nextInt(ERROR_FACES.size)] +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/base/components/KtorImage.kt b/src/main/kotlin/ca/gosyer/ui/base/components/KtorImage.kt new file mode 100644 index 00000000..def59e9e --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/base/components/KtorImage.kt @@ -0,0 +1,93 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.DefaultAlpha +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale +import ca.gosyer.util.compose.imageFromUrl +import io.ktor.client.HttpClient +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +@Composable +fun KtorImage( + client: HttpClient, + imageUrl: String, + imageModifier: Modifier = Modifier.fillMaxSize(), + loadingModifier: Modifier = imageModifier, + contentDescription: String? = null, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + retries: Int = 3 +) { + BoxWithConstraints { + val drawable: MutableState = remember { mutableStateOf(null) } + val loading: MutableState = remember { mutableStateOf(true) } + DisposableEffect(imageUrl) { + val handler = CoroutineExceptionHandler { _, _ -> + loading.value = false + } + val job = GlobalScope.launch(handler) { + if (drawable.value == null) { + drawable.value = getImage(client, imageUrl, retries) + } + loading.value = false + } + + onDispose { + job.cancel() + drawable.value = null + } + } + + val value = drawable.value + if (value != null) { + Image( + value, + modifier = imageModifier, + contentDescription = contentDescription, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter + ) + } else { + LoadingScreen(loading.value, loadingModifier) + } + } +} + +private suspend fun getImage(client: HttpClient, imageUrl: String, retries: Int = 3): ImageBitmap { + var attempt = 1 + var lastException: Exception + do { + try { + return imageFromUrl(client, imageUrl) + } catch (e: Exception) { + if (e is CancellationException) throw e + lastException = e + } + attempt++ + } while (attempt <= retries) + throw lastException +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/base/components/LoadingScreen.kt b/src/main/kotlin/ca/gosyer/ui/base/components/LoadingScreen.kt new file mode 100644 index 00000000..0bdd04e8 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/base/components/LoadingScreen.kt @@ -0,0 +1,31 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base.components + +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.min + +@Composable +fun LoadingScreen( + isLoading: Boolean = true, + modifier: Modifier = Modifier.fillMaxSize(), + errorMessage: String? = null +) { + BoxWithConstraints(modifier) { + if (isLoading) { + CircularProgressIndicator(Modifier.align(Alignment.Center).size(min(maxHeight, maxWidth) / 2)) + } else { + ErrorScreen(errorMessage) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/base/components/Manga.kt b/src/main/kotlin/ca/gosyer/ui/base/components/Manga.kt new file mode 100644 index 00000000..91be7e09 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/base/components/Manga.kt @@ -0,0 +1,85 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ca.gosyer.util.system.get + +@Composable +fun MangaGridItem( + title: String, + cover: String?, + onClick: () -> Unit = {}, +) { + val fontStyle = LocalTextStyle.current.merge( + TextStyle(letterSpacing = 0.sp, fontFamily = FontFamily.SansSerif, fontSize = 14.sp) + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(mangaAspectRatio) + .padding(4.dp) + .clickable(onClick = onClick), + elevation = 4.dp, + shape = RoundedCornerShape(4.dp) + ) { + Box(modifier = Modifier.fillMaxSize()) { + if (cover != null) { + KtorImage(get(), cover, contentScale = ContentScale.Crop) + } + Box(modifier = Modifier.fillMaxSize().then(shadowGradient)) + Text( + text = title, + color = Color.White, + style = fontStyle, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.wrapContentHeight(Alignment.CenterVertically) + .align(Alignment.BottomStart) + .padding(8.dp), + ) + } + } +} + +const val mangaAspectRatio = 12.8F / 18.2F + +private val shadowGradient = Modifier.drawWithCache { + val gradient = Brush.linearGradient( + 0.75f to Color.Transparent, + 1.0f to Color(0xAA000000), + start = Offset(0f, 0f), + end = Offset(0f, size.height) + ) + onDrawBehind { + drawRect(gradient) + } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/base/components/Pager.kt b/src/main/kotlin/ca/gosyer/ui/base/components/Pager.kt new file mode 100644 index 00000000..129bf49f --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/base/components/Pager.kt @@ -0,0 +1,217 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.structuralEqualityPolicy +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.ParentDataModifier +import androidx.compose.ui.unit.Density +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +/** + * This is a modified version of: + * https://gist.github.com/adamp/07d468f4bcfe632670f305ce3734f511 + */ + +class PagerState( + currentPage: Int = 0, + minPage: Int = 0, + maxPage: Int = 0 +) { + private var _minPage by mutableStateOf(minPage) + var minPage: Int + get() = _minPage + set(value) { + _minPage = value.coerceAtMost(_maxPage) + _currentPage = _currentPage.coerceIn(_minPage, _maxPage) + } + + private var _maxPage by mutableStateOf(maxPage, structuralEqualityPolicy()) + var maxPage: Int + get() = _maxPage + set(value) { + _maxPage = value.coerceAtLeast(_minPage) + _currentPage = _currentPage.coerceIn(_minPage, maxPage) + } + + private var _currentPage by mutableStateOf(currentPage.coerceIn(minPage, maxPage)) + var currentPage: Int + get() = _currentPage + set(value) { + _currentPage = value.coerceIn(minPage, maxPage) + } + + enum class SelectionState { Selected, Undecided } + + var selectionState by mutableStateOf(SelectionState.Selected) + + suspend inline fun selectPage(block: PagerState.() -> R): R = try { + selectionState = SelectionState.Undecided + block() + } finally { + selectPage() + } + + suspend fun selectPage() { + currentPage -= currentPageOffset.roundToInt() + snapToOffset(0f) + selectionState = SelectionState.Selected + } + + private var _currentPageOffset = Animatable(0f).apply { + updateBounds(-1f, 1f) + } + val currentPageOffset: Float + get() = _currentPageOffset.value + + suspend fun snapToOffset(offset: Float) { + val max = if (currentPage == minPage) 0f else 1f + val min = if (currentPage == maxPage) 0f else -1f + _currentPageOffset.snapTo(offset.coerceIn(min, max)) + } + + suspend fun fling(velocity: Float) { + if (velocity < 0 && currentPage == maxPage) return + if (velocity > 0 && currentPage == minPage) return + + _currentPageOffset.animateTo(currentPageOffset.roundToInt().toFloat()) + selectPage() + } + + override fun toString(): String = "PagerState{minPage=$minPage, maxPage=$maxPage, " + + "currentPage=$currentPage, currentPageOffset=$currentPageOffset}" +} + +@Immutable +private data class PageData(val page: Int) : ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any = this@PageData +} + +private val Measurable.page: Int + get() = (parentData as? PageData)?.page ?: error("no PageData for measurable $this") + +@Composable +fun Pager( + state: PagerState, + modifier: Modifier = Modifier, + offscreenLimit: Int = 2, + pageContent: @Composable PagerScope.() -> Unit +) { + var pageSize by remember { mutableStateOf(0) } + val coroutineScope = rememberCoroutineScope() + Layout( + content = { + val minPage = (state.currentPage - offscreenLimit).coerceAtLeast(state.minPage) + val maxPage = (state.currentPage + offscreenLimit).coerceAtMost(state.maxPage) + + for (page in minPage..maxPage) { + val pageData = PageData(page) + val scope = PagerScope(state, page) + key(pageData) { + Box(contentAlignment = Alignment.Center, modifier = pageData) { + scope.pageContent() + } + } + } + }, + modifier = modifier.draggable( + orientation = Orientation.Horizontal, + onDragStarted = { + state.selectionState = PagerState.SelectionState.Undecided + }, + onDragStopped = { velocity -> + coroutineScope.launch { + // Velocity is in pixels per second, but we deal in percentage offsets, so we + // need to scale the velocity to match + state.fling(velocity / pageSize) + } + }, + state = rememberDraggableState { dy -> + coroutineScope.launch { + with(state) { + val pos = pageSize * currentPageOffset + val max = if (currentPage == minPage) 0 else pageSize * offscreenLimit + val min = if (currentPage == maxPage) 0 else -pageSize * offscreenLimit + val newPos = (pos + dy).coerceIn(min.toFloat(), max.toFloat()) + snapToOffset(newPos / pageSize) + } + } + }, + ) + ) { measurables, constraints -> + layout(constraints.maxWidth, constraints.maxHeight) { + val currentPage = state.currentPage + val offset = state.currentPageOffset + val childConstraints = constraints.copy(minWidth = 0, minHeight = 0) + + measurables + .map { + it.measure(childConstraints) to it.page + } + .forEach { (placeable, page) -> + // TODO: current this centers each page. We should investigate reading + // gravity modifiers on the child, or maybe as a param to Pager. + val xCenterOffset = (constraints.maxWidth - placeable.width) / 2 + val yCenterOffset = (constraints.maxHeight - placeable.height) / 2 + + if (currentPage == page) { + pageSize = placeable.width + } + + val xItemOffset = ((page + offset - currentPage) * placeable.width).roundToInt() + + placeable.place( + x = xCenterOffset + xItemOffset, + y = yCenterOffset + ) + } + } + } +} + +/** + * Scope for [Pager] content. + */ +class PagerScope( + private val state: PagerState, + val page: Int +) { + /** + * Returns the current selected page + */ + val currentPage: Int + get() = state.currentPage + + /** + * Returns the current selected page offset + */ + val currentPageOffset: Float + get() = state.currentPageOffset + + /** + * Returns the current selection state + */ + val selectionState: PagerState.SelectionState + get() = state.selectionState +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/base/vm/ComposeViewModel.kt b/src/main/kotlin/ca/gosyer/ui/base/vm/ComposeViewModel.kt new file mode 100644 index 00000000..870ef08a --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/base/vm/ComposeViewModel.kt @@ -0,0 +1,25 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base.vm + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import org.koin.core.context.GlobalContext + +@Composable +inline fun composeViewModel(): VM { + val viewModel = remember { + GlobalContext.get().get() + } + DisposableEffect(viewModel) { + onDispose { + viewModel.destroy() + } + } + return viewModel +} diff --git a/src/main/kotlin/ca/gosyer/ui/base/vm/ViewModel.kt b/src/main/kotlin/ca/gosyer/ui/base/vm/ViewModel.kt new file mode 100644 index 00000000..9f136dfa --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/base/vm/ViewModel.kt @@ -0,0 +1,22 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base.vm + +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel + +abstract class ViewModel { + + protected val scope = MainScope() + + fun destroy() { + scope.cancel() + onDestroy() + } + + open fun onDestroy() {} +} diff --git a/src/main/kotlin/ca/gosyer/ui/base/vm/ViewModelModule.kt b/src/main/kotlin/ca/gosyer/ui/base/vm/ViewModelModule.kt new file mode 100644 index 00000000..fc4a24ad --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/base/vm/ViewModelModule.kt @@ -0,0 +1,26 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base.vm + +import ca.gosyer.ui.categories.CategoriesMenuViewModel +import ca.gosyer.ui.extensions.ExtensionsMenuViewModel +import ca.gosyer.ui.library.LibraryScreenViewModel +import ca.gosyer.ui.main.MainViewModel +import ca.gosyer.ui.manga.MangaMenuViewModel +import ca.gosyer.ui.sources.SourcesMenuViewModel +import ca.gosyer.ui.sources.components.SourceScreenViewModel +import org.koin.dsl.module + +val viewModelModule = module { + factory { MainViewModel() } + factory { ExtensionsMenuViewModel() } + factory { SourcesMenuViewModel() } + factory { SourceScreenViewModel() } + factory { MangaMenuViewModel() } + factory { LibraryScreenViewModel() } + factory { CategoriesMenuViewModel() } +} diff --git a/src/main/kotlin/ca/gosyer/ui/categories/CategoriesDialogs.kt b/src/main/kotlin/ca/gosyer/ui/categories/CategoriesDialogs.kt new file mode 100644 index 00000000..824c3b00 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/categories/CategoriesDialogs.kt @@ -0,0 +1,80 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.categories + +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.text.input.TextFieldValue +import ca.gosyer.ui.base.WindowDialog +import kotlinx.coroutines.flow.MutableStateFlow + +fun openRenameDialog( + category: CategoriesMenuViewModel.MenuCategory, + onRename: (String) -> Unit +) { + val newName = MutableStateFlow(TextFieldValue(category.name)) + + WindowDialog( + title = "TachideskJUI - Categories - Rename Dialog", + positiveButtonText = "Rename", + onPositiveButton = { + if (newName.value.text != category.name) { + onRename(newName.value.text) + } + } + ) { + val newNameState by newName.collectAsState() + + TextField( + newNameState, + onValueChange = { + newName.value = it + } + ) + } +} + +fun openDeleteDialog( + category: CategoriesMenuViewModel.MenuCategory, + onDelete: (CategoriesMenuViewModel.MenuCategory) -> Unit +) { + WindowDialog( + title = "TachideskJUI - Categories - Delete Dialog", + positiveButtonText = "Yes", + onPositiveButton = { + onDelete(category) + }, + negativeButtonText = "No" + ) { + Text("Do you wish to delete the category ${category.name}?") + } +} + +fun openCreateDialog( + onCreate: (String) -> Unit +) { + val name = MutableStateFlow(TextFieldValue("")) + + WindowDialog( + title = "TachideskJUI - Categories - Create Dialog", + positiveButtonText = "Create", + onPositiveButton = { + onCreate(name.value.text) + } + ) { + val nameState by name.collectAsState() + + TextField( + nameState, + onValueChange = { + name.value = it + } + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/categories/CategoriesMenu.kt b/src/main/kotlin/ca/gosyer/ui/categories/CategoriesMenu.kt new file mode 100644 index 00000000..351ad45d --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/categories/CategoriesMenu.kt @@ -0,0 +1,156 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.categories + +import androidx.compose.desktop.WindowEvents +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.Card +import androidx.compose.material.ContentAlpha +import androidx.compose.material.ExtendedFloatingActionButton +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.outlined.List +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import ca.gosyer.ui.base.vm.composeViewModel +import ca.gosyer.util.compose.ThemedWindow + +fun openCategoriesMenu() { + val windowEvents = WindowEvents() + ThemedWindow("TachideskJUI - Categories", events = windowEvents) { + CategoriesMenu(windowEvents) + } +} + +@Composable +fun CategoriesMenu(windowEvents: WindowEvents) { + val vm = composeViewModel() + val categories by vm.categories.collectAsState() + remember { + windowEvents.onClose = { vm.updateCategories() } + } + + Box { + LazyColumn(modifier = Modifier.fillMaxSize()) { + itemsIndexed(categories) { i, category -> + CategoryRow( + category = category, + moveUpEnabled = i != 0, + moveDownEnabled = i != categories.lastIndex, + onMoveUp = { vm.moveUp(category) }, + onMoveDown = { vm.moveDown(category) }, + onRename = { + openRenameDialog(category) { + vm.renameCategory(category, it) + } + }, + onDelete = { + openDeleteDialog(category) { + vm.deleteCategory(category) + } + }, + ) + } + } + ExtendedFloatingActionButton( + text = { Text(text = "Add") }, + icon = { Icon(imageVector = Icons.Default.Add, contentDescription = null) }, + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), + onClick = { + openCreateDialog { + vm.createCategory(it) + } + } + ) + } +} + +@Composable +private fun CategoryRow( + category: CategoriesMenuViewModel.MenuCategory, + moveUpEnabled: Boolean = true, + moveDownEnabled: Boolean = true, + onMoveUp: () -> Unit = {}, + onMoveDown: () -> Unit = {}, + onRename: () -> Unit = {}, + onDelete: () -> Unit = {}, +) { + Card(Modifier.padding(8.dp)) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Outlined.List, + modifier = Modifier.padding(16.dp), + tint = MaterialTheme.colors.primary, + contentDescription = null, + ) + Text( + text = category.name, + modifier = Modifier.weight(1f).padding(end = 16.dp) + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { + val enabledColor = LocalContentColor.current + val disabledColor = enabledColor.copy(ContentAlpha.disabled) + IconButton( + onClick = onMoveUp, + enabled = moveUpEnabled + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowUp, + tint = if (moveUpEnabled) enabledColor else disabledColor, + contentDescription = null + ) + } + IconButton( + onClick = onMoveDown, + enabled = moveDownEnabled + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + tint = if (moveDownEnabled) enabledColor else disabledColor, + contentDescription = null + ) + } + Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = onRename) { + Icon(imageVector = Icons.Default.Edit, + contentDescription = null) + } + IconButton(onClick = onDelete) { + Icon(imageVector = Icons.Default.Delete, + contentDescription = null) + } + } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/categories/CategoriesMenuViewModel.kt b/src/main/kotlin/ca/gosyer/ui/categories/CategoriesMenuViewModel.kt new file mode 100644 index 00000000..1ff1ce25 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/categories/CategoriesMenuViewModel.kt @@ -0,0 +1,119 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.categories + +import ca.gosyer.backend.models.Category +import ca.gosyer.backend.network.interactions.CategoryInteractionHandler +import ca.gosyer.ui.base.vm.ViewModel +import ca.gosyer.util.system.inject +import io.ktor.client.HttpClient +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import mu.KotlinLogging + +class CategoriesMenuViewModel : ViewModel() { + private val httpClient: HttpClient by inject() + private val logger = KotlinLogging.logger {} + private var originalCategories = emptyList() + private val _categories = MutableStateFlow(emptyList()) + val categories = _categories.asStateFlow() + + private val _isLoading = MutableStateFlow(true) + val isLoading = _isLoading.asStateFlow() + + init { + getCategories() + } + + fun getCategories() { + scope.launch { + _categories.value = emptyList() + _isLoading.value = true + try { + _categories.value = CategoryInteractionHandler(httpClient).getCategories() + .sortedBy { it.order } + .also { originalCategories = it } + .map { it.toMenuCategory() } + } catch (e: Exception) { + if (e is CancellationException) throw e + } finally { + _isLoading.value = false + } + } + } + + fun updateCategories(manualUpdate: Boolean = false) { + val handler = CoroutineExceptionHandler { _, throwable -> + logger.debug { throwable } + } + GlobalScope.launch(handler) { + val categories = _categories.value + val newCategories = categories.filter { it.id == null } + newCategories.forEach { + CategoryInteractionHandler(httpClient).createCategory(it.name) + } + originalCategories.forEach { originalCategory -> + val category = categories.find { it.id == originalCategory.id } + if (category == null) { + CategoryInteractionHandler(httpClient).deleteCategory(originalCategory.id) + } else if (category.name != originalCategory.name) { + CategoryInteractionHandler(httpClient).modifyCategory(originalCategory.id, category.name) + } + } + val updatedCategories = CategoryInteractionHandler(httpClient).getCategories() + updatedCategories.forEach { updatedCategory -> + val category = categories.find { it.id == updatedCategory.id || it.name == updatedCategory.name } ?: return@forEach + if (category.order != updatedCategory.order) { + logger.debug { category.order.toString() + " to " + updatedCategory.order.toString() } + CategoryInteractionHandler(httpClient).reorderCategory(updatedCategory.id, category.order, updatedCategory.order) + } + } + + if (manualUpdate) { + getCategories() + } + } + } + + fun renameCategory(category: MenuCategory, newName: String) { + _categories.value = (_categories.value - category + category.copy(name = newName)).sortedBy { it.order } + } + + fun deleteCategory(category: MenuCategory) { + _categories.value = _categories.value - category + } + + fun createCategory(name: String) { + _categories.value += MenuCategory(order = categories.value.size, name = name, landing = false) + } + + fun moveUp(category: MenuCategory) { + val categories = _categories.value + val index = categories.indexOf(category) + if (index == -1) throw Exception("Invalid index") + categories[index].order = category.order - 1 + categories[index - 1].order = category.order + 1 + _categories.value = categories.sortedBy { it.order } + } + + fun moveDown(category: MenuCategory) { + val categories = _categories.value + val index = categories.indexOf(category) + if (index == -1) throw Exception("Invalid index") + categories[index].order = category.order + 1 + categories[index + 1].order = category.order - 1 + _categories.value = categories.sortedBy { it.order } + } + + fun Category.toMenuCategory() = MenuCategory(id, order, name, landing) + + data class MenuCategory(val id: Long? = null, var order: Int, val name: String, val landing: Boolean = false) +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenu.kt b/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenu.kt new file mode 100644 index 00000000..a83f23cd --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenu.kt @@ -0,0 +1,138 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.extensions + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.material.Button +import androidx.compose.material.ContentAlpha +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ca.gosyer.backend.models.Extension +import ca.gosyer.ui.base.components.KtorImage +import ca.gosyer.ui.base.components.LoadingScreen +import ca.gosyer.ui.base.vm.composeViewModel +import ca.gosyer.util.compose.ThemedWindow +import ca.gosyer.util.system.get + +fun openExtensionsMenu() { + ThemedWindow(title = "TachideskJUI - Extensions", size = IntSize(550, 700)) { + ExtensionsMenu() + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ExtensionsMenu() { + val vm = composeViewModel() + val extensions by vm.extensions.collectAsState() + val isLoading by vm.isLoading.collectAsState() + val serverUrl by vm.serverUrl.collectAsState() + + Box(Modifier.fillMaxSize().background(MaterialTheme.colors.background)) { + if (extensions.isEmpty()) { + LoadingScreen(isLoading) + } else { + val state = rememberLazyListState() + val itemCount = extensions.size + + Box(Modifier.fillMaxSize()) { + LazyColumn(Modifier.fillMaxSize().padding(end = 12.dp), state) { + items(extensions) { extension -> + ExtensionItem( + extension, + serverUrl, + onInstallClicked = { + vm.install(it) + }, + onUninstallClicked = { + vm.uninstall(it) + } + ) + Spacer(Modifier.height(8.dp)) + } + } + VerticalScrollbar( + modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), + adapter = rememberScrollbarAdapter( + scrollState = state, + itemCount = itemCount, + averageItemSize = 37.dp // TextBox height + Spacer height + ) + ) + } + } + } +} + +@Composable +fun ExtensionItem( + extension: Extension, + serverUrl: String, + onInstallClicked: (Extension) -> Unit, + onUninstallClicked: (Extension) -> Unit +) { + Box(modifier = Modifier.fillMaxWidth().height(64.dp).background(MaterialTheme.colors.background)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.width(4.dp)) + KtorImage(get(), extension.iconUrl(serverUrl), Modifier.size(60.dp)) + Spacer(Modifier.width(8.dp)) + Column { + val title = buildAnnotatedString { + append("${extension.name} ") + val mediumColor = MaterialTheme.colors.onBackground.copy(alpha = ContentAlpha.medium) + withStyle(SpanStyle(fontSize = 12.sp, color = mediumColor)) { append("v${extension.versionName}") } + } + Text(title, fontSize = 26.sp, color = MaterialTheme.colors.onBackground) + Row { + Text(extension.lang.toUpperCase(), fontSize = 14.sp, color = MaterialTheme.colors.onBackground) + if (extension.nsfw) { + Spacer(Modifier.width(4.dp)) + Text("18+", fontSize = 14.sp, color = Color.Red) + } + } + } + + } + Button( + { + if (extension.installed) onUninstallClicked(extension) else onInstallClicked(extension) + }, + modifier = Modifier.align(Alignment.CenterEnd) + ) { + Text(if (extension.installed) "Uninstall" else "Install") + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenuViewModel.kt b/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenuViewModel.kt new file mode 100644 index 00000000..93109b77 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenuViewModel.kt @@ -0,0 +1,77 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.extensions + +import ca.gosyer.backend.models.Extension +import ca.gosyer.backend.network.interactions.ExtensionInteractionHandler +import ca.gosyer.backend.preferences.PreferenceHelper +import ca.gosyer.ui.base.vm.ViewModel +import ca.gosyer.util.system.inject +import io.ktor.client.HttpClient +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import mu.KotlinLogging + +class ExtensionsMenuViewModel: ViewModel() { + private val preferences: PreferenceHelper by inject() + private val httpClient: HttpClient by inject() + private val logger = KotlinLogging.logger {} + + val serverUrl = preferences.serverUrl.asStateFlow(scope) + + private val _extensions = MutableStateFlow(emptyList()) + val extensions = _extensions.asStateFlow() + + private val _isLoading = MutableStateFlow(true) + val isLoading = _isLoading.asStateFlow() + + + init { + scope.launch { + getExtensions() + } + } + + private suspend fun getExtensions() { + try { + _isLoading.value = true + val enabledLangs = preferences.enabledLangs.get() + val extensions = ExtensionInteractionHandler(httpClient).getExtensionList() + _extensions.value = extensions.filter { it.lang in enabledLangs }.sortedWith(compareBy({ it.lang }, { it.pkgName })) + } catch (e: Exception) { + if (e is CancellationException) throw e + } finally { + _isLoading.value = false + } + } + + fun install(extension: Extension) { + logger.info { "Install clicked" } + scope.launch { + try { + ExtensionInteractionHandler(httpClient).installExtension(extension) + } catch (e: Exception) { + if (e is CancellationException) throw e + } + getExtensions() + } + } + + fun uninstall(extension: Extension) { + logger.info { "Uninstall clicked" } + scope.launch { + try { + ExtensionInteractionHandler(httpClient).uninstallExtension(extension) + } catch (e: Exception) { + if (e is CancellationException) throw e + } + getExtensions() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/library/LibraryMangaBadges.kt b/src/main/kotlin/ca/gosyer/ui/library/LibraryMangaBadges.kt new file mode 100644 index 00000000..b0ea5d34 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/library/LibraryMangaBadges.kt @@ -0,0 +1,47 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.library + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp + +@Composable +fun LibraryMangaBadges( + unread: Int?, + downloaded: Int?, + modifier: Modifier = Modifier, +) { + if (unread == null && downloaded == null) return + + Row(modifier = modifier.clip(MaterialTheme.shapes.medium)) { + if (unread != null && unread > 0) { + Text( + text = unread.toString(), + modifier = Modifier.background(MaterialTheme.colors.primary).then(BadgesInnerPadding), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onPrimary + ) + } + if (downloaded != null && downloaded > 0) { + Text( + text = downloaded.toString(), + modifier = Modifier.background(MaterialTheme.colors.secondary).then(BadgesInnerPadding), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSecondary + ) + } + } +} + +private val BadgesInnerPadding = Modifier.padding(horizontal = 3.dp, vertical = 1.dp) \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/library/LibraryScreen.kt b/src/main/kotlin/ca/gosyer/ui/library/LibraryScreen.kt new file mode 100644 index 00000000..8ec57328 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/library/LibraryScreen.kt @@ -0,0 +1,182 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.library + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ScrollableTabRow +import androidx.compose.material.Tab +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.unit.dp +import ca.gosyer.backend.models.Category +import ca.gosyer.backend.models.Manga +import ca.gosyer.ui.base.components.LoadingScreen +import ca.gosyer.ui.base.components.Pager +import ca.gosyer.ui.base.components.PagerState +import ca.gosyer.ui.base.vm.composeViewModel +import ca.gosyer.ui.manga.openMangaMenu +import ca.gosyer.util.compose.ThemedWindow +import kotlinx.serialization.Serializable + +fun openLibraryMenu() { + ThemedWindow { + LibraryScreen() + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun LibraryScreen() { + val vm = composeViewModel() + val categories by vm.categories.collectAsState() + val selectedCategoryIndex by vm.selectedCategoryIndex.collectAsState() + val displayMode by vm.displayMode.collectAsState() + val isLoading by vm.isLoading.collectAsState() + val serverUrl by vm.serverUrl.collectAsState() + //val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) + + if (categories.isEmpty()) { + LoadingScreen(isLoading) + } else { + + /*ModalBottomSheetLayout( + sheetState = sheetState, + sheetContent = { *//*LibrarySheet()*//* } + ) {*/ + Column { + /*Toolbar( + title = { + val text = if (vm.showCategoryTabs) { + stringResource(R.string.library_label) + } else { + vm.selectedCategory?.visibleName.orEmpty() + } + Text(text) + }, + actions = { + IconButton(onClick = { scope.launch { sheetState.show() }}) { + Icon(Icons.Default.FilterList, contentDescription = null) + } + } + )*/ + LibraryTabs( + visible = true, //vm.showCategoryTabs, + categories = categories, + selectedPage = selectedCategoryIndex, + onPageChanged = vm::setSelectedPage + ) + LibraryPager( + categories = categories, + displayMode = displayMode, + selectedPage = selectedCategoryIndex, + serverUrl = serverUrl, + getLibraryForPage = { vm.getLibraryForCategoryIndex(it).collectAsState() }, + onPageChanged = { vm.setSelectedPage(it) }, + onClickManga = ::openMangaMenu + ) + } + // } + } +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +private fun LibraryTabs( + visible: Boolean, + categories: List, + selectedPage: Int, + onPageChanged: (Int) -> Unit +) { + if (categories.isEmpty()) return + + AnimatedVisibility( + visible = visible, + enter = expandVertically(), + exit = shrinkVertically() + ) { + ScrollableTabRow( + selectedTabIndex = selectedPage, + // backgroundColor = CustomColors.current.bars, + // contentColor = CustomColors.current.onBars, + edgePadding = 0.dp + ) { + categories.forEachIndexed { i, category -> + Tab( + selected = selectedPage == i, + onClick = { onPageChanged(i) }, + text = { Text(category.name) } + ) + } + } + } +} + +@Composable +private fun LibraryPager( + categories: List, + displayMode: DisplayMode, + selectedPage: Int, + serverUrl: String, + getLibraryForPage: @Composable (Int) -> State>, + onPageChanged: (Int) -> Unit, + onClickManga: (Manga) -> Unit +) { + if (categories.isEmpty()) return + + val state = remember(categories.size, selectedPage) { + PagerState( + currentPage = selectedPage, + minPage = 0, + maxPage = categories.lastIndex + ) + } + LaunchedEffect(state.currentPage) { + if (state.currentPage != selectedPage) { + onPageChanged(state.currentPage) + } + } + Pager(state = state, offscreenLimit = 1) { + val library by getLibraryForPage(page) + when (displayMode) { + DisplayMode.CompactGrid -> LibraryMangaCompactGrid( + library = library, + serverUrl = serverUrl, + onClickManga = onClickManga + ) + /*DisplayMode.ComfortableGrid -> LibraryMangaComfortableGrid( + library = library, + onClickManga = onClickManga + ) + DisplayMode.List -> LibraryMangaList( + library = library, + onClickManga = onClickManga + )*/ + else -> Box {} + } + } +} + +@Serializable +sealed class DisplayMode { + @Serializable + object List : DisplayMode() + @Serializable + object CompactGrid : DisplayMode() + @Serializable + object ComfortableGrid : DisplayMode() +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/library/LibraryScreenViewModel.kt b/src/main/kotlin/ca/gosyer/ui/library/LibraryScreenViewModel.kt new file mode 100644 index 00000000..c0ad527a --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/library/LibraryScreenViewModel.kt @@ -0,0 +1,93 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.library + +import ca.gosyer.backend.models.Category +import ca.gosyer.backend.models.Manga +import ca.gosyer.backend.network.interactions.CategoryInteractionHandler +import ca.gosyer.backend.network.interactions.LibraryInteractionHandler +import ca.gosyer.backend.preferences.PreferenceHelper +import ca.gosyer.ui.base.vm.ViewModel +import ca.gosyer.util.system.inject +import io.ktor.client.HttpClient +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +private typealias LibraryMap = MutableMap>> +private data class Library(val categories: MutableStateFlow>, val mangaMap: LibraryMap) + +private fun LibraryMap.getManga(order: Int) = + getOrPut(order) { MutableStateFlow(emptyList()) } +private fun LibraryMap.setManga(order: Int, manga: List) { + getManga(order).value = manga +} + +class LibraryScreenViewModel: ViewModel() { + private val preferences: PreferenceHelper by inject() + private val httpClient: HttpClient by inject() + + val serverUrl = preferences.serverUrl.asStateFlow(scope) + + private val library = Library(MutableStateFlow(emptyList()), mutableMapOf()) + val categories = library.categories.asStateFlow() + + private val _selectedCategoryIndex = MutableStateFlow(0) + val selectedCategoryIndex = _selectedCategoryIndex.asStateFlow() + + val displayMode = preferences.libraryDisplay.asStateFlow(scope) + + private val _isLoading = MutableStateFlow(true) + val isLoading = _isLoading.asStateFlow() + + init { + getLibrary() + } + + private fun getLibrary() { + scope.launch { + _isLoading.value = true + try { + val categories = CategoryInteractionHandler(httpClient).getCategories() + if (categories.isEmpty()) { + library.categories.value = listOf(defaultCategory) + library.mangaMap.setManga(defaultCategory.order, LibraryInteractionHandler(httpClient).getLibraryManga()) + } else { + library.categories.value = listOf(defaultCategory) + categories.sortedBy { it.order } + categories.map { + async { + library.mangaMap.setManga(it.order, CategoryInteractionHandler(httpClient).getMangaFromCategory(it.id)) + } + }.awaitAll() + val mangaInCategories = library.mangaMap.flatMap { it.value.value }.map { it.id }.distinct() + library.mangaMap.setManga(defaultCategory.order, LibraryInteractionHandler(httpClient).getLibraryManga().filterNot { it.id in mangaInCategories }) + } + } catch (e: Exception) { + } finally { + _isLoading.value = false + } + } + } + + + fun setSelectedPage(page: Int) { + _selectedCategoryIndex.value = page + } + + fun getLibraryForCategoryIndex(index: Int): StateFlow> { + return library.mangaMap.getManga(index).asStateFlow() + } + + companion object { + val defaultCategory = Category(0, 0, "Default",true) + } +} + + diff --git a/src/main/kotlin/ca/gosyer/ui/library/MangaCompactGrid.kt b/src/main/kotlin/ca/gosyer/ui/library/MangaCompactGrid.kt new file mode 100644 index 00000000..e2dfc0f8 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/library/MangaCompactGrid.kt @@ -0,0 +1,109 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.library + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.GridCells +import androidx.compose.foundation.lazy.LazyVerticalGrid +import androidx.compose.foundation.lazy.items +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ca.gosyer.backend.models.Manga +import ca.gosyer.ui.base.components.KtorImage +import ca.gosyer.util.system.get + +@Composable +fun LibraryMangaCompactGrid( + library: List, + serverUrl: String, + onClickManga: (Manga) -> Unit = {} +) { + LazyVerticalGrid( + cells = GridCells.Adaptive(160.dp), + modifier = Modifier.fillMaxSize().padding(4.dp) + ) { + items(library) { manga -> + LibraryMangaCompactGridItem( + manga = manga, + unread = null, // TODO + downloaded = null, // TODO + serverUrl = serverUrl, + onClick = { onClickManga(manga) } + ) + } + } +} + +@Composable +private fun LibraryMangaCompactGridItem( + manga: Manga, + unread: Int?, + downloaded: Int?, + serverUrl: String, + onClick: () -> Unit = {} +) { + val cover = remember(manga.id, serverUrl) { manga.cover(serverUrl) } + val fontStyle = LocalTextStyle.current.merge( + TextStyle(letterSpacing = 0.sp, fontFamily = FontFamily.SansSerif, fontSize = 14.sp) + ) + + Box(modifier = Modifier.padding(4.dp) + .fillMaxWidth() + .aspectRatio(3f / 4f) + .clip(MaterialTheme.shapes.medium) + .clickable(onClick = onClick) + ) { + if (cover != null) { + KtorImage(get(), cover, contentScale = ContentScale.Crop) + } + Box(modifier = Modifier.fillMaxSize().then(shadowGradient)) + Text( + text = manga.title, + color = Color.White, + style = fontStyle, + maxLines = 2, + modifier = Modifier.align(Alignment.BottomStart).padding(8.dp) + ) + LibraryMangaBadges( + unread = unread, + downloaded = downloaded, + modifier = Modifier.padding(4.dp) + ) + } +} + +private val shadowGradient = Modifier.drawWithCache { + val gradient = Brush.linearGradient( + 0.75f to Color.Transparent, + 1.0f to Color(0xAA000000), + start = Offset(0f, 0f), + end = Offset(0f, size.height) + ) + onDrawBehind { + drawRect(gradient) + } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/main/MainViewModel.kt b/src/main/kotlin/ca/gosyer/ui/main/MainViewModel.kt new file mode 100644 index 00000000..8ec91538 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/main/MainViewModel.kt @@ -0,0 +1,11 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.main + +import ca.gosyer.ui.base.vm.ViewModel + +class MainViewModel : ViewModel() \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/main/main.kt b/src/main/kotlin/ca/gosyer/ui/main/main.kt new file mode 100644 index 00000000..80634171 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/main/main.kt @@ -0,0 +1,85 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.main + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Button +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.ui.Modifier +import ca.gosyer.backend.network.networkModule +import ca.gosyer.backend.preferences.preferencesModule +import ca.gosyer.ui.base.vm.composeViewModel +import ca.gosyer.ui.base.vm.viewModelModule +import ca.gosyer.ui.categories.openCategoriesMenu +import ca.gosyer.ui.extensions.openExtensionsMenu +import ca.gosyer.ui.library.openLibraryMenu +import ca.gosyer.ui.sources.openSourcesMenu +import ca.gosyer.util.compose.ThemedWindow +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import mu.KotlinLogging +import org.koin.core.context.startKoin +import kotlin.concurrent.thread + +fun main() { + GlobalScope.launch { + val logger = KotlinLogging.logger("Server") + val runtime = Runtime.getRuntime() + + val process = runtime.exec("java -jar resources/Tachidesk.jar") + runtime.addShutdownHook(thread(start = false) { + process?.destroy() + }) + val reader = process.inputStream.reader().buffered() + logger.info { "Server started successfully" } + var line: String? + while (reader.readLine().also { line = it } != null) { + logger.info { line } + } + logger.info { "Server closed" } + val exitVal = process.waitFor() + logger.info { "Process exitValue: $exitVal" } + } + + startKoin { + modules( + preferencesModule, + networkModule, + viewModelModule + ) + } + + + ThemedWindow(title = "TachideskJUI") { + val vm = composeViewModel() + Column(Modifier.fillMaxSize().background(MaterialTheme.colors.background)) { + Button( + onClick = ::openExtensionsMenu + ) { + Text("Extensions") + } + Button( + onClick = ::openSourcesMenu + ) { + Text("Sources") + } + Button( + onClick = ::openLibraryMenu + ) { + Text("Library") + } + Button( + onClick = ::openCategoriesMenu + ) { + Text("Categories") + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/manga/MangaMenu.kt b/src/main/kotlin/ca/gosyer/ui/manga/MangaMenu.kt new file mode 100644 index 00000000..b47e82b7 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/manga/MangaMenu.kt @@ -0,0 +1,214 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.manga + +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +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.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ca.gosyer.backend.models.Chapter +import ca.gosyer.backend.models.Manga +import ca.gosyer.ui.base.components.KtorImage +import ca.gosyer.ui.base.components.LoadingScreen +import ca.gosyer.ui.base.components.mangaAspectRatio +import ca.gosyer.ui.base.vm.composeViewModel +import ca.gosyer.util.compose.ThemedWindow +import ca.gosyer.util.system.get + +fun openMangaMenu(mangaId: Long) { + ThemedWindow("TachideskJUI") { + MangaMenu(mangaId) + } +} + +fun openMangaMenu(manga: Manga) { + ThemedWindow("TachideskJUI - ${manga.title}") { + MangaMenu(manga) + } +} + +@Composable +fun MangaMenu(mangaId: Long) { + val vm = composeViewModel() + remember(mangaId) { + vm.init(mangaId) + } + MangaMenu(vm) +} + +@Composable +fun MangaMenu(manga: Manga) { + val vm = composeViewModel() + remember(manga) { + vm.init(manga) + } + MangaMenu(vm) +} + +@Composable +fun MangaMenu(vm: MangaMenuViewModel) { + val manga by vm.manga.collectAsState() + val chapters by vm.chapters.collectAsState() + val isLoading by vm.isLoading.collectAsState() + val serverUrl by vm.serverUrl.collectAsState() + + Column(Modifier.background(MaterialTheme.colors.background)) { + Surface(Modifier.height(40.dp).fillMaxWidth()) { + Row { + Button(onClick = vm::toggleFavorite) { + Text(if (manga?.inLibrary == true) "UnFavorite" else "Favorite") + } + } + } + manga?.let { manga -> + Box { + val state = rememberLazyListState() + LazyColumn(state = state) { + items( + listOf(MangaMenu.MangaMenuManga(manga)) + chapters.map { MangaMenu.MangaMenuChapter(it) } + ) { + when (it) { + is MangaMenu.MangaMenuManga -> MangaItem(it.manga, serverUrl) + is MangaMenu.MangaMenuChapter -> ChapterItem(it.chapter) + } + } + } + VerticalScrollbar( + modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), + adapter = rememberScrollbarAdapter( + scrollState = state, + itemCount = chapters.size + 1, + averageItemSize = 70.dp + ) + ) + if (isLoading) { + LoadingScreen() + } + } + } + } +} + +@Composable +fun MangaItem(manga: Manga, serverUrl: String) { + BoxWithConstraints(Modifier.padding(8.dp)) { + if (maxWidth > 600.dp) { + Row { + Cover(manga, serverUrl) + Spacer(Modifier.width(16.dp)) + Surface( + elevation = 2.dp, + modifier = Modifier.defaultMinSize(minHeight = 450.dp).fillMaxWidth() + ) { + MangaInfo(manga) + } + } + } else { + Column { + Cover(manga, serverUrl, Modifier.align(Alignment.CenterHorizontally)) + Spacer(Modifier.height(16.dp)) + Surface(elevation = 2.dp) { + MangaInfo(manga) + } + } + } + } +} + +@Composable +private fun Cover(manga: Manga, serverUrl: String, modifier: Modifier = Modifier) { + Surface( + modifier = modifier then Modifier + .width(300.dp) + .aspectRatio(mangaAspectRatio) + .padding(4.dp), + elevation = 4.dp, + shape = RoundedCornerShape(4.dp) + ) { + Box(modifier = Modifier.fillMaxSize()) { + manga.cover(serverUrl).let { + if (it != null) { + KtorImage(get(), it) + } + } + } + } +} + +sealed class MangaMenu { + data class MangaMenuManga(val manga: Manga) + + data class MangaMenuChapter(val chapter: Chapter) +} + +@Composable +private fun MangaInfo(manga: Manga, modifier: Modifier = Modifier) { + Column(modifier) { + Text(manga.title, fontSize = 22.sp, fontWeight = FontWeight.Bold) + if (!manga.author.isNullOrEmpty()) { + Text(manga.author, fontSize = 18.sp) + } + if (!manga.artist.isNullOrEmpty() && manga.artist != manga.author) { + Text(manga.artist, fontSize = 18.sp) + } + if (!manga.description.isNullOrEmpty()) { + Text(manga.description) + } + if (!manga.genre.isNullOrEmpty()) { + Text(manga.genre) + } + } +} + +@Composable +private fun ChapterItem(chapter: Chapter) { + Surface(modifier = Modifier.fillMaxWidth().height(70.dp).padding(4.dp), elevation = 2.dp) { + Column(Modifier.padding(4.dp)) { + Text(chapter.name, fontSize = 20.sp) + val description = mutableListOf() + if (chapter.dateUpload != 0L) { + description += chapter.dateUpload.toString() + } + if (!chapter.scanlator.isNullOrEmpty()) { + description += chapter.scanlator + } + if (description.isNotEmpty()) { + Spacer(Modifier.height(2.dp)) + Text(description.joinToString(" - ")) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/manga/MangaMenuViewModel.kt b/src/main/kotlin/ca/gosyer/ui/manga/MangaMenuViewModel.kt new file mode 100644 index 00000000..3acd7bf5 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/manga/MangaMenuViewModel.kt @@ -0,0 +1,87 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.manga + +import ca.gosyer.backend.models.Chapter +import ca.gosyer.backend.models.Manga +import ca.gosyer.backend.network.interactions.ChapterInteractionHandler +import ca.gosyer.backend.network.interactions.LibraryInteractionHandler +import ca.gosyer.backend.network.interactions.MangaInteractionHandler +import ca.gosyer.backend.preferences.PreferenceHelper +import ca.gosyer.ui.base.vm.ViewModel +import ca.gosyer.util.system.inject +import io.ktor.client.HttpClient +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class MangaMenuViewModel : ViewModel() { + private val preferences: PreferenceHelper by inject() + private val httpClient: HttpClient by inject() + + val serverUrl = preferences.serverUrl.asStateFlow(scope) + + private val _manga = MutableStateFlow(null) + val manga = _manga.asStateFlow() + + private val _chapters = MutableStateFlow(emptyList()) + val chapters = _chapters.asStateFlow() + + private val _isLoading = MutableStateFlow(true) + val isLoading = _isLoading.asStateFlow() + + fun init(manga: Manga) { + _manga.value = manga + init(manga.id) + } + + fun init(mangaId: Long) { + scope.launch { + refreshMangaAsync(mangaId).await() to refreshChaptersAsync(mangaId).await() + _isLoading.value = false + } + } + + private suspend fun refreshMangaAsync(mangaId: Long) = withContext(Dispatchers.IO) { + async { + try { + _manga.value = MangaInteractionHandler(httpClient).getManga(mangaId) + } catch (e: Exception) { + if (e is CancellationException) throw e + } + } + } + + suspend fun refreshChaptersAsync(mangaId: Long) = withContext(Dispatchers.IO) { + async { + try { + _chapters.value = ChapterInteractionHandler(httpClient).getChapters(mangaId) + } catch (e: Exception) { + if (e is CancellationException) throw e + } + } + } + + fun toggleFavorite() { + scope.launch { + manga.value?.let { + if (it.inLibrary) { + LibraryInteractionHandler(httpClient).removeMangaFromLibrary(it) + } else { + LibraryInteractionHandler(httpClient).addMangaToLibrary(it) + } + + refreshMangaAsync(it.id).await() + } + } + + } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenu.kt b/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenu.kt new file mode 100644 index 00000000..9e1aed37 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenu.kt @@ -0,0 +1,57 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.sources + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import ca.gosyer.backend.models.Source +import ca.gosyer.ui.base.vm.composeViewModel +import ca.gosyer.ui.sources.components.SourceHomeScreen +import ca.gosyer.ui.sources.components.SourceScreen +import ca.gosyer.ui.sources.components.SourceTopBar +import ca.gosyer.util.compose.ThemedWindow + +fun openSourcesMenu() { + ThemedWindow(title = "TachideskJUI - Sources") { + SourcesMenu() + } +} + +@Composable +fun SourcesMenu() { + val vm = composeViewModel() + val isLoading by vm.isLoading.collectAsState() + val sources by vm.sources.collectAsState() + val sourceTabs by vm.sourceTabs.collectAsState() + val selectedSourceTab by vm.selectedSourceTab.collectAsState() + val serverUrl by vm.serverUrl.collectAsState() + + Column(Modifier.fillMaxSize().background(MaterialTheme.colors.background)) { + SourceTopBar( + openHome = { + vm.selectTab(null) + }, + sources = sourceTabs, + tabSelected = selectedSourceTab, + onTabSelected = vm::selectTab, + onTabClosed = vm::closeTab + ) + + val selectedSource: Source? = selectedSourceTab + if (selectedSource != null) { + SourceScreen(selectedSource) + } else { + SourceHomeScreen(isLoading, sources, serverUrl, vm::addTab) + } + } +} diff --git a/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenuViewModel.kt b/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenuViewModel.kt new file mode 100644 index 00000000..1b06fd13 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenuViewModel.kt @@ -0,0 +1,74 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.sources + +import ca.gosyer.backend.models.Source +import ca.gosyer.backend.network.interactions.SourceInteractionHandler +import ca.gosyer.backend.preferences.PreferenceHelper +import ca.gosyer.ui.base.vm.ViewModel +import ca.gosyer.util.system.inject +import io.ktor.client.HttpClient +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import mu.KotlinLogging + +class SourcesMenuViewModel: ViewModel() { + private val preferences: PreferenceHelper by inject() + private val httpClient: HttpClient by inject() + private val logger = KotlinLogging.logger {} + + val serverUrl = preferences.serverUrl.asStateFlow(scope) + + private val _isLoading = MutableStateFlow(true) + val isLoading = _isLoading.asStateFlow() + + private val _sources = MutableStateFlow(emptyList()) + val sources = _sources.asStateFlow() + + private val _sourceTabs = MutableStateFlow(mapOf(null to null)) + val sourceTabs = _sourceTabs.asStateFlow() + + private val _selectedSourceTab = MutableStateFlow(null) + val selectedSourceTab = _selectedSourceTab.asStateFlow() + + init { + getSources() + } + + private fun getSources() { + scope.launch { + try { + val sources = SourceInteractionHandler(httpClient).getSourceList() + logger.info { sources } + _sources.value = sources//.filter { it.lang in Preferences.enabledLangs } + logger.info { _sources.value } + } catch (e: Exception) { + if (e is CancellationException) throw e + } finally { + _isLoading.value = false + } + } + } + + fun selectTab(source: Source?) { + _selectedSourceTab.value = source + } + + fun addTab(source: Source) { + _sourceTabs.value += source.id to source + selectTab(source) + } + + fun closeTab(source: Source) { + _sourceTabs.value -= source.id + if (selectedSourceTab.value?.id == source.id) { + _selectedSourceTab.value = null + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/sources/components/SourceHomeScreen.kt b/src/main/kotlin/ca/gosyer/ui/sources/components/SourceHomeScreen.kt new file mode 100644 index 00000000..084ca90f --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/sources/components/SourceHomeScreen.kt @@ -0,0 +1,117 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.sources.components + +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.GridCells +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyVerticalGrid +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import ca.gosyer.backend.models.Source +import ca.gosyer.ui.base.components.KtorImage +import ca.gosyer.ui.base.components.LoadingScreen +import ca.gosyer.util.system.get + +@Composable +fun SourceHomeScreen( + isLoading: Boolean, + sources: List, + serverUrl: String, + onSourceClicked: (Source) -> Unit +) { + if (sources.isEmpty()) { + LoadingScreen(isLoading) + } else { + Box(Modifier.fillMaxSize(), Alignment.TopCenter) { + val state = rememberLazyListState() + SourceCategory("all", sources, serverUrl, onSourceClicked, state) + /*val sourcesByLang = sources.groupBy { it.lang.toLowerCase() }.toList() + LazyColumn(state = state) { + items(sourcesByLang) { (lang, sources) -> + SourceCategory( + lang, + sources, + onSourceClicked = sourceClicked + ) + Spacer(Modifier.height(8.dp)) + } + }*/ + + VerticalScrollbar( + modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), + adapter = rememberScrollbarAdapter( + scrollState = state, + itemCount = sources.size, + averageItemSize = 12.dp // TextBox height + Spacer height + ) + ) + } + } +} + +@Composable +fun SourceCategory( + lang: String, + sources: List, + serverUrl: String, + onSourceClicked: (Source) -> Unit, + state: LazyListState +) { + Column { + Surface(elevation = 1.dp, modifier = Modifier.fillMaxWidth()) { + Text(lang.toUpperCase(), modifier = Modifier.align(Alignment.CenterHorizontally), color = MaterialTheme.colors.onBackground) + } + LazyVerticalGrid(GridCells.Adaptive(120.dp), state = state) { + items(sources) { source -> + SourceItem( + source, + serverUrl, + onSourceClicked = onSourceClicked + ) + Spacer(Modifier.height(8.dp)) + } + } + } +} + + +@Composable +fun SourceItem( + source: Source, + serverUrl: String, + onSourceClicked: (Source) -> Unit +) { + Column( + Modifier.size(120.dp) + .clickable { + onSourceClicked(source) + }, + horizontalAlignment = Alignment.CenterHorizontally + ) { + KtorImage(get(), source.iconUrl(serverUrl), Modifier.size(96.dp)) + Spacer(Modifier.height(4.dp)) + Text("${source.name} (${source.lang})", color = MaterialTheme.colors.onBackground) + } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/sources/components/SourceScreen.kt b/src/main/kotlin/ca/gosyer/ui/sources/components/SourceScreen.kt new file mode 100644 index 00000000..a15c3ae7 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/sources/components/SourceScreen.kt @@ -0,0 +1,102 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.sources.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.GridCells +import androidx.compose.foundation.lazy.LazyVerticalGrid +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import ca.gosyer.backend.models.Manga +import ca.gosyer.backend.models.Source +import ca.gosyer.ui.base.components.LoadingScreen +import ca.gosyer.ui.base.components.MangaGridItem +import ca.gosyer.ui.base.vm.composeViewModel +import ca.gosyer.ui.manga.openMangaMenu + +@Composable +fun SourceScreen( + source: Source +) { + val vm = composeViewModel() + remember(source) { + vm.init(source) + } + val mangas by vm.mangas.collectAsState() + val hasNextPage by vm.hasNextPage.collectAsState() + val loading by vm.loading.collectAsState() + val isLatest by vm.isLatest.collectAsState() + val serverUrl by vm.serverUrl.collectAsState() + + MangaTable( + mangas, + loading, + hasNextPage, + isLatest, + serverUrl, + onLoadNextPage = vm::loadNextPage, + onClickManga = ::openMangaMenu, + onClickMode = vm::setMode + ) +} + +@Composable +private fun MangaTable( + mangas: List, + isLoading: Boolean = false, + hasNextPage: Boolean = false, + isLatest: Boolean, + serverUrl: String, + onLoadNextPage: () -> Unit, + onClickManga: (Manga) -> Unit, + onClickMode: (Boolean) -> Unit +) { + if (mangas.isEmpty()) { + LoadingScreen(isLoading) + } else { + Column { + // TODO: this should happen automatically on scroll + Box(modifier = Modifier.fillMaxWidth()) { + Button( + onClick = onLoadNextPage, + enabled = hasNextPage && !isLoading, + modifier = Modifier.align(Alignment.TopStart) + ) { + Text(text = if (isLoading) "Loading..." else "Load next page") + } + Button( + onClick = { onClickMode(!isLatest) }, + enabled = !isLoading, + modifier = Modifier.align(Alignment.TopEnd) + ) { + Text(text = if (isLatest) "Latest" else "Browse") + } + } + + + LazyVerticalGrid(GridCells.Adaptive(160.dp)) { + items(mangas) { manga -> + MangaGridItem( + title = manga.title, + cover = manga.cover(serverUrl), + onClick = { onClickManga(manga) } + ) + } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/sources/components/SourceScreenViewModel.kt b/src/main/kotlin/ca/gosyer/ui/sources/components/SourceScreenViewModel.kt new file mode 100644 index 00000000..e94e9de4 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/sources/components/SourceScreenViewModel.kt @@ -0,0 +1,84 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.sources.components + +import ca.gosyer.backend.models.Manga +import ca.gosyer.backend.models.MangaPage +import ca.gosyer.backend.models.Source +import ca.gosyer.backend.network.interactions.SourceInteractionHandler +import ca.gosyer.backend.preferences.PreferenceHelper +import ca.gosyer.ui.base.vm.ViewModel +import ca.gosyer.util.system.asStateFlow +import ca.gosyer.util.system.inject +import io.ktor.client.HttpClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class SourceScreenViewModel: ViewModel() { + private lateinit var source: Source + private val preferences: PreferenceHelper by inject() + private val httpClient: HttpClient by inject() + + val serverUrl = preferences.serverUrl.asFLow() + .asStateFlow(preferences.serverUrl.get(),scope, true) + + private val _mangas = MutableStateFlow(emptyList()) + val mangas = _mangas.asStateFlow() + + private val _hasNextPage = MutableStateFlow(false) + val hasNextPage = _hasNextPage.asStateFlow() + + private val _loading = MutableStateFlow(true) + val loading = _loading.asStateFlow() + + private val _isLatest = MutableStateFlow(true) + val isLatest = _isLatest.asStateFlow() + + private val _pageNum = MutableStateFlow(1) + val pageNum = _pageNum.asStateFlow() + + fun init(source: Source) { + this.source = source + scope.launch { + _loading.value = true + _mangas.value = emptyList() + _hasNextPage.value = false + _pageNum.value = 1 + val page = getPage() + _mangas.value += page.mangaList + _hasNextPage.value = page.hasNextPage + _loading.value = false + } + } + + fun loadNextPage() { + scope.launch { + _hasNextPage.value = false + _pageNum.value++ + val page = getPage() + _mangas.value += page.mangaList + _hasNextPage.value = page.hasNextPage + _loading.value = false + } + } + + fun setMode(toLatest: Boolean) { + if (isLatest.value != toLatest){ + _isLatest.value = toLatest + init(source) + } + } + + private suspend fun getPage(): MangaPage { + return if (isLatest.value) { + SourceInteractionHandler(httpClient).getLatestManga(source, pageNum.value) + } else { + SourceInteractionHandler(httpClient).getPopularManga(source, pageNum.value) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/ui/sources/components/SourceTabs.kt b/src/main/kotlin/ca/gosyer/ui/sources/components/SourceTabs.kt new file mode 100644 index 00000000..ff129de8 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/sources/components/SourceTabs.kt @@ -0,0 +1,147 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.sources.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ScrollableTabRow +import androidx.compose.material.Surface +import androidx.compose.material.Tab +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Home +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import ca.gosyer.backend.models.Source + +@Composable +fun SourceTopBar( + openHome: () -> Unit, + sources: Map, + tabSelected: Source?, + onTabSelected: (Source?) -> Unit, + onTabClosed: (Source) -> Unit, + modifier: Modifier = Modifier +) { + Surface( + elevation = 2.dp, + color = MaterialTheme.colors.surface + ) { + SourceTabBar( + modifier = modifier, + onHomeClicked = openHome + ) { tabBarModifier -> + SourceTabs( + modifier = tabBarModifier, + sources = sources, + tabSelected = tabSelected, + onTabSelected = { newTab -> onTabSelected(newTab) }, + onTabClosed = onTabClosed + ) + } + } + +} + +@Composable +fun SourceTabBar( + modifier: Modifier = Modifier, + onHomeClicked: () -> Unit, + children: @Composable (Modifier) -> Unit +) { + Row(modifier) { + // Separate Row as the children shouldn't have the padding + Box(Modifier.padding(4.dp).align(Alignment.CenterVertically)) { + Image( + modifier = Modifier + .clickable(onClick = onHomeClicked), + imageVector = Icons.Filled.Home, + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colors.onSurface) + ) + } + children( + Modifier + .weight(1f) + .align(Alignment.CenterVertically) + ) + + } + +} + +@Composable +fun SourceTabs( + modifier: Modifier = Modifier, + sources: Map, + tabSelected: Source?, + onTabSelected: (Source?) -> Unit, + onTabClosed: (Source) -> Unit +) { + ScrollableTabRow( + selectedTabIndex = 1,//sources.indexOf(tabSelected).let { if (it == -1) 1 else it }, + modifier = modifier, + contentColor = MaterialTheme.colors.onSurface, + indicator = { + + }, + divider = { + + }, + backgroundColor = MaterialTheme.colors.surface + ) { + sources.forEach { (sourceId, source) -> + val selected = sourceId == tabSelected?.id + + var rowModifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp) + if (selected) { + rowModifier = + Modifier + .border(BorderStroke(2.dp, MaterialTheme.colors.onSurface), RectangleShape) + .then(rowModifier) + } + + Tab( + modifier = Modifier.background(MaterialTheme.colors.surface), + selected = selected, + onClick = { onTabSelected(source) } + ) { + Row(rowModifier, verticalAlignment = Alignment.CenterVertically) { + Text( + text = source?.name?.toUpperCase() ?: "Sources", + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (source != null) { + Image( + modifier = Modifier + .clickable { + onTabClosed(source) + }, + imageVector = Icons.Filled.Close, + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colors.onSurface) + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/util/compose/Color.kt b/src/main/kotlin/ca/gosyer/util/compose/Color.kt new file mode 100644 index 00000000..f4ab2391 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/util/compose/Color.kt @@ -0,0 +1,11 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.util.compose + +import androidx.compose.ui.graphics.Color + +val Long.color get() = Color(this) \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/util/compose/Image.kt b/src/main/kotlin/ca/gosyer/util/compose/Image.kt new file mode 100644 index 00000000..549fec2e --- /dev/null +++ b/src/main/kotlin/ca/gosyer/util/compose/Image.kt @@ -0,0 +1,24 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.util.compose + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.readBytes +import org.jetbrains.skija.Image +import java.io.File + +fun imageFromFile(file: File): ImageBitmap { + return Image.makeFromEncoded(file.readBytes()).asImageBitmap() +} + +suspend fun imageFromUrl(client: HttpClient, url: String): ImageBitmap { + return Image.makeFromEncoded(client.get(url).readBytes()).asImageBitmap() +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/util/compose/Theme.kt b/src/main/kotlin/ca/gosyer/util/compose/Theme.kt new file mode 100644 index 00000000..caa7106f --- /dev/null +++ b/src/main/kotlin/ca/gosyer/util/compose/Theme.kt @@ -0,0 +1,38 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.util.compose + +import androidx.compose.desktop.DesktopMaterialTheme +import androidx.compose.desktop.Window +import androidx.compose.desktop.WindowEvents +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.window.MenuBar +import ca.gosyer.backend.preferences.PreferenceHelper +import ca.gosyer.util.system.get +import java.awt.image.BufferedImage + +fun ThemedWindow( + title: String = "JetpackDesktopWindow", + size: IntSize = IntSize(800, 600), + location: IntOffset = IntOffset.Zero, + centered: Boolean = true, + icon: BufferedImage? = null, + menuBar: MenuBar? = null, + undecorated: Boolean = false, + resizable: Boolean = true, + events: WindowEvents = WindowEvents(), + onDismissRequest: (() -> Unit)? = null, + content: @Composable () -> Unit = { } +) { + Window(title, size, location, centered, icon, menuBar, undecorated, resizable, events, onDismissRequest) { + DesktopMaterialTheme(get().getTheme()) { + content() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/util/system/Flow.kt b/src/main/kotlin/ca/gosyer/util/system/Flow.kt new file mode 100644 index 00000000..3b228d8c --- /dev/null +++ b/src/main/kotlin/ca/gosyer/util/system/Flow.kt @@ -0,0 +1,30 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.util.system + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch + +fun Flow.asStateFlow(defaultValue: T, scope: CoroutineScope, dropFirst: Boolean = false): StateFlow { + val flow = MutableStateFlow(defaultValue) + scope.launch { + if (dropFirst) { + drop(1) + } else { + this@asStateFlow + }.collect { + flow.value = it + } + } + return flow.asStateFlow() +} \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/util/system/Koin.kt b/src/main/kotlin/ca/gosyer/util/system/Koin.kt new file mode 100644 index 00000000..4d32ca5f --- /dev/null +++ b/src/main/kotlin/ca/gosyer/util/system/Koin.kt @@ -0,0 +1,13 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.util.system + +import org.koin.core.context.GlobalContext + +inline fun get() = GlobalContext.get().get() + +inline fun inject() = GlobalContext.get().inject() \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/util/system/ProcessFile.kt b/src/main/kotlin/ca/gosyer/util/system/ProcessFile.kt new file mode 100644 index 00000000..4266cede --- /dev/null +++ b/src/main/kotlin/ca/gosyer/util/system/ProcessFile.kt @@ -0,0 +1,24 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.util.system + +import java.io.File + +fun processFile() { + val pidFile = ProcessHandle.current().pid() + val strTmp = System.getProperty("java.io.tmpdir") + val file = File("$strTmp/TachideskJUI.pid") + + //backup deletion + if (file.exists()) { + file.delete() + } + + file.writeText(pidFile.toString()) + + file.deleteOnExit() +} \ No newline at end of file diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 00000000..47543e7b --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,38 @@ + + + + + + + + %highlight(%d{HH:mm:ss.SSS} [%thread] %level/%logger{0}: %msg%n) + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/kotlin/ca/gosyer/backend/network/ExtensionInteractionTest.kt b/src/test/kotlin/ca/gosyer/backend/network/ExtensionInteractionTest.kt new file mode 100644 index 00000000..cf61a97f --- /dev/null +++ b/src/test/kotlin/ca/gosyer/backend/network/ExtensionInteractionTest.kt @@ -0,0 +1,12 @@ +package ca.gosyer.backend.network + +import kotlin.test.Test + +class ExtensionInteractionTest { + + + @Test + fun `Install a extension`() { + + } +} \ No newline at end of file