diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e9a9bff --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..3c2b3ec --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: [KurozeroPB] +patreon: Kurozero +open_collective: # Replace with a single Open Collective username +ko_fi: kurozero +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: ['https://www.paypal.me/pvdbroek', 'https://donatebot.io/checkout/240059867744698368'] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b582e9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Built application files +*.apk +*.ap_ + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +sentry.properties +local.properties +.env + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# Intellij +*.iml +.idea +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/dictionaries +.idea/libraries + +# Keystore files +*.jks + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b254c01 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Pepijn van den Broek + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0938a8d --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Nekos +Work in progress mobile app for https://nekos.moe \ +[![play-store][playstore]](https://play.google.com/store/apps/details?id=dev.vdbroek.nekos) + +[playstore]: https://b.catgirlsare.sexy/3lTD.png diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..9447e7c --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,148 @@ +plugins { + id("com.android.application") + kotlin("android") + kotlin("kapt") +} + +object Versions { + private const val versionMajor = 2 + private const val versionMinor = 0 + private const val versionPatch = 0 + + const val minSdk = 28 + const val targetSdk = 32 + + fun generateVersionCode(): Int = minSdk * 10000000 + versionMajor * 10000 + versionMinor * 100 + versionPatch + + fun generateVersionName(): String = "$versionMajor.$versionMinor.$versionPatch" +} + +android { + namespace = "dev.vdbroek.nekos" + compileSdk = Versions.targetSdk + + defaultConfig { + applicationId = "dev.vdbroek.nekos" + minSdk = Versions.minSdk + targetSdk = Versions.targetSdk + versionCode = Versions.generateVersionCode() + versionName = Versions.generateVersionName() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + getByName("release") { + isMinifyEnabled = true + isShrinkResources = true + + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + + isDebuggable = false + isJniDebuggable = false + isRenderscriptDebuggable = false + isPseudoLocalesEnabled = false + } + + create("beta") { + versionNameSuffix = "-BETA" + applicationIdSuffix = ".beta" + + isShrinkResources = false + isMinifyEnabled = false + isDebuggable = false + isJniDebuggable = false + isRenderscriptDebuggable = false + isPseudoLocalesEnabled = false + + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + + getByName("debug") { + versionNameSuffix = "-DEBUG" + applicationIdSuffix = ".debug" + + isShrinkResources = false + isMinifyEnabled = false + isDebuggable = true // Set to false whenever publishing debug app to play console otherwise the AAB/APK will show as not signed. + isJniDebuggable = true + isRenderscriptDebuggable = true + isPseudoLocalesEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + buildFeatures { + compose = true + viewBinding = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.2.0-beta02" + } + + packagingOptions { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + // Android libraries + implementation("androidx.core:core-ktx:1.7.0") + implementation("androidx.core:core-splashscreen:1.0.0-rc01") + implementation("androidx.datastore:datastore-preferences:1.0.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.4.1") + + // Compose libraries + implementation("androidx.activity:activity-compose:1.4.0") + implementation("androidx.compose.ui:ui:1.2.0-beta02") + implementation("androidx.compose.ui:ui-tooling-preview:1.2.0-beta02") + implementation("androidx.compose.material3:material3:1.0.0-alpha12") + implementation("androidx.compose.material:material-icons-extended:1.2.0-beta02") + implementation("androidx.navigation:navigation-compose:2.4.2") + + // Google accompanist components + implementation("com.google.accompanist:accompanist-flowlayout:0.24.9-beta") + implementation("com.google.accompanist:accompanist-swiperefresh:0.24.9-beta") + + // Better fling behaviour modification + implementation("com.github.iamjosephmj:Flinger:1.1.1") + // Until lazy staggered grid is officially supported this is the best implementation I could find + // It is on the roadmap https://developer.android.com/jetpack/androidx/compose-roadmap + // So surely some day it will be added to jetpack compose :) + implementation("com.github.nesyou01:LazyStaggeredGrid:1.1") + // Collapsing toolbar + implementation("me.onebone:toolbar-compose:2.3.3") + + // HTTP Requests + implementation("com.google.code.gson:gson:2.9.0") + implementation("com.github.kittinunf.fuel:fuel:2.3.1") + implementation("com.github.kittinunf.fuel:fuel-android:2.3.1") + implementation("com.github.kittinunf.fuel:fuel-coroutines:2.3.1") + implementation("com.github.kittinunf.fuel:fuel-gson:2.3.1") + implementation("com.squareup.okhttp3:okhttp:4.9.3") + + // Loading network images + implementation("com.github.bumptech.glide:glide:4.13.2") + kapt("com.github.bumptech.glide:compiler:4.13.2") + + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.3") + androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") + androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.2.0-beta02") + debugImplementation("androidx.compose.ui:ui-tooling:1.2.0-beta02") + debugImplementation("androidx.compose.ui:ui-test-manifest:1.2.0-beta02") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/dev/vdbroek/nekos/ExampleInstrumentedTest.kt b/app/src/androidTest/java/dev/vdbroek/nekos/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..d044271 --- /dev/null +++ b/app/src/androidTest/java/dev/vdbroek/nekos/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package dev.vdbroek.nekos + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("dev.vdbroek.nekos", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/beta/ic_launcher-playstore.png b/app/src/beta/ic_launcher-playstore.png new file mode 100644 index 0000000..eac9461 Binary files /dev/null and b/app/src/beta/ic_launcher-playstore.png differ diff --git a/app/src/beta/res/drawable/ic_launcher.png b/app/src/beta/res/drawable/ic_launcher.png new file mode 100644 index 0000000..9e38018 Binary files /dev/null and b/app/src/beta/res/drawable/ic_launcher.png differ diff --git a/app/src/beta/res/drawable/ic_launcher_original.png b/app/src/beta/res/drawable/ic_launcher_original.png new file mode 100644 index 0000000..eac9461 Binary files /dev/null and b/app/src/beta/res/drawable/ic_launcher_original.png differ diff --git a/app/src/beta/res/drawable/ic_launcher_round.png b/app/src/beta/res/drawable/ic_launcher_round.png new file mode 100644 index 0000000..f64bc68 Binary files /dev/null and b/app/src/beta/res/drawable/ic_launcher_round.png differ diff --git a/app/src/beta/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/beta/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/app/src/beta/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/beta/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/beta/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/app/src/beta/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/beta/res/mipmap-hdpi/ic_launcher.png b/app/src/beta/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..a133ef5 Binary files /dev/null and b/app/src/beta/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/beta/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/beta/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..04e2445 Binary files /dev/null and b/app/src/beta/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/beta/res/mipmap-hdpi/ic_launcher_round.png b/app/src/beta/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..d01aae5 Binary files /dev/null and b/app/src/beta/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/beta/res/mipmap-mdpi/ic_launcher.png b/app/src/beta/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..6317aa5 Binary files /dev/null and b/app/src/beta/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/beta/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/beta/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..827d193 Binary files /dev/null and b/app/src/beta/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/beta/res/mipmap-mdpi/ic_launcher_round.png b/app/src/beta/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..340c537 Binary files /dev/null and b/app/src/beta/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/beta/res/mipmap-xhdpi/ic_launcher.png b/app/src/beta/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..9e2572f Binary files /dev/null and b/app/src/beta/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/beta/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/beta/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..cae551c Binary files /dev/null and b/app/src/beta/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/beta/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/beta/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..4353a38 Binary files /dev/null and b/app/src/beta/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/beta/res/mipmap-xxhdpi/ic_launcher.png b/app/src/beta/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..03f7b5e Binary files /dev/null and b/app/src/beta/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/beta/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/beta/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..fd8783c Binary files /dev/null and b/app/src/beta/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/beta/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/beta/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..9b89f5f Binary files /dev/null and b/app/src/beta/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/beta/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/beta/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..9e38018 Binary files /dev/null and b/app/src/beta/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/beta/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/beta/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..adc9634 Binary files /dev/null and b/app/src/beta/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/beta/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/beta/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..f64bc68 Binary files /dev/null and b/app/src/beta/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/beta/res/values/ic_launcher_background.xml b/app/src/beta/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..3381408 --- /dev/null +++ b/app/src/beta/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #E2D7D7 + \ No newline at end of file diff --git a/app/src/beta/res/values/strings.xml b/app/src/beta/res/values/strings.xml new file mode 100644 index 0000000..5a845d6 --- /dev/null +++ b/app/src/beta/res/values/strings.xml @@ -0,0 +1,3 @@ + + Nekos Beta + diff --git a/app/src/debug/ic_launcher-playstore.png b/app/src/debug/ic_launcher-playstore.png new file mode 100644 index 0000000..6367300 Binary files /dev/null and b/app/src/debug/ic_launcher-playstore.png differ diff --git a/app/src/debug/res/drawable/ic_launcher.png b/app/src/debug/res/drawable/ic_launcher.png new file mode 100644 index 0000000..96c92e0 Binary files /dev/null and b/app/src/debug/res/drawable/ic_launcher.png differ diff --git a/app/src/debug/res/drawable/ic_launcher_original.png b/app/src/debug/res/drawable/ic_launcher_original.png new file mode 100644 index 0000000..6367300 Binary files /dev/null and b/app/src/debug/res/drawable/ic_launcher_original.png differ diff --git a/app/src/debug/res/drawable/ic_launcher_round.png b/app/src/debug/res/drawable/ic_launcher_round.png new file mode 100644 index 0000000..9f8bde0 Binary files /dev/null and b/app/src/debug/res/drawable/ic_launcher_round.png differ diff --git a/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/debug/res/mipmap-hdpi/ic_launcher.png b/app/src/debug/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..6402f4b Binary files /dev/null and b/app/src/debug/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..913e7bf Binary files /dev/null and b/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..e87bdce Binary files /dev/null and b/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/debug/res/mipmap-mdpi/ic_launcher.png b/app/src/debug/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..38ab4cf Binary files /dev/null and b/app/src/debug/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..18b96d5 Binary files /dev/null and b/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..db2ebe7 Binary files /dev/null and b/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/debug/res/mipmap-xhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..b8e862e Binary files /dev/null and b/app/src/debug/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..43be88f Binary files /dev/null and b/app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..d2f1d3d Binary files /dev/null and b/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..7819665 Binary files /dev/null and b/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..8e26ec5 Binary files /dev/null and b/app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..fb0d507 Binary files /dev/null and b/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..96c92e0 Binary files /dev/null and b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..96c5c83 Binary files /dev/null and b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..9f8bde0 Binary files /dev/null and b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/debug/res/values/ic_launcher_background.xml b/app/src/debug/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..3381408 --- /dev/null +++ b/app/src/debug/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #E2D7D7 + \ No newline at end of file diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml new file mode 100644 index 0000000..ec0f656 --- /dev/null +++ b/app/src/debug/res/values/strings.xml @@ -0,0 +1,3 @@ + + Nekos Debug + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..953ca32 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/header_register.svg b/app/src/main/header_register.svg new file mode 100644 index 0000000..d36a4e4 --- /dev/null +++ b/app/src/main/header_register.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..31f68c9 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/dev/vdbroek/nekos/MainActivity.kt b/app/src/main/java/dev/vdbroek/nekos/MainActivity.kt new file mode 100644 index 0000000..b41d9a9 --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/MainActivity.kt @@ -0,0 +1,255 @@ +package dev.vdbroek.nekos + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.view.View +import android.view.WindowInsetsController +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.google.gson.Gson +import dev.vdbroek.nekos.components.* +import dev.vdbroek.nekos.models.Neko +import dev.vdbroek.nekos.ui.Screens +import dev.vdbroek.nekos.ui.screens.* +import dev.vdbroek.nekos.ui.theme.NekosTheme +import dev.vdbroek.nekos.ui.theme.ThemeState +import dev.vdbroek.nekos.utils.App +import dev.vdbroek.nekos.utils.LocalActivity +import dev.vdbroek.nekos.utils.dataStore + +@Composable +fun EnterAnimation(content: @Composable () -> Unit) { + AnimatedVisibility( + visibleState = remember { + MutableTransitionState( + initialState = false + ) + }.apply { + targetState = true + }, + enter = fadeIn(animationSpec = tween(200), initialAlpha = 0.3f), + exit = fadeOut(animationSpec = tween(200)) + ) { + content() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +class MainActivity : ComponentActivity() { + private val noNavBar = listOf( + Screens.Login.route, + Screens.Register.route, + Screens.Post.route + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { granted -> + App.permissionGranted = granted + } + + val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute by remember { derivedStateOf { navBackStackEntry?.destination?.route } } + + LaunchedEffect(key1 = true) { + if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + launcher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } else { + App.permissionGranted = true + } + } + + CompositionLocalProvider(LocalActivity provides this) { + NekosTheme { + when (currentRoute) { + Screens.Login.route, + Screens.Register.route -> { + window.statusBarColor = MaterialTheme.colorScheme.primary.toArgb() + } + else -> { + window.statusBarColor = MaterialTheme.colorScheme.background.toArgb() + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.insetsController?.setSystemBarsAppearance( + if (ThemeState.isDark) + 0 + else + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS, WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS + ) + } else { + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = + if (ThemeState.isDark) + 0 + else + window.decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + } + + Scaffold( + contentColor = MaterialTheme.colorScheme.background, + bottomBar = { + if (!noNavBar.contains(currentRoute)) { + NekosNavBar( + navController = navController, + currentRoute = currentRoute + ) + } + }, + snackbarHost = { + Alert( + onDismiss = { + App.snackbarHost.isActive = false + App.snackbarHost.currentSnackbarData?.dismiss() + } + ) + }, + ) { padding -> + NavHost( + modifier = Modifier + .padding(padding), + navController = navController, + startDestination = Screens.Home.route + ) { + composable( + route = Screens.Home.route + ) { + EnterAnimation { + HomeRefresh { + NekosAppBar( + navController = navController, + dataStore = dataStore, + route = currentRoute + ) { + Home( + navController = navController + ) + } + } + } + } + + composable( + route = Screens.Post.route + ) { + val jsonData = it.arguments?.getString("data") + val data = Gson().fromJson(jsonData, Neko::class.java) + EnterAnimation { + NekosAppBar( + navController = navController, + dataStore = dataStore, + route = currentRoute + ) { + Post( + navController = navController, + data = data + ) + } + } + } + + composable( + route = Screens.Settings.route + ) { + EnterAnimation { + NekosAppBar( + navController = navController, + dataStore = dataStore, + route = currentRoute + ) { + Settings(dataStore = dataStore) + } + } + } + + // -BEGIN: PROFILE FLOW + composable( + route = Screens.Login.route + ) { + EnterAnimation { + Login( + dataStore = dataStore, + navController = navController + ) + } + } + + composable( + route = Screens.Register.route + ) { + EnterAnimation { + Register( + navController = navController + ) + } + } + + composable( + route = Screens.Profile.route + ) { + EnterAnimation { + NekosAppBar( + navController = navController, + dataStore = dataStore, + route = currentRoute + ) { toolbarState -> + Profile( + scrollState = toolbarState, + navController = navController + ) + } + } + } + + composable( + route = Screens.User.route + ) { + val userID = it.arguments?.getString("id") ?: return@composable + EnterAnimation { + NekosAppBar( + navController = navController, + dataStore = dataStore, + route = currentRoute + ) { toolbarState -> + User( + scrollState = toolbarState, + navController = navController, + id = userID + ) + } + } + } + // -END: PROFILE FLOW + } + } + } + } + } + } +} diff --git a/app/src/main/java/dev/vdbroek/nekos/SplashActivity.kt b/app/src/main/java/dev/vdbroek/nekos/SplashActivity.kt new file mode 100644 index 0000000..85135a8 --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/SplashActivity.kt @@ -0,0 +1,65 @@ +package dev.vdbroek.nekos + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.lifecycleScope +import com.github.kittinunf.fuel.core.FuelManager +import dev.vdbroek.nekos.api.Nekos +import dev.vdbroek.nekos.api.UserState +import dev.vdbroek.nekos.ui.screens.HomeScreenState +import dev.vdbroek.nekos.ui.theme.ThemeState +import dev.vdbroek.nekos.utils.* +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +@SuppressLint("CustomSplashScreen") +class SplashActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val splashScreen = installSplashScreen() + splashScreen.setKeepOnScreenCondition { true } + } + super.onCreate(savedInstanceState) + + val (version, code) = App.getVersions(this) + App.version = version + App.versionCode = code + App.userAgent = "Nekos-Android/v$version (https://github.com/Pepijn98/Nekos)" + + FuelManager.instance.basePath = App.baseUrl + FuelManager.instance.baseHeaders = mapOf("User-Agent" to App.userAgent) + + lifecycleScope.launchWhenCreated { + // Set theme colors + ThemeState.isDark = dataStore.data.map { it[IS_DARK] ?: true }.first() + ThemeState.manual = dataStore.data.map { it[MANUAL] ?: false }.first() + ThemeState.staggered = dataStore.data.map { it[STAGGERED] ?: false }.first() + + UserState.isLoggedIn = dataStore.data.map { it[IS_LOGGED_IN] ?: false }.first() + if (UserState.isLoggedIn) { + UserState.token = dataStore.data.map { it[TOKEN] }.first() + UserState.username = dataStore.data.map { it[USERNAME] }.first() + } + + val (tagsResponse) = Nekos.getTags() + if (tagsResponse != null) { + App.tags.addAll(tagsResponse.tags) + } + + val (response, exception) = Nekos.getImages() + when { + response != null -> HomeScreenState.images.addAll(response.images.filter { !it.tags.contains(App.buggedTag) }) + exception != null -> finish() + } + + val intent = Intent(this@SplashActivity, MainActivity::class.java) + startActivity(intent) + finish() + } + } +} diff --git a/app/src/main/java/dev/vdbroek/nekos/api/Api.kt b/app/src/main/java/dev/vdbroek/nekos/api/Api.kt new file mode 100644 index 0000000..1cde831 --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/api/Api.kt @@ -0,0 +1,29 @@ +package dev.vdbroek.nekos.api + +import com.github.kittinunf.fuel.core.FuelError +import com.google.gson.Gson +import dev.vdbroek.nekos.models.ApiException +import dev.vdbroek.nekos.models.HttpException +import dev.vdbroek.nekos.utils.Response +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import okhttp3.OkHttpClient + +open class Api { + val client = OkHttpClient() + val coroutine = CoroutineScope(Dispatchers.IO) + + fun handleException(exception: FuelError?, label: String = "UNKNOWN"): Response { + return if (exception != null) { + val httpException: HttpException? = try { + Gson().fromJson(String(exception.errorData), HttpException::class.java) + } catch (e: Exception) { + null + } + + Response(null, if (httpException != null) ApiException(httpException, label) else exception) + } else { + Response(null, Exception("[$label]: Invalid response from API")) + } + } +} diff --git a/app/src/main/java/dev/vdbroek/nekos/api/Nekos.kt b/app/src/main/java/dev/vdbroek/nekos/api/Nekos.kt new file mode 100644 index 0000000..83f999c --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/api/Nekos.kt @@ -0,0 +1,135 @@ +package dev.vdbroek.nekos.api + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import com.github.kittinunf.fuel.httpGet +import com.github.kittinunf.fuel.httpPost +import com.github.kittinunf.result.Result +import com.google.gson.Gson +import dev.vdbroek.nekos.components.SnackbarType +import dev.vdbroek.nekos.components.showCustomSnackbar +import dev.vdbroek.nekos.models.* +import dev.vdbroek.nekos.ui.screens.HomeScreenState +import dev.vdbroek.nekos.utils.App +import dev.vdbroek.nekos.utils.Response +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlin.properties.Delegates + +object NekosRequestState { + var end by mutableStateOf(false) + var skip by mutableStateOf(0) + // var tags = mutableStateListOf() + var tags = App.defaultTags.toMutableStateList() + var sort by Delegates.observable("newest") { _, _, _ -> + // Clear current loaded images and request new ones with the updated sorting option + val coroutine = CoroutineScope(Dispatchers.Default) + coroutine.launch { + val (response, exception) = Nekos.getImages() + when { + response != null -> { + HomeScreenState.images.apply { + clear() + addAll(response.images.filter { !it.tags.contains(App.buggedTag) }) + } + } + exception != null -> { + App.snackbarHost.showCustomSnackbar( + message = exception.message ?: "Failed to fetch images", + actionLabel = "x", + withDismissAction = true, + snackbarType = SnackbarType.DANGER, + duration = SnackbarDuration.Long + ) + } + } + } + } +} + +object Nekos : Api() { + data class ImageSearchBody( + val nsfw: Boolean = false, + val tags: List, + val limit: Int = 30, + val skip: Int, + val sort: String, + val uploader: String? = null + ) + + suspend fun getImages(): Response { + if (NekosRequestState.end) return Response(null, EndException("You have reached the end")) + + // Remove all images with tags that could potentially show sexually suggestive images + val tags = NekosRequestState.tags.map { + if (it.startsWith("-")) { + val tag = it.replaceFirst("-", "") + "-\"$tag\"" + } else { + "\"$it\"" + } + } + + val bodyData = ImageSearchBody( +// nsfw = true, + tags = tags, + skip = NekosRequestState.skip, + sort = NekosRequestState.sort + ) + + val (_, _, result) = coroutine.async { + return@async "/images/search".httpPost() + .header(mapOf("Content-Type" to "application/json")) + .body(Gson().toJson(bodyData)) + .responseString() + }.await() + + val (data, exception) = result + when (result) { + is Result.Success -> { + NekosRequestState.skip += 30 + + if (data != null) { + val nekosResponse = Gson().fromJson(data, NekosResponse::class.java) + if (nekosResponse.images.size == 0) { + NekosRequestState.end = true + return Response(null, EndException("You have reached the end")) + } + + return Response(nekosResponse, null) + } + return Response(null, Exception("No data returned")) + } + is Result.Failure -> { + return handleException(exception) + } + } + } + + suspend fun getTags(): Response { + val (_, _, result) = coroutine.async { + return@async "/tags".httpGet() + .header(mapOf("Content-Type" to "application/json")) + .responseString() + }.await() + + val (data, exception) = result + return when (result) { + is Result.Success -> { + if (data != null) { + Response(Gson().fromJson(data, TagsResponse::class.java), null) + } else { + Response(null, Exception("[GET_TAGS]: Invalid response from API")) + } + } + is Result.Failure -> { + handleException(exception, "GET_TAGS") + } + } + } +} diff --git a/app/src/main/java/dev/vdbroek/nekos/api/User.kt b/app/src/main/java/dev/vdbroek/nekos/api/User.kt new file mode 100644 index 0000000..232ff59 --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/api/User.kt @@ -0,0 +1,240 @@ +package dev.vdbroek.nekos.api + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import com.github.kittinunf.fuel.core.FuelError +import com.github.kittinunf.fuel.httpGet +import com.github.kittinunf.fuel.httpPost +import com.github.kittinunf.result.Result +import com.google.gson.Gson +import dev.vdbroek.nekos.models.EndException +import dev.vdbroek.nekos.models.LoginResponse +import dev.vdbroek.nekos.models.NekosResponse +import dev.vdbroek.nekos.models.UserResponse +import dev.vdbroek.nekos.utils.App +import dev.vdbroek.nekos.utils.Response +import kotlinx.coroutines.async +import okhttp3.Headers +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +sealed class RelationshipType(val value: String) { + object Like : RelationshipType("like") + object Favorite : RelationshipType("favorite") +} + +object UserRequestState { + var end by mutableStateOf(false) + var skip by mutableStateOf(0) + + // var tags = mutableStateListOf() + var tags = App.defaultTags.toMutableStateList() +} + +object UserState { + var isLoggedIn by mutableStateOf(false) + var token by mutableStateOf(null) + var username by mutableStateOf(null) +} + +object User : Api() { + + suspend fun authenticate(username: String, password: String): Response { + val (_, _, result) = coroutine.async { + return@async "/auth".httpPost() + .header(mapOf("Content-Type" to "application/json")) + .body("{\"username\": \"$username\", \"password\": \"$password\"}") + .responseString() + }.await() + + val (data, exception) = result + return when (result) { + is Result.Success -> { + if (data != null) { + Response(Gson().fromJson(data, LoginResponse::class.java), null) + } else { + Response(null, Exception("[AUTH]: Invalid response from API")) + } + } + is Result.Failure -> { + handleException(exception, "AUTH") + } + } + } + + suspend fun register(email: String, username: String, password: String): Response { + val (_, response, result) = coroutine.async { + return@async "/register".httpPost() + .header(mapOf("Content-Type" to "application/json")) + .body("{\"username\": \"$username\", \"email\": \"$email\", \"password\": \"$password\"}") + .responseString() + }.await() + + val (data, exception) = result + return when (result) { + is Result.Success -> { + if (data != null || response.statusCode == 201) { + Response("A confirmation email has been send, please confirm before logging in.", null) + } else { + Response(null, Exception("[REGISTER]: Invalid response from API")) + } + } + is Result.Failure -> { + handleException(exception, "REGISTER") + } + } + } + + suspend fun getMe(): Response { + if (UserState.token.isNullOrBlank()) + return Response(null, Exception("Not logged in")) + + val (_, _, result) = coroutine.async { + return@async "/user/@me".httpGet() + .header(mapOf("Authorization" to UserState.token!!)) + .responseString() + }.await() + + val (data, exception) = result + return when (result) { + is Result.Success -> { + if (data != null) { + Response(Gson().fromJson(data, UserResponse::class.java), null) + } else { + Response(null, Exception("[GET_ME]: Invalid response from API")) + } + } + is Result.Failure -> { + handleException(exception, "GET_ME") + } + } + } + + suspend fun getUser(id: String): Response { + val (_, _, result) = coroutine.async { + return@async "/user/$id".httpGet() + .responseString() + }.await() + + val (data, exception) = result + return when (result) { + is Result.Success -> { + if (data != null) { + Response(Gson().fromJson(data, UserResponse::class.java), null) + } else { + Response(null, Exception("[GET_USER]: Invalid response from API")) + } + } + is Result.Failure -> { + handleException(exception, "GET_USER") + } + } + } + + suspend fun getUploads(uploader: String): Response { + if (UserRequestState.end) return Response(null, EndException("You have reached the end")) + + // Remove all images with tags that could potentially show sexually suggestive images + val tags = UserRequestState.tags.map { + if (it.startsWith("-")) { + val tag = it.replaceFirst("-", "") + "-\"$tag\"" + } else { + "\"$it\"" + } + } + + val bodyData = Nekos.ImageSearchBody( +// nsfw = true, + tags = tags, + skip = UserRequestState.skip, + sort = "newest", + uploader = uploader + ) + + val (_, _, result) = coroutine.async { + return@async "/images/search".httpPost() + .header(mapOf("Content-Type" to "application/json")) + .body(Gson().toJson(bodyData)) + .responseString() + }.await() + + val (data, exception) = result + when (result) { + is Result.Success -> { + UserRequestState.skip += 30 + + if (data != null) { + val nekosResponse = Gson().fromJson(data, NekosResponse::class.java) + + if (nekosResponse.images.size == 0) { + UserRequestState.end = true + return Response(null, EndException("You have reached the end")) + } + + // We request 30 images if the amount of images returned is less we've reached the end + // but we don't need to return since the response still has some images + if (nekosResponse.images.size < 30) { + UserRequestState.end = true + } + + return Response(nekosResponse, null) + } + return Response(null, Exception("No data returned")) + } + is Result.Failure -> { + return handleException(exception) + } + } + } + + suspend fun patchRelationship(image: String, type: String, create: Boolean): Response { + if (!UserState.isLoggedIn || UserState.token.isNullOrBlank()) return Response(null, Exception("[PATCH]: Not logged in")) + + val jsonBody = "{\"type\": \"$type\", \"create\": $create}" + .toRequestBody("application/json".toMediaTypeOrNull()) + + val request = Request.Builder() + .url("${App.baseUrl}/image/$image/relationship") + .headers( + Headers.Builder() + .add("Authorization", UserState.token!!) + .add("User-Agent", App.userAgent) + .add("Content-Type", "application/json;charset=utf-8") + .build() + ) + .patch(jsonBody) + .build() + + @Suppress("BlockingMethodInNonBlockingContext") + val result = coroutine.async { + return@async try { + val response = client.newCall(request) + .execute() + .also { + it.close() + } + if (response.isSuccessful && response.code in 200..204) { + Result.success(true) + } else { + Result.error(FuelError.wrap(Exception("Invalid response from API"))) + } + } catch (e: Exception) { + Result.error(FuelError.wrap(e)) + } + }.await() + + val (data, exception) = result + return when (result) { + is Result.Success -> { + Response(data, null) + } + is Result.Failure -> { + handleException(exception, "PATCH") + } + } + } +} diff --git a/app/src/main/java/dev/vdbroek/nekos/components/Alert.kt b/app/src/main/java/dev/vdbroek/nekos/components/Alert.kt new file mode 100644 index 0000000..c8e474c --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/components/Alert.kt @@ -0,0 +1,158 @@ +package dev.vdbroek.nekos.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Clear +import androidx.compose.material.icons.rounded.Home +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.vdbroek.nekos.ui.theme.NekoColors +import dev.vdbroek.nekos.ui.theme.ThemeState +import dev.vdbroek.nekos.utils.App +import kotlinx.coroutines.delay +import java.util.* + +enum class SnackbarType { + DEFAULT, + INFO, + SUCCESS, + WARNING, + DANGER +} + +/** + * If there's currently a snackbar shown + */ +var SnackbarHostState.isActive by mutableStateOf(false) +var SnackbarHostState.type by mutableStateOf(SnackbarType.DEFAULT) + +suspend fun SnackbarHostState.showCustomSnackbar( + message: String, + actionLabel: String? = null, + withDismissAction: Boolean, + snackbarType: SnackbarType = SnackbarType.DEFAULT, + duration: SnackbarDuration = SnackbarDuration.Short +): SnackbarResult { + // If there's currently a snackbar dismiss that one and open the new one + if (isActive) { + currentSnackbarData?.dismiss() + isActive = false + } + + type = snackbarType + isActive = true + return showSnackbar(message, actionLabel, withDismissAction, duration) +} + +@Composable +fun Alert( + modifier: Modifier = Modifier, + onDismiss: () -> Unit? +) { + SnackbarHost( + modifier = modifier, + hostState = App.snackbarHost, + snackbar = { data -> + // Update snackbar active state when snackbar auto hides after X seconds. + // Unless it's set to Indefinite, which means the user has to manually dismiss the snackbar + if (data.visuals.duration != SnackbarDuration.Indefinite) { + val time = when (data.visuals.duration) { + SnackbarDuration.Long -> 10000L + SnackbarDuration.Short -> 4000L + else -> return@SnackbarHost // Else will never reach but Kotlin doesn't seem to recognize that + } + + LaunchedEffect(key1 = true) { + delay(time) + onDismiss() + } + } + + val backgroundColor = when (App.snackbarHost.type) { + SnackbarType.DEFAULT -> if (ThemeState.isDark) NekoColors.darkCard else MaterialTheme.colorScheme.background + SnackbarType.INFO -> NekoColors.info + SnackbarType.SUCCESS -> NekoColors.success + SnackbarType.WARNING -> NekoColors.warning + SnackbarType.DANGER -> NekoColors.danger + } + + val textColor = when (App.snackbarHost.type) { + SnackbarType.DEFAULT -> if (ThemeState.isDark) NekoColors.light else MaterialTheme.colorScheme.onBackground + SnackbarType.WARNING -> NekoColors.dark + else -> NekoColors.light + } + + Snackbar( + modifier = Modifier + .padding(8.dp), + containerColor = backgroundColor, + contentColor = textColor, + actionContentColor = textColor, + action = { + data.visuals.actionLabel?.let { label -> + when (label.lowercase(Locale.getDefault())) { + "x" -> { + IconButton( + onClick = { + onDismiss() + } + ) { + Icon( + imageVector = Icons.Rounded.Clear, + contentDescription = null + ) + } + } + "home" -> { + IconButton( + onClick = { + onDismiss() + } + ) { + Icon( + imageVector = Icons.Rounded.Home, + contentDescription = null + ) + } + } + else -> { + TextButton( + onClick = { + onDismiss() + } + ) { + Text( + text = label, + style = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W500, + fontSize = 16.sp + ), + color = textColor + ) + } + } + } + } + }, + content = { + Text( + text = data.visuals.message, + style = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W500, + fontSize = 16.sp + ), + color = textColor + ) + } + ) + } + ) +} diff --git a/app/src/main/java/dev/vdbroek/nekos/components/HomeRefresh.kt b/app/src/main/java/dev/vdbroek/nekos/components/HomeRefresh.kt new file mode 100644 index 0000000..eeae9cc --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/components/HomeRefresh.kt @@ -0,0 +1,73 @@ +package dev.vdbroek.nekos.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.unit.dp +import com.google.accompanist.swiperefresh.SwipeRefresh +import com.google.accompanist.swiperefresh.SwipeRefreshIndicator +import com.google.accompanist.swiperefresh.rememberSwipeRefreshState +import dev.vdbroek.nekos.api.Nekos +import dev.vdbroek.nekos.api.NekosRequestState +import dev.vdbroek.nekos.ui.screens.HomeScreenState +import dev.vdbroek.nekos.utils.App +import dev.vdbroek.nekos.utils.rememberMutableStateOf +import kotlinx.coroutines.launch + +@Composable +fun HomeRefresh( + content: @Composable () -> Unit +) { + val coroutine = rememberCoroutineScope() + val refreshState = rememberMutableStateOf(false) + val swipeRefreshState = rememberSwipeRefreshState(refreshState.value) + + SwipeRefresh( + state = swipeRefreshState, + indicatorPadding = PaddingValues( + top = 108.dp + ), + indicator = { state, trigger -> + SwipeRefreshIndicator( + state = state, + refreshTriggerDistance = trigger, + scale = true, + backgroundColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + shape = CircleShape, + ) + }, + onRefresh = { + refreshState.value = true + NekosRequestState.apply { + end = false + skip = 0 + } + coroutine.launch { + val (response, exception) = Nekos.getImages() + when { + response != null -> { + HomeScreenState.images.apply { + clear() + addAll(response.images.filter { !it.tags.contains(App.buggedTag) }) + } + refreshState.value = false + } + exception != null -> { + App.snackbarHost.showCustomSnackbar( + message = exception.message ?: "Failed to fetch images", + actionLabel = "x", + withDismissAction = true, + snackbarType = SnackbarType.DANGER, + duration = SnackbarDuration.Long + ) + } + } + } + }, + content = content + ) +} diff --git a/app/src/main/java/dev/vdbroek/nekos/components/InfiniteList.kt b/app/src/main/java/dev/vdbroek/nekos/components/InfiniteList.kt new file mode 100644 index 0000000..34bb474 --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/components/InfiniteList.kt @@ -0,0 +1,192 @@ +package dev.vdbroek.nekos.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.google.gson.Gson +import com.nesyou.staggeredgrid.LazyStaggeredGrid +import com.nesyou.staggeredgrid.StaggeredCells +import dev.vdbroek.nekos.models.Neko +import dev.vdbroek.nekos.ui.Screens +import dev.vdbroek.nekos.ui.theme.NekoColors +import dev.vdbroek.nekos.ui.theme.ThemeState +import dev.vdbroek.nekos.ui.theme.imageShape +import dev.vdbroek.nekos.utils.App +import kotlinx.coroutines.flow.distinctUntilChanged +import java.net.URLEncoder + +object InfiniteListState { + var scrollState: ScrollableState? = null +} + +@Composable +fun InfiniteList( + items: SnapshotStateList, + navController: NavController, + cells: Int, + onLoadMore: () -> Unit +) { + if (ThemeState.staggered) { + InfiniteListState.scrollState = rememberLazyListState() + StaggeredItems( + listState = InfiniteListState.scrollState as LazyListState, + items = items, + navController = navController, + cells = cells + ) + } else { + InfiniteListState.scrollState = rememberLazyGridState() + FixedItems( + gridState = InfiniteListState.scrollState as LazyGridState, + items = items, + navController = navController, + cells = cells + ) + } + + InfiniteListHandler(scrollableState = InfiniteListState.scrollState!!) { + onLoadMore() + } +} + +@Composable +fun StaggeredItems( + listState: LazyListState, + items: SnapshotStateList, + navController: NavController, + cells: Int +) { + LazyStaggeredGrid( + modifier = Modifier.fillMaxWidth(), + state = listState, + cells = StaggeredCells.Fixed(cells), + contentPadding = PaddingValues( + start = 6.dp, + top = 8.dp, + end = 6.dp, + bottom = 8.dp + ) + ) { + items(items.size) { i -> + ListItem(data = items[i]) { + val jsonData = Gson().toJson(it) + // We HAVE to urlencode the json since there's a tag that contains a forward slash (":/") which breaks the navigation routing obviously + val encoded = URLEncoder.encode(jsonData, "utf-8") + navController.navigate(Screens.Post.route.replace("{data}", encoded)) + } + } + } +} + +@Composable +fun FixedItems( + gridState: LazyGridState, + items: SnapshotStateList, + navController: NavController, + cells: Int +) { + LazyVerticalGrid( + modifier = Modifier.fillMaxWidth(), + state = gridState, + flingBehavior = App.flingBehavior(), + columns = GridCells.Fixed(cells), + contentPadding = PaddingValues( + start = 6.dp, + top = 8.dp, + end = 6.dp, + bottom = 8.dp + ) + ) { + items(items.size) { i -> + ListItem(data = items[i]) { + val jsonData = Gson().toJson(it) + // We HAVE to urlencode the json since there's a tag that contains a forward slash (":/") which breaks the navigation routing obviously + val encoded = URLEncoder.encode(jsonData, "utf-8") + navController.navigate(Screens.Post.route.replace("{data}", encoded)) + } + } + } +} + +@Composable +fun InfiniteListHandler( + scrollableState: ScrollableState, + buffer: Int = 10, + onLoadMore: () -> Unit +) { + // InfiniteListHandler should only accept LazyListState and LazyGridState + check(scrollableState is LazyListState || scrollableState is LazyGridState) { + "InfiniteListHandler state has to be either LazyListState or LazyGridState" + } + + val loadMore = remember { + derivedStateOf { + val totalItemsNumber: Int + val lastVisibleItemIndex: Int + when (scrollableState) { + is LazyListState -> { + val layoutInfo = scrollableState.layoutInfo + totalItemsNumber = layoutInfo.totalItemsCount + lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 + } + is LazyGridState -> { + val layoutInfo = scrollableState.layoutInfo + totalItemsNumber = layoutInfo.totalItemsCount + lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 + } + else -> throw IllegalStateException("InfiniteListHandler state has to be either LazyListState or LazyGridState") + } + + lastVisibleItemIndex > (totalItemsNumber - buffer) + } + } + + LaunchedEffect(loadMore) { + snapshotFlow { loadMore.value } + .distinctUntilChanged() + .collect { + // Only load more when true + if (it) onLoadMore() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ListItem(data: Neko, onItemClicked: (Neko) -> Unit) { + Card( + modifier = Modifier + .padding(6.dp) + .shadow(3.dp, imageShape, true, NekoColors.dark) + .clip(imageShape) + .clickable(onClick = { onItemClicked(data) }), + shape = imageShape + ) { + NetworkImage( + url = data.getThumbnailUrl(), + modifier = if (ThemeState.staggered) Modifier.fillMaxSize() else Modifier + .fillMaxWidth() + .height(180.dp), + alignment = Alignment.Center, + contentScale = if (ThemeState.staggered) ContentScale.FillWidth else ContentScale.Crop, + contentDescription = "Image" + ) + } +} diff --git a/app/src/main/java/dev/vdbroek/nekos/components/InfiniteRow.kt b/app/src/main/java/dev/vdbroek/nekos/components/InfiniteRow.kt new file mode 100644 index 0000000..7cee4b4 --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/components/InfiniteRow.kt @@ -0,0 +1,109 @@ +package dev.vdbroek.nekos.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.google.gson.Gson +import dev.vdbroek.nekos.models.Neko +import dev.vdbroek.nekos.ui.Screens +import dev.vdbroek.nekos.ui.theme.NekoColors +import dev.vdbroek.nekos.ui.theme.imageShape +import kotlinx.coroutines.flow.distinctUntilChanged +import java.net.URLEncoder + +@Composable +fun InfiniteRow( + items: SnapshotStateList, + navController: NavController, + onLoadMore: () -> Unit +) { + val listState = rememberLazyListState() + + LazyRow( + modifier = Modifier.fillMaxWidth(), + state = listState, + contentPadding = PaddingValues( + start = 6.dp, + top = 8.dp, + end = 6.dp, + bottom = 8.dp + ) + ) { + items(items.size) { i -> + ListItem(data = items[i]) { + val jsonData = Gson().toJson(it) + // We HAVE to urlencode the json since there's a tag that contains a forward slash (":/") which breaks the navigation routing obviously + val encoded = URLEncoder.encode(jsonData, "utf-8") + navController.navigate(Screens.Post.route.replace("{data}", encoded)) + } + } + } + + InfiniteRowHandler(scrollableState = listState) { + onLoadMore() + } +} + +@Composable +fun InfiniteRowHandler( + scrollableState: LazyListState, + buffer: Int = 1, + onLoadMore: () -> Unit +) { + val loadMore = remember { + derivedStateOf { + val layoutInfo = scrollableState.layoutInfo + val totalItemsNumber = layoutInfo.totalItemsCount + val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 + + lastVisibleItemIndex > (totalItemsNumber - buffer) + } + } + + LaunchedEffect(loadMore) { + snapshotFlow { loadMore.value } + .distinctUntilChanged() + .collect { + // Only load more when true + if (it) onLoadMore() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ListItem(data: Neko, onItemClicked: (Neko) -> Unit) { + Card( + modifier = Modifier + .padding(6.dp) + .shadow(3.dp, imageShape, true, NekoColors.dark) + .clip(imageShape) + .clickable(onClick = { onItemClicked(data) }), + shape = imageShape + ) { + NetworkImage( + url = data.getThumbnailUrl(), + modifier = Modifier + .size(180.dp), + alignment = Alignment.Center, + contentScale = ContentScale.Crop, + contentDescription = "Image" + ) + } +} diff --git a/app/src/main/java/dev/vdbroek/nekos/components/NekosAppBar.kt b/app/src/main/java/dev/vdbroek/nekos/components/NekosAppBar.kt new file mode 100644 index 0000000..a3f6edb --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/components/NekosAppBar.kt @@ -0,0 +1,221 @@ +package dev.vdbroek.nekos.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Logout +import androidx.compose.material.icons.filled.Sort +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.navigation.NavHostController +import dev.vdbroek.nekos.api.UserRequestState +import dev.vdbroek.nekos.api.UserState +import dev.vdbroek.nekos.ui.Screens +import dev.vdbroek.nekos.ui.screens.ProfileScreenState +import dev.vdbroek.nekos.ui.screens.UserScreenState +import dev.vdbroek.nekos.utils.App +import dev.vdbroek.nekos.utils.IS_LOGGED_IN +import dev.vdbroek.nekos.utils.TOKEN +import dev.vdbroek.nekos.utils.USERNAME +import kotlinx.coroutines.launch +import me.onebone.toolbar.* + +@Composable +fun NekosAppBar( + navController: NavHostController, + dataStore: DataStore, + route: String?, + body: @Composable (CollapsingToolbarScaffoldScope.(CollapsingToolbarState) -> Unit) +) { + val toolbarScaffoldState = rememberCollapsingToolbarScaffoldState() + val coroutine = rememberCoroutineScope() + val toolbarState by remember { derivedStateOf { toolbarScaffoldState.toolbarState } } + + App.globalToolbarState = toolbarState + + CollapsingToolbarScaffold( + modifier = Modifier + .fillMaxSize(), + state = toolbarScaffoldState, + scrollStrategy = ScrollStrategy.EnterAlwaysCollapsed, + toolbar = { + Box( + modifier = Modifier + .fillMaxWidth() + .height(100.dp) + .parallax(ratio = 0.2f) + ) + when (route) { + Screens.Home.route -> { + Box( + modifier = Modifier + .wrapContentSize() + .road( + whenCollapsed = Alignment.CenterEnd, + whenExpanded = Alignment.TopEnd + ) + ) { + SortingDropdown() + IconButton( + onClick = { + SortingDropdownState.expanded = true + } + ) { + Icon( + imageVector = Icons.Filled.Sort, + contentDescription = "Order", + tint = MaterialTheme.colorScheme.onBackground + ) + } + } + } + Screens.Settings.route, + Screens.Post.route -> { + IconButton( + modifier = Modifier + .road( + whenCollapsed = Alignment.CenterStart, + whenExpanded = Alignment.TopStart + ), + onClick = { + navController.popBackStack() + } + ) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onBackground + ) + } + } + Screens.Profile.route -> { + IconButton( + modifier = Modifier + .road( + whenCollapsed = Alignment.CenterStart, + whenExpanded = Alignment.TopStart + ), + onClick = { + navController.popBackStack() + + UserRequestState.apply { + end = false + skip = 0 + tags = App.defaultTags.toMutableStateList() + } + + ProfileScreenState.apply { + uploaderImages.clear() + initialRequest = true + user = null + } + } + ) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onBackground + ) + } + IconButton( + modifier = Modifier + .road( + whenCollapsed = Alignment.CenterEnd, + whenExpanded = Alignment.TopEnd + ), + onClick = { + navController.backQueue.clear() + navController.navigate(Screens.Home.route) + + UserState.apply { + isLoggedIn = false + token = null + username = null + } + + UserRequestState.apply { + end = false + skip = 0 + tags = App.defaultTags.toMutableStateList() + } + + ProfileScreenState.apply { + uploaderImages.clear() + initialRequest = true + user = null + } + + coroutine.launch { + dataStore.edit { preferences -> + preferences[IS_LOGGED_IN] = false + preferences[TOKEN] = "" + preferences[USERNAME] = "" + } + } + } + ) { + Icon( + imageVector = Icons.Filled.Logout, + contentDescription = "Logout", + tint = MaterialTheme.colorScheme.onBackground + ) + } + } + Screens.User.route -> { + IconButton( + modifier = Modifier + .road( + whenCollapsed = Alignment.CenterStart, + whenExpanded = Alignment.TopStart + ), + onClick = { + navController.popBackStack() + + UserRequestState.apply { + end = false + skip = 0 + tags = App.defaultTags.toMutableStateList() + } + + UserScreenState.apply { + uploaderImages.clear() + initialRequest = true + user = null + } + } + ) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onBackground + ) + } + } + } + Text( + modifier = Modifier + .padding(16.dp, 16.dp, 16.dp, 16.dp) + .road( + whenCollapsed = Alignment.Center, + whenExpanded = Alignment.BottomStart + ), + text = App.screenTitle, + fontSize = (MaterialTheme.typography.headlineLarge.fontSize.value + (30 - 18) * toolbarState.progress).sp, + color = MaterialTheme.colorScheme.onBackground + ) + }, + body = { + body(toolbarState) + } + ) +} diff --git a/app/src/main/java/dev/vdbroek/nekos/components/NekosNavBar.kt b/app/src/main/java/dev/vdbroek/nekos/components/NekosNavBar.kt new file mode 100644 index 0000000..10d03cc --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/components/NekosNavBar.kt @@ -0,0 +1,130 @@ +package dev.vdbroek.nekos.components + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Login +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.Login +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.navigation.NavHostController +import dev.vdbroek.nekos.api.UserState +import dev.vdbroek.nekos.ui.Screens +import dev.vdbroek.nekos.utils.App +import kotlinx.coroutines.launch +import me.onebone.toolbar.ExperimentalToolbarApi + +@OptIn(ExperimentalToolbarApi::class) +@Composable +fun NekosNavBar( + navController: NavHostController, + currentRoute: String? +) { + val coroutine = rememberCoroutineScope() + + NavigationBar { + NavigationBarItem( + selected = currentRoute == Screens.Home.route, + label = { + Text(text = "Home") + }, + icon = { + Icon( + imageVector = if (currentRoute == Screens.Home.route) Icons.Filled.Home else Icons.Outlined.Home, + contentDescription = "Home" + ) + }, + onClick = { + if (currentRoute != Screens.Home.route) { + navController.backQueue.clear() + navController.navigate(Screens.Home.route) + } else { + coroutine.launch { + when (InfiniteListState.scrollState) { + is LazyListState -> { + val state = (InfiniteListState.scrollState as LazyListState) + val firstVisibleItemIndex by derivedStateOf { state.firstVisibleItemIndex } + + if (firstVisibleItemIndex > 1) { + state.scrollToItem(0) + App.globalToolbarState?.expand() + } + } + is LazyGridState -> { + val state = (InfiniteListState.scrollState as LazyGridState) + val firstVisibleItemIndex by derivedStateOf { state.firstVisibleItemIndex } + + if (firstVisibleItemIndex > 1) { + state.scrollToItem(0) + App.globalToolbarState?.expand() + } + } + } + } + } + } + ) + if (UserState.isLoggedIn) { + NavigationBarItem( + selected = currentRoute == Screens.Profile.route, + label = { + Text(text = "Profile") + }, + icon = { + Icon( + imageVector = if (currentRoute == Screens.Profile.route) Icons.Filled.Person else Icons.Outlined.Person, + contentDescription = "Profile" + ) + }, + onClick = { + if (currentRoute != Screens.Profile.route) { + navController.navigate(Screens.Profile.route) + } + } + ) + } else { + NavigationBarItem( + selected = false, + label = { + Text(text = "Login") + }, + icon = { + Icon( + imageVector = if (currentRoute == Screens.Login.route) Icons.Filled.Login else Icons.Outlined.Login, + contentDescription = "Login" + ) + }, + onClick = { + navController.navigate(Screens.Login.route) + } + ) + } + NavigationBarItem( + selected = currentRoute == Screens.Settings.route, + label = { + Text(text = "Settings") + }, + icon = { + Icon( + imageVector = if (currentRoute == Screens.Settings.route) Icons.Filled.Settings else Icons.Outlined.Settings, + contentDescription = "Settings" + ) + }, + onClick = { + navController.navigate(Screens.Settings.route) + } + ) + } +} diff --git a/app/src/main/java/dev/vdbroek/nekos/components/NekosScaffold.kt b/app/src/main/java/dev/vdbroek/nekos/components/NekosScaffold.kt new file mode 100644 index 0000000..21d6c0a --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/components/NekosScaffold.kt @@ -0,0 +1,150 @@ +package dev.vdbroek.nekos.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Login +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.Login +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import dev.vdbroek.nekos.api.UserState +import dev.vdbroek.nekos.ui.Screens +import dev.vdbroek.nekos.utils.App +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NekosScaffold( + navController: NavHostController, + currentRoute: String +) { + val coroutine = rememberCoroutineScope() + + Scaffold( + contentColor = MaterialTheme.colorScheme.background, + bottomBar = { + if ( + currentRoute == Screens.Login.route || + currentRoute == Screens.Register.route || + currentRoute == Screens.Post.route + ) return@Scaffold + + NavigationBar { + NavigationBarItem( + selected = currentRoute == Screens.Home.route, + label = { + Text(text = "Home") + }, + icon = { + Icon( + imageVector = if (currentRoute == Screens.Home.route) Icons.Filled.Home else Icons.Outlined.Home, + contentDescription = "Home" + ) + }, + onClick = { + if (currentRoute != Screens.Home.route) { + navController.backQueue.clear() + navController.navigate(Screens.Home.route) + } else { + coroutine.launch { + when (InfiniteListState.scrollState) { + is LazyListState -> { + val state = (InfiniteListState.scrollState as LazyListState) + val firstVisibleItemIndex by derivedStateOf { state.firstVisibleItemIndex } + + if (firstVisibleItemIndex > 1) { + state.scrollToItem(0) +// toolbarState.expand() + } + } + is LazyGridState -> { + val state = (InfiniteListState.scrollState as LazyGridState) + val firstVisibleItemIndex by derivedStateOf { state.firstVisibleItemIndex } + + if (firstVisibleItemIndex > 1) { + state.scrollToItem(0) +// toolbarState.expand() + } + } + } + } + } + } + ) + if (UserState.isLoggedIn) { + NavigationBarItem( + selected = currentRoute == Screens.Profile.route, + label = { + Text(text = "Profile") + }, + icon = { + Icon( + imageVector = if (currentRoute == Screens.Profile.route) Icons.Filled.Person else Icons.Outlined.Person, + contentDescription = "Profile" + ) + }, + onClick = { + if (currentRoute != Screens.Profile.route) { + navController.navigate(Screens.Profile.route) + } + } + ) + } else { + NavigationBarItem( + selected = false, + label = { + Text(text = "Login") + }, + icon = { + Icon( + imageVector = if (currentRoute == Screens.Login.route) Icons.Filled.Login else Icons.Outlined.Login, + contentDescription = "Login" + ) + }, + onClick = { + navController.navigate(Screens.Login.route) + } + ) + } + NavigationBarItem( + selected = currentRoute == Screens.Settings.route, + label = { + Text(text = "Settings") + }, + icon = { + Icon( + imageVector = if (currentRoute == Screens.Settings.route) Icons.Filled.Settings else Icons.Outlined.Settings, + contentDescription = "Settings" + ) + }, + onClick = { + navController.navigate(Screens.Settings.route) + } + ) + } + }, + snackbarHost = { + Alert( + onDismiss = { + App.snackbarHost.isActive = false + App.snackbarHost.currentSnackbarData?.dismiss() + } + ) + }, + ) { padding -> + Box(modifier = Modifier.padding(padding)) + } +} diff --git a/app/src/main/java/dev/vdbroek/nekos/components/NetworkImage.kt b/app/src/main/java/dev/vdbroek/nekos/components/NetworkImage.kt new file mode 100644 index 0000000..5cf075a --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/components/NetworkImage.kt @@ -0,0 +1,93 @@ +package dev.vdbroek.nekos.components + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.drawable.Drawable +import androidx.compose.foundation.Image +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy +import com.bumptech.glide.request.RequestOptions +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import dev.vdbroek.nekos.R +import dev.vdbroek.nekos.utils.GlideApp +import java.io.ByteArrayOutputStream + + +@Composable +fun NetworkImage( + url: String?, + contentDescription: String?, + modifier: Modifier = Modifier, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + thumbnail: Boolean = true +) { + + val context = LocalContext.current + val placeholder = painterResource(id = R.drawable.placeholder) + val failedPlaceholder = painterResource(id = R.drawable.no_image_placeholder) + var state by remember { mutableStateOf(placeholder) } + var isPlaceholder by remember { mutableStateOf(true) } + + val requestOptions = if (thumbnail) { + // Caching a big list of images makes the scrolling very underperform, who would have tought + RequestOptions() + .downsample(DownsampleStrategy.CENTER_INSIDE) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + } else { + RequestOptions() + .downsample(DownsampleStrategy.CENTER_INSIDE) + } + + GlideApp.with(context) + .asBitmap() + .load(url) + .centerInside() + .apply(requestOptions) + .into(object : CustomTarget() { + override fun onLoadCleared(p: Drawable?) { + isPlaceholder = true + state = placeholder + } + + override fun onLoadFailed(errorDrawable: Drawable?) { + isPlaceholder = true + state = failedPlaceholder + } + + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + isPlaceholder = false + state = if (thumbnail) { + val stream = ByteArrayOutputStream() + resource.compress(Bitmap.CompressFormat.JPEG, 50, stream) + val byteArray = stream.toByteArray() + val compressed = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) + BitmapPainter(compressed.asImageBitmap()) + } else { + BitmapPainter(resource.asImageBitmap()) + } + } + }) + + Image( + painter = state, + contentDescription = contentDescription, + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + alpha = if (isPlaceholder) 0.2f else alpha, + colorFilter = colorFilter + ) +} diff --git a/app/src/main/java/dev/vdbroek/nekos/components/RoundedTextField.kt b/app/src/main/java/dev/vdbroek/nekos/components/RoundedTextField.kt new file mode 100644 index 0000000..dc95563 --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/components/RoundedTextField.kt @@ -0,0 +1,143 @@ +package dev.vdbroek.nekos.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.foundation.text.selection.TextSelectionColors +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.vdbroek.nekos.R +import dev.vdbroek.nekos.ui.theme.ThemeState +import dev.vdbroek.nekos.utils.alpha +import dev.vdbroek.nekos.utils.dim +import dev.vdbroek.nekos.utils.lighten + +@Composable +fun RoundedTextField( + modifier: Modifier = Modifier, + text: String, + placeholder: String, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + counter: Boolean = false, + maxChar: Int = 10, + isPassword: Boolean = false, + isError: Boolean = false, + singleLine: Boolean = true, + maxLines: Int = 1, + icon: @Composable (() -> Unit)? = null, + onValueChange: (String) -> Unit +) { + var pwVisible by rememberSaveable { mutableStateOf(false) } + + val primaryErrorColor = if (isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + } + + val mCustomTextSelectionColors = TextSelectionColors( + handleColor = primaryErrorColor, + backgroundColor = primaryErrorColor.alpha(0.6f) + ) + + Column { + CompositionLocalProvider(LocalTextSelectionColors provides mCustomTextSelectionColors) { + TextField( + modifier = modifier + .border( + border = BorderStroke( + width = 1.dp, + color = primaryErrorColor + ), + shape = CircleShape + ), + value = text, + onValueChange = onValueChange, + placeholder = { + Text( + text = placeholder + ) + }, + keyboardOptions = keyboardOptions, + visualTransformation = if (!pwVisible && isPassword) { + PasswordVisualTransformation() + } else { + VisualTransformation.None + }, + isError = isError, + singleLine = singleLine, + maxLines = maxLines, + shape = CircleShape, + leadingIcon = icon, + trailingIcon = { + if (isPassword) { + val description = if (pwVisible) { + "Hide password" + } else { + "Show password" + } + + IconButton( + onClick = { + pwVisible = !pwVisible + } + ) { + Icon( + imageVector = if (pwVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff, + contentDescription = description + ) + } + } + }, + colors = TextFieldDefaults.textFieldColors( + containerColor = MaterialTheme.colorScheme.background, + placeholderColor = if (ThemeState.isDark) { + MaterialTheme.colorScheme.onBackground.dim(0.6f) + } else { + MaterialTheme.colorScheme.onBackground.lighten(0.6f) + }, + cursorColor = MaterialTheme.colorScheme.primary, + errorCursorColor = MaterialTheme.colorScheme.error, + disabledTrailingIconColor = MaterialTheme.colorScheme.onBackground, + errorTrailingIconColor = MaterialTheme.colorScheme.onBackground, + focusedTrailingIconColor = MaterialTheme.colorScheme.onBackground, + unfocusedTrailingIconColor = MaterialTheme.colorScheme.onBackground, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ) + ) + } + + if (counter) { + Text( + modifier = Modifier + .fillMaxWidth(), + text = "${text.length} / $maxChar", + textAlign = TextAlign.End, + color = if (isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onBackground + }, + style = MaterialTheme.typography.labelSmall + ) + } + } +} diff --git a/app/src/main/java/dev/vdbroek/nekos/components/SortingDropdown.kt b/app/src/main/java/dev/vdbroek/nekos/components/SortingDropdown.kt new file mode 100644 index 0000000..189fe7f --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/components/SortingDropdown.kt @@ -0,0 +1,70 @@ +package dev.vdbroek.nekos.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import dev.vdbroek.nekos.api.NekosRequestState +import dev.vdbroek.nekos.utils.App +import dev.vdbroek.nekos.utils.capitalize + +object SortingDropdownState { + var expanded by mutableStateOf(false) + var selected by mutableStateOf(App.defaultSort) +} + +@Composable +fun SortingDropdown( + modifier: Modifier = Modifier +) { + val items = listOf( + "newest", + "likes", + "oldest", + "relevance" + ) + + DropdownMenu( + modifier = modifier, + expanded = SortingDropdownState.expanded, + offset = DpOffset(36.dp, (-16).dp), + onDismissRequest = { + SortingDropdownState.expanded = false + } + ) { + items.forEach { text -> + DropdownMenuItem( + text = { + Text(text = text.capitalize()) + }, + onClick = { + if (SortingDropdownState.selected != text) { + NekosRequestState.apply { + end = false + skip = 0 + sort = text + tags = App.defaultTags.toMutableStateList() + } + + SortingDropdownState.selected = text + SortingDropdownState.expanded = false + } + }, + trailingIcon = { + if (SortingDropdownState.selected == text) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "Selected sort option" + ) + } + } + ) + } + } +} diff --git a/app/src/main/java/dev/vdbroek/nekos/components/ZoomableImage.kt b/app/src/main/java/dev/vdbroek/nekos/components/ZoomableImage.kt new file mode 100644 index 0000000..ca87754 --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/components/ZoomableImage.kt @@ -0,0 +1,129 @@ +package dev.vdbroek.nekos.components + +/** + * Source code: https://github.com/umutsoysl/ComposeZoomableImage + */ + +import androidx.compose.foundation.* +import androidx.compose.foundation.gestures.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch + +@ExperimentalFoundationApi +@Composable +fun ZoomableImage( + painter: Painter, + modifier: Modifier = Modifier, + backgroundColor: Color = Color.Transparent, + imageAlign: Alignment = Alignment.Center, + shape: Shape = RectangleShape, + maxScale: Float = 1f, + minScale: Float = 3f, + contentScale: ContentScale = ContentScale.Fit, + isRotation: Boolean = false, + isZoomable: Boolean = true, + scrollState: ScrollableState? = null +) { + val coroutineScope = rememberCoroutineScope() + + val scale = remember { mutableStateOf(1f) } + val rotationState = remember { mutableStateOf(1f) } + val offsetX = remember { mutableStateOf(1f) } + val offsetY = remember { mutableStateOf(1f) } + + Box( + modifier = Modifier + .clip(shape) + .background(backgroundColor) + .combinedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { /* NADA :) */ }, + onDoubleClick = { + if (scale.value >= 2f) { + scale.value = 1f + offsetX.value = 1f + offsetY.value = 1f + } else scale.value = 3f + }, + ) + .pointerInput(Unit) { + if (isZoomable) { + forEachGesture { + awaitPointerEventScope { + awaitFirstDown() + do { + val event = awaitPointerEvent() + scale.value *= event.calculateZoom() + if (scale.value > 1) { + scrollState?.run { + coroutineScope.launch { + setScrolling(false) + } + } + val offset = event.calculatePan() + offsetX.value += offset.x + offsetY.value += offset.y + rotationState.value += event.calculateRotation() + scrollState?.run { + coroutineScope.launch { + setScrolling(true) + } + } + } else { + scale.value = 1f + offsetX.value = 1f + offsetY.value = 1f + } + } while (event.changes.any { it.pressed }) + } + } + } + } + + ) { + Image( + painter = painter, + contentDescription = null, + contentScale = contentScale, + modifier = modifier + .align(imageAlign) + .graphicsLayer { + if (isZoomable) { + scaleX = maxOf(maxScale, minOf(minScale, scale.value)) + scaleY = maxOf(maxScale, minOf(minScale, scale.value)) + if (isRotation) { + rotationZ = rotationState.value + } + translationX = offsetX.value + translationY = offsetY.value + } + } + ) + } +} + +suspend fun ScrollableState.setScrolling(value: Boolean) { + scroll(scrollPriority = MutatePriority.PreventUserInput) { + when (value) { + true -> Unit + else -> awaitCancellation() + } + } +} diff --git a/app/src/main/java/dev/vdbroek/nekos/components/ZoomableNetworkImage.kt b/app/src/main/java/dev/vdbroek/nekos/components/ZoomableNetworkImage.kt new file mode 100644 index 0000000..4938dae --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/components/ZoomableNetworkImage.kt @@ -0,0 +1,74 @@ +package dev.vdbroek.nekos.components + +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import dev.vdbroek.nekos.R +import dev.vdbroek.nekos.ui.theme.imageShape +import dev.vdbroek.nekos.utils.GlideApp + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ZoomableNetworkImage( + url: String?, + modifier: Modifier = Modifier, + backgroundColor: Color = MaterialTheme.colorScheme.background, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + shape: Shape = imageShape, + maxScale: Float = 1f, + minScale: Float = 3f, + isRotation: Boolean = false, + isZoomable: Boolean = true, + scrollState: ScrollableState? = null +) { + + val context = LocalContext.current + val placeholder = painterResource(id = R.drawable.placeholder) + val failedPlaceholder = painterResource(id = R.drawable.no_image_placeholder) + var state by remember { mutableStateOf(placeholder) } + + GlideApp.with(context) + .asBitmap() + .load(url) + .centerInside() + .into(object : CustomTarget() { + override fun onLoadCleared(p: Drawable?) { + state = placeholder + } + + override fun onLoadFailed(errorDrawable: Drawable?) { + state = failedPlaceholder + } + + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + state = BitmapPainter(resource.asImageBitmap()) + } + }) + + ZoomableImage( + painter = state, + modifier = modifier, + backgroundColor = backgroundColor, + imageAlign = alignment, + contentScale = contentScale, + shape = shape, + maxScale = maxScale, + minScale = minScale, + isRotation = isRotation, + isZoomable= isZoomable, + scrollState = scrollState + ) +} diff --git a/app/src/main/java/dev/vdbroek/nekos/models/Errors.kt b/app/src/main/java/dev/vdbroek/nekos/models/Errors.kt new file mode 100644 index 0000000..3b38fd6 --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/models/Errors.kt @@ -0,0 +1,17 @@ +package dev.vdbroek.nekos.models + +data class HttpException( + val message: String? +) + +class EndException(override val message: String) : Exception(message) + +class ApiException( + httpException: HttpException, + label: String = "UNKNOWN" +) : Exception( + if (httpException.message != null) + "[$label]: ${httpException.message}" + else + "[$label]: Unknown error from nekos.moe" +) diff --git a/app/src/main/java/dev/vdbroek/nekos/models/Nekos.kt b/app/src/main/java/dev/vdbroek/nekos/models/Nekos.kt new file mode 100644 index 0000000..592b81e --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/models/Nekos.kt @@ -0,0 +1,38 @@ +package dev.vdbroek.nekos.models + +data class Uploader( + val id: String, + val username: String +) + +data class Approver( + val id: String, + val username: String +) + +data class Neko( + val id: String, + val originalHash: String, + val uploader: Uploader, + val approver: Approver?, + val nsfw: Boolean, + val artist: String?, + val tags: ArrayList, + val comments: ArrayList, + val createdAt: String, + val likes: Int, + val favorites: Int +) { + fun getPostUrl(): String = "https://nekos.moe/post/$id" + fun getImageUrl(): String = "https://nekos.moe/image/$id" + fun getThumbnailUrl(): String = "https://nekos.moe/thumbnail/$id" +} + +data class TagsResponse( + val options: Any?, + val tags: ArrayList +) + +data class NekosResponse( + val images: MutableList +) diff --git a/app/src/main/java/dev/vdbroek/nekos/models/UserData.kt b/app/src/main/java/dev/vdbroek/nekos/models/UserData.kt new file mode 100644 index 0000000..d1d6a8f --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/models/UserData.kt @@ -0,0 +1,23 @@ +package dev.vdbroek.nekos.models + +data class UserResponse( + val user: UserData +) + +data class UserData( + val id: String, + val username: String, + val createdAt: String, + val favoritesReceived: Int, + val likesReceived: Int, + val favorites: ArrayList, + val likes: ArrayList, + val uploads: Int, + val roles: ArrayList, + val verified: Boolean, + val savedTags: ArrayList +) + +data class LoginResponse( + val token: String +) diff --git a/app/src/main/java/dev/vdbroek/nekos/ui/Screens.kt b/app/src/main/java/dev/vdbroek/nekos/ui/Screens.kt new file mode 100644 index 0000000..0c9ee71 --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/ui/Screens.kt @@ -0,0 +1,11 @@ +package dev.vdbroek.nekos.ui + +sealed class Screens(val route: String) { + object Home : Screens("home") + object Post : Screens("post/data={data}") + object Settings : Screens("settings") + object Login : Screens("login") + object Register : Screens("register") + object Profile : Screens("profile") + object User : Screens("user/{id}") +} diff --git a/app/src/main/java/dev/vdbroek/nekos/ui/screens/Home.kt b/app/src/main/java/dev/vdbroek/nekos/ui/screens/Home.kt new file mode 100644 index 0000000..1058c88 --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/ui/screens/Home.kt @@ -0,0 +1,59 @@ +package dev.vdbroek.nekos.ui.screens + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.navigation.NavHostController +import dev.vdbroek.nekos.api.Nekos +import dev.vdbroek.nekos.components.InfiniteList +import dev.vdbroek.nekos.components.SnackbarType +import dev.vdbroek.nekos.components.showCustomSnackbar +import dev.vdbroek.nekos.models.EndException +import dev.vdbroek.nekos.models.Neko +import dev.vdbroek.nekos.utils.App +import kotlinx.coroutines.launch + +object HomeScreenState { + val images = mutableStateListOf() +} + +@Composable +fun Home( + navController: NavHostController +) { + App.screenTitle = "Posts" + + val coroutine = rememberCoroutineScope() + + InfiniteList( + items = HomeScreenState.images, + navController = navController, + cells = 2 + ) { + coroutine.launch { + val (response, exception) = Nekos.getImages() + when { + response != null -> HomeScreenState.images.addAll(response.images.filter { !it.tags.contains(App.buggedTag) }) + exception != null && exception is EndException -> { + App.snackbarHost.showCustomSnackbar( + message = exception.message, + actionLabel = "X", + withDismissAction = true, + snackbarType = SnackbarType.INFO, + duration = SnackbarDuration.Short + ) + } + exception != null -> { + App.snackbarHost.showCustomSnackbar( + message = exception.message ?: "Failed to fetch more images", + actionLabel = "X", + withDismissAction = true, + snackbarType = SnackbarType.DANGER, + duration = SnackbarDuration.Long + ) + } + } + } + } +} diff --git a/app/src/main/java/dev/vdbroek/nekos/ui/screens/Login.kt b/app/src/main/java/dev/vdbroek/nekos/ui/screens/Login.kt new file mode 100644 index 0000000..02ca618 --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/ui/screens/Login.kt @@ -0,0 +1,286 @@ +package dev.vdbroek.nekos.ui.screens + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.navigation.NavHostController +import dev.vdbroek.nekos.api.User +import dev.vdbroek.nekos.api.UserState +import dev.vdbroek.nekos.components.RoundedTextField +import dev.vdbroek.nekos.components.SnackbarType +import dev.vdbroek.nekos.components.showCustomSnackbar +import dev.vdbroek.nekos.ui.Screens +import dev.vdbroek.nekos.ui.theme.ThemeState +import dev.vdbroek.nekos.utils.App +import dev.vdbroek.nekos.utils.IS_LOGGED_IN +import dev.vdbroek.nekos.utils.TOKEN +import dev.vdbroek.nekos.utils.USERNAME +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Login( + dataStore: DataStore, + navController: NavHostController +) { + App.screenTitle = "Login" + + val coroutine = rememberCoroutineScope() + + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + + var usernameError by remember { mutableStateOf(false) } + var passwordError by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + ) { + // -START: HEADER + Box( + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(bottomStart = 100.dp) + ) + ) { + Column( + modifier = Modifier + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Image( + modifier = Modifier + .size(148.dp), + imageVector = Icons.Filled.AccountCircle, + contentDescription = "", + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.background) + ) + Text( + text = App.screenTitle, + color = MaterialTheme.colorScheme.background, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + } + } + // -END: HEADER + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 36.dp, end = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // -START: INPUT + RoundedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + text = username, + placeholder = "Username", + counter = true, + isError = usernameError, + maxChar = App.maxUsernameChars, + icon = { + Icon( + imageVector = Icons.Filled.Person, + contentDescription = "Username icon" + ) + } + ) { + if (it.length <= App.maxUsernameChars) { + username = it + usernameError = !App.validateUsername(it) + } + } + + RoundedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + text = password, + placeholder = "Password", + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password + ), + counter = true, + isError = passwordError, + maxChar = App.maxPasswordChars, + isPassword = true, + icon = { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = "Password icon" + ) + } + ) { + if (it.length <= App.maxPasswordChars) { + password = it + passwordError = !App.validatePassword(it) + } + } + // -END: INPUT + + Column( + modifier = Modifier + .padding(top = 16.dp) + ) { + Button( + modifier = Modifier + .fillMaxWidth(), + shape = CircleShape, + onClick = { + coroutine.launch { + if (usernameError) { + App.snackbarHost.showCustomSnackbar( + message = "Invalid username", + actionLabel = "x", + withDismissAction = true, + snackbarType = SnackbarType.WARNING, + duration = SnackbarDuration.Short + ) + return@launch + } + + if (passwordError) { + App.snackbarHost.showCustomSnackbar( + message = "Invalid password", + actionLabel = "x", + withDismissAction = true, + snackbarType = SnackbarType.WARNING, + duration = SnackbarDuration.Short + ) + return@launch + } + + if (username.isBlank() || password.isBlank()) { + App.snackbarHost.showCustomSnackbar( + message = "One or more required fields are blank", + actionLabel = "x", + withDismissAction = true, + snackbarType = SnackbarType.WARNING, + duration = SnackbarDuration.Short + ) + return@launch + } + + val (login, loginException) = User.authenticate( + username = username, + password = password + ) + when { + login != null -> { + UserState.token = login.token + UserState.isLoggedIn = true + val (userData, userException) = User.getMe() + when { + userData != null -> { + UserState.username = userData.user.username + + dataStore.edit { preferences -> + preferences[TOKEN] = UserState.token ?: "" + preferences[USERNAME] = UserState.username ?: "" + preferences[IS_LOGGED_IN] = UserState.isLoggedIn + } + + navController.backQueue.clear() + navController.navigate(Screens.Home.route) + } + userException != null -> { + App.snackbarHost.showCustomSnackbar( + message = userException.message ?: "Could not retrieve user data", + actionLabel = "x", + withDismissAction = true, + snackbarType = SnackbarType.DANGER + ) + } + } + } + loginException != null -> { + App.snackbarHost.showCustomSnackbar( + message = loginException.message ?: "Failed to login", + actionLabel = "x", + withDismissAction = true, + snackbarType = SnackbarType.DANGER + ) + } + } + } + } + ) { + Text(text = "Login") + } + + TextButton( + modifier = Modifier + .fillMaxWidth(), + shape = CircleShape, + onClick = { + navController.navigate(Screens.Register.route) + } + ) { + Text(text = "New User? Register Now") + } + + OutlinedIconButton( + modifier = Modifier + .padding(top = 2.dp) + .align(Alignment.CenterHorizontally), + border = BorderStroke( + width = 1.dp, + color = if (ThemeState.isDark) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground + } + ), + shape = CircleShape, + colors = IconButtonDefaults.iconButtonColors( + containerColor = Color.Transparent, + contentColor = if (ThemeState.isDark) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground + } + ), + onClick = { + navController.popBackStack() + } + ) { + Icon( + modifier = Modifier + .padding(4.dp), + imageVector = Icons.Filled.KeyboardArrowLeft, + contentDescription = "Back" + ) + } + } + } + } +} diff --git a/app/src/main/java/dev/vdbroek/nekos/ui/screens/Post.kt b/app/src/main/java/dev/vdbroek/nekos/ui/screens/Post.kt new file mode 100644 index 0000000..6ea54a4 --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/ui/screens/Post.kt @@ -0,0 +1,571 @@ +package dev.vdbroek.nekos.ui.screens + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.drawable.Drawable +import android.os.Build +import androidx.compose.animation.core.* +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.google.accompanist.flowlayout.FlowRow +import dev.vdbroek.nekos.api.RelationshipType +import dev.vdbroek.nekos.api.User +import dev.vdbroek.nekos.api.UserRequestState +import dev.vdbroek.nekos.api.UserState +import dev.vdbroek.nekos.components.SnackbarType +import dev.vdbroek.nekos.components.ZoomableNetworkImage +import dev.vdbroek.nekos.components.showCustomSnackbar +import dev.vdbroek.nekos.models.Neko +import dev.vdbroek.nekos.ui.Screens +import dev.vdbroek.nekos.ui.theme.NekoColors +import dev.vdbroek.nekos.ui.theme.imageShape +import dev.vdbroek.nekos.utils.App +import dev.vdbroek.nekos.utils.App.saveImageBitmap +import dev.vdbroek.nekos.utils.App.saveImageBitmap29 +import dev.vdbroek.nekos.utils.rememberMutableStateOf +import kotlinx.coroutines.launch +import java.io.ByteArrayOutputStream + +enum class LoadingState { + LOADING, + FAILED, + SUCCESS, + NONE +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Post( + navController: NavHostController, + data: Neko +) { + App.screenTitle = "Post Info" + + val context = LocalContext.current + + var loadingState by rememberMutableStateOf(LoadingState.NONE) + val infiniteTransition = rememberInfiniteTransition() + val angle by infiniteTransition.animateFloat( + initialValue = 0F, + targetValue = 360F, + animationSpec = infiniteRepeatable( + animation = tween(2000, easing = LinearEasing) + ) + ) + + val groupedTags = data.tags.chunked(3) + val scrollState = rememberLazyListState() + val coroutine = rememberCoroutineScope() + + var liked by rememberMutableStateOf(false) + var favorited by rememberMutableStateOf(false) + + var likeCount by rememberMutableStateOf(data.likes) + var favoriteCount by rememberMutableStateOf(data.favorites) + + if (UserState.isLoggedIn) { + LaunchedEffect(key1 = true) { + val (response, exception) = User.getMe() + when { + response != null -> { + liked = response.user.likes.contains(data.id) + favorited = response.user.favorites.contains(data.id) + } + exception != null -> { + App.snackbarHost.showCustomSnackbar( + message = exception.message ?: "Could not retrieve user data", + actionLabel = "x", + withDismissAction = true, + snackbarType = SnackbarType.DANGER + ) + } + } + } + } + + LazyColumn( + state = scrollState + ) { + item { + Card( + modifier = Modifier + .padding(10.dp) + .shadow(3.dp, imageShape, true, NekoColors.dark) + .clip(imageShape) + .background(color = Color.Transparent), + shape = imageShape + ) { + ZoomableNetworkImage( + url = data.getImageUrl(), + modifier = Modifier + .fillMaxWidth(), + alignment = Alignment.Center, + contentScale = ContentScale.FillWidth, + scrollState = scrollState + ) + } + } + + item { + Row( + modifier = Modifier + .padding(start = 16.dp, top = 8.dp) + ) { + // -LIKE + if (liked) { + Button( + modifier = Modifier + .padding(end = 4.dp), + colors = ButtonDefaults.buttonColors( + containerColor = NekoColors.like, + contentColor = Color.White + ), + onClick = { + coroutine.launch { + val (success, exception) = User.patchRelationship(data.id, RelationshipType.Like.value, false) + when { + success == true -> { + liked = false + likeCount-- + } + exception != null -> { + App.snackbarHost.showCustomSnackbar( + message = exception.message ?: "Failed removing like", + actionLabel = "x", + withDismissAction = true, + snackbarType = SnackbarType.DANGER + ) + } + } + } + } + ) { + Icon( + modifier = Modifier + .padding(end = 2.dp), + imageVector = Icons.Filled.ThumbUp, + contentDescription = "Unlike button" + ) + Text( + text = "$likeCount Likes", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleMedium + ) + } + } else { + OutlinedButton( + modifier = Modifier + .padding(end = 4.dp), + border = BorderStroke( + width = 1.dp, + color = NekoColors.like + ), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = NekoColors.like + ), + onClick = { + coroutine.launch { + val (success, exception) = User.patchRelationship(data.id, RelationshipType.Like.value, true) + when { + success == true -> { + liked = true + likeCount++ + } + exception != null -> { + App.snackbarHost.showCustomSnackbar( + message = exception.message ?: "Failed adding like", + actionLabel = "x", + withDismissAction = true, + snackbarType = SnackbarType.DANGER + ) + } + } + } + } + ) { + Icon( + modifier = Modifier + .padding(end = 2.dp), + imageVector = Icons.Filled.ThumbUp, + contentDescription = "Like button" + ) + Text( + text = "$likeCount Likes", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleMedium + ) + } + } + + // -FAVORITE + if (favorited) { + Button( + colors = ButtonDefaults.buttonColors( + containerColor = NekoColors.favorite, + contentColor = Color.White + ), + onClick = { + coroutine.launch { + val (success, exception) = User.patchRelationship(data.id, RelationshipType.Favorite.value, false) + when { + success == true -> { + favorited = false + favoriteCount-- + } + exception != null -> { + App.snackbarHost.showCustomSnackbar( + message = exception.message ?: "Failed removing favorite", + actionLabel = "x", + withDismissAction = true, + snackbarType = SnackbarType.DANGER + ) + } + } + } + } + ) { + Icon( + modifier = Modifier + .padding(end = 2.dp), + imageVector = Icons.Filled.Favorite, + contentDescription = "Unfavorite button" + ) + Text( + text = "$favoriteCount Favorites", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleMedium + ) + } + } else { + OutlinedButton( + border = BorderStroke( + width = 1.dp, + color = NekoColors.favorite + ), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = NekoColors.favorite + ), + onClick = { + coroutine.launch { + val (success, exception) = User.patchRelationship(data.id, RelationshipType.Favorite.value, true) + when { + success == true -> { + favorited = true + favoriteCount++ + } + exception != null -> { + App.snackbarHost.showCustomSnackbar( + message = exception.message ?: "Failed adding favorite", + actionLabel = "x", + withDismissAction = true, + snackbarType = SnackbarType.DANGER + ) + } + } + } + } + ) { + Icon( + modifier = Modifier + .padding(end = 2.dp), + imageVector = Icons.Filled.Favorite, + contentDescription = "Favorite button" + ) + Text( + text = "$favoriteCount Favorites", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleMedium + ) + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(end = 12.dp), + contentAlignment = Alignment.CenterEnd + ) { + val modifier = Modifier + .graphicsLayer { + rotationZ = angle + } + + FilledIconButton( + modifier = if (loadingState == LoadingState.LOADING) modifier else Modifier, + shape = CircleShape, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + onClick = { + if (loadingState == LoadingState.NONE && App.permissionGranted) { + Glide.with(context) + .asBitmap() + .load(data.getImageUrl()) + .into(object : CustomTarget() { + override fun onLoadCleared(p: Drawable?) { + loadingState = LoadingState.LOADING + } + + override fun onLoadFailed(errorDrawable: Drawable?) { + loadingState = LoadingState.FAILED + } + + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + try { + val success = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + context.saveImageBitmap29(resource, data.id) + } else { + context.saveImageBitmap(resource, data.id) + } + + loadingState = if (success) { + LoadingState.SUCCESS + } else { + LoadingState.FAILED + } + } catch (e: Exception) { + loadingState = LoadingState.FAILED + coroutine.launch { + App.snackbarHost.showCustomSnackbar( + message = e.message ?: "Failed downloading image", + actionLabel = "x", + withDismissAction = true, + snackbarType = SnackbarType.DANGER + ) + } + } + } + }) + } + } + ) { + when (loadingState) { + LoadingState.LOADING -> { + Icon( + imageVector = Icons.Filled.Autorenew, + contentDescription = "Downloading image" + ) + } + LoadingState.FAILED -> { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Failed downloading image" + ) + } + LoadingState.SUCCESS -> { + Icon( + imageVector = Icons.Filled.Done, + contentDescription = "Success downloading image" + ) + } + else -> { + Icon( + imageVector = Icons.Filled.Save, + contentDescription = "Save image" + ) + } + } + } + } + } + } + + item { + Row( + modifier = Modifier + .padding(start = 16.dp, top = 8.dp) + ) { + Text( + text = "Uploader:", + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.ExtraBold, + style = MaterialTheme.typography.titleMedium + ) + Text( + modifier = Modifier + .padding(start = 2.dp) + .clickable { + UserRequestState.apply { + end = false + skip = 0 + tags = App.defaultTags.toMutableStateList() + } + + UserScreenState.apply { + uploaderImages.clear() + initialRequest = true + user = null + } + + navController.navigate(Screens.User.route.replace("{id}", data.uploader.id)) + }, + text = data.uploader.username, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleMedium + ) + } + } + + if (data.approver != null) { + item { + Row( + modifier = Modifier + .padding(start = 16.dp, top = 8.dp) + ) { + Text( + text = "Approver:", + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.ExtraBold, + style = MaterialTheme.typography.titleMedium + ) + Text( + modifier = Modifier + .padding(start = 2.dp) + .clickable { + UserRequestState.apply { + end = false + skip = 0 + tags = App.defaultTags.toMutableStateList() + } + + UserScreenState.apply { + uploaderImages.clear() + initialRequest = true + user = null + } + + navController.navigate(Screens.User.route.replace("{id}", data.approver.id)) + }, + text = data.approver.username, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleMedium + ) + } + } + } + + item { + Row( + modifier = Modifier + .padding(start = 16.dp, top = 8.dp) + ) { + Text( + text = "Uploaded:", + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.ExtraBold, + style = MaterialTheme.typography.titleMedium + ) + Text( + modifier = Modifier + .padding(start = 4.dp), + text = App.timestamp(data.createdAt), + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleMedium + ) + } + } + + if (data.artist != null) { + item { + Row( + modifier = Modifier + .padding(start = 16.dp, top = 8.dp) + ) { + Text( + text = if (data.artist.contains("+")) "Artists:" else "Artist:", + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.ExtraBold, + style = MaterialTheme.typography.titleMedium + ) + Text( + modifier = Modifier + .padding(start = 4.dp), + text = data.artist.replace("+", ", "), + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleMedium + ) + } + } + } + + item { + Text( + modifier = Modifier + .padding(start = 8.dp, top = 8.dp), + text = "Tags", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.headlineSmall + ) + Divider( + modifier = Modifier + .padding(horizontal = 8.dp, vertical = 8.dp) + ) + } + + items(groupedTags.size) { i -> + TagGroup( + tags = groupedTags[i], + last = ((groupedTags.size - 1) == i) + ) + } + } +} + +@Composable +fun TagGroup( + tags: List, + last: Boolean = false +) { + FlowRow( + modifier = Modifier + .padding( + start = 8.dp, + end = 4.dp, + bottom = if (last) 6.dp else 0.dp + ) + ) { + tags.forEach { tag -> + val realTag = tag.replace("+", " ") + Box( + modifier = Modifier + .padding(2.dp) + .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(6.dp)) + ) { + Text( + modifier = Modifier + .padding(horizontal = 4.dp, vertical = 2.dp), + text = realTag, + color = MaterialTheme.colorScheme.onPrimary + ) + } + } + } +} diff --git a/app/src/main/java/dev/vdbroek/nekos/ui/screens/Profile.kt b/app/src/main/java/dev/vdbroek/nekos/ui/screens/Profile.kt new file mode 100644 index 0000000..79ea8bb --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/ui/screens/Profile.kt @@ -0,0 +1,212 @@ +package dev.vdbroek.nekos.ui.screens + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.ThumbUp +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import dev.vdbroek.nekos.R +import dev.vdbroek.nekos.api.UserRequestState +import dev.vdbroek.nekos.api.User +import dev.vdbroek.nekos.api.UserState +import dev.vdbroek.nekos.components.InfiniteRow +import dev.vdbroek.nekos.components.SnackbarType +import dev.vdbroek.nekos.components.showCustomSnackbar +import dev.vdbroek.nekos.models.EndException +import dev.vdbroek.nekos.models.Neko +import dev.vdbroek.nekos.models.UserData +import dev.vdbroek.nekos.ui.theme.NekoColors +import dev.vdbroek.nekos.ui.theme.imageShape +import dev.vdbroek.nekos.utils.App +import kotlinx.coroutines.launch +import me.onebone.toolbar.CollapsingToolbarState +import java.time.LocalDate +import java.time.Period +import java.time.format.DateTimeFormatter + +object ProfileScreenState { + val uploaderImages = mutableStateListOf() + var initialRequest by mutableStateOf(true) + var user by mutableStateOf(null) +} + +@Composable +fun Profile( + scrollState: CollapsingToolbarState, + navController: NavHostController +) { + App.screenTitle = UserState.username ?: "Profile" + + val coroutine = rememberCoroutineScope() + + BackHandler { + navController.popBackStack() + + UserRequestState.apply { + end = false + skip = 0 + tags = App.defaultTags.toMutableStateList() + } + + ProfileScreenState.apply { + uploaderImages.clear() + initialRequest = true + user = null + } + } + + suspend fun getUserUploads( + username: String + ) { + val (response, exception) = User.getUploads(username) + when { + response != null -> ProfileScreenState.uploaderImages.addAll(response.images.filter { !it.tags.contains(App.buggedTag) }) + exception != null && exception is EndException -> return + exception != null -> { + App.snackbarHost.showCustomSnackbar( + message = exception.message ?: "Failed to fetch more images", + actionLabel = "X", + withDismissAction = true, + snackbarType = SnackbarType.DANGER, + duration = SnackbarDuration.Long + ) + } + } + } + + LaunchedEffect(key1 = true) { + if (ProfileScreenState.initialRequest) { + val (userData, userException) = User.getMe() + when { + userData != null -> { + UserState.username = userData.user.username + ProfileScreenState.user = userData.user + getUserUploads(userData.user.username) + ProfileScreenState.initialRequest = false + } + userException != null -> { + App.snackbarHost.showCustomSnackbar( + message = userException.message ?: "Could not retrieve user data", + actionLabel = "x", + withDismissAction = true, + snackbarType = SnackbarType.DANGER + ) + } + } + } + } + + Column( + Modifier + .scrollable( + state = scrollState, + orientation = Orientation.Vertical + ) + ) { + if (ProfileScreenState.user != null) { + val dateTime = ProfileScreenState.user!!.createdAt.split("T")[0] + val from = LocalDate.parse(dateTime, DateTimeFormatter.ofPattern("yyyy-MM-dd")) + val today = LocalDate.now() + val period = Period.between(from, today) + + Column( + modifier = Modifier + .padding(start = 8.dp, top = 8.dp, end = 8.dp) + ) { + Image( + modifier = Modifier + .size(250.dp) + .clip(imageShape), + painter = painterResource(id = R.drawable.profile_placeholder), + contentDescription = "Avatar" + ) + Row( + modifier = Modifier + .padding(start = 16.dp, top = 16.dp) + ) { + Icon( + modifier = Modifier.padding(end = 8.dp), + imageVector = Icons.Filled.ThumbUp, + tint = NekoColors.like, + contentDescription = "Thumb up icon" + ) + Text( + text = "${ProfileScreenState.user!!.likesReceived} Likes", + color = NekoColors.like, + style = MaterialTheme.typography.titleLarge + ) + } + Row( + modifier = Modifier + .padding(start = 16.dp, top = 16.dp) + ) { + Icon( + modifier = Modifier.padding(end = 8.dp), + imageVector = Icons.Filled.Favorite, + tint = NekoColors.favorite, + contentDescription = "Favorite icon" + ) + Text( + text = "${ProfileScreenState.user!!.favoritesReceived} Favorites", + color = NekoColors.favorite, + style = MaterialTheme.typography.titleLarge + ) + } + Text( + modifier = Modifier + .padding(start = 16.dp, top = 8.dp), + text = "Joined ${period.years} years ago", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.labelLarge + ) + Text( + modifier = Modifier + .padding(start = 16.dp, top = 8.dp), + text = "Posted ${ProfileScreenState.user!!.uploads} images", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.labelLarge + ) + Text( + modifier = Modifier + .padding(start = 16.dp, top = 8.dp), + text = "Has given ${ProfileScreenState.user!!.likes.size} likes and ${ProfileScreenState.user!!.favorites.size} favorites", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.labelLarge + ) + Text( + modifier = Modifier + .padding(start = 4.dp, top = 8.dp), + text = "Uploads", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.headlineSmall + ) + Divider( + modifier = Modifier + .padding(horizontal = 4.dp, vertical = 8.dp) + ) + } + InfiniteRow( + items = ProfileScreenState.uploaderImages, + navController = navController + ) { + coroutine.launch { + if (!ProfileScreenState.initialRequest) + getUserUploads(ProfileScreenState.user!!.username) + } + } + } + } +} diff --git a/app/src/main/java/dev/vdbroek/nekos/ui/screens/Register.kt b/app/src/main/java/dev/vdbroek/nekos/ui/screens/Register.kt new file mode 100644 index 0000000..fb2a406 --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/ui/screens/Register.kt @@ -0,0 +1,333 @@ +package dev.vdbroek.nekos.ui.screens + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import dev.vdbroek.nekos.api.User +import dev.vdbroek.nekos.components.RoundedTextField +import dev.vdbroek.nekos.components.SnackbarType +import dev.vdbroek.nekos.components.showCustomSnackbar +import dev.vdbroek.nekos.ui.theme.ThemeState +import dev.vdbroek.nekos.utils.App +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Register( + navController: NavHostController +) { + App.screenTitle = "Register" + + val coroutine = rememberCoroutineScope() + + var email by remember { mutableStateOf("") } + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var confirmPassword by remember { mutableStateOf("") } + + var emailError by remember { mutableStateOf(false) } + var usernameError by remember { mutableStateOf(false) } + var passwordError by remember { mutableStateOf(false) } + var confirmPasswordError by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + ) { + // -START: HEADER + Box( + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(bottomStart = 100.dp) + ) + ) { + Column( + modifier = Modifier + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Image( + modifier = Modifier + .size(148.dp), + imageVector = Icons.Filled.AccountCircle, + contentDescription = "", + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.background) + ) + Text( + text = App.screenTitle, + color = MaterialTheme.colorScheme.background, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + } + } + // -END: HEADER + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 36.dp, end = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // -START: INPUT + RoundedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + text = email, + placeholder = "Email", + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email + ), + counter = true, + isError = emailError, + maxChar = App.maxEmailChars, + icon = { + Icon( + imageVector = Icons.Filled.Email, + contentDescription = "Email icon" + ) + } + ) { + if (it.length <= App.maxEmailChars) { + email = it + emailError = !App.validateEmail(it) + } + } + + RoundedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + text = username, + placeholder = "Username", + counter = true, + isError = usernameError, + maxChar = App.maxUsernameChars, + icon = { + Icon( + imageVector = Icons.Filled.Person, + contentDescription = "Username icon" + ) + } + ) { + if (it.length <= App.maxUsernameChars) { + username = it + usernameError = !App.validateUsername(it) + } + } + + RoundedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + text = password, + placeholder = "Password", + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password + ), + counter = true, + isError = passwordError, + maxChar = App.maxPasswordChars, + isPassword = true, + icon = { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = "Password icon" + ) + } + ) { + if (it.length <= App.maxPasswordChars) { + password = it + passwordError = !App.validatePassword(it) + } + } + + RoundedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + text = confirmPassword, + placeholder = "Confirm Password", + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password + ), + counter = true, + isError = confirmPasswordError, + maxChar = App.maxPasswordChars, + isPassword = true, + icon = { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = "Password icon" + ) + } + ) { + if (it.length <= App.maxPasswordChars) { + confirmPassword = it + confirmPasswordError = !App.validatePassword(it) || confirmPassword != password + } + } + // -END: INPUT + + Column( + modifier = Modifier + .padding(top = 16.dp) + ) { + Button( + modifier = Modifier + .fillMaxWidth(), + shape = CircleShape, + onClick = { + coroutine.launch { + if (emailError) { + App.snackbarHost.showCustomSnackbar( + message = "Invalid email address", + actionLabel = "x", + withDismissAction = true, + snackbarType = SnackbarType.WARNING, + duration = SnackbarDuration.Short + ) + return@launch + } + + if (usernameError) { + App.snackbarHost.showCustomSnackbar( + message = "Invalid username", + actionLabel = "x", + withDismissAction = true, + snackbarType = SnackbarType.WARNING, + duration = SnackbarDuration.Short + ) + return@launch + } + + if (passwordError) { + App.snackbarHost.showCustomSnackbar( + message = "Invalid password", + actionLabel = "x", + withDismissAction = true, + snackbarType = SnackbarType.WARNING, + duration = SnackbarDuration.Short + ) + return@launch + } + + if (confirmPasswordError) { + App.snackbarHost.showCustomSnackbar( + message = "Invalid password confirmation", + actionLabel = "x", + withDismissAction = true, + snackbarType = SnackbarType.WARNING, + duration = SnackbarDuration.Short + ) + return@launch + } + + if ( + email.isBlank() || + username.isBlank() || + password.isBlank() || + confirmPassword.isBlank() + ) { + App.snackbarHost.showCustomSnackbar( + message = "One or more required fields are blank", + actionLabel = "x", + withDismissAction = true, + snackbarType = SnackbarType.WARNING, + duration = SnackbarDuration.Short + ) + return@launch + } + + val (message, exception) = User.register( + email = email, + username = username, + password = password + ) + + when { + message != null -> { + email = "" + username = "" + password = "" + confirmPassword = "" + + App.snackbarHost.showCustomSnackbar( + message = message, + actionLabel = "x", + withDismissAction = true, + snackbarType = SnackbarType.INFO + ) + } + exception != null -> { + App.snackbarHost.showCustomSnackbar( + message = exception.message ?: "Failed to register", + actionLabel = "x", + withDismissAction = true, + snackbarType = SnackbarType.DANGER + ) + } + } + } + } + ) { + Text(text = "Register") + } + + OutlinedIconButton( + modifier = Modifier + .padding(top = 2.dp) + .align(Alignment.CenterHorizontally), + border = BorderStroke( + width = 1.dp, + color = if (ThemeState.isDark) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground + } + ), + shape = CircleShape, + colors = IconButtonDefaults.iconButtonColors( + containerColor = Color.Transparent, + contentColor = if (ThemeState.isDark) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground + } + ), + onClick = { + navController.popBackStack() + } + ) { + Icon( + modifier = Modifier + .padding(4.dp), + imageVector = Icons.Filled.KeyboardArrowLeft, + contentDescription = "Back" + ) + } + } + } + } +} diff --git a/app/src/main/java/dev/vdbroek/nekos/ui/screens/Settings.kt b/app/src/main/java/dev/vdbroek/nekos/ui/screens/Settings.kt new file mode 100644 index 0000000..f9dd13a --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/ui/screens/Settings.kt @@ -0,0 +1,289 @@ +package dev.vdbroek.nekos.ui.screens + +import android.content.Intent +import androidx.compose.foundation.Image +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import dev.vdbroek.nekos.SplashActivity +import dev.vdbroek.nekos.ui.theme.NekoColors +import dev.vdbroek.nekos.ui.theme.ThemeState +import dev.vdbroek.nekos.utils.* +import kotlinx.coroutines.launch + +private var openStaggeredWarning by mutableStateOf(false) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Settings( + dataStore: DataStore +) { + App.screenTitle = "Settings" + + val activity = LocalActivity.current + val context = LocalContext.current + val locale = context.resources.configuration.locales[0] + val isSystemInDarkTheme = isSystemInDarkTheme() + val coroutine = rememberCoroutineScope() + + val themeOptions = listOf("dark", "light", "system") + val defaultThemeOption = if (ThemeState.manual) { + if (ThemeState.isDark) { + themeOptions[0] + } else { + themeOptions[1] + } + } else { + themeOptions[2] + } + var selectedThemeOption by remember { mutableStateOf(defaultThemeOption) } + + val layoutOptions = listOf("fixed", "staggered") + val defaultLayoutOption = if (ThemeState.staggered) { + layoutOptions[1] + } else { + layoutOptions[0] + } + var selectedLayoutOption by remember { mutableStateOf(defaultLayoutOption) } + + Column { + Text( + modifier = Modifier.padding(start = 24.dp), + text = "Theme", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.headlineSmall + ) + Divider( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + Column { + themeOptions.forEach { text -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = text == selectedThemeOption, + onClick = { + selectedThemeOption = text + coroutine.launch { changeTheme(text, isSystemInDarkTheme, dataStore) } + } + ) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + modifier = Modifier.padding(all = Dp(value = 8F)), + selected = (text == selectedThemeOption), + onClick = { + selectedThemeOption = text + coroutine.launch { changeTheme(text, isSystemInDarkTheme, dataStore) } + } + ) + Text( + modifier = Modifier.padding(start = 4.dp), + text = text.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }, + color = MaterialTheme.colorScheme.onBackground + ) + } + } + } + + Text( + modifier = Modifier.padding(start = 24.dp), + text = "Layout", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.headlineSmall + ) + Divider( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + Column { + layoutOptions.forEach { text -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = text == selectedLayoutOption, + onClick = { + if (text == "staggered") { + openStaggeredWarning = true + } else { + selectedLayoutOption = text + coroutine.launch { changeLayout(text, dataStore) } + } + } + ) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + modifier = Modifier.padding(all = Dp(value = 8F)), + selected = (text == selectedLayoutOption), + onClick = { + if (text == "staggered") { + openStaggeredWarning = true + } else { + selectedLayoutOption = text + coroutine.launch { changeLayout(text, dataStore) } + } + } + ) + Text( + modifier = Modifier.padding(start = 4.dp), + text = text.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }, + color = MaterialTheme.colorScheme.onBackground + ) + } + } + } + } + + // TODO : Move Dialog to components/dialog as StaggeredWarningDialog + if (openStaggeredWarning) { + Dialog( + onDismissRequest = { openStaggeredWarning = false } + ) { + Card( + shape = RoundedCornerShape(10.dp), + modifier = Modifier.padding(10.dp, 5.dp, 10.dp, 10.dp), + elevation = CardDefaults.cardElevation(8.dp) + ) { + Column { + Image( + imageVector = Icons.Outlined.Warning, + contentDescription = null, + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(color = if (ThemeState.isDark) NekoColors.warning else NekoColors.warning.dim(0.9f)), + modifier = Modifier + .padding(top = 35.dp) + .height(70.dp) + .fillMaxWidth() + ) + + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Experimental UI Warning", + textAlign = TextAlign.Center, + modifier = Modifier + .padding(top = 5.dp) + .fillMaxWidth(), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "Staggered layout grid is an experimental feature and will have some bugs. The app needs to restart to apply the changes.", + textAlign = TextAlign.Center, + modifier = Modifier + .padding(top = 10.dp, start = 25.dp, end = 25.dp) + .fillMaxWidth(), + style = MaterialTheme.typography.bodyMedium + ) + } + Row( + Modifier + .fillMaxWidth() + .padding(top = 10.dp), + horizontalArrangement = Arrangement.SpaceAround + ) { + + TextButton(onClick = { + selectedLayoutOption = layoutOptions[0] + openStaggeredWarning = false + }) { + + Text( + text = "Not Now", + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 5.dp, bottom = 5.dp) + ) + } + TextButton(onClick = { + selectedLayoutOption = layoutOptions[1] + coroutine.launch { changeLayout(layoutOptions[1], dataStore) } + openStaggeredWarning = false + + // Restart app + val splashActivity = Intent(context, SplashActivity::class.java) + activity.finish() + activity.startActivity(splashActivity) + }) { + Text( + text = "I Understand", + fontWeight = FontWeight.ExtraBold, + modifier = Modifier.padding(top = 5.dp, bottom = 5.dp) + ) + } + } + } + } + } + } +} + +// TODO : Move functions to utils/Utils.kt in App +suspend fun changeTheme( + theme: String, + isSystemInDarkTheme: Boolean, + dataStore: DataStore +) { + when (theme) { + "dark" -> { + ThemeState.manual = true + ThemeState.isDark = true + } + "light" -> { + ThemeState.manual = true + ThemeState.isDark = false + } + "system" -> { + ThemeState.manual = false + ThemeState.isDark = isSystemInDarkTheme + } + } + + dataStore.edit { preferences -> + preferences[MANUAL] = ThemeState.manual + preferences[IS_DARK] = ThemeState.isDark + } +} + +suspend fun changeLayout( + layout: String, + dataStore: DataStore +) { + when (layout) { + "fixed" -> { + ThemeState.staggered = false + } + "staggered" -> { + ThemeState.staggered = true + } + } + + dataStore.edit { preferences -> + preferences[STAGGERED] = ThemeState.staggered + } +} diff --git a/app/src/main/java/dev/vdbroek/nekos/ui/screens/User.kt b/app/src/main/java/dev/vdbroek/nekos/ui/screens/User.kt new file mode 100644 index 0000000..25d9f35 --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/ui/screens/User.kt @@ -0,0 +1,213 @@ +package dev.vdbroek.nekos.ui.screens + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.ThumbUp +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import dev.vdbroek.nekos.R +import dev.vdbroek.nekos.api.User +import dev.vdbroek.nekos.api.UserRequestState +import dev.vdbroek.nekos.components.InfiniteRow +import dev.vdbroek.nekos.components.SnackbarType +import dev.vdbroek.nekos.components.showCustomSnackbar +import dev.vdbroek.nekos.models.EndException +import dev.vdbroek.nekos.models.Neko +import dev.vdbroek.nekos.models.UserData +import dev.vdbroek.nekos.ui.theme.NekoColors +import dev.vdbroek.nekos.ui.theme.imageShape +import dev.vdbroek.nekos.utils.App +import kotlinx.coroutines.launch +import me.onebone.toolbar.CollapsingToolbarState +import java.time.LocalDate +import java.time.Period +import java.time.format.DateTimeFormatter + +object UserScreenState { + val uploaderImages = mutableStateListOf() + var initialRequest by mutableStateOf(true) + var user by mutableStateOf(null) +} + +@Composable +fun User( + scrollState: CollapsingToolbarState, + navController: NavHostController, + id: String +) { + App.screenTitle = "" + + val coroutine = rememberCoroutineScope() + + BackHandler { + navController.popBackStack() + + UserRequestState.apply { + end = false + skip = 0 + tags = App.defaultTags.toMutableStateList() + } + + UserScreenState.apply { + uploaderImages.clear() + initialRequest = true + user = null + } + } + + suspend fun getUserUploads( + username: String + ) { + val (response, exception) = User.getUploads(username) + when { + response != null -> UserScreenState.uploaderImages.addAll(response.images.filter { !it.tags.contains(App.buggedTag) }) + exception != null && exception is EndException -> return + exception != null -> { + App.snackbarHost.showCustomSnackbar( + message = exception.message ?: "Failed to fetch more images", + actionLabel = "x", + withDismissAction = true, + snackbarType = SnackbarType.DANGER, + duration = SnackbarDuration.Long + ) + } + } + } + + LaunchedEffect(key1 = true) { + if (UserScreenState.initialRequest) { + val (userData, userException) = User.getUser(id) + when { + userData != null -> { + UserScreenState.user = userData.user + getUserUploads(userData.user.username) + UserScreenState.initialRequest = false + } + userException != null -> { + App.snackbarHost.showCustomSnackbar( + message = userException.message ?: "Could not retrieve user data", + actionLabel = "x", + withDismissAction = true, + snackbarType = SnackbarType.DANGER + ) + } + } + } + } + + Column( + Modifier + .scrollable( + state = scrollState, + orientation = Orientation.Vertical + ) + ) { + if (UserScreenState.user != null) { + App.screenTitle = UserScreenState.user!!.username + + val dateTime = UserScreenState.user!!.createdAt.split("T")[0] + val from = LocalDate.parse(dateTime, DateTimeFormatter.ofPattern("yyyy-MM-dd")) + val today = LocalDate.now() + val period = Period.between(from, today) + + Column( + modifier = Modifier + .padding(start = 8.dp, top = 8.dp, end = 8.dp) + ) { + Image( + modifier = Modifier + .size(250.dp) + .clip(imageShape), + painter = painterResource(id = R.drawable.profile_placeholder), + contentDescription = "Avatar" + ) + Row( + modifier = Modifier + .padding(start = 16.dp, top = 16.dp) + ) { + Icon( + modifier = Modifier.padding(end = 8.dp), + imageVector = Icons.Filled.ThumbUp, + tint = NekoColors.like, + contentDescription = "Thumb up icon" + ) + Text( + text = "${UserScreenState.user!!.likesReceived} Likes", + color = NekoColors.like, + style = MaterialTheme.typography.titleLarge + ) + } + Row( + modifier = Modifier + .padding(start = 16.dp, top = 16.dp) + ) { + Icon( + modifier = Modifier.padding(end = 8.dp), + imageVector = Icons.Filled.Favorite, + tint = NekoColors.favorite, + contentDescription = "Favorite icon" + ) + Text( + text = "${UserScreenState.user!!.favoritesReceived} Favorites", + color = NekoColors.favorite, + style = MaterialTheme.typography.titleLarge + ) + } + Text( + modifier = Modifier + .padding(start = 16.dp, top = 8.dp), + text = "Joined ${period.years} years ago", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.labelLarge + ) + Text( + modifier = Modifier + .padding(start = 16.dp, top = 8.dp), + text = "Posted ${UserScreenState.user!!.uploads} images", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.labelLarge + ) + Text( + modifier = Modifier + .padding(start = 16.dp, top = 8.dp), + text = "Has given ${UserScreenState.user!!.likes.size} likes and ${UserScreenState.user!!.favorites.size} favorites", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.labelLarge + ) + Text( + modifier = Modifier + .padding(start = 4.dp, top = 8.dp), + text = "Uploads", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.headlineSmall + ) + Divider( + modifier = Modifier + .padding(horizontal = 4.dp, vertical = 8.dp) + ) + } + InfiniteRow( + items = UserScreenState.uploaderImages, + navController = navController + ) { + coroutine.launch { + if (!UserScreenState.initialRequest) + getUserUploads(UserScreenState.user!!.username) + } + } + } + } +} diff --git a/app/src/main/java/dev/vdbroek/nekos/ui/theme/Color.kt b/app/src/main/java/dev/vdbroek/nekos/ui/theme/Color.kt new file mode 100644 index 0000000..da72430 --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/ui/theme/Color.kt @@ -0,0 +1,77 @@ +package dev.vdbroek.nekos.ui.theme + +import androidx.compose.ui.graphics.Color + +object NekoColors { + val darkCard = Color(0xFF292E35) + + val dark = Color(0xFF21252b) + val light = Color(0xFFEEEEEE) + val info = Color(0xFF209CEE) + val success = Color(0xFF23D160) + val warning = Color(0xFFFFDD57) + val danger = Color(0xFFFF3860) + + val like = Color(0xFF209CEE) + val favorite = Color(0xFFFF3860) + + val md_theme_light_primary = Color(0xFF00639a) + val md_theme_light_onPrimary = Color(0xFFffffff) + val md_theme_light_primaryContainer = Color(0xFFcbe5ff) + val md_theme_light_onPrimaryContainer = Color(0xFF001d32) + val md_theme_light_secondary = Color(0xFF51606f) + val md_theme_light_onSecondary = Color(0xFFffffff) + val md_theme_light_secondaryContainer = Color(0xFFd5e4f6) + val md_theme_light_onSecondaryContainer = Color(0xFF0e1d2a) + val md_theme_light_tertiary = Color(0xFF67587a) + val md_theme_light_onTertiary = Color(0xFFffffff) + val md_theme_light_tertiaryContainer = Color(0xFFefdbff) + val md_theme_light_onTertiaryContainer = Color(0xFF221533) + val md_theme_light_error = danger + val md_theme_light_errorContainer = Color(0xFFffdad4) + val md_theme_light_onError = Color(0xFFffffff) + val md_theme_light_onErrorContainer = Color(0xFF410001) + val md_theme_light_background = Color(0xFFfcfcff) + val md_theme_light_onBackground = Color(0xFF1a1c1e) + val md_theme_light_surface = Color(0xFFfcfcff) + val md_theme_light_onSurface = Color(0xFF1a1c1e) + val md_theme_light_surfaceVariant = Color(0xFFdee3eb) + val md_theme_light_onSurfaceVariant = Color(0xFF41474d) + val md_theme_light_outline = Color(0xFF72777e) + val md_theme_light_inverseOnSurface = Color(0xFFf0f0f3) + val md_theme_light_inverseSurface = Color(0xFF2f3032) + val md_theme_light_inversePrimary = Color(0xFF92ccff) + val md_theme_light_shadow = Color(0xFF000000) + + val md_theme_dark_primary = Color(0xFF92ccff) + val md_theme_dark_onPrimary = Color(0xFF1a1c1e) +// val md_theme_dark_onPrimary = Color(0xFF003353) + val md_theme_dark_primaryContainer = Color(0xFF004a75) + val md_theme_dark_onPrimaryContainer = Color(0xFFcbe5ff) + val md_theme_dark_secondary = Color(0xFFb8c8da) + val md_theme_dark_onSecondary = Color(0xFF233240) + val md_theme_dark_secondaryContainer = Color(0xFF394857) + val md_theme_dark_onSecondaryContainer = Color(0xFFd5e4f6) + val md_theme_dark_tertiary = Color(0xFFd2bfe6) + val md_theme_dark_onTertiary = Color(0xFF382a4a) + val md_theme_dark_tertiaryContainer = Color(0xFF4f4161) + val md_theme_dark_onTertiaryContainer = Color(0xFFefdbff) + val md_theme_dark_error = danger + val md_theme_dark_errorContainer = Color(0xFF930006) + val md_theme_dark_onError = Color(0xFFffffff) + val md_theme_dark_onErrorContainer = Color(0xFFffdad4) + val md_theme_dark_background = Color(0xFF1a1c1e) + val md_theme_dark_onBackground = Color(0xFFe2e2e5) + val md_theme_dark_surface = Color(0xFF1a1c1e) + val md_theme_dark_onSurface = Color(0xFFe2e2e5) + val md_theme_dark_surfaceVariant = Color(0xFF41474d) + val md_theme_dark_onSurfaceVariant = Color(0xFFc2c7ce) + val md_theme_dark_outline = Color(0xFF8c9198) + val md_theme_dark_inverseOnSurface = Color(0xFF1a1c1e) + val md_theme_dark_inverseSurface = Color(0xFFe2e2e5) + val md_theme_dark_inversePrimary = Color(0xFF00639a) + val md_theme_dark_shadow = Color(0xFF000000) + + val seed = Color(0xFF81b2df) + val error = danger +} diff --git a/app/src/main/java/dev/vdbroek/nekos/ui/theme/Shape.kt b/app/src/main/java/dev/vdbroek/nekos/ui/theme/Shape.kt new file mode 100644 index 0000000..99e9038 --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/ui/theme/Shape.kt @@ -0,0 +1,12 @@ +package dev.vdbroek.nekos.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +val imageShape = RoundedCornerShape(10.dp) +val shapes = Shapes( + small = RoundedCornerShape(10.dp), + medium = RoundedCornerShape(10.dp), + large = RoundedCornerShape(10.dp) +) diff --git a/app/src/main/java/dev/vdbroek/nekos/ui/theme/Theme.kt b/app/src/main/java/dev/vdbroek/nekos/ui/theme/Theme.kt new file mode 100644 index 0000000..c2635f8 --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/ui/theme/Theme.kt @@ -0,0 +1,93 @@ +package dev.vdbroek.nekos.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +object ThemeState { + var isDark by mutableStateOf(true) + var manual by mutableStateOf(false) + var staggered by mutableStateOf(false) +} + +private val LightThemeColors = lightColorScheme( + primary = NekoColors.md_theme_light_primary, + onPrimary = NekoColors.md_theme_light_onPrimary, + primaryContainer = NekoColors.md_theme_light_primaryContainer, + onPrimaryContainer = NekoColors.md_theme_light_onPrimaryContainer, + secondary = NekoColors.md_theme_light_secondary, + onSecondary = NekoColors.md_theme_light_onSecondary, + secondaryContainer = NekoColors.md_theme_light_secondaryContainer, + onSecondaryContainer = NekoColors.md_theme_light_onSecondaryContainer, + tertiary = NekoColors.md_theme_light_tertiary, + onTertiary = NekoColors.md_theme_light_onTertiary, + tertiaryContainer = NekoColors.md_theme_light_tertiaryContainer, + onTertiaryContainer = NekoColors.md_theme_light_onTertiaryContainer, + error = NekoColors.md_theme_light_error, + errorContainer = NekoColors.md_theme_light_errorContainer, + onError = NekoColors.md_theme_light_onError, + onErrorContainer = NekoColors.md_theme_light_onErrorContainer, + background = NekoColors.md_theme_light_background, + onBackground = NekoColors.md_theme_light_onBackground, + surface = NekoColors.md_theme_light_surface, + onSurface = NekoColors.md_theme_light_onSurface, + surfaceVariant = NekoColors.md_theme_light_surfaceVariant, + onSurfaceVariant = NekoColors.md_theme_light_onSurfaceVariant, + outline = NekoColors.md_theme_light_outline, + inverseOnSurface = NekoColors.md_theme_light_inverseOnSurface, + inverseSurface = NekoColors.md_theme_light_inverseSurface, + inversePrimary = NekoColors.md_theme_light_inversePrimary, +) +private val DarkThemeColors = darkColorScheme( + primary = NekoColors.md_theme_dark_primary, + onPrimary = NekoColors.md_theme_dark_onPrimary, + primaryContainer = NekoColors.md_theme_dark_primaryContainer, + onPrimaryContainer = NekoColors.md_theme_dark_onPrimaryContainer, + secondary = NekoColors.md_theme_dark_secondary, + onSecondary = NekoColors.md_theme_dark_onSecondary, + secondaryContainer = NekoColors.md_theme_dark_secondaryContainer, + onSecondaryContainer = NekoColors.md_theme_dark_onSecondaryContainer, + tertiary = NekoColors.md_theme_dark_tertiary, + onTertiary = NekoColors.md_theme_dark_onTertiary, + tertiaryContainer = NekoColors.md_theme_dark_tertiaryContainer, + onTertiaryContainer = NekoColors.md_theme_dark_onTertiaryContainer, + error = NekoColors.md_theme_dark_error, + errorContainer = NekoColors.md_theme_dark_errorContainer, + onError = NekoColors.md_theme_dark_onError, + onErrorContainer = NekoColors.md_theme_dark_onErrorContainer, + background = NekoColors.md_theme_dark_background, + onBackground = NekoColors.md_theme_dark_onBackground, + surface = NekoColors.md_theme_dark_surface, + onSurface = NekoColors.md_theme_dark_onSurface, + surfaceVariant = NekoColors.md_theme_dark_surfaceVariant, + onSurfaceVariant = NekoColors.md_theme_dark_onSurfaceVariant, + outline = NekoColors.md_theme_dark_outline, + inverseOnSurface = NekoColors.md_theme_dark_inverseOnSurface, + inverseSurface = NekoColors.md_theme_dark_inverseSurface, + inversePrimary = NekoColors.md_theme_dark_inversePrimary, +) + +@Composable +fun NekosTheme(content: @Composable () -> Unit) { + ThemeState.isDark = if (ThemeState.manual) ThemeState.isDark else isSystemInDarkTheme() + +// uiController.setSystemBarsColor(color = Color.Transparent) + + val colors = if (ThemeState.isDark) { + DarkThemeColors + } else { + LightThemeColors + } + + MaterialTheme( + colorScheme = colors, + typography = Typography, + shapes = shapes, + content = content + ) +} diff --git a/app/src/main/java/dev/vdbroek/nekos/ui/theme/Type.kt b/app/src/main/java/dev/vdbroek/nekos/ui/theme/Type.kt new file mode 100644 index 0000000..26f8a36 --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/ui/theme/Type.kt @@ -0,0 +1,130 @@ +package dev.vdbroek.nekos.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import dev.vdbroek.nekos.R + +val Nunito = FontFamily( + Font(R.font.nunito_black, FontWeight.Black), + Font(R.font.nunito_bold, FontWeight.Bold), + Font(R.font.nunito_extra_bold, FontWeight.ExtraBold), + Font(R.font.nunito_extra_light, FontWeight.ExtraLight), + Font(R.font.nunito_italic, style = FontStyle.Italic), + Font(R.font.nunito_light, FontWeight.Light), + Font(R.font.nunito_medium, FontWeight.Medium), + Font(R.font.nunito_regular), + Font(R.font.nunito_semi_bold, FontWeight.SemiBold), +) + +val Typography = Typography( + displayLarge = TextStyle( + fontFamily = Nunito, + fontWeight = FontWeight.W400, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp, + ), + displayMedium = TextStyle( + fontFamily = Nunito, + fontWeight = FontWeight.W400, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp, + ), + displaySmall = TextStyle( + fontFamily = Nunito, + fontWeight = FontWeight.W400, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp, + ), + headlineLarge = TextStyle( + fontFamily = Nunito, + fontWeight = FontWeight.W400, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp, + ), + headlineMedium = TextStyle( + fontFamily = Nunito, + fontWeight = FontWeight.W400, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp, + ), + headlineSmall = TextStyle( + fontFamily = Nunito, + fontWeight = FontWeight.W400, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp, + ), + titleLarge = TextStyle( + fontFamily = Nunito, + fontWeight = FontWeight.W400, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp, + ), + titleMedium = TextStyle( + fontFamily = Nunito, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.1.sp, + ), + titleSmall = TextStyle( + fontFamily = Nunito, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + bodyLarge = TextStyle( + fontFamily = Nunito, + fontWeight = FontWeight.W400, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + bodyMedium = TextStyle( + fontFamily = Nunito, + fontWeight = FontWeight.W400, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + bodySmall = TextStyle( + fontFamily = Nunito, + fontWeight = FontWeight.W400, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), + labelLarge = TextStyle( + fontFamily = Nunito, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = TextStyle( + fontFamily = Nunito, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + labelSmall = TextStyle( + fontFamily = Nunito, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), +) diff --git a/app/src/main/java/dev/vdbroek/nekos/utils/BitmapModule.kt b/app/src/main/java/dev/vdbroek/nekos/utils/BitmapModule.kt new file mode 100644 index 0000000..d7edad1 --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/utils/BitmapModule.kt @@ -0,0 +1,24 @@ +package dev.vdbroek.nekos.utils + +import android.content.Context +import com.bumptech.glide.GlideBuilder +import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.load.DecodeFormat +import com.bumptech.glide.module.AppGlideModule +import com.bumptech.glide.request.RequestOptions + +@GlideModule +class BitmapModule : AppGlideModule() { + + override fun applyOptions(context: Context, builder: GlideBuilder) { + builder.setDefaultRequestOptions(RequestOptions().format(getBitmapQuality())) + } + + private fun getBitmapQuality(): DecodeFormat { + return if (App.hasLowRam()) { + DecodeFormat.PREFER_RGB_565 + } else { + DecodeFormat.PREFER_ARGB_8888 + } + } +} diff --git a/app/src/main/java/dev/vdbroek/nekos/utils/Globals.kt b/app/src/main/java/dev/vdbroek/nekos/utils/Globals.kt new file mode 100644 index 0000000..2156d6b --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/utils/Globals.kt @@ -0,0 +1,12 @@ +package dev.vdbroek.nekos.utils + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey + +val IS_DARK = booleanPreferencesKey("is_dark") +val MANUAL = booleanPreferencesKey("manual") +val STAGGERED = booleanPreferencesKey("staggered") + +val IS_LOGGED_IN = booleanPreferencesKey("is_logged_in") +val TOKEN = stringPreferencesKey("token") +val USERNAME = stringPreferencesKey("username") diff --git a/app/src/main/java/dev/vdbroek/nekos/utils/Response.kt b/app/src/main/java/dev/vdbroek/nekos/utils/Response.kt new file mode 100644 index 0000000..4c85578 --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/utils/Response.kt @@ -0,0 +1,3 @@ +package dev.vdbroek.nekos.utils + +data class Response(val value: V, val exception: E) diff --git a/app/src/main/java/dev/vdbroek/nekos/utils/Utils.kt b/app/src/main/java/dev/vdbroek/nekos/utils/Utils.kt new file mode 100644 index 0000000..4c3ad06 --- /dev/null +++ b/app/src/main/java/dev/vdbroek/nekos/utils/Utils.kt @@ -0,0 +1,279 @@ +package dev.vdbroek.nekos.utils + +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.media.MediaScannerConnection +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.text.format.DateUtils +import androidx.activity.ComponentActivity +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.graphics.ColorUtils +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import io.iamjosephmj.flinger.configs.FlingConfiguration +import io.iamjosephmj.flinger.flings.flingBehavior +import me.onebone.toolbar.CollapsingToolbarState +import java.io.File +import java.io.FileOutputStream +import java.text.SimpleDateFormat +import java.util.* +import kotlin.math.ln +import kotlin.math.min + + +val LocalActivity = staticCompositionLocalOf { + error("CompositionLocal LocalActivity not present") +} + +/** + * Converts dp to px using LocalDensity. + */ +val Int.px: Float @Composable get() = with(LocalDensity.current) { this@px.dp.toPx() } + +/** + * Simple data store for key, value pairs + */ +val Context.dataStore: DataStore by preferencesDataStore(name = "settings") + +/** + * Dim color + */ +fun Color.dim(factor: Float): Color { + require(factor in 0.0..1.0) { + "factor has to be between 0.0 and 1.0" + } + + val r = min((red * factor), 255f) + val g = min((green * factor), 255f) + val b = min((blue * factor), 255f) + return Color(r, g, b, alpha) +} + +/** + * Lighten color + */ +fun Color.lighten(factor: Float): Color { + require(factor in 0.0..1.0) { + "factor has to be between 0.0 and 1.0" + } + + return Color(ColorUtils.blendARGB(toArgb(), Color.White.toArgb(), factor)) +} + +/** + * Set alpha for color + */ +fun Color.alpha(newAlpha: Float): Color { + require(newAlpha in 0.0..1.0) { + "alpha has to be between 0.0 and 1.0" + } + + return Color(red, green, blue, newAlpha) +} + +val PaddingValues.top: Dp get() = calculateTopPadding() +val PaddingValues.end: Dp @Composable get() = calculateEndPadding(LocalLayoutDirection.current) +val PaddingValues.bottom: Dp get() = calculateBottomPadding() +val PaddingValues.start: Dp @Composable get() = calculateStartPadding(LocalLayoutDirection.current) + +@Composable +fun PaddingValues.copy( + start: Dp = this.start, + top: Dp = this.top, + end: Dp = this.end, + bottom: Dp = this.bottom +): PaddingValues = PaddingValues(start, top, end, bottom) + +/** + * Create a copy of the original state list + */ +fun SnapshotStateList.copy() = mutableStateListOf().also { it.addAll(this) } + +/** + * Capitalize string (defaults to Locale.ROOT) + */ +fun String.capitalize() = replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() } + +@Composable +fun rememberMutableStateOf( + value: T, + policy: SnapshotMutationPolicy = structuralEqualityPolicy() +): MutableState = remember { + mutableStateOf(value, policy) +} + +object App { + const val baseUrl = "https://nekos.moe/api/v1" + + var permissionGranted by mutableStateOf(false) + var screenTitle by mutableStateOf("") + val snackbarHost = SnackbarHostState() + + // Only use when absolutely necessary and there is 100% no other way to access the toolbar state + var globalToolbarState: CollapsingToolbarState? = null + + lateinit var version: String + lateinit var versionCode: String + lateinit var userAgent: String + + val tags = mutableStateListOf() + const val defaultSort = "newest" + const val buggedTag = "off-shoulder shirt" + val defaultTags = listOf( + "-bare shoulders", + "-bikini", + "-crop top", + "-swimsuit", + "-midriff", + "-no bra", + "-panties", + "-covered nipples", + "-from behind", + "-knees up", + "-leotard", + "-black bikini top", + "-black bikini bottom", + "-off-shoulder shirt", + "-naked shirt" + ) + + const val minUsernameChars = 1 + const val maxUsernameChars = 35 + + const val minEmailChars = 5 + const val maxEmailChars = 70 + + const val minPasswordChars = 8 + const val maxPasswordChars = 70 + + @Composable + fun flingBehavior() = flingBehavior( + FlingConfiguration.Builder() + .scrollViewFriction(0.008f) + .absVelocityThreshold(0f) + .gravitationalForce(9.80665f) + .inchesPerMeter(39.37f) + .decelerationRate((ln(0.78) / ln(0.9)).toFloat()) + .decelerationFriction(0.09f) + .splineInflection(0.1f) + .splineStartTension(0.1f) + .splineEndTension(1.0f) + .numberOfSplinePoints(100) + .build(), + ) + + /** + * Usernames cannot contain whitespace chars, newlines or "@" and have to be between 1 and 35 characters + */ + fun validateUsername(text: String): Boolean = + (!Regex("[@\\r\\n\\t\\s]").containsMatchIn(text) && text.length in minUsernameChars..maxUsernameChars) + + /** + * Validate email pattern and have to be between 5 and 70 characters + */ + fun validateEmail(text: String): Boolean = + (Regex("^[^@]+@[^.@]+\\.[^.@]+$").matches(text) && text.length in minEmailChars..maxEmailChars) + + /** + * Passwords have to be between 8 and 70 characters + */ + fun validatePassword(text: String): Boolean = + (text.length in minPasswordChars..maxPasswordChars) + + fun timestamp(timeCreated: String): String { + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ENGLISH) + val timeCreatedDate = dateFormat.parse(timeCreated)!! + return DateUtils.getRelativeTimeSpanString(timeCreatedDate.time, System.currentTimeMillis(), DateUtils.SECOND_IN_MILLIS) as String + } + + fun getVersions(ctx: Context): Pair { + val packageInfo = ctx.packageManager.getPackageInfo(ctx.packageName, 0) + return Pair(packageInfo.versionName, String.format("%03d", packageInfo.longVersionCode)) + } + + fun hasLowRam(): Boolean { + // Get app memory info + val available = Runtime.getRuntime().maxMemory() + val used = Runtime.getRuntime().totalMemory() + + // Check for & and handle low memory state + val percentAvailable = 100f * (1f - used.toFloat() / available) + + return percentAvailable <= 5.0f + } + + fun Context.saveImageBitmap(image: Bitmap, id: String): Boolean { + val mediaStorage = File("${Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM)}${File.separator}Nekos${File.separator}") + if (!mediaStorage.exists()) { + mediaStorage.mkdirs() + } + + val file = File(mediaStorage, "$id.jpg").also { + it.createNewFile() + } + val fos = FileOutputStream(file) + + return image + .compress(Bitmap.CompressFormat.JPEG, 100, fos) + .also { + MediaScannerConnection.scanFile(this, arrayOf(file.toString()), arrayOf(file.name), null) + fos.flush() + fos.close() + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + fun Context.saveImageBitmap29(image: Bitmap, id: String): Boolean { + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, id) + put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") + put(MediaStore.MediaColumns.RELATIVE_PATH, "DCIM/Nekos") + } + val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return false + val fos = contentResolver.openOutputStream(uri) ?: return false + + return image + .compress(Bitmap.CompressFormat.JPEG, 100, fos) + .also { + fos.flush() + fos.close() + } + +// val root = Environment.getExternalStorageDirectory().toString() +// println(root) +// val myDir = File(root, "/nekos") +// println(myDir) +// if (!myDir.exists()) { +// myDir.mkdirs() +// } +// +// val fname = "Neko-$id.jpg" +// val file = File(myDir, fname) +// println(file) +// if (file.exists()) { +// file.delete() +// } +// +// file.createNewFile() // if file already exists will do nothing +// val out = FileOutputStream(file) +// image.compress(Bitmap.CompressFormat.JPEG, 100, out) +// out.flush() +// out.close() + } +} diff --git a/app/src/main/res/drawable/ic_launcher.png b/app/src/main/res/drawable/ic_launcher.png new file mode 100644 index 0000000..f3d8bf5 Binary files /dev/null and b/app/src/main/res/drawable/ic_launcher.png differ diff --git a/app/src/main/res/drawable/ic_launcher_original.png b/app/src/main/res/drawable/ic_launcher_original.png new file mode 100644 index 0000000..31f68c9 Binary files /dev/null and b/app/src/main/res/drawable/ic_launcher_original.png differ diff --git a/app/src/main/res/drawable/ic_launcher_round.png b/app/src/main/res/drawable/ic_launcher_round.png new file mode 100644 index 0000000..7add670 Binary files /dev/null and b/app/src/main/res/drawable/ic_launcher_round.png differ diff --git a/app/src/main/res/drawable/ic_logo_splash.xml b/app/src/main/res/drawable/ic_logo_splash.xml new file mode 100644 index 0000000..7f63216 --- /dev/null +++ b/app/src/main/res/drawable/ic_logo_splash.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/no_image_placeholder.jpg b/app/src/main/res/drawable/no_image_placeholder.jpg new file mode 100644 index 0000000..58b0e47 Binary files /dev/null and b/app/src/main/res/drawable/no_image_placeholder.jpg differ diff --git a/app/src/main/res/drawable/placeholder.xml b/app/src/main/res/drawable/placeholder.xml new file mode 100644 index 0000000..e1dd4f0 --- /dev/null +++ b/app/src/main/res/drawable/placeholder.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/profile_placeholder.jpg b/app/src/main/res/drawable/profile_placeholder.jpg new file mode 100644 index 0000000..1c5013f Binary files /dev/null and b/app/src/main/res/drawable/profile_placeholder.jpg differ diff --git a/app/src/main/res/font/nunito_black.ttf b/app/src/main/res/font/nunito_black.ttf new file mode 100644 index 0000000..500800c Binary files /dev/null and b/app/src/main/res/font/nunito_black.ttf differ diff --git a/app/src/main/res/font/nunito_bold.ttf b/app/src/main/res/font/nunito_bold.ttf new file mode 100644 index 0000000..6519feb Binary files /dev/null and b/app/src/main/res/font/nunito_bold.ttf differ diff --git a/app/src/main/res/font/nunito_extra_bold.ttf b/app/src/main/res/font/nunito_extra_bold.ttf new file mode 100644 index 0000000..02491b6 Binary files /dev/null and b/app/src/main/res/font/nunito_extra_bold.ttf differ diff --git a/app/src/main/res/font/nunito_extra_light.ttf b/app/src/main/res/font/nunito_extra_light.ttf new file mode 100644 index 0000000..364e7f6 Binary files /dev/null and b/app/src/main/res/font/nunito_extra_light.ttf differ diff --git a/app/src/main/res/font/nunito_italic.ttf b/app/src/main/res/font/nunito_italic.ttf new file mode 100644 index 0000000..36a94a6 Binary files /dev/null and b/app/src/main/res/font/nunito_italic.ttf differ diff --git a/app/src/main/res/font/nunito_light.ttf b/app/src/main/res/font/nunito_light.ttf new file mode 100644 index 0000000..8a0736c Binary files /dev/null and b/app/src/main/res/font/nunito_light.ttf differ diff --git a/app/src/main/res/font/nunito_medium.ttf b/app/src/main/res/font/nunito_medium.ttf new file mode 100644 index 0000000..88fccdc Binary files /dev/null and b/app/src/main/res/font/nunito_medium.ttf differ diff --git a/app/src/main/res/font/nunito_regular.ttf b/app/src/main/res/font/nunito_regular.ttf new file mode 100644 index 0000000..e7b8375 Binary files /dev/null and b/app/src/main/res/font/nunito_regular.ttf differ diff --git a/app/src/main/res/font/nunito_semi_bold.ttf b/app/src/main/res/font/nunito_semi_bold.ttf new file mode 100644 index 0000000..4fefdbf Binary files /dev/null and b/app/src/main/res/font/nunito_semi_bold.ttf differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..a6fe3cd Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..e019e50 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..3d7a37d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..db36e6f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..1520ed8 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..49b5cb3 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..fd259df Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..db8f97c Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..c7bd470 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..ebb7a16 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..f23992d Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..3a507eb Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..f3d8bf5 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..3b43f9b Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..7add670 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..15aaee7 --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,11 @@ + + + #FF90CAF9 + #FF2196F3 + #FF1976D2 + #FF21252b + #FFe2d7d7 + #FFEEEEEE + #FF21252b + #00000000 + diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml new file mode 100644 index 0000000..ec473a6 --- /dev/null +++ b/app/src/main/res/values-v31/themes.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..ca51727 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,11 @@ + + + #FF90CAF9 + #FF2196F3 + #FF1976D2 + #FF21252b + #FFe2d7d7 + #FFEEEEEE + #FFEEEEEE + #00000000 + diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..3381408 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #E2D7D7 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..366445a --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Nekos + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..1d30e38 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/xml/authenticator.xml b/app/src/main/res/xml/authenticator.xml new file mode 100644 index 0000000..2512b93 --- /dev/null +++ b/app/src/main/res/xml/authenticator.xml @@ -0,0 +1,6 @@ + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..fa0f996 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ee9997 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/dev/vdbroek/nekos/ExampleUnitTest.kt b/app/src/test/java/dev/vdbroek/nekos/ExampleUnitTest.kt new file mode 100644 index 0000000..3b107dc --- /dev/null +++ b/app/src/test/java/dev/vdbroek/nekos/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package dev.vdbroek.nekos + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..632b7ef --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,30 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:7.2.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.21") + } +} + +allprojects { + // Enable @OptIn annotation + tasks.withType { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + + freeCompilerArgs += listOf( + "-Xopt-in=kotlin.RequiresOptIn" + ) + } + } +} + +tasks.register("clean", Delete::class) { + delete(rootProject.buildDir) +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..3c5031e --- /dev/null +++ b/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..9d8ee73 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Thu May 12 23:25:16 CEST 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..4f906e0 --- /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 0000000..107acd3 --- /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 0000000..add65cc --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,19 @@ +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven(url = "https://jitpack.io") + } +} + +rootProject.name = "Nekos" +include(":app")