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