diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index d124cf2a..781af008 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -10,7 +10,11 @@
diff --git a/ai/.gitignore b/ai/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/ai/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/ai/build.gradle.kts b/ai/build.gradle.kts
new file mode 100644
index 00000000..4d52c48f
--- /dev/null
+++ b/ai/build.gradle.kts
@@ -0,0 +1,62 @@
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+}
+
+android {
+ namespace = "nethical.questphone.ai"
+ compileSdk = 36
+
+ defaultConfig {
+ minSdk = 26
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+
+ splits {
+ abi {
+ isEnable = true
+ reset()
+ include("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
+ isUniversalApk = true
+ }
+ }
+
+ sourceSets["main"].jniLibs.srcDirs("src/main/jniLibs")
+
+ externalNativeBuild {
+ cmake {
+ path = file("src/main/cpp/CMakeLists.txt")
+ }
+ }
+
+ ndkVersion = "29.0.13599879 rc2"
+}
+
+dependencies {
+
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.material)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+}
\ No newline at end of file
diff --git a/app/src/main/sentencepiece/include/sentencepiece/normalization_rule.h b/ai/consumer-rules.pro
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/normalization_rule.h
rename to ai/consumer-rules.pro
diff --git a/ai/proguard-rules.pro b/ai/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/ai/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/ai/src/androidTest/java/nethical/questphone/ai/ExampleInstrumentedTest.kt b/ai/src/androidTest/java/nethical/questphone/ai/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..1b8c1a60
--- /dev/null
+++ b/ai/src/androidTest/java/nethical/questphone/ai/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package nethical.questphone.ai
+
+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("nethical.questphone.ai.test", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/ai/src/main/AndroidManifest.xml b/ai/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..8bdb7e14
--- /dev/null
+++ b/ai/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/cpp/CMakeLists.txt b/ai/src/main/cpp/CMakeLists.txt
similarity index 100%
rename from app/src/main/cpp/CMakeLists.txt
rename to ai/src/main/cpp/CMakeLists.txt
diff --git a/app/src/main/cpp/sentencepiece_jni.cpp b/ai/src/main/cpp/sentencepiece_jni.cpp
similarity index 75%
rename from app/src/main/cpp/sentencepiece_jni.cpp
rename to ai/src/main/cpp/sentencepiece_jni.cpp
index 2d91235e..0460cb6b 100644
--- a/app/src/main/cpp/sentencepiece_jni.cpp
+++ b/ai/src/main/cpp/sentencepiece_jni.cpp
@@ -5,7 +5,7 @@
sentencepiece::SentencePieceProcessor sp;
extern "C" JNIEXPORT jint JNICALL
-Java_neth_iecal_questphone_utils_ai_SentencePieceProcessor_load(JNIEnv *env, jobject, jstring modelPath) {
+Java_nethical_questphone_ai_SentencePieceProcessor_load(JNIEnv *env, jobject, jstring modelPath) {
const char *path = env->GetStringUTFChars(modelPath, nullptr);
auto status = sp.Load(path);
env->ReleaseStringUTFChars(modelPath, path);
@@ -13,7 +13,7 @@ Java_neth_iecal_questphone_utils_ai_SentencePieceProcessor_load(JNIEnv *env, job
}
extern "C" JNIEXPORT jintArray JNICALL
-Java_neth_iecal_questphone_utils_ai_SentencePieceProcessor_encodeAsIds(JNIEnv *env, jobject, jstring input) {
+Java_nethical_questphone_ai_SentencePieceProcessor_encodeAsIds(JNIEnv *env, jobject, jstring input) {
const char *text = env->GetStringUTFChars(input, nullptr);
std::vector ids;
sp.Encode(text, &ids);
diff --git a/app/src/main/java/neth/iecal/questphone/utils/ai/OneShotTools.kt b/ai/src/main/java/nethical/questphone/ai/OneShotTools.kt
similarity index 98%
rename from app/src/main/java/neth/iecal/questphone/utils/ai/OneShotTools.kt
rename to ai/src/main/java/nethical/questphone/ai/OneShotTools.kt
index 70f6ed22..6089b7e5 100644
--- a/app/src/main/java/neth/iecal/questphone/utils/ai/OneShotTools.kt
+++ b/ai/src/main/java/nethical/questphone/ai/OneShotTools.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.utils.ai
+package nethical.questphone.ai
import android.content.Context
import android.graphics.Bitmap
diff --git a/app/src/main/jniLibs/arm64-v8a/libsentencepiece.so b/ai/src/main/jniLibs/arm64-v8a/libsentencepiece.so
similarity index 100%
rename from app/src/main/jniLibs/arm64-v8a/libsentencepiece.so
rename to ai/src/main/jniLibs/arm64-v8a/libsentencepiece.so
diff --git a/app/src/main/jniLibs/armeabi-v7a/libsentencepiece.so b/ai/src/main/jniLibs/armeabi-v7a/libsentencepiece.so
similarity index 100%
rename from app/src/main/jniLibs/armeabi-v7a/libsentencepiece.so
rename to ai/src/main/jniLibs/armeabi-v7a/libsentencepiece.so
diff --git a/app/src/main/jniLibs/x86/libsentencepiece.so b/ai/src/main/jniLibs/x86/libsentencepiece.so
similarity index 100%
rename from app/src/main/jniLibs/x86/libsentencepiece.so
rename to ai/src/main/jniLibs/x86/libsentencepiece.so
diff --git a/app/src/main/jniLibs/x86_64/libsentencepiece.so b/ai/src/main/jniLibs/x86_64/libsentencepiece.so
similarity index 100%
rename from app/src/main/jniLibs/x86_64/libsentencepiece.so
rename to ai/src/main/jniLibs/x86_64/libsentencepiece.so
diff --git a/app/src/main/sentencepiece/include/sentencepiece/bpe_model.h b/ai/src/main/sentencepiece/include/sentencepiece/bpe_model.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/bpe_model.h
rename to ai/src/main/sentencepiece/include/sentencepiece/bpe_model.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/bpe_model_trainer.h b/ai/src/main/sentencepiece/include/sentencepiece/bpe_model_trainer.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/bpe_model_trainer.h
rename to ai/src/main/sentencepiece/include/sentencepiece/bpe_model_trainer.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/builder.h b/ai/src/main/sentencepiece/include/sentencepiece/builder.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/builder.h
rename to ai/src/main/sentencepiece/include/sentencepiece/builder.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/char_model.h b/ai/src/main/sentencepiece/include/sentencepiece/char_model.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/char_model.h
rename to ai/src/main/sentencepiece/include/sentencepiece/char_model.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/char_model_trainer.h b/ai/src/main/sentencepiece/include/sentencepiece/char_model_trainer.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/char_model_trainer.h
rename to ai/src/main/sentencepiece/include/sentencepiece/char_model_trainer.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/common.h b/ai/src/main/sentencepiece/include/sentencepiece/common.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/common.h
rename to ai/src/main/sentencepiece/include/sentencepiece/common.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/filesystem.h b/ai/src/main/sentencepiece/include/sentencepiece/filesystem.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/filesystem.h
rename to ai/src/main/sentencepiece/include/sentencepiece/filesystem.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/freelist.h b/ai/src/main/sentencepiece/include/sentencepiece/freelist.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/freelist.h
rename to ai/src/main/sentencepiece/include/sentencepiece/freelist.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/init.h b/ai/src/main/sentencepiece/include/sentencepiece/init.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/init.h
rename to ai/src/main/sentencepiece/include/sentencepiece/init.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/model_factory.h b/ai/src/main/sentencepiece/include/sentencepiece/model_factory.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/model_factory.h
rename to ai/src/main/sentencepiece/include/sentencepiece/model_factory.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/model_interface.h b/ai/src/main/sentencepiece/include/sentencepiece/model_interface.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/model_interface.h
rename to ai/src/main/sentencepiece/include/sentencepiece/model_interface.h
diff --git a/ai/src/main/sentencepiece/include/sentencepiece/normalization_rule.h b/ai/src/main/sentencepiece/include/sentencepiece/normalization_rule.h
new file mode 100644
index 00000000..e69de29b
diff --git a/app/src/main/sentencepiece/include/sentencepiece/normalizer.h b/ai/src/main/sentencepiece/include/sentencepiece/normalizer.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/normalizer.h
rename to ai/src/main/sentencepiece/include/sentencepiece/normalizer.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/pretokenizer_for_training.h b/ai/src/main/sentencepiece/include/sentencepiece/pretokenizer_for_training.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/pretokenizer_for_training.h
rename to ai/src/main/sentencepiece/include/sentencepiece/pretokenizer_for_training.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/sentencepiece_processor.h b/ai/src/main/sentencepiece/include/sentencepiece/sentencepiece_processor.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/sentencepiece_processor.h
rename to ai/src/main/sentencepiece/include/sentencepiece/sentencepiece_processor.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/sentencepiece_trainer.h b/ai/src/main/sentencepiece/include/sentencepiece/sentencepiece_trainer.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/sentencepiece_trainer.h
rename to ai/src/main/sentencepiece/include/sentencepiece/sentencepiece_trainer.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/spec_parser.h b/ai/src/main/sentencepiece/include/sentencepiece/spec_parser.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/spec_parser.h
rename to ai/src/main/sentencepiece/include/sentencepiece/spec_parser.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/testharness.h b/ai/src/main/sentencepiece/include/sentencepiece/testharness.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/testharness.h
rename to ai/src/main/sentencepiece/include/sentencepiece/testharness.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/trainer_factory.h b/ai/src/main/sentencepiece/include/sentencepiece/trainer_factory.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/trainer_factory.h
rename to ai/src/main/sentencepiece/include/sentencepiece/trainer_factory.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/trainer_interface.h b/ai/src/main/sentencepiece/include/sentencepiece/trainer_interface.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/trainer_interface.h
rename to ai/src/main/sentencepiece/include/sentencepiece/trainer_interface.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/unicode_script.h b/ai/src/main/sentencepiece/include/sentencepiece/unicode_script.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/unicode_script.h
rename to ai/src/main/sentencepiece/include/sentencepiece/unicode_script.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/unicode_script_map.h b/ai/src/main/sentencepiece/include/sentencepiece/unicode_script_map.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/unicode_script_map.h
rename to ai/src/main/sentencepiece/include/sentencepiece/unicode_script_map.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/unigram_model.h b/ai/src/main/sentencepiece/include/sentencepiece/unigram_model.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/unigram_model.h
rename to ai/src/main/sentencepiece/include/sentencepiece/unigram_model.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/unigram_model_trainer.h b/ai/src/main/sentencepiece/include/sentencepiece/unigram_model_trainer.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/unigram_model_trainer.h
rename to ai/src/main/sentencepiece/include/sentencepiece/unigram_model_trainer.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/util.h b/ai/src/main/sentencepiece/include/sentencepiece/util.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/util.h
rename to ai/src/main/sentencepiece/include/sentencepiece/util.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/word_model.h b/ai/src/main/sentencepiece/include/sentencepiece/word_model.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/word_model.h
rename to ai/src/main/sentencepiece/include/sentencepiece/word_model.h
diff --git a/app/src/main/sentencepiece/include/sentencepiece/word_model_trainer.h b/ai/src/main/sentencepiece/include/sentencepiece/word_model_trainer.h
similarity index 100%
rename from app/src/main/sentencepiece/include/sentencepiece/word_model_trainer.h
rename to ai/src/main/sentencepiece/include/sentencepiece/word_model_trainer.h
diff --git a/ai/src/test/java/nethical/questphone/ai/ExampleUnitTest.kt b/ai/src/test/java/nethical/questphone/ai/ExampleUnitTest.kt
new file mode 100644
index 00000000..4eb030c8
--- /dev/null
+++ b/ai/src/test/java/nethical/questphone/ai/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package nethical.questphone.ai
+
+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/app/build.gradle.kts b/app/build.gradle.kts
index 06a1b4eb..7f7ea7b9 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,6 +1,4 @@
-import java.io.FileInputStream
-import java.util.Properties
plugins {
alias(libs.plugins.android.application)
@@ -8,14 +6,9 @@ plugins {
alias(libs.plugins.kotlin.compose)
kotlin("plugin.serialization") version "2.0.20"
+ id("com.google.devtools.ksp")
+ id("com.google.dagger.hilt.android")
- id("com.google.devtools.ksp") version "2.1.21-2.0.1"
-}
-
-val localProperties = Properties()
-val localPropertiesFile = rootProject.file("local.properties")
-if (localPropertiesFile.exists()) {
- localProperties.load(FileInputStream(localPropertiesFile))
}
android {
@@ -30,9 +23,6 @@ android {
versionName = "1.6"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- buildConfigField("String", "SUPABASE_URL", "\"${localProperties["SUPABASE_URL"]}\"")
- buildConfigField("String", "SUPABASE_API_KEY", "\"${localProperties["SUPABASE_API_KEY"]}\"")
- buildConfigField("String", "API_URL", "\"${localProperties["API_URL"]}\"")
}
flavorDimensions += "distribution"
@@ -50,14 +40,6 @@ android {
}
}
- splits {
- abi {
- isEnable = true
- reset()
- include("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
- isUniversalApk = true
- }
- }
buildTypes {
release {
isMinifyEnabled = false
@@ -77,16 +59,8 @@ android {
buildFeatures {
compose = true
buildConfig = true
- }
- sourceSets["main"].jniLibs.srcDirs("src/main/jniLibs")
- externalNativeBuild {
- cmake {
- path = file("src/main/cpp/CMakeLists.txt")
- }
}
-
- ndkVersion = "29.0.13599879 rc2"
}
dependencies {
@@ -107,7 +81,7 @@ dependencies {
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
-
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation (libs.kotlinx.serialization.json)
@@ -140,7 +114,15 @@ dependencies {
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
+ implementation(libs.hilt.android)
+ implementation(libs.androidx.hilt.navigation.compose)
+ ksp(libs.hilt.android.compiler)
+
ksp(libs.androidx.room.compiler)
implementation (libs.androidx.ui.text.google.fonts)
+ implementation(project(":data"))
+ implementation(project(":core"))
+ implementation(project(":backend"))
+ implementation(project(":ai"))
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 04f3c322..83c51f23 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -104,26 +104,18 @@
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
@@ -136,12 +128,12 @@
@@ -150,6 +142,14 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/neth/iecal/questphone/MainActivity.kt b/app/src/main/java/neth/iecal/questphone/MainActivity.kt
index 19d57bd7..d6d13692 100644
--- a/app/src/main/java/neth/iecal/questphone/MainActivity.kt
+++ b/app/src/main/java/neth/iecal/questphone/MainActivity.kt
@@ -13,55 +13,63 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
+import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
-import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.work.Data
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
+import dagger.hilt.android.AndroidEntryPoint
+import neth.iecal.questphone.app.navigation.Navigator
+import neth.iecal.questphone.app.navigation.RootRoute
+import neth.iecal.questphone.app.screens.account.UserInfoScreen
+import neth.iecal.questphone.app.screens.game.RewardDialogMaker
+import neth.iecal.questphone.app.screens.game.StoreScreen
+import neth.iecal.questphone.app.screens.launcher.AppList
+import neth.iecal.questphone.app.screens.launcher.AppListViewModel
+import neth.iecal.questphone.app.screens.launcher.HomeScreen
+import neth.iecal.questphone.app.screens.launcher.HomeScreenViewModel
+import neth.iecal.questphone.app.screens.onboard.subscreens.SelectApps
+import neth.iecal.questphone.app.screens.onboard.subscreens.SelectAppsModes
+import neth.iecal.questphone.app.screens.onboard.subscreens.SetCoinRewardRatio
+import neth.iecal.questphone.app.screens.pet.TheSystemDialog
+import neth.iecal.questphone.app.screens.quest.ListAllQuests
+import neth.iecal.questphone.app.screens.quest.ViewQuest
+import neth.iecal.questphone.app.screens.quest.setup.SetIntegration
+import neth.iecal.questphone.app.screens.quest.stats.specific.BaseQuestStatsView
+import neth.iecal.questphone.app.screens.quest.templates.SelectFromTemplates
+import neth.iecal.questphone.app.screens.quest.templates.SetupTemplate
+import neth.iecal.questphone.app.screens.quest.templates.TemplatesViewModel
+import neth.iecal.questphone.app.theme.LauncherTheme
+import neth.iecal.questphone.core.services.AppBlockerService
+import neth.iecal.questphone.core.utils.reminder.NotificationScheduler
import neth.iecal.questphone.data.IntegrationId
-import neth.iecal.questphone.data.game.User
-import neth.iecal.questphone.data.quest.QuestDatabaseProvider
-import neth.iecal.questphone.data.quest.stats.StatsDatabaseProvider
-import neth.iecal.questphone.services.AppBlockerService
-import neth.iecal.questphone.ui.navigation.Navigator
-import neth.iecal.questphone.ui.navigation.Screen
-import neth.iecal.questphone.ui.navigation.SetupQuestScreen
-import neth.iecal.questphone.ui.screens.account.UserInfoScreen
-import neth.iecal.questphone.ui.screens.game.StoreScreen
-import neth.iecal.questphone.ui.screens.launcher.AppList
-import neth.iecal.questphone.ui.screens.launcher.HomeScreen
-import neth.iecal.questphone.ui.screens.onboard.SelectApps
-import neth.iecal.questphone.ui.screens.onboard.SelectAppsModes
-import neth.iecal.questphone.ui.screens.onboard.SetCoinRewardRatio
-import neth.iecal.questphone.ui.screens.pet.TheSystemDialog
-import neth.iecal.questphone.ui.screens.quest.ListAllQuests
-import neth.iecal.questphone.ui.screens.quest.RewardDialogMaker
-import neth.iecal.questphone.ui.screens.quest.ViewQuest
-import neth.iecal.questphone.ui.screens.quest.setup.SetIntegration
-import neth.iecal.questphone.ui.screens.quest.stats.specific.BaseQuestStatsView
-import neth.iecal.questphone.ui.screens.quest.templates.SelectFromTemplates
-import neth.iecal.questphone.ui.screens.quest.templates.SetupTemplate
-import neth.iecal.questphone.ui.theme.LauncherTheme
-import neth.iecal.questphone.utils.isOnline
-import neth.iecal.questphone.utils.reminder.NotificationScheduler
-import neth.iecal.questphone.utils.triggerQuestSync
-import neth.iecal.questphone.utils.worker.FileDownloadWorker
+import nethical.questphone.backend.isOnline
+import nethical.questphone.backend.repositories.QuestRepository
+import nethical.questphone.backend.repositories.StatsRepository
+import nethical.questphone.backend.repositories.UserRepository
+import nethical.questphone.backend.triggerQuestSync
+import nethical.questphone.backend.worker.FileDownloadWorker
import java.io.File
+import javax.inject.Inject
+@AndroidEntryPoint(ComponentActivity::class)
class MainActivity : ComponentActivity() {
+ @Inject lateinit var userRepository: UserRepository
+ @Inject lateinit var questRepository: QuestRepository
+ @Inject lateinit var statRepository: StatsRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val data = getSharedPreferences("onboard", MODE_PRIVATE)
- val notificationScheduler = NotificationScheduler(applicationContext)
+ val notificationScheduler = NotificationScheduler(applicationContext,questRepository)
val modelSp = getSharedPreferences("models", Context.MODE_PRIVATE)
val isTokenizerDownloaded = modelSp.getBoolean("is_downloaded_tokenizer",false)
@@ -83,7 +91,6 @@ class MainActivity : ComponentActivity() {
setContent {
val isUserOnboarded = remember {mutableStateOf(true)}
- val isPetDialogVisible = remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
isUserOnboarded.value = data.getBoolean("onboard",false)
@@ -99,28 +106,24 @@ class MainActivity : ComponentActivity() {
LauncherTheme {
Surface {
val navController = rememberNavController()
- val currentRoute = navController.currentBackStackEntryAsState()
- val questDao = QuestDatabaseProvider.getInstance(applicationContext).questDao()
- val statsDao = StatsDatabaseProvider.getInstance(applicationContext).statsDao()
-
- val unSyncedQuestItems = remember { questDao.getUnSyncedQuests() }
- val unSyncedStatsItems = remember { statsDao.getAllUnSyncedStats() }
+ val unSyncedQuestItems = remember { questRepository.getUnSyncedQuests() }
+ val unSyncedStatsItems = remember { statRepository.getAllUnSyncedStats() }
val context = LocalContext.current
val forceCurrentScreen = remember { derivedStateOf { Navigator.currentScreen } }
- RewardDialogMaker()
+ RewardDialogMaker(userRepository)
TheSystemDialog()
LaunchedEffect(Unit) {
unSyncedQuestItems.collect {
notificationScheduler.reloadAllReminders()
- if (context.isOnline() && !User.userInfo.isAnonymous) {
+ if (context.isOnline() && !userRepository.userInfo.isAnonymous) {
triggerQuestSync(applicationContext)
}
}
unSyncedStatsItems.collect {
- if (context.isOnline() && !User.userInfo.isAnonymous ) {
+ if (context.isOnline() && !userRepository.userInfo.isAnonymous ) {
triggerQuestSync(applicationContext)
}
}
@@ -132,50 +135,52 @@ class MainActivity : ComponentActivity() {
Navigator.currentScreen = null
}
}
-
+ val homeScreenViewModel : HomeScreenViewModel = hiltViewModel()
+ val templatesViewModel: TemplatesViewModel = hiltViewModel()
NavHost(
navController = navController,
- startDestination = Screen.HomeScreen.route,
+ startDestination = RootRoute.HomeScreen.route,
) {
- composable(Screen.UserInfo.route) {
- UserInfoScreen()
+ composable(RootRoute.UserInfo.route) {
+ UserInfoScreen(navController = navController)
}
composable(
- route = "${Screen.SelectApps.route}{mode}",
+ route = "${RootRoute.SelectApps.route}{mode}",
arguments = listOf(navArgument("mode") { type = NavType.IntType })
) { backstack ->
val mode = backstack.arguments?.getInt("mode")
SelectApps(SelectAppsModes.entries[mode!!])
}
- composable(Screen.HomeScreen.route) {
- HomeScreen(navController)
+ composable(RootRoute.HomeScreen.route) {
+ HomeScreen(navController,homeScreenViewModel)
}
- composable(Screen.Store.route) {
+ composable(RootRoute.Store.route) {
StoreScreen(navController)
}
- composable(Screen.AppList.route) {
- AppList(navController)
+ composable(RootRoute.AppList.route) {
+ val appListViewModel : AppListViewModel = hiltViewModel()
+ AppList(navController,appListViewModel)
}
- composable(Screen.ListAllQuest.route) {
+ composable(RootRoute.ListAllQuest.route) {
ListAllQuests(navController)
}
composable(
- route = "${Screen.ViewQuest.route}{id}",
+ route = "${RootRoute.ViewQuest.route}{id}",
arguments = listOf(navArgument("id") { type = NavType.StringType })
) { backStackEntry ->
val id = backStackEntry.arguments?.getString("id")
- ViewQuest(navController, id!!)
+ ViewQuest(navController, questRepository,id!!)
}
navigation(
- startDestination = SetupQuestScreen.Integration.route,
- route = Screen.AddNewQuest.route
+ startDestination = RootRoute.SetIntegration.route,
+ route = RootRoute.AddNewQuest.route
) {
- composable(SetupQuestScreen.Integration.route) {
+ composable(RootRoute.SetIntegration.route) {
SetIntegration(
navController
)
@@ -195,20 +200,20 @@ class MainActivity : ComponentActivity() {
}
}
}
- composable("${Screen.QuestStats.route}{id}") { backStackEntry ->
+ composable("${RootRoute.QuestStats.route}{id}") { backStackEntry ->
val id = backStackEntry.arguments?.getString("id")
BaseQuestStatsView(id!!, navController)
}
- composable(Screen.SelectTemplates.route) {
- SelectFromTemplates(navController)
+ composable(RootRoute.SelectTemplates.route) {
+ SelectFromTemplates(navController,templatesViewModel)
}
- composable(Screen.SetCoinRewardRatio.route){
- SetCoinRewardRatio()
+ composable(RootRoute.SetupTemplate.route) {
+ SetupTemplate(navController,templatesViewModel)
}
- composable("${Screen.SetupTemplate.route}{id}") { backStackEntry ->
- val id = backStackEntry.arguments?.getString("id")
- SetupTemplate(id!!,navController)
+
+ composable(RootRoute.SetCoinRewardRatio.route){
+ SetCoinRewardRatio()
}
}
}
diff --git a/app/src/main/java/neth/iecal/questphone/MyApp.kt b/app/src/main/java/neth/iecal/questphone/MyApp.kt
index 05d11c73..c1e0fa30 100644
--- a/app/src/main/java/neth/iecal/questphone/MyApp.kt
+++ b/app/src/main/java/neth/iecal/questphone/MyApp.kt
@@ -3,22 +3,27 @@ package neth.iecal.questphone
import android.app.Application
import android.net.ConnectivityManager
import android.net.Network
-import neth.iecal.questphone.data.game.User
-import neth.iecal.questphone.services.reloadServiceInfo
-import neth.iecal.questphone.utils.VibrationHelper
-import neth.iecal.questphone.utils.isOnline
-import neth.iecal.questphone.utils.triggerQuestSync
-import neth.iecal.questphone.utils.triggerStatsSync
+import dagger.hilt.android.HiltAndroidApp
+import neth.iecal.questphone.core.services.reloadServiceInfo
+import nethical.questphone.backend.isOnline
+import nethical.questphone.backend.repositories.UserRepository
+import nethical.questphone.backend.triggerQuestSync
+import nethical.questphone.backend.triggerStatsSync
+import nethical.questphone.core.core.utils.VibrationHelper
+import javax.inject.Inject
+
+@HiltAndroidApp(Application::class)
class MyApp : Application() {
private lateinit var connectivityManager: ConnectivityManager
private lateinit var networkCallback: ConnectivityManager.NetworkCallback
+ @Inject lateinit var userRepository: UserRepository
override fun onCreate() {
super.onCreate()
- User.init(this)
+
VibrationHelper.init(this)
reloadServiceInfo(this)
connectivityManager = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
diff --git a/app/src/main/java/neth/iecal/questphone/OnboardActivity.kt b/app/src/main/java/neth/iecal/questphone/OnboardActivity.kt
index a4e36fcb..c10361c7 100644
--- a/app/src/main/java/neth/iecal/questphone/OnboardActivity.kt
+++ b/app/src/main/java/neth/iecal/questphone/OnboardActivity.kt
@@ -14,15 +14,17 @@ import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
+import dagger.hilt.android.AndroidEntryPoint
import io.github.jan.supabase.auth.handleDeeplinks
-import neth.iecal.questphone.ui.navigation.Screen
-import neth.iecal.questphone.ui.screens.account.SetupNewPassword
-import neth.iecal.questphone.ui.screens.onboard.OnBoardScreen
-import neth.iecal.questphone.ui.screens.onboard.TermsScreen
-import neth.iecal.questphone.ui.theme.LauncherTheme
-import neth.iecal.questphone.utils.Supabase
+import neth.iecal.questphone.app.navigation.RootRoute
+import neth.iecal.questphone.app.screens.account.SetupNewPassword
+import neth.iecal.questphone.app.screens.onboard.OnBoarderView
+import neth.iecal.questphone.app.screens.onboard.subscreens.TermsScreen
+import neth.iecal.questphone.app.theme.LauncherTheme
+import nethical.questphone.backend.Supabase
+@AndroidEntryPoint(ComponentActivity::class)
class OnboardActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -61,9 +63,9 @@ class OnboardActivity : ComponentActivity() {
}
}
- val startDestination = if (isLoginResetPassword.value) Screen.ResetPass.route
- else if (!isTosAccepted.value) Screen.TermsScreen.route
- else Screen.OnBoard.route
+ val startDestination = if (isLoginResetPassword.value) RootRoute.ResetPass.route
+ else if (!isTosAccepted.value) RootRoute.TermsScreen.route
+ else RootRoute.OnBoard.route
LauncherTheme {
Surface {
@@ -80,16 +82,16 @@ class OnboardActivity : ComponentActivity() {
startDestination = startDestination
) {
- composable(Screen.OnBoard.route) {
- OnBoardScreen(navController)
+ composable(RootRoute.OnBoard.route) {
+ OnBoarderView(navController)
}
composable(
- Screen.ResetPass.route
+ RootRoute.ResetPass.route
) {
SetupNewPassword(navController)
}
- composable(Screen.TermsScreen.route) {
+ composable(RootRoute.TermsScreen.route) {
TermsScreen(isTosAccepted)
}
}
diff --git a/app/src/main/java/neth/iecal/questphone/PrivacyPolicyActivity.kt b/app/src/main/java/neth/iecal/questphone/PrivacyPolicyActivity.kt
index 41b829f0..e60c8b08 100644
--- a/app/src/main/java/neth/iecal/questphone/PrivacyPolicyActivity.kt
+++ b/app/src/main/java/neth/iecal/questphone/PrivacyPolicyActivity.kt
@@ -16,7 +16,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
-import neth.iecal.questphone.ui.theme.LauncherTheme
+import neth.iecal.questphone.app.theme.LauncherTheme
class PrivacyPolicyActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
diff --git a/app/src/main/java/neth/iecal/questphone/app/navigation/LauncherDialogRoutes.kt b/app/src/main/java/neth/iecal/questphone/app/navigation/LauncherDialogRoutes.kt
new file mode 100644
index 00000000..a746764d
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/navigation/LauncherDialogRoutes.kt
@@ -0,0 +1,19 @@
+package neth.iecal.questphone.app.navigation
+
+/**
+ * Main screen navigation
+ *
+ * @property route
+ */
+sealed class LauncherDialogRoutes(val route: String) {
+ data object UnlockAppDialog: LauncherDialogRoutes("unlock_app/")
+ data object ShowAllQuest: LauncherDialogRoutes("all_quests/")
+ data object LowCoins: LauncherDialogRoutes("low_coins/")
+
+ // the one where the benefits of performing a quest vs using free pass shown
+ data object MakeAChoice: LauncherDialogRoutes("make_a_choice")
+ // the one where user can finally use a freepass
+ data object FreePassInfo: LauncherDialogRoutes("free_pass_info")
+
+}
+
diff --git a/app/src/main/java/neth/iecal/questphone/ui/navigation/Navigator.kt b/app/src/main/java/neth/iecal/questphone/app/navigation/Navigator.kt
similarity index 82%
rename from app/src/main/java/neth/iecal/questphone/ui/navigation/Navigator.kt
rename to app/src/main/java/neth/iecal/questphone/app/navigation/Navigator.kt
index d653a8d3..ba69c2f1 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/navigation/Navigator.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/navigation/Navigator.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.navigation
+package neth.iecal.questphone.app.navigation
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
diff --git a/app/src/main/java/neth/iecal/questphone/app/navigation/RootRoute.kt b/app/src/main/java/neth/iecal/questphone/app/navigation/RootRoute.kt
new file mode 100644
index 00000000..88342eef
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/navigation/RootRoute.kt
@@ -0,0 +1,31 @@
+package neth.iecal.questphone.app.navigation
+
+/**
+ * Main screen navigation
+ *
+ * @property route
+ */
+sealed class RootRoute(val route: String) {
+ data object HomeScreen : RootRoute("home_screen/")
+ data object AppList : RootRoute("app_list/")
+ data object ViewQuest : RootRoute("view_quest/")
+ data object AddNewQuest : RootRoute("add_quest/")
+ data object ListAllQuest : RootRoute("list_quest/")
+
+ data object OnBoard : RootRoute("onboard/")
+ data object ResetPass : RootRoute("reset_pass/")
+ data object Store : RootRoute("store/")
+ data object UserInfo : RootRoute("userInfo/")
+ data object QuestStats : RootRoute("questStats/")
+
+ data object SelectApps : RootRoute("select_apps/")
+
+ data object TermsScreen : RootRoute("terms_screen")
+ data object SelectTemplates : RootRoute("templates_screen/")
+ data object SetupTemplate : RootRoute("setup_template/")
+
+ data object SetCoinRewardRatio : RootRoute("set_coin_reward_ratio/")
+
+ data object SetIntegration : RootRoute("set_quest_integration/")
+}
+
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/account/ForgotPasswordScreen.kt b/app/src/main/java/neth/iecal/questphone/app/screens/account/ForgotPasswordScreen.kt
similarity index 78%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/account/ForgotPasswordScreen.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/account/ForgotPasswordScreen.kt
index 08472ba8..7a8befc9 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/account/ForgotPasswordScreen.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/account/ForgotPasswordScreen.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.account
+package neth.iecal.questphone.app.screens.account
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
@@ -28,12 +28,8 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
@@ -42,53 +38,28 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
-import io.github.jan.supabase.auth.auth
-import kotlinx.coroutines.launch
-import neth.iecal.questphone.utils.Supabase
+import neth.iecal.questphone.app.screens.account.login.AuthStep
+import neth.iecal.questphone.app.screens.account.login.LoginViewModel
enum class ForgotPasswordStep {
- EMAIL,
+ FORM,
VERIFICATION
}
@Composable
-fun ForgotPasswordScreen(loginStep: MutableState) {
- // States
- var email by remember { mutableStateOf("") }
- var newPassword by remember { mutableStateOf("") }
- var confirmPassword by remember { mutableStateOf("") }
- var isLoading by remember { mutableStateOf(false) }
- var errorMessage by remember { mutableStateOf(null) }
- var forgotPasswordStep by remember { mutableStateOf(ForgotPasswordStep.EMAIL) }
+fun ForgotPasswordScreen(viewModel: LoginViewModel) {
val focusManager = LocalFocusManager.current
val scrollState = rememberScrollState()
- val coroutineScope = rememberCoroutineScope()
- // Email validation
- val isEmailValid = email.contains("@") && email.contains(".")
-
- // Function to handle email submission
- val handleEmailSubmit = {
- if (email.isBlank()) {
- errorMessage = "Please enter your email"
- } else if (!isEmailValid) {
- errorMessage = "Please enter a valid email"
- } else {
- errorMessage = null
- isLoading = true
-
- coroutineScope.launch {
- Supabase.supabase.auth.resetPasswordForEmail(email)
- }
-
- forgotPasswordStep = ForgotPasswordStep.VERIFICATION
- isLoading = false
- }
- }
-
+ val email by viewModel.email.collectAsState()
+ val isEmailValid = viewModel.isEmailValid()
+ val errorMessage by viewModel.errorMessage.collectAsState()
+ val isLoading by viewModel.isLoading.collectAsState()
+ val forgetPasswordStep by viewModel.forgetPasswordStep.collectAsState()
+ val authStep = viewModel.authStep
BackHandler {
- loginStep.value = LoginStep.LOGIN
+ authStep.value = AuthStep.LOGIN
}
Box(
@@ -100,7 +71,7 @@ fun ForgotPasswordScreen(loginStep: MutableState) {
// Back button
IconButton(
onClick = {
- loginStep.value = LoginStep.LOGIN
+ authStep.value = AuthStep.LOGIN
},
modifier = Modifier
@@ -135,8 +106,8 @@ fun ForgotPasswordScreen(loginStep: MutableState) {
Spacer(modifier = Modifier.height(8.dp))
Text(
- text = when (forgotPasswordStep) {
- ForgotPasswordStep.EMAIL -> "Reset Password"
+ text = when (forgetPasswordStep) {
+ ForgotPasswordStep.FORM -> "Reset Password"
ForgotPasswordStep.VERIFICATION -> "Check your email"
},
style = MaterialTheme.typography.bodyLarge,
@@ -158,9 +129,9 @@ fun ForgotPasswordScreen(loginStep: MutableState) {
}
}
- when (forgotPasswordStep) {
+ when (forgetPasswordStep) {
// Email step
- ForgotPasswordStep.EMAIL -> {
+ ForgotPasswordStep.FORM -> {
Text(
text = "Enter your email address and we'll send you a link to reset your password.",
style = MaterialTheme.typography.bodyMedium,
@@ -171,7 +142,7 @@ fun ForgotPasswordScreen(loginStep: MutableState) {
// Email field
OutlinedTextField(
value = email,
- onValueChange = { email = it; errorMessage = null },
+ onValueChange = { viewModel.onEmailChanged(it) },
label = { Text("Email") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
@@ -188,7 +159,7 @@ fun ForgotPasswordScreen(loginStep: MutableState) {
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
- handleEmailSubmit()
+ viewModel.forgetPassword()
}
),
isError = errorMessage != null && (email.isBlank() || !isEmailValid)
@@ -197,7 +168,7 @@ fun ForgotPasswordScreen(loginStep: MutableState) {
// Submit button
Button(
- onClick = handleEmailSubmit,
+ onClick = { viewModel.forgetPassword() },
modifier = Modifier
.fillMaxWidth(),
enabled = !isLoading
@@ -241,7 +212,7 @@ fun ForgotPasswordScreen(loginStep: MutableState) {
// Back button
TextButton(
- onClick = { forgotPasswordStep = ForgotPasswordStep.EMAIL },
+ onClick = { viewModel.forgetPasswordStep.value = ForgotPasswordStep.FORM },
modifier = Modifier.fillMaxWidth()
) {
Text("Back to email")
@@ -253,7 +224,7 @@ fun ForgotPasswordScreen(loginStep: MutableState) {
Spacer(modifier = Modifier.height(32.dp))
// Login option
- if (forgotPasswordStep == ForgotPasswordStep.EMAIL) {
+ if (forgetPasswordStep == ForgotPasswordStep.FORM) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
@@ -266,7 +237,7 @@ fun ForgotPasswordScreen(loginStep: MutableState) {
)
TextButton(onClick = {
- loginStep.value = LoginStep.LOGIN
+ authStep.value = AuthStep.LOGIN
}) {
Text("Login")
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/account/SetupNewPasswordScreen.kt b/app/src/main/java/neth/iecal/questphone/app/screens/account/SetupNewPasswordScreen.kt
similarity index 97%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/account/SetupNewPasswordScreen.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/account/SetupNewPasswordScreen.kt
index 0fa42163..a5714ab5 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/account/SetupNewPasswordScreen.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/account/SetupNewPasswordScreen.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.account
+package neth.iecal.questphone.app.screens.account
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
@@ -43,8 +43,8 @@ import androidx.navigation.NavController
import io.github.jan.supabase.auth.auth
import kotlinx.coroutines.launch
import neth.iecal.questphone.R
-import neth.iecal.questphone.ui.navigation.Screen
-import neth.iecal.questphone.utils.Supabase
+import neth.iecal.questphone.app.navigation.RootRoute
+import nethical.questphone.backend.Supabase
@Composable
fun SetupNewPassword(navController: NavController) {
@@ -86,7 +86,7 @@ fun SetupNewPassword(navController: NavController) {
coroutineScope.launch {
Supabase.supabase.auth.updateUser { password = confirmPassword}
- navController.navigate(Screen.OnBoard.route)
+ navController.navigate(RootRoute.OnBoard.route)
}
}
}
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/account/SetupProfile.kt b/app/src/main/java/neth/iecal/questphone/app/screens/account/SetupProfile.kt
similarity index 51%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/account/SetupProfile.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/account/SetupProfile.kt
index 76c58a82..fd3326c7 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/account/SetupProfile.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/account/SetupProfile.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.account
+package neth.iecal.questphone.app.screens.account
import android.content.Context
import android.net.Uri
@@ -30,11 +30,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -46,84 +44,215 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
+import dagger.hilt.android.lifecycle.HiltViewModel
import io.github.jan.supabase.auth.auth
import io.github.jan.supabase.postgrest.from
import io.github.jan.supabase.postgrest.postgrest
import io.github.jan.supabase.postgrest.query.Columns
import io.github.jan.supabase.storage.FileUploadResponse
import io.github.jan.supabase.storage.storage
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import neth.iecal.questphone.R
-import neth.iecal.questphone.data.game.User
-import neth.iecal.questphone.data.game.UserInfo
-import neth.iecal.questphone.data.game.saveUserInfo
-import neth.iecal.questphone.utils.Supabase
+import nethical.questphone.backend.Supabase
+import nethical.questphone.backend.repositories.UserRepository
+import nethical.questphone.data.game.UserInfo
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.util.Base64
+import javax.inject.Inject
-@Composable
-fun SetupProfileScreen(isNextEnabledSetupProfile: MutableState = mutableStateOf(false)) {
- var name by remember { mutableStateOf("") }
- var username by remember { mutableStateOf("") }
+@HiltViewModel
+class SetupProfileViewModel @Inject constructor(
+ val userRepository: UserRepository
+) : ViewModel() {
+ private val _name = MutableStateFlow("")
+ val name: StateFlow = _name.asStateFlow()
- var isLoading by remember { mutableStateOf(true) }
- var isProfileSetupDone by remember { mutableStateOf(false) }
+ private val _username = MutableStateFlow("")
+ val username: StateFlow = _username.asStateFlow()
- var errorMessage by remember { mutableStateOf(null) }
+ private val _isLoading = MutableStateFlow(true)
+ val isLoading: StateFlow = _isLoading.asStateFlow()
- var profileUri by remember { mutableStateOf(null) }
- var profileUrl by remember { mutableStateOf(null) }
+ private val _isProfileSetupDone = MutableStateFlow(false)
+ val isProfileSetupDone: StateFlow = _isProfileSetupDone.asStateFlow()
- val focusManager = LocalFocusManager.current
- val scrollState = rememberScrollState()
- val coroutineScope = rememberCoroutineScope()
+ private val _errorMessage = MutableStateFlow(null)
+ val errorMessage: StateFlow = _errorMessage.asStateFlow()
- val launcher = rememberLauncherForActivityResult(
- contract = ActivityResultContracts.GetContent()
- ) { uri: Uri? ->
+ private val _profileUri = MutableStateFlow(null)
+ val profileUri: StateFlow = _profileUri.asStateFlow()
+
+ private val _profileUrl = MutableStateFlow(null)
+ val profileUrl: StateFlow = _profileUrl.asStateFlow()
+
+ fun initializeProfile() {
+ viewModelScope.launch {
+ if (userRepository.userInfo.isAnonymous) {
+ _isLoading.value = false
+ }else {
+ val userId = Supabase.supabase.auth.currentUserOrNull()!!.id
+
+ val profile = Supabase.supabase.from("profiles")
+ .select {
+ filter {
+ eq("id", userId)
+ }
+ }
+ .decodeSingleOrNull()
+
+ if (profile != null) {
+ userRepository.userInfo = profile
+ if (profile.has_profile) {
+ _profileUrl.value =
+ "https://hplszhlnchhfwngbojnc.supabase.co/storage/v1/object/public/profile/$userId/profile"
+ }
+ } else {
+ userRepository.userInfo.username = squashUserIdToUsername(userId)
+ Supabase.supabase.postgrest["profiles"].upsert(userRepository.userInfo)
+ }
+ userRepository.saveUserInfo()
+
+ _name.value = userRepository.userInfo.full_name
+ _username.value = userRepository.userInfo.username
+ _isLoading.value = false
+ }
+ }
+ }
+
+ fun updateName(name: String) {
+ _name.value = name
+ _errorMessage.value = null
+ }
+
+ fun updateUsername(username: String) {
+ val filtered = username.filter { ch -> ch.isLetterOrDigit() || ch == '_' || ch == '-' }
+ if (filtered == username) {
+ _username.value = filtered
+ _errorMessage.value = null
+ }
+ }
+
+ fun updateProfileImage(uri: Uri?) {
+ _profileUri.value = uri
if (uri != null) {
- profileUri = uri
- profileUrl = null
+ _profileUrl.value = null
}
}
- val context = LocalContext.current
+ fun updateProfile(context: Context) {
+ if (_name.value.isBlank() || _username.value.isBlank()) {
+ _errorMessage.value = "Please fill in all fields."
+ return
+ }
- LaunchedEffect(Unit) {
- if(User.userInfo.isAnonymous) {
- isNextEnabledSetupProfile.value = true
- isLoading = false
- return@LaunchedEffect
+ _isLoading.value = true
+
+ if (userRepository.userInfo.isAnonymous) {
+ userRepository.userInfo = UserInfo(
+ username = _username.value,
+ full_name = _name.value,
+ has_profile = _profileUri.value != null || _profileUrl.value != null
+ )
+ if (_profileUri.value != null) {
+ copyFileFromUriToAppStorage(context, _profileUri.value!!)
+ }
+ userRepository.saveUserInfo()
+ _isLoading.value = false
+ _isProfileSetupDone.value = true
+ return
}
- val userId = Supabase.supabase.auth.currentUserOrNull()!!.id
- val profile = Supabase.supabase.from("profiles")
- .select {
- filter {
- eq("id", userId)
+ viewModelScope.launch {
+ try {
+ val userId = Supabase.supabase.auth.currentUserOrNull()!!.id
+
+ if (isUsernameTaken(_username.value, userId)) {
+ _errorMessage.value = "This username has already been taken"
+ _isLoading.value = false
+ return@launch
}
+
+ val avatarUrlResult: FileUploadResponse? = if (_profileUri.value != null) {
+ val avatarBytes = getBytesFromUri(context, _profileUri.value!!)
+ if (avatarBytes == null) {
+ _errorMessage.value = "Failed to read image"
+ _isLoading.value = false
+ return@launch
+ }
+
+ if (avatarBytes.size > 5 * 1024 * 1024) {
+ _errorMessage.value = "Avatar file is too large (max 5MB)"
+ _isLoading.value = false
+ return@launch
+ }
+
+ Supabase.supabase.storage
+ .from("profile")
+ .upload(
+ path = "$userId/profile",
+ data = avatarBytes,
+ options = {
+ upsert = true
+ })
+ } else {
+ null
+ }
+
+ userRepository.userInfo = UserInfo(
+ username = _username.value,
+ full_name = _name.value,
+ has_profile = _profileUri.value != null || _profileUrl.value != null
+ )
+ userRepository.saveUserInfo()
+
+ Log.d("SetupProfile", userRepository.userInfo.toString())
+ Supabase.supabase.postgrest["profiles"].upsert(userRepository.userInfo)
+
+ _isLoading.value = false
+ _isProfileSetupDone.value = true
+ } catch (e: Exception) {
+ _errorMessage.value = "Failed to update profile: ${e.message}"
+ _isLoading.value = false
}
- .decodeSingleOrNull()
- if (profile != null) {
- User.userInfo = profile
- if (profile.has_profile) {
- profileUrl =
- "https://hplszhlnchhfwngbojnc.supabase.co/storage/v1/object/public/profile/$userId/profile"
- }
- } else {
- User.userInfo.username = squashUserIdToUsername(userId)
- Supabase.supabase.postgrest["profiles"].upsert(
- User.userInfo
- )
}
- User.saveUserInfo()
- name = User.userInfo.full_name
- username = User.userInfo.username
- isLoading = false
+ }
+}
+
+@Composable
+fun SetupProfileScreen(
+ isNextEnabledSetupProfile: MutableState = mutableStateOf(false),
+ viewModel: SetupProfileViewModel = hiltViewModel()
+) {
+ val name by viewModel.name.collectAsState()
+ val username by viewModel.username.collectAsState()
+ val isLoading by viewModel.isLoading.collectAsState()
+ val isProfileSetupDone by viewModel.isProfileSetupDone.collectAsState()
+ val errorMessage by viewModel.errorMessage.collectAsState()
+ val profileUri by viewModel.profileUri.collectAsState()
+ val profileUrl by viewModel.profileUrl.collectAsState()
+
+ val focusManager = LocalFocusManager.current
+ val scrollState = rememberScrollState()
+ val context = LocalContext.current
+
+ val launcher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.GetContent()
+ ) { uri: Uri? ->
+ viewModel.updateProfileImage(uri)
+ }
+
+ LaunchedEffect(Unit) {
+ viewModel.initializeProfile()
isNextEnabledSetupProfile.value = true
}
@@ -139,28 +268,24 @@ fun SetupProfileScreen(isNextEnabledSetupProfile: MutableState = mutabl
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
-
Image(
painter = rememberAsyncImagePainter(
-
model = ImageRequest.Builder(LocalContext.current)
.data(
- if (profileUrl != null)
- profileUrl
- else if (profileUri != null) profileUri
- else R.drawable.baseline_person_24
+ when {
+ profileUrl != null -> profileUrl
+ profileUri != null -> profileUri
+ else -> R.drawable.baseline_person_24
+ }
)
.crossfade(true)
.error(R.drawable.baseline_person_24)
.placeholder(R.drawable.baseline_person_24)
.build(),
),
-
contentDescription = "Avatar",
-
modifier = Modifier
.size(96.dp)
-
.clip(CircleShape)
.clickable {
launcher.launch("image/*")
@@ -173,9 +298,10 @@ fun SetupProfileScreen(isNextEnabledSetupProfile: MutableState = mutabl
)
Spacer(modifier = Modifier.height(24.dp))
+
OutlinedTextField(
value = name,
- onValueChange = { name = it; errorMessage = null },
+ onValueChange = viewModel::updateName,
label = { Text("Full Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
@@ -189,15 +315,10 @@ fun SetupProfileScreen(isNextEnabledSetupProfile: MutableState = mutabl
)
Spacer(modifier = Modifier.height(16.dp))
+
OutlinedTextField(
value = username,
- onValueChange = {
- val filtered = it.filter { ch -> ch.isLetterOrDigit() || ch == '_' || ch == '-' }
- if (filtered == it) {
- username = filtered
- errorMessage = null
- }
- },
+ onValueChange = viewModel::updateUsername,
label = { Text("Username") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
@@ -213,79 +334,8 @@ fun SetupProfileScreen(isNextEnabledSetupProfile: MutableState = mutabl
Spacer(modifier = Modifier.height(24.dp))
if (!isProfileSetupDone) {
-
Button(
- onClick = {
- if (name.isBlank() || username.isBlank()) {
- errorMessage = "Please fill in all fields."
- return@Button
- }
- isLoading = true
- if(User.userInfo.isAnonymous){
- User.userInfo = UserInfo(
- username = username,
- full_name = name,
- has_profile = profileUri != null || profileUrl != null
- )
- if(profileUri!=null){
- copyFileFromUriToAppStorage(context,profileUri!!)
- }
- User.saveUserInfo()
- isLoading = false
- isProfileSetupDone = true
- return@Button
- }
- coroutineScope.launch {
- isNextEnabledSetupProfile.value = true
- val userId = Supabase.supabase.auth.currentUserOrNull()!!.id
- if (isUsernameTaken(username, userId)) {
- errorMessage = "This username has already been taken"
- isLoading = false
- return@launch
- }
- val avatarUrlResult: FileUploadResponse? = if (profileUri != null) {
- val avatarBytes = getBytesFromUri(context, profileUri!!)
- if (avatarBytes == null) {
- errorMessage = "Failed to read image"
- isLoading = false
- return@launch
- }
-
- if (avatarBytes.size > 5 * 1024 * 1024) {
- errorMessage = "Avatar file is too large (max 5MB)"
- isLoading = false
- return@launch
- }
-
- Supabase.supabase.storage
- .from("profile")
- .upload(
- path = "$userId/profile",
- data = avatarBytes,
- options = {
- upsert = true
- })
- } else {
- null
- }
-
- User.userInfo = UserInfo(
- username = username,
- full_name = name,
- has_profile = profileUri != null || profileUrl != null
- )
- User.saveUserInfo()
-
- Log.d("SetupProfile", User.userInfo.toString())
- Supabase.supabase.postgrest["profiles"].upsert(
- User.userInfo
- )
- isLoading = false
- isNextEnabledSetupProfile.value = true
-
- isProfileSetupDone = true
- }
- },
+ onClick = { viewModel.updateProfile(context) },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading
) {
@@ -301,6 +351,7 @@ fun SetupProfileScreen(isNextEnabledSetupProfile: MutableState = mutabl
} else {
Text("Profile setup successful!!")
}
+
Spacer(modifier = Modifier.height(12.dp))
AnimatedVisibility(visible = errorMessage != null) {
@@ -313,30 +364,31 @@ fun SetupProfileScreen(isNextEnabledSetupProfile: MutableState = mutabl
)
}
}
-
}
}
}
-suspend fun isUsernameTaken(username: String,id:String): Boolean {
+
+// Utility functions
+private suspend fun isUsernameTaken(username: String, id: String): Boolean {
return try {
val result = Supabase.supabase
.from("profiles")
.select(columns = Columns.list("id")) {
filter {
eq("username", username)
- neq("id",id)
+ neq("id", id)
}
}
.decodeList()
- return result.isNotEmpty()
- //check if the record is the users record itself
+ result.isNotEmpty()
} catch (e: Exception) {
println("Error checking username: ${e.message}")
true
}
}
-fun getBytesFromUri(context: Context, uri: Uri): ByteArray? {
+
+private fun getBytesFromUri(context: Context, uri: Uri): ByteArray? {
return try {
context.contentResolver.openInputStream(uri)?.use { inputStream: InputStream ->
inputStream.readBytes()
@@ -346,12 +398,14 @@ fun getBytesFromUri(context: Context, uri: Uri): ByteArray? {
null
}
}
-fun squashUserIdToUsername(userId: String): String {
+
+private fun squashUserIdToUsername(userId: String): String {
val bytes = userId.toByteArray(Charsets.UTF_8)
val base64 = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
return base64.take(5) // first 5 chars
}
-fun copyFileFromUriToAppStorage(
+
+private fun copyFileFromUriToAppStorage(
context: Context,
uri: Uri,
): File? {
@@ -370,4 +424,4 @@ fun copyFileFromUriToAppStorage(
e.printStackTrace()
null
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/account/UserInfoScreen.kt b/app/src/main/java/neth/iecal/questphone/app/screens/account/UserInfoScreen.kt
new file mode 100644
index 00000000..50e5ee2c
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/account/UserInfoScreen.kt
@@ -0,0 +1,434 @@
+package neth.iecal.questphone.app.screens.account
+
+import android.app.Activity
+import android.app.Application
+import android.content.Intent
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material3.BasicAlertDialog
+import androidx.compose.material3.Card
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.navigation.NavController
+import coil.compose.rememberAsyncImagePainter
+import coil.request.ImageRequest
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import neth.iecal.questphone.OnboardActivity
+import neth.iecal.questphone.R
+import neth.iecal.questphone.app.screens.game.InventoryBox
+import neth.iecal.questphone.app.screens.quest.stats.components.HeatMapChart
+import nethical.questphone.backend.BuildConfig
+import nethical.questphone.backend.repositories.UserRepository
+import nethical.questphone.core.core.utils.formatNumber
+import nethical.questphone.data.game.InventoryItem
+import nethical.questphone.data.game.UserInfo
+import nethical.questphone.data.game.xpToLevelUp
+import java.io.File
+import javax.inject.Inject
+
+
+@HiltViewModel
+class UserInfoViewModel @Inject constructor(
+ application: Application,
+ private val userRepository: UserRepository,
+) : AndroidViewModel(application) {
+
+ val userInfo: UserInfo = userRepository.userInfo
+ val totalXpForCurrentLevel = xpToLevelUp(userInfo.level)
+ val totalXpForNextLevel = xpToLevelUp(userInfo.level + 1)
+
+ val xpProgress = (userInfo.xp - totalXpForCurrentLevel).toFloat() /
+ (totalXpForNextLevel - totalXpForCurrentLevel)
+
+ val profilePicLink = if (userInfo.has_profile){
+ if(userInfo.isAnonymous){
+ val profileFile = File(application.filesDir, "profile")
+ profileFile.absolutePath
+ }else{
+ "${BuildConfig.SUPABASE_URL}/storage/v1/object/public/profile/${userRepository.getUserId()}/profile"
+ }
+ } else null
+
+
+
+
+ fun logOut(onLoggedOut: () -> Unit) {
+ viewModelScope.launch {
+ userRepository.signOut()
+ withContext(Dispatchers.Main) {
+ onLoggedOut()
+ }
+ }
+ }
+
+}
+
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun UserInfoScreen(viewModel: UserInfoViewModel = hiltViewModel(),navController: NavController) {
+ val context = LocalContext.current
+ Scaffold { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(innerPadding)
+ .padding(16.dp),
+ ) {
+ Row(
+ modifier = Modifier.padding(bottom = 32.dp)
+ ) {
+
+ // Profile Header
+ Text(
+ text = "Profile",
+ fontSize = 24.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ Spacer(Modifier.weight(1f))
+
+ Menu(viewModel.userInfo.isAnonymous, {
+ viewModel.logOut {
+ val intent = Intent(context, OnboardActivity::class.java)
+ context.startActivity(intent)
+ (context as Activity).finish()
+ }
+ })
+ }
+
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ // Avatar
+ Box(
+ modifier = Modifier
+ .size(120.dp)
+ .clip(RoundedCornerShape(12.dp))
+ ) {
+ Image(
+ painter = rememberAsyncImagePainter(
+
+ model = ImageRequest.Builder(LocalContext.current)
+ .data(viewModel.profilePicLink)
+ .crossfade(true)
+ .error(R.drawable.baseline_person_24)
+ .placeholder(R.drawable.baseline_person_24)
+ .build(),
+ ),
+ contentDescription = "Avatar",
+ Modifier.fillMaxSize(),
+ colorFilter = if (viewModel.profilePicLink == null)
+ ColorFilter.tint(MaterialTheme.colorScheme.onSurface)
+ else
+ null,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ "@${viewModel.userInfo.username}",
+ style = MaterialTheme.typography.bodyMedium
+ )
+
+ Text(
+ viewModel.userInfo.username,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+
+
+ // Level Progress Bar
+ Column(
+ modifier = Modifier.width(250.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ "Level ${viewModel.userInfo.level}",
+ color = MaterialTheme.colorScheme.outline,
+ fontSize = 12.sp
+ )
+ Text(
+ "XP: ${viewModel.userInfo.xp} / ${viewModel.totalXpForNextLevel}",
+ color = MaterialTheme.colorScheme.outline,
+ fontSize = 12.sp
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(12.dp)
+ .align(Alignment.CenterHorizontally)
+ ) {
+ LinearProgressIndicator(
+ progress = { viewModel.xpProgress.coerceIn(0f, 1f) },
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Stats Box
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ StatItem(
+ value = formatNumber(viewModel.userInfo.coins),
+ label = "coins"
+ )
+
+ StatItem(
+ value = "${formatNumber(viewModel.userInfo.streak.currentStreak)}d",
+ label = "Streak"
+ )
+
+ StatItem(
+ value = "${formatNumber(viewModel.userInfo.streak.longestStreak)}d",
+ label = "Top Streak"
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+ }
+ HeatMapChart(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+
+ InventoryBox(navController)
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun Menu(isAnonymous: Boolean,onLogout: () -> Unit, ) {
+ var expanded by remember { mutableStateOf(false) }
+ var isLogoutInfoVisible by remember { mutableStateOf(false) }
+
+ IconButton(onClick = { expanded = true }) {
+ Icon(
+ imageVector = Icons.Default.MoreVert, // This is the 3-dot icon
+ contentDescription = "More Options"
+ )
+ }
+
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false }
+ ) {
+ DropdownMenuItem(
+ text = { Text("Log Out") },
+ onClick = {
+ isLogoutInfoVisible = true
+ expanded = false
+ // handle click
+ }
+ )
+ }
+
+ if (isLogoutInfoVisible) {
+ BasicAlertDialog(
+ {
+ isLogoutInfoVisible = false
+ }
+
+ ) {
+ Surface {
+ Column(
+ modifier = Modifier
+ .padding(20.dp)
+ .width(IntrinsicSize.Min),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "Are you sure you want to log out?",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ if (isAnonymous) {
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Text(
+ text =
+ "You will lose all your quests, progress, stats and everything if you log out.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Spacer(modifier = Modifier.height(20.dp))
+
+ Row(
+ horizontalArrangement = Arrangement.End,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ TextButton(onClick = {
+ isLogoutInfoVisible = false
+ }) {
+ Text("Cancel")
+ }
+ TextButton(onClick = {
+ onLogout()
+ }) {
+ Text("Log Out", color = Color.Red)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+
+@Composable
+private fun StatItem(
+ value: String,
+ label: String
+) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = value,
+ style = MaterialTheme.typography.headlineLarge
+ )
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.outline,
+
+ )
+ }
+}
+
+
+@Composable
+fun ActiveBoostsItem(
+ item: InventoryItem,
+ remaining: String,
+) {
+ Card(
+ shape = RoundedCornerShape(16.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Item preview/icon
+ Box(
+ modifier = Modifier
+ .size(60.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(Color(0xFF2A2A2A)),
+ contentAlignment = Alignment.Center
+ ) {
+ Image(
+ painter = painterResource(item.icon),
+ contentDescription = item.simpleName
+ )
+ }
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ // Item details
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(
+ text = item.simpleName,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ )
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.baseline_timer_24),
+ contentDescription = "Remaining Time",
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = remaining,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold,
+ fontSize = 16.sp
+ )
+ }
+ }
+
+
+ }
+ }
+}
+
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/account/login/AuthSteps.kt b/app/src/main/java/neth/iecal/questphone/app/screens/account/login/AuthSteps.kt
new file mode 100644
index 00000000..8e4cdf3c
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/account/login/AuthSteps.kt
@@ -0,0 +1,5 @@
+package neth.iecal.questphone.app.screens.account.login
+
+enum class AuthStep {
+ LOGIN, SIGNUP, FORGOT_PASSWORD, COMPLETE
+}
\ No newline at end of file
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/account/LoginScreen.kt b/app/src/main/java/neth/iecal/questphone/app/screens/account/login/LoginScreen.kt
similarity index 77%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/account/LoginScreen.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/account/login/LoginScreen.kt
index 743071e9..0e6d72ca 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/account/LoginScreen.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/account/login/LoginScreen.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.account
+package neth.iecal.questphone.app.screens.account.login
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
@@ -27,11 +27,10 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -45,35 +44,27 @@ 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 io.github.jan.supabase.auth.auth
-import io.github.jan.supabase.auth.exception.AuthRestException
-import io.github.jan.supabase.auth.providers.builtin.Email
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
+import androidx.lifecycle.viewmodel.compose.viewModel
import neth.iecal.questphone.R
-import neth.iecal.questphone.utils.Supabase
@Composable
-fun LoginScreen(loginStep : MutableState, onLoginSucess: ()->Unit) {
- var email by remember { mutableStateOf("") }
- var password by remember { mutableStateOf("") }
+fun LoginScreen(viewModel: LoginViewModel = viewModel(), onLoginSucess: ()->Unit) {
+
var isPasswordVisible by remember { mutableStateOf(false) }
- var isLoading by remember { mutableStateOf(false) }
- var errorMessage by remember { mutableStateOf(null) }
val focusManager = LocalFocusManager.current
val scrollState = rememberScrollState()
- val coroutineScope = rememberCoroutineScope()
-
- // Email validation
- val isEmailValid = email.contains("@") && email.contains(".")
-
-
+ val email by viewModel.email.collectAsState()
+ val password by viewModel.password.collectAsState()
+ val isEmailValid = viewModel.isEmailValid()
+ val errorMessage by viewModel.errorMessage.collectAsState()
+ val isLoading by viewModel.isLoading.collectAsState()
+ val authStep = viewModel.authStep
BackHandler {
- loginStep.value = LoginStep.SIGNUP
+ authStep.value = AuthStep.SIGNUP
}
Box(
@@ -126,7 +117,7 @@ fun LoginScreen(loginStep : MutableState, onLoginSucess: ()->Unit) {
// Email field
OutlinedTextField(
value = email,
- onValueChange = { email = it; errorMessage = null },
+ onValueChange = { viewModel.onEmailChanged(it) },
label = { Text("Email") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
@@ -151,7 +142,7 @@ fun LoginScreen(loginStep : MutableState, onLoginSucess: ()->Unit) {
// Password field
OutlinedTextField(
value = password,
- onValueChange = { password = it; errorMessage = null },
+ onValueChange = { viewModel.onPasswordChanged(it) },
label = { Text("Password") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
@@ -190,7 +181,7 @@ fun LoginScreen(loginStep : MutableState, onLoginSucess: ()->Unit) {
// Forgot password
TextButton(
onClick = {
- loginStep.value = LoginStep.FORGOT_PASSWORD
+ authStep.value = AuthStep.FORGOT_PASSWORD
},
modifier = Modifier.align(Alignment.End)
) {
@@ -199,34 +190,11 @@ fun LoginScreen(loginStep : MutableState, onLoginSucess: ()->Unit) {
Spacer(modifier = Modifier.height(24.dp))
- // Login button
Button(
onClick = {
-
- if (email.isBlank() || password.isBlank()) {
- errorMessage = "Please fill in all fields"
- } else if (!isEmailValid) {
- errorMessage = "Please enter a valid email"
- } else {
-
- isLoading = true
- errorMessage = null
-
-
- coroutineScope.launch(Dispatchers.IO) {
- try {
- Supabase.supabase.auth.signInWith(Email) {
- this.email = email
- this.password = password
- }
- } catch (e: AuthRestException) {
- errorMessage = e.errorDescription
- isLoading = false
- }
- onLoginSucess()
- }
-
- }
+ viewModel.signIn(
+ onSuccess = onLoginSucess,
+ )
},
modifier = Modifier
.fillMaxWidth(),
@@ -245,7 +213,6 @@ fun LoginScreen(loginStep : MutableState, onLoginSucess: ()->Unit) {
Spacer(modifier = Modifier.height(32.dp))
- // Sign up option
Row(
modifier = Modifier.fillMaxWidth(),
@@ -259,7 +226,7 @@ fun LoginScreen(loginStep : MutableState, onLoginSucess: ()->Unit) {
)
TextButton(onClick = {
- loginStep.value = LoginStep.SIGNUP
+ authStep.value = AuthStep.SIGNUP
}) {
Text("Sign up")
}
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/account/login/LoginViewModel.kt b/app/src/main/java/neth/iecal/questphone/app/screens/account/login/LoginViewModel.kt
new file mode 100644
index 00000000..e660387c
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/account/login/LoginViewModel.kt
@@ -0,0 +1,183 @@
+package neth.iecal.questphone.app.screens.account.login
+
+import androidx.compose.runtime.mutableStateOf
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import io.github.jan.supabase.auth.OtpType
+import io.github.jan.supabase.auth.auth
+import io.github.jan.supabase.auth.exception.AuthRestException
+import io.github.jan.supabase.auth.providers.builtin.Email
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+import neth.iecal.questphone.app.screens.account.ForgotPasswordStep
+import nethical.questphone.backend.Supabase
+import nethical.questphone.backend.repositories.UserRepository
+import javax.inject.Inject
+
+@HiltViewModel
+class LoginViewModel @Inject constructor(
+ private val userRepository: UserRepository
+) : ViewModel() {
+
+ val email = MutableStateFlow("")
+ val password = MutableStateFlow("")
+ val confirmPassword = MutableStateFlow("")
+
+ val isPasswordVisible = MutableStateFlow(false)
+ val isConfirmPasswordVisible = MutableStateFlow(false)
+
+ val isLoading = MutableStateFlow(false)
+ val errorMessage = MutableStateFlow(null)
+
+ val signUpStep = MutableStateFlow(SignUpStep.FORM)
+ val forgetPasswordStep = MutableStateFlow(ForgotPasswordStep.FORM)
+ val authStep = mutableStateOf(AuthStep.SIGNUP)
+
+ fun onEmailChanged(value: String) {
+ email.value = value
+ errorMessage.value = null
+ }
+
+ fun onPasswordChanged(value: String) {
+ password.value = value
+ errorMessage.value = null
+ }
+
+ fun onConfirmPasswordChanged(value: String) {
+ confirmPassword.value = value
+ errorMessage.value = null
+ }
+
+ fun togglePasswordVisibility() {
+ isPasswordVisible.value = !isPasswordVisible.value
+ }
+
+ fun toggleConfirmPasswordVisibility() {
+ isConfirmPasswordVisible.value = !isConfirmPasswordVisible.value
+ }
+
+ fun signIn(onSuccess: () -> Unit) {
+ val e = email.value
+ val p = password.value
+
+ if (e.isBlank() || p.isBlank()) {
+ errorMessage.value = "Please fill in all fields"
+ return
+ }
+
+ if (!e.contains("@") || !e.contains(".")) {
+ errorMessage.value = "Please enter a valid email"
+ return
+ }
+
+ isLoading.value = true
+ errorMessage.value = null
+
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ Supabase.supabase.auth.signInWith(Email) {
+ this.email = e
+ this.password = p
+ }
+ onSuccess()
+ } catch (e: AuthRestException) {
+ errorMessage.value = e.errorDescription
+ } finally {
+ isLoading.value = false
+ }
+ }
+ }
+
+ fun signUp(onVerificationSent: () -> Unit = {}) {
+ val e = email.value
+ val p = password.value
+ val c = confirmPassword.value
+
+ when {
+ e.isBlank() || p.isBlank() || c.isBlank() -> {
+ errorMessage.value = "Please fill in all fields"
+ }
+ !e.contains("@") || !e.contains(".") -> {
+ errorMessage.value = "Please enter a valid email"
+ }
+ p.length < 8 -> {
+ errorMessage.value = "Password must be at least 8 characters"
+ }
+ p != c -> {
+ errorMessage.value = "Passwords don't match"
+ }
+ else -> {
+ isLoading.value = true
+ errorMessage.value = null
+
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ Supabase.supabase.auth.signUpWith(Email) {
+ this.email = e
+ this.password = p
+ }
+ signUpStep.value = SignUpStep.VERIFICATION
+ onVerificationSent()
+ } catch (e: AuthRestException) {
+ errorMessage.value = e.message ?: "Sign-up failed"
+ } finally {
+ isLoading.value = false
+ }
+ }
+ }
+ }
+ }
+
+ fun resendVerificationEmail() {
+ val e = email.value
+ viewModelScope.launch {
+ try {
+ isLoading.value = true
+ Supabase.supabase.auth.resendEmail(
+ email = e,
+ type = OtpType.Email.SIGNUP
+ )
+ } catch (e: Exception) {
+ errorMessage.value = "Failed to resend email: ${e.message}"
+ } finally {
+ isLoading.value = false
+ }
+ }
+ }
+
+ fun forgetPassword(){
+
+ if (email.value.isBlank()) {
+ errorMessage.value = "Please enter your email"
+ } else if (!isEmailValid()) {
+ errorMessage.value = "Please enter a valid email"
+ } else {
+ errorMessage.value = null
+ isLoading.value = true
+
+ viewModelScope.launch {
+ Supabase.supabase.auth.resetPasswordForEmail(email.value)
+ }
+
+ forgetPasswordStep.value = ForgotPasswordStep.VERIFICATION
+ isLoading.value = false
+ }
+ }
+
+ fun resetSignUpForm() {
+ signUpStep.value = SignUpStep.FORM
+ }
+
+ fun isEmailValid(): Boolean {
+ return email.value.contains("@") && email.value.contains(".")
+ }
+
+ fun signInAnonymously() {
+ authStep.value = AuthStep.COMPLETE
+ userRepository.userInfo.isAnonymous = true
+ userRepository.saveUserInfo()
+ }
+
+}
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/account/SignUpScreen.kt b/app/src/main/java/neth/iecal/questphone/app/screens/account/login/SignUpScreen.kt
similarity index 77%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/account/SignUpScreen.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/account/login/SignUpScreen.kt
index f61fb7fc..ad8ed9f2 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/account/SignUpScreen.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/account/login/SignUpScreen.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.account
+package neth.iecal.questphone.app.screens.account.login
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
@@ -29,7 +29,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -47,16 +47,9 @@ 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 io.github.jan.supabase.auth.OtpType
-import io.github.jan.supabase.auth.auth
-import io.github.jan.supabase.auth.exception.AuthRestException
-import io.github.jan.supabase.auth.providers.builtin.Email
import kotlinx.coroutines.launch
import neth.iecal.questphone.BuildConfig
import neth.iecal.questphone.R
-import neth.iecal.questphone.data.game.User
-import neth.iecal.questphone.data.game.saveUserInfo
-import neth.iecal.questphone.utils.Supabase
enum class SignUpStep {
FORM,
@@ -65,23 +58,27 @@ enum class SignUpStep {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun SignUpScreen(loginStep: MutableState) {
-
- var email by remember { mutableStateOf("") }
- var password by remember { mutableStateOf("") }
- var confirmPassword by remember { mutableStateOf("") }
+fun SignUpScreen(viewModel: LoginViewModel, onAnonymousSignInSuccess: () -> Unit) {
var isPasswordVisible by remember { mutableStateOf(false) }
- var isConfirmPasswordVisible by remember { mutableStateOf(false) }
- var isLoading by remember { mutableStateOf(false) }
- var errorMessage by remember { mutableStateOf(null) }
- var signUpStep by remember { mutableStateOf(SignUpStep.FORM) }
val focusManager = LocalFocusManager.current
val scrollState = rememberScrollState()
+
+ val email by viewModel.email.collectAsState()
+ val password by viewModel.password.collectAsState()
+ val isEmailValid = viewModel.isEmailValid()
+ val errorMessage by viewModel.errorMessage.collectAsState()
+ val isLoading by viewModel.isLoading.collectAsState()
+ val authStep = viewModel.authStep
+
+ val confirmPassword by viewModel.confirmPassword.collectAsState()
+
+ val isConfirmPasswordVisible by viewModel.isConfirmPasswordVisible.collectAsState()
+ val signUpStep by viewModel.signUpStep.collectAsState()
+
val scope = rememberCoroutineScope()
// Email and password validation
- val isEmailValid = email.contains("@") && email.contains(".")
val isPasswordValid = password.length >= 8
val doPasswordsMatch = password == confirmPassword
@@ -142,7 +139,7 @@ fun SignUpScreen(loginStep: MutableState) {
// Email field
OutlinedTextField(
value = email,
- onValueChange = { email = it; errorMessage = null },
+ onValueChange = { viewModel.onEmailChanged(it) },
label = { Text("Email") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
@@ -164,7 +161,7 @@ fun SignUpScreen(loginStep: MutableState) {
// Password field
OutlinedTextField(
value = password,
- onValueChange = { password = it; errorMessage = null },
+ onValueChange = { viewModel.onPasswordChanged(it) },
label = { Text("Password") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
@@ -195,7 +192,7 @@ fun SignUpScreen(loginStep: MutableState) {
// Confirm Password field
OutlinedTextField(
value = confirmPassword,
- onValueChange = { confirmPassword = it; errorMessage = null },
+ onValueChange = { viewModel.onConfirmPasswordChanged(it) },
label = { Text("Confirm Password") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
@@ -211,7 +208,7 @@ fun SignUpScreen(loginStep: MutableState) {
),
trailingIcon = {
IconButton(onClick = {
- isConfirmPasswordVisible = !isConfirmPasswordVisible
+ viewModel.toggleConfirmPasswordVisibility()
}) {
Icon(
painter = painterResource(
@@ -230,42 +227,7 @@ fun SignUpScreen(loginStep: MutableState) {
// Sign up button
Button(
onClick = {
- when {
- email.isBlank() || password.isBlank() || confirmPassword.isBlank() -> {
- errorMessage = "Please fill in all fields"
- }
-
- !isEmailValid -> {
- errorMessage = "Please enter a valid email"
- }
-
- !isPasswordValid -> {
- errorMessage = "Password must be at least 8 characters"
- }
-
- !doPasswordsMatch -> {
- errorMessage = "Passwords don't match"
- }
-
- else -> {
- scope.launch {
- isLoading = true
- errorMessage = null
- try {
- Supabase.supabase.auth.signUpWith(Email) {
- this.email = email
- this.password = password
- }
- signUpStep = SignUpStep.VERIFICATION
- } catch (e: AuthRestException) {
- errorMessage = e.message ?: "Sign-up failed"
- } finally {
- isLoading = false
- }
- }
- }
- }
-
+ viewModel.signUp()
},
modifier = Modifier
.fillMaxWidth(),
@@ -305,17 +267,7 @@ fun SignUpScreen(loginStep: MutableState) {
TextButton(
onClick = {
scope.launch {
- isLoading = true
- try {
- Supabase.supabase.auth.resendEmail(
- email = email,
- type = OtpType.Email.SIGNUP
- )
- } catch (e: Exception) {
- errorMessage = "Failed to resend email: ${e.message}"
- } finally {
- isLoading = false
- }
+ viewModel.resendVerificationEmail()
}
},
modifier = Modifier.align(Alignment.End),
@@ -328,7 +280,7 @@ fun SignUpScreen(loginStep: MutableState) {
// Back button
TextButton(
- onClick = { signUpStep = SignUpStep.FORM },
+ onClick = { viewModel.signUpStep.value = SignUpStep.FORM },
modifier = Modifier.fillMaxWidth()
) {
Text("Back to sign up")
@@ -351,7 +303,7 @@ fun SignUpScreen(loginStep: MutableState) {
color = MaterialTheme.colorScheme.onSurfaceVariant
)
TextButton(onClick = {
- loginStep.value = LoginStep.LOGIN
+ authStep.value = AuthStep.LOGIN
}) {
Text("Login")
@@ -373,9 +325,8 @@ fun SignUpScreen(loginStep: MutableState) {
confirmButton = {
TextButton(onClick = {
isContinueWithoutLoginDialog.value = false
- User.userInfo.isAnonymous = true
- User.saveUserInfo()
- loginStep.value = LoginStep.COMPLETE
+ viewModel.signInAnonymously()
+ onAnonymousSignInSuccess()
}) {
Text("Continue Anyway")
}
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/components/NeuralMeshAsymmetrical.kt b/app/src/main/java/neth/iecal/questphone/app/screens/components/NeuralMeshAsymmetrical.kt
similarity index 99%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/components/NeuralMeshAsymmetrical.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/components/NeuralMeshAsymmetrical.kt
index a9f5868c..0899a9fc 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/components/NeuralMeshAsymmetrical.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/components/NeuralMeshAsymmetrical.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.components
+package neth.iecal.questphone.app.screens.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/components/NeuralMeshSymmetrical.kt b/app/src/main/java/neth/iecal/questphone/app/screens/components/NeuralMeshSymmetrical.kt
similarity index 99%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/components/NeuralMeshSymmetrical.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/components/NeuralMeshSymmetrical.kt
index 37bf55e3..d179a711 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/components/NeuralMeshSymmetrical.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/components/NeuralMeshSymmetrical.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.components
+package neth.iecal.questphone.app.screens.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/components/TopBarActions.kt b/app/src/main/java/neth/iecal/questphone/app/screens/components/TopBarActions.kt
similarity index 89%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/components/TopBarActions.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/components/TopBarActions.kt
index 3a2dbba4..41ff37da 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/components/TopBarActions.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/components/TopBarActions.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.components
+package neth.iecal.questphone.app.screens.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -19,11 +19,12 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import neth.iecal.questphone.R
-import neth.iecal.questphone.data.game.User
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun TopBarActions(isCoinsVisible: Boolean = false, isStreakVisible: Boolean = false ) {
+fun TopBarActions(coins: Int,streak: Int,isCoinsVisible: Boolean = false, isStreakVisible: Boolean = false ) {
+
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
) {
@@ -44,7 +45,7 @@ fun TopBarActions(isCoinsVisible: Boolean = false, isStreakVisible: Boolean = fa
)
Spacer(modifier = Modifier.width(4.dp))
Text(
- text = User.userInfo.coins.toString(),
+ text = coins.toString(),
color = Color.White,
fontWeight = FontWeight.Bold
)
@@ -69,7 +70,7 @@ fun TopBarActions(isCoinsVisible: Boolean = false, isStreakVisible: Boolean = fa
)
Spacer(Modifier.size(4.dp))
Text(
- text = User.userInfo.streak.currentStreak.toString(),
+ text = streak.toString(),
color = Color.White,
fontWeight = FontWeight.Bold
)
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/game/InventoryBox.kt b/app/src/main/java/neth/iecal/questphone/app/screens/game/InventoryBox.kt
new file mode 100644
index 00000000..92931b60
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/game/InventoryBox.kt
@@ -0,0 +1,295 @@
+package neth.iecal.questphone.app.screens.game
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.navigation.NavController
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import neth.iecal.questphone.R
+import neth.iecal.questphone.app.screens.account.ActiveBoostsItem
+import neth.iecal.questphone.core.utils.managers.executeItem
+import neth.iecal.questphone.data.InventoryExecParams
+import nethical.questphone.backend.repositories.QuestRepository
+import nethical.questphone.backend.repositories.StatsRepository
+import nethical.questphone.backend.repositories.UserRepository
+import nethical.questphone.core.core.utils.formatRemainingTime
+import nethical.questphone.data.game.Category
+import nethical.questphone.data.game.InventoryItem
+import javax.inject.Inject
+
+@HiltViewModel
+class InventoryBoxViewModel @Inject constructor(
+ private val userRepository: UserRepository,
+ private val questRepository: QuestRepository,
+ private val statsRepository: StatsRepository
+): ViewModel(){
+ val userInfo = userRepository.userInfo
+ val activeBoosts = userRepository.activeBoostsState
+
+ private var _selectedInventoryItem = MutableStateFlow(null)
+ val selectedInventoryItem: StateFlow = _selectedInventoryItem.asStateFlow()
+
+ fun isBoosterActive(item: InventoryItem): Boolean{
+ return userRepository.isBoosterActive(item)
+ }
+
+ fun useSelectedItem(navController: NavController){
+ executeItem( selectedInventoryItem.value!!, InventoryExecParams(
+ navController = navController,
+ userRepository = userRepository,
+ questRepository = questRepository,
+ statsRepository = statsRepository
+ ))
+
+ userRepository.deductFromInventory(selectedInventoryItem.value!!)
+ _selectedInventoryItem.value = null
+ }
+ fun selectedItem(item: InventoryItem?){
+ _selectedInventoryItem.value = item
+ }
+}
+
+@Composable
+fun InventoryBox(navController: NavController,viewModel: InventoryBoxViewModel = hiltViewModel()) {
+ val selectedInventoryItem by viewModel.selectedInventoryItem.collectAsState()
+ val activeBoosts by viewModel.activeBoosts.collectAsState()
+
+
+ if (selectedInventoryItem != null) {
+ InventoryItemInfoDialog(
+ selectedInventoryItem!!,
+ viewModel.isBoosterActive(selectedInventoryItem!!),
+ onUseRequest = {
+ viewModel.useSelectedItem(navController)
+ },
+ onDismissRequest = {
+ viewModel.selectedItem(null)
+ })
+ }
+
+ Text(
+ text = "Active Boosts",
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+ ActiveBoostItems(activeBoosts)
+ Spacer(Modifier.padding(bottom = 16.dp))
+ Text(
+ text = "Inventory",
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+ Column(
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ viewModel.userInfo.inventory.forEach { it ->
+ InventoryItemCard(it.key, it.value) { item ->
+ viewModel.selectedItem(item)
+ }
+
+ }
+ }
+
+}
+
+@Composable
+fun ActiveBoostItems(activeBoosts: HashMap,modifier: Modifier = Modifier){
+
+ if(activeBoosts.isNotEmpty()) {
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = modifier
+ ) {
+ activeBoosts.forEach { it ->
+ ActiveBoostsItem(it.key, formatRemainingTime(it.value))
+ }
+ }
+ }
+
+}
+
+@Composable
+private fun InventoryItemCard(
+ item: InventoryItem,
+ quantity: Int,
+ onClick: (InventoryItem) -> Unit,
+) {
+ Card(
+ shape = RoundedCornerShape(16.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onClick(item) }
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Item preview/icon
+ Box(
+ modifier = Modifier
+ .size(60.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(Color(0xFF2A2A2A)),
+ contentAlignment = Alignment.Center
+ ) {
+ Image(
+ painter = painterResource(item.icon),
+ contentDescription = item.simpleName
+ )
+ }
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(
+ text = item.simpleName,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ )
+
+ Text(
+ text = item.description,
+ fontSize = 14.sp,
+ color = MaterialTheme.colorScheme.outline,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ // Price or actions
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.baseline_inventory_24),
+ contentDescription = "Coins",
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = "$quantity",
+ fontWeight = FontWeight.Bold,
+ fontSize = 16.sp
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun InventoryItemInfoDialog(
+ reward: InventoryItem,
+ isBoosterActive:Boolean,
+ onUseRequest: () -> Unit = {},
+ onDismissRequest: () -> Unit = {}
+) {
+ Dialog(
+ onDismissRequest = onDismissRequest,
+ properties = DialogProperties(dismissOnClickOutside = true)
+ ) {
+
+ Surface(
+ shape = MaterialTheme.shapes.medium,
+ tonalElevation = 8.dp,
+ modifier = Modifier
+ .padding(24.dp)
+ .wrapContentSize()
+ ) {
+
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+
+
+ Image(
+ painter = painterResource(reward.icon),
+ contentDescription = reward.simpleName,
+ modifier = Modifier.size(60.dp)
+ )
+
+ Text(
+ text = reward.simpleName,
+ style = MaterialTheme.typography.headlineSmall
+ )
+
+ Text(
+ text = reward.description,
+ style = MaterialTheme.typography.bodyMedium
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End
+ ) {
+ TextButton(onClick = onDismissRequest) {
+ Text("Close")
+
+ }
+
+ if (reward.isDirectlyUsableFromInventory) {
+ if (reward.category == Category.BOOSTERS && !isBoosterActive) {
+ Button(
+ onClick = {
+ onUseRequest()
+ onDismissRequest()
+ }) {
+ Text("Use")
+ }
+ }
+ }
+
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/game/RewardDialog.kt b/app/src/main/java/neth/iecal/questphone/app/screens/game/RewardDialog.kt
new file mode 100644
index 00000000..3aa71dda
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/game/RewardDialog.kt
@@ -0,0 +1,182 @@
+package neth.iecal.questphone.app.screens.game
+
+import android.annotation.SuppressLint
+import android.util.Log
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import neth.iecal.questphone.app.screens.game.RewardDialogInfo.coinsEarned
+import neth.iecal.questphone.app.screens.game.RewardDialogInfo.currentDialog
+import neth.iecal.questphone.app.screens.game.RewardDialogInfo.streakFreezerReturn
+import neth.iecal.questphone.app.screens.game.dialogs.LevelUpDialog
+import neth.iecal.questphone.app.screens.game.dialogs.QuestCompletionDialog
+import neth.iecal.questphone.app.screens.game.dialogs.StreakFailedDialog
+import neth.iecal.questphone.app.screens.game.dialogs.StreakFreezersUsedDialog
+import neth.iecal.questphone.app.screens.game.dialogs.StreakUpDialog
+import nethical.questphone.backend.CommonQuestInfo
+import nethical.questphone.backend.repositories.UserRepository
+import nethical.questphone.data.game.InventoryItem
+import nethical.questphone.data.game.StreakFreezerReturn
+import nethical.questphone.data.game.xpFromStreak
+import nethical.questphone.data.game.xpToRewardForQuest
+
+enum class DialogState { QUEST_COMPLETED, LEVEL_UP, STREAK_UP,STREAK_FREEZER_USED, STREAK_FAILED, NONE }
+
+/**
+ * This values in here must be set to true in order to show the dialog [RewardDialogMaker]
+ * from the [neth.iecal.questphone.MainActivity]
+ */
+object RewardDialogInfo{
+ var currentDialog by mutableStateOf(DialogState.NONE)
+ var coinsEarned by mutableIntStateOf(0)
+ var streakFreezerReturn : StreakFreezerReturn? = null
+}
+
+/**
+ * Handles both showing the rewards dialog as well as rewarding user with xp, coins and bs.
+ */
+@SuppressLint("MutableCollectionMutableState")
+@Composable
+fun RewardDialogMaker(userRepository: UserRepository) {
+ // Track current dialog state
+ val currentDialog = currentDialog
+
+ // store the last level so later when user earns xp, we compare it to find if they levelled up
+ var oldLevel by remember { mutableIntStateOf( userRepository.userInfo.level) }
+ var levelledUpUserRewards by remember { mutableStateOf( hashMapOf()) }
+ val xpEarned = remember { mutableIntStateOf(0) }
+
+ fun didUserLevelUp(): Boolean {
+ Log.d("LevelUp","old level $oldLevel, new Level ${userRepository.userInfo.level}")
+ return oldLevel != userRepository.userInfo.level
+ }
+ LaunchedEffect(currentDialog) {
+ when (currentDialog) {
+ DialogState.QUEST_COMPLETED -> {
+ var xp = xpToRewardForQuest(userRepository.userInfo.level)
+ if(userRepository.isBoosterActive(InventoryItem.XP_BOOSTER)){
+ xp+=xp
+ }
+ userRepository.addXp(xp)
+ xpEarned.intValue = xp
+ }
+
+ DialogState.LEVEL_UP -> {
+ Log.d("Level Up","User levelled up")
+ oldLevel = userRepository.userInfo.level
+ levelledUpUserRewards = userRepository.calculateLevelUpInvRewards()
+ coinsEarned = userRepository.calculateLevelUpCoinsRewards()
+ userRepository.addItemsToInventory(levelledUpUserRewards)
+ }
+
+ DialogState.STREAK_FREEZER_USED -> {
+ xpEarned.intValue = (streakFreezerReturn!!.lastStreak until userRepository.currentStreakState.value).sumOf { day ->
+ val xp = xpFromStreak(day)
+ userRepository.addXp(xp)
+ xp
+ }
+ }
+ DialogState.STREAK_UP -> {
+ xpEarned.intValue = xpFromStreak(userRepository.currentStreakState.value)
+ userRepository.addXp(xpEarned.intValue)
+
+ }
+ DialogState.STREAK_FAILED -> {}
+ DialogState.NONE -> {
+ streakFreezerReturn = null
+ coinsEarned = 0
+ xpEarned.intValue = 0
+ levelledUpUserRewards.clear()
+ oldLevel = userRepository.userInfo.level
+ }
+
+ }
+
+ userRepository.addCoins(coinsEarned)
+
+ }
+
+
+ // Show the appropriate dialog based on the current state
+ when (currentDialog) {
+ DialogState.QUEST_COMPLETED -> {
+ QuestCompletionDialog(
+ coinReward = coinsEarned,
+ xpReward = xpEarned.intValue,
+ onDismiss = {
+ // If user leveled up, show level up dialog next, otherwise end
+ RewardDialogInfo.currentDialog = if (didUserLevelUp()) {
+ DialogState.LEVEL_UP
+ } else {
+ DialogState.NONE
+ }
+ }
+ )
+ }
+
+ DialogState.LEVEL_UP -> {
+ LevelUpDialog(
+ coinReward = coinsEarned,
+ lvUpRew = levelledUpUserRewards,
+ newLevel = userRepository.userInfo.level,
+ onDismiss = {
+ RewardDialogInfo.currentDialog = DialogState.NONE
+ }
+ )
+ }
+
+ DialogState.STREAK_UP -> {
+ StreakUpDialog(
+ streakData = userRepository.userInfo.streak,
+ xpEarned = xpEarned.intValue
+ ) {
+ RewardDialogInfo.currentDialog =
+ if (didUserLevelUp()) DialogState.LEVEL_UP else DialogState.NONE
+ }
+ }
+ DialogState.STREAK_FREEZER_USED -> {
+ StreakFreezersUsedDialog(streakFreezerReturn!!.streakFreezersUsed!!,userRepository.userInfo.streak,xpEarned.intValue) {
+ RewardDialogInfo.currentDialog = DialogState.NONE
+ }
+ }
+
+ DialogState.STREAK_FAILED -> {
+ StreakFailedDialog(
+ streakFreezerReturn = streakFreezerReturn!!
+ ) {
+ RewardDialogInfo.currentDialog = DialogState.NONE
+ }
+ }
+
+ DialogState.NONE -> {}
+ }
+}
+
+/**
+ * Calculates what to reward user as well as trigger the reward dialog to be shown to the user when user
+ * completes a quest
+ */
+fun rewardUserForQuestCompl(commonQuestInfo: CommonQuestInfo){
+ coinsEarned = commonQuestInfo.reward
+ currentDialog = DialogState.QUEST_COMPLETED
+}
+
+
+fun handleStreakFreezers(streakReturn: StreakFreezerReturn?){
+ if(streakReturn!=null){
+ streakFreezerReturn = streakReturn
+ currentDialog = if (streakFreezerReturn!!.isOngoing) {
+ DialogState.STREAK_FREEZER_USED
+ }else{
+ DialogState.STREAK_FAILED
+ }
+ }
+}
+
+fun showStreakUpDialog(){
+ currentDialog = DialogState.STREAK_UP
+}
\ No newline at end of file
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/game/StoreScreen.kt b/app/src/main/java/neth/iecal/questphone/app/screens/game/StoreScreen.kt
similarity index 82%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/game/StoreScreen.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/game/StoreScreen.kt
index a7df5e8e..22e9a454 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/game/StoreScreen.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/game/StoreScreen.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.game
+package neth.iecal.questphone.app.screens.game
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
@@ -35,14 +35,17 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -59,48 +62,55 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
import androidx.navigation.NavController
-import kotlinx.coroutines.delay
+import dagger.hilt.android.lifecycle.HiltViewModel
import neth.iecal.questphone.R
-import neth.iecal.questphone.data.game.Category
-import neth.iecal.questphone.data.game.InventoryItem
-import neth.iecal.questphone.data.game.User
-import neth.iecal.questphone.data.game.addItemsToInventory
-import neth.iecal.questphone.data.game.getInventoryItemCount
-import neth.iecal.questphone.data.game.useCoins
-
-
-// View model for the store
-class StoreViewModel {
- var coins by mutableIntStateOf(User.userInfo.coins)
- // Currently selected category
+import neth.iecal.questphone.app.navigation.RootRoute
+import neth.iecal.questphone.app.screens.components.TopBarActions
+import nethical.questphone.backend.repositories.UserRepository
+import nethical.questphone.data.game.Category
+import nethical.questphone.data.game.InventoryItem
+import javax.inject.Inject
+
+@HiltViewModel
+class StoreViewModel @Inject constructor(
+ private val userRepository: UserRepository
+): ViewModel() {
+ var coins = userRepository.coinsState
+
var selectedCategory by mutableStateOf(Category.BOOSTERS)
private set
- // Store items - in a real app, this would come from a repository
private val _items = InventoryItem.entries
- // Public store items getter
val items: List
get() = _items.toList()
- // Get items by category
+ fun hasEnoughCoinsToPurchaseItem(item: InventoryItem): Boolean {
+ val userCoins = userRepository.userInfo.coins
+ return userCoins >= item.price
+ }
+
+ fun getItemInventoryCount(item: InventoryItem): Int{
+ return userRepository.getInventoryItemCount(item)
+ }
+
fun getItemsByCategory(category: Category): List {
return items.filter { it.category == category }
}
- // Select a category
fun selectCategory(category: Category) {
selectedCategory = category
}
- // Purchase an item
- fun purchaseItem(item: InventoryItem): Boolean {
+ fun makeItemPurchase(item: InventoryItem): Boolean {
var itemMap = hashMapOf()
itemMap.put(item,1)
- User.addItemsToInventory(itemMap)
- User.useCoins(item.price)
- coins = User.userInfo.coins
+
+ userRepository.addItemsToInventory(itemMap)
+ userRepository.useCoins(item.price)
return true
}
@@ -110,20 +120,29 @@ class StoreViewModel {
@Composable
fun StoreScreen(
navController: NavController,
- viewModel: StoreViewModel = remember { StoreViewModel() }
+ viewModel: StoreViewModel = hiltViewModel()
) {
val coroutineScope = rememberCoroutineScope()
- var showPurchaseDialog by remember { mutableStateOf(null) }
+ var selectedItem by remember { mutableStateOf(null) }
var showSuccessMessage by remember { mutableStateOf(null) }
-
- // Success snackbar
+ val snackbarHostState = remember { SnackbarHostState() }
+ val coins by viewModel.coins.collectAsState()
+ // auto dismiss message
showSuccessMessage?.let { message ->
LaunchedEffect(message) {
- delay(2000)
- showSuccessMessage = null
+ val result = snackbarHostState
+ .showSnackbar(
+ message = message,
+ actionLabel = "Inventory",
+ )
+ when (result) {
+ SnackbarResult.Dismissed -> {}
+ SnackbarResult.ActionPerformed -> {
+ navController.navigate(RootRoute.UserInfo.route)
+ }
+ }
}
}
-
Scaffold(
topBar = {
TopAppBar(
@@ -138,70 +157,46 @@ fun StoreScreen(
titleContentColor = Color.White
),
actions = {
- // Coins display
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier
- .background(
- color = Color(0xFF2A2A2A),
- shape = RoundedCornerShape(16.dp)
- )
- .padding(horizontal = 12.dp, vertical = 6.dp)
- ) {
- Image(
- painter = painterResource(R.drawable.coin_icon),
- contentDescription = "Coins",
- modifier = Modifier.size(20.dp),
- )
- Spacer(modifier = Modifier.width(4.dp))
- Text(
- text = "${viewModel.coins}",
- color = Color.White,
- fontWeight = FontWeight.Bold
- )
- }
- }
+ TopBarActions(coins,0,true,false)
+ },
)
},
- containerColor = Color.Black
+ containerColor = Color.Black,
+ snackbarHost = {
+ SnackbarHost(hostState = snackbarHostState)
+ },
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
- // Category selector
CategorySelector(
selectedCategory = viewModel.selectedCategory,
onCategorySelected = { viewModel.selectCategory(it) }
)
- // Store items
StoreItemsList(
items = viewModel.getItemsByCategory(viewModel.selectedCategory),
- onItemClick = { showPurchaseDialog = it },
- onEquipClick = { item ->
-// if (viewModel.equipItem(item.id)) {
-// showSuccessMessage = "${item.name} equipped!"
-// }
- }
+ onItemClick = { selectedItem = it },
)
// Purchase dialog
- showPurchaseDialog?.let { item ->
+ selectedItem?.let { item ->
PurchaseDialog(
item = item,
- onDismiss = { showPurchaseDialog = null },
+ hasEnoughCoins = viewModel.hasEnoughCoinsToPurchaseItem(selectedItem!!),
+ userCoins = coins,
+ inventoryCount = viewModel.getItemInventoryCount(selectedItem!!),
+ onDismiss = { selectedItem = null },
onPurchase = {
- if (viewModel.purchaseItem(item)) {
- showSuccessMessage = "Successfully purchased ${item.name}!"
- showPurchaseDialog = null
+ if (viewModel.makeItemPurchase(item)) {
+ showSuccessMessage = "Successfully purchased ${item.simpleName}!"
}
}
)
}
- // Success message
AnimatedVisibility(
visible = showSuccessMessage != null,
enter = fadeIn() + slideInVertically(),
@@ -244,7 +239,7 @@ fun StoreScreen(
}
@Composable
-fun CategorySelector(
+private fun CategorySelector(
selectedCategory: Category,
onCategorySelected: (Category) -> Unit
) {
@@ -268,7 +263,7 @@ fun CategorySelector(
}
@Composable
-fun CategoryItem(
+private fun CategoryItem(
category: Category,
isSelected: Boolean,
onClick: () -> Unit
@@ -299,10 +294,9 @@ fun CategoryItem(
}
@Composable
-fun StoreItemsList(
+private fun StoreItemsList(
items: List,
onItemClick: (InventoryItem) -> Unit,
- onEquipClick: (InventoryItem) -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
@@ -313,17 +307,15 @@ fun StoreItemsList(
StoreItemCard(
item = item,
onClick = { onItemClick(item) },
- onEquipClick = { onEquipClick(item) }
)
}
}
}
@Composable
-fun StoreItemCard(
+private fun StoreItemCard(
item: InventoryItem,
onClick: () -> Unit,
- onEquipClick: () -> Unit
) {
Card(
shape = RoundedCornerShape(16.dp),
@@ -400,14 +392,14 @@ fun StoreItemCard(
}
@Composable
-fun PurchaseDialog(
+private fun PurchaseDialog(
item: InventoryItem,
+ hasEnoughCoins: Boolean,
+ inventoryCount: Int,
+ userCoins: Int,
onDismiss: () -> Unit,
onPurchase: () -> Unit
) {
- val userCoins = User.userInfo.coins
- val hasEnoughCoins = userCoins >= item.price
-
Dialog(onDismissRequest = onDismiss) {
Card(
shape = RoundedCornerShape(16.dp),
@@ -490,7 +482,7 @@ fun PurchaseDialog(
)
Spacer(modifier = Modifier.width(8.dp))
Text(
- text = "${User.getInventoryItemCount(item)}",
+ text = "$inventoryCount",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
@@ -526,7 +518,8 @@ fun PurchaseDialog(
}
Button(
- onClick = onPurchase,
+ onClick = { onPurchase()
+ onDismiss()},
modifier = Modifier.weight(1f),
enabled = hasEnoughCoins,
colors = ButtonDefaults.buttonColors(
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/game/dialogs/LevelUpDialog.kt b/app/src/main/java/neth/iecal/questphone/app/screens/game/dialogs/LevelUpDialog.kt
new file mode 100644
index 00000000..cf7e0fc7
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/game/dialogs/LevelUpDialog.kt
@@ -0,0 +1,128 @@
+package neth.iecal.questphone.app.screens.game.dialogs
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import neth.iecal.questphone.R
+import nethical.questphone.core.core.utils.VibrationHelper
+import nethical.questphone.data.game.InventoryItem
+
+
+@Composable
+fun LevelUpDialog(
+ newLevel: Int,
+ onDismiss: () -> Unit,
+ lvUpRew: HashMap = hashMapOf(),
+ coinReward: Int
+) {
+ Dialog(onDismissRequest = onDismiss) {
+
+ Column(
+ modifier = Modifier.padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ val rotationAnimation = remember { Animatable(0f) }
+
+ LaunchedEffect(key1 = true) {
+ rotationAnimation.animateTo(
+ targetValue = 360f,
+ animationSpec = tween(1000)
+ )
+ }
+
+ Icon(
+ painter = painterResource(R.drawable.star),
+ contentDescription = "Voila!",
+ tint = Color(0xFFFFC107), // Gold color
+ modifier = Modifier
+ .size(50.dp)
+ .rotate(rotationAnimation.value)
+ )
+
+ Spacer(modifier = Modifier.size(8.dp))
+
+ Text(
+ text = "Level Up!",
+ textAlign = TextAlign.Center,
+ fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.titleMedium,
+ )
+
+
+ Text(
+ text = "You advanced to level $newLevel",
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.titleMedium,
+ )
+
+ Spacer(modifier = Modifier.size(8.dp))
+
+ Text(
+ text = "Rewards",
+ textAlign = TextAlign.Center,
+ )
+
+ RewardItem(R.drawable.coin_icon, coinReward,"Coins")
+
+ if (lvUpRew.isNotEmpty()) {
+ lvUpRew.forEach {
+ RewardItem(it.key.icon,it.value,it.key.simpleName)
+ }
+ }
+
+ Spacer(Modifier.size(16.dp))
+ Button(
+ onClick = {
+ VibrationHelper.vibrate(50)
+ onDismiss()
+ },
+ modifier = Modifier.fillMaxWidth(0.7f),
+ ) {
+ Text("Continue")
+ }
+ }
+ }
+}
+
+@Composable
+private fun RewardItem(icon: Int, amount: Int,name:String){
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Image(
+ painter = painterResource(icon),
+ contentDescription = name,
+ modifier = Modifier.size(25.dp)
+ )
+ Spacer(Modifier.size(4.dp))
+
+ Text(
+ text = "x $amount",
+ textAlign = TextAlign.Center,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/game/dialogs/QuestCompletionDialog.kt b/app/src/main/java/neth/iecal/questphone/app/screens/game/dialogs/QuestCompletionDialog.kt
new file mode 100644
index 00000000..f2a336d2
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/game/dialogs/QuestCompletionDialog.kt
@@ -0,0 +1,95 @@
+package neth.iecal.questphone.app.screens.game.dialogs
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import neth.iecal.questphone.R
+import nethical.questphone.core.core.utils.VibrationHelper
+
+
+@Composable
+fun QuestCompletionDialog(coinReward: Int,xpReward:Int, onDismiss: () -> Unit) {
+ Dialog(onDismissRequest = onDismiss) {
+ Column(
+ modifier = Modifier.padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Animation for coin
+ val bounceAnimation = remember {
+ Animatable(0f)
+ }
+
+ LaunchedEffect(key1 = true) {
+ bounceAnimation.animateTo(
+ targetValue = 1f,
+ animationSpec = spring(
+ dampingRatio = 0.3f,
+ stiffness = Spring.StiffnessLow
+ )
+ )
+ }
+
+ // Apply bounce animation to the coin image
+ Image(
+ painter = painterResource(R.drawable.coin_icon),
+ contentDescription = "coin",
+ modifier = Modifier
+ .size(50.dp)
+ .scale(1f + (bounceAnimation.value * 0.2f))
+ .offset(y = (-20 * bounceAnimation.value).dp),
+ contentScale = ContentScale.Fit
+ )
+
+ Spacer(modifier = Modifier.size(16.dp))
+
+ // Animated text appearance
+ AnimatedVisibility(
+ visible = true,
+ enter = fadeIn() + expandVertically()
+ ) {
+ Text(
+ text = "You earned $coinReward ${if (coinReward > 1) "coins" else "coin"} + $xpReward xp!",
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.size(8.dp))
+
+ Button(
+ onClick = {
+ VibrationHelper.vibrate(50)
+ onDismiss()
+ },
+ modifier = Modifier.fillMaxWidth(0.7f),
+ ) {
+ Text("Collect")
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/game/dialogs/StreakFailedDialog.kt b/app/src/main/java/neth/iecal/questphone/app/screens/game/dialogs/StreakFailedDialog.kt
new file mode 100644
index 00000000..44914571
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/game/dialogs/StreakFailedDialog.kt
@@ -0,0 +1,72 @@
+package neth.iecal.questphone.app.screens.game.dialogs
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import nethical.questphone.core.core.utils.VibrationHelper
+import nethical.questphone.data.game.StreakFreezerReturn
+
+
+@Composable
+fun StreakFailedDialog(streakFreezerReturn: StreakFreezerReturn, onDismiss: () -> Unit,) {
+ val streakDaysLost = streakFreezerReturn.streakDaysLost ?: 0
+ Dialog(onDismissRequest = onDismiss) {
+ Surface(Modifier
+ .clip(RoundedCornerShape(11.dp))) {
+ Column(
+ modifier = Modifier.padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+
+ Spacer(modifier = Modifier.size(16.dp))
+
+ Text(
+ text = "You lost your $streakDaysLost day streak!!",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ fontSize = 28.sp,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.size(8.dp))
+
+ Text(
+ text = "Don't worry, you can rise again....",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ fontSize = 16.sp,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+
+ Spacer(modifier = Modifier.size(8.dp))
+
+ Button(
+ onClick = {
+ VibrationHelper.vibrate(50)
+ onDismiss()
+ },
+ modifier = Modifier.fillMaxWidth(0.7f),
+ ) {
+ Text("Continue", fontSize = 16.sp)
+ }
+ }
+ }
+ }
+}
+
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/game/dialogs/StreakFreezersUsedDialog.kt b/app/src/main/java/neth/iecal/questphone/app/screens/game/dialogs/StreakFreezersUsedDialog.kt
new file mode 100644
index 00000000..09a81448
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/game/dialogs/StreakFreezersUsedDialog.kt
@@ -0,0 +1,100 @@
+package neth.iecal.questphone.app.screens.game.dialogs
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import nethical.questphone.core.core.utils.VibrationHelper
+import nethical.questphone.data.game.StreakData
+
+@Composable
+fun StreakFreezersUsedDialog( streakFreezersUsed:Int, streakData: StreakData, xpEarned: Int,onDismiss: () -> Unit,) {
+ Dialog(onDismissRequest = onDismiss) {
+ Surface(Modifier
+ .clip(RoundedCornerShape(11.dp))) {
+ Column(
+ modifier = Modifier.padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ // Animated level up icon
+ val rotationAnimation = remember { Animatable(0f) }
+
+ LaunchedEffect(Unit) {
+ rotationAnimation.animateTo(
+ targetValue = 360f,
+ animationSpec = tween(1000)
+ )
+ }
+
+ Image(
+ painter = painterResource(nethical.questphone.data.R.drawable.streak_freezer),
+ contentDescription = "Streak",
+ modifier = Modifier
+ .size(50.dp)
+ .rotate(rotationAnimation.value)
+ )
+
+ Spacer(modifier = Modifier.size(8.dp))
+
+ Text(
+ text = "$streakFreezersUsed streak freezers were used to save your streak!",
+ style = MaterialTheme.typography.titleMedium,
+ textAlign = TextAlign.Center,
+ fontWeight = FontWeight.Bold,
+
+ )
+
+
+ Text(
+ text = "New Streak: ${streakData.currentStreak} days",
+ textAlign = TextAlign.Center,
+ fontSize = 16.sp,
+ )
+ Text(
+ text = "Rewards",
+ textAlign = TextAlign.Center,
+
+ )
+ Text(
+ text = "XP: $xpEarned",
+ textAlign = TextAlign.Center,
+ )
+
+
+ Button(
+ onClick = {
+ VibrationHelper.vibrate(50)
+ onDismiss()
+ },
+ modifier = Modifier.fillMaxWidth(0.7f),
+ ) {
+ Text("Continue", fontSize = 16.sp)
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/game/StreakDialog.kt b/app/src/main/java/neth/iecal/questphone/app/screens/game/dialogs/StreakUpDialog.kt
similarity index 50%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/game/StreakDialog.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/game/dialogs/StreakUpDialog.kt
index e586786b..acbad6d4 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/game/StreakDialog.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/game/dialogs/StreakUpDialog.kt
@@ -1,7 +1,8 @@
-package neth.iecal.questphone.ui.screens.game
+package neth.iecal.questphone.app.screens.game.dialogs
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -28,24 +29,23 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import neth.iecal.questphone.R
-import neth.iecal.questphone.data.game.User
-import neth.iecal.questphone.ui.screens.quest.RewardDialogInfo
-import neth.iecal.questphone.utils.VibrationHelper
+import nethical.questphone.core.core.utils.VibrationHelper
+import nethical.questphone.data.game.StreakData
@Composable
-fun StreakUpDialog( onDismiss: () -> Unit) {
- val streakFreezersUsed = RewardDialogInfo.streakData?.streakFreezersUsed ?: 0
+fun StreakUpDialog( streakData: StreakData, xpEarned: Int,onDismiss: () -> Unit,) {
Dialog(onDismissRequest = onDismiss) {
Surface(Modifier
.clip(RoundedCornerShape(11.dp))) {
Column(
modifier = Modifier.padding(24.dp),
- horizontalAlignment = Alignment.CenterHorizontally
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Animated level up icon
val rotationAnimation = remember { Animatable(0f) }
- LaunchedEffect(key1 = true) {
+ LaunchedEffect(Unit) {
rotationAnimation.animateTo(
targetValue = 360f,
animationSpec = tween(1000)
@@ -61,91 +61,30 @@ fun StreakUpDialog( onDismiss: () -> Unit) {
.rotate(rotationAnimation.value)
)
- Spacer(modifier = Modifier.size(16.dp))
+ Spacer(modifier = Modifier.size(8.dp))
Text(
- text = if (streakFreezersUsed == 0) "All Quests Completed for today!" else "$streakFreezersUsed streak freezers were used to save your streak!",
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ text = "All Quests Completed for today!",
textAlign = TextAlign.Center,
- fontSize = 28.sp,
+ style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
- Spacer(modifier = Modifier.size(8.dp))
-
Text(
- text = "New Streak: ${User.userInfo.streak.currentStreak} days",
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ text = "New Streak: ${streakData.currentStreak} days",
textAlign = TextAlign.Center,
fontSize = 16.sp,
- modifier = Modifier.padding(bottom = 16.dp)
)
-
- if (streakFreezersUsed == 0) {
- Text(
- text = "Rewards",
- color = MaterialTheme.colorScheme.primary,
- textAlign = TextAlign.Center,
- fontWeight = FontWeight.Bold,
- fontSize = 18.sp,
- modifier = Modifier.padding(bottom = 16.dp)
- )
- Text(
- text = "XP: ${User.lastXpEarned}",
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center,
- fontSize = 16.sp,
- )
- }
-
- Spacer(Modifier.size(16.dp))
-
- Button(
- onClick = {
- VibrationHelper.vibrate(50)
- onDismiss()
- },
- modifier = Modifier.fillMaxWidth(0.7f),
- ) {
- Text("Continue", fontSize = 16.sp)
- }
- }
- }
- }
-}
-@Composable
-fun StreakFailedDialog( onDismiss: () -> Unit,) {
- val streakDaysLost = RewardDialogInfo.streakData?.streakDaysLost ?: 0
- Dialog(onDismissRequest = onDismiss) {
- Surface(Modifier
- .clip(RoundedCornerShape(11.dp))) {
- Column(
- modifier = Modifier.padding(24.dp),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
-
- Spacer(modifier = Modifier.size(16.dp))
-
+ Spacer(modifier = Modifier.size(8.dp))
Text(
- text = "You lost your $streakDaysLost day streak!!",
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ text = "Rewards",
textAlign = TextAlign.Center,
- fontSize = 28.sp,
- fontWeight = FontWeight.Bold
)
-
- Spacer(modifier = Modifier.size(8.dp))
-
Text(
- text = "Don't worry, you can rise again....",
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ text = "XP: $xpEarned",
textAlign = TextAlign.Center,
- fontSize = 16.sp,
- modifier = Modifier.padding(bottom = 16.dp)
)
- Spacer(modifier = Modifier.size(8.dp))
-
Button(
onClick = {
VibrationHelper.vibrate(50)
@@ -158,5 +97,4 @@ fun StreakFailedDialog( onDismiss: () -> Unit,) {
}
}
}
-}
-
+}
\ No newline at end of file
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/launcher/AppList.kt b/app/src/main/java/neth/iecal/questphone/app/screens/launcher/AppList.kt
new file mode 100644
index 00000000..d779fbcd
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/launcher/AppList.kt
@@ -0,0 +1,147 @@
+package neth.iecal.questphone.app.screens.launcher
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.foundation.layout.windowInsetsBottomHeight
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavController
+import neth.iecal.questphone.app.navigation.LauncherDialogRoutes
+import neth.iecal.questphone.app.screens.launcher.dialogs.LauncherDialog
+
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
+@Composable
+fun AppList(navController: NavController, viewModel: AppListViewModel) {
+ val apps by viewModel.filteredApps.collectAsState()
+ val showDialog by viewModel.showCoinDialog.collectAsState()
+ val selectedPackage by viewModel.selectedPackage.collectAsState()
+ val searchQuery by viewModel.searchQuery.collectAsState()
+
+ val focusRequester = remember { FocusRequester() }
+ var textFieldLoaded by remember { mutableStateOf(false) }
+ val minutesPer5Coins by viewModel.minutesPerFiveCoins.collectAsState()
+
+ val coins by viewModel.coins.collectAsState()
+ val remainingFreePasses by viewModel.remainingFreePassesToday.collectAsState()
+
+ Scaffold { innerPadding ->
+ if (showDialog) {
+ LauncherDialog(
+ coins = coins,
+ onDismiss = {viewModel.dismissDialog()},
+ pkgName = selectedPackage,
+ rootNavController = navController,
+ minutesPerFiveCoins = minutesPer5Coins,
+ unlockApp = {
+ viewModel.onConfirmUnlockApp(it)
+ },
+ startDestination = if (coins >= 5) {
+ LauncherDialogRoutes.UnlockAppDialog.route
+ }else
+ {
+ LauncherDialogRoutes.LowCoins.route
+ },
+ remainingFreePasses = remainingFreePasses,
+ onFreePassUsed = { viewModel.useFreePass() }
+ )
+ }
+
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .consumeWindowInsets(innerPadding)
+ .padding(12.dp),
+ contentPadding = innerPadding,
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+
+ item {
+ OutlinedTextField(
+ value = searchQuery,
+ onValueChange = viewModel::onSearchQueryChange,
+ label = { Text("Search Apps") },
+ placeholder = { Text("Type app name...") },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = "Search"
+ )
+ },
+ trailingIcon = {
+ if (searchQuery.isNotEmpty()) {
+ IconButton(onClick = { viewModel.onSearchQueryChange("") }) {
+ Icon(
+ imageVector = Icons.Default.Clear,
+ contentDescription = "Clear search"
+ )
+ }
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester)
+ .onGloballyPositioned {
+ if (!textFieldLoaded) {
+ focusRequester.requestFocus()
+ textFieldLoaded = true // stop cyclic recompositions
+ }
+ },
+ singleLine = true
+ )
+ Spacer(Modifier.size(4.dp))
+ }
+ items(apps) { app ->
+ Text(
+ text = app.name,
+ style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Normal),
+ modifier = Modifier
+ .fillMaxWidth()
+ .combinedClickable(
+ onClick = {
+ viewModel.onAppClick(app.packageName)
+ },
+ onLongClick = { viewModel.onLongAppClick(app.packageName) })
+ )
+ }
+ item {
+ Spacer(
+ Modifier.windowInsetsBottomHeight(
+ WindowInsets.systemBars
+ )
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/launcher/AppListViewModel.kt b/app/src/main/java/neth/iecal/questphone/app/screens/launcher/AppListViewModel.kt
new file mode 100644
index 00000000..694ed68e
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/launcher/AppListViewModel.kt
@@ -0,0 +1,235 @@
+package neth.iecal.questphone.app.screens.launcher
+
+import android.annotation.SuppressLint
+import android.app.Application
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.provider.Settings
+import androidx.core.content.ContextCompat.startForegroundService
+import androidx.core.content.edit
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.datetime.Clock
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.daysUntil
+import neth.iecal.questphone.core.services.AppBlockerService
+import neth.iecal.questphone.core.services.AppBlockerServiceInfo
+import neth.iecal.questphone.core.services.INTENT_ACTION_UNLOCK_APP
+import nethical.questphone.backend.repositories.UserRepository
+import nethical.questphone.core.core.utils.ScreenUsageStatsHelper
+import nethical.questphone.core.core.utils.managers.getCachedApps
+import nethical.questphone.core.core.utils.managers.reloadApps
+import nethical.questphone.data.AppInfo
+import java.time.LocalDate
+import javax.inject.Inject
+import kotlin.math.roundToInt
+
+@HiltViewModel
+class AppListViewModel @Inject constructor(
+ application: Application,
+ private val userRepository: UserRepository
+) : AndroidViewModel(application) {
+
+ @SuppressLint("StaticFieldLeak")
+ private val context = application.applicationContext
+
+ val coins = userRepository.coinsState
+
+ private val _apps = MutableStateFlow>(emptyList())
+ val apps: StateFlow> = _apps
+
+ private val _filteredApps = MutableStateFlow>(emptyList())
+ val filteredApps: StateFlow> = _filteredApps.asStateFlow()
+
+ private val _isLoading = MutableStateFlow(true)
+ val isLoading = _isLoading.asStateFlow()
+
+ private val _error = MutableStateFlow(null)
+ val error = _error.asStateFlow()
+
+ private val _showCoinDialog = MutableStateFlow(false)
+ val showCoinDialog = _showCoinDialog.asStateFlow()
+
+ private val _selectedPackage = MutableStateFlow("")
+ val selectedPackage = _selectedPackage.asStateFlow()
+
+ private val _distractions = MutableStateFlow>(emptySet())
+ val distractions = _distractions.asStateFlow()
+
+ private val _searchQuery = MutableStateFlow("")
+ val searchQuery = _searchQuery.asStateFlow()
+
+ var minutesPerFiveCoins = MutableStateFlow(10)
+ private set
+
+ var remainingFreePassesToday = MutableStateFlow(0)
+
+ init {
+ viewModelScope.launch {
+ loadApps()
+ initFreePasses()
+ }
+ }
+
+ private suspend fun loadApps() {
+ val prefs = context.getSharedPreferences("minutes_per_5", Context.MODE_PRIVATE)
+ minutesPerFiveCoins.value = prefs.getInt("minutes_per_5", 10)
+
+ val cached = getCachedApps(context)
+ if (cached.isNotEmpty()) {
+ _apps.value = cached
+ _filteredApps.value = cached
+ _isLoading.value = false
+ }
+
+ withContext(Dispatchers.IO) {
+ reloadApps(context.packageManager, context).onSuccess {
+ _apps.value = it
+ _filteredApps.value = it
+ _isLoading.value = false
+ }.onFailure {
+ _error.value = it.message
+ _isLoading.value = false
+ }
+ }
+
+ val sp = context.getSharedPreferences("distractions", Context.MODE_PRIVATE)
+ _distractions.value = sp.getStringSet("distracting_apps", emptySet()) ?: emptySet()
+ }
+
+ fun onSearchQueryChange(query: String) {
+ _searchQuery.value = query
+ _filteredApps.value = _apps.value.filter { it.name.contains(query, ignoreCase = true) }
+ }
+
+ fun onAppClick(packageName: String) {
+ val cooldownUntil = AppBlockerServiceInfo.unlockedApps[packageName] ?: 0L
+ val isDistraction = _distractions.value.contains(packageName)
+
+ if (isDistraction && (cooldownUntil == -1L || System.currentTimeMillis() > cooldownUntil)) {
+ _selectedPackage.value = packageName
+ _showCoinDialog.value = true
+ } else {
+ launchApp(context, packageName)
+ onSearchQueryChange("")
+ }
+ }
+
+ fun onLongAppClick(packageName: String) {
+ val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
+ data = Uri.fromParts("package", packageName, null)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ }
+
+ fun onConfirmUnlockApp(coins: Int) {
+ val cooldownTime = (minutesPerFiveCoins.value * coins) * 60_000L
+ val pkg = _selectedPackage.value
+ val intent = Intent().apply {
+ action = INTENT_ACTION_UNLOCK_APP
+ putExtra("selected_time", cooldownTime)
+ putExtra("package_name", pkg)
+ }
+ context.sendBroadcast(intent)
+
+ if (!AppBlockerServiceInfo.isUsingAccessibilityService &&
+ AppBlockerServiceInfo.appBlockerService == null
+ ) {
+ startForegroundService(context, Intent(context, AppBlockerService::class.java))
+ AppBlockerServiceInfo.unlockedApps[pkg] = System.currentTimeMillis() + cooldownTime
+ }
+
+ userRepository.useCoins(coins)
+ launchApp(context, pkg)
+ _showCoinDialog.value = false
+ }
+
+ fun dismissDialog() {
+ _showCoinDialog.value = false
+ }
+
+
+ fun calculateAvailableFreePasses(screenTimes: List): Int {
+ if (screenTimes.size < 7) return 3 // Fallback for partial data
+
+ val now = Clock.System.now()
+ val questStreak = userRepository.userInfo.streak.currentStreak
+ val daysSinceCreated = userRepository.userInfo.created_on.daysUntil(now, TimeZone.currentSystemDefault())
+ val weeksSinceFirstUse = daysSinceCreated / 7.0
+ val userLevel = userRepository.userInfo.level
+
+ val weights = listOf(0.25, 0.2, 0.15, 0.15, 0.1, 0.1, 0.05)
+ val weightedAvg = screenTimes.zip(weights).sumOf { (t, w) -> t * w }
+
+ val today = screenTimes[0]
+ val yesterday = screenTimes[1]
+ val yesterdayAvg = screenTimes.drop(1).average()
+
+ val isNewUser = daysSinceCreated < 7
+ val isImproving = today < yesterdayAvg - 1
+ val isConsistent = questStreak >= (2 + userLevel / 2)
+
+ val generosityBoost = if (isNewUser) 2.0 else (1.5 - 0.1 * weeksSinceFirstUse).coerceAtLeast(0.5)
+ val difficulty = 2.0 + (userLevel * 0.25)
+ val baseUnlocks = ((weightedAvg / difficulty) * generosityBoost)
+
+ val progressBonus = if (isImproving) 1 else 0
+ val streakBonus = if (isConsistent) 1 else 0
+
+ // 📊 Dynamic max based on yesterday's screen time
+ val baseCap = (yesterday * 60 / 10).roundToInt() // 10 min = 1 unlock
+ val trendFactor = if (isImproving) 0.75 else 1.0
+ val generosityDecay = (1.2 - 0.1 * weeksSinceFirstUse).coerceAtLeast(0.5)
+ val dynamicMax = (baseCap * trendFactor * generosityDecay).roundToInt().coerceIn(2, 10)
+
+ return (baseUnlocks + progressBonus + streakBonus).roundToInt().coerceIn(1, dynamicMax)
+ }
+
+ fun initFreePasses(){
+ val prefs = context.getSharedPreferences("user_prefs", Context.MODE_PRIVATE)
+ val today = LocalDate.now().toString()
+ val lastUsedDate = prefs.getString("last_freepass_date", null)
+
+ if (lastUsedDate == today) {
+ remainingFreePassesToday .value= prefs.getInt("freepass_count", 0)
+ } else {
+ val stats = ScreenUsageStatsHelper(context).getStatsForLast7Days()
+ val filteredTimes = stats.filter { it.packageName == selectedPackage.value }.map { it.totalTime.toDouble() }
+ remainingFreePassesToday.value = calculateAvailableFreePasses(filteredTimes)
+ prefs.edit {
+ putString("last_freepass_date", today)
+ putInt("freepass_count", remainingFreePassesToday.value)
+ }
+ }
+ }
+
+ fun useFreePass() {
+
+ val prefs = context.getSharedPreferences("user_prefs", Context.MODE_PRIVATE)
+ val today = LocalDate.now().toString()
+ val remainingFreePassesToday = prefs.getInt("freepass_count", 0) - 1
+
+ prefs.edit {
+ putString("last_freepass_date", today)
+ putInt("freepass_count", remainingFreePassesToday)
+ }
+
+ onConfirmUnlockApp(0)
+ }
+
+
+}
+
+fun launchApp(context: Context, packageName: String) {
+ val intent = context.packageManager.getLaunchIntentForPackage(packageName)
+ intent?.let { context.startActivity(it) }
+}
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/launcher/HomeScreen.kt b/app/src/main/java/neth/iecal/questphone/app/screens/launcher/HomeScreen.kt
similarity index 65%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/launcher/HomeScreen.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/launcher/HomeScreen.kt
index 3bb75500..e5a7a089 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/launcher/HomeScreen.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/launcher/HomeScreen.kt
@@ -1,9 +1,6 @@
-package neth.iecal.questphone.ui.screens.launcher
+package neth.iecal.questphone.app.screens.launcher
-import android.content.Context.MODE_PRIVATE
-import android.content.Intent
import android.os.Build
-import android.provider.Settings
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.LinearEasing
@@ -61,13 +58,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateListOf
-import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
-import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -81,38 +75,25 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import androidx.core.content.edit
import androidx.navigation.NavController
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
-import kotlinx.datetime.LocalDate
import neth.iecal.questphone.R
-import neth.iecal.questphone.data.MeshStyles
-import neth.iecal.questphone.data.game.StreakCheckReturn
-import neth.iecal.questphone.data.game.User
-import neth.iecal.questphone.data.game.checkIfStreakFailed
-import neth.iecal.questphone.data.game.continueStreak
-import neth.iecal.questphone.data.quest.CommonQuestInfo
-import neth.iecal.questphone.data.quest.QuestDatabaseProvider
-import neth.iecal.questphone.data.quest.stats.StatsDatabaseProvider
-import neth.iecal.questphone.ui.navigation.Screen
-import neth.iecal.questphone.ui.screens.components.NeuralMeshAsymmetrical
-import neth.iecal.questphone.ui.screens.components.NeuralMeshSymmetrical
-import neth.iecal.questphone.ui.screens.components.TopBarActions
-import neth.iecal.questphone.ui.screens.launcher.components.AllQuestsDialog
-import neth.iecal.questphone.ui.screens.quest.DialogState
-import neth.iecal.questphone.ui.screens.quest.RewardDialogInfo
-import neth.iecal.questphone.ui.screens.quest.setup.deep_focus.SelectAppsDialog
-import neth.iecal.questphone.ui.screens.quest.stats.components.HeatMapChart
-import neth.iecal.questphone.utils.QuestHelper
-import neth.iecal.questphone.utils.getCurrentDate
-import neth.iecal.questphone.utils.getCurrentDay
-import neth.iecal.questphone.utils.getCurrentTime12Hr
-import neth.iecal.questphone.utils.isLockScreenServiceEnabled
-import neth.iecal.questphone.utils.isSetToDefaultLauncher
-import neth.iecal.questphone.utils.openDefaultLauncherSettings
-import neth.iecal.questphone.utils.performLockScreenAction
+import neth.iecal.questphone.app.navigation.LauncherDialogRoutes
+import neth.iecal.questphone.app.navigation.RootRoute
+import neth.iecal.questphone.app.screens.components.NeuralMeshAsymmetrical
+import neth.iecal.questphone.app.screens.components.NeuralMeshSymmetrical
+import neth.iecal.questphone.app.screens.components.TopBarActions
+import neth.iecal.questphone.app.screens.launcher.dialogs.LauncherDialog
+import neth.iecal.questphone.app.screens.quest.setup.deep_focus.SelectAppsDialog
+import neth.iecal.questphone.app.screens.quest.stats.components.HeatMapChart
+import neth.iecal.questphone.core.services.LockScreenService
+import neth.iecal.questphone.core.services.performLockScreenAction
+import neth.iecal.questphone.core.utils.managers.QuestHelper
+import nethical.questphone.core.core.utils.managers.isAccessibilityServiceEnabled
+import nethical.questphone.core.core.utils.managers.isSetToDefaultLauncher
+import nethical.questphone.core.core.utils.managers.openAccessibilityServiceScreen
+import nethical.questphone.core.core.utils.managers.openDefaultLauncherSettings
+import nethical.questphone.data.MeshStyles
data class SidePanelItem(
val icon: Int,
@@ -124,30 +105,29 @@ data class SidePanelItem(
ExperimentalLayoutApi::class
)
@Composable
-fun HomeScreen(navController: NavController) {
+fun HomeScreen(
+ navController: NavController,
+ viewModel: HomeScreenViewModel,
+) {
val context = LocalContext.current
- val dao = QuestDatabaseProvider.getInstance(context).questDao()
-
- val questHelper = QuestHelper(context)
- val questListUnfiltered by dao.getAllQuests().collectAsState(initial = emptyList())
-
- var isFirstRender by remember { mutableStateOf(true) }
- val questList = remember { mutableStateListOf() }
- var time by remember { mutableStateOf(getCurrentTime12Hr()) }
-
- val completedQuests = remember { SnapshotStateList() }
+ val time by viewModel.time
+ val questList by viewModel.questList.collectAsState()
+ val meshStyle by viewModel.meshStyle.collectAsState(initial = MeshStyles.SYMMETRICAL)
+ val completedQuests by viewModel.completedQuests.collectAsState()
+ val shortcuts = viewModel.shortcuts
+ val tempShortcuts = viewModel.tempShortcuts
+ val coins by viewModel.coins.collectAsState()
+ val streak by viewModel.currentStreak.collectAsState()
var isAppSelectorVisible by remember { mutableStateOf(false) }
- var shortcuts = remember { mutableStateListOf() }
- var tempShortcuts = remember { mutableStateListOf() } //used by app selector dialog, to store the shortcuts temporarily before saving
val sidePanelItems = listOf(
- SidePanelItem(R.drawable.profile_d,{navController.navigate(Screen.UserInfo.route)},"Profile"),
+ SidePanelItem(R.drawable.profile_d,{navController.navigate(RootRoute.UserInfo.route)},"Profile"),
SidePanelItem(R.drawable.notification_up,{ Toast.makeText(context,"Coming soon!", Toast.LENGTH_SHORT).show()},"Notifications"),
- SidePanelItem(R.drawable.store,{navController.navigate(Screen.Store.route)},"Store"),
- SidePanelItem(R.drawable.quest_analytics,{navController.navigate(Screen.ListAllQuest.route)},"Quest Analytics"),
- SidePanelItem(R.drawable.quest_adderpng,{navController.navigate(Screen.SelectTemplates.route)},"Add Quest")
+ SidePanelItem(R.drawable.store,{navController.navigate(RootRoute.Store.route)},"Store"),
+ SidePanelItem(nethical.questphone.data.R.drawable.quest_analytics,{navController.navigate(RootRoute.ListAllQuest.route)},"Quest Analytics"),
+ SidePanelItem(nethical.questphone.data.R.drawable.quest_adderpng,{navController.navigate(RootRoute.SelectTemplates.route)},"Add Quest")
)
var isAllQuestsDialogVisible by remember { mutableStateOf(false) }
@@ -163,124 +143,30 @@ fun HomeScreen(navController: NavController) {
label = "offsetY"
)
- var meshStyle by remember { mutableStateOf(MeshStyles.ASYMMETRICAL) }
val hapticFeedback = LocalHapticFeedback.current
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
- val isServiceEnabled = remember(context) {
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && isLockScreenServiceEnabled(context)
+ val isDoubleTapToSleepEnabled = remember(context) {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && isAccessibilityServiceEnabled(context,
+ LockScreenService::class.java)
}
- BackHandler { }
- val successfulDates = remember { mutableStateMapOf>() }
-
- LaunchedEffect (meshStyle) {
- if(meshStyle == MeshStyles.USER_STATS_HEATMAP){
- val dao = StatsDatabaseProvider.getInstance(context).statsDao()
-
- var stats = dao.getAllStatsForUser().first()
-
- stats = stats.toMutableList()
- stats.addAll(dao.getAllUnSyncedStats().first())
-
- stats.forEach {
- val prevList = (successfulDates[it.date]?: emptyList()).toMutableList()
- prevList.add(it.quest_id)
- successfulDates[it.date] = prevList
- }
- }
- }
+ // duck tape fix, trying to figure out a different way to do it without problems
LaunchedEffect(Unit) {
- val meshStylesp = context.getSharedPreferences("mesh_style",MODE_PRIVATE)
- val meshStyleOrd = meshStylesp.getInt("mesh_style",meshStyle.ordinal)
- meshStyle = MeshStyles.entries.toTypedArray()[meshStyleOrd]
-
- val shortcutsSp = context.getSharedPreferences("shortcuts", MODE_PRIVATE)
- val tshortcuts = shortcutsSp.getStringSet("shortcuts", setOf())?.toList() ?: listOf()
- shortcuts.addAll(tshortcuts)
- tempShortcuts.addAll(tshortcuts)
- while (true) {
- time = getCurrentTime12Hr()
- val delayMillis =
- 60_000 - (System.currentTimeMillis() % 60_000) // Delay until next minute
- delay(delayMillis)
- }
- }
-
-
-
- fun streakFailResultHandler(streakCheckReturn: StreakCheckReturn?) {
- if (streakCheckReturn != null) {
- RewardDialogInfo.streakData = streakCheckReturn
- if (streakCheckReturn.streakFreezersUsed != null) {
- RewardDialogInfo.currentDialog = DialogState.STREAK_UP
- }
- if (streakCheckReturn.streakDaysLost != null) {
- RewardDialogInfo.currentDialog = DialogState.STREAK_FAILED
- }
- RewardDialogInfo.isRewardDialogVisible = true
-
- }
+ viewModel.handleCheckStreakFailure()
+ viewModel.filterQuests()
}
- LaunchedEffect(questListUnfiltered) {
- if (isFirstRender) {
- isFirstRender = false // Ignore the first emission (initial = emptyList())
- } else {
- val todayDay = getCurrentDay()
-
- var list = questListUnfiltered.filter {
- !it.is_destroyed && it.selected_days.contains(todayDay)
- }
-
-
- list.forEach { item ->
- if (item.last_completed_on == getCurrentDate()) {
- completedQuests.add(item.id)
- }
- if (questHelper.isQuestRunning(item.title)) {
- navController.navigate(item.integration_id.name + item.id)
- }
- }
-
- val data = context.getSharedPreferences("onboard", MODE_PRIVATE)
- if (User.userInfo.streak.currentStreak != 0) {
- streakFailResultHandler(User.checkIfStreakFailed())
- }
- if (completedQuests.size == list.size && data.getBoolean("onboard", false)) {
- if (User.continueStreak()) {
- RewardDialogInfo.currentDialog = DialogState.STREAK_UP
- RewardDialogInfo.isRewardDialogVisible = true
- }
- }
-
- // Separate uncompleted and completed quests
- val uncompleted = list.filter { it.id !in completedQuests }
- val completed = list.filter { it.id in completedQuests }
-
- // Merge and sort, prioritizing uncompleted first
- val merged = (uncompleted + completed).sortedBy { QuestHelper.isInTimeRange(it) }
-
- // Take up to 4 items from the merged list
- list = if (merged.size >= 4) merged.take(4) else merged
-
- questList.clear()
- questList.addAll(list)
-
-
-
-
- }
- }
+ BackHandler { }
Scaffold(
modifier = Modifier.safeDrawingPadding(),
topBar = {
TopAppBar({}, actions = {
- TopBarActions( true, true)
+ TopBarActions(coins,streak, true, true)
})
},
@@ -292,24 +178,16 @@ fun HomeScreen(navController: NavController) {
tempShortcuts,
onDismiss = { isAppSelectorVisible = false },
onConfirm = {
- val shortcutsSp = context.getSharedPreferences("shortcuts", MODE_PRIVATE)
- shortcutsSp.edit(commit = true) {
- putStringSet(
- "shortcuts",
- tempShortcuts.toSet()
- )
- }
- shortcuts.clear()
- shortcuts.addAll(tempShortcuts)
+ viewModel.saveShortcuts()
isAppSelectorVisible = false
})
}
if(isAllQuestsDialogVisible){
- AllQuestsDialog(
- navController = navController
- ) {
- isAllQuestsDialogVisible = false
- }
+ LauncherDialog(
+ onDismiss = { isAllQuestsDialogVisible = false },
+ rootNavController = navController,
+ startDestination = LauncherDialogRoutes.ShowAllQuest.route
+ )
}
Box(
@@ -331,7 +209,7 @@ fun HomeScreen(navController: NavController) {
// Negative value for swipe-up, adjust threshold as needed
val swipeThreshold = -100f // Increased for more deliberate swipe
if (verticalDragOffset < swipeThreshold) {
- navController.navigate(Screen.AppList.route)
+ navController.navigate(RootRoute.AppList.route)
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
}
@@ -340,7 +218,7 @@ fun HomeScreen(navController: NavController) {
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && isServiceEnabled) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && isDoubleTapToSleepEnabled) {
performLockScreenAction()
} else {
scope.launch {
@@ -350,11 +228,10 @@ fun HomeScreen(navController: NavController) {
duration = SnackbarDuration.Short
).also { result ->
if (result == SnackbarResult.ActionPerformed) {
- val intent =
- Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply {
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- }
- context.startActivity(intent)
+ openAccessibilityServiceScreen(
+ context,
+ LockScreenService::class.java
+ )
}
}
}
@@ -366,17 +243,20 @@ fun HomeScreen(navController: NavController) {
Column(
Modifier.padding(8.dp)
) {
- Box(Modifier.size(200.dp).combinedClickable(onClick = {},onLongClick = {
- val meshStylesp = context.getSharedPreferences("mesh_style",MODE_PRIVATE)
- val options = MeshStyles.entries.filter { it != meshStyle }
- meshStyle = options.random()
- meshStylesp.edit(commit = true) { putInt("mesh_style", meshStyle.ordinal) }
+ Box(Modifier
+ .combinedClickable(onClick = {}, onLongClick = {
+ viewModel.toggleMeshStyle()
- })){
+ })){
when(meshStyle){
- MeshStyles.SYMMETRICAL -> NeuralMeshSymmetrical(modifier = Modifier.fillMaxSize())
- MeshStyles.ASYMMETRICAL -> NeuralMeshAsymmetrical(modifier = Modifier.fillMaxSize())
- MeshStyles.USER_STATS_HEATMAP -> HeatMapChart(successfulDates, Modifier.height(200.dp))
+ MeshStyles.SYMMETRICAL -> NeuralMeshSymmetrical(modifier = Modifier.size(200.dp))
+ MeshStyles.ASYMMETRICAL -> NeuralMeshAsymmetrical(modifier = Modifier.size(200.dp))
+ MeshStyles.USER_STATS_HEATMAP -> {
+ Column (modifier = Modifier.height(200.dp),
+ verticalArrangement = Arrangement.Center){
+ HeatMapChart(Modifier.padding(8.dp))
+ }
+ }
}
}
Spacer(Modifier.size(12.dp))
@@ -394,7 +274,7 @@ fun HomeScreen(navController: NavController) {
if(questList.isEmpty()){
TextButton(onClick = {
- navController.navigate(Screen.SelectTemplates.route)
+ navController.navigate(RootRoute.SelectTemplates.route)
}) {
Row {
Icon(imageVector = Icons.Default.Add, contentDescription = "Add Quests")
@@ -415,7 +295,7 @@ fun HomeScreen(navController: NavController) {
) {
items(questList.size) { index ->
val baseQuest = questList[index]
- val isFailed = questHelper.isOver(baseQuest)
+ val isFailed = QuestHelper.isTimeOver(baseQuest)
val isCompleted = completedQuests.contains(baseQuest.id)
Text(
@@ -425,7 +305,7 @@ fun HomeScreen(navController: NavController) {
color = if (isFailed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
textDecoration = if (isCompleted) TextDecoration.LineThrough else TextDecoration.None,
modifier = Modifier.clickable(onClick = {
- navController.navigate(Screen.ViewQuest.route + baseQuest.id)
+ navController.navigate(RootRoute.ViewQuest.route + baseQuest.id)
},
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false)
@@ -469,7 +349,8 @@ fun HomeScreen(navController: NavController) {
Column(
modifier = Modifier
.align(Alignment.BottomStart)
- .padding(start = 8.dp,
+ .padding(
+ start = 8.dp,
bottom = WindowInsets.navigationBars.asPaddingValues()
.calculateBottomPadding() + 8.dp
),
@@ -513,7 +394,8 @@ fun HomeScreen(navController: NavController) {
Column(
modifier = Modifier
.align(Alignment.BottomEnd)
- .padding(end = 8.dp,
+ .padding(
+ end = 8.dp,
bottom = WindowInsets.navigationBars.asPaddingValues()
.calculateBottomPadding() + 8.dp
),
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/launcher/HomeScreenViewModel.kt b/app/src/main/java/neth/iecal/questphone/app/screens/launcher/HomeScreenViewModel.kt
new file mode 100644
index 00000000..2b9436aa
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/launcher/HomeScreenViewModel.kt
@@ -0,0 +1,143 @@
+package neth.iecal.questphone.app.screens.launcher
+
+import android.app.Application
+import android.content.Context.MODE_PRIVATE
+import android.util.Log
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.core.content.edit
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import neth.iecal.questphone.app.screens.game.handleStreakFreezers
+import neth.iecal.questphone.app.screens.game.showStreakUpDialog
+import neth.iecal.questphone.core.utils.managers.QuestHelper
+import nethical.questphone.backend.CommonQuestInfo
+import nethical.questphone.backend.repositories.QuestRepository
+import nethical.questphone.backend.repositories.StatsRepository
+import nethical.questphone.backend.repositories.UserRepository
+import nethical.questphone.core.core.utils.getCurrentDate
+import nethical.questphone.core.core.utils.getCurrentDay
+import nethical.questphone.core.core.utils.getCurrentTime12Hr
+import nethical.questphone.data.MeshStyles
+import javax.inject.Inject
+
+@HiltViewModel
+class HomeScreenViewModel @Inject constructor(
+ application: Application,
+ private val questRepository: QuestRepository,
+ private val statsRepository: StatsRepository,
+ private val userRepository: UserRepository,
+) : AndroidViewModel(application){
+
+ val coins = userRepository.coinsState
+ val currentStreak = userRepository.currentStreakState
+
+ val questList = MutableStateFlow>(emptyList())
+ val completedQuests = MutableStateFlow>(emptyList())
+
+ val shortcuts = mutableStateListOf()
+ val tempShortcuts = mutableStateListOf()
+
+ private val _time = mutableStateOf(getCurrentTime12Hr())
+ val time = _time
+
+ private val _meshStyle = MutableStateFlow(MeshStyles.ASYMMETRICAL)
+ val meshStyle: StateFlow = _meshStyle
+
+
+ private val meshStylesp = application.applicationContext.getSharedPreferences("mesh_style", MODE_PRIVATE)
+ private val shortcutsSp = application.applicationContext.getSharedPreferences("shortcuts", MODE_PRIVATE)
+
+
+
+ init {
+ viewModelScope.launch {
+ loadSavedConfigs()
+ // Keep updating time every minute
+ while (true) {
+ _time.value = getCurrentTime12Hr()
+ val delayMillis = 60_000 - (System.currentTimeMillis() % 60_000)
+ delay(delayMillis)
+ }
+ }
+ }
+
+ private fun loadSavedConfigs() {
+ // Load mesh style from SharedPreferences
+ val meshStyleOrd = meshStylesp.getInt("mesh_style", meshStyle.value.ordinal)
+ _meshStyle.value = MeshStyles.entries.toTypedArray()[meshStyleOrd]
+
+ // Load shortcuts
+ shortcuts.addAll(shortcutsSp.getStringSet("shortcuts", setOf())?.toList() ?: listOf())
+ tempShortcuts.addAll(shortcuts)
+
+ }
+
+
+ suspend fun filterQuests(){
+ Log.d("HomeScreenViewModel", "quest list state changed")
+
+ // we reload the list from disk cause android triggers the function twice, once with an empty list initially.
+ // we cannot ignore empty lists cuz some dates have no quests so in those cases, we cannot trigger
+ // the function that checks streaks for those days
+ var rawQuestsListLocal = questRepository.getAllQuests().first()
+ val today = getCurrentDay()
+ val filtered = rawQuestsListLocal.filter {
+ !it.is_destroyed && it.selected_days.contains(today)
+ }
+ // Mark completed
+ val tempCompletedList = mutableListOf()
+ filtered.forEach {
+ if (it.last_completed_on == getCurrentDate()) {
+ tempCompletedList.add(it.id)
+ }
+ }
+
+ val uncompleted = filtered.filter { it.id !in tempCompletedList }
+ val completed = filtered.filter { it.id in tempCompletedList }
+
+ val merged =
+ (uncompleted + completed).sortedBy { QuestHelper.isInTimeRange(it) }
+
+ if (completed.size == rawQuestsListLocal.size) {
+ if (userRepository.continueStreak()) {
+ showStreakUpDialog()
+ }
+ }
+ questList.value = if (merged.size >= 4) merged.take(4) else merged
+ completedQuests.value = tempCompletedList.toList()
+ }
+
+ fun handleCheckStreakFailure(){
+ if (userRepository.userInfo.streak.currentStreak != 0) {
+ val daysSince = userRepository.checkIfStreakFailed()
+ if(daysSince!=null){
+ handleStreakFreezers(userRepository.tryUsingStreakFreezers(daysSince))
+ }
+
+ }
+ }
+
+ fun toggleMeshStyle() {
+ val currentIndex = MeshStyles.entries.indexOf(meshStyle.value)
+ _meshStyle.value = MeshStyles.entries[(currentIndex + 1) % MeshStyles.entries.size]
+ meshStylesp.edit { putInt("mesh_style", meshStyle.value.ordinal) }
+ }
+
+ fun saveShortcuts() {
+ shortcutsSp.edit(commit = true) {
+ putStringSet("shortcuts", tempShortcuts.toSet())
+ }
+ shortcuts.clear()
+ shortcuts.addAll(tempShortcuts)
+ }
+
+}
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/launcher/dialogs/AllQuestsDialog.kt b/app/src/main/java/neth/iecal/questphone/app/screens/launcher/dialogs/AllQuestsDialog.kt
new file mode 100644
index 00000000..2e7481d3
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/launcher/dialogs/AllQuestsDialog.kt
@@ -0,0 +1,336 @@
+package neth.iecal.questphone.app.screens.launcher.dialogs
+
+import android.app.Application
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.ripple
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+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.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.navigation.NavController
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import neth.iecal.questphone.core.utils.managers.QuestHelper
+import neth.iecal.questphone.app.navigation.RootRoute
+import nethical.questphone.backend.CommonQuestInfo
+import nethical.questphone.backend.repositories.QuestRepository
+import nethical.questphone.core.core.utils.formatHour
+import nethical.questphone.core.core.utils.getCurrentDate
+import nethical.questphone.core.core.utils.getCurrentDay
+import javax.inject.Inject
+
+@HiltViewModel
+class AllQuestDialogViewModel @Inject constructor(
+ questRepository: QuestRepository,
+ application: Application
+) : AndroidViewModel(application){
+
+ val completedQuests = SnapshotStateList()
+ val questList = mutableStateListOf()
+ val isLoading = mutableStateOf(true)
+
+ init {
+ viewModelScope.launch {
+ questRepository.getAllQuests().collectLatest { unfiltered ->
+ val todayDay = getCurrentDay()
+ val filtered = unfiltered.filter {
+ !it.is_destroyed && it.selected_days.contains(todayDay)
+ }
+
+ questList.clear()
+ questList.addAll(filtered)
+
+ completedQuests.clear()
+ completedQuests.addAll(
+ filtered.filter { it.last_completed_on == getCurrentDate() }
+ .map { it.id }
+ )
+
+ isLoading.value = false
+ }
+ }
+ }
+}
+
+@Composable
+fun AllQuestsDialog(
+ rootNavController: NavController,
+ viewModel: AllQuestDialogViewModel = hiltViewModel(),
+ onDismiss: () -> Unit,
+ ) {
+ val completedQuests = viewModel.completedQuests
+ val questList = viewModel.questList
+ val isLoading = viewModel.isLoading
+
+
+ val progress =
+ (completedQuests.size.toFloat() / questList.size.toFloat()).coerceIn(0f, 1f)
+
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth(0.92f)
+ .wrapContentHeight(),
+ shape = RoundedCornerShape(28.dp),
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ shadowElevation = 24.dp,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp)
+ ) {
+ // Header with icon and title
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Surface(
+ modifier = Modifier.size(48.dp),
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.primaryContainer
+ ) {
+ Icon(
+ painter = painterResource(neth.iecal.questphone.R.drawable.baseline_gamepad_24),
+ contentDescription = null,
+ modifier = Modifier
+ .padding(12.dp)
+ .size(24.dp),
+ )
+ }
+
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(
+ text = "Today's Quests",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ if (!isLoading.value && questList.isNotEmpty()) {
+ Text(
+ text = "${completedQuests.size} of ${questList.size} completed",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Content
+ AnimatedContent(
+ targetState = isLoading,
+ transitionSpec = {
+ fadeIn(animationSpec = tween(300)) togetherWith
+ fadeOut(animationSpec = tween(300))
+ },
+ label = "loading_content"
+ ) { loading ->
+ if (loading.value) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ CircularProgressIndicator(
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = "Loading your quests...",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ } else {
+ Column {
+ // Progress indicator
+ if (questList.isNotEmpty()) {
+
+ Column {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Progress",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = "${(progress * 100).toInt()}%",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Medium
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ LinearProgressIndicator(
+ progress = { progress },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(6.dp)
+ .clip(RoundedCornerShape(3.dp)),
+ color = MaterialTheme.colorScheme.primary,
+ trackColor = MaterialTheme.colorScheme.surfaceVariant,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ // Quest list
+ if (questList.isEmpty()) {
+ EmptyQuestState()
+ } else {
+ LazyColumn(
+ modifier = Modifier.heightIn(max = 400.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(
+ items = questList,
+ key = { it.id }
+ ) { baseQuest ->
+ val timeRange =
+ "${formatHour(baseQuest.time_range[0])} - ${
+ formatHour(
+ baseQuest.time_range[1]
+ )
+ } : "
+ val prefix =
+ if (baseQuest.time_range[0] == 0 && baseQuest.time_range[1] == 24) ""
+ else timeRange
+ val isFailed = QuestHelper.isTimeOver(baseQuest)
+ val isCompleted = completedQuests.contains(baseQuest.id)
+
+ Text(
+ text = if (QuestHelper.Companion.isInTimeRange(baseQuest) && isFailed) baseQuest.title else prefix + baseQuest.title,
+ fontWeight = FontWeight.ExtraLight,
+ fontSize = 23.sp,
+ color = if (isFailed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
+ textDecoration = if (isCompleted) TextDecoration.LineThrough else TextDecoration.None,
+ modifier = Modifier.clickable(
+ onClick = {
+ onDismiss()
+ rootNavController.navigate(RootRoute.ViewQuest.route + baseQuest.id)
+ },
+ interactionSource = remember { MutableInteractionSource() },
+ indication = ripple(bounded = false)
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Action buttons
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End
+ ) {
+ FilledTonalButton(
+ onClick = onDismiss,
+ modifier = Modifier.height(40.dp)
+ ) {
+ Text("Close")
+ }
+ }
+ }
+ }
+}
+@Composable
+private fun EmptyQuestState() {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Surface(
+ modifier = Modifier.size(80.dp),
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.surfaceContainer
+ ) {
+ Icon(
+ imageVector = Icons.Default.CheckCircle,
+ contentDescription = null,
+ modifier = Modifier
+ .padding(20.dp)
+ .size(40.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Text(
+ text = "No quests for today",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ Text(
+ text = "You're all caught up! Check back tomorrow.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/launcher/dialogs/LauncherDialog.kt b/app/src/main/java/neth/iecal/questphone/app/screens/launcher/dialogs/LauncherDialog.kt
new file mode 100644
index 00000000..8a60ce4e
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/launcher/dialogs/LauncherDialog.kt
@@ -0,0 +1,68 @@
+package neth.iecal.questphone.app.screens.launcher.dialogs
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.navigation.NavController
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import neth.iecal.questphone.app.navigation.LauncherDialogRoutes
+
+
+@Composable
+fun LauncherDialog(
+ coins: Int = 0,
+ onDismiss: () -> Unit,
+ pkgName: String = "",
+ rootNavController: NavController,
+ minutesPerFiveCoins :Int = 0,
+ unlockApp: (Int) -> Unit = {},
+ startDestination: String,
+ remainingFreePasses :Int= 0,
+ onFreePassUsed : ()->Unit = {}
+) {
+ val navController = rememberNavController()
+
+ Dialog(
+ onDismissRequest = onDismiss,
+ properties = DialogProperties(
+ usePlatformDefaultWidth = false,
+ dismissOnBackPress = true,
+ dismissOnClickOutside = true
+ )
+ ) {
+ NavHost(
+ navController = navController,
+ startDestination = startDestination
+ ) {
+ composable(LauncherDialogRoutes.ShowAllQuest.route) {
+ AllQuestsDialog(rootNavController = rootNavController, onDismiss = onDismiss)
+ }
+ composable(LauncherDialogRoutes.FreePassInfo.route) {
+ FreePassInfo(
+ onShowAllQuests = { navController.navigate(LauncherDialogRoutes.ShowAllQuest.route) },
+ pkgName = pkgName,
+ onDismiss = onDismiss,
+ remainingFreePassesToday = remainingFreePasses,
+ onFreePassUsed = onFreePassUsed,
+ )
+ }
+ composable(LauncherDialogRoutes.MakeAChoice.route) {
+ MakeAChoice(
+ onQuestClick = { navController.navigate(LauncherDialogRoutes.ShowAllQuest.route) },
+ onFreePassClick = { navController.navigate(LauncherDialogRoutes.FreePassInfo.route) }
+ )
+ }
+ composable(LauncherDialogRoutes.LowCoins.route) {
+ LowCoinsDialog(
+ coins,pkgName,navController
+ )
+ }
+ composable(LauncherDialogRoutes.UnlockAppDialog.route) {
+ UnlockAppDialog(coins,onDismiss, unlockApp,pkgName,minutesPerFiveCoins)
+ }
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/launcher/dialogs/LowCoinsDialog.kt b/app/src/main/java/neth/iecal/questphone/app/screens/launcher/dialogs/LowCoinsDialog.kt
new file mode 100644
index 00000000..1272dcf3
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/launcher/dialogs/LowCoinsDialog.kt
@@ -0,0 +1,100 @@
+package neth.iecal.questphone.app.screens.launcher.dialogs
+
+import android.graphics.Canvas
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.unit.dp
+import androidx.core.graphics.createBitmap
+import androidx.navigation.NavController
+import neth.iecal.questphone.app.navigation.LauncherDialogRoutes
+
+
+@Composable
+fun LowCoinsDialog(
+ coins: Int,
+ pkgName: String,
+ navController: NavController
+) {
+ val context = LocalContext.current
+ val appIconDrawable = context.packageManager.getApplicationIcon(pkgName)
+ val bitmap = remember (appIconDrawable) {
+ val bitmap =
+ createBitmap(appIconDrawable.intrinsicWidth, appIconDrawable.intrinsicHeight)
+ val canvas = Canvas(bitmap)
+ appIconDrawable.setBounds(0, 0, canvas.width, canvas.height)
+ appIconDrawable.draw(canvas)
+ bitmap.asImageBitmap()
+ }
+ Column(
+ modifier = Modifier.padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+
+ Image(
+ bitmap = bitmap,
+ contentDescription = "Instagram Icon",
+ Modifier
+ .size(100.dp)
+ .padding(16.dp)
+ )
+ Text(
+ text = "Balance: $coins coins",
+ color = Color.White,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+
+ val appName = try {
+ context.packageManager.getApplicationInfo(pkgName, 0)
+ .loadLabel(context.packageManager).toString()
+ } catch (_: Exception) {
+ pkgName
+ }
+ Text(
+ text = "You're too broke to use $appName right now. ",
+ color = Color.White,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .padding(bottom = 16.dp)
+ .align(Alignment.CenterHorizontally)
+ )
+
+ Spacer(Modifier.size(12.dp))
+
+ OutlinedButton(
+ onClick = {
+ navController.navigate(LauncherDialogRoutes.ShowAllQuest.route)
+ },
+ ) {
+ Text("Start A Quest")
+ }
+ Spacer(Modifier.size(8.dp))
+
+ Text(
+ text = "Just let me in for a while",
+ color = Color.White,
+ textAlign = TextAlign.Center,
+ textDecoration = TextDecoration.Underline,
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .clickable(onClick = {
+ navController.navigate(LauncherDialogRoutes.MakeAChoice.route)
+ })
+ )
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/launcher/dialogs/UnlockAnywayDialog.kt b/app/src/main/java/neth/iecal/questphone/app/screens/launcher/dialogs/UnlockAnywayDialog.kt
new file mode 100644
index 00000000..0ab7d32f
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/launcher/dialogs/UnlockAnywayDialog.kt
@@ -0,0 +1,195 @@
+package neth.iecal.questphone.app.screens.launcher.dialogs
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+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.compose.ui.unit.sp
+
+@Composable
+fun FreePassInfo(
+ onShowAllQuests: () -> Unit,
+ pkgName: String,
+ remainingFreePassesToday: Int,
+ onFreePassUsed:()->Unit,
+ onDismiss: () -> Unit
+) {
+ val context = LocalContext.current
+
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "😤 I THOUGHT YOU DOWNLOADED THIS APP TO FIX YOUR LIFE",
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color(0xFFFF6F00)
+ )
+
+ Text(
+ text = "You have $remainingFreePassesToday free ${if (remainingFreePassesToday == 1) "pass" else "passes"} today for this app. Each pass gives 10 minutes of app usage",
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Medium,
+ color = Color.White
+ )
+
+ Text(
+ text = "These free passes adapt to how consistent and committed you’ve been.\n" +
+ "Keep up the grind, or the boosts slow down. And so does your progress. \uD83D\uDC40",
+ fontSize = 13.sp,
+ color = Color.White.copy(alpha = 0.85f),
+ lineHeight = 18.sp,
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(Modifier.height(24.dp))
+
+ Button(
+ onClick = onShowAllQuests,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(52.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF00C853)), // Green
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Text(
+ "🔥 Start a Quest",
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color.White
+ )
+ }
+
+ if(remainingFreePassesToday>0){
+ OutlinedButton(
+ onClick = {
+ onFreePassUsed()
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(52.dp),
+ border = BorderStroke(1.dp, Color.LightGray),
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Text(
+ "😐 Use Free Pass",
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Medium,
+ color = Color.LightGray
+ )
+ }
+ }
+
+ }
+ }
+}
+
+@Composable
+fun MakeAChoice(
+ onQuestClick: () -> Unit,
+ onFreePassClick: () -> Unit
+) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // QUEST CARD
+ Card(
+ onClick = onQuestClick,
+ modifier = Modifier
+ .weight(0.65f)
+ .shadow(16.dp, RoundedCornerShape(24.dp)),
+ colors = CardDefaults.cardColors(containerColor = Color(0xFF00C853)), // green
+ shape = RoundedCornerShape(24.dp),
+ elevation = CardDefaults.cardElevation(12.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(24.dp),
+ verticalArrangement = Arrangement.SpaceBetween,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ "🔥\n\nTake the Quest!",
+ fontSize = 24.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color.White
+ )
+ Text("Earn coins + XP + streak boost", fontSize = 14.sp, color = Color.White)
+ Spacer(Modifier.height(12.dp))
+ Spacer(Modifier.height(12.dp))
+ Text(
+ "🚀 Only takes a few mins!",
+ fontSize = 12.sp,
+ color = Color.White.copy(alpha = 0.8f)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ // FREE PASS CARD
+ Card(
+ onClick = onFreePassClick,
+ modifier = Modifier
+ .weight(0.35f)
+ .shadow(4.dp, RoundedCornerShape(16.dp)),
+ colors = CardDefaults.cardColors(containerColor = Color(0xFFB0BEC5)), // dull gray
+ shape = RoundedCornerShape(16.dp),
+ elevation = CardDefaults.cardElevation(4.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(16.dp),
+ verticalArrangement = Arrangement.SpaceEvenly,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ "😐 Use Free Pass",
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Medium,
+ color = Color.DarkGray
+ )
+ Text(
+ "No XP. No progress.",
+ fontSize = 12.sp,
+ color = Color.DarkGray.copy(alpha = 0.8f)
+ )
+ Text("Lame route", fontSize = 12.sp, color = Color.Gray)
+ }
+ }
+ }
+}
+
+
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/launcher/dialogs/UnlockAppDialog.kt b/app/src/main/java/neth/iecal/questphone/app/screens/launcher/dialogs/UnlockAppDialog.kt
new file mode 100644
index 00000000..43d9103a
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/launcher/dialogs/UnlockAppDialog.kt
@@ -0,0 +1,108 @@
+package neth.iecal.questphone.app.screens.launcher.dialogs
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun UnlockAppDialog(
+ coins: Int,
+ onDismiss: () -> Unit,
+ onConfirm: (coinsSpent: Int) -> Unit,
+ pkgName: String,
+ minutesPerFiveCoins : Int
+) {
+ val context = LocalContext.current
+ val maxSpendableCoins = coins - (coins % 5)
+ var coinsToSpend by remember { mutableIntStateOf(5) }
+
+
+
+ Column(
+ modifier = Modifier.padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "Balance: $coins coins",
+ color = Color.White,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+
+ val appName = try {
+ context.packageManager.getApplicationInfo(pkgName, 0)
+ .loadLabel(context.packageManager).toString()
+ } catch (_: Exception) {
+ pkgName
+ }
+
+ Text(
+ text = "Open $appName?",
+ color = Color.White,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+
+ Text(
+ text = "Select coins to spend (in 5s):",
+ color = Color.White
+ )
+
+ // Coin step selector
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ modifier = Modifier.padding(vertical = 12.dp)
+ ) {
+ Button(
+ onClick = { if (coinsToSpend > 5) coinsToSpend -= 5 },
+ enabled = coinsToSpend > 5
+ ) {
+ Text("-5")
+ }
+
+ Text(
+ text = "$coinsToSpend",
+ color = Color.White,
+ modifier = Modifier.padding(horizontal = 24.dp)
+ )
+
+ Button(
+ onClick = { if (coinsToSpend + 5 <= maxSpendableCoins) coinsToSpend += 5 },
+ enabled = coinsToSpend + 5 <= maxSpendableCoins
+ ) {
+ Text("+5")
+ }
+ }
+
+ Text(
+ text = "You'll get ${coinsToSpend / 5 * minutesPerFiveCoins} minutes",
+ color = Color.White,
+ modifier = Modifier.padding(bottom = 24.dp)
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ Button(onClick = onDismiss) {
+ Text("No")
+ }
+ Button(onClick = { onConfirm(coinsToSpend) }) {
+ Text("Yes")
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/onboard/OnBoarderView.kt b/app/src/main/java/neth/iecal/questphone/app/screens/onboard/OnBoarderView.kt
new file mode 100644
index 00000000..79564386
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/onboard/OnBoarderView.kt
@@ -0,0 +1,270 @@
+package neth.iecal.questphone.app.screens.onboard
+
+import android.app.Application
+import android.content.Context
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.content.edit
+import androidx.lifecycle.AndroidViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+
+open class OnboardingContent {
+ // Standard title and description page
+ data class StandardPage(
+ val title: String,
+ val description: String
+ ) : OnboardingContent()
+
+ // Custom composable content
+ data class CustomPage(
+ val onNextPressed: () -> Boolean = {true},
+ val isNextEnabled: MutableState = mutableStateOf(true),
+ val content: @Composable () -> Unit
+ ) : OnboardingContent()
+}
+
+
+class OnboarderViewModel(application: Application) : AndroidViewModel(application) {
+
+
+ private val _currentPage = MutableStateFlow(0)
+ val currentPage: StateFlow = _currentPage
+
+ private val currentPageSp = application.applicationContext.getSharedPreferences("crnt_pg_onboard",
+ Context.MODE_PRIVATE)
+
+
+ private val _isNextEnabled = MutableStateFlow(true)
+ val isNextEnabled: StateFlow = _isNextEnabled
+
+
+ init {
+ loadCurrentPage()
+ }
+
+ fun setNextEnabled(enabled: Boolean) {
+ _isNextEnabled.value = enabled
+ }
+ private fun loadCurrentPage(){
+ _currentPage.value = currentPageSp.getInt("crnt_pg_onboard",0)
+ }
+ fun setCurrentPage(page: Int) {
+ _currentPage.value = page
+ currentPageSp.edit { putInt("crnt_pg_onboard", page) }
+ }
+
+}
+
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun OnBoarderView(
+ viewModel: OnboarderViewModel,
+ onFinishOnboarding: () -> Unit,
+ pages: List
+) {
+ val haptic = LocalHapticFeedback.current
+ val scope = rememberCoroutineScope()
+
+ val currentPage = viewModel.currentPage.collectAsState()
+ val isNextEnabled = viewModel.isNextEnabled.collectAsState()
+
+ val isFirstPage = currentPage.value == 0
+ val isLastPage = currentPage.value == pages.size - 1
+
+ val pagerState = rememberPagerState(pageCount = { pages.size }, initialPage = currentPage.value)
+
+ LaunchedEffect(pagerState) {
+ snapshotFlow { pagerState.currentPage }.collect { page ->
+ viewModel.setCurrentPage(page)
+ }
+ }
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ ) {
+ // Horizontal Pager for swipeable pages
+ HorizontalPager(
+ state = pagerState,
+ userScrollEnabled = false,
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ ) { position ->
+ when (val page = pages[position]) {
+ is OnboardingContent.StandardPage -> {
+ StandardPageContent(
+ title = page.title,
+ description = page.description
+ )
+ }
+
+ is OnboardingContent.CustomPage -> {
+ page.content()
+ }
+ }
+ }
+
+ // Page indicators
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ repeat(pages.size) { iteration ->
+ val color = if (pagerState.currentPage == iteration)
+ MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
+
+ Box(
+ modifier = Modifier
+ .padding(2.dp)
+ .clip(CircleShape)
+ .background(color)
+ .size(8.dp)
+ )
+ }
+ }
+
+ // Back and Next buttons
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(horizontal = 16.dp, vertical = 24.dp),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ AnimatedVisibility(
+ visible = !isFirstPage,
+ enter = fadeIn(animationSpec = tween(300)),
+ exit = fadeOut(animationSpec = tween(300))
+ ) {
+ TextButton(
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ scope.launch {
+ pagerState.animateScrollToPage(pagerState.currentPage - 1)
+ }
+ }
+ ) {
+ Text(
+ text = "Back",
+ fontSize = 16.sp
+ )
+ }
+ }
+
+ // Spacer if no back button
+ if (isFirstPage) {
+ Spacer(modifier = Modifier.weight(1f))
+ }
+
+ Button(
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ if (isLastPage) {
+ onFinishOnboarding()
+ } else {
+ val crnPage = pages[pagerState.currentPage]
+ if (crnPage is OnboardingContent.CustomPage) {
+ val result = crnPage.onNextPressed.invoke()
+ if (result) {
+ scope.launch {
+ pagerState.animateScrollToPage(pagerState.currentPage + 1)
+ }
+ }
+ return@Button
+ }
+ scope.launch {
+ pagerState.animateScrollToPage(pagerState.currentPage + 1)
+ }
+ }
+ },
+ enabled = if (pages[pagerState.currentPage] is OnboardingContent.CustomPage) {
+ (pages[pagerState.currentPage] as OnboardingContent.CustomPage).isNextEnabled.value
+ } else {
+ isNextEnabled.value
+ }
+
+ ) {
+ Text(
+ text = if (isLastPage) "Get Started" else "Next",
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold
+ )
+
+ }
+ }
+ }
+}
+@Composable
+fun StandardPageContent(
+ title: String,
+ description: String
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = title,
+ fontSize = 28.sp,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(bottom = 24.dp)
+ )
+
+ Text(
+ text = description,
+ fontSize = 16.sp,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(horizontal = 24.dp)
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+ }
+}
+
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/onboard/OnboardingScreen.kt b/app/src/main/java/neth/iecal/questphone/app/screens/onboard/OnboardingScreen.kt
new file mode 100644
index 00000000..0e7b7bb9
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/onboard/OnboardingScreen.kt
@@ -0,0 +1,163 @@
+package neth.iecal.questphone.app.screens.onboard
+
+import android.Manifest
+import android.app.Activity
+import android.content.Context.MODE_PRIVATE
+import android.content.Intent
+import android.os.Build
+import android.provider.Settings
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.content.ContextCompat.startForegroundService
+import androidx.core.content.edit
+import androidx.core.net.toUri
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.NavHostController
+import neth.iecal.questphone.MainActivity
+import neth.iecal.questphone.app.screens.account.SetupProfileScreen
+import neth.iecal.questphone.app.screens.onboard.subscreens.LoginOnboard
+import neth.iecal.questphone.app.screens.onboard.subscreens.NotificationPerm
+import neth.iecal.questphone.app.screens.onboard.subscreens.OverlayScreenPerm
+import neth.iecal.questphone.app.screens.onboard.subscreens.ScheduleExactAlarmPerm
+import neth.iecal.questphone.app.screens.onboard.subscreens.SelectApps
+import neth.iecal.questphone.app.screens.onboard.subscreens.TermsScreen
+import neth.iecal.questphone.app.screens.onboard.subscreens.UsageAccessPerm
+import neth.iecal.questphone.core.services.AppBlockerService
+import neth.iecal.questphone.core.utils.reminder.NotificationScheduler
+import nethical.questphone.core.core.utils.managers.checkNotificationPermission
+import nethical.questphone.core.core.utils.managers.checkUsagePermission
+
+@Composable
+fun OnBoarderView(navController: NavHostController) {
+
+ val viewModel: OnboarderViewModel = viewModel()
+
+ val context = LocalContext.current
+ val notificationPermLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission(),
+ onResult = { _ ->
+ }
+ )
+ val isTosAccepted = remember { mutableStateOf(true) }
+ LaunchedEffect(Unit) {
+ isTosAccepted.value = context.getSharedPreferences("terms", MODE_PRIVATE).getBoolean("isAccepted",false)
+ }
+ val isNextEnabledLogin = rememberSaveable {mutableStateOf(false)}
+ val isNextEnabledSetupProfile = rememberSaveable {mutableStateOf(false)}
+
+
+
+ val onboardingPages = mutableListOf(
+ OnboardingContent.StandardPage(
+ "What Are You Doing?",
+ "You’re not relaxing. You’re escaping.\n\nHours lost to apps that drain you.\n\nQuestPhone makes you earn screentime—by doing something real. Walk. Study. Breathe.\n\nEach day, you get less screen… until you don’t need it at all.\n\nStop feeding the machine. Start choosing yourself."
+ ),
+ OnboardingContent.StandardPage(
+ "How it works",
+ "Take control of your screen time like never before. Instead of mindless scrolling, you’ll earn your access by completing real-life Quests—like going for a walk, meditating, studying, or anything that helps you grow. Each quest rewards you with Coins and XP: spend 5 Coins to unlock your favorite distracting app for 10 minutes, and level up as you build better habits!\n" +
+ "\n" +
+ "It’s not just about restrictions—it’s a game. Stay motivated by collecting items, leveling up, and watching your progress unfold. QuestPhone makes self-discipline feel like an epic adventure."
+ ),
+ OnboardingContent.CustomPage(
+ isNextEnabled = isNextEnabledLogin){ ->
+ LoginOnboard(isNextEnabledLogin, navController)
+ },
+
+
+ OnboardingContent.CustomPage(
+ content = {
+ OverlayScreenPerm()
+ },
+ onNextPressed = {
+ val isAllowed = Settings.canDrawOverlays(context)
+ if(!isAllowed){
+ val intent = Intent(
+ Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
+ "package:${context.packageName}".toUri()
+ )
+ context.startActivity(intent)
+ return@CustomPage false
+ }
+ return@CustomPage true
+ }
+ ),
+ OnboardingContent.CustomPage(
+ content = {
+ UsageAccessPerm()
+ }, onNextPressed = {
+ if(checkUsagePermission(context)){
+ return@CustomPage true
+ }
+ val intent = Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)
+ context.startActivity(intent)
+ return@CustomPage false
+
+ }
+ ),
+ OnboardingContent.CustomPage(
+ onNextPressed = {
+ if(checkNotificationPermission(context)){
+ return@CustomPage true
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ notificationPermLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ return@CustomPage false
+ }else{
+ return@CustomPage true
+ }
+ }
+ ){
+ NotificationPerm()
+ },
+ OnboardingContent.CustomPage(
+ content = {
+ ScheduleExactAlarmPerm()
+ }, onNextPressed = {
+ val notificationScheduler = NotificationScheduler(context)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ if (!notificationScheduler.alarmManager.canScheduleExactAlarms()) {
+ val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
+ context.startActivity(intent)
+ false
+ }else{
+ true
+ }
+ }else{
+ true
+ }
+
+ }
+ ),
+
+ OnboardingContent.CustomPage(isNextEnabled = isNextEnabledSetupProfile) {
+ SetupProfileScreen(isNextEnabledSetupProfile)
+ },
+ OnboardingContent.CustomPage {
+ SelectApps()
+ }
+ )
+
+
+ if(isTosAccepted.value) {
+ OnBoarderView(
+ viewModel,
+ onFinishOnboarding = {
+ startForegroundService(context, Intent(context, AppBlockerService::class.java))
+ val data = context.getSharedPreferences("onboard", MODE_PRIVATE)
+ data.edit { putBoolean("onboard", true) }
+ val intent = Intent(context, MainActivity::class.java)
+ context.startActivity(intent)
+ (context as Activity).finish()
+ },
+ pages = onboardingPages
+ )
+ } else {
+ TermsScreen(isTosAccepted)
+ }
+}
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/LoginOnboard.kt b/app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/LoginOnboard.kt
new file mode 100644
index 00000000..40d6dea3
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/LoginOnboard.kt
@@ -0,0 +1,85 @@
+package neth.iecal.questphone.app.screens.onboard.subscreens
+
+import android.util.Log
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.ui.platform.LocalContext
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavHostController
+import io.github.jan.supabase.auth.auth
+import io.github.jan.supabase.auth.status.SessionStatus
+import kotlinx.coroutines.flow.collectLatest
+import neth.iecal.questphone.app.screens.account.ForgotPasswordScreen
+import neth.iecal.questphone.app.screens.account.login.AuthStep
+import neth.iecal.questphone.app.screens.account.login.LoginScreen
+import neth.iecal.questphone.app.screens.account.login.LoginViewModel
+import neth.iecal.questphone.app.screens.account.login.SignUpScreen
+import neth.iecal.questphone.app.screens.onboard.StandardPageContent
+import nethical.questphone.backend.Supabase
+import nethical.questphone.backend.isOnline
+import nethical.questphone.backend.triggerProfileSync
+import nethical.questphone.backend.triggerQuestSync
+import nethical.questphone.backend.triggerStatsSync
+
+@Composable
+fun LoginOnboard(isNextEnabled: MutableState, navController: NavHostController){
+ val context = LocalContext.current
+
+ val viewModel: LoginViewModel = hiltViewModel()
+
+ val authStep = viewModel.authStep
+ LaunchedEffect(Unit) {
+ Supabase.supabase.auth.sessionStatus.collectLatest { authState ->
+ Log.d("authState",authState.toString())
+ when (authState) {
+ is SessionStatus.Authenticated -> {
+ authStep.value = AuthStep.COMPLETE
+ isNextEnabled.value = true
+ }
+
+ is SessionStatus.NotAuthenticated -> {
+ isNextEnabled.value = false
+ }
+ is SessionStatus.Initializing -> {
+ Log.d("Signup", "Initializing session...")
+ }
+
+ else -> {}
+ }
+ }
+ }
+
+ AnimatedContent(targetState = authStep.value, transitionSpec = {
+ (fadeIn(animationSpec = tween(300))
+ .togetherWith(fadeOut(animationSpec = tween(300))))
+ }) { it ->
+
+ when(it) {
+ AuthStep.LOGIN -> {
+ LoginScreen(viewModel) {
+ if (context.isOnline()) {
+ triggerProfileSync(context,true)
+ triggerQuestSync(context.applicationContext, true)
+ triggerStatsSync(context, true)
+ }
+ }
+ }
+ AuthStep.SIGNUP -> {
+ SignUpScreen(viewModel, {isNextEnabled.value = true})
+
+ }
+ AuthStep.FORGOT_PASSWORD -> ForgotPasswordScreen(viewModel)
+ AuthStep.COMPLETE ->
+ {
+ StandardPageContent("A New Journey Begins Here!", "Press Next to continue!")
+ }
+
+ }
+ }
+}
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/onboard/NotificationPermissionScreen.kt b/app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/NotificationPerm.kt
similarity index 93%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/onboard/NotificationPermissionScreen.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/NotificationPerm.kt
index 31d8b979..e0a60b67 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/onboard/NotificationPermissionScreen.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/NotificationPerm.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.onboard
+package neth.iecal.questphone.app.screens.onboard.subscreens
import android.Manifest
import android.os.Build
@@ -18,7 +18,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -27,10 +26,10 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
-import neth.iecal.questphone.utils.checkNotificationPermission
+import nethical.questphone.core.core.utils.managers.checkNotificationPermission
@Composable
-fun NotificationPermissionScreen( isFromOnboard: Boolean = true) {
+fun NotificationPerm(isFromOnboard: Boolean = true) {
val context = LocalContext.current
val hasPermission = remember {
mutableStateOf(
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/onboard/OverlayScreenPermissionScreen.kt b/app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/OverlayScreenPerm.kt
similarity index 95%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/onboard/OverlayScreenPermissionScreen.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/OverlayScreenPerm.kt
index 2ead3d9e..198aaca8 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/onboard/OverlayScreenPermissionScreen.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/OverlayScreenPerm.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.onboard
+package neth.iecal.questphone.app.screens.onboard.subscreens
import android.content.Intent
import android.provider.Settings
@@ -27,7 +27,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
@Composable
-fun OverlayPermissionScreen(isOnBoardingScreen : Boolean= true) {
+fun OverlayScreenPerm(isOnBoardingScreen : Boolean= true) {
val context = LocalContext.current
val hasPermission = remember {
mutableStateOf(
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/onboard/ScheduleExactAlarmsScreen.kt b/app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/ScheduleExactAlarmsPerm.kt
similarity index 94%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/onboard/ScheduleExactAlarmsScreen.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/ScheduleExactAlarmsPerm.kt
index 8221bfb3..cbf72c35 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/onboard/ScheduleExactAlarmsScreen.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/ScheduleExactAlarmsPerm.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.onboard
+package neth.iecal.questphone.app.screens.onboard.subscreens
import android.content.Intent
import android.os.Build
@@ -25,10 +25,10 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
-import neth.iecal.questphone.utils.reminder.NotificationScheduler
+import neth.iecal.questphone.core.utils.reminder.NotificationScheduler
@Composable
-fun ScheduleExactAlarmScreen(isOnBoardingScreen : Boolean= true) {
+fun ScheduleExactAlarmPerm(isOnBoardingScreen : Boolean= true) {
val context = LocalContext.current
val notificationScheduler = NotificationScheduler(LocalContext.current)
val hasPermission = remember {
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/SelectApps.kt b/app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/SelectApps.kt
new file mode 100644
index 00000000..643799d6
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/SelectApps.kt
@@ -0,0 +1,304 @@
+package neth.iecal.questphone.app.screens.onboard.subscreens
+
+import android.app.Application
+import android.content.Context
+import android.util.Log
+import android.widget.Toast
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.content.edit
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.compose.viewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import neth.iecal.questphone.core.services.AppBlockerServiceInfo
+import neth.iecal.questphone.core.services.INTENT_ACTION_REFRESH_APP_BLOCKER
+import nethical.questphone.core.core.utils.managers.reloadApps
+import nethical.questphone.core.core.utils.managers.sendRefreshRequest
+import nethical.questphone.data.AppInfo
+
+enum class SelectAppsModes{
+ ALLOW_ADD, // only allow adding one app, block removing any apps
+ ALLOW_REMOVE, // only allow removing one app, block adding any app
+ ALLOW_ADD_AND_REMOVE // no restrictions
+}
+class SelectAppsViewModel(application: Application) : AndroidViewModel(application) {
+
+ private val context: Context get() = getApplication().applicationContext
+ private val sharedPrefs = context.getSharedPreferences("distractions", Context.MODE_PRIVATE)
+
+ val searchQuery = mutableStateOf("")
+
+ private val _apps = MutableStateFlow>(emptyList())
+ val apps: StateFlow> = _apps
+
+ private val _selectedApps = MutableStateFlow>(emptySet())
+ val selectedApps: StateFlow> = _selectedApps
+
+ init {
+ viewModelScope.launch {
+ loadApps()
+ loadSelectedApps()
+ }
+ }
+
+ fun loadApps() {
+ viewModelScope.launch(Dispatchers.IO) {
+ reloadApps(context.packageManager, context)
+ .onSuccess {
+ _apps.value = it
+ }
+ .onFailure {
+ Log.e("SelectAppsVM", "Failed to load apps: $it")
+ Toast.makeText(context, "Error loading apps", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ fun loadSelectedApps() {
+ _selectedApps.value = sharedPrefs.getStringSet("distracting_apps", emptySet()) ?: emptySet()
+ }
+
+ fun toggleApp(packageName: String) {
+ val updated = _selectedApps.value.toMutableSet().apply {
+ if (contains(packageName)) remove(packageName) else add(packageName)
+ }
+ _selectedApps.value = updated
+ saveToPrefs()
+ }
+
+ private fun saveToPrefs() {
+ sharedPrefs.edit { putStringSet("distracting_apps", _selectedApps.value) }
+ sendRefreshRequest(context, INTENT_ACTION_REFRESH_APP_BLOCKER)
+ AppBlockerServiceInfo.appBlockerService?.loadLockedApps()
+ }
+
+ fun clearSelectedApps() {
+ _selectedApps.value = emptySet()
+ saveToPrefs()
+ }
+ fun addApp(packageName: String) {
+ if (!_selectedApps.value.contains(packageName)) {
+ val updated = _selectedApps.value.toMutableSet().apply { add(packageName) }
+ _selectedApps.value = updated
+ saveToPrefs()
+ }
+ }
+
+ fun removeApp(packageName: String) {
+ if (_selectedApps.value.contains(packageName)) {
+ val updated = _selectedApps.value.toMutableSet().apply { remove(packageName) }
+ _selectedApps.value = updated
+ saveToPrefs()
+ }
+ }
+
+}
+
+
+@Composable
+fun SelectApps( selectAppsModes: SelectAppsModes = SelectAppsModes.ALLOW_ADD_AND_REMOVE,viewModel: SelectAppsViewModel = viewModel(),) {
+
+ val context = LocalContext.current
+
+ val apps by viewModel.apps.collectAsState()
+ val selectedApps by viewModel.selectedApps.collectAsState()
+
+ var searchQuery by viewModel.searchQuery
+ val filteredApps = remember(apps, searchQuery) {
+ if (searchQuery.isBlank()) apps
+ else apps.filter {
+ it.name.contains(searchQuery, ignoreCase = true) ||
+ it.packageName.contains(searchQuery, ignoreCase = true)
+ }
+ }
+ // the one special app that was added for ALLOW_ADD or ALLOW_REMOVE mode. is not used for the third mode
+ var specialChosenApp by remember { mutableStateOf(null) }
+
+
+ val sp = context.getSharedPreferences("distractions", Context.MODE_PRIVATE)
+
+ fun saveToBlocker(){
+ sp.edit { putStringSet("distracting_apps", selectedApps.toSet()) }
+ sendRefreshRequest(context, INTENT_ACTION_REFRESH_APP_BLOCKER)
+ AppBlockerServiceInfo.appBlockerService?.loadLockedApps()
+ }
+
+ fun handleAppSelection(packageName: String) {
+ when (selectAppsModes) {
+ SelectAppsModes.ALLOW_ADD -> {
+ // Only allow adding one app, but allow undoing that addition
+ if (specialChosenApp == null && !selectedApps.contains(packageName)) {
+ // Allow adding one app
+ specialChosenApp = packageName
+ viewModel.addApp (packageName)
+ } else if (specialChosenApp == packageName) {
+ // Allow undoing the addition (removing the special app that was added)
+ specialChosenApp = null
+ viewModel.removeApp(packageName)
+ }
+ // Block removing pre-existing apps or adding more apps
+ }
+
+ SelectAppsModes.ALLOW_REMOVE -> {
+ // Only allow removing one app, but allow undoing that removal
+ if (specialChosenApp == null && selectedApps.contains(packageName)) {
+ // Allow removing one app
+ viewModel.removeApp(packageName)
+ specialChosenApp = packageName
+ } else if (specialChosenApp == packageName) {
+ // Allow undoing the removal (re-adding the special app that was removed)
+ viewModel.addApp(packageName)
+ specialChosenApp = null
+ }
+ // Block adding new apps or removing more apps
+ }
+
+ SelectAppsModes.ALLOW_ADD_AND_REMOVE -> {
+ // No restrictions - toggle selection
+ if (selectedApps.contains(packageName)) {
+ viewModel.removeApp(packageName)
+ } else {
+ viewModel.addApp(packageName)
+ }
+ }
+ }
+ saveToBlocker()
+
+ }
+
+ Column(
+ modifier = Modifier
+ .padding(16.dp)
+ .fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+
+ Spacer(modifier = Modifier.height(80.dp))
+
+ Text(
+ text = "Select Distractions",
+ fontSize = 28.sp,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(bottom = 24.dp)
+ )
+
+ Text(
+ text = "These might be social media or games..",
+ fontSize = 16.sp,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(horizontal = 24.dp)
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize(),
+ ) {
+ item {
+ OutlinedTextField(
+ value = searchQuery,
+ onValueChange = { searchQuery = it},
+ label = { Text("Search Apps") },
+ placeholder = { Text("Type app name...") },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = "Search"
+ )
+ },
+ trailingIcon = {
+ if (searchQuery.isNotEmpty()) {
+ IconButton(onClick = { searchQuery = "" }) {
+ Icon(
+ imageVector = Icons.Default.Clear,
+ contentDescription = "Clear search"
+ )
+ }
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 8.dp),
+ singleLine = true
+ )
+ }
+ items(filteredApps) { (appName, packageName) ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ handleAppSelection(packageName)
+ }
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ val isSelected = selectedApps.contains(packageName)
+
+ val isEnabled = when(selectAppsModes) {
+ SelectAppsModes.ALLOW_ADD -> {
+ // Enable unselected apps if no special app chosen yet (can add)
+ // OR enable the special app that was added (can undo)
+ (specialChosenApp == null && !isSelected) || specialChosenApp == packageName
+ }
+ SelectAppsModes.ALLOW_REMOVE -> {
+ // Enable selected apps if no special app chosen yet (can remove)
+ // OR enable the special app that was removed (can undo)
+ (specialChosenApp == null && isSelected) || specialChosenApp == packageName
+ }
+ SelectAppsModes.ALLOW_ADD_AND_REMOVE -> {
+ // Always enabled - no restrictions
+ true
+ }
+ }
+
+ Checkbox(
+ checked = isSelected,
+ enabled = isEnabled,
+ onCheckedChange = { _ ->
+ handleAppSelection(packageName)
+ }
+ )
+ Text(
+ text = appName,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/onboard/SetCoinRewardRatio.kt b/app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/SetCoinRewardRatio.kt
similarity index 98%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/onboard/SetCoinRewardRatio.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/SetCoinRewardRatio.kt
index 99816aaf..f5cf1938 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/onboard/SetCoinRewardRatio.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/SetCoinRewardRatio.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.onboard
+package neth.iecal.questphone.app.screens.onboard.subscreens
import android.content.Context
import androidx.compose.foundation.layout.Arrangement
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/onboard/TermsScreen.kt b/app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/TermsScreen.kt
similarity index 98%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/onboard/TermsScreen.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/TermsScreen.kt
index ae6ffae9..3bfa69f9 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/onboard/TermsScreen.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/TermsScreen.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.onboard
+package neth.iecal.questphone.app.screens.onboard.subscreens
import android.content.Context
import android.content.Intent
@@ -38,7 +38,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import neth.iecal.questphone.R
-import neth.iecal.questphone.utils.getCurrentDate
+import nethical.questphone.core.core.utils.getCurrentDate
@Composable
fun TermsScreen(isTosAccepted: MutableState) {
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/onboard/UsageAccessPermissionScreen.kt b/app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/UsageAccessPerm.kt
similarity index 90%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/onboard/UsageAccessPermissionScreen.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/UsageAccessPerm.kt
index 14a14c41..3189ca6d 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/onboard/UsageAccessPermissionScreen.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/onboard/subscreens/UsageAccessPerm.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.onboard
+package neth.iecal.questphone.app.screens.onboard.subscreens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -22,11 +22,11 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
-import neth.iecal.questphone.utils.checkUsagePermission
-import neth.iecal.questphone.utils.openBatteryOptimizationSettings
+import nethical.questphone.core.core.utils.managers.checkUsagePermission
+import nethical.questphone.core.core.utils.managers.openBatteryOptimizationSettings
@Composable
-fun UsageAccessPermission(isFromOnboardingScreen : Boolean = true) {
+fun UsageAccessPerm(isFromOnboardingScreen : Boolean = true) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val hasUsagePermission = remember { mutableStateOf(false) }
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/pet/TheSystemDialog.kt b/app/src/main/java/neth/iecal/questphone/app/screens/pet/TheSystemDialog.kt
similarity index 97%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/pet/TheSystemDialog.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/pet/TheSystemDialog.kt
index ac5ae97b..0b446a99 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/pet/TheSystemDialog.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/pet/TheSystemDialog.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.pet
+package neth.iecal.questphone.app.screens.pet
import android.content.Context
import androidx.compose.animation.animateContentSize
@@ -48,10 +48,10 @@ import androidx.compose.ui.window.DialogProperties
import androidx.core.content.edit
import dev.jeziellago.compose.markdowntext.MarkdownText
import kotlinx.coroutines.delay
-import neth.iecal.questphone.data.game.StoryNode
-import neth.iecal.questphone.data.game.introductionStory
-import neth.iecal.questphone.ui.screens.components.NeuralMeshSymmetrical
-import neth.iecal.questphone.ui.theme.JetBrainMono
+import nethical.questphone.data.game.StoryNode
+import nethical.questphone.data.game.introductionStory
+import neth.iecal.questphone.app.screens.components.NeuralMeshSymmetrical
+import neth.iecal.questphone.app.theme.JetBrainMono
import kotlin.random.Random
@Composable
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/ListAllQuests.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/ListAllQuests.kt
similarity index 70%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/quest/ListAllQuests.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/quest/ListAllQuests.kt
index e1c3a8df..dc0f25f0 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/ListAllQuests.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/ListAllQuests.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.quest
+package neth.iecal.questphone.app.screens.quest
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -23,50 +23,73 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
-import neth.iecal.questphone.data.quest.CommonQuestInfo
-import neth.iecal.questphone.data.quest.QuestDatabaseProvider
-import neth.iecal.questphone.ui.navigation.Screen
+import kotlinx.coroutines.launch
+import neth.iecal.questphone.app.navigation.RootRoute
+import nethical.questphone.backend.CommonQuestInfo
+import nethical.questphone.backend.repositories.QuestRepository
+import javax.inject.Inject
-@Composable
-fun ListAllQuests(navHostController: NavHostController) {
- var questList by remember { mutableStateOf>(emptyList()) }
- val dao = QuestDatabaseProvider.getInstance(LocalContext.current).questDao()
-
- var searchQuery by remember { mutableStateOf("") }
- val filteredQuestList = remember(questList, searchQuery) {
- if (searchQuery.isBlank()) {
- questList
+@HiltViewModel
+class ListAllQuestsViewModel @Inject constructor(
+ private val questRepository: QuestRepository
+) : ViewModel() {
+
+ private val _searchQuery = MutableStateFlow("")
+ val searchQuery: StateFlow = _searchQuery
+
+ private val _questList = MutableStateFlow>(emptyList())
+ var questList: MutableStateFlow> = MutableStateFlow>(emptyList())
+
+
+ init {
+ viewModelScope.launch {
+ _questList.value = questRepository.getAllQuests().first()
+ questList.value = _questList.value
+ }
+ }
+
+ fun onSearchQueryChange(newQuery: String) {
+ _searchQuery.value = newQuery
+
+ if (_searchQuery.value.isBlank()) {
+ questList.value = _questList.value
} else {
- questList.filter { item ->
- item.title.contains(searchQuery, ignoreCase = true) ||
- item.instructions.contains(searchQuery, ignoreCase = true)
+ questList.value = _questList.value.filter { item ->
+ item.title.contains(searchQuery.value, ignoreCase = true) ||
+ item.instructions.contains(searchQuery.value, ignoreCase = true)
}
}
}
- LaunchedEffect(Unit) {
- questList = dao.getAllQuests().first()
- }
+}
+
+@Composable
+fun ListAllQuests(navHostController: NavHostController, viewModel: ListAllQuestsViewModel = hiltViewModel()) {
+
+ val searchQuery by viewModel.searchQuery.collectAsState()
+ val questList by viewModel.questList.collectAsState()
Scaffold(
modifier = Modifier.fillMaxSize(),
floatingActionButton = {
Button(
- onClick = { navHostController.navigate(Screen.SelectTemplates.route)},
+ onClick = { navHostController.navigate(RootRoute.SelectTemplates.route)},
shape = RoundedCornerShape(8.dp),
modifier = Modifier.padding(bottom = 32.dp)
) {
@@ -93,7 +116,7 @@ fun ListAllQuests(navHostController: NavHostController) {
item {
OutlinedTextField(
value = searchQuery,
- onValueChange = { searchQuery = it },
+ onValueChange = { viewModel.onSearchQueryChange(it) },
label = { Text("Search Quests") },
placeholder = { Text("Type Quest Title...") },
leadingIcon = {
@@ -104,7 +127,7 @@ fun ListAllQuests(navHostController: NavHostController) {
},
trailingIcon = {
if (searchQuery.isNotEmpty()) {
- IconButton(onClick = { searchQuery = "" }) {
+ IconButton(onClick = { viewModel.onSearchQueryChange("")}) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = "Clear search"
@@ -118,11 +141,11 @@ fun ListAllQuests(navHostController: NavHostController) {
singleLine = true
)
}
- items(filteredQuestList) { questBase: CommonQuestInfo ->
+ items(questList) { questBase: CommonQuestInfo ->
QuestItem(
quest = questBase,
onClick = {
- navHostController.navigate(Screen.QuestStats.route + questBase.id)
+ navHostController.navigate(RootRoute.QuestStats.route + questBase.id)
}
)
}
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/ViewQuest.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/ViewQuest.kt
similarity index 76%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/quest/ViewQuest.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/quest/ViewQuest.kt
index 6e030cce..b4a200b7 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/ViewQuest.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/ViewQuest.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.quest
+package neth.iecal.questphone.app.screens.quest
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -24,30 +24,28 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.navigation.NavHostController
import kotlinx.coroutines.launch
-import neth.iecal.questphone.data.quest.CommonQuestInfo
-import neth.iecal.questphone.data.quest.QuestDatabaseProvider
-import neth.iecal.questphone.ui.navigation.Screen
-import neth.iecal.questphone.utils.QuestHelper
+import neth.iecal.questphone.app.navigation.RootRoute
+import neth.iecal.questphone.core.utils.managers.QuestHelper
+import neth.iecal.questphone.data.toAdv
+import nethical.questphone.backend.CommonQuestInfo
+import nethical.questphone.backend.repositories.QuestRepository
@Composable
fun ViewQuest(
navController: NavHostController,
+ questRepository: QuestRepository,
id: String
) {
val context = LocalContext.current
- val questHelper = QuestHelper(context)
val showDestroyQuestDialog = remember { mutableStateOf(false) }
-
val scope = rememberCoroutineScope()
- val dao = QuestDatabaseProvider.getInstance(context).questDao()
var commonQuestInfo by remember { mutableStateOf(null) }
LaunchedEffect(Unit) {
- val dao = QuestDatabaseProvider.getInstance(context).questDao()
- commonQuestInfo = dao.getQuestById(id)
+ commonQuestInfo = questRepository.getQuestById(id)
}
Surface {
@@ -55,7 +53,7 @@ fun ViewQuest(
if (QuestHelper.Companion.isNeedAutoDestruction(commonQuestInfo!!)) {
showDestroyQuestDialog.value = true
} else {
- commonQuestInfo!!.integration_id.viewScreen.invoke(commonQuestInfo!!)
+ commonQuestInfo!!.integration_id.toAdv().viewScreen.invoke(commonQuestInfo!!)
}
if (showDestroyQuestDialog.value)
DestroyQuestDialog {
@@ -63,10 +61,10 @@ fun ViewQuest(
commonQuestInfo!!.synced = false
commonQuestInfo!!.last_updated = System.currentTimeMillis()
scope.launch {
- dao.upsertQuest(commonQuestInfo!!)
+ questRepository.upsertQuest(commonQuestInfo!!)
}
- navController.navigate(Screen.HomeScreen.route) {
- popUpTo(Screen.ViewQuest.route) { inclusive = true }
+ navController.navigate(RootRoute.HomeScreen.route) {
+ popUpTo(RootRoute.ViewQuest.route) { inclusive = true }
}
}
}
@@ -75,7 +73,7 @@ fun ViewQuest(
}
@Composable
-fun DestroyQuestDialog(onDismiss: () -> Unit) {
+private fun DestroyQuestDialog(onDismiss: () -> Unit) {
Dialog(onDismissRequest = onDismiss) {
Column(
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/CommonSetBaseQuest.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/CommonSetBaseQuest.kt
new file mode 100644
index 00000000..43af4c34
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/CommonSetBaseQuest.kt
@@ -0,0 +1,53 @@
+package neth.iecal.questphone.app.screens.quest.setup
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import neth.iecal.questphone.data.QuestInfoState
+import neth.iecal.questphone.app.screens.quest.setup.components.AutoDestruct
+import neth.iecal.questphone.app.screens.quest.setup.components.SelectDaysOfWeek
+import neth.iecal.questphone.app.screens.quest.setup.components.SetTimeRange
+import nethical.questphone.core.core.utils.getCurrentDate
+import nethical.questphone.core.core.utils.getCurrentDay
+
+@Composable
+fun CommonSetBaseQuest(createdOnDate:String,questInfoState: QuestInfoState, isTimeRangeSupported: Boolean = true) {
+
+ OutlinedTextField(
+ value = questInfoState.title,
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
+ onValueChange = {
+ questInfoState.title = it
+ },
+ label = { Text("Quest Title") },
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+
+ if(questInfoState.selectedDays.contains(getCurrentDay()) && createdOnDate != getCurrentDate()){
+ Text("Fake a quest if you want. It'll sit in your history, reminding you you're a fraud. Real ones can ignore this, you’ve got nothing to hide.")
+ }
+ SelectDaysOfWeek(questInfoState)
+
+ OutlinedTextField(
+ value = questInfoState.instructions,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
+ onValueChange = { questInfoState.instructions = it }, // Direct update
+ label = { Text("Instructions") },
+ modifier = Modifier.fillMaxWidth()
+ .height(200.dp)
+ )
+ AutoDestruct(questInfoState)
+
+ if(isTimeRangeSupported){
+ SetTimeRange(questInfoState)
+ }
+
+}
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/QuestSetupViewModel.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/QuestSetupViewModel.kt
new file mode 100644
index 00000000..f387a6ea
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/QuestSetupViewModel.kt
@@ -0,0 +1,43 @@
+package neth.iecal.questphone.app.screens.quest.setup
+
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+import neth.iecal.questphone.data.QuestInfoState
+import nethical.questphone.backend.CommonQuestInfo
+import nethical.questphone.backend.repositories.QuestRepository
+import nethical.questphone.backend.repositories.UserRepository
+import nethical.questphone.data.BaseIntegrationId
+
+open class QuestSetupViewModel(
+ protected val questRepository: QuestRepository,
+ protected val userRepository: UserRepository
+): ViewModel() {
+ val isReviewDialogVisible: MutableStateFlow = MutableStateFlow(false)
+
+ val questInfoState = MutableStateFlow(QuestInfoState())
+
+ fun getBaseQuestInfo(): CommonQuestInfo{
+ return questInfoState.value.toBaseQuest(null)
+ }
+
+ val userCreatedOn = userRepository.userInfo.getCreatedOnString()
+
+ suspend fun loadQuestData(id:String?,integrationId: BaseIntegrationId,onQuestLoaded:(CommonQuestInfo) -> Unit = {}){
+ val quest = questRepository.getQuestById(id.toString())
+ questInfoState.value.fromBaseQuest(quest ?: CommonQuestInfo(integration_id = integrationId))
+ }
+ fun addQuestToDb(json: String,reward: Int = 5, onSuccess: ()-> Unit){
+ viewModelScope.launch {
+ val baseQuest = getBaseQuestInfo()
+ baseQuest.quest_json = json
+ baseQuest.reward = reward
+ Log.d("Setup Quest","Added quest ${nethical.questphone.data.json.encodeToString(baseQuest)} ")
+ questRepository.upsertQuest(baseQuest)
+ isReviewDialogVisible.value = false
+ onSuccess()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/ReviewDialog.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/ReviewDialog.kt
similarity index 88%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/ReviewDialog.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/ReviewDialog.kt
index 391931af..0a583857 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/ReviewDialog.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/ReviewDialog.kt
@@ -1,22 +1,29 @@
-package neth.iecal.questphone.ui.screens.quest.setup
+package neth.iecal.questphone.app.screens.quest.setup
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
-import androidx.compose.material3.*
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
+import nethical.questphone.data.ExcludeFromReviewDialog
import kotlin.reflect.KClass
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.isAccessible
-// Define a custom annotation for excluding fields from review
-@Retention(AnnotationRetention.RUNTIME)
-@Target(AnnotationTarget.PROPERTY)
-annotation class ExcludeFromReviewDialog
@Composable
fun ReviewDialog(
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/SetIntegration.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/SetIntegration.kt
similarity index 92%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/SetIntegration.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/SetIntegration.kt
index 4accfd47..d0302e91 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/SetIntegration.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/SetIntegration.kt
@@ -1,7 +1,6 @@
-package neth.iecal.questphone.ui.screens.quest.setup
+package neth.iecal.questphone.app.screens.quest.setup
import android.annotation.SuppressLint
-import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
@@ -38,17 +37,28 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
import androidx.navigation.NavHostController
+import dagger.hilt.android.lifecycle.HiltViewModel
import neth.iecal.questphone.R
+import neth.iecal.questphone.app.screens.quest_docs.QuestTutorial
import neth.iecal.questphone.data.IntegrationId
-import neth.iecal.questphone.data.game.User
-import neth.iecal.questphone.ui.screens.tutorial.QuestTutorial
-import neth.iecal.questphone.utils.VibrationHelper
+import nethical.questphone.backend.repositories.UserRepository
+import nethical.questphone.core.core.utils.VibrationHelper
+import javax.inject.Inject
+
+@HiltViewModel
+class SetIntegrationVM @Inject constructor(
+ userRepository: UserRepository
+): ViewModel() {
+ val isAnonymous = userRepository.userInfo.isAnonymous
+}
@OptIn(ExperimentalFoundationApi::class)
@SuppressLint("UnrememberedMutableState")
@Composable
-fun SetIntegration(navController: NavHostController) {
+fun SetIntegration(navController: NavHostController, viewModel: SetIntegrationVM = hiltViewModel()) {
val showLoginRequiredDialog = remember { mutableStateOf(false) }
Scaffold()
@@ -120,8 +130,7 @@ fun SetIntegration(navController: NavHostController) {
// inclusive = true
// }
// }
- Log.d("anonymous",User.userInfo.isAnonymous.toString())
- if(!item.isLoginRequired || !User.userInfo.isAnonymous){
+ if(!item.isLoginRequired || !viewModel.isAnonymous){
navController.navigate("${item.name}/ntg") {
popUpTo(navController.currentDestination?.route ?: "") {
inclusive = true
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/ai_snap/SetAiSnap.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/ai_snap/SetAiSnap.kt
similarity index 75%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/ai_snap/SetAiSnap.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/ai_snap/SetAiSnap.kt
index ab140884..7e5deea3 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/ai_snap/SetAiSnap.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/ai_snap/SetAiSnap.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.quest.setup.ai_snap
+package neth.iecal.questphone.app.screens.quest.setup.ai_snap
import android.annotation.SuppressLint
import androidx.compose.foundation.background
@@ -31,83 +31,91 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
-import kotlinx.coroutines.launch
-import neth.iecal.questphone.data.IntegrationId
-import neth.iecal.questphone.data.quest.QuestDatabaseProvider
-import neth.iecal.questphone.data.quest.QuestInfoState
-import neth.iecal.questphone.data.quest.ai.snap.AiSnap
-import neth.iecal.questphone.ui.screens.quest.setup.ReviewDialog
-import neth.iecal.questphone.ui.screens.quest.setup.SetBaseQuest
-import neth.iecal.questphone.ui.screens.quest.setup.ai_snap.model.ModelDownloadDialog
-import neth.iecal.questphone.utils.json
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import neth.iecal.questphone.app.screens.quest.setup.CommonSetBaseQuest
+import neth.iecal.questphone.app.screens.quest.setup.ReviewDialog
+import neth.iecal.questphone.app.screens.quest.setup.QuestSetupViewModel
+import neth.iecal.questphone.app.screens.quest.setup.ai_snap.model.ModelDownloadDialog
+import nethical.questphone.backend.repositories.QuestRepository
+import nethical.questphone.backend.repositories.UserRepository
+import nethical.questphone.data.BaseIntegrationId
+import nethical.questphone.data.json
+import nethical.questphone.data.quest.ai.snap.AiSnap
+import javax.inject.Inject
+@HiltViewModel
+class SetAiSnapViewModelQuest @Inject constructor (questRepository: QuestRepository,
+ userRepository: UserRepository
+) : QuestSetupViewModel(questRepository, userRepository){
+ val taskDescription = MutableStateFlow("")
+ val features : SnapshotStateList = mutableStateListOf()
+
+ fun getAiQuest(): AiSnap{
+ return AiSnap(
+ taskDescription = taskDescription.value,
+ features = features.toMutableList()
+ )
+ }
+
+ fun saveQuest(onSuccess:()-> Unit){
+ addQuestToDb(json.encodeToString(getAiQuest())) { onSuccess() }
+
+ }
+}
@SuppressLint("UnrememberedMutableState")
@Composable
-fun SetAiSnap(editQuestId:String? = null,navController: NavHostController) {
- val context = LocalContext.current
+fun SetAiSnap(editQuestId:String? = null,navController: NavHostController, viewModel: SetAiSnapViewModelQuest = hiltViewModel()) {
val scrollState = rememberScrollState()
- // State for the quest
- val questInfoState = remember { QuestInfoState(initialIntegrationId = IntegrationId.AI_SNAP) }
- val taskDescription = remember { mutableStateOf("") }
- var features = remember { mutableStateListOf() }
-
- val scope = rememberCoroutineScope()
+ val questInfoState by viewModel.questInfoState.collectAsState()
+ val taskDescription by viewModel.taskDescription.collectAsState()
+ var features = viewModel.features
- val isReviewDialogVisible = remember { mutableStateOf(false) }
- var isModelDownloadDialogVisible =remember{ mutableStateOf(false)}
+ val isReviewDialogVisible by viewModel.isReviewDialogVisible.collectAsState()
+ var isModelDownloadDialogVisible = remember{ mutableStateOf(false)}
ModelDownloadDialog(modelDownloadDialogVisible = isModelDownloadDialogVisible)
LaunchedEffect(Unit) {
- if(editQuestId!=null){
- val dao = QuestDatabaseProvider.getInstance(context).questDao()
- val quest = dao.getQuest(editQuestId)
- questInfoState.fromBaseQuest(quest!!)
- val aiSnap = json.decodeFromString(quest.quest_json)
- taskDescription.value = aiSnap.taskDescription
- features.addAll(aiSnap.features)
-// spatialImageUri.value = aiSnap.spatialImageUrl
+ viewModel.loadQuestData(editQuestId, BaseIntegrationId.AI_SNAP) {
+ val aiSnap = json.decodeFromString(it.quest_json)
+ viewModel.taskDescription.value = aiSnap.taskDescription
+ viewModel.features.addAll(aiSnap.features)
}
}
// Review dialog before creating the quest
- if (isReviewDialogVisible.value) {
- val aiSnapQuest = AiSnap(
- taskDescription = taskDescription.value,
- features = features.toMutableList()
- )
- val baseQuest = questInfoState.toBaseQuest(aiSnapQuest)
+ if (isReviewDialogVisible) {
+ val aiSnapQuest = viewModel.getAiQuest()
+ val baseQuest = viewModel.getBaseQuestInfo()
ReviewDialog(
items = listOf(baseQuest, aiSnapQuest),
onConfirm = {
- scope.launch {
- val dao = QuestDatabaseProvider.getInstance(context).questDao()
- dao.upsertQuest(baseQuest)
+ viewModel.saveQuest {
+ navController.popBackStack()
}
- isReviewDialogVisible.value = false
- navController.popBackStack()
},
onDismiss = {
- isReviewDialogVisible.value = false
+ viewModel.isReviewDialogVisible.value = false
}
)
}
@@ -139,14 +147,14 @@ fun SetAiSnap(editQuestId:String? = null,navController: NavHostController) {
}
// Base quest configuration
- SetBaseQuest(questInfoState)
+ CommonSetBaseQuest(viewModel.userCreatedOn,questInfoState)
// Task description
OutlinedTextField(
- value = taskDescription.value,
- onValueChange = { taskDescription.value = it },
+ value = taskDescription,
+ onValueChange = { viewModel.taskDescription.value = it },
label = { Text("Task Description") },
- placeholder = { Text("e.g., Clean the bedroom, Organize desk") },
+ placeholder = { Text("e.g., A Clean Bedroom, An organized desk") },
modifier = Modifier.fillMaxWidth(),
minLines = 2
)
@@ -171,12 +179,11 @@ fun SetAiSnap(editQuestId:String? = null,navController: NavHostController) {
AddRemoveListWithDialog(items =features)
}
- // Create Quest button
Button(
- enabled = questInfoState.selectedDays.isNotEmpty(),
+ enabled = questInfoState.selectedDays.isNotEmpty() && taskDescription.isNotBlank(),
onClick = {
- if (taskDescription.value.isNotBlank()) {
- isReviewDialogVisible.value = true
+ if (taskDescription.isNotBlank()) {
+ viewModel.isReviewDialogVisible.value = true
}
},
modifier = Modifier.fillMaxWidth(),
@@ -202,7 +209,7 @@ fun SetAiSnap(editQuestId:String? = null,navController: NavHostController) {
}
@Composable
-fun AddRemoveListWithDialog(
+private fun AddRemoveListWithDialog(
modifier: Modifier = Modifier,
items: SnapshotStateList
) {
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/ai_snap/model/ModelDownloadDialog.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/ai_snap/model/ModelDownloadDialog.kt
similarity index 97%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/ai_snap/model/ModelDownloadDialog.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/ai_snap/model/ModelDownloadDialog.kt
index 65e553fb..74ed9d8b 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/ai_snap/model/ModelDownloadDialog.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/ai_snap/model/ModelDownloadDialog.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.quest.setup.ai_snap.model
+package neth.iecal.questphone.app.screens.quest.setup.ai_snap.model
import android.content.Context
import android.widget.Toast
@@ -36,8 +36,8 @@ import androidx.work.Data
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import kotlinx.serialization.builtins.ListSerializer
-import neth.iecal.questphone.utils.fetchUrlContent
-import neth.iecal.questphone.utils.worker.FileDownloadWorker
+import nethical.questphone.backend.fetchUrlContent
+import nethical.questphone.backend.worker.FileDownloadWorker
@kotlinx.serialization.Serializable
data class DownloadableModel(
@@ -125,6 +125,7 @@ fun ModelDownloadDialog(
) {
Column(modifier = Modifier.padding(24.dp)) {
Text("Download Model", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(bottom = 20.dp))
+ Text("Performing this quest requires more additional files to be downloaded. Please select any one from the below to continue.", modifier = Modifier.padding(bottom = 20.dp))
if (isModelDownloading) {
Text(
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/components/AutoDestruct.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/components/AutoDestruct.kt
similarity index 99%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/components/AutoDestruct.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/components/AutoDestruct.kt
index 623e7fcb..b605f4c5 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/components/AutoDestruct.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/components/AutoDestruct.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.quest.setup.components
+package neth.iecal.questphone.app.screens.quest.setup.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
@@ -46,7 +46,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
-import neth.iecal.questphone.data.quest.QuestInfoState
+import neth.iecal.questphone.data.QuestInfoState
import java.time.Instant
import java.time.LocalDate
import java.time.format.DateTimeFormatter
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/components/SelectDaysOfWeek.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/components/SelectDaysOfWeek.kt
similarity index 97%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/components/SelectDaysOfWeek.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/components/SelectDaysOfWeek.kt
index f869d2b2..48ce1413 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/components/SelectDaysOfWeek.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/components/SelectDaysOfWeek.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.quest.setup.components
+package neth.iecal.questphone.app.screens.quest.setup.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
@@ -33,8 +33,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
-import neth.iecal.questphone.data.DayOfWeek
-import neth.iecal.questphone.data.quest.QuestInfoState
+import neth.iecal.questphone.data.QuestInfoState
+import nethical.questphone.data.DayOfWeek
@Composable
fun SelectDaysOfWeek(
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/components/SetTimeRange.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/components/SetTimeRange.kt
similarity index 98%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/components/SetTimeRange.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/components/SetTimeRange.kt
index 8db61da9..d63dbdaa 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/components/SetTimeRange.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/components/SetTimeRange.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.quest.setup.components
+package neth.iecal.questphone.app.screens.quest.setup.components
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
@@ -40,8 +40,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
-import neth.iecal.questphone.data.quest.QuestInfoState
-import neth.iecal.questphone.utils.formatHour
+import neth.iecal.questphone.data.QuestInfoState
@Composable
fun SetTimeRange(initialTimeRange: QuestInfoState) {
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/deep_focus/SelectAppsDialog.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/deep_focus/SelectAppsDialog.kt
similarity index 93%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/deep_focus/SelectAppsDialog.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/deep_focus/SelectAppsDialog.kt
index 2db2e4b9..ebf4c9ab 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/deep_focus/SelectAppsDialog.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/deep_focus/SelectAppsDialog.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.quest.setup.deep_focus
+package neth.iecal.questphone.app.screens.quest.setup.deep_focus
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
@@ -26,8 +26,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
-import neth.iecal.questphone.data.AppInfo
-import neth.iecal.questphone.utils.reloadApps
+import nethical.questphone.core.core.utils.managers.reloadApps
+import nethical.questphone.data.AppInfo
@Composable
fun SelectAppsDialog(
@@ -96,7 +96,9 @@ fun SelectAppsDialog(
}
},
confirmButton = {
- TextButton(onClick = onConfirm) {
+ TextButton(onClick = {
+ onConfirm()
+ onDismiss()}) {
Text("OK")
}
},
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/deep_focus/SetDeepFocus.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/deep_focus/SetDeepFocus.kt
new file mode 100644
index 00000000..f0ef4339
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/deep_focus/SetDeepFocus.kt
@@ -0,0 +1,196 @@
+package neth.iecal.questphone.app.screens.quest.setup.deep_focus
+
+import android.annotation.SuppressLint
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Done
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.snapshots.SnapshotStateList
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavHostController
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import neth.iecal.questphone.app.screens.quest.setup.CommonSetBaseQuest
+import neth.iecal.questphone.app.screens.quest.setup.ReviewDialog
+import neth.iecal.questphone.app.screens.quest.setup.QuestSetupViewModel
+import nethical.questphone.backend.repositories.QuestRepository
+import nethical.questphone.backend.repositories.UserRepository
+import nethical.questphone.data.BaseIntegrationId
+import nethical.questphone.data.json
+import nethical.questphone.data.quest.focus.DeepFocus
+import nethical.questphone.data.quest.focus.FocusTimeConfig
+import javax.inject.Inject
+
+@HiltViewModel
+class SetDeepFocusViewModelQuest @Inject constructor(
+ questRepository: QuestRepository, userRepository: UserRepository
+) : QuestSetupViewModel(questRepository, userRepository){
+ var selectedApps :SnapshotStateList = mutableStateListOf()
+
+ var focusTimeConfig = MutableStateFlow(FocusTimeConfig())
+ var showAppSelectionDialog = MutableStateFlow(false)
+
+ fun getDeepFocusQuest(): DeepFocus{
+ return DeepFocus(
+ focusTimeConfig = focusTimeConfig.value,
+ unrestrictedApps = selectedApps.toSet(),
+ nextFocusDurationInMillis = focusTimeConfig.value.initialTimeInMs
+ )
+ }
+ fun saveQuest(onSuccess:()-> Unit){
+ addQuestToDb(json.encodeToString(getDeepFocusQuest())) { onSuccess() }
+
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@SuppressLint("UnrememberedMutableState")
+@Composable
+fun SetDeepFocus(editQuestId:String? = null,navController: NavHostController, viewModel: SetDeepFocusViewModelQuest = hiltViewModel()) {
+
+ val haptic = LocalHapticFeedback.current
+ val showAppSelectionDialog by viewModel.showAppSelectionDialog.collectAsState()
+ val selectedApps = viewModel.selectedApps
+ val focusTimeConfig by viewModel.focusTimeConfig.collectAsState()
+ val questInfoState by viewModel.questInfoState.collectAsState()
+ val isReviewDialogVisible by viewModel.isReviewDialogVisible.collectAsState()
+
+ val scrollState = rememberScrollState()
+
+ LaunchedEffect(Unit) {
+ viewModel.loadQuestData(editQuestId, BaseIntegrationId.DEEP_FOCUS) {
+ val deepFocus = json.decodeFromString(it.quest_json)
+ viewModel.focusTimeConfig.value = deepFocus.focusTimeConfig
+ selectedApps.addAll(deepFocus.unrestrictedApps)
+ }
+ }
+ if (showAppSelectionDialog) {
+ SelectAppsDialog(
+ selectedApps = viewModel.selectedApps,
+ onDismiss = {
+ viewModel.showAppSelectionDialog.value = false
+ }
+ )
+ }
+ if (isReviewDialogVisible) {
+ val baseQuest = viewModel.getBaseQuestInfo()
+ val deepFocus = viewModel.getDeepFocusQuest()
+ ReviewDialog(
+ items = listOf(
+ baseQuest, deepFocus
+ ),
+
+ onConfirm = {
+ viewModel.saveQuest {
+ navController.popBackStack()
+
+ }
+ },
+ onDismiss = {
+ viewModel.isReviewDialogVisible.value = false
+ }
+ )
+ }
+ Scaffold(
+ modifier = Modifier.safeDrawingPadding(),
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 8.dp),
+ text = "Deep Focus",
+ style = MaterialTheme.typography.headlineLarge,
+ )
+ }
+ )
+ }
+ )
+ { paddingValues ->
+
+ Box(Modifier.padding(paddingValues)) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(scrollState)
+ .padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+
+ ) {
+
+ CommonSetBaseQuest(viewModel.userCreatedOn,questInfoState)
+
+ OutlinedButton(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ viewModel.showAppSelectionDialog.value = true
+ },
+ ) {
+
+ Text(
+ text = "Selected App Exceptions ${selectedApps.size}",
+ style = MaterialTheme.typography.labelMedium
+ )
+ }
+
+ SetFocusTimeUI(focusTimeConfig){
+ viewModel.focusTimeConfig.value = it
+ }
+
+ Button(
+ onClick = {
+ viewModel.isReviewDialogVisible.value = true
+
+ },
+ enabled = questInfoState.selectedDays.isNotEmpty(),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = Icons.Default.Done,
+ contentDescription = "Done"
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = if (editQuestId == null) "Create Quest" else "Save Changes",
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+ }
+ Spacer(Modifier.size(100.dp))
+ }
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/components/SetFocusTime.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/deep_focus/SetFocusTime.kt
similarity index 79%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/components/SetFocusTime.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/deep_focus/SetFocusTime.kt
index e06ef93c..52467b90 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/setup/components/SetFocusTime.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/deep_focus/SetFocusTime.kt
@@ -1,12 +1,23 @@
-package neth.iecal.questphone.ui.screens.quest.setup.components
+package neth.iecal.questphone.app.screens.quest.setup.deep_focus
import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material3.*
-import androidx.compose.runtime.*
-import androidx.compose.ui.*
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ElevatedButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
@@ -14,30 +25,22 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import neth.iecal.questphone.data.quest.focus.FocusTimeConfig
+import nethical.questphone.data.quest.focus.FocusTimeConfig
@Composable
fun SetFocusTimeUI(
- focusTime: MutableState
+ focusTime: FocusTimeConfig, onUpdate: (FocusTimeConfig) -> Unit
) {
-//
-// Text(
-// modifier = Modifier
-// .fillMaxWidth()
-// .padding(top = 16.dp),
-// text = "Duration",
-// style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.Bold),
-// )
TimeInputRow(
label = "Initial Focus Time",
description = "Starting duration for your focus sessions",
- time = focusTime.value.initialTime,
- unit = focusTime.value.initialUnit,
+ time = focusTime.initialTime,
+ unit = focusTime.initialUnit,
onUpdate = { value, unit ->
val initial = convertToMinutes(value, unit)
- val goal = convertToMinutes(focusTime.value.finalTime, focusTime.value.finalUnit)
+ val goal = convertToMinutes(focusTime.finalTime, focusTime.finalUnit)
if (initial in 0..goal) {
- focusTime.value = focusTime.value.copy(initialTime = value, initialUnit = unit)
+ onUpdate(focusTime.copy(initialTime = value, initialUnit = unit))
}
}
)
@@ -45,24 +48,24 @@ fun SetFocusTimeUI(
TimeInputRow(
label = "Increment Daily by",
description = "How much to increase each day",
- time = focusTime.value.incrementTime,
- unit = focusTime.value.incrementUnit,
+ time = focusTime.incrementTime,
+ unit = focusTime.incrementUnit,
availableUnits = listOf("m"),
onUpdate = { value, unit ->
- focusTime.value = focusTime.value.copy(incrementTime = value, incrementUnit = unit)
+ onUpdate(focusTime.copy(incrementTime = value, incrementUnit = unit))
}
)
TimeInputRow(
label = "Goal Focus Time",
description = "Target duration to build up to",
- time = focusTime.value.finalTime,
- unit = focusTime.value.finalUnit,
+ time = focusTime.finalTime,
+ unit = focusTime.finalUnit,
onUpdate = { value, unit ->
- val initial = convertToMinutes(focusTime.value.initialTime, focusTime.value.initialUnit)
+ val initial = convertToMinutes(focusTime.initialTime, focusTime.initialUnit)
val goal = convertToMinutes(value, unit)
if (goal >= initial) {
- focusTime.value = focusTime.value.copy(finalTime = value, finalUnit = unit)
+ onUpdate(focusTime.copy(finalTime = value, finalUnit = unit))
}
}
)
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/health_connect/SetHealthConnect.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/health_connect/SetHealthConnect.kt
new file mode 100644
index 00000000..4a3df8c9
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/health_connect/SetHealthConnect.kt
@@ -0,0 +1,292 @@
+package neth.iecal.questphone.app.screens.quest.setup.health_connect
+
+import android.annotation.SuppressLint
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material.icons.filled.Done
+import androidx.compose.material3.Button
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MenuAnchorType
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavHostController
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import neth.iecal.questphone.app.screens.quest.setup.CommonSetBaseQuest
+import neth.iecal.questphone.app.screens.quest.setup.ReviewDialog
+import neth.iecal.questphone.app.screens.quest.setup.QuestSetupViewModel
+import nethical.questphone.backend.repositories.QuestRepository
+import nethical.questphone.backend.repositories.UserRepository
+import nethical.questphone.data.BaseIntegrationId
+import nethical.questphone.data.json
+import nethical.questphone.data.quest.health.HealthQuest
+import nethical.questphone.data.quest.health.HealthTaskType
+import javax.inject.Inject
+
+@HiltViewModel
+class SetHealthConnectViewModelQuest @Inject constructor (questRepository: QuestRepository,
+ userRepository: UserRepository
+): QuestSetupViewModel(questRepository, userRepository){
+ val healthQuest = MutableStateFlow(HealthQuest())
+
+ fun saveQuest(onSuccess: ()-> Unit){
+ healthQuest.value.nextGoal = healthQuest.value.healthGoalConfig.initial
+ addQuestToDb(json.encodeToString(healthQuest.value)) { onSuccess() }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@SuppressLint("UnrememberedMutableState")
+@Composable
+fun SetHealthConnect(editQuestId:String? = null,navController: NavHostController,viewModel: SetHealthConnectViewModelQuest = hiltViewModel()) {
+ val scrollState = rememberScrollState()
+ val haptic = LocalHapticFeedback.current
+
+ val questInfoState by viewModel.questInfoState.collectAsState()
+ val healthQuest by viewModel.healthQuest.collectAsState()
+ val isReviewDialogVisible by viewModel.isReviewDialogVisible.collectAsState()
+
+
+ LaunchedEffect(Unit) {
+ viewModel.loadQuestData(editQuestId, BaseIntegrationId.HEALTH_CONNECT) {
+ viewModel.healthQuest.value = json.decodeFromString(it.quest_json)
+ }
+ }
+
+ if (isReviewDialogVisible) {
+ val baseQuest = viewModel.getBaseQuestInfo()
+ ReviewDialog(
+ items = listOf(
+ baseQuest, healthQuest
+ ),
+ onConfirm = {
+ viewModel.saveQuest {
+ navController.popBackStack()
+ }
+ },
+ onDismiss = {
+ viewModel.isReviewDialogVisible.value = false
+ }
+ )
+ }
+
+ Scaffold(
+ modifier = Modifier.safeDrawingPadding(),
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 8.dp),
+ text = "Health Connect",
+ style = MaterialTheme.typography.headlineLarge,
+ )
+ }
+ )
+ }
+ ) { paddingValues ->
+ Box(Modifier.padding(paddingValues)) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(scrollState)
+ .padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+
+
+ ) {
+
+ CommonSetBaseQuest(
+ viewModel.userCreatedOn,
+ questInfoState,
+ isTimeRangeSupported = false
+ )
+
+ Text(
+ text = "Health Goal Settings",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ // Task Type Dropdown
+ HealthTaskTypeSelector(
+ selectedType = healthQuest.type,
+ onTypeSelected = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ viewModel.healthQuest.value = healthQuest.copy(type = it)
+ }
+ )
+
+ // Goal Config Inputs
+ GoalConfigInput(
+ label = "Initial Count",
+ value = healthQuest.healthGoalConfig.initial.toString(),
+ onValueChange = {
+ val newValue = it.toIntOrNull() ?: 0
+ viewModel.healthQuest.value = healthQuest.copy(
+ healthGoalConfig = healthQuest.healthGoalConfig.copy(initial = newValue)
+ )
+ },
+ unit = healthQuest.type.unit
+ )
+
+ GoalConfigInput(
+ label = "Increment Daily By",
+ value = healthQuest.healthGoalConfig.increment.toString(),
+ onValueChange = {
+ val newValue = it.toIntOrNull() ?: 0
+ viewModel.healthQuest.value = healthQuest.copy(
+ healthGoalConfig = healthQuest.healthGoalConfig.copy(increment = newValue)
+ )
+ },
+ unit = healthQuest.type.unit
+ )
+ GoalConfigInput(
+ label = "Final Count",
+ value = healthQuest.healthGoalConfig.final.toString(),
+ onValueChange = {
+ val newValue = it.toIntOrNull() ?: 0
+ viewModel.healthQuest.value = healthQuest.copy(
+ healthGoalConfig = healthQuest.healthGoalConfig.copy(final = newValue)
+ )
+ },
+ unit = healthQuest.type.unit
+ )
+
+ Button(
+ enabled = questInfoState.selectedDays.isNotEmpty(),
+ onClick = { viewModel.isReviewDialogVisible.value = true },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = Icons.Default.Done,
+ contentDescription = "Done"
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = if (editQuestId == null) "Create Quest" else "Save Changes",
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+ }
+
+ Spacer(Modifier.size(100.dp))
+
+ }
+
+ }
+ }
+}
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun HealthTaskTypeSelector(
+ selectedType: HealthTaskType,
+ onTypeSelected: (HealthTaskType) -> Unit
+) {
+ var expanded by remember { mutableStateOf(false) }
+
+ ExposedDropdownMenuBox(
+ expanded = expanded,
+ onExpandedChange = { expanded = it },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ OutlinedTextField(
+ value = selectedType.name.lowercase().replaceFirstChar { it.uppercase() },
+ onValueChange = {},
+ readOnly = true,
+ label = { Text("Activity Type") },
+ trailingIcon = {
+ Icon(
+ imageVector = Icons.Default.ArrowDropDown,
+ contentDescription = "Select activity type"
+ )
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .menuAnchor(MenuAnchorType.PrimaryEditable, true),
+ colors = OutlinedTextFieldDefaults.colors(
+ unfocusedBorderColor = MaterialTheme.colorScheme.outline,
+ focusedBorderColor = MaterialTheme.colorScheme.primary
+ )
+ )
+
+ ExposedDropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ modifier = Modifier.exposedDropdownSize()
+ ) {
+ HealthTaskType.entries.forEach { type ->
+ DropdownMenuItem(
+ text = {
+ Text(type.label)
+ },
+ onClick = {
+ onTypeSelected(type)
+ expanded = false
+ }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun GoalConfigInput(
+ label: String,
+ value: String,
+ onValueChange: (String) -> Unit,
+ unit: String
+) {
+ OutlinedTextField(
+ value = value,
+ onValueChange = { if (it.all { char -> char.isDigit() } || it.isEmpty()) onValueChange(it) },
+ label = { Text(label) },
+ trailingIcon = {
+ Text(
+ text = unit,
+ modifier = Modifier.padding(end = 12.dp),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true
+ )
+}
+
diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/swift_mark/SetSwiftMark.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/swift_mark/SetSwiftMark.kt
new file mode 100644
index 00000000..25e43aeb
--- /dev/null
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/setup/swift_mark/SetSwiftMark.kt
@@ -0,0 +1,128 @@
+package neth.iecal.questphone.app.screens.quest.setup.swift_mark
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Done
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavHostController
+import dagger.hilt.android.lifecycle.HiltViewModel
+import neth.iecal.questphone.app.screens.quest.setup.CommonSetBaseQuest
+import neth.iecal.questphone.app.screens.quest.setup.ReviewDialog
+import neth.iecal.questphone.app.screens.quest.setup.QuestSetupViewModel
+import nethical.questphone.backend.repositories.QuestRepository
+import nethical.questphone.backend.repositories.UserRepository
+import nethical.questphone.data.BaseIntegrationId
+import javax.inject.Inject
+
+@HiltViewModel
+class SetSwiftMarkViewModelQuest @Inject constructor (questRepository: QuestRepository,
+ userRepository: UserRepository
+): QuestSetupViewModel(questRepository, userRepository)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SetSwiftMark(editQuestId:String? = null,navController: NavHostController, viewModel: SetSwiftMarkViewModelQuest = hiltViewModel()) {
+ val questInfoState by viewModel.questInfoState.collectAsState()
+
+ val scrollState = rememberScrollState()
+ val isReviewDialogVisible by viewModel.isReviewDialogVisible.collectAsState()
+
+ LaunchedEffect(Unit) {
+ viewModel.loadQuestData(editQuestId, BaseIntegrationId.SWIFT_MARK)
+ }
+
+ if (isReviewDialogVisible) {
+ val baseQuest = viewModel.getBaseQuestInfo()
+ ReviewDialog(
+ items = listOf(
+ baseQuest
+ ),
+ onConfirm = {
+ viewModel.addQuestToDb("",1) {
+ navController.popBackStack()
+ }
+ },
+ onDismiss = {
+ viewModel.isReviewDialogVisible.value = false
+ }
+ )
+ }
+
+ Scaffold(
+ modifier = Modifier.safeDrawingPadding(),
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 8.dp),
+ text = "Swift Quest",
+ style = MaterialTheme.typography.headlineLarge,
+ )
+ }
+ )
+ }
+ )
+ { paddingValues ->
+
+ Box(Modifier.padding(paddingValues)) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(scrollState)
+ .padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+
+ CommonSetBaseQuest(viewModel.userCreatedOn,questInfoState)
+ Button(
+ enabled = questInfoState.selectedDays.isNotEmpty(),
+ onClick = {
+ viewModel.isReviewDialogVisible.value = true
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = Icons.Default.Done,
+ contentDescription = "Done"
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = if (editQuestId == null) "Create Quest" else "Save Changes",
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+ }
+ Spacer(Modifier.size(100.dp))
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/stats/components/HeatMapChart.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/stats/components/HeatMapChart.kt
similarity index 71%
rename from app/src/main/java/neth/iecal/questphone/ui/screens/quest/stats/components/HeatMapChart.kt
rename to app/src/main/java/neth/iecal/questphone/app/screens/quest/stats/components/HeatMapChart.kt
index 831232f0..8ffdbcce 100644
--- a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/stats/components/HeatMapChart.kt
+++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/stats/components/HeatMapChart.kt
@@ -1,4 +1,4 @@
-package neth.iecal.questphone.ui.screens.quest.stats.components
+package neth.iecal.questphone.app.screens.quest.stats.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -19,19 +19,27 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.toLowerCase
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
@@ -41,7 +49,9 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.todayIn
-import neth.iecal.questphone.data.quest.QuestDatabaseProvider
+import nethical.questphone.backend.repositories.QuestRepository
+import nethical.questphone.backend.repositories.StatsRepository
+import javax.inject.Inject
// --- Data class to hold daily quest information ---
data class DailyQuestInfo(
@@ -57,12 +67,48 @@ private val MONTH_SPACING = 8.dp // Space between months in the grid
private val DAY_LABEL_WIDTH = 32.dp
private val MONTH_LABEL_HEIGHT = 20.dp
+@HiltViewModel
+class HeatMapChartVM @Inject constructor(
+ val questRepository: QuestRepository,
+ val statsRepository: StatsRepository
+): ViewModel() {
+ var successfulDates: MutableStateFlow