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>> = MutableStateFlow>>(emptyMap()) + var allQuestTitles = MutableStateFlow>(emptyMap()) + + + init { + viewModelScope.launch(Dispatchers.IO) { + loadStats() + loadAllQuestTitles() + } + } + + suspend fun loadStats() { + val allStats = statsRepository.getAllStatsForUser().first().toMutableList() + + val result = mutableMapOf>() + for (stat in allStats) { + val list = result.getOrPut(stat.date) { mutableListOf() } + list.add(stat.quest_id) + } + successfulDates.value = result + } + + + suspend fun loadAllQuestTitles(){ + allQuestTitles.value = questRepository.getAllQuests().first().associate { it.id to it.title } + } +} + @Composable fun HeatMapChart( - questMap: Map>, // Using kotlinx.datetime.LocalDate - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + viewModel: HeatMapChartVM = hiltViewModel() ) { - if (questMap.isEmpty()) { + val statsMap by viewModel.successfulDates.collectAsState() + val allQuestTitles by viewModel.allQuestTitles.collectAsState() + + if (statsMap.isEmpty()) { Text("No Data available.", modifier = modifier.padding(16.dp)) return } @@ -70,11 +116,11 @@ fun HeatMapChart( val currentSystemTimeZone = remember { TimeZone.currentSystemDefault() } // Determine the date range and pad with empty days - val dateRange = remember(questMap) { + val dateRange = remember(statsMap) { val today = Clock.System.todayIn(currentSystemTimeZone) // The start date is still calculated from the first data point or today. - val minDate = questMap.keys.minOrNull() ?: today + val minDate = statsMap.keys.minOrNull() ?: today val daysFromMondayStart = minDate.dayOfWeek.value - DayOfWeek.MONDAY.value val startDate = minDate.minus(daysFromMondayStart, DateTimeUnit.DAY) @@ -89,12 +135,12 @@ fun HeatMapChart( startDate to endDate } - val allDaysData = remember(dateRange, questMap) { + val allDaysData = remember(dateRange, statsMap) { val (startDate, endDate) = dateRange val days = mutableListOf() var currentDate = startDate while (currentDate <= endDate) { - val questsForDate = questMap[currentDate] ?: emptyList() + val questsForDate = statsMap[currentDate] ?: emptyList() days.add( DailyQuestInfo(date = currentDate, quests = questsForDate) ) @@ -140,11 +186,16 @@ fun HeatMapChart( ) } } + if(selectedDayInfo.value!=null) { + QuestTooltip( + dailyInfo = selectedDayInfo.value!!, + questTitleList = allQuestTitles, + onDismiss = { + selectedDayInfo.value = null + } + ) - QuestTooltip( - dailyInfo = selectedDayInfo - ) - + } Spacer(modifier = Modifier.height(8.dp)) ContributionLegend(modifier = Modifier.padding(horizontal = 16.dp)) @@ -327,71 +378,65 @@ fun ContributionLegend(modifier: Modifier = Modifier) { @Composable -fun QuestTooltip(dailyInfo: MutableState) { - val context = LocalContext.current - val questList = remember { mutableMapOf()} - - LaunchedEffect(dailyInfo.value) { - if(dailyInfo.value!=null){ - val dao = QuestDatabaseProvider.getInstance(context).questDao() - dailyInfo.value!!.quests.forEach { - questList[it] = dao.getQuestById(it)?.title ?: it +fun QuestTooltip(dailyInfo: DailyQuestInfo, onDismiss: ()-> Unit, questTitleList: Map) { + val filteredQuestTitles = remember { mutableStateOf(mapOf()) } + + LaunchedEffect(Unit) { + val tempMap = mutableMapOf() + dailyInfo.quests.forEach { + tempMap[it] = questTitleList[it].toString() } + filteredQuestTitles.value = tempMap } - } - - if (dailyInfo.value != null) { - - Dialog(onDismissRequest = { - dailyInfo.value = null - }) { - Card( - modifier = Modifier.padding(8.dp) + Dialog(onDismissRequest = { + onDismiss() + }) { + Card( + modifier = Modifier.padding(8.dp) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - // Format date string manually - val dayName = - dailyInfo.value!!.date.dayOfWeek.name.toLowerCase(Locale.current) - .replaceFirstChar { it.titlecase() } - val monthName = - dailyInfo.value!!.date.month.name.toLowerCase(Locale.current) - .replaceFirstChar { it.titlecase() } - val dateText = - "$dayName, $monthName ${dailyInfo.value!!.date.dayOfMonth}, ${dailyInfo.value!!.date.year}" - - val questCount = dailyInfo.value!!.quests.size + // Format date string manually + val dayName = + dailyInfo.date.dayOfWeek.name.toLowerCase(Locale.current) + .replaceFirstChar { it.titlecase() } + val monthName = + dailyInfo.date.month.name.toLowerCase(Locale.current) + .replaceFirstChar { it.titlecase() } + val dateText = + "$dayName, $monthName ${dailyInfo.date.dayOfMonth}, ${dailyInfo.date.year}" + + val questCount = dailyInfo.quests.size - Text( - text = dateText, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold - ) + Text( + text = dateText, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + + val questText = if (questCount == 0) { + "No quests attempted." + } else { + "$questCount ${if (questCount == 1) "Quest" else "Quests"} Attempted" + } - val questText = if (questCount == 0) { - "No quests attempted." - } else { - "$questCount ${if (questCount == 1) "Quest" else "Quests"} Attempted" - } + Text( + text = questText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + filteredQuestTitles.value.forEach { questName -> Text( - text = questText, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "• ${questName.value}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) ) - - questList.forEach { questName -> - Text( - text = "• ${questName.value}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) - ) - } } } } - } -} \ No newline at end of file +} + diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/stats/specific/BaseQuestStatsView.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/stats/specific/BaseQuestStatsView.kt similarity index 71% rename from app/src/main/java/neth/iecal/questphone/ui/screens/quest/stats/specific/BaseQuestStatsView.kt rename to app/src/main/java/neth/iecal/questphone/app/screens/quest/stats/specific/BaseQuestStatsView.kt index 519f69ff..b2b00666 100644 --- a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/stats/specific/BaseQuestStatsView.kt +++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/stats/specific/BaseQuestStatsView.kt @@ -1,5 +1,6 @@ -package neth.iecal.questphone.ui.screens.quest.stats.specific +package neth.iecal.questphone.app.screens.quest.stats.specific +// BaseQuestStatsViewVM.kt import android.content.ClipData import android.content.ClipboardManager import android.content.Context @@ -43,15 +44,10 @@ 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.getValue -import androidx.compose.runtime.mutableDoubleStateOf -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf -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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -62,7 +58,14 @@ 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 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.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.datetime.Clock @@ -73,76 +76,225 @@ import kotlinx.datetime.plus import kotlinx.datetime.toKotlinLocalDate import kotlinx.datetime.toLocalDateTime import neth.iecal.questphone.R -import neth.iecal.questphone.data.game.InventoryItem -import neth.iecal.questphone.data.game.User -import neth.iecal.questphone.data.game.getInventoryItemCount -import neth.iecal.questphone.data.game.useInventoryItem -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.data.quest.stats.StatsInfo -import neth.iecal.questphone.utils.daysSince -import neth.iecal.questphone.utils.formatHour -import neth.iecal.questphone.utils.getStartOfWeek -import neth.iecal.questphone.utils.json -import neth.iecal.questphone.utils.toJavaDayOfWeek +import nethical.questphone.backend.CommonQuestInfo +import nethical.questphone.backend.StatsInfo +import nethical.questphone.backend.repositories.QuestRepository +import nethical.questphone.backend.repositories.StatsRepository +import nethical.questphone.backend.repositories.UserRepository +import nethical.questphone.core.core.utils.daysSince +import nethical.questphone.core.core.utils.formatHour +import nethical.questphone.core.core.utils.getStartOfWeek +import nethical.questphone.core.core.utils.toJavaDayOfWeek +import nethical.questphone.data.game.InventoryItem +import nethical.questphone.data.json import java.time.DayOfWeek import java.time.LocalDate import java.time.YearMonth import java.time.format.TextStyle import java.util.Locale +import javax.inject.Inject -@Composable -fun BaseQuestStatsView(id: String, navController: NavHostController) { - val context = LocalContext.current - var successfulDates = remember { mutableStateListOf() } - - /* - Total Amount of quests(including failed and successful) that could be performed since the creation of the quest - */ - var totalPerformableQuests by remember { mutableIntStateOf(0) } - var totalSuccessfulQuests by remember { mutableIntStateOf(0) } - var totalFailedQuests by remember { mutableIntStateOf(0) } - var currentStreak by remember { mutableIntStateOf(0) } - var longestStreak by remember { mutableIntStateOf(0) } - var failureRate by remember { mutableFloatStateOf(0f) } - var successRate by remember { mutableFloatStateOf(0f) } - var totalCoins by remember { mutableIntStateOf(0) } - var weeklyAverageCompletions by remember { mutableDoubleStateOf(0.0) } - - var baseData by remember { mutableStateOf(CommonQuestInfo()) } - LaunchedEffect(Unit) { - val bdao = QuestDatabaseProvider.getInstance(context).questDao() - baseData = bdao.getQuestById(id)!! - - val dao = StatsDatabaseProvider.getInstance(context).statsDao() - - var stats = dao.getStatsByQuestId(baseData.id).first() - - successfulDates.addAll(stats.map { it.date }) - val allowedDays = baseData.selected_days.map { it.toJavaDayOfWeek() }.toSet() - totalPerformableQuests = daysSince(baseData.created_on, allowedDays) - totalSuccessfulQuests = stats.size - totalFailedQuests = totalPerformableQuests - totalFailedQuests - currentStreak = calculateCurrentStreak(stats,allowedDays) - longestStreak = calculateLongestStreak(stats,allowedDays) - failureRate = if (totalPerformableQuests > 0) (totalFailedQuests.toFloat() / totalPerformableQuests) * 100 else 0f - successRate = if (totalPerformableQuests > 0) (totalSuccessfulQuests.toFloat() / totalPerformableQuests) * 100 else 0f - totalCoins = totalSuccessfulQuests * baseData.reward - weeklyAverageCompletions = weeklyAverage(stats) +@HiltViewModel +class BaseQuestStatsViewVM @Inject constructor( + private val userRepository: UserRepository, + private val questRepository: QuestRepository, + private val statsRepository: StatsRepository +) : ViewModel() { + + // Loading state + private val _isLoading = MutableStateFlow(true) + val isLoading: StateFlow = _isLoading.asStateFlow() + + // Quest data + private val _baseData = MutableStateFlow(CommonQuestInfo()) + val baseData: StateFlow = _baseData.asStateFlow() + + private val _successfulDates = MutableStateFlow>(emptyList()) + val successfulDates: StateFlow> = _successfulDates.asStateFlow() + + // Quest statistics + private val _totalPerformableQuests = MutableStateFlow(0) + val totalPerformableQuests: StateFlow = _totalPerformableQuests.asStateFlow() + + private val _totalSuccessfulQuests = MutableStateFlow(0) + val totalSuccessfulQuests: StateFlow = _totalSuccessfulQuests.asStateFlow() + + private val _totalFailedQuests = MutableStateFlow(0) + val totalFailedQuests: StateFlow = _totalFailedQuests.asStateFlow() + + private val _currentStreak = MutableStateFlow(0) + val currentStreak: StateFlow = _currentStreak.asStateFlow() + + private val _longestStreak = MutableStateFlow(0) + val longestStreak: StateFlow = _longestStreak.asStateFlow() + + private val _failureRate = MutableStateFlow(0f) + val failureRate: StateFlow = _failureRate.asStateFlow() + + private val _successRate = MutableStateFlow(0f) + val successRate: StateFlow = _successRate.asStateFlow() + + private val _totalCoins = MutableStateFlow(0) + val totalCoins: StateFlow = _totalCoins.asStateFlow() + + private val _weeklyAverageCompletions = MutableStateFlow(0.0) + val weeklyAverageCompletions: StateFlow = _weeklyAverageCompletions.asStateFlow() + + // Dialog states + private val _showCalendarDialog = MutableStateFlow(false) + val showCalendarDialog: StateFlow = _showCalendarDialog.asStateFlow() + + private val _selectedDate = MutableStateFlow(null) + val selectedDate: StateFlow = _selectedDate.asStateFlow() + + private val _currentYearMonth = MutableStateFlow(YearMonth.now()) + val currentYearMonth: StateFlow = _currentYearMonth.asStateFlow() + + private val _isQuestEditorInfoDialogVisible = MutableStateFlow(false) + val isQuestEditorInfoDialogVisible: StateFlow = _isQuestEditorInfoDialogVisible.asStateFlow() + + private val _isQuestDeleterInfoDialogVisible = MutableStateFlow(false) + val isQuestDeleterInfoDialogVisible: StateFlow = _isQuestDeleterInfoDialogVisible.asStateFlow() + + fun loadQuestStats(questId: String) { + viewModelScope.launch { + try { + _isLoading.value = true + + val baseData = questRepository.getQuestById(questId) ?: return@launch + _baseData.value = baseData + + val stats = statsRepository.getStatsByQuestId(baseData.id).first() + + val successfulDates = stats.map { it.date } + _successfulDates.value = successfulDates + + val allowedDays = baseData.selected_days.map { it.toJavaDayOfWeek() }.toSet() + val totalPerformableQuests = daysSince(baseData.created_on, allowedDays) + val totalSuccessfulQuests = stats.size + val totalFailedQuests = totalPerformableQuests - totalSuccessfulQuests + val currentStreak = calculateCurrentStreak(stats, allowedDays) + val longestStreak = calculateLongestStreak(stats, allowedDays) + val failureRate = if (totalPerformableQuests > 0) + (totalFailedQuests.toFloat() / totalPerformableQuests) * 100 else 0f + val successRate = if (totalPerformableQuests > 0) + (totalSuccessfulQuests.toFloat() / totalPerformableQuests) * 100 else 0f + val totalCoins = totalSuccessfulQuests * baseData.reward + val weeklyAverageCompletions = weeklyAverage(stats) + + _totalPerformableQuests.value = totalPerformableQuests + _totalSuccessfulQuests.value = totalSuccessfulQuests + _totalFailedQuests.value = totalFailedQuests + _currentStreak.value = currentStreak + _longestStreak.value = longestStreak + _failureRate.value = failureRate + _successRate.value = successRate + _totalCoins.value = totalCoins + _weeklyAverageCompletions.value = weeklyAverageCompletions + + } catch (e: Exception) { + // Handle error + } finally { + _isLoading.value = false + } + } + } + + fun showCalendarDialog() { + _showCalendarDialog.value = true + } + + fun hideCalendarDialog() { + _showCalendarDialog.value = false + } + + fun updateCurrentYearMonth(yearMonth: YearMonth) { + _currentYearMonth.value = yearMonth + } + + fun updateSelectedDate(date: LocalDate?) { + _selectedDate.value = date + } + + fun showQuestEditorDialog() { + _isQuestEditorInfoDialogVisible.value = true + } + + fun hideQuestEditorDialog() { + _isQuestEditorInfoDialogVisible.value = false + } + + fun showQuestDeleterDialog() { + _isQuestDeleterInfoDialogVisible.value = true } + fun hideQuestDeleterDialog() { + _isQuestDeleterInfoDialogVisible.value = false + } - var showCalendarDialog by remember { mutableStateOf(false) } - var selectedDate by remember { mutableStateOf(null) } + fun deductFromInventory(item: InventoryItem){ + userRepository.deductFromInventory(item) + } + fun doesUserHaveItem(item: InventoryItem): Boolean { + return userRepository.getInventoryItemCount(item)>0 + } - val currentYearMonth = remember { mutableStateOf(YearMonth.now()) } + fun deleteQuest(onSuccess: () -> Unit) { + viewModelScope.launch { + try { + val quest = questRepository.getQuest(_baseData.value.title) + quest?.let { + it.is_destroyed = true + questRepository.upsertQuest(it) + onSuccess() + } + } catch (e: Exception) { + // Handle error + } + } + } +} +// Updated Composable +@Composable +fun BaseQuestStatsView( + id: String, + navController: NavHostController, + viewModel: BaseQuestStatsViewVM = hiltViewModel() +) { val scrollState = rememberScrollState() - val isQuestEditorInfoDialogVisible = remember { mutableStateOf(false) } - val isQuestDeleterInfoDialogVisible = remember { mutableStateOf(false) } - val coroutineScope = rememberCoroutineScope() + // Collect states + val isLoading by viewModel.isLoading.collectAsState() + val baseData by viewModel.baseData.collectAsState() + val successfulDates by viewModel.successfulDates.collectAsState() + val totalPerformableQuests by viewModel.totalPerformableQuests.collectAsState() + val totalSuccessfulQuests by viewModel.totalSuccessfulQuests.collectAsState() + val currentStreak by viewModel.currentStreak.collectAsState() + val longestStreak by viewModel.longestStreak.collectAsState() + val successRate by viewModel.successRate.collectAsState() + val weeklyAverageCompletions by viewModel.weeklyAverageCompletions.collectAsState() + val totalCoins by viewModel.totalCoins.collectAsState() + val showCalendarDialog by viewModel.showCalendarDialog.collectAsState() + val currentYearMonth by viewModel.currentYearMonth.collectAsState() + val isQuestEditorInfoDialogVisible by viewModel.isQuestEditorInfoDialogVisible.collectAsState() + val isQuestDeleterInfoDialogVisible by viewModel.isQuestDeleterInfoDialogVisible.collectAsState() + + LaunchedEffect(Unit) { + viewModel.loadQuestStats(id) + } + + if (isLoading) { + // Show loading state + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + return + } + Scaffold( containerColor = MaterialTheme.colorScheme.background ) { padding -> @@ -169,41 +321,55 @@ fun BaseQuestStatsView(id: String, navController: NavHostController) { // Calendar preview showing last month's completions CalendarSection( questStats = successfulDates.toSet(), - onShowFullCalendar = { showCalendarDialog = true } + onShowFullCalendar = { viewModel.showCalendarDialog() } ) // Quest Details - QuestDetailsCard(baseData,isQuestEditorInfoDialogVisible,isQuestDeleterInfoDialogVisible) + QuestDetailsCard( + baseData, + { viewModel.showQuestEditorDialog() }, + { viewModel.showQuestDeleterDialog() } + ) - UseItemDialog(InventoryItem.QUEST_EDITOR,isQuestEditorInfoDialogVisible){ - navController.navigate(baseData.integration_id.name + "/${baseData.title}") + if (isQuestEditorInfoDialogVisible) { + UseItemDialog( + InventoryItem.QUEST_EDITOR, + viewModel.doesUserHaveItem(InventoryItem.QUEST_EDITOR), + { + viewModel.hideQuestEditorDialog() + } + ) { + viewModel.deductFromInventory(InventoryItem.QUEST_EDITOR) + navController.navigate(baseData.integration_id.name + "/${baseData.id}") + } } - UseItemDialog(InventoryItem.QUEST_DELETER,isQuestDeleterInfoDialogVisible){ - val dao = QuestDatabaseProvider.getInstance(context).questDao() - coroutineScope.launch { - val quest = dao.getQuest(baseData.title) - quest!!.is_destroyed = true - dao.upsertQuest(quest) + if (isQuestDeleterInfoDialogVisible) { + UseItemDialog( + InventoryItem.QUEST_DELETER, + viewModel.doesUserHaveItem(InventoryItem.QUEST_DELETER), + { + viewModel.hideQuestDeleterDialog() + } + ) { + viewModel.deductFromInventory(InventoryItem.QUEST_DELETER) + viewModel.deleteQuest { + navController.popBackStack() + } } - navController.popBackStack() } - } // Calendar Dialog if (showCalendarDialog) { CalendarDialog( successfulDates = successfulDates.toSet(), - currentYearMonth = currentYearMonth, - onDismiss = { showCalendarDialog = false } + currentYearMonth = remember { mutableStateOf(currentYearMonth) }, + onDismiss = { viewModel.hideCalendarDialog() } ) } - - } } - @Composable fun QuestHeader(baseData: CommonQuestInfo, currentStreak: Int) { Card( @@ -515,7 +681,7 @@ fun CalendarSection( @Composable -fun QuestDetailsCard(baseData: CommonQuestInfo, isQuestEditorInfoDialogVisible: MutableState, isQuestDeleterInfoDialogVisible: MutableState) { +fun QuestDetailsCard(baseData: CommonQuestInfo, onQuestEditorClicked: () -> Unit, onQuestDeleterClicked: ()-> Unit) { val context = LocalContext.current Card( modifier = Modifier.fillMaxWidth(), @@ -553,7 +719,7 @@ fun QuestDetailsCard(baseData: CommonQuestInfo, isQuestEditorInfoDialogVisible: if(!baseData.is_destroyed) { OutlinedButton(onClick = { - isQuestEditorInfoDialogVisible.value = true + onQuestEditorClicked() }, modifier = Modifier.fillMaxWidth()) { Row( @@ -561,7 +727,7 @@ fun QuestDetailsCard(baseData: CommonQuestInfo, isQuestEditorInfoDialogVisible: horizontalArrangement = Arrangement.Center ) { Image( - painter = painterResource(R.drawable.quest_editor), + painter = painterResource(nethical.questphone.data.R.drawable.quest_editor), contentDescription = "quest_editor", modifier = Modifier .size(25.dp) @@ -580,7 +746,7 @@ fun QuestDetailsCard(baseData: CommonQuestInfo, isQuestEditorInfoDialogVisible: Spacer(Modifier.size(4.dp)) OutlinedButton(onClick = { - isQuestDeleterInfoDialogVisible.value = true + onQuestDeleterClicked() }, modifier = Modifier.fillMaxWidth()) { Row( @@ -588,7 +754,7 @@ fun QuestDetailsCard(baseData: CommonQuestInfo, isQuestEditorInfoDialogVisible: horizontalArrangement = Arrangement.Center ) { Image( - painter = painterResource(R.drawable.quest_deletor), + painter = painterResource(nethical.questphone.data.R.drawable.quest_deletor), contentDescription = "quest_editor", modifier = Modifier .size(25.dp) @@ -756,7 +922,10 @@ fun CalendarDialog( .clip(CircleShape) .background( when { - isSuccessful -> MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) + isSuccessful -> MaterialTheme.colorScheme.primary.copy( + alpha = 0.8f + ) + isToday -> MaterialTheme.colorScheme.surfaceVariant else -> Color.Transparent } @@ -778,7 +947,9 @@ fun CalendarDialog( ) } } else { - Box(modifier = Modifier.weight(1f).aspectRatio(1f)) + Box(modifier = Modifier + .weight(1f) + .aspectRatio(1f)) } } } @@ -839,57 +1010,51 @@ fun LegendItem(color: Color? = null, borderColor: Color? = null, text: String) { } @Composable -fun UseItemDialog(item: InventoryItem, isDialogVisible: MutableState, onUse: ()-> Unit = {}){ - if(isDialogVisible.value){ - Dialog(onDismissRequest = { - isDialogVisible.value = false - }) { - val doesUserOwnEditor = User.getInventoryItemCount(item)>0 - Surface( - shape = MaterialTheme.shapes.medium, - tonalElevation = 8.dp, - modifier = Modifier - .padding(24.dp) - .wrapContentSize() +private fun UseItemDialog(item: InventoryItem,doesUserOwnEditor:Boolean, onDismiss: ()-> Unit, onUse: ()-> Unit = {}){ + Dialog(onDismissRequest = onDismiss) { + 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(item.icon), + contentDescription = InventoryItem.QUEST_EDITOR.simpleName, + modifier = Modifier.size(60.dp) + ) + if (doesUserOwnEditor) { + Text("Do You Want to Spend 1 ${item.simpleName} to perform this action?") + } else { + Text("You currently have no ${item.simpleName}. Please buy one from the shop to edit this quest") + } + + Spacer(modifier = Modifier.height(8.dp)) - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End ) { - Image( - painter = painterResource(item.icon), - contentDescription = InventoryItem.QUEST_EDITOR.simpleName, - modifier = Modifier.size(60.dp) - ) - if (doesUserOwnEditor) { - Text("Do You Want to Spend 1 ${item.simpleName} to perform this action?") - } else { - Text("You currently have no ${item.simpleName}. Please buy one from the shop to edit this quest") + TextButton(onClick = onDismiss) { + Text("Close") } - Spacer(modifier = Modifier.height(8.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton(onClick = { isDialogVisible.value = false }) { - Text("Close") - } - - if(doesUserOwnEditor){ - Button(onClick = { - User.useInventoryItem(item) - onUse() - isDialogVisible.value = false - }) { - Text("Use") - } + if(doesUserOwnEditor){ + Button(onClick = { + onUse() + onDismiss() + }) { + Text("Use") } } - } + } } } diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/templates/SelectFromTemplates.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/templates/SelectFromTemplates.kt similarity index 80% rename from app/src/main/java/neth/iecal/questphone/ui/screens/quest/templates/SelectFromTemplates.kt rename to app/src/main/java/neth/iecal/questphone/app/screens/quest/templates/SelectFromTemplates.kt index 5dfa60c8..4473e06f 100644 --- a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/templates/SelectFromTemplates.kt +++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/templates/SelectFromTemplates.kt @@ -1,6 +1,5 @@ -package neth.iecal.questphone.ui.screens.quest.templates +package neth.iecal.questphone.app.screens.quest.templates -import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -42,12 +41,10 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf +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.graphics.Color @@ -58,74 +55,26 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.graphics.toColorInt import androidx.navigation.NavController -import kotlinx.serialization.Serializable -import neth.iecal.questphone.data.IntegrationId -import neth.iecal.questphone.data.game.User -import neth.iecal.questphone.ui.navigation.Screen -import neth.iecal.questphone.utils.fetchUrlContent -import neth.iecal.questphone.utils.json +import neth.iecal.questphone.app.navigation.RootRoute +import neth.iecal.questphone.data.Template -@Serializable -data class Activity( - val name: String, - val description: String, - val requirements: String, - val color: String, - val integration: IntegrationId, - val category: String, - val id: String -) @OptIn(ExperimentalMaterial3Api::class) @Composable fun SelectFromTemplates( - navController: NavController + navController: NavController, + viewModel: TemplatesViewModel ) { - var response by remember { mutableStateOf("") } - var isLoading by remember { mutableStateOf(true) } - var activities by remember { mutableStateOf(emptyList()) } - var searchQuery by remember { mutableStateOf("") } - var selectedCategory by remember { mutableStateOf(null) } - val keyboardController = LocalSoftwareKeyboardController.current - - // Get unique categories - val categories by remember(activities) { - derivedStateOf { - activities.map { it.category }.distinct().sorted() - } - } - - // Filter activities based on search and category - val filteredActivities by remember(activities, searchQuery, selectedCategory) { - derivedStateOf { - activities.filter { activity -> - val matchesSearch = searchQuery.isBlank() || - activity.name.contains(searchQuery, ignoreCase = true) || - activity.description.contains(searchQuery, ignoreCase = true) || - activity.category.contains(searchQuery, ignoreCase = true) - - val matchesCategory = - selectedCategory == null || activity.category == selectedCategory - - matchesSearch && matchesCategory - } - } - } - - LaunchedEffect(response) { - if(response.isNotEmpty()){ - activities = parseActivitiesJson(response) - } - Log.d("response", response) - } + val activities by viewModel.template.collectAsState() + val filteredTemplates by viewModel.filteredActivities.collectAsState() + val searchQuery by viewModel.searchQuery.collectAsState() + val selectedCategory by viewModel.selectedCategory.collectAsState() + val categories by viewModel.categories.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + val showLoginRequiredDialog by viewModel.showLoginDialog.collectAsState() - LaunchedEffect(Unit) { - response = - fetchUrlContent("https://raw.githubusercontent.com/QuestPhone/quest-templates/refs/heads/main/all.json") - ?: "" - isLoading = false - } + val keyboardController = LocalSoftwareKeyboardController.current Scaffold( topBar = { @@ -180,7 +129,7 @@ fun SelectFromTemplates( // Custom Quest Card CustomQuestCard( onClick = { - navController.navigate(Screen.AddNewQuest.route) { + navController.navigate(RootRoute.AddNewQuest.route) { popUpTo(navController.currentDestination?.route ?: "") { inclusive = true } @@ -192,7 +141,7 @@ fun SelectFromTemplates( // Search Bar OutlinedTextField( value = searchQuery, - onValueChange = { searchQuery = it }, + onValueChange = { viewModel.setSearchQuery(it) }, modifier = Modifier.fillMaxWidth(), placeholder = { Text( @@ -211,7 +160,7 @@ fun SelectFromTemplates( if (searchQuery.isNotEmpty()) { IconButton( onClick = { - searchQuery = "" + viewModel.setSearchQuery("") keyboardController?.hide() } ) { @@ -243,7 +192,7 @@ fun SelectFromTemplates( ) { item { FilterChip( - onClick = { selectedCategory = null }, + onClick = { viewModel.setSelectedCategory(null) }, label = { Text("All") }, selected = selectedCategory == null, colors = FilterChipDefaults.filterChipColors( @@ -256,8 +205,7 @@ fun SelectFromTemplates( items(categories) { category -> FilterChip( onClick = { - selectedCategory = - if (selectedCategory == category) null else category + viewModel.setSelectedCategory(if (selectedCategory == category) null else category) }, label = { Text(category) }, selected = selectedCategory == category, @@ -274,28 +222,29 @@ fun SelectFromTemplates( // Results count Text( - text = "${filteredActivities.size} templates found", + text = "${filteredTemplates.size} templates found", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } - items(filteredActivities) { activity -> + items(filteredTemplates) { template -> ActivityCard( - activity = activity, + template = template, modifier = Modifier.padding(horizontal = 16.dp), onClick = { - if(activity.integration.isLoginRequired && User.userInfo.isAnonymous){ + if(template.integration.isLoginRequired && viewModel.isAnonymous){ showLoginRequiredDialog.value = true }else { - navController.navigate(Screen.SetupTemplate.route + activity.id) + viewModel.selectTemplate(template) + navController.navigate(RootRoute.SetupTemplate.route) } } ) } - if (filteredActivities.isEmpty() && !isLoading) { + if (filteredTemplates.isEmpty() && !isLoading) { item { Box( modifier = Modifier @@ -395,7 +344,7 @@ private fun CustomQuestCard( @Composable private fun ActivityCard( - activity: Activity, + template: Template, onClick: () -> Unit, modifier: Modifier = Modifier ) { @@ -416,7 +365,7 @@ private fun ActivityCard( .size(12.dp) .background( color = try { - Color(activity.color.toColorInt()) + Color(template.color.toColorInt()) } catch (e: Exception) { MaterialTheme.colorScheme.primary }, @@ -430,7 +379,7 @@ private fun ActivityCard( modifier = Modifier.weight(1f) ) { Text( - text = activity.name, + text = template.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, @@ -439,7 +388,7 @@ private fun ActivityCard( ) Text( - text = activity.description, + text = template.description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp), @@ -447,9 +396,9 @@ private fun ActivityCard( overflow = TextOverflow.Ellipsis ) - if (activity.requirements != "none" && activity.requirements.isNotBlank()) { + if (template.requirements != "none" && template.requirements.isNotBlank()) { Text( - text = "Requirements: ${activity.requirements}", + text = "Requirements: ${template.requirements}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp), @@ -463,7 +412,7 @@ private fun ActivityCard( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - text = activity.category, + text = template.category, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier @@ -475,7 +424,7 @@ private fun ActivityCard( ) Text( - text = activity.integration.label, + text = template.integration.label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onTertiaryContainer, modifier = Modifier @@ -491,11 +440,3 @@ private fun ActivityCard( } } -private fun parseActivitiesJson(jsonString: String): List{ - return try { - json.decodeFromString>(jsonString) - } catch (e: Exception) { - Log.e("SelectFromTemplates", "Failed to parse activities JSON", e) - emptyList() - } -} \ No newline at end of file diff --git a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/templates/SetupTemplate.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/templates/SetupTemplate.kt similarity index 79% rename from app/src/main/java/neth/iecal/questphone/ui/screens/quest/templates/SetupTemplate.kt rename to app/src/main/java/neth/iecal/questphone/app/screens/quest/templates/SetupTemplate.kt index e0b66512..4e1a67af 100644 --- a/app/src/main/java/neth/iecal/questphone/ui/screens/quest/templates/SetupTemplate.kt +++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/templates/SetupTemplate.kt @@ -1,6 +1,6 @@ @file:OptIn(ExperimentalMaterial3Api::class) -package neth.iecal.questphone.ui.screens.quest.templates +package neth.iecal.questphone.app.screens.quest.templates import android.annotation.SuppressLint import android.util.Log @@ -46,13 +46,11 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf +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.ui.Alignment import androidx.compose.ui.Modifier @@ -69,79 +67,31 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.navigation.NavController -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import neth.iecal.questphone.R -import neth.iecal.questphone.data.DayOfWeek -import neth.iecal.questphone.data.IntegrationId -import neth.iecal.questphone.data.TemplateData +import neth.iecal.questphone.app.screens.quest.setup.ai_snap.model.ModelDownloadDialog +import neth.iecal.questphone.app.screens.quest.setup.components.DateSelector +import neth.iecal.questphone.app.screens.quest.setup.components.TimeRangeDialog +import neth.iecal.questphone.app.screens.quest.setup.deep_focus.SelectAppsDialog import neth.iecal.questphone.data.TemplateVariable -import neth.iecal.questphone.data.VariableName import neth.iecal.questphone.data.VariableType -import neth.iecal.questphone.data.convertToTemplate -import neth.iecal.questphone.data.game.User -import neth.iecal.questphone.data.quest.QuestDatabaseProvider -import neth.iecal.questphone.ui.screens.quest.setup.ai_snap.model.ModelDownloadDialog -import neth.iecal.questphone.ui.screens.quest.setup.components.DateSelector -import neth.iecal.questphone.ui.screens.quest.setup.components.TimeRangeDialog -import neth.iecal.questphone.ui.screens.quest.setup.deep_focus.SelectAppsDialog -import neth.iecal.questphone.utils.fetchUrlContent -import neth.iecal.questphone.utils.formatAppList -import neth.iecal.questphone.utils.getCurrentDate -import neth.iecal.questphone.utils.json -import neth.iecal.questphone.utils.readableTimeRange +import nethical.questphone.core.core.utils.getCurrentDate +import nethical.questphone.core.core.utils.managers.formatAppList +import nethical.questphone.core.core.utils.readableTimeRange +import nethical.questphone.data.BaseIntegrationId +import nethical.questphone.data.DayOfWeek +import nethical.questphone.data.json @SuppressLint("MutableCollectionMutableState") @Composable -fun SetupTemplate(id: String,controller: NavController) { - var response by remember { mutableStateOf("") } - var isLoading by remember { mutableStateOf(true) } - var templateData by remember { mutableStateOf(null) } - var variableValues by remember { mutableStateOf(mutableMapOf()) } - var showDialog by remember { mutableStateOf(false) } +fun SetupTemplate(controller: NavController,viewModel: TemplatesViewModel) { + val isLoading by viewModel.isLoading.collectAsState() + val templateContent by viewModel.selectedTemplateContent.collectAsState() + val variableValues by viewModel.variableValues.collectAsState() + var showSaveConfirmation by remember { mutableStateOf(false) } var currentVariable by remember { mutableStateOf(null) } var isModelDownloadDialogVisible =remember{ mutableStateOf(false)} - // Check if all required variables are filled - val allVariablesFilled by remember { - derivedStateOf { - templateData?.questExtraVariableDeclaration?.all { variable -> - val value = variableValues[variable.name] - !value.isNullOrBlank() && value != "Not set" - } == true - } - } - - val context = LocalContext.current - val scope = rememberCoroutineScope() - LaunchedEffect(Unit) { - response = - fetchUrlContent("https://raw.githubusercontent.com/QuestPhone/quest-templates/refs/heads/main/templates/${id}.json") - ?: "" - isLoading = false - } - - LaunchedEffect(response) { - if (response.isNotEmpty()) { - try { - val data = json.decodeFromString(response) - templateData = data - VariableName.entries.forEach { - templateData!!.variableTypes.add(convertToTemplate(it)) - if(templateData!!.content.contains("#{${it.name}}")){ - templateData!!.questExtraVariableDeclaration.add(it) - } - variableValues[it.name] = it.default - } - } catch (e: Exception) { - Log.e("TemplateScreen", "Error parsing JSON: ${e.message}") - } - } - } - - Scaffold( modifier = Modifier.fillMaxSize(), topBar = { @@ -160,17 +110,9 @@ fun SetupTemplate(id: String,controller: NavController) { ) }, floatingActionButton = { - if (!isLoading && templateData != null) { + if (!isLoading && templateContent != null) { FloatingActionButton( onClick = { showSaveConfirmation = true }, - containerColor = if (allVariablesFilled) - MaterialTheme.colorScheme.primary - else - MaterialTheme.colorScheme.outline, - contentColor = if (allVariablesFilled) - MaterialTheme.colorScheme.onPrimary - else - MaterialTheme.colorScheme.onSurface ) { Icon( painter = painterResource(R.drawable.baseline_check_24), @@ -180,7 +122,7 @@ fun SetupTemplate(id: String,controller: NavController) { } } ) { padding -> - if(templateData?.basicQuest?.integration_id == IntegrationId.AI_SNAP){ + if(templateContent?.basicQuest?.integration_id == BaseIntegrationId.AI_SNAP){ ModelDownloadDialog(modelDownloadDialogVisible = isModelDownloadDialogVisible) } if (isLoading) { @@ -204,7 +146,7 @@ fun SetupTemplate(id: String,controller: NavController) { } } } else { - templateData?.let { data -> + templateContent?.let { data -> Column( modifier = Modifier .fillMaxSize() @@ -230,14 +172,13 @@ fun SetupTemplate(id: String,controller: NavController) { ) Spacer(modifier = Modifier.width(12.dp)) Text( - text = if (getCurrentDate() == User.userInfo.getCreatedOnString())"Click on the highlighted items to change values" else "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.", + text = if (getCurrentDate() == viewModel.userCreatedOn)"Click on the highlighted items to change values" else "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.", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium ) } } - // Template content Box( modifier = Modifier .fillMaxWidth() @@ -245,12 +186,11 @@ fun SetupTemplate(id: String,controller: NavController) { .padding(bottom = 80.dp) // Space for FAB ) { ClickableTemplateText( - content = data.content.replace("#{userName}", User.userInfo.username), + content = data.content.replace("#{userName}", viewModel.username), variables = data.variableTypes, variableValues = variableValues, onVariableClick = { variable -> currentVariable = variable - showDialog = true } ) } @@ -281,14 +221,13 @@ fun SetupTemplate(id: String,controller: NavController) { } // Variable edit dialog - if (showDialog && currentVariable != null) { + if (currentVariable != null) { VariableEditDialog( variable = currentVariable!!, initialValue = variableValues[currentVariable!!.name] ?: "", - onDismiss = { showDialog = false }, + onDismiss = { currentVariable = null }, onSave = { name, value -> - variableValues = variableValues.toMutableMap().also { it[name] = value } - showDialog = false + viewModel.setVariable(name,value) } ) } @@ -296,8 +235,8 @@ fun SetupTemplate(id: String,controller: NavController) { // Save confirmation dialog if (showSaveConfirmation) { SaveConfirmationDialog( - allVariablesFilled = allVariablesFilled, - unfilledCount = templateData?.variableTypes?.count { variable -> + allVariablesFilled = viewModel.areAllVariablesFilled(), + unfilledCount = templateContent?.questExtraVariableDeclaration?.count { variable -> val value = variableValues[variable.name] value.isNullOrBlank() || value == "Not set" } ?: 0, @@ -305,37 +244,17 @@ fun SetupTemplate(id: String,controller: NavController) { onConfirm = { // Handle save logic here showSaveConfirmation = false - Log.d("vars",variableValues.toString()) - - templateData?.let { - it.basicQuest.auto_destruct = variableValues.getOrDefault("auto_destruct","9999-12-31") - it.basicQuest.selected_days = json.decodeFromString(variableValues["selected_days"].toString()) - - var templateExtra = it.questExtra - Log.d("Declared Variables",it.questExtraVariableDeclaration.toString()) - it.questExtraVariableDeclaration.forEach { - templateExtra = it.setter(templateExtra,variableValues,it.name) - } - Log.d("FInal data",templateExtra.toString()) - it.basicQuest.quest_json = templateExtra.getQuestJson(it.basicQuest.integration_id) - Log.d("Final Data",it.basicQuest.toString()) - scope.launch { - val questDao = QuestDatabaseProvider.getInstance(context).questDao() - questDao.upsertQuest(it.basicQuest) - withContext(Dispatchers.Main) { - controller.popBackStack() - showSaveConfirmation = false - } - } + viewModel.addToQuests { + controller.popBackStack() + showSaveConfirmation = false } - } ) } } @Composable -fun ClickableTemplateText( +private fun ClickableTemplateText( content: String, variables: List, variableValues: Map, @@ -429,7 +348,7 @@ fun ClickableTemplateText( } @Composable -fun VariableEditDialog( +private fun VariableEditDialog( variable: TemplateVariable, initialValue: String, onDismiss: () -> Unit, @@ -548,7 +467,9 @@ fun VariableEditDialog( } Spacer(modifier = Modifier.width(12.dp)) Button( - onClick = { onSave(variable.name, textValue.ifBlank { "Not set" }) }, + onClick = { + onDismiss() + onSave(variable.name, textValue.ifBlank { "Not set" }) }, shape = RoundedCornerShape(8.dp) ) { Icon( @@ -566,7 +487,7 @@ fun VariableEditDialog( } @Composable -fun DaysOfWeekSelectorDialog( +private fun DaysOfWeekSelectorDialog( initialSelected: Set, onSelectionChanged: (Set) -> Unit ) { diff --git a/app/src/main/java/neth/iecal/questphone/app/screens/quest/templates/TemplatesViewModel.kt b/app/src/main/java/neth/iecal/questphone/app/screens/quest/templates/TemplatesViewModel.kt new file mode 100644 index 00000000..b58fb705 --- /dev/null +++ b/app/src/main/java/neth/iecal/questphone/app/screens/quest/templates/TemplatesViewModel.kt @@ -0,0 +1,193 @@ +package neth.iecal.questphone.app.screens.quest.templates + +import android.util.Log +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.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import neth.iecal.questphone.data.Template +import neth.iecal.questphone.data.TemplateContent +import neth.iecal.questphone.data.VariableName +import neth.iecal.questphone.data.convertToTemplate +import neth.iecal.questphone.data.toAdv +import nethical.questphone.backend.CommonQuestInfo +import nethical.questphone.backend.fetchUrlContent +import nethical.questphone.backend.repositories.QuestRepository +import nethical.questphone.backend.repositories.UserRepository +import nethical.questphone.data.json +import javax.inject.Inject + +@HiltViewModel +class TemplatesViewModel @Inject constructor( + private val questRepository: QuestRepository, + userRepository: UserRepository +) : ViewModel() { + + private val _template = MutableStateFlow>(emptyList()) + val template: StateFlow> = _template.asStateFlow() + + val isAnonymous = userRepository.userInfo.isAnonymous + val userCreatedOn = userRepository.userInfo.getCreatedOnString() + val username = userRepository.userInfo.username + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val _selectedCategory = MutableStateFlow(null) + val selectedCategory: StateFlow = _selectedCategory.asStateFlow() + + private val _isLoading = MutableStateFlow(true) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _showLoginDialog = MutableStateFlow(false) + val showLoginDialog: StateFlow = _showLoginDialog.asStateFlow() + + private val _questList = MutableStateFlow>(emptyList()) + val questList: StateFlow> = _questList.asStateFlow() + + var selectedTemplate: Template? = null + private set + var selectedTemplateContent: MutableStateFlow = MutableStateFlow(null) + private set + var variableValues: MutableStateFlow> = MutableStateFlow> (mapOf()) + private set + + + val categories: StateFlow> = template.map { list -> + list.map { it.category }.distinct().sorted() + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val filteredActivities: StateFlow> = combine( + template, searchQuery, selectedCategory + ) { list, query, category -> + list.filter { activity -> + val matchesSearch = query.isBlank() || + activity.name.contains(query, ignoreCase = true) || + activity.description.contains(query, ignoreCase = true) || + activity.category.contains(query, ignoreCase = true) + + val matchesCategory = category == null || activity.category == category + + matchesSearch && matchesCategory + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + init { + viewModelScope.launch { + loadTemplates() + questRepository.getAllQuests().collect { + _questList.value = it + } + } + } + + private fun loadTemplates() { + viewModelScope.launch { + val response = + fetchUrlContent("https://raw.githubusercontent.com/QuestPhone/quest-templates/refs/heads/main/all.json") + _template.value = parseActivitiesJson(response ?: "") + _isLoading.value = false + } + } + fun setVariable(key: String, value: String){ + variableValues.value= variableValues.value.toMutableMap().also { it[key] = value } + + } + + fun setSearchQuery(query: String) { + _searchQuery.value = query + } + + fun setSelectedCategory(category: String?) { + _selectedCategory.value = category + } + + fun showLoginDialog() { + _showLoginDialog.value = true + } + + fun hideLoginDialog() { + _showLoginDialog.value = false + } + + fun areAllVariablesFilled(): Boolean { + return selectedTemplateContent.value?.questExtraVariableDeclaration?.all { variable -> + val value = variableValues.value[variable.name] + !value.isNullOrBlank() && value != "Not set" + } == true + } + + fun selectTemplate(template: Template) { + selectedTemplate = template + selectedTemplateContent.value = null + variableValues.value = mutableMapOf() + viewModelScope.launch { + _isLoading.value = true + val response = + fetchUrlContent("https://raw.githubusercontent.com/QuestPhone/quest-templates/refs/heads/main/templates/${template.id}.json") + ?: "" + if (response.isNotEmpty()) { + try { + val data = json.decodeFromString(response) + selectedTemplateContent.value = data + VariableName.entries.forEach { + selectedTemplateContent.value!!.variableTypes.add(convertToTemplate(it)) + if (selectedTemplateContent.value!!.content.contains("#{${it.name}}")) { + selectedTemplateContent.value!!.questExtraVariableDeclaration.add(it) + } + setVariable(it.name,it.default) + } + } catch (e: Exception) { + Log.e("TemplateViewModel", "Error parsing JSON: ${e.message}") + } + } + _isLoading.value = false + } + } + + fun addToQuests(onAdded: () -> Unit) { + Log.d("vars", variableValues.toString()) + _isLoading.value = true + selectedTemplateContent.value?.let { + it.basicQuest.auto_destruct = variableValues.value.getOrDefault("auto_destruct", "9999-12-31") + it.basicQuest.selected_days = + json.decodeFromString(variableValues.value["selected_days"].toString()) + + var templateExtra = it.questExtra + Log.d("Declared Variables", it.questExtraVariableDeclaration.toString()) + it.questExtraVariableDeclaration.forEach { + templateExtra = it.setter(templateExtra, variableValues.value, it.name) + } + Log.d("FInal data", templateExtra.toString()) + it.basicQuest.quest_json = + templateExtra.getQuestJson(it.basicQuest.integration_id.toAdv()) + Log.d("Final Data", it.basicQuest.toString()) + viewModelScope.launch { + questRepository.upsertQuest(it.basicQuest) + withContext(Dispatchers.Main) { + _isLoading.value = false + onAdded() + } + } + } + + } +} + +private fun parseActivitiesJson(jsonString: String): List