From c5b855764d4532b80de7955bbff1de2f3ef22d96 Mon Sep 17 00:00:00 2001 From: feragusper Date: Sun, 28 Sep 2025 11:49:59 +0200 Subject: [PATCH 01/12] chore(deps): update SDK versions, library dependencies, and Gradle wrapper --- apps/mobile/build.gradle.kts | 35 +++--- build.gradle.kts | 2 +- buildSrc/src/main/kotlin/Android.kt | 4 +- buildSrc/src/main/kotlin/KoverConfig.kt | 108 ++++++------------ .../src/main/kotlin/android-lib.gradle.kts | 42 +++---- buildSrc/src/main/kotlin/java-lib.gradle.kts | 3 +- .../history/presentation/build.gradle.kts | 2 + .../presentation/mvi/compose/HomeViewState.kt | 2 + features/stats/presentation/build.gradle.kts | 2 + gradle/libs.versions.toml | 68 +++++------ gradle/wrapper/gradle-wrapper.properties | 2 +- .../authentication/data/FirebaseAuthModule.kt | 4 +- .../presentation/build.gradle.kts | 1 + .../compose/GoogleSignInComponent.kt | 4 +- .../smokes/presentation/build.gradle.kts | 2 + .../presentation/compose/SwipeToDismissRow.kt | 3 +- 16 files changed, 120 insertions(+), 164 deletions(-) diff --git a/apps/mobile/build.gradle.kts b/apps/mobile/build.gradle.kts index c7787a2c..b46cee1b 100644 --- a/apps/mobile/build.gradle.kts +++ b/apps/mobile/build.gradle.kts @@ -1,4 +1,5 @@ import com.google.common.base.Charsets +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.io.ByteArrayOutputStream import java.io.FileInputStream import java.io.InputStreamReader @@ -39,7 +40,7 @@ val gitCode: Int by lazy { } // Construct the version name using a major.minor.patch pattern with the git code. -val majorMinorPatchVersionName = "0.4.0.$gitCode" +val majorMinorPatchVersionName = "0.5.0.$gitCode" android { // Set the application namespace. @@ -115,15 +116,6 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - // Set the JVM target for Kotlin. - jvmTarget = Java.JVM_TARGET - // Enable experimental APIs. - freeCompilerArgs = listOf( - "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", - "-opt-in=kotlin.RequiresOptIn" - ) - } buildFeatures { // Enable Jetpack Compose. @@ -132,12 +124,6 @@ android { buildConfig = true } - @Suppress("UnstableApiUsage") - composeOptions { - // Specify the Kotlin compiler extension version for Compose. - kotlinCompilerExtensionVersion = Java.KOTLIN_COMPILER_EXTENSION_VERSION - } - packaging { // Exclude unnecessary META-INF resources. resources { @@ -151,6 +137,17 @@ android { } } +kotlin { + jvmToolchain(17) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + freeCompilerArgs.addAll( + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-opt-in=kotlin.RequiresOptIn" + ) + } +} + // Utility function to load properties from a file located at the root directory. fun properties(propertiesFileName: String): Properties { val properties = Properties() @@ -196,8 +193,6 @@ dependencies { } // Task to print the current version name to the console. -task("printVersionName") { - doLast { - println(majorMinorPatchVersionName) - } +tasks.register("printVersionName") { + doLast { println(majorMinorPatchVersionName) } } diff --git a/build.gradle.kts b/build.gradle.kts index e96033a9..cc2f6b6f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -26,7 +26,7 @@ buildscript { plugins { // Google Services plugin is declared here but not applied by default. // Apply it in the respective modules as needed. - id("com.google.gms.google-services") version "4.4.2" apply false + id("com.google.gms.google-services") version "4.4.3" apply false // Apply the SonarQube plugin globally for static code analysis. sonarqube diff --git a/buildSrc/src/main/kotlin/Android.kt b/buildSrc/src/main/kotlin/Android.kt index f56133e4..5426ba1f 100644 --- a/buildSrc/src/main/kotlin/Android.kt +++ b/buildSrc/src/main/kotlin/Android.kt @@ -3,8 +3,8 @@ object Android { const val MIN_SDK = 26 // Target SDK version, used to optimize the app's behavior on newer OS versions. - const val TARGET_SDK = 35 + const val TARGET_SDK = 36 // Compile SDK version for building the app. - const val COMPILE_SDK = 35 + const val COMPILE_SDK = 36 } diff --git a/buildSrc/src/main/kotlin/KoverConfig.kt b/buildSrc/src/main/kotlin/KoverConfig.kt index b7ec978a..8fddcf9b 100644 --- a/buildSrc/src/main/kotlin/KoverConfig.kt +++ b/buildSrc/src/main/kotlin/KoverConfig.kt @@ -2,93 +2,55 @@ import org.gradle.api.Action import org.gradle.api.file.ProjectLayout import org.gradle.api.file.RegularFile import org.gradle.api.provider.Provider +import kotlinx.kover.gradle.plugin.dsl.AggregationType +import kotlinx.kover.gradle.plugin.dsl.CoverageUnit +import kotlinx.kover.gradle.plugin.dsl.GroupingEntityType +import kotlinx.kover.gradle.plugin.dsl.KoverProjectExtension -class KoverConfig( - private val layout: ProjectLayout -) { +class KoverConfig(private val layout: ProjectLayout) { companion object { internal const val KOVER_REPORT_DIR = "smoke-analytics-report" internal const val KOVER_REPORT_XML_FILE = "result.xml" internal val koverReportExclusionsClasses = listOf( - "**/*Application.*", - "**/*Activity.*", - "**/*Navigator.*", - "**/*NavigationGraph.*", - "**/*View.*", - "**/*Color.kt", - "**/*Typography.kt", - "**/compose/**", - "**/di/**", - "**/extensions/**", + "**/*Application.*","**/*Activity.*","**/*Navigator.*","**/*NavigationGraph.*", + "**/*View.*","**/*Color.kt","**/*Typography.kt","**/compose/**","**/di/**","**/extensions/**", ) } - // Defines the provider for the XML report file location. - private val koverReportFileXML: Provider = + private val xmlFile: Provider = layout.buildDirectory.file("$KOVER_REPORT_DIR/$KOVER_REPORT_XML_FILE") - // Configure the Kover report settings. - val koverReport = Action { - // Configure common filters for all reports. - filters { - excludes { - // Exclude classes by fully-qualified name (wildcards '*' and '?' are supported). - classes(koverReportExclusionsClasses) - // Exclude all classes and functions annotated with matching annotations. - annotatedBy("*Generated*") - } - } - - // Configure default report generation. - defaults { - // XML Report configuration. - xml { - // Generate an XML report when running the `check` task. - onCheck = true - // Set the destination file for the XML report. - setReportFile(koverReportFileXML) - } - - // HTML Report configuration. - html { - // Generate an HTML report when running the `check` task. - onCheck = true - // Set the destination directory for the HTML report. - setReportDir(layout.buildDirectory.dir("$KOVER_REPORT_DIR/html-result")) + val configure: Action = Action { + reports { + filters { + excludes { + classes(koverReportExclusionsClasses) + annotatedBy("*Generated*") + } } - - // Verification configuration to enforce coverage rules. - verify { - // Verify coverage during the `check` task. - onCheck = true - // Define a verification rule. - rule { - // Enable this verification rule. - isEnabled = true - - // Specify the grouping entity for which coverage is aggregated. - entity = kotlinx.kover.gradle.plugin.dsl.GroupingEntityType.APPLICATION - - // Define the coverage bounds. - bound { - // Lower coverage bound. - minValue = 0 - // Upper coverage bound. - maxValue = 100 - // Metric to measure (e.g., lines). - metric = kotlinx.kover.gradle.plugin.dsl.MetricType.LINE - // Aggregation type to compute the coverage percentage. - aggregation = - kotlinx.kover.gradle.plugin.dsl.AggregationType.COVERED_PERCENTAGE + total { + xml { + onCheck.set(true) + xmlFile.set(this@KoverConfig.xmlFile) + } + html { + onCheck.set(true) + htmlDir.set(layout.buildDirectory.dir("$KOVER_REPORT_DIR/html-result")) + } + verify { + onCheck.set(true) + rule { + // Si tu versión expone 'groupBy' como Property: + runCatching { groupBy.set(GroupingEntityType.APPLICATION) } + .onFailure { /* fallback for older API: */ + @Suppress("UNUSED_EXPRESSION") (GroupingEntityType.APPLICATION) + } + minBound(0, CoverageUnit.LINE, AggregationType.COVERED_PERCENTAGE) + maxBound(100, CoverageUnit.LINE, AggregationType.COVERED_PERCENTAGE) } - - // Additional lower bound for percentage of covered lines. - minBound(0) - // Additional upper bound for percentage of covered lines. - maxBound(100) } } } } -} +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/android-lib.gradle.kts b/buildSrc/src/main/kotlin/android-lib.gradle.kts index 9405293b..fe9e3fa5 100644 --- a/buildSrc/src/main/kotlin/android-lib.gradle.kts +++ b/buildSrc/src/main/kotlin/android-lib.gradle.kts @@ -1,3 +1,6 @@ +import kotlinx.kover.gradle.plugin.dsl.KoverProjectExtension +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { // Apply the Android Library plugin for building library modules. id("com.android.library") @@ -18,28 +21,11 @@ android { minSdk = Android.MIN_SDK } - compileOptions { - // Set Java source and target compatibility to Java 17. - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - // Set the JVM target for Kotlin compilation. - jvmTarget = Java.JVM_TARGET - } - buildFeatures { // Enable generation of BuildConfig class. buildConfig = true } - composeOptions { - // Specify the Kotlin compiler extension version for Jetpack Compose. - kotlinCompilerExtensionVersion = Java.KOTLIN_COMPILER_EXTENSION_VERSION - } - - @Suppress("UnstableApiUsage") testOptions { unitTests.all { // Use JUnit Platform for unit tests. @@ -58,18 +44,20 @@ android { } } +kotlin { + jvmToolchain(17) + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } +} + // Configure Kover code coverage reports using the centralized KoverConfig. -koverReport(KoverConfig(layout).koverReport) +extensions.configure("kover", KoverConfig(layout).configure) // Additional Kover report configuration. -koverReport { - defaults { - // Merge default reports with those of the 'release' build variant. - mergeWith("release") - } - - // Configure reports for the 'release' build variant. - androidReports("release") { - // Additional release-specific configuration can be added here. +kover { + reports { + variant("release") { + html { onCheck.set(true) } + xml { onCheck.set(true) } + } } } diff --git a/buildSrc/src/main/kotlin/java-lib.gradle.kts b/buildSrc/src/main/kotlin/java-lib.gradle.kts index 8e1afbe1..43e1e198 100644 --- a/buildSrc/src/main/kotlin/java-lib.gradle.kts +++ b/buildSrc/src/main/kotlin/java-lib.gradle.kts @@ -1,3 +1,4 @@ +import kotlinx.kover.gradle.plugin.dsl.KoverProjectExtension import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { @@ -29,4 +30,4 @@ java { // Configure Kover code coverage reports using the centralized KoverConfig. // 'layout' is a Gradle-provided property representing the project layout. -koverReport(KoverConfig(layout).koverReport) +extensions.configure("kover", KoverConfig(layout).configure) diff --git a/features/history/presentation/build.gradle.kts b/features/history/presentation/build.gradle.kts index ad35f308..1cf6ec41 100644 --- a/features/history/presentation/build.gradle.kts +++ b/features/history/presentation/build.gradle.kts @@ -48,6 +48,8 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.bundles.compose) implementation(libs.material3) + implementation(libs.androidx.compose.material.icons.core) + implementation(libs.androidx.compose.material.icons.extended) implementation(libs.bundles.androidx.navigation) // Dependency injection with Hilt diff --git a/features/home/presentation/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/mvi/compose/HomeViewState.kt b/features/home/presentation/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/mvi/compose/HomeViewState.kt index 40aab43b..2ac185e5 100644 --- a/features/home/presentation/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/mvi/compose/HomeViewState.kt +++ b/features/home/presentation/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/mvi/compose/HomeViewState.kt @@ -84,6 +84,8 @@ data class HomeViewState( object : PullToRefreshState { private val anim = Animatable(0f, Float.VectorConverter) override val distanceFraction get() = anim.value + override val isAnimating: Boolean + get() = anim.isRunning override suspend fun animateToThreshold() { anim.animateTo(1f, spring(dampingRatio = Spring.DampingRatioHighBouncy)) } diff --git a/features/stats/presentation/build.gradle.kts b/features/stats/presentation/build.gradle.kts index 5ffc384e..cbdd6f7e 100644 --- a/features/stats/presentation/build.gradle.kts +++ b/features/stats/presentation/build.gradle.kts @@ -42,6 +42,8 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.bundles.compose) implementation(libs.material3) + implementation(libs.androidx.compose.material.icons.core) + implementation(libs.androidx.compose.material.icons.extended) implementation(libs.bundles.androidx.navigation) // Dependency injection with Hilt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d15d3bc1..3e501ec1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,45 +1,45 @@ # Versions [versions] accompanist = "0.36.0" -androidxAppcompat = "1.7.0" -androidxCompose = "1.7.8" -androidxComposeBOM = "2025.04.00" -androidxCoreKtx = "1.16.0" -androidxNavigationCompose = "2.8.9" +androidxAppcompat = "1.7.1" +androidxCompose = "1.9.3" +androidxComposeBOM = "2025.10.00" +androidxCoreKtx = "1.17.0" +androidxNavigationCompose = "2.9.5" animatedNavigationBar = "1.0.0" -composeShimmer = "1.3.2" +composeShimmer = "1.3.3" coroutinesTest = "1.10.2" -credentials = "1.1.0" -espressoCore = "3.6.1" -firebaseBOM = "33.12.0" +credentials = "1.5.0" +espressoCore = "3.7.0" +firebaseBOM = "34.4.0" generativeai = "0.9.0" -gradle = "8.9.1" -hilt = "2.56.2" -hiltNavigationCompose = "1.2.0" -horologistComposables = "0.6.23" -horologistComposeToolsVersion = "0.6.23" -horologistTiles = "0.6.23" +googleid = "1.1.1" +gradle = "8.13.0" +hilt = "2.57.2" +hiltNavigationCompose = "1.3.0" +horologistComposables = "0.7.15" +horologistComposeToolsVersion = "0.7.15" +horologistTiles = "0.7.15" javaxInject = "1" -junit = "1.2.1" +junit = "1.3.0" junit4 = "4.13.2" -junitBOM = "5.10.2" +junitBOM = "6.0.0" kluent = "1.73" -kotlin = "2.1.20" -kotlinGradlePlugin = "2.0.0" -kover = "0.7.4" -material3 = "1.3.2" -mockk = "1.14.0" -navVersion = "2.8.9" +kotlin = "2.2.20" +kover = "0.9.2" +material3 = "1.4.0" +mockk = "1.14.6" +navVersion = "2.9.5" playServicesWearable = "19.0.0" -protolayoutCore = "1.2.1" -protolayoutMaterial = "1.2.1" -revealswipe = "2.2.0" +protolayoutCore = "1.3.0" +protolayoutMaterial = "1.3.0" +revealswipe = "3.0.0" sonarqubeGradlePlugin = "4.0.0.2929" -tiles = "1.4.1" -tilesMaterial = "1.4.1" +tiles = "1.5.0" +tilesMaterial = "1.5.0" timber = "5.0.1" -turbine = "1.2.0" -vico = "2.1.2" +turbine = "1.2.1" +vico = "2.2.1" # Libraries [libraries] @@ -49,6 +49,8 @@ androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "a androidx-compose-activity = { module = "androidx.activity:activity-compose" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidxComposeBOM" } androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-material-icons-core = { module = "androidx.compose.material:material-icons-core" } +androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } androidx-compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "androidxCompose" } androidx-compose-ui = { module = "androidx.compose.ui:ui" } androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "androidxCompose" } @@ -71,7 +73,7 @@ animated-navigation-bar = { module = "com.exyte:animated-navigation-bar", versio app-cash-turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" } coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesTest" } -firebase-auth = { module = "com.google.firebase:firebase-auth-ktx" } +firebase-auth = { module = "com.google.firebase:firebase-auth" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBOM" } firebase-firestore = { module = "com.google.firebase:firebase-firestore" } generativeai = { module = "com.google.ai.client.generativeai:generativeai", version.ref = "generativeai" } @@ -82,7 +84,7 @@ hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", ve horologist-composables = { module = "com.google.android.horologist:horologist-composables", version.ref = "horologistComposables" } horologist-compose-tools = { module = "com.google.android.horologist:horologist-compose-tools", version.ref = "horologistComposeToolsVersion" } horologist-tiles = { module = "com.google.android.horologist:horologist-tiles", version.ref = "horologistTiles" } -identity-googleid = { module = "com.google.android.libraries.identity.googleid:googleid", version.ref = "credentials" } +identity-googleid = { module = "com.google.android.libraries.identity.googleid:googleid", version.ref = "googleid" } javax-inject = { module = "javax.inject:javax.inject", version.ref = "javaxInject" } junit-bom = { module = "org.junit:junit-bom", version.ref = "junitBOM" } junit4 = { module = "junit:junit", version.ref = "junit4" } @@ -91,7 +93,7 @@ junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine" } junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params" } junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine" } kluent = { module = "org.amshove.kluent:kluent", version.ref = "kluent" } -kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlinGradlePlugin" } +kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kover-gradle-plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" } material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 33be9d38..21089922 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ #Wed Aug 02 23:48:31 CEST 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/libraries/authentication/data/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/data/FirebaseAuthModule.kt b/libraries/authentication/data/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/data/FirebaseAuthModule.kt index 389fcc9a..4fc756ed 100644 --- a/libraries/authentication/data/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/data/FirebaseAuthModule.kt +++ b/libraries/authentication/data/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/data/FirebaseAuthModule.kt @@ -1,8 +1,8 @@ package com.feragusper.smokeanalytics.libraries.authentication.data +import com.google.firebase.Firebase import com.google.firebase.auth.FirebaseAuth -import com.google.firebase.auth.ktx.auth -import com.google.firebase.ktx.Firebase +import com.google.firebase.auth.auth import dagger.Module import dagger.Provides import dagger.hilt.InstallIn diff --git a/libraries/authentication/presentation/build.gradle.kts b/libraries/authentication/presentation/build.gradle.kts index 9647acee..8f055869 100644 --- a/libraries/authentication/presentation/build.gradle.kts +++ b/libraries/authentication/presentation/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { implementation(libs.androidx.credentials) implementation(libs.androidx.credentials.play.services.auth) implementation(libs.identity.googleid) + implementation(libs.identity.googleid) // Include Timber for logging. implementation(libs.timber) diff --git a/libraries/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/presentation/compose/GoogleSignInComponent.kt b/libraries/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/presentation/compose/GoogleSignInComponent.kt index ad5a4d23..558412fd 100644 --- a/libraries/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/presentation/compose/GoogleSignInComponent.kt +++ b/libraries/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/presentation/compose/GoogleSignInComponent.kt @@ -33,9 +33,9 @@ import com.feragusper.smokeanalytics.libraries.design.compose.theme.SmokeAnalyti import com.google.android.libraries.identity.googleid.GetGoogleIdOption import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException +import com.google.firebase.Firebase import com.google.firebase.auth.GoogleAuthProvider -import com.google.firebase.auth.ktx.auth -import com.google.firebase.ktx.Firebase +import com.google.firebase.auth.auth import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await diff --git a/libraries/smokes/presentation/build.gradle.kts b/libraries/smokes/presentation/build.gradle.kts index 3fdf0322..e1c44b01 100644 --- a/libraries/smokes/presentation/build.gradle.kts +++ b/libraries/smokes/presentation/build.gradle.kts @@ -33,6 +33,8 @@ dependencies { implementation(libs.bundles.compose) // Include Material3 components for modern UI design. implementation(libs.material3) + implementation(libs.androidx.compose.material.icons.core) + implementation(libs.androidx.compose.material.icons.extended) // Dagger Hilt dependencies for dependency injection. implementation(libs.hilt) diff --git a/libraries/smokes/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/presentation/compose/SwipeToDismissRow.kt b/libraries/smokes/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/presentation/compose/SwipeToDismissRow.kt index d6382810..421ddd29 100644 --- a/libraries/smokes/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/presentation/compose/SwipeToDismissRow.kt +++ b/libraries/smokes/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/presentation/compose/SwipeToDismissRow.kt @@ -67,7 +67,6 @@ fun SwipeToDismissRow( val state = rememberRevealState( directions = setOf(RevealDirection.StartToEnd, RevealDirection.EndToStart), - positionalThreshold = { it * 0.5f }, ) var showDatePicker by remember { mutableStateOf(false) } @@ -100,7 +99,7 @@ fun SwipeToDismissRow( hiddenContentEnd = { IconButton(onClick = onDelete) { Icon( - imageVector = Icons.Default.Delete, + imageVector = Icons.Filled.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.onErrorContainer ) From 2ef46c7886a986a82a49710505d744c36604f8fc Mon Sep 17 00:00:00 2001 From: feragusper Date: Sun, 21 Dec 2025 21:21:44 +0100 Subject: [PATCH 02/12] [BIG REFACTOR] WEB SUPPORT --- .firebaserc | 23 ++ .github/workflows/deploy-web-hosting.yml | 47 +++ .gitignore | 14 + apps/mobile/build.gradle.kts | 16 +- .../smokeanalytics/UseCasesModule.kt | 74 ++++ apps/wear/build.gradle.kts | 4 +- .../presentation => apps/web}/.gitignore | 0 apps/web/build.gradle.kts | 98 +++++ .../com/feragusper/smokeanalytics/AppRoot.kt | 126 +++++++ .../smokeanalytics/FirebaseWebInit.kt | 64 ++++ .../feragusper/smokeanalytics/WebAppGraph.kt | 94 +++++ .../feragusper/smokeanalytics/WebScaffold.kt | 103 ++++++ .../com/feragusper/smokeanalytics/main.kt | 15 + apps/web/src/jsMain/resources/index.html | 13 + buildSrc/build.gradle.kts | 1 - buildSrc/settings.gradle.kts | 1 - buildSrc/src/main/kotlin/kmp-lib.gradle.kts | 30 ++ .../presentation/mobile}/.gitignore | 0 .../{ => mobile}/build.gradle.kts | 4 +- .../{ => mobile}/src/main/AndroidManifest.xml | 0 .../presentation/AuthenticationActivity.kt | 0 .../presentation/AuthenticationView.kt | 0 .../presentation/AuthenticationViewModel.kt | 0 .../presentation/mvi/AuthenticationIntent.kt | 0 .../presentation/mvi/AuthenticationResult.kt | 0 .../mvi/compose/AuthenticationViewState.kt | 0 .../AuthenticationNavigationGraph.kt | 0 .../navigation/AuthenticationNavigator.kt | 0 .../process/AuthenticationProcessHolder.kt | 0 .../src/main/res/values/strings.xml | 0 .../AuthenticationViewModelTest.kt | 0 .../AuthenticationProcessHolderTest.kt | 0 .../presentation/web}/.gitignore | 0 .../presentation/web/build.gradle.kts | 26 ++ .../presentation/AuthenticationViewState.kt | 25 ++ .../AuthenticationWebDependencies.kt | 23 ++ .../presentation/AuthenticationWebScreen.kt | 94 +++++ .../presentation/mvi/AuthenticationIntent.kt | 27 ++ .../presentation/mvi/AuthenticationResult.kt | 37 ++ .../mvi/AuthenticationWebStore.kt | 105 ++++++ .../process/AuthenticationProcessHolder.kt | 65 ++++ features/chatbot/data/build.gradle.kts | 2 + .../chatbot/data/ChatbotRepositoryImpl.kt | 79 ++-- .../chatbot/data/ChatbotRepositoryImplTest.kt | 1 - features/chatbot/domain/build.gradle.kts | 61 ++-- .../chatbot/domain/ChatbotRepository.kt | 0 .../features/chatbot/domain/ChatbotUseCase.kt | 3 +- .../chatbot/presentation/build.gradle.kts | 2 +- .../devtools/presentation/build.gradle.kts | 6 +- .../presentation/mobile}/.gitignore | 0 .../{ => mobile}/build.gradle.kts | 6 +- .../{ => mobile}/src/main/AndroidManifest.xml | 0 .../history/presentation/HistoryActivity.kt | 0 .../history/presentation/HistoryView.kt | 0 .../history/presentation/HistoryViewModel.kt | 48 +-- .../history/presentation/mvi/HistoryIntent.kt | 17 + .../history/presentation/mvi/HistoryResult.kt | 11 +- .../mvi/compose/HistoryViewState.kt | 80 ++--- .../navigation/HistoryNavigationGraph.kt | 0 .../navigation/HistoryNavigator.kt | 0 .../process/HistoryProcessHolder.kt | 0 .../src/main/res/drawable/ic_cigarette.xml | 0 .../src/main/res/values/strings.xml | 0 .../presentation/HistoryViewModelTest.kt | 1 - .../process/HistoryProcessHolderTest.kt | 4 - .../history/presentation/mvi/HistoryIntent.kt | 47 --- .../presentation/web}/.gitignore | 0 .../history/presentation/web/build.gradle.kts | 24 ++ .../history/presentation/HistoryViewState.kt | 13 + .../presentation/HistoryWebDependencies.kt | 7 + .../history/presentation/HistoryWebScreen.kt | 291 +++++++++++++++ .../history/presentation/mvi/HistoryIntent.kt | 11 + .../history/presentation/mvi/HistoryResult.kt | 29 ++ .../presentation/mvi/HistoryWebStore.kt | 80 +++++ .../process/HistoryProcessHolder.kt | 94 +++++ features/home/domain/build.gradle.kts | 62 +++- .../home/domain/FetchSmokeCountListUseCase.kt | 3 +- .../home/domain/SmokeCountListResult.kt | 2 +- .../home/presentation/mobile}/.gitignore | 0 .../{ => mobile}/build.gradle.kts | 4 +- .../home/presentation/HomeViewTest.kt | 0 .../{ => mobile}/src/main/AndroidManifest.xml | 0 .../features/home/presentation/HomeView.kt | 0 .../home/presentation/HomeViewModel.kt | 0 .../home/presentation/mvi/HomeIntent.kt | 6 +- .../home/presentation/mvi/HomeResult.kt | 0 .../presentation/mvi/compose/HomeViewState.kt | 17 +- .../navigation/HomeNavigationGraph.kt | 0 .../presentation/navigation/HomeNavigator.kt | 0 .../presentation/process/HomeProcessHolder.kt | 2 +- .../src/main/res/drawable/ic_cigarette.xml | 0 .../res/drawable/il_cigarette_background.xml | 0 .../src/main/res/values/strings.xml | 0 .../home/presentation/HomeViewModelTest.kt | 1 - .../process/HomeProcessHolderTest.kt | 3 - .../home/presentation/web}/.gitignore | 0 .../home/presentation/web/build.gradle.kts | 26 ++ .../presentation/web/EditSmokeDialogWeb.kt | 116 ++++++ .../home/presentation/web/HomeIntent.kt | 15 + .../presentation/web/HomeProcessHolder.kt | 104 ++++++ .../home/presentation/web/HomeResult.kt | 37 ++ .../home/presentation/web/HomeViewState.kt | 19 + .../presentation/web/HomeWebDependencies.kt | 29 ++ .../home/presentation/web/HomeWebScreen.kt | 195 ++++++++++ .../home/presentation/web/HomeWebStore.kt | 111 ++++++ .../settings/presentation/mobile}/.gitignore | 0 .../{ => mobile}/build.gradle.kts | 6 +- .../settings/presentation/SettingsViewTest.kt | 0 .../{ => mobile}/src/main/AndroidManifest.xml | 0 .../settings/presentation/SettingsView.kt | 0 .../presentation/SettingsViewModel.kt | 0 .../presentation/mvi/SettingsIntent.kt | 0 .../presentation/mvi/SettingsResult.kt | 0 .../mvi/compose/SettingsViewState.kt | 0 .../navigation/SettingsNavigationGraph.kt | 0 .../navigation/SettingsNavigator.kt | 0 .../process/SettingsProcessHolder.kt | 0 .../src/main/res/drawable/ic_person.xml | 0 .../src/main/res/values/strings.xml | 0 .../presentation/SettingsViewModelTest.kt | 0 .../process/SettingsProcessHolderTest.kt | 0 .../settings/presentation/web}/.gitignore | 0 .../presentation/web/build.gradle.kts | 30 ++ .../presentation/web/SettingsIntent.kt | 6 + .../presentation/web/SettingsProcessHolder.kt | 36 ++ .../presentation/web/SettingsResult.kt | 8 + .../presentation/web/SettingsViewState.kt | 7 + .../web/SettingsWebDependencies.kt | 20 ++ .../presentation/web/SettingsWebScreen.kt | 76 ++++ .../presentation/web/SettingsWebStore.kt | 66 ++++ features/stats/presentation/mobile/.gitignore | 1 + .../{ => mobile}/build.gradle.kts | 2 +- .../{ => mobile}/src/main/AndroidManifest.xml | 0 .../features/stats/presentation/StatsView.kt | 0 .../stats/presentation/StatsViewModel.kt | 0 .../stats/presentation/mvi/StatsIntent.kt | 4 +- .../stats/presentation/mvi/StatsResult.kt | 0 .../mvi/compose/StatsViewState.kt | 12 +- .../navigation/StatsNavigationGraph.kt | 0 .../presentation/navigation/StatsNavigator.kt | 0 .../process/StatsProcessHolder.kt | 0 .../src/main/res/drawable/ill_chart.xml | 0 .../stats/presentation/StatsViewModelTest.kt | 1 - .../process/StatsProcessHolderTest.kt | 1 - features/stats/presentation/web/.gitignore | 1 + .../stats/presentation/web/build.gradle.kts | 34 ++ .../stats/presentation/web/ChartJs.kt | 13 + .../stats/presentation/web/StatsIntent.kt | 12 + .../presentation/web/StatsProcessHolder.kt | 28 ++ .../stats/presentation/web/StatsResult.kt | 9 + .../stats/presentation/web/StatsViewState.kt | 13 + .../presentation/web/StatsWebDependencies.kt | 15 + .../stats/presentation/web/StatsWebScreen.kt | 339 ++++++++++++++++++ .../stats/presentation/web/StatsWebStore.kt | 57 +++ .../stats/presentation/web/canvas2dContext.kt | 11 + firebase.json | 16 + gradle.properties | 4 +- gradle/libs.versions.toml | 16 + .../architecture/domain/build.gradle.kts | 62 +++- .../architecture/domain/DateExtensions.kt | 78 ++++ .../domain/extensions/DateExtensions.kt | 128 ------- .../presentation/mobile/.gitignore | 1 + .../{ => mobile}/build.gradle.kts | 0 .../{ => mobile}/src/main/AndroidManifest.xml | 0 .../architecture/presentation/MVIViewModel.kt | 0 .../presentation/extensions/AppInfo.kt | 0 .../presentation/extensions/ErrorHandling.kt | 0 .../presentation/mvi/MVIIntent.kt | 0 .../presentation/mvi/MVIResult.kt | 0 .../presentation/mvi/MVIViewState.kt | 0 .../presentation/navigation/MVINavigator.kt | 0 .../presentation/process/MVIProcessHolder.kt | 0 .../architecture/presentation/web/.gitignore | 1 + .../presentation/web/build.gradle.kts | 17 + .../architecture/presentation/MviStore.kt | 32 ++ .../authentication/data/mobile/.gitignore | 1 + .../data/{ => mobile}/build.gradle.kts | 0 .../{ => mobile}/src/main/AndroidManifest.xml | 0 .../data/AuthenticationRepositoryImpl.kt | 2 +- .../data/AuthenticationRepositoryModule.kt | 0 .../authentication/data/FirebaseAuthModule.kt | 0 .../data/AuthenticationRepositoryImplTest.kt | 0 libraries/authentication/data/web/.gitignore | 1 + .../authentication/data/web/build.gradle.kts | 20 ++ .../data/AuthenticationRepositoryImpl.kt | 30 ++ .../authentication/domain/build.gradle.kts | 60 +++- .../domain/AuthenticationRepository.kt | 6 + .../domain/FetchSessionUseCase.kt | 7 + .../authentication/domain/Session.kt | 14 + .../authentication/domain/SignOutUseCase.kt | 7 + .../domain/AuthenticationRepository.kt | 19 - .../domain/FetchSessionUseCase.kt | 21 -- .../authentication/domain/Session.kt | 34 -- .../authentication/domain/SignOutUseCase.kt | 19 - .../presentation/mobile/.gitignore | 1 + .../{ => mobile}/build.gradle.kts | 0 .../compose/GoogleSignInComponent.kt | 0 .../src/main/res/drawable/google.png | Bin .../src/main/res/values/strings.xml | 0 .../presentation/web/.gitignore | 1 + .../presentation/web/build.gradle.kts | 33 ++ .../compose/GoogleSignInComponentWeb.kt | 60 ++++ libraries/logging/.gitignore | 1 + libraries/logging/build.gradle.kts | 27 ++ .../libraries/logging /AppLogger.kt | 13 + libraries/smokes/data/mobile/.gitignore | 1 + .../smokes/data/{ => mobile}/build.gradle.kts | 0 .../{ => mobile}/src/main/AndroidManifest.xml | 0 .../libraries/smokes/data/SmokeEntity.kt | 15 + .../smokes/data/SmokeRepositoryImpl.kt | 108 ++++++ .../smokes/data/di/FirestoreModule.kt | 0 .../smokes/data/di/SmokeRepositoryModule.kt | 0 .../smokes/data/SmokeRepositoryImplTest.kt | 1 - .../libraries/smokes/data/SmokeEntity.kt | 10 - .../smokes/data/SmokeRepositoryImpl.kt | 183 ---------- libraries/smokes/data/web/.gitignore | 1 + libraries/smokes/data/web/build.gradle.kts | 23 ++ .../libraries/smokes/data/SmokeEntity.kt | 18 + .../smokes/data/SmokeRepositoryImpl.kt | 112 ++++++ libraries/smokes/domain/build.gradle.kts | 55 ++- .../libraries/smokes/domain/model/Smoke.kt | 10 + .../smokes/domain/model/SmokeCount.kt | 9 + .../smokes/domain/model/SmokeStats.kt | 150 ++++++++ .../domain/repository/SmokeRepository.kt | 22 ++ .../smokes/domain/usecase/AddSmokeUseCase.kt | 15 + .../domain/usecase/DeleteSmokeUseCase.kt | 13 + .../smokes/domain/usecase/EditSmokeUseCase.kt | 14 + .../domain/usecase/FetchSmokeStatsUseCase.kt | 69 ++++ .../domain/usecase/FetchSmokesUseCase.kt | 15 + .../domain/usecase/SyncWithWearUseCase.kt | 0 .../libraries/smokes/domain/model/Smoke.kt | 17 - .../smokes/domain/model/SmokeCount.kt | 17 - .../smokes/domain/model/SmokeStats.kt | 118 ------ .../domain/repository/SmokeRepository.kt | 53 --- .../smokes/domain/usecase/AddSmokeUseCase.kt | 26 -- .../domain/usecase/DeleteSmokeUseCase.kt | 24 -- .../smokes/domain/usecase/EditSmokeUseCase.kt | 26 -- .../domain/usecase/FetchSmokeStatsUseCase.kt | 70 ---- .../domain/usecase/FetchSmokesUseCase.kt | 28 -- .../presentation/compose/DatePickerDialog.kt | 42 +-- .../presentation/compose/SwipeToDismissRow.kt | 120 ++++--- libraries/wear/data/build.gradle.kts | 10 +- .../wear/data/WearSyncManagerImpl.kt | 6 +- settings.gradle.kts | 44 ++- 244 files changed, 4574 insertions(+), 1209 deletions(-) create mode 100644 .firebaserc create mode 100644 .github/workflows/deploy-web-hosting.yml create mode 100644 apps/mobile/src/main/java/com/feragusper/smokeanalytics/UseCasesModule.kt rename {features/authentication/presentation => apps/web}/.gitignore (100%) create mode 100644 apps/web/build.gradle.kts create mode 100644 apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/AppRoot.kt create mode 100644 apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/FirebaseWebInit.kt create mode 100644 apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/WebAppGraph.kt create mode 100644 apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/WebScaffold.kt create mode 100644 apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/main.kt create mode 100644 apps/web/src/jsMain/resources/index.html create mode 100644 buildSrc/src/main/kotlin/kmp-lib.gradle.kts rename features/{history/presentation => authentication/presentation/mobile}/.gitignore (100%) rename features/authentication/presentation/{ => mobile}/build.gradle.kts (98%) rename features/authentication/presentation/{ => mobile}/src/main/AndroidManifest.xml (100%) rename features/authentication/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationActivity.kt (100%) rename features/authentication/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationView.kt (100%) rename features/authentication/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationViewModel.kt (100%) rename features/authentication/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/AuthenticationIntent.kt (100%) rename features/authentication/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/AuthenticationResult.kt (100%) rename features/authentication/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/compose/AuthenticationViewState.kt (100%) rename features/authentication/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/navigation/AuthenticationNavigationGraph.kt (100%) rename features/authentication/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/navigation/AuthenticationNavigator.kt (100%) rename features/authentication/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/process/AuthenticationProcessHolder.kt (100%) rename features/authentication/presentation/{ => mobile}/src/main/res/values/strings.xml (100%) rename features/authentication/presentation/{ => mobile}/src/test/java/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationViewModelTest.kt (100%) rename features/authentication/presentation/{ => mobile}/src/test/java/com/feragusper/smokeanalytics/features/authentication/presentation/process/AuthenticationProcessHolderTest.kt (100%) rename features/{home/presentation => authentication/presentation/web}/.gitignore (100%) create mode 100644 features/authentication/presentation/web/build.gradle.kts create mode 100644 features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationViewState.kt create mode 100644 features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationWebDependencies.kt create mode 100644 features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationWebScreen.kt create mode 100644 features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/AuthenticationIntent.kt create mode 100644 features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/AuthenticationResult.kt create mode 100644 features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/AuthenticationWebStore.kt create mode 100644 features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/process/AuthenticationProcessHolder.kt rename features/chatbot/domain/src/{main/java => commonMain/kotlin}/com/feragusper/smokeanalytics/features/chatbot/domain/ChatbotRepository.kt (100%) rename features/chatbot/domain/src/{main/java => commonMain/kotlin}/com/feragusper/smokeanalytics/features/chatbot/domain/ChatbotUseCase.kt (93%) rename features/{settings/presentation => history/presentation/mobile}/.gitignore (100%) rename features/history/presentation/{ => mobile}/build.gradle.kts (96%) rename features/history/presentation/{ => mobile}/src/main/AndroidManifest.xml (100%) rename features/history/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryActivity.kt (100%) rename features/history/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryView.kt (100%) rename features/history/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewModel.kt (66%) create mode 100644 features/history/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryIntent.kt rename features/history/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryResult.kt (93%) rename features/history/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/mvi/compose/HistoryViewState.kt (83%) rename features/history/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/navigation/HistoryNavigationGraph.kt (100%) rename features/history/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/navigation/HistoryNavigator.kt (100%) rename features/history/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/process/HistoryProcessHolder.kt (100%) rename features/history/presentation/{ => mobile}/src/main/res/drawable/ic_cigarette.xml (100%) rename features/history/presentation/{ => mobile}/src/main/res/values/strings.xml (100%) rename features/history/presentation/{ => mobile}/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewModelTest.kt (99%) rename features/history/presentation/{ => mobile}/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/process/HistoryProcessHolderTest.kt (93%) delete mode 100644 features/history/presentation/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryIntent.kt rename features/{stats/presentation => history/presentation/web}/.gitignore (100%) create mode 100644 features/history/presentation/web/build.gradle.kts create mode 100644 features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewState.kt create mode 100644 features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/HistoryWebDependencies.kt create mode 100644 features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/HistoryWebScreen.kt create mode 100644 features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryIntent.kt create mode 100644 features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryResult.kt create mode 100644 features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryWebStore.kt create mode 100644 features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/process/HistoryProcessHolder.kt rename features/home/domain/src/{main/java => commonMain/kotlin}/com/feragusper/smokeanalytics/features/home/domain/FetchSmokeCountListUseCase.kt (94%) rename features/home/domain/src/{main/java => commonMain/kotlin}/com/feragusper/smokeanalytics/features/home/domain/SmokeCountListResult.kt (97%) rename {libraries/architecture/presentation => features/home/presentation/mobile}/.gitignore (100%) rename features/home/presentation/{ => mobile}/build.gradle.kts (97%) rename features/home/presentation/{ => mobile}/src/androidTest/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewTest.kt (100%) rename features/home/presentation/{ => mobile}/src/main/AndroidManifest.xml (100%) rename features/home/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/HomeView.kt (100%) rename features/home/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewModel.kt (100%) rename features/home/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/mvi/HomeIntent.kt (93%) rename features/home/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/mvi/HomeResult.kt (100%) rename features/home/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/mvi/compose/HomeViewState.kt (97%) rename features/home/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/navigation/HomeNavigationGraph.kt (100%) rename features/home/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/navigation/HomeNavigator.kt (100%) rename features/home/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/process/HomeProcessHolder.kt (99%) rename features/home/presentation/{ => mobile}/src/main/res/drawable/ic_cigarette.xml (100%) rename features/home/presentation/{ => mobile}/src/main/res/drawable/il_cigarette_background.xml (100%) rename features/home/presentation/{ => mobile}/src/main/res/values/strings.xml (100%) rename features/home/presentation/{ => mobile}/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewModelTest.kt (99%) rename features/home/presentation/{ => mobile}/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/process/HomeProcessHolderTest.kt (96%) rename {libraries/authentication/data => features/home/presentation/web}/.gitignore (100%) create mode 100644 features/home/presentation/web/build.gradle.kts create mode 100644 features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/EditSmokeDialogWeb.kt create mode 100644 features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeIntent.kt create mode 100644 features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeProcessHolder.kt create mode 100644 features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeResult.kt create mode 100644 features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeViewState.kt create mode 100644 features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebDependencies.kt create mode 100644 features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebScreen.kt create mode 100644 features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebStore.kt rename {libraries/authentication/presentation => features/settings/presentation/mobile}/.gitignore (100%) rename features/settings/presentation/{ => mobile}/build.gradle.kts (95%) rename features/settings/presentation/{ => mobile}/src/androidTest/java/com/feragusper/smokeanalytics/features/settings/presentation/SettingsViewTest.kt (100%) rename features/settings/presentation/{ => mobile}/src/main/AndroidManifest.xml (100%) rename features/settings/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/SettingsView.kt (100%) rename features/settings/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/SettingsViewModel.kt (100%) rename features/settings/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/mvi/SettingsIntent.kt (100%) rename features/settings/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/mvi/SettingsResult.kt (100%) rename features/settings/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/mvi/compose/SettingsViewState.kt (100%) rename features/settings/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/navigation/SettingsNavigationGraph.kt (100%) rename features/settings/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/navigation/SettingsNavigator.kt (100%) rename features/settings/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/process/SettingsProcessHolder.kt (100%) rename features/settings/presentation/{ => mobile}/src/main/res/drawable/ic_person.xml (100%) rename features/settings/presentation/{ => mobile}/src/main/res/values/strings.xml (100%) rename features/settings/presentation/{ => mobile}/src/test/java/com/feragusper/smokeanalytics/features/settings/presentation/SettingsViewModelTest.kt (100%) rename features/settings/presentation/{ => mobile}/src/test/java/com/feragusper/smokeanalytics/features/settings/presentation/process/SettingsProcessHolderTest.kt (100%) rename {libraries/smokes/data => features/settings/presentation/web}/.gitignore (100%) create mode 100644 features/settings/presentation/web/build.gradle.kts create mode 100644 features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsIntent.kt create mode 100644 features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsProcessHolder.kt create mode 100644 features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsResult.kt create mode 100644 features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsViewState.kt create mode 100644 features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebDependencies.kt create mode 100644 features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebScreen.kt create mode 100644 features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebStore.kt create mode 100644 features/stats/presentation/mobile/.gitignore rename features/stats/presentation/{ => mobile}/build.gradle.kts (99%) rename features/stats/presentation/{ => mobile}/src/main/AndroidManifest.xml (100%) rename features/stats/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/StatsView.kt (100%) rename features/stats/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/StatsViewModel.kt (100%) rename features/stats/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/mvi/StatsIntent.kt (93%) rename features/stats/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/mvi/StatsResult.kt (100%) rename features/stats/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/mvi/compose/StatsViewState.kt (96%) rename features/stats/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/navigation/StatsNavigationGraph.kt (100%) rename features/stats/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/navigation/StatsNavigator.kt (100%) rename features/stats/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/process/StatsProcessHolder.kt (100%) rename features/stats/presentation/{ => mobile}/src/main/res/drawable/ill_chart.xml (100%) rename features/stats/presentation/{ => mobile}/src/test/java/com/feragusper/smokeanalytics/features/stats/presentation/StatsViewModelTest.kt (97%) rename features/stats/presentation/{ => mobile}/src/test/java/com/feragusper/smokeanalytics/features/stats/presentation/process/StatsProcessHolderTest.kt (96%) create mode 100644 features/stats/presentation/web/.gitignore create mode 100644 features/stats/presentation/web/build.gradle.kts create mode 100644 features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/ChartJs.kt create mode 100644 features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsIntent.kt create mode 100644 features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsProcessHolder.kt create mode 100644 features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsResult.kt create mode 100644 features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsViewState.kt create mode 100644 features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebDependencies.kt create mode 100644 features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebScreen.kt create mode 100644 features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebStore.kt create mode 100644 features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/canvas2dContext.kt create mode 100644 firebase.json create mode 100644 libraries/architecture/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/architecture/domain/DateExtensions.kt delete mode 100644 libraries/architecture/domain/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/domain/extensions/DateExtensions.kt create mode 100644 libraries/architecture/presentation/mobile/.gitignore rename libraries/architecture/presentation/{ => mobile}/build.gradle.kts (100%) rename libraries/architecture/presentation/{ => mobile}/src/main/AndroidManifest.xml (100%) rename libraries/architecture/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/MVIViewModel.kt (100%) rename libraries/architecture/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/extensions/AppInfo.kt (100%) rename libraries/architecture/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/extensions/ErrorHandling.kt (100%) rename libraries/architecture/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/mvi/MVIIntent.kt (100%) rename libraries/architecture/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/mvi/MVIResult.kt (100%) rename libraries/architecture/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/mvi/MVIViewState.kt (100%) rename libraries/architecture/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/navigation/MVINavigator.kt (100%) rename libraries/architecture/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/process/MVIProcessHolder.kt (100%) create mode 100644 libraries/architecture/presentation/web/.gitignore create mode 100644 libraries/architecture/presentation/web/build.gradle.kts create mode 100644 libraries/architecture/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/architecture/presentation/MviStore.kt create mode 100644 libraries/authentication/data/mobile/.gitignore rename libraries/authentication/data/{ => mobile}/build.gradle.kts (100%) rename libraries/authentication/data/{ => mobile}/src/main/AndroidManifest.xml (100%) rename libraries/authentication/data/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImpl.kt (96%) rename libraries/authentication/data/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryModule.kt (100%) rename libraries/authentication/data/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/data/FirebaseAuthModule.kt (100%) rename libraries/authentication/data/{ => mobile}/src/test/java/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImplTest.kt (100%) create mode 100644 libraries/authentication/data/web/.gitignore create mode 100644 libraries/authentication/data/web/build.gradle.kts create mode 100644 libraries/authentication/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImpl.kt create mode 100644 libraries/authentication/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/AuthenticationRepository.kt create mode 100644 libraries/authentication/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/FetchSessionUseCase.kt create mode 100644 libraries/authentication/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/Session.kt create mode 100644 libraries/authentication/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/SignOutUseCase.kt delete mode 100644 libraries/authentication/domain/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/domain/AuthenticationRepository.kt delete mode 100644 libraries/authentication/domain/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/domain/FetchSessionUseCase.kt delete mode 100644 libraries/authentication/domain/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/domain/Session.kt delete mode 100644 libraries/authentication/domain/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/domain/SignOutUseCase.kt create mode 100644 libraries/authentication/presentation/mobile/.gitignore rename libraries/authentication/presentation/{ => mobile}/build.gradle.kts (100%) rename libraries/authentication/presentation/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/presentation/compose/GoogleSignInComponent.kt (100%) rename libraries/authentication/presentation/{ => mobile}/src/main/res/drawable/google.png (100%) rename libraries/authentication/presentation/{ => mobile}/src/main/res/values/strings.xml (100%) create mode 100644 libraries/authentication/presentation/web/.gitignore create mode 100644 libraries/authentication/presentation/web/build.gradle.kts create mode 100644 libraries/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/presentation/compose/GoogleSignInComponentWeb.kt create mode 100644 libraries/logging/.gitignore create mode 100644 libraries/logging/build.gradle.kts create mode 100644 libraries/logging/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/logging /AppLogger.kt create mode 100644 libraries/smokes/data/mobile/.gitignore rename libraries/smokes/data/{ => mobile}/build.gradle.kts (100%) rename libraries/smokes/data/{ => mobile}/src/main/AndroidManifest.xml (100%) create mode 100644 libraries/smokes/data/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeEntity.kt create mode 100644 libraries/smokes/data/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImpl.kt rename libraries/smokes/data/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/di/FirestoreModule.kt (100%) rename libraries/smokes/data/{ => mobile}/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/di/SmokeRepositoryModule.kt (100%) rename libraries/smokes/data/{ => mobile}/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImplTest.kt (99%) delete mode 100644 libraries/smokes/data/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeEntity.kt delete mode 100644 libraries/smokes/data/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImpl.kt create mode 100644 libraries/smokes/data/web/.gitignore create mode 100644 libraries/smokes/data/web/build.gradle.kts create mode 100644 libraries/smokes/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeEntity.kt create mode 100644 libraries/smokes/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImpl.kt create mode 100644 libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/model/Smoke.kt create mode 100644 libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeCount.kt create mode 100644 libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeStats.kt create mode 100644 libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/repository/SmokeRepository.kt create mode 100644 libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/AddSmokeUseCase.kt create mode 100644 libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/DeleteSmokeUseCase.kt create mode 100644 libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/EditSmokeUseCase.kt create mode 100644 libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokeStatsUseCase.kt create mode 100644 libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokesUseCase.kt rename libraries/smokes/domain/src/{main => jvmMain}/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/SyncWithWearUseCase.kt (100%) delete mode 100644 libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/model/Smoke.kt delete mode 100644 libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeCount.kt delete mode 100644 libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeStats.kt delete mode 100644 libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/repository/SmokeRepository.kt delete mode 100644 libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/AddSmokeUseCase.kt delete mode 100644 libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/DeleteSmokeUseCase.kt delete mode 100644 libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/EditSmokeUseCase.kt delete mode 100644 libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokeStatsUseCase.kt delete mode 100644 libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokesUseCase.kt diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 00000000..40d99d46 --- /dev/null +++ b/.firebaserc @@ -0,0 +1,23 @@ +{ + "projects": { + "staging": "smoke-analytics-staging", + "prod": "smoke-analytics" + }, + "targets": { + "smoke-analytics-staging": { + "hosting": { + "staging": [ + "smoke-analytics-staging" + ] + } + }, + "smoke-analytics": { + "hosting": { + "prod": [ + "smoke-analytics" + ] + } + } + }, + "etags": {} +} \ No newline at end of file diff --git a/.github/workflows/deploy-web-hosting.yml b/.github/workflows/deploy-web-hosting.yml new file mode 100644 index 00000000..fe135bb8 --- /dev/null +++ b/.github/workflows/deploy-web-hosting.yml @@ -0,0 +1,47 @@ +name: Deploy Web (Firebase Hosting) + +on: + workflow_dispatch: + inputs: + env: + description: "Target environment" + required: true + type: choice + options: [staging, prod] + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: zulu + java-version: 17 + + - name: Build web bundle + prepare hosting dir + run: ./gradlew :apps:web:prepareFirebaseHosting -Psmoke.env=${{ inputs.env }} --no-configuration-cache --rerun-tasks + + - name: Deploy to Firebase Hosting (staging) + if: ${{ inputs.env == 'staging' }} + uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: ${{ secrets.GITHUB_TOKEN }} + firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_SMOKE_ANALYTICS_STAGING }} + projectId: smoke-analytics-staging + target: staging + channelId: live + + - name: Deploy to Firebase Hosting (prod) + if: ${{ inputs.env == 'prod' }} + uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: ${{ secrets.GITHUB_TOKEN }} + firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_SMOKE_ANALYTICS }} + projectId: smoke-analytics + target: prod + channelId: live \ No newline at end of file diff --git a/.gitignore b/.gitignore index ff386d75..76240d8b 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,17 @@ playstore.credentials.json /buildSrc/build/ /buildSrc/build/classes/kotlin/main/ /playstore.credentiales.json + +# --- Kotlin compiler internals --- +.kotlin/ + +# --- Kotlin JS / Compose Web --- +kotlin-js-store/ +node_modules/ +yarn.lock + +# --- Compose Multiplatform --- +compose-build/ + +# Firebase local cache +.firebase/ diff --git a/apps/mobile/build.gradle.kts b/apps/mobile/build.gradle.kts index b46cee1b..74db12ad 100644 --- a/apps/mobile/build.gradle.kts +++ b/apps/mobile/build.gradle.kts @@ -177,13 +177,17 @@ dependencies { implementation(libs.timber) // Project modules. implementation(project(":libraries:design")) - implementation(project(":libraries:architecture:presentation")) - implementation(project(":features:authentication:presentation")) - implementation(project(":features:history:presentation")) - implementation(project(":features:home:presentation")) - implementation(project(":features:settings:presentation")) - implementation(project(":features:stats:presentation")) + implementation(project(":libraries:architecture:presentation:mobile")) + implementation(project(":libraries:authentication:domain")) + implementation(project(":libraries:smokes:domain")) + implementation(project(":features:authentication:presentation:mobile")) + implementation(project(":features:history:presentation:mobile")) + implementation(project(":features:home:presentation:mobile")) + implementation(project(":features:home:domain")) + implementation(project(":features:settings:presentation:mobile")) + implementation(project(":features:stats:presentation:mobile")) implementation(project(":features:chatbot:presentation")) + implementation(project(":features:chatbot:domain")) // Hilt annotation processor. kapt(libs.hilt.compiler) // Include devtools module only in debug builds. diff --git a/apps/mobile/src/main/java/com/feragusper/smokeanalytics/UseCasesModule.kt b/apps/mobile/src/main/java/com/feragusper/smokeanalytics/UseCasesModule.kt new file mode 100644 index 00000000..bfb72812 --- /dev/null +++ b/apps/mobile/src/main/java/com/feragusper/smokeanalytics/UseCasesModule.kt @@ -0,0 +1,74 @@ +package com.feragusper.smokeanalytics + +import com.feragusper.smokeanalytics.features.chatbot.domain.ChatbotRepository +import com.feragusper.smokeanalytics.features.chatbot.domain.ChatbotUseCase +import com.feragusper.smokeanalytics.features.home.domain.FetchSmokeCountListUseCase +import com.feragusper.smokeanalytics.libraries.authentication.domain.AuthenticationRepository +import com.feragusper.smokeanalytics.libraries.authentication.domain.FetchSessionUseCase +import com.feragusper.smokeanalytics.libraries.authentication.domain.SignOutUseCase +import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.AddSmokeUseCase +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.DeleteSmokeUseCase +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.EditSmokeUseCase +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokeStatsUseCase +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokesUseCase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object UseCasesModule { + + @Provides + fun provideFetchSessionUseCase( + repo: AuthenticationRepository + ) = FetchSessionUseCase(repo) + + @Provides + fun provideSignOutUseCase( + repo: AuthenticationRepository + ) = SignOutUseCase(repo) + + @Provides + fun provideAddSmokeUseCase( + repo: SmokeRepository + ) = AddSmokeUseCase(repo) + + @Provides + fun provideEditSmokeUseCase( + repo: SmokeRepository + ) = EditSmokeUseCase(repo) + + @Provides + fun provideDeleteSmokeUseCase( + repo: SmokeRepository + ) = DeleteSmokeUseCase(repo) + + @Provides + fun provideFetchSmokesUseCase( + repo: SmokeRepository + ) = FetchSmokesUseCase(repo) + + @Provides + fun provideFetchSmokeStatsUseCase( + repo: SmokeRepository + ) = FetchSmokeStatsUseCase(repo) + + @Provides + fun provideFetchSmokeCountListUseCase( + repo: SmokeRepository + ) = FetchSmokeCountListUseCase(repo) + + @Provides + fun provideChatbotUseCase( + smokeRepository: SmokeRepository, + authenticationRepository: AuthenticationRepository, + chatbotRepository: ChatbotRepository + ) = ChatbotUseCase( + smokeRepository = smokeRepository, + authRepository = authenticationRepository, + chatbotRepository = chatbotRepository + ) +} \ No newline at end of file diff --git a/apps/wear/build.gradle.kts b/apps/wear/build.gradle.kts index 586a9a9c..2d00fe4e 100644 --- a/apps/wear/build.gradle.kts +++ b/apps/wear/build.gradle.kts @@ -130,11 +130,11 @@ fun properties(propertiesFileName: String): Properties { } dependencies { - implementation(project(":libraries:architecture:presentation")) + implementation(project(":libraries:architecture:presentation:mobile")) implementation(project(":libraries:architecture:common")) implementation(project(":libraries:wear:domain")) implementation(project(":libraries:wear:data")) - implementation(project(":libraries:smokes:data")) + implementation(project(":libraries:smokes:data:mobile")) implementation(libs.bundles.androidx.base) implementation(platform(libs.androidx.compose.bom)) implementation(libs.bundles.compose) diff --git a/features/authentication/presentation/.gitignore b/apps/web/.gitignore similarity index 100% rename from features/authentication/presentation/.gitignore rename to apps/web/.gitignore diff --git a/apps/web/build.gradle.kts b/apps/web/build.gradle.kts new file mode 100644 index 00000000..1b28d9a8 --- /dev/null +++ b/apps/web/build.gradle.kts @@ -0,0 +1,98 @@ +import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.STRING +import org.gradle.api.tasks.Sync + +plugins { + kotlin("multiplatform") + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.buildkonfig) +} + +private val smokeEnv: String = providers.gradleProperty("smoke.env").orNull ?: "staging" + +private data class WebFirebaseConfig( + val apiKey: String, + val authDomain: String, + val projectId: String, + val storageBucket: String, + val appId: String, +) + +private val stagingConfig = WebFirebaseConfig( + apiKey = "AIzaSyCQsNHxeSiaXTr5KugYx4AmMxpflL_O9lI", + authDomain = "smoke-analytics-staging.firebaseapp.com", + projectId = "smoke-analytics-staging", + storageBucket = "smoke-analytics-staging.firebasestorage.app", + appId = "1:1016019974225:web:ed48cf5c4e50e5357ee070", +) + +private val prodConfig = WebFirebaseConfig( + apiKey = "AIzaSyC4P6TscDf8CgRFvup2uouvixEVRklnYkc", + authDomain = "smoke-analytics.firebaseapp.com", + projectId = "smoke-analytics", + storageBucket = "smoke-analytics.firebasestorage.app", + appId = "1:235081091876:web:1f590358b355fa999141b1", +) + +private fun selectedConfig(env: String): WebFirebaseConfig = + if (env == "prod") prodConfig else stagingConfig + +private val cfg = selectedConfig(smokeEnv) + +buildkonfig { + packageName = "com.feragusper.smokeanalytics.apps.web" + + defaultConfigs { + buildConfigField(STRING, "SMOKE_ENV", smokeEnv) + buildConfigField(STRING, "FIREBASE_API_KEY", cfg.apiKey) + buildConfigField(STRING, "FIREBASE_AUTH_DOMAIN", cfg.authDomain) + buildConfigField(STRING, "FIREBASE_PROJECT_ID", cfg.projectId) + buildConfigField(STRING, "FIREBASE_STORAGE_BUCKET", cfg.storageBucket) + buildConfigField(STRING, "FIREBASE_APP_ID", cfg.appId) + } +} + +kotlin { + js(IR) { + browser { + commonWebpackConfig { outputFileName = "smokeanalytics.js" } + } + binaries.executable() + } + sourceSets { + val jsMain by getting { + dependencies { + implementation(compose.runtime) + implementation(compose.html.core) + implementation(libs.kotlinx.coroutines.core) + + implementation(project(":libraries:architecture:domain")) + implementation(project(":features:home:domain")) + implementation(project(":features:home:presentation:web")) + implementation(project(":features:history:presentation:web")) + implementation(project(":features:authentication:presentation:web")) + implementation(project(":features:stats:presentation:web")) + implementation(project(":features:settings:presentation:web")) + implementation(project(":libraries:smokes:domain")) + implementation(project(":libraries:smokes:data:web")) + implementation(project(":libraries:authentication:domain")) + implementation(project(":libraries:authentication:data:web")) + + implementation("dev.gitlive:firebase-auth:1.13.0") + implementation("dev.gitlive:firebase-app:1.13.0") + } + } + } +} + +val prepareFirebaseHosting by tasks.registering(Sync::class) { + dependsOn(tasks.named("jsBrowserProductionWebpack")) + + val webpackOut = layout.buildDirectory.dir("kotlin-webpack/js/productionExecutable") + val resourcesOut = layout.buildDirectory.dir("processedResources/js/main") + val firebaseOut = layout.buildDirectory.dir("firebaseHosting") + + from(webpackOut) + from(resourcesOut) + into(firebaseOut) +} \ No newline at end of file diff --git a/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/AppRoot.kt b/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/AppRoot.kt new file mode 100644 index 00000000..d2deb660 --- /dev/null +++ b/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/AppRoot.kt @@ -0,0 +1,126 @@ +package com.feragusper.smokeanalytics + +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 com.feragusper.smokeanalytics.features.authentication.presentation.AuthenticationWebScreen +import com.feragusper.smokeanalytics.features.authentication.presentation.createAuthenticationWebDependencies +import com.feragusper.smokeanalytics.features.history.presentation.process.HistoryProcessHolder +import com.feragusper.smokeanalytics.features.history.presentation.HistoryWebDependencies +import com.feragusper.smokeanalytics.features.history.presentation.HistoryWebScreen +import com.feragusper.smokeanalytics.features.home.presentation.web.HomeWebDependencies +import com.feragusper.smokeanalytics.features.home.presentation.web.HomeWebScreen +import com.feragusper.smokeanalytics.features.settings.presentation.web.SettingsWebScreen +import com.feragusper.smokeanalytics.features.settings.presentation.web.createSettingsWebDependencies +import com.feragusper.smokeanalytics.features.stats.presentation.web.StatsWebScreen +import com.feragusper.smokeanalytics.features.stats.presentation.web.createStatsWebDependencies + +/** + * The root composable for the web application. + * + * @param graph The dependency graph for the web application. + */ +@Composable +fun AppRoot(graph: WebAppGraph) { + var tab by remember { mutableStateOf(WebTab.Home) } + var route by remember { mutableStateOf(WebRoute.Tabs) } + + val homeDeps = remember(graph) { + HomeWebDependencies( + homeProcessHolder = graph.homeProcessHolder, + ) + } + + val historyDeps = remember(graph) { + HistoryWebDependencies( + historyProcessHolder = HistoryProcessHolder( + addSmokeUseCase = graph.addSmokeUseCase, + editSmokeUseCase = graph.editSmokeUseCase, + deleteSmokeUseCase = graph.deleteSmokeUseCase, + fetchSmokesUseCase = graph.fetchSmokesUseCase, + fetchSessionUseCase = graph.fetchSessionUseCase, + ) + ) + } + + when (route) { + WebRoute.Tabs -> { + WebScaffold( + tab = tab, + onTabSelected = { tab = it }, + ) { + when (tab) { + WebTab.Home -> HomeWebScreen( + deps = homeDeps, + onNavigateToAuth = { route = WebRoute.Auth }, + onNavigateToHistory = { route = WebRoute.History }, + ) + + WebTab.Stats -> { + val statsDeps = remember(graph) { + createStatsWebDependencies( + fetchSmokeStatsUseCase = graph.fetchSmokeStatsUseCase, + ) + } + StatsWebScreen( + deps = statsDeps + ) + } + + WebTab.Settings -> { + val settingsDeps = remember(graph) { + createSettingsWebDependencies( + fetchSessionUseCase = graph.fetchSessionUseCase, + signOutUseCase = graph.signOutUseCase, + ) + } + + SettingsWebScreen(deps = settingsDeps) + } + } + } + } + + WebRoute.Auth -> { + val authDeps = remember(graph) { + createAuthenticationWebDependencies( + fetchSessionUseCase = graph.fetchSessionUseCase, + signOutUseCase = graph.signOutUseCase, + signInWithGoogle = { /* no-op for now (handled by UI component) */ } + ) + } + + + AuthenticationWebScreen( + deps = authDeps, + onLoggedIn = { + route = WebRoute.Tabs + tab = WebTab.Home + } + ) + } + + WebRoute.History -> { + HistoryWebScreen( + deps = historyDeps, + onNavigateUp = { + route = WebRoute.Tabs + tab = WebTab.Home + }, + onNavigateToAuth = { route = WebRoute.Auth }, + ) + } + } +} + +/** + * The dependency graph for the web application. + */ +private enum class WebRoute { Tabs, Auth, History } + +/** + * The tabs for the web application. + */ +enum class WebTab { Home, Stats, Settings } diff --git a/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/FirebaseWebInit.kt b/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/FirebaseWebInit.kt new file mode 100644 index 00000000..e996c66e --- /dev/null +++ b/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/FirebaseWebInit.kt @@ -0,0 +1,64 @@ +package com.feragusper.smokeanalytics + +import com.feragusper.smokeanalytics.apps.web.BuildKonfig +import dev.gitlive.firebase.Firebase +import dev.gitlive.firebase.FirebaseOptions +import dev.gitlive.firebase.initialize + +/** + * Initializes Firebase for the web application. + */ +object FirebaseWebInit { + + /** + * Initializes Firebase with the provided options. + */ + fun init() { + // Avoid double init in HMR / recomposition scenarios + runCatching { +// Firebase.initialize( +// options = FirebaseOptions( +// apiKey = "AIzaSyCQsNHxeSiaXTr5KugYx4AmMxpflL_O9lI", +// authDomain = "smoke-analytics-staging.firebaseapp.com", +// projectId = "smoke-analytics-staging", +// storageBucket = "smoke-analytics-staging.firebasestorage.app", // optional if you use storage +// applicationId = "1:1016019974225:web:ed48cf5c4e50e5357ee070", +// //messagingSenderId: "1016019974225", +// //measurementId: "G-7MPPG1QTDD" +// ) +// ) + + Firebase.initialize( + options = FirebaseOptions( + apiKey = BuildKonfig.FIREBASE_API_KEY, + authDomain = BuildKonfig.FIREBASE_AUTH_DOMAIN, + projectId = BuildKonfig.FIREBASE_PROJECT_ID, + storageBucket = BuildKonfig.FIREBASE_STORAGE_BUCKET, + applicationId = BuildKonfig.FIREBASE_APP_ID, + ) + ) + } + } +} + +//// Import the functions you need from the SDKs you need +//import { initializeApp } from "firebase/app"; +//import { getAnalytics } from "firebase/analytics"; +//// TODO: Add SDKs for Firebase products that you want to use +//// https://firebase.google.com/docs/web/setup#available-libraries +// +//// Your web app's Firebase configuration +//// For Firebase JS SDK v7.20.0 and later, measurementId is optional +//const firebaseConfig = { +// apiKey: "AIzaSyC4P6TscDf8CgRFvup2uouvixEVRklnYkc", +// authDomain: "smoke-analytics.firebaseapp.com", +// projectId: "smoke-analytics", +// storageBucket: "smoke-analytics.firebasestorage.app", +// messagingSenderId: "235081091876", +// appId: "1:235081091876:web:1f590358b355fa999141b1", +// measurementId: "G-QKWQM4SMN8" +//}; +// +//// Initialize Firebase +//const app = initializeApp(firebaseConfig); +//const analytics = getAnalytics(app); \ No newline at end of file diff --git a/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/WebAppGraph.kt b/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/WebAppGraph.kt new file mode 100644 index 00000000..fcd923f7 --- /dev/null +++ b/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/WebAppGraph.kt @@ -0,0 +1,94 @@ +package com.feragusper.smokeanalytics + +import com.feragusper.smokeanalytics.features.home.domain.FetchSmokeCountListUseCase +import com.feragusper.smokeanalytics.features.home.presentation.web.HomeProcessHolder +import com.feragusper.smokeanalytics.libraries.authentication.data.AuthenticationRepositoryImpl +import com.feragusper.smokeanalytics.libraries.authentication.domain.AuthenticationRepository +import com.feragusper.smokeanalytics.libraries.authentication.domain.FetchSessionUseCase +import com.feragusper.smokeanalytics.libraries.authentication.domain.SignOutUseCase +import com.feragusper.smokeanalytics.libraries.smokes.data.SmokeRepositoryImpl +import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.AddSmokeUseCase +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.DeleteSmokeUseCase +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.EditSmokeUseCase +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokeStatsUseCase +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokesUseCase +import dev.gitlive.firebase.Firebase +import dev.gitlive.firebase.auth.auth +import dev.gitlive.firebase.auth.externals.GoogleAuthProvider +import dev.gitlive.firebase.auth.externals.signInWithPopup +import kotlinx.coroutines.await + +/** + * The dependency graph for the web application. + * + * @property homeProcessHolder The process holder for the home screen. + * @property fetchSessionUseCase The use case for fetching the session. + * @property signOutUseCase The use case for signing out. + * @property addSmokeUseCase The use case for adding a smoke. + * @property editSmokeUseCase The use case for editing a smoke. + * @property deleteSmokeUseCase The use case for deleting a smoke. + * @property fetchSmokesUseCase The use case for fetching smokes. + * @property fetchSmokeStatsUseCase The use case for fetching smoke stats. + * @property signInWithGoogleWeb The function for signing in with Google. + */ +data class WebAppGraph( + val homeProcessHolder: HomeProcessHolder, + val fetchSessionUseCase: FetchSessionUseCase, + val signOutUseCase: SignOutUseCase, + val addSmokeUseCase: AddSmokeUseCase, + val editSmokeUseCase: EditSmokeUseCase, + val deleteSmokeUseCase: DeleteSmokeUseCase, + val fetchSmokesUseCase: FetchSmokesUseCase, + val fetchSmokeStatsUseCase: FetchSmokeStatsUseCase, + val signInWithGoogleWeb: suspend () -> Unit, +) { + companion object { + + /** + * Creates a new instance of the dependency graph for the web application. + * + * @return The new instance of the dependency graph. + */ + fun create(): WebAppGraph { + val authRepo: AuthenticationRepository = AuthenticationRepositoryImpl() + val smokeRepo: SmokeRepository = SmokeRepositoryImpl() + + val fetchSession = FetchSessionUseCase(authRepo) + val signOut = SignOutUseCase(authRepo) + + val addSmoke = AddSmokeUseCase(smokeRepo) + val editSmoke = EditSmokeUseCase(smokeRepo) + val deleteSmoke = DeleteSmokeUseCase(smokeRepo) + val fetchSmokes = FetchSmokesUseCase(smokeRepo) + val fetchStats = FetchSmokeStatsUseCase(smokeRepo) + val fetchSmokeCounts = FetchSmokeCountListUseCase(smokeRepo) + + val homeProcessHolder = HomeProcessHolder( + addSmokeUseCase = addSmoke, + editSmokeUseCase = editSmoke, + deleteSmokeUseCase = deleteSmoke, + fetchSmokeCountListUseCase = fetchSmokeCounts, + fetchSessionUseCase = fetchSession, + ) + + val signInWithGoogleWeb: suspend () -> Unit = { + val auth = Firebase.auth + val provider = GoogleAuthProvider() + signInWithPopup(auth.js, provider).await() + } + + return WebAppGraph( + homeProcessHolder = homeProcessHolder, + fetchSessionUseCase = fetchSession, + signOutUseCase = signOut, + addSmokeUseCase = addSmoke, + editSmokeUseCase = editSmoke, + deleteSmokeUseCase = deleteSmoke, + fetchSmokesUseCase = fetchSmokes, + fetchSmokeStatsUseCase = fetchStats, + signInWithGoogleWeb = signInWithGoogleWeb, + ) + } + } +} \ No newline at end of file diff --git a/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/WebScaffold.kt b/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/WebScaffold.kt new file mode 100644 index 00000000..501ab6d9 --- /dev/null +++ b/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/WebScaffold.kt @@ -0,0 +1,103 @@ +package com.feragusper.smokeanalytics + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.css.DisplayStyle +import org.jetbrains.compose.web.css.FlexDirection +import org.jetbrains.compose.web.css.JustifyContent +import org.jetbrains.compose.web.css.cursor +import org.jetbrains.compose.web.css.display +import org.jetbrains.compose.web.css.flexDirection +import org.jetbrains.compose.web.css.flexGrow +import org.jetbrains.compose.web.css.fontWeight +import org.jetbrains.compose.web.css.height +import org.jetbrains.compose.web.css.justifyContent +import org.jetbrains.compose.web.css.padding +import org.jetbrains.compose.web.css.px +import org.jetbrains.compose.web.css.vh +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.Text + +/** + * The scaffold for the web application. + * + * @param tab The current tab. + * @param onTabSelected The callback for when a tab is selected. + * @param content The content to display. + */ +@Composable +fun WebScaffold( + tab: WebTab, + onTabSelected: (WebTab) -> Unit, + content: @Composable () -> Unit, +) { + Div({ + style { + display(DisplayStyle.Flex) + flexDirection(FlexDirection.Column) + height(100.vh) + } + }) { + Div({ + style { + flexGrow(1) + padding(16.px) + } + }) { + content() + } + + WebBottomNav( + selected = tab, + onSelected = onTabSelected, + ) + } +} + +@Composable +private fun WebBottomNav( + selected: WebTab, + onSelected: (WebTab) -> Unit, +) { + Div({ + style { + display(DisplayStyle.Flex) + justifyContent(JustifyContent.SpaceAround) + padding(12.px) + property( + "border-top", + "1px solid lightgray" + ) + } + }) { + WebTab.entries.forEach { tab -> + WebNavItem( + label = tab.label(), + selected = tab == selected, + onClick = { onSelected(tab) }, + ) + } + } +} + +@Composable +private fun WebNavItem( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + Div({ + onClick { onClick() } + style { + cursor("pointer") + fontWeight(if (selected) "bold" else "normal") + } + }) { + Text(label) + } +} + +private fun WebTab.label(): String = when (this) { + WebTab.Home -> "Home" + WebTab.Stats -> "Stats" + WebTab.Settings -> "Settings" +} \ No newline at end of file diff --git a/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/main.kt b/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/main.kt new file mode 100644 index 00000000..f96e04ad --- /dev/null +++ b/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/main.kt @@ -0,0 +1,15 @@ +package com.feragusper.smokeanalytics + +import org.jetbrains.compose.web.renderComposable + +/** + * The main entry point for the web application. + */ +fun main() { + FirebaseWebInit.init() + val graph = WebAppGraph.create() + + renderComposable(rootElementId = "root") { + AppRoot(graph) + } +} \ No newline at end of file diff --git a/apps/web/src/jsMain/resources/index.html b/apps/web/src/jsMain/resources/index.html new file mode 100644 index 00000000..4bbb271b --- /dev/null +++ b/apps/web/src/jsMain/resources/index.html @@ -0,0 +1,13 @@ + + + + + Smoke Analytics Web + + +
+ + + + + \ No newline at end of file diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 171c9e1e..53ddcd73 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -1,4 +1,3 @@ -// buildSrc/build.gradle.kts // This build script sets up the buildSrc module using the Kotlin DSL. // It defines repositories and dependencies required to work with the version catalog // and common Gradle plugins used throughout the project. diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts index ff21a356..6d531254 100644 --- a/buildSrc/settings.gradle.kts +++ b/buildSrc/settings.gradle.kts @@ -1,4 +1,3 @@ -// buildSrc/settings.gradle.kts // This file configures the version catalog for dependencies used throughout the project. // It creates a catalog named "libs" that references the TOML file in the gradle directory of the project root. dependencyResolutionManagement { diff --git a/buildSrc/src/main/kotlin/kmp-lib.gradle.kts b/buildSrc/src/main/kotlin/kmp-lib.gradle.kts new file mode 100644 index 00000000..7f329249 --- /dev/null +++ b/buildSrc/src/main/kotlin/kmp-lib.gradle.kts @@ -0,0 +1,30 @@ +plugins { + kotlin("multiplatform") + id("org.jetbrains.kotlinx.kover") + id("org.sonarqube") +} + +kotlin { + jvm() + + js(IR) { + browser() + } + + jvmToolchain(17) + + sourceSets { + val commonMain by getting + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + + val jvmMain by getting + val jvmTest by getting + + val jsMain by getting + val jsTest by getting + } +} \ No newline at end of file diff --git a/features/history/presentation/.gitignore b/features/authentication/presentation/mobile/.gitignore similarity index 100% rename from features/history/presentation/.gitignore rename to features/authentication/presentation/mobile/.gitignore diff --git a/features/authentication/presentation/build.gradle.kts b/features/authentication/presentation/mobile/build.gradle.kts similarity index 98% rename from features/authentication/presentation/build.gradle.kts rename to features/authentication/presentation/mobile/build.gradle.kts index 39908625..e4c7d751 100644 --- a/features/authentication/presentation/build.gradle.kts +++ b/features/authentication/presentation/mobile/build.gradle.kts @@ -26,11 +26,11 @@ android { dependencies { // Architecture and presentation layers - implementation(project(":libraries:architecture:presentation")) + implementation(project(":libraries:architecture:presentation:mobile")) implementation(project(":libraries:architecture:domain")) // Authentication modules - implementation(project(":libraries:authentication:presentation")) + implementation(project(":libraries:authentication:presentation:mobile")) implementation(project(":libraries:authentication:domain")) // Design system for consistent theming and UI components diff --git a/features/authentication/presentation/src/main/AndroidManifest.xml b/features/authentication/presentation/mobile/src/main/AndroidManifest.xml similarity index 100% rename from features/authentication/presentation/src/main/AndroidManifest.xml rename to features/authentication/presentation/mobile/src/main/AndroidManifest.xml diff --git a/features/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationActivity.kt b/features/authentication/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationActivity.kt similarity index 100% rename from features/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationActivity.kt rename to features/authentication/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationActivity.kt diff --git a/features/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationView.kt b/features/authentication/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationView.kt similarity index 100% rename from features/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationView.kt rename to features/authentication/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationView.kt diff --git a/features/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationViewModel.kt b/features/authentication/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationViewModel.kt similarity index 100% rename from features/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationViewModel.kt rename to features/authentication/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationViewModel.kt diff --git a/features/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/AuthenticationIntent.kt b/features/authentication/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/AuthenticationIntent.kt similarity index 100% rename from features/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/AuthenticationIntent.kt rename to features/authentication/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/AuthenticationIntent.kt diff --git a/features/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/AuthenticationResult.kt b/features/authentication/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/AuthenticationResult.kt similarity index 100% rename from features/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/AuthenticationResult.kt rename to features/authentication/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/AuthenticationResult.kt diff --git a/features/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/compose/AuthenticationViewState.kt b/features/authentication/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/compose/AuthenticationViewState.kt similarity index 100% rename from features/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/compose/AuthenticationViewState.kt rename to features/authentication/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/compose/AuthenticationViewState.kt diff --git a/features/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/navigation/AuthenticationNavigationGraph.kt b/features/authentication/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/navigation/AuthenticationNavigationGraph.kt similarity index 100% rename from features/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/navigation/AuthenticationNavigationGraph.kt rename to features/authentication/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/navigation/AuthenticationNavigationGraph.kt diff --git a/features/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/navigation/AuthenticationNavigator.kt b/features/authentication/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/navigation/AuthenticationNavigator.kt similarity index 100% rename from features/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/navigation/AuthenticationNavigator.kt rename to features/authentication/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/navigation/AuthenticationNavigator.kt diff --git a/features/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/process/AuthenticationProcessHolder.kt b/features/authentication/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/process/AuthenticationProcessHolder.kt similarity index 100% rename from features/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/process/AuthenticationProcessHolder.kt rename to features/authentication/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/authentication/presentation/process/AuthenticationProcessHolder.kt diff --git a/features/authentication/presentation/src/main/res/values/strings.xml b/features/authentication/presentation/mobile/src/main/res/values/strings.xml similarity index 100% rename from features/authentication/presentation/src/main/res/values/strings.xml rename to features/authentication/presentation/mobile/src/main/res/values/strings.xml diff --git a/features/authentication/presentation/src/test/java/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationViewModelTest.kt b/features/authentication/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationViewModelTest.kt similarity index 100% rename from features/authentication/presentation/src/test/java/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationViewModelTest.kt rename to features/authentication/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationViewModelTest.kt diff --git a/features/authentication/presentation/src/test/java/com/feragusper/smokeanalytics/features/authentication/presentation/process/AuthenticationProcessHolderTest.kt b/features/authentication/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/authentication/presentation/process/AuthenticationProcessHolderTest.kt similarity index 100% rename from features/authentication/presentation/src/test/java/com/feragusper/smokeanalytics/features/authentication/presentation/process/AuthenticationProcessHolderTest.kt rename to features/authentication/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/authentication/presentation/process/AuthenticationProcessHolderTest.kt diff --git a/features/home/presentation/.gitignore b/features/authentication/presentation/web/.gitignore similarity index 100% rename from features/home/presentation/.gitignore rename to features/authentication/presentation/web/.gitignore diff --git a/features/authentication/presentation/web/build.gradle.kts b/features/authentication/presentation/web/build.gradle.kts new file mode 100644 index 00000000..0d1fa6fe --- /dev/null +++ b/features/authentication/presentation/web/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + kotlin("multiplatform") + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.compose.compiler) +} + +kotlin { + js(IR) { browser() } + + sourceSets { + val jsMain by getting { + dependencies { + implementation(libs.kotlinx.coroutines.core) + implementation(compose.runtime) + implementation(compose.html.core) + + implementation(project(":libraries:authentication:domain")) + implementation(project(":libraries:authentication:data:web")) + implementation(project(":libraries:authentication:presentation:web")) + + implementation(libs.gitlive.firebase.auth) + implementation(libs.firebase.app) + } + } + } +} \ No newline at end of file diff --git a/features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationViewState.kt b/features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationViewState.kt new file mode 100644 index 00000000..beefc164 --- /dev/null +++ b/features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationViewState.kt @@ -0,0 +1,25 @@ +package com.feragusper.smokeanalytics.features.authentication.presentation + +/** + * Represents the current state of the authentication feature. + * + * @property displayLoading Indicates whether the loading indicator should be displayed. + * @property isLoggedIn Indicates whether the user is currently logged in. + * @property error The error that occurred during the authentication process, if any. + */ +data class AuthenticationViewState( + val displayLoading: Boolean = false, + val isLoggedIn: Boolean = false, + val error: AuthenticationError? = null, +) { + /** + * Represents the different types of errors that can occur during the authentication process. + */ + sealed interface AuthenticationError { + + /** + * Represents an error that occurred due to a generic error. + */ + data object Generic : AuthenticationError + } +} \ No newline at end of file diff --git a/features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationWebDependencies.kt b/features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationWebDependencies.kt new file mode 100644 index 00000000..65a7fe69 --- /dev/null +++ b/features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationWebDependencies.kt @@ -0,0 +1,23 @@ +package com.feragusper.smokeanalytics.features.authentication.presentation + +import com.feragusper.smokeanalytics.features.authentication.presentation.process.AuthenticationProcessHolder +import com.feragusper.smokeanalytics.libraries.authentication.domain.FetchSessionUseCase +import com.feragusper.smokeanalytics.libraries.authentication.domain.SignOutUseCase + +class AuthenticationWebDependencies( + val processHolder: AuthenticationProcessHolder, +) + +fun createAuthenticationWebDependencies( + fetchSessionUseCase: FetchSessionUseCase, + signOutUseCase: SignOutUseCase, + signInWithGoogle: suspend () -> Unit, +): AuthenticationWebDependencies { + return AuthenticationWebDependencies( + processHolder = AuthenticationProcessHolder( + fetchSessionUseCase = fetchSessionUseCase, + signOutUseCase = signOutUseCase, + signInWithGoogle = signInWithGoogle, + ) + ) +} \ No newline at end of file diff --git a/features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationWebScreen.kt b/features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationWebScreen.kt new file mode 100644 index 00000000..a9841b99 --- /dev/null +++ b/features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/AuthenticationWebScreen.kt @@ -0,0 +1,94 @@ +package com.feragusper.smokeanalytics.features.authentication.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import com.feragusper.smokeanalytics.features.authentication.presentation.mvi.AuthenticationIntent +import com.feragusper.smokeanalytics.features.authentication.presentation.mvi.AuthenticationWebStore +import com.feragusper.smokeanalytics.libraries.authentication.presentation.compose.GoogleSignInComponentWeb +import org.jetbrains.compose.web.dom.Button +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.H2 +import org.jetbrains.compose.web.dom.P +import org.jetbrains.compose.web.dom.Text + +/** + * Composable function that represents the authentication screen for the web platform. + * It manages the authentication flow, including displaying a sign-in component and handling + * user interactions like signing in, signing out, and navigating away. + * + * @param deps The dependencies required by this screen, such as the process holder for the MVI store. + * @param onLoggedIn A lambda function to be invoked when the user has successfully logged in. + */ +@Composable +fun AuthenticationWebScreen( + deps: AuthenticationWebDependencies, + onLoggedIn: () -> Unit, +) { + val store = remember(deps) { AuthenticationWebStore(processHolder = deps.processHolder) } + + LaunchedEffect(store) { store.start() } + + val state by store.state.collectAsState() + + state.Render( + onLoggedIn = onLoggedIn, + onIntent = { intent -> + store.send(intent) + } + ) +} + +/** + * Composable function that renders the authentication view state. + * + * @param onLoggedIn A lambda function to be invoked when the user has successfully logged in. + * @param onIntent A lambda function to be invoked when an intent is received. + */ +@Composable +fun AuthenticationViewState.Render( + onLoggedIn: () -> Unit, + onIntent: (AuthenticationIntent) -> Unit, +) { + if (isLoggedIn) { + onLoggedIn() + return + } + + Div { + H2 { Text("Auth") } + + if (displayLoading) { + P { Text("Loading...") } + } + + GoogleSignInComponentWeb( + onSignInSuccess = { + onIntent(AuthenticationIntent.FetchUser) + }, + onSignInError = { + onIntent(AuthenticationIntent.FetchUser) + } + ) + + Button(attrs = { onClick { onIntent(AuthenticationIntent.SignOut) } }) { + Text("Sign out") + } + + Button(attrs = { onClick { onIntent(AuthenticationIntent.NavigateUp) } }) { + Text("Back") + } + + if (error != null) { + P { + Text( + when (error) { + AuthenticationViewState.AuthenticationError.Generic -> "Something went wrong" + } + ) + } + } + } +} \ No newline at end of file diff --git a/features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/AuthenticationIntent.kt b/features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/AuthenticationIntent.kt new file mode 100644 index 00000000..3c0ec49b --- /dev/null +++ b/features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/AuthenticationIntent.kt @@ -0,0 +1,27 @@ +package com.feragusper.smokeanalytics.features.authentication.presentation.mvi + +/** + * Represents the different intents that can be sent to the authentication feature. + */ +sealed interface AuthenticationIntent { + + /** + * Represents the intent to fetch the current authentication session. + */ + data object FetchUser : AuthenticationIntent + + /** + * Represents the intent to sign in with Google. + */ + data object SignInWithGoogle : AuthenticationIntent + + /** + * Represents the intent to sign out the current user. + */ + data object SignOut : AuthenticationIntent + + /** + * Represents the intent to navigate back to the previous screen. + */ + data object NavigateUp : AuthenticationIntent +} \ No newline at end of file diff --git a/features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/AuthenticationResult.kt b/features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/AuthenticationResult.kt new file mode 100644 index 00000000..b98d6657 --- /dev/null +++ b/features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/AuthenticationResult.kt @@ -0,0 +1,37 @@ +package com.feragusper.smokeanalytics.features.authentication.presentation.mvi + +/** + * Represents the different results that can be produced by the authentication feature. + */ +sealed interface AuthenticationResult { + + /** + * Represents the result of a successful authentication attempt. + */ + data object Loading : AuthenticationResult + + /** + * Represents the result of a successful authentication attempt. + */ + data object UserLoggedIn : AuthenticationResult + + /** + * Represents the result of a successful authentication attempt. + */ + data object UserLoggedOut : AuthenticationResult + + /** + * Represents the result of a successful authentication attempt. + */ + data object NavigateUp : AuthenticationResult + + /** + * Represents the result of a failed authentication attempt. + */ + sealed interface Error : AuthenticationResult { + /** + * Represents the result of a failed authentication attempt due to a generic error. + */ + data object Generic : Error + } +} \ No newline at end of file diff --git a/features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/AuthenticationWebStore.kt b/features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/AuthenticationWebStore.kt new file mode 100644 index 00000000..6777ae96 --- /dev/null +++ b/features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/mvi/AuthenticationWebStore.kt @@ -0,0 +1,105 @@ +package com.feragusper.smokeanalytics.features.authentication.presentation.mvi + +import com.feragusper.smokeanalytics.features.authentication.presentation.AuthenticationViewState +import com.feragusper.smokeanalytics.features.authentication.presentation.process.AuthenticationProcessHolder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch + +/** + * Manages the state for the authentication feature, acting as a central hub for UI-related data. + * This class follows a Model-View-Intent (MVI) pattern, where it consumes [AuthenticationIntent]s, + * processes them via the [AuthenticationProcessHolder], and emits [com.feragusper.smokeanalytics.features.authentication.presentation.AuthenticationViewState] updates. + * + * It is responsible for: + * 1. Receiving intents from the UI (e.g., login attempts, user session checks). + * 2. Delegating the business logic for these intents to the [processHolder]. + * 3. Reducing the results from the process holder into a new view state. + * 4. Exposing the current [com.feragusper.smokeanalytics.features.authentication.presentation.AuthenticationViewState] as a [StateFlow] for the UI to observe. + * + * @property processHolder The processor that contains the business logic for handling authentication intents. + * @property scope The [CoroutineScope] in which the store's logic runs. Defaults to a scope with a [SupervisorJob] and [Dispatchers.Default]. + */ +class AuthenticationWebStore( + private val processHolder: AuthenticationProcessHolder, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), +) { + private val intents = Channel(capacity = Channel.Factory.BUFFERED) + + private val _state = MutableStateFlow(AuthenticationViewState()) + val state: StateFlow = _state.asStateFlow() + + /** + * Sends an [AuthenticationIntent] to the store for processing. + * + * @param intent The intent to be processed. + */ + fun send(intent: AuthenticationIntent) { + intents.trySend(intent) + } + + /** + * Initializes the store, starting the flow of processing intents and updating the state. + * + * This function launches a coroutine that listens for incoming [AuthenticationIntent]s from a channel. + * Each intent is processed by the [AuthenticationProcessHolder], and the resulting [AuthenticationResult] + * is used to update the view state. + * + * It also sends an initial `FetchUser` intent to check the current user's session status upon startup. + */ + fun start() { + scope.launch { + intents + .receiveAsFlow() + .flatMapLatest { intent -> processHolder.processIntent(intent) } + .collect { result -> reduce(result) } + } + + send(AuthenticationIntent.FetchUser) + } + + private fun reduce(result: AuthenticationResult) { + val previous = _state.value + + val newState = when (result) { + AuthenticationResult.Loading -> previous.copy( + displayLoading = true, + error = null, + ) + + AuthenticationResult.UserLoggedIn -> previous.copy( + displayLoading = false, + isLoggedIn = true, + error = null, + ) + + AuthenticationResult.UserLoggedOut -> previous.copy( + displayLoading = false, + isLoggedIn = false, + error = null, + ) + + is AuthenticationResult.Error -> previous.copy( + displayLoading = false, + isLoggedIn = false, + error = result.toAuthError(), + ) + + AuthenticationResult.NavigateUp -> previous + } + + _state.value = newState + } + + private fun AuthenticationResult.Error.toAuthError(): AuthenticationViewState.AuthenticationError = + when (this) { + AuthenticationResult.Error.Generic -> AuthenticationViewState.AuthenticationError.Generic + } +} \ No newline at end of file diff --git a/features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/process/AuthenticationProcessHolder.kt b/features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/process/AuthenticationProcessHolder.kt new file mode 100644 index 00000000..3f6676ba --- /dev/null +++ b/features/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/authentication/presentation/process/AuthenticationProcessHolder.kt @@ -0,0 +1,65 @@ +package com.feragusper.smokeanalytics.features.authentication.presentation.process + +import com.feragusper.smokeanalytics.features.authentication.presentation.mvi.AuthenticationIntent +import com.feragusper.smokeanalytics.features.authentication.presentation.mvi.AuthenticationResult +import com.feragusper.smokeanalytics.libraries.authentication.domain.FetchSessionUseCase +import com.feragusper.smokeanalytics.libraries.authentication.domain.Session +import com.feragusper.smokeanalytics.libraries.authentication.domain.SignOutUseCase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flow + +/** + * Holds the logic for processing authentication-related intents and emitting corresponding results. + * + * @property fetchSessionUseCase Use case to fetch the current authentication session. + * @property signOutUseCase Use case to sign out the current user. + * @property signInWithGoogle Lambda to handle Google sign-in. + */ +class AuthenticationProcessHolder( + private val fetchSessionUseCase: FetchSessionUseCase, + private val signOutUseCase: SignOutUseCase, + private val signInWithGoogle: suspend () -> Unit, +) { + + /** + * Processes the given authentication intent and returns a flow of authentication results. + * + * @param intent The authentication intent to process. + */ + fun processIntent(intent: AuthenticationIntent): Flow = when (intent) { + AuthenticationIntent.FetchUser -> processFetchUser() + AuthenticationIntent.SignInWithGoogle -> processSignIn() + AuthenticationIntent.SignOut -> processSignOut() + AuthenticationIntent.NavigateUp -> flow { emit(AuthenticationResult.NavigateUp) } + } + + private fun processFetchUser(): Flow = flow { + emit(AuthenticationResult.Loading) + emit(fetchSessionResult()) + }.catch { + emit(AuthenticationResult.Error.Generic) + } + + private fun processSignIn(): Flow = flow { + emit(AuthenticationResult.Loading) + signInWithGoogle() + emit(fetchSessionResult()) + }.catch { + emit(AuthenticationResult.Error.Generic) + } + + private fun processSignOut(): Flow = flow { + emit(AuthenticationResult.Loading) + signOutUseCase() + emit(AuthenticationResult.UserLoggedOut) + }.catch { + emit(AuthenticationResult.Error.Generic) + } + + private fun fetchSessionResult(): AuthenticationResult = + when (fetchSessionUseCase()) { + is Session.LoggedIn -> AuthenticationResult.UserLoggedIn + is Session.Anonymous -> AuthenticationResult.UserLoggedOut + } +} \ No newline at end of file diff --git a/features/chatbot/data/build.gradle.kts b/features/chatbot/data/build.gradle.kts index d852fbe5..d4f9d504 100644 --- a/features/chatbot/data/build.gradle.kts +++ b/features/chatbot/data/build.gradle.kts @@ -33,6 +33,8 @@ dependencies { implementation(libs.generativeai) + implementation(libs.kotlinx.datetime) + // Dagger Hilt dependencies for dependency injection. implementation(libs.hilt) kapt(libs.hilt.compiler) diff --git a/features/chatbot/data/src/main/java/com/feragusper/smokeanalytics/features/chatbot/data/ChatbotRepositoryImpl.kt b/features/chatbot/data/src/main/java/com/feragusper/smokeanalytics/features/chatbot/data/ChatbotRepositoryImpl.kt index e7566a96..037cfcd9 100644 --- a/features/chatbot/data/src/main/java/com/feragusper/smokeanalytics/features/chatbot/data/ChatbotRepositoryImpl.kt +++ b/features/chatbot/data/src/main/java/com/feragusper/smokeanalytics/features/chatbot/data/ChatbotRepositoryImpl.kt @@ -3,49 +3,76 @@ package com.feragusper.smokeanalytics.features.chatbot.data import com.feragusper.smokeanalytics.features.chatbot.domain.ChatbotRepository import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke import com.google.ai.client.generativeai.GenerativeModel -import java.time.LocalDate import javax.inject.Inject import javax.inject.Singleton +import kotlinx.datetime.Clock +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime @Singleton class ChatbotRepositoryImpl @Inject constructor( - private val gemini: GenerativeModel + private val gemini: GenerativeModel, ) : ChatbotRepository { - override suspend fun sendMessage(message: String) = runCatching { - gemini.generateContent(message).text - }.getOrElse { - it.printStackTrace() - "Ups, el coach tuvo un mal dĆ­a y no pudo responder." - } ?: "Sin respuesta del modelo." + private val timeZone: TimeZone = TimeZone.currentSystemDefault() + + override suspend fun sendMessage(message: String): String { + return runCatching { + gemini.generateContent(message).text + }.getOrElse { throwable -> + throwable.printStackTrace() + null + } ?: "No response from the model." + } override suspend fun sendInitialMessageWithContext( name: String, - recentSmokes: List + recentSmokes: List, ): String { - val todayCount = recentSmokes.count { it.date.toLocalDate() == LocalDate.now() } + val today = Clock.System.now().toLocalDateTime(timeZone).date + + val todayCount = recentSmokes.count { smoke -> + smoke.date.toLocalDateTime(timeZone).date == today + } + val total = recentSmokes.size - val lastDate = recentSmokes.firstOrNull()?.date?.toString() ?: "No registrado" - val prompt = buildPrompt(name, todayCount, total, lastDate) + val lastDate = recentSmokes + .firstOrNull() + ?.date + ?.toLocalDateTime(timeZone) + ?.toString() + ?: "Not recorded" + + val prompt = buildPrompt( + name = name, + today = todayCount, + total = total, + lastDate = lastDate, + ) return runCatching { gemini.generateContent(prompt).text - }.getOrElse { - it.printStackTrace() - "No se pudo generar un mensaje motivacional. ProbĆ” mĆ”s tarde." - } ?: "Sin respuesta del modelo." + }.getOrElse { throwable -> + throwable.printStackTrace() + null + } ?: "No response from the model." } - private fun buildPrompt(name: String, today: Int, total: Int, lastDate: String): String = """ - Sos un coach motivacional para dejar de fumar. RespondĆ© con empatĆ­a, motivación y un poco de humor. - EstĆ”s hablando con $name - Datos del usuario: - - Fumó $today cigarrillos hoy - - Tiene $total cigarrillos registrados - - El Ćŗltimo fue el $lastDate + private fun buildPrompt( + name: String, + today: Int, + total: Int, + lastDate: String, + ): String = """ + You are a motivational coach helping someone quit smoking. Respond with empathy, motivation, and a bit of humor. + You are talking to $name. + + User data: + - Smoked $today cigarettes today + - Has $total cigarettes recorded + - The last one was at $lastDate - ĀæQuĆ© le dirĆ­as para motivarlo? + What would you say to motivate them? """.trimIndent() -} - +} \ No newline at end of file diff --git a/features/chatbot/data/src/test/java/com/feragusper/smokeanalytics/features/chatbot/data/ChatbotRepositoryImplTest.kt b/features/chatbot/data/src/test/java/com/feragusper/smokeanalytics/features/chatbot/data/ChatbotRepositoryImplTest.kt index 63969cb5..3c688915 100644 --- a/features/chatbot/data/src/test/java/com/feragusper/smokeanalytics/features/chatbot/data/ChatbotRepositoryImplTest.kt +++ b/features/chatbot/data/src/test/java/com/feragusper/smokeanalytics/features/chatbot/data/ChatbotRepositoryImplTest.kt @@ -1,6 +1,5 @@ package com.feragusper.smokeanalytics.features.chatbot.data -import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke import com.google.ai.client.generativeai.GenerativeModel import com.google.ai.client.generativeai.type.GenerateContentResponse import io.mockk.coEvery diff --git a/features/chatbot/domain/build.gradle.kts b/features/chatbot/domain/build.gradle.kts index 9b8b2fa6..f60387b1 100644 --- a/features/chatbot/domain/build.gradle.kts +++ b/features/chatbot/domain/build.gradle.kts @@ -1,26 +1,45 @@ -plugins { - // Apply the Java Library plugin for this module. - `java-lib` - // Enable Kotlin annotation processing. - id("kotlin-kapt") -} +plugins { id("kmp-lib") } // tu convención KMP (jvm + wasmJs + kover/sonar) -dependencies { - // Architecture domain module for shared business logic. - implementation(project(":libraries:architecture:domain")) - // Smokes domain module for smoke-related business logic. - implementation(project(":libraries:smokes:domain")) - implementation(project(":libraries:authentication:domain")) +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation(project(":libraries:architecture:domain")) + implementation(project(":libraries:authentication:domain")) + implementation(project(":libraries:smokes:domain")) + } + } - // Dependency injection using javax.inject annotations. - implementation(libs.javax.inject) + @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) + wasmJs { + browser() + binaries.library() + } - // Unit testing dependencies. - testImplementation(platform(libs.junit.bom)) - testImplementation(libs.bundles.test) -} + val commonTest by getting { + dependencies { implementation(kotlin("test")) } + } -tasks.test { - // Use JUnit Platform for running tests. - useJUnitPlatform() + val jvmMain by getting { + dependencies { + // Si necesitĆ”s @Inject del lado JVM: + implementation(libs.javax.inject) + implementation(project(":libraries:smokes:domain")) + } + } + + val jvmTest by getting { + dependencies { + implementation(libs.junit.jupiter.api) + implementation(libs.junit.jupiter.params) + runtimeOnly(libs.junit.jupiter.engine) + implementation(libs.kluent) + implementation(libs.coroutines.test) + implementation(libs.app.cash.turbine) + } + } + // wasmJsMain / wasmJsTest quedan vacĆ­os por ahora + } } + +tasks.withType().configureEach { useJUnitPlatform() } \ No newline at end of file diff --git a/features/chatbot/domain/src/main/java/com/feragusper/smokeanalytics/features/chatbot/domain/ChatbotRepository.kt b/features/chatbot/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/chatbot/domain/ChatbotRepository.kt similarity index 100% rename from features/chatbot/domain/src/main/java/com/feragusper/smokeanalytics/features/chatbot/domain/ChatbotRepository.kt rename to features/chatbot/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/chatbot/domain/ChatbotRepository.kt diff --git a/features/chatbot/domain/src/main/java/com/feragusper/smokeanalytics/features/chatbot/domain/ChatbotUseCase.kt b/features/chatbot/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/chatbot/domain/ChatbotUseCase.kt similarity index 93% rename from features/chatbot/domain/src/main/java/com/feragusper/smokeanalytics/features/chatbot/domain/ChatbotUseCase.kt rename to features/chatbot/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/chatbot/domain/ChatbotUseCase.kt index 18f748e6..3d8fa728 100644 --- a/features/chatbot/domain/src/main/java/com/feragusper/smokeanalytics/features/chatbot/domain/ChatbotUseCase.kt +++ b/features/chatbot/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/chatbot/domain/ChatbotUseCase.kt @@ -3,9 +3,8 @@ package com.feragusper.smokeanalytics.features.chatbot.domain import com.feragusper.smokeanalytics.libraries.authentication.domain.AuthenticationRepository import com.feragusper.smokeanalytics.libraries.authentication.domain.Session import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository -import javax.inject.Inject -class ChatbotUseCase @Inject constructor( +class ChatbotUseCase constructor( private val smokeRepository: SmokeRepository, private val authRepository: AuthenticationRepository, private val chatbotRepository: ChatbotRepository diff --git a/features/chatbot/presentation/build.gradle.kts b/features/chatbot/presentation/build.gradle.kts index 96aabecf..ec8d0b05 100644 --- a/features/chatbot/presentation/build.gradle.kts +++ b/features/chatbot/presentation/build.gradle.kts @@ -29,7 +29,7 @@ android { dependencies { // Architecture and presentation layers - implementation(project(":libraries:architecture:presentation")) + implementation(project(":libraries:architecture:presentation:mobile")) // Design system for consistent theming and UI components implementation(project(":libraries:design")) diff --git a/features/devtools/presentation/build.gradle.kts b/features/devtools/presentation/build.gradle.kts index aafeecfe..da559e6c 100644 --- a/features/devtools/presentation/build.gradle.kts +++ b/features/devtools/presentation/build.gradle.kts @@ -26,13 +26,13 @@ android { dependencies { // Architecture and presentation layers - implementation(project(":libraries:architecture:presentation")) + implementation(project(":libraries:architecture:presentation:mobile")) implementation(project(":libraries:design")) // Authentication modules for user session and sign-in management - implementation(project(":libraries:authentication:presentation")) + implementation(project(":libraries:authentication:presentation:mobile")) implementation(project(":libraries:authentication:domain")) - implementation(project(":libraries:authentication:data")) + implementation(project(":libraries:authentication:data:mobile")) // Core AndroidX libraries and Compose dependencies implementation(libs.bundles.androidx.base) diff --git a/features/settings/presentation/.gitignore b/features/history/presentation/mobile/.gitignore similarity index 100% rename from features/settings/presentation/.gitignore rename to features/history/presentation/mobile/.gitignore diff --git a/features/history/presentation/build.gradle.kts b/features/history/presentation/mobile/build.gradle.kts similarity index 96% rename from features/history/presentation/build.gradle.kts rename to features/history/presentation/mobile/build.gradle.kts index 1cf6ec41..2454d9bc 100644 --- a/features/history/presentation/build.gradle.kts +++ b/features/history/presentation/mobile/build.gradle.kts @@ -26,10 +26,10 @@ android { dependencies { // Authentication feature - implementation(project(":features:authentication:presentation")) + implementation(project(":features:authentication:presentation:mobile")) // Architecture and presentation layers - implementation(project(":libraries:architecture:presentation")) + implementation(project(":libraries:architecture:presentation:mobile")) implementation(project(":libraries:architecture:domain")) // Authentication modules @@ -39,7 +39,7 @@ dependencies { implementation(project(":libraries:design")) // Smoke feature dependencies - implementation(project(":libraries:smokes:data")) + implementation(project(":libraries:smokes:data:mobile")) implementation(project(":libraries:smokes:domain")) implementation(project(":libraries:smokes:presentation")) diff --git a/features/history/presentation/src/main/AndroidManifest.xml b/features/history/presentation/mobile/src/main/AndroidManifest.xml similarity index 100% rename from features/history/presentation/src/main/AndroidManifest.xml rename to features/history/presentation/mobile/src/main/AndroidManifest.xml diff --git a/features/history/presentation/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryActivity.kt b/features/history/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryActivity.kt similarity index 100% rename from features/history/presentation/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryActivity.kt rename to features/history/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryActivity.kt diff --git a/features/history/presentation/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryView.kt b/features/history/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryView.kt similarity index 100% rename from features/history/presentation/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryView.kt rename to features/history/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryView.kt diff --git a/features/history/presentation/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewModel.kt b/features/history/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewModel.kt similarity index 66% rename from features/history/presentation/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewModel.kt rename to features/history/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewModel.kt index e9709850..fbd06a86 100644 --- a/features/history/presentation/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewModel.kt +++ b/features/history/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewModel.kt @@ -17,17 +17,9 @@ import com.feragusper.smokeanalytics.features.history.presentation.navigation.Hi import com.feragusper.smokeanalytics.features.history.presentation.process.HistoryProcessHolder import com.feragusper.smokeanalytics.libraries.architecture.presentation.MVIViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import java.time.LocalDateTime +import kotlinx.datetime.Clock import javax.inject.Inject -/** - * ViewModel for the history feature, managing UI state based on user intents and processing results. - * - * It extends [MVIViewModel] to implement the Model-View-Intent (MVI) architecture pattern. - * This ViewModel handles smoke history-related logic and updates the UI state accordingly. - * - * @property processHolder Encapsulates business logic to process [HistoryIntent] into [HistoryResult]. - */ @HiltViewModel class HistoryViewModel @Inject constructor( private val processHolder: HistoryProcessHolder, @@ -35,33 +27,14 @@ class HistoryViewModel @Inject constructor( initialState = HistoryViewState() ) { - /** - * Navigator instance for handling navigation actions. - */ override lateinit var navigator: HistoryNavigator init { - // Trigger initial intent to fetch smokes for the current date. - intents().trySend(HistoryIntent.FetchSmokes(LocalDateTime.now())) + intents().trySend(HistoryIntent.FetchSmokes(Clock.System.now())) } - /** - * Transforms [HistoryIntent] into a stream of [HistoryResult]s. - * - * @param intent The user intent to be processed. - * @return A Flow of [HistoryResult] representing the result of processing the intent. - */ override fun transformer(intent: HistoryIntent) = processHolder.processIntent(intent) - /** - * Reduces the previous [HistoryViewState] and a new [HistoryResult] to a new state. - * - * This function is responsible for creating the new state based on the current state and the result. - * - * @param previous The previous state of the UI. - * @param result The result of processing the intent. - * @return The new state of the UI. - */ override fun reducer( previous: HistoryViewState, result: HistoryResult @@ -78,17 +51,14 @@ class HistoryViewModel @Inject constructor( selectedDate = result.selectedDate, ) - is FetchSmokesSuccess -> { - previous.copy( - displayLoading = false, - error = null, - smokes = result.smokes, - selectedDate = result.selectedDate, - ) - } + is FetchSmokesSuccess -> previous.copy( + displayLoading = false, + error = null, + smokes = result.smokes, + selectedDate = result.selectedDate, + ) DeleteSmokeSuccess, EditSmokeSuccess, AddSmokeSuccess -> { - // Re-fetch smokes when adding, editing, or deleting a smoke. intents().trySend(HistoryIntent.FetchSmokes(previous.selectedDate)) previous } @@ -113,4 +83,4 @@ class HistoryViewModel @Inject constructor( previous } } -} +} \ No newline at end of file diff --git a/features/history/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryIntent.kt b/features/history/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryIntent.kt new file mode 100644 index 00000000..beea1f9f --- /dev/null +++ b/features/history/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryIntent.kt @@ -0,0 +1,17 @@ +package com.feragusper.smokeanalytics.features.history.presentation.mvi + +import com.feragusper.smokeanalytics.libraries.architecture.presentation.mvi.MVIIntent +import kotlinx.datetime.Instant + +sealed class HistoryIntent : MVIIntent { + + data class EditSmoke(val id: String, val date: Instant) : HistoryIntent() + + data class DeleteSmoke(val id: String) : HistoryIntent() + + data class AddSmoke(val date: Instant) : HistoryIntent() + + data class FetchSmokes(val date: Instant) : HistoryIntent() + + data object NavigateUp : HistoryIntent() +} \ No newline at end of file diff --git a/features/history/presentation/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryResult.kt b/features/history/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryResult.kt similarity index 93% rename from features/history/presentation/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryResult.kt rename to features/history/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryResult.kt index d89c69b8..8e2dce82 100644 --- a/features/history/presentation/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryResult.kt +++ b/features/history/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryResult.kt @@ -2,7 +2,7 @@ package com.feragusper.smokeanalytics.features.history.presentation.mvi import com.feragusper.smokeanalytics.libraries.architecture.presentation.mvi.MVIResult import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke -import java.time.LocalDateTime +import kotlinx.datetime.Instant /** * Represents the results of processing [HistoryIntent], which will modify the view state. @@ -22,7 +22,9 @@ sealed interface HistoryResult : MVIResult { * * @property selectedDate The date that was selected before the user was found to be not logged in. */ - data class NotLoggedIn(val selectedDate: LocalDateTime) : HistoryResult + data class NotLoggedIn( + val selectedDate: Instant + ) : HistoryResult /** * Indicates that a smoke event was successfully added. @@ -48,6 +50,7 @@ sealed interface HistoryResult : MVIResult { * Represents errors that might occur during the processing of history intents. */ sealed interface Error : HistoryResult { + /** * A generic error result. */ @@ -66,7 +69,7 @@ sealed interface HistoryResult : MVIResult { * @property smokes The list of fetched [Smoke] events. */ data class FetchSmokesSuccess( - val selectedDate: LocalDateTime, + val selectedDate: Instant, val smokes: List ) : HistoryResult @@ -79,4 +82,4 @@ sealed interface HistoryResult : MVIResult { * Triggers navigation to the previous screen. */ data object NavigateUp : HistoryResult -} +} \ No newline at end of file diff --git a/features/history/presentation/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/mvi/compose/HistoryViewState.kt b/features/history/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/mvi/compose/HistoryViewState.kt similarity index 83% rename from features/history/presentation/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/mvi/compose/HistoryViewState.kt rename to features/history/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/mvi/compose/HistoryViewState.kt index b9a0e0af..0c7585c0 100644 --- a/features/history/presentation/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/mvi/compose/HistoryViewState.kt +++ b/features/history/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/mvi/compose/HistoryViewState.kt @@ -52,45 +52,34 @@ import androidx.compose.ui.unit.dp import com.feragusper.smokeanalytics.features.history.presentation.R import com.feragusper.smokeanalytics.features.history.presentation.mvi.HistoryIntent import com.feragusper.smokeanalytics.features.history.presentation.mvi.HistoryResult -import com.feragusper.smokeanalytics.libraries.architecture.domain.extensions.dateFormatted import com.feragusper.smokeanalytics.libraries.architecture.presentation.mvi.MVIViewState -import com.feragusper.smokeanalytics.libraries.design.compose.CombinedPreviews -import com.feragusper.smokeanalytics.libraries.design.compose.theme.SmokeAnalyticsTheme import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke import com.feragusper.smokeanalytics.libraries.smokes.presentation.compose.DatePickerDialog import com.feragusper.smokeanalytics.libraries.smokes.presentation.compose.EmptySmokes import com.feragusper.smokeanalytics.libraries.smokes.presentation.compose.Stat import com.feragusper.smokeanalytics.libraries.smokes.presentation.compose.SwipeToDismissRow import com.valentinilk.shimmer.shimmer -import java.time.LocalDateTime +import kotlinx.datetime.Clock +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime -/** - * Describes the state of the history view, including loading indicators, smoke events, errors, and the currently selected date. - * - * @property displayLoading Indicates if the loading UI should be shown. - * @property smokes The list of [Smoke] events to display, or null if not available. - * @property error An optional error result affecting the current view state. - * @property selectedDate The currently selected date for displaying smoke events. - */ data class HistoryViewState( internal val displayLoading: Boolean = false, internal val smokes: List? = null, internal val error: HistoryResult.Error? = null, - internal val selectedDate: LocalDateTime = LocalDateTime.now(), + internal val selectedDate: Instant = Clock.System.now(), ) : MVIViewState { - /** - * Composable function that renders the history UI based on the current state. - * - * @param intent Lambda function to send user intentions to the ViewModel. - */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun Compose(intent: (HistoryIntent) -> Unit) { val snackbarHostState = remember { SnackbarHostState() } val isFABVisible = rememberSaveable { mutableStateOf(true) } + val timeZone = remember { TimeZone.currentSystemDefault() } - // Handle nested scrolling to hide/show FAB val nestedScrollConnection = remember { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { @@ -110,9 +99,7 @@ data class HistoryViewState( exit = slideOutVertically(targetOffsetY = { it * 2 }), ) { FloatingActionButton( - onClick = { - intent(HistoryIntent.AddSmoke(selectedDate)) - }, + onClick = { intent(HistoryIntent.AddSmoke(selectedDate)) }, ) { Row( modifier = Modifier.padding(16.dp), @@ -121,7 +108,7 @@ data class HistoryViewState( ) { Icon( imageVector = ImageVector.vectorResource(R.drawable.ic_cigarette), - contentDescription = "" + contentDescription = null ) Text( text = stringResource(R.string.history_button_track), @@ -156,12 +143,13 @@ data class HistoryViewState( } ) { contentPadding -> var showDatePicker by remember { mutableStateOf(false) } + if (showDatePicker) { DatePickerDialog( initialDate = selectedDate, - onConfirm = { date -> + onConfirm = { dateInstant -> showDatePicker = false - intent(HistoryIntent.FetchSmokes(date)) + intent(HistoryIntent.FetchSmokes(dateInstant)) }, onDismiss = { showDatePicker = false } ) @@ -175,30 +163,31 @@ data class HistoryViewState( .background(MaterialTheme.colorScheme.background), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - // Date Picker and Navigation Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { IconButton(onClick = { - intent(HistoryIntent.FetchSmokes(selectedDate.plusDays(-1))) + intent(HistoryIntent.FetchSmokes(selectedDate.minusDays(1, timeZone))) }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "" + contentDescription = null ) } + Text( modifier = Modifier.clickable { showDatePicker = true }, - text = selectedDate.dateFormatted() + text = selectedDate.toLocalDateTime(timeZone).dateFormattedUi(), ) + IconButton(onClick = { - intent(HistoryIntent.FetchSmokes(selectedDate.plusDays(1))) + intent(HistoryIntent.FetchSmokes(selectedDate.plusDays(1, timeZone))) }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = "" + contentDescription = null ) } } @@ -241,7 +230,9 @@ data class HistoryViewState( timeElapsedSincePreviousSmoke = smoke.timeElapsedSincePreviousSmoke, onDelete = { intent(HistoryIntent.DeleteSmoke(smoke.id)) }, fullDateTimeEdit = true, - onEdit = { date -> intent(HistoryIntent.EditSmoke(smoke.id, date)) } + onEdit = { editedInstant -> + intent(HistoryIntent.EditSmoke(smoke.id, editedInstant)) + } ) HorizontalDivider() } @@ -254,18 +245,15 @@ data class HistoryViewState( } } -@CombinedPreviews -@Composable -private fun HistoryViewLoadingPreview() { - SmokeAnalyticsTheme { - HistoryViewState(displayLoading = true).Compose {} - } -} +private fun Instant.plusDays(days: Int, timeZone: TimeZone): Instant = + this.plus(days, DateTimeUnit.DAY, timeZone) -@CombinedPreviews -@Composable -private fun HistoryViewPreview() { - SmokeAnalyticsTheme { - HistoryViewState().Compose {} - } -} +private fun Instant.minusDays(days: Int, timeZone: TimeZone): Instant = + this.plus(-days, DateTimeUnit.DAY, timeZone) + +private fun kotlinx.datetime.LocalDateTime.dateFormattedUi(): String { + // CambiĆ” el formato si querĆ©s. Esto evita depender de tu extension vieja de java.time. + val day = "%02d".format(dayOfMonth) + val month = "%02d".format(monthNumber) + return "$day/$month/$year" +} \ No newline at end of file diff --git a/features/history/presentation/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/navigation/HistoryNavigationGraph.kt b/features/history/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/navigation/HistoryNavigationGraph.kt similarity index 100% rename from features/history/presentation/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/navigation/HistoryNavigationGraph.kt rename to features/history/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/navigation/HistoryNavigationGraph.kt diff --git a/features/history/presentation/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/navigation/HistoryNavigator.kt b/features/history/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/navigation/HistoryNavigator.kt similarity index 100% rename from features/history/presentation/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/navigation/HistoryNavigator.kt rename to features/history/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/navigation/HistoryNavigator.kt diff --git a/features/history/presentation/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/process/HistoryProcessHolder.kt b/features/history/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/process/HistoryProcessHolder.kt similarity index 100% rename from features/history/presentation/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/process/HistoryProcessHolder.kt rename to features/history/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/process/HistoryProcessHolder.kt diff --git a/features/history/presentation/src/main/res/drawable/ic_cigarette.xml b/features/history/presentation/mobile/src/main/res/drawable/ic_cigarette.xml similarity index 100% rename from features/history/presentation/src/main/res/drawable/ic_cigarette.xml rename to features/history/presentation/mobile/src/main/res/drawable/ic_cigarette.xml diff --git a/features/history/presentation/src/main/res/values/strings.xml b/features/history/presentation/mobile/src/main/res/values/strings.xml similarity index 100% rename from features/history/presentation/src/main/res/values/strings.xml rename to features/history/presentation/mobile/src/main/res/values/strings.xml diff --git a/features/history/presentation/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewModelTest.kt b/features/history/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewModelTest.kt similarity index 99% rename from features/history/presentation/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewModelTest.kt rename to features/history/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewModelTest.kt index dfd975db..73c0b276 100644 --- a/features/history/presentation/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewModelTest.kt +++ b/features/history/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewModelTest.kt @@ -4,7 +4,6 @@ import app.cash.turbine.test import com.feragusper.smokeanalytics.features.history.presentation.mvi.HistoryIntent import com.feragusper.smokeanalytics.features.history.presentation.mvi.HistoryResult import com.feragusper.smokeanalytics.features.history.presentation.process.HistoryProcessHolder -import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic diff --git a/features/history/presentation/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/process/HistoryProcessHolderTest.kt b/features/history/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/process/HistoryProcessHolderTest.kt similarity index 93% rename from features/history/presentation/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/process/HistoryProcessHolderTest.kt rename to features/history/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/process/HistoryProcessHolderTest.kt index 704b0067..a1e44f66 100644 --- a/features/history/presentation/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/process/HistoryProcessHolderTest.kt +++ b/features/history/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/process/HistoryProcessHolderTest.kt @@ -5,10 +5,6 @@ import com.feragusper.smokeanalytics.features.history.presentation.mvi.HistoryIn import com.feragusper.smokeanalytics.features.history.presentation.mvi.HistoryResult import com.feragusper.smokeanalytics.libraries.authentication.domain.FetchSessionUseCase import com.feragusper.smokeanalytics.libraries.authentication.domain.Session -import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.AddSmokeUseCase -import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.DeleteSmokeUseCase -import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.EditSmokeUseCase -import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokesUseCase import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.SyncWithWearUseCase import io.mockk.Runs import io.mockk.coEvery diff --git a/features/history/presentation/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryIntent.kt b/features/history/presentation/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryIntent.kt deleted file mode 100644 index d6b70b7b..00000000 --- a/features/history/presentation/src/main/java/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryIntent.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.feragusper.smokeanalytics.features.history.presentation.mvi - -import com.feragusper.smokeanalytics.libraries.architecture.presentation.mvi.MVIIntent -import java.time.LocalDateTime - -/** - * Defines intents related to the history feature, representing actions the user can initiate. - * - * This sealed class represents all possible user actions within the History module, - * allowing the ViewModel to handle them in a structured manner. - */ -sealed class HistoryIntent : MVIIntent { - - /** - * Requests to edit a specific smoke event. - * - * @property id The unique identifier of the smoke event to be edited. - * @property date The new date and time for the smoke event. - */ - data class EditSmoke(val id: String, val date: LocalDateTime) : HistoryIntent() - - /** - * Requests to delete a specific smoke event. - * - * @property id The unique identifier of the smoke event to be deleted. - */ - data class DeleteSmoke(val id: String) : HistoryIntent() - - /** - * Requests to add a new smoke event. - * - * @property date The date and time when the new smoke event occurred. - */ - data class AddSmoke(val date: LocalDateTime) : HistoryIntent() - - /** - * Requests to fetch smoke events for a specific date. - * - * @property date The date for which smoke events should be fetched. - */ - data class FetchSmokes(val date: LocalDateTime) : HistoryIntent() - - /** - * Indicates a request to navigate up in the navigation stack. - */ - data object NavigateUp : HistoryIntent() -} diff --git a/features/stats/presentation/.gitignore b/features/history/presentation/web/.gitignore similarity index 100% rename from features/stats/presentation/.gitignore rename to features/history/presentation/web/.gitignore diff --git a/features/history/presentation/web/build.gradle.kts b/features/history/presentation/web/build.gradle.kts new file mode 100644 index 00000000..81cd575a --- /dev/null +++ b/features/history/presentation/web/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + kotlin("multiplatform") + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.compose.compiler) +} + +kotlin { + js(IR) { browser() } + + sourceSets { + val jsMain by getting { + dependencies { + implementation(libs.kotlinx.coroutines.core) + + implementation(compose.runtime) + implementation(compose.html.core) + + implementation(project(":libraries:architecture:domain")) + implementation(project(":libraries:smokes:domain")) + implementation(project(":libraries:authentication:domain")) + } + } + } +} \ No newline at end of file diff --git a/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewState.kt b/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewState.kt new file mode 100644 index 00000000..2b8e2087 --- /dev/null +++ b/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewState.kt @@ -0,0 +1,13 @@ +package com.feragusper.smokeanalytics.features.history.presentation + +import com.feragusper.smokeanalytics.features.history.presentation.mvi.HistoryResult +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +data class HistoryViewState( + val displayLoading: Boolean = false, + val smokes: List = emptyList(), + val selectedDate: Instant = Clock.System.now(), + val error: HistoryResult.Error? = null, +) \ No newline at end of file diff --git a/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/HistoryWebDependencies.kt b/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/HistoryWebDependencies.kt new file mode 100644 index 00000000..a3e843a0 --- /dev/null +++ b/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/HistoryWebDependencies.kt @@ -0,0 +1,7 @@ +package com.feragusper.smokeanalytics.features.history.presentation + +import com.feragusper.smokeanalytics.features.history.presentation.process.HistoryProcessHolder + +data class HistoryWebDependencies( + val historyProcessHolder: HistoryProcessHolder, +) \ No newline at end of file diff --git a/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/HistoryWebScreen.kt b/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/HistoryWebScreen.kt new file mode 100644 index 00000000..2a933c93 --- /dev/null +++ b/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/HistoryWebScreen.kt @@ -0,0 +1,291 @@ +package com.feragusper.smokeanalytics.features.history.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import com.feragusper.smokeanalytics.features.history.presentation.mvi.HistoryIntent +import com.feragusper.smokeanalytics.features.history.presentation.mvi.HistoryResult +import com.feragusper.smokeanalytics.features.history.presentation.mvi.HistoryWebStore +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.minus +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import org.jetbrains.compose.web.attributes.InputType +import org.jetbrains.compose.web.attributes.disabled +import org.jetbrains.compose.web.dom.Button +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.H3 +import org.jetbrains.compose.web.dom.Input +import org.jetbrains.compose.web.dom.Li +import org.jetbrains.compose.web.dom.Span +import org.jetbrains.compose.web.dom.Text +import org.jetbrains.compose.web.dom.Ul + +@Composable +fun HistoryWebScreen( + deps: HistoryWebDependencies, + onNavigateUp: () -> Unit, + onNavigateToAuth: () -> Unit, +) { + val store = remember(deps) { HistoryWebStore(deps.historyProcessHolder) } + LaunchedEffect(store) { store.start() } + + val state by store.state.collectAsState() + val tz = remember { TimeZone.currentSystemDefault() } + + // Per-row edit state + val editing = remember { mutableStateMapOf() } + val draftDateTime = remember { mutableStateMapOf() } + + // āœ… Normalize selected day to 00:00 so all "day actions" are consistent. + val selectedDayStart = state.selectedDate.dayStart(tz) + val selectedLocalDate = selectedDayStart.toLocalDateTime(tz).date + val selectedDateLabel = selectedLocalDate.toUiDate() + + Div { + H3 { Text("History • $selectedDateLabel") } + + if (state.displayLoading) { + Div { Text("Loading...") } + } + + state.error?.let { err -> + Div { + Text( + when (err) { + HistoryResult.Error.NotLoggedIn -> "Not logged in" + HistoryResult.Error.Generic -> "Something went wrong" + } + ) + } + + if (err == HistoryResult.Error.NotLoggedIn) { + Button(attrs = { onClick { onNavigateToAuth() } }) { Text("Go to sign in") } + } + } + + // Controls + Div { + Button(attrs = { onClick { store.send(HistoryIntent.NavigateUp); onNavigateUp() } }) { + Text("Back") + } + + Span { Text(" ") } + + Button( + attrs = { + if (state.displayLoading) disabled() + onClick { store.send(HistoryIntent.AddSmoke(selectedDayStart)) } + } + ) { Text("Add smoke") } + + Span { Text(" ") } + + Button( + attrs = { + if (state.displayLoading) disabled() + onClick { store.send(HistoryIntent.FetchSmokes(selectedDayStart)) } + } + ) { Text("Refresh") } + } + + // Day navigation + day picker + Div { + Button( + attrs = { + if (state.displayLoading) disabled() + onClick { + store.send( + HistoryIntent.FetchSmokes( + selectedDayStart.minusDays(1, tz) + ) + ) + } + } + ) { Text("←") } + + Span { Text(" ") } + + Input( + type = InputType.Date, + attrs = { + value(selectedLocalDate.toHtmlDate()) + if (state.displayLoading) disabled() + onInput { e -> + val picked = (e.value ?: "").toLocalDateOrNull() ?: return@onInput + store.send(HistoryIntent.FetchSmokes(picked.atStartOfDayIn(tz))) + } + } + ) + + Span { Text(" ") } + + Button( + attrs = { + if (state.displayLoading) disabled() + onClick { + store.send( + HistoryIntent.FetchSmokes( + selectedDayStart.plusDays(1, tz) + ) + ) + } + } + ) { Text("→") } + } + + Div { Text("Smokes: ${state.smokes.size}") } + + Ul { + state.smokes.forEach { smoke -> + val id = smoke.id + val isEditing = editing[id] == true + + val local = smoke.date.toLocalDateTime(tz) + val label = "${local.date.toUiDate()} ${local.toUiTime()}" + + Li { + Text(label) + Span { Text(" ") } + + if (!isEditing) { + Button( + attrs = { + if (state.displayLoading) disabled() + onClick { + editing[id] = true + draftDateTime[id] = smoke.date.toHtmlDateTimeLocal(tz) + } + } + ) { Text("Edit") } + + Span { Text(" ") } + + Button( + attrs = { + if (state.displayLoading) disabled() + onClick { store.send(HistoryIntent.DeleteSmoke(id)) } + } + ) { Text("Delete") } + } else { + val draft = draftDateTime[id] ?: smoke.date.toHtmlDateTimeLocal(tz) + + Input( + type = InputType.DateTimeLocal, + attrs = { + value(draft) + if (state.displayLoading) disabled() + onInput { ev -> + draftDateTime[id] = ev.value ?: draft + } + } + ) + + Span { Text(" ") } + + Button( + attrs = { + if (state.displayLoading) disabled() + onClick { + val v = draftDateTime[id] ?: return@onClick + val newInstant = + v.toInstantFromHtmlDateTimeLocalOrNull(tz) ?: return@onClick + store.send(HistoryIntent.EditSmoke(id, newInstant)) + editing[id] = false + } + } + ) { Text("Apply") } + + Span { Text(" ") } + + Button( + attrs = { + if (state.displayLoading) disabled() + onClick { + editing[id] = false + draftDateTime.remove(id) + } + } + ) { Text("Cancel") } + } + } + } + } + } +} + +private fun Instant.dayStart(timeZone: TimeZone): Instant = + toLocalDateTime(timeZone).date.atStartOfDayIn(timeZone) + +private fun Instant.plusDays(days: Int, timeZone: TimeZone): Instant = + this.plus(days, DateTimeUnit.DAY, timeZone) + +private fun Instant.minusDays(days: Int, timeZone: TimeZone): Instant = + this.minus(days, DateTimeUnit.DAY, timeZone) + +private fun LocalDate.toHtmlDate(): String { + val y = year.toString().padStart(4, '0') + val m = monthNumber.toString().padStart(2, '0') + val d = dayOfMonth.toString().padStart(2, '0') + return "$y-$m-$d" +} + +private fun LocalDate.toUiDate(): String { + val d = dayOfMonth.toString().padStart(2, '0') + val m = monthNumber.toString().padStart(2, '0') + return "$d/$m/$year" +} + +private fun LocalDateTime.toUiTime(): String { + val h = hour.toString().padStart(2, '0') + val m = minute.toString().padStart(2, '0') + return "$h:$m" +} + +private fun Instant.toHtmlDateTimeLocal(timeZone: TimeZone): String { + val ldt = toLocalDateTime(timeZone) + val y = ldt.year.toString().padStart(4, '0') + val mo = ldt.monthNumber.toString().padStart(2, '0') + val d = ldt.dayOfMonth.toString().padStart(2, '0') + val h = ldt.hour.toString().padStart(2, '0') + val mi = ldt.minute.toString().padStart(2, '0') + return "$y-$mo-$d" + "T" + "$h:$mi" +} + +private fun String.toLocalDateOrNull(): LocalDate? { + if (length != 10) return null + val y = substring(0, 4).toIntOrNull() ?: return null + val m = substring(5, 7).toIntOrNull() ?: return null + val d = substring(8, 10).toIntOrNull() ?: return null + return runCatching { LocalDate(y, m, d) }.getOrNull() +} + +private fun String.toInstantFromHtmlDateTimeLocalOrNull(timeZone: TimeZone): Instant? { + if (length < 16) return null + val y = substring(0, 4).toIntOrNull() ?: return null + val mo = substring(5, 7).toIntOrNull() ?: return null + val d = substring(8, 10).toIntOrNull() ?: return null + val h = substring(11, 13).toIntOrNull() ?: return null + val mi = substring(14, 16).toIntOrNull() ?: return null + + val ldt = LocalDateTime( + year = y, + monthNumber = mo, + dayOfMonth = d, + hour = h, + minute = mi, + second = 0, + nanosecond = 0, + ) + + return runCatching { ldt.toInstant(timeZone) }.getOrNull() +} \ No newline at end of file diff --git a/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryIntent.kt b/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryIntent.kt new file mode 100644 index 00000000..0395b8ec --- /dev/null +++ b/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryIntent.kt @@ -0,0 +1,11 @@ +package com.feragusper.smokeanalytics.features.history.presentation.mvi + +import kotlinx.datetime.Instant + +sealed interface HistoryIntent { + data class FetchSmokes(val date: Instant) : HistoryIntent + data class AddSmoke(val date: Instant) : HistoryIntent + data class EditSmoke(val id: String, val date: Instant) : HistoryIntent + data class DeleteSmoke(val id: String) : HistoryIntent + data object NavigateUp : HistoryIntent +} \ No newline at end of file diff --git a/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryResult.kt b/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryResult.kt new file mode 100644 index 00000000..e244f190 --- /dev/null +++ b/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryResult.kt @@ -0,0 +1,29 @@ +package com.feragusper.smokeanalytics.features.history.presentation.mvi + +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke +import kotlinx.datetime.Instant + +sealed interface HistoryResult { + data object Loading : HistoryResult + + data class NotLoggedIn(val selectedDate: Instant) : HistoryResult + + data class FetchSmokesSuccess( + val selectedDate: Instant, + val smokes: List, + ) : HistoryResult + + data object FetchSmokesError : HistoryResult + + data object AddSmokeSuccess : HistoryResult + data object EditSmokeSuccess : HistoryResult + data object DeleteSmokeSuccess : HistoryResult + + data object NavigateUp : HistoryResult + data object GoToAuthentication : HistoryResult + + sealed interface Error : HistoryResult { + data object Generic : Error + data object NotLoggedIn : Error + } +} \ No newline at end of file diff --git a/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryWebStore.kt b/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryWebStore.kt new file mode 100644 index 00000000..d31b656c --- /dev/null +++ b/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryWebStore.kt @@ -0,0 +1,80 @@ +package com.feragusper.smokeanalytics.features.history.presentation.mvi + +import com.feragusper.smokeanalytics.features.history.presentation.HistoryViewState +import com.feragusper.smokeanalytics.features.history.presentation.process.HistoryProcessHolder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock + +class HistoryWebStore( + private val processHolder: HistoryProcessHolder, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), +) { + private val intents = Channel(capacity = Channel.Factory.BUFFERED) + + private val _state = MutableStateFlow(HistoryViewState()) + val state: StateFlow = _state.asStateFlow() + + fun send(intent: HistoryIntent) { + intents.trySend(intent) + } + + fun start() { + scope.launch { + intents + .receiveAsFlow() + .flatMapLatest { processHolder.processIntent(it) } + .collect { reduce(it) } + } + + send(HistoryIntent.FetchSmokes(Clock.System.now())) + } + + private fun reduce(result: HistoryResult) { + val prev = _state.value + val next = when (result) { + HistoryResult.Loading -> prev.copy(displayLoading = true, error = null) + + is HistoryResult.NotLoggedIn -> prev.copy( + displayLoading = false, + error = null, + selectedDate = result.selectedDate, + smokes = emptyList(), + ) + + is HistoryResult.FetchSmokesSuccess -> prev.copy( + displayLoading = false, + error = null, + selectedDate = result.selectedDate, + smokes = result.smokes, + ) + + HistoryResult.FetchSmokesError -> prev.copy( + displayLoading = false, + error = HistoryResult.Error.Generic, + ) + + HistoryResult.AddSmokeSuccess, + HistoryResult.EditSmokeSuccess, + HistoryResult.DeleteSmokeSuccess -> { + send(HistoryIntent.FetchSmokes(prev.selectedDate)) + prev + } + + is HistoryResult.Error -> prev.copy(displayLoading = false, error = result) + + HistoryResult.NavigateUp, + HistoryResult.GoToAuthentication -> prev + } + + _state.value = next + } +} \ No newline at end of file diff --git a/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/process/HistoryProcessHolder.kt b/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/process/HistoryProcessHolder.kt new file mode 100644 index 00000000..3cb6952a --- /dev/null +++ b/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/process/HistoryProcessHolder.kt @@ -0,0 +1,94 @@ +package com.feragusper.smokeanalytics.features.history.presentation.process + +import com.feragusper.smokeanalytics.features.history.presentation.mvi.HistoryIntent +import com.feragusper.smokeanalytics.features.history.presentation.mvi.HistoryResult +import com.feragusper.smokeanalytics.libraries.authentication.domain.FetchSessionUseCase +import com.feragusper.smokeanalytics.libraries.authentication.domain.Session +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.AddSmokeUseCase +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.DeleteSmokeUseCase +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.EditSmokeUseCase +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokesUseCase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flow +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime + +class HistoryProcessHolder( + private val addSmokeUseCase: AddSmokeUseCase, + private val editSmokeUseCase: EditSmokeUseCase, + private val deleteSmokeUseCase: DeleteSmokeUseCase, + private val fetchSmokesUseCase: FetchSmokesUseCase, + private val fetchSessionUseCase: FetchSessionUseCase, +) { + + fun processIntent(intent: HistoryIntent): Flow = when (intent) { + is HistoryIntent.FetchSmokes -> processFetchSmokes(intent) + is HistoryIntent.AddSmoke -> processAddSmoke(intent) + is HistoryIntent.EditSmoke -> processEditSmoke(intent) + is HistoryIntent.DeleteSmoke -> processDeleteSmoke(intent) + HistoryIntent.NavigateUp -> flow { emit(HistoryResult.NavigateUp) } + } + + private fun processFetchSmokes(intent: HistoryIntent.FetchSmokes) = flow { + val tz = TimeZone.Companion.currentSystemDefault() + + val dayStart = intent.date.toLocalDateTime(tz).date.atStartOfDayIn(tz) + val nextDayStart = dayStart.plus(1, DateTimeUnit.Companion.DAY, tz) + + when (fetchSessionUseCase()) { + is Session.Anonymous -> emit(HistoryResult.NotLoggedIn(dayStart)) + + is Session.LoggedIn -> { + emit(HistoryResult.Loading) + + val raw = fetchSmokesUseCase( + start = dayStart, + end = nextDayStart, + ) + + val filtered = raw.filter { smoke -> + val d = smoke.date.toLocalDateTime(tz).date + d == dayStart.toLocalDateTime(tz).date + } + + emit( + HistoryResult.FetchSmokesSuccess( + selectedDate = dayStart, + smokes = filtered, + ) + ) + } + } + }.catch { emit(HistoryResult.FetchSmokesError) } + + private fun processAddSmoke(intent: HistoryIntent.AddSmoke) = flow { + when (fetchSessionUseCase()) { + is Session.Anonymous -> { + emit(HistoryResult.Error.NotLoggedIn) + emit(HistoryResult.GoToAuthentication) + } + + is Session.LoggedIn -> { + emit(HistoryResult.Loading) + addSmokeUseCase(intent.date) + emit(HistoryResult.AddSmokeSuccess) + } + } + }.catch { emit(HistoryResult.Error.Generic) } + + private fun processEditSmoke(intent: HistoryIntent.EditSmoke) = flow { + emit(HistoryResult.Loading) + editSmokeUseCase(intent.id, intent.date) + emit(HistoryResult.EditSmokeSuccess) + }.catch { emit(HistoryResult.Error.Generic) } + + private fun processDeleteSmoke(intent: HistoryIntent.DeleteSmoke) = flow { + emit(HistoryResult.Loading) + deleteSmokeUseCase(intent.id) + emit(HistoryResult.DeleteSmokeSuccess) + }.catch { emit(HistoryResult.Error.Generic) } +} \ No newline at end of file diff --git a/features/home/domain/build.gradle.kts b/features/home/domain/build.gradle.kts index cd70c6ab..c97f05ec 100644 --- a/features/home/domain/build.gradle.kts +++ b/features/home/domain/build.gradle.kts @@ -1,23 +1,51 @@ -plugins { - // Apply the Java Library plugin for this module. - `java-lib` -} +plugins { id("kmp-lib") } // tu convención: jvm + wasmJs + kover/sonar -dependencies { - // Architecture domain module for shared business logic. - implementation(project(":libraries:architecture:domain")) - // Smokes domain module for smoke-related business logic. - implementation(project(":libraries:smokes:domain")) +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation(project(":libraries:architecture:domain")) + implementation(project(":libraries:smokes:domain")) + // ā›”ļø No metas javax.inject acĆ” (es JVM-only) + } + } + + @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) + wasmJs { + browser() + binaries.library() + } + + val commonTest by getting { + dependencies { implementation(kotlin("test")) } + } - // Dependency injection using javax.inject annotations. - implementation(libs.javax.inject) + val jvmMain by getting { + dependencies { + // Si usĆ”s @Inject en constructores del dominio: + implementation(libs.javax.inject) + } + } - // Unit testing dependencies. - testImplementation(platform(libs.junit.bom)) - testImplementation(libs.bundles.test) + val jvmTest by getting { + dependencies { + implementation(libs.junit.jupiter.api) + implementation(libs.junit.jupiter.params) + runtimeOnly(libs.junit.jupiter.engine) + + implementation(libs.kluent) + implementation(libs.coroutines.test) + implementation(libs.app.cash.turbine) + } + } + + // wasmJsMain / wasmJsTest se quedan vacĆ­os por ahora + } } -tasks.test { - // Use JUnit Platform for running tests. - useJUnitPlatform() +// (Opcional) Si venĆ­as usando BOM de JUnit: +dependencies { + add("jvmTestImplementation", platform(libs.junit.bom)) } + +tasks.withType().configureEach { useJUnitPlatform() } \ No newline at end of file diff --git a/features/home/domain/src/main/java/com/feragusper/smokeanalytics/features/home/domain/FetchSmokeCountListUseCase.kt b/features/home/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/domain/FetchSmokeCountListUseCase.kt similarity index 94% rename from features/home/domain/src/main/java/com/feragusper/smokeanalytics/features/home/domain/FetchSmokeCountListUseCase.kt rename to features/home/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/domain/FetchSmokeCountListUseCase.kt index a1c7c7a1..60e058ad 100644 --- a/features/home/domain/src/main/java/com/feragusper/smokeanalytics/features/home/domain/FetchSmokeCountListUseCase.kt +++ b/features/home/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/domain/FetchSmokeCountListUseCase.kt @@ -1,7 +1,6 @@ package com.feragusper.smokeanalytics.features.home.domain import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository -import javax.inject.Inject /** * Use case for fetching aggregated counts of smoke events over different time periods. @@ -11,7 +10,7 @@ import javax.inject.Inject * * @property smokeRepository The repository responsible for fetching smoke event data. */ -class FetchSmokeCountListUseCase @Inject constructor( +class FetchSmokeCountListUseCase( private val smokeRepository: SmokeRepository ) { diff --git a/features/home/domain/src/main/java/com/feragusper/smokeanalytics/features/home/domain/SmokeCountListResult.kt b/features/home/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/domain/SmokeCountListResult.kt similarity index 97% rename from features/home/domain/src/main/java/com/feragusper/smokeanalytics/features/home/domain/SmokeCountListResult.kt rename to features/home/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/domain/SmokeCountListResult.kt index a12d0fe2..26ce41f8 100644 --- a/features/home/domain/src/main/java/com/feragusper/smokeanalytics/features/home/domain/SmokeCountListResult.kt +++ b/features/home/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/domain/SmokeCountListResult.kt @@ -1,7 +1,7 @@ package com.feragusper.smokeanalytics.features.home.domain -import com.feragusper.smokeanalytics.libraries.architecture.domain.extensions.timeElapsedSinceNow import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke +import com.feragusper.smokeanalytics.libraries.architecture.domain.timeElapsedSinceNow /** * Represents the result of aggregating smoke event counts over different time periods. diff --git a/libraries/architecture/presentation/.gitignore b/features/home/presentation/mobile/.gitignore similarity index 100% rename from libraries/architecture/presentation/.gitignore rename to features/home/presentation/mobile/.gitignore diff --git a/features/home/presentation/build.gradle.kts b/features/home/presentation/mobile/build.gradle.kts similarity index 97% rename from features/home/presentation/build.gradle.kts rename to features/home/presentation/mobile/build.gradle.kts index fe1bd38a..558553d4 100644 --- a/features/home/presentation/build.gradle.kts +++ b/features/home/presentation/mobile/build.gradle.kts @@ -26,7 +26,7 @@ android { dependencies { // Architecture and presentation layers - implementation(project(":libraries:architecture:presentation")) + implementation(project(":libraries:architecture:presentation:mobile")) implementation(project(":libraries:architecture:domain")) // Design system for consistent theming and UI components @@ -39,7 +39,7 @@ dependencies { implementation(project(":features:home:domain")) // Smokes feature dependencies for data, domain, and presentation layers - implementation(project(":libraries:smokes:data")) + implementation(project(":libraries:smokes:data:mobile")) implementation(project(":libraries:smokes:domain")) implementation(project(":libraries:smokes:presentation")) diff --git a/features/home/presentation/src/androidTest/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewTest.kt b/features/home/presentation/mobile/src/androidTest/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewTest.kt similarity index 100% rename from features/home/presentation/src/androidTest/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewTest.kt rename to features/home/presentation/mobile/src/androidTest/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewTest.kt diff --git a/features/home/presentation/src/main/AndroidManifest.xml b/features/home/presentation/mobile/src/main/AndroidManifest.xml similarity index 100% rename from features/home/presentation/src/main/AndroidManifest.xml rename to features/home/presentation/mobile/src/main/AndroidManifest.xml diff --git a/features/home/presentation/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/HomeView.kt b/features/home/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/HomeView.kt similarity index 100% rename from features/home/presentation/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/HomeView.kt rename to features/home/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/HomeView.kt diff --git a/features/home/presentation/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewModel.kt b/features/home/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewModel.kt similarity index 100% rename from features/home/presentation/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewModel.kt rename to features/home/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewModel.kt diff --git a/features/home/presentation/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/mvi/HomeIntent.kt b/features/home/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/mvi/HomeIntent.kt similarity index 93% rename from features/home/presentation/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/mvi/HomeIntent.kt rename to features/home/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/mvi/HomeIntent.kt index 0f88e0b1..71686cd9 100644 --- a/features/home/presentation/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/mvi/HomeIntent.kt +++ b/features/home/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/mvi/HomeIntent.kt @@ -2,7 +2,7 @@ package com.feragusper.smokeanalytics.features.home.presentation.mvi import com.feragusper.smokeanalytics.libraries.architecture.presentation.mvi.MVIIntent import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke -import java.time.LocalDateTime +import kotlinx.datetime.Instant /** * Defines user intentions that trigger actions within the Home feature. @@ -25,7 +25,7 @@ sealed class HomeIntent : MVIIntent { * @property id The unique identifier of the smoke event to be edited. * @property date The new date and time for the smoke event. */ - data class EditSmoke(val id: String, val date: LocalDateTime) : HomeIntent() + data class EditSmoke(val id: String, val date: Instant) : HomeIntent() /** * Represents an intent to delete an existing smoke entry. @@ -53,4 +53,4 @@ sealed class HomeIntent : MVIIntent { * Represents an intent to navigate to the smoke history screen. */ data object OnClickHistory : HomeIntent() -} +} \ No newline at end of file diff --git a/features/home/presentation/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/mvi/HomeResult.kt b/features/home/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/mvi/HomeResult.kt similarity index 100% rename from features/home/presentation/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/mvi/HomeResult.kt rename to features/home/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/mvi/HomeResult.kt diff --git a/features/home/presentation/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/mvi/compose/HomeViewState.kt b/features/home/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/mvi/compose/HomeViewState.kt similarity index 97% rename from features/home/presentation/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/mvi/compose/HomeViewState.kt rename to features/home/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/mvi/compose/HomeViewState.kt index 2ac185e5..21328b5d 100644 --- a/features/home/presentation/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/mvi/compose/HomeViewState.kt +++ b/features/home/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/mvi/compose/HomeViewState.kt @@ -51,7 +51,7 @@ import com.feragusper.smokeanalytics.libraries.smokes.presentation.compose.Empty import com.feragusper.smokeanalytics.libraries.smokes.presentation.compose.Stat import com.feragusper.smokeanalytics.libraries.smokes.presentation.compose.SwipeToDismissRow import com.valentinilk.shimmer.shimmer -import java.time.LocalDateTime +import kotlinx.datetime.Instant /** * Represents the state of the Home screen in the application, encapsulating all UI-related data. @@ -79,13 +79,13 @@ data class HomeViewState( onFabConfigChanged: (Boolean, (() -> Unit)?) -> Unit, intent: (HomeIntent) -> Unit ) { - // Pull to refresh state management val pullToRefreshState = remember { object : PullToRefreshState { private val anim = Animatable(0f, Float.VectorConverter) override val distanceFraction get() = anim.value override val isAnimating: Boolean get() = anim.isRunning + override suspend fun animateToThreshold() { anim.animateTo(1f, spring(dampingRatio = Spring.DampingRatioHighBouncy)) } @@ -104,7 +104,6 @@ data class HomeViewState( onFabConfigChanged.invoke(!displayLoading) { intent(HomeIntent.AddSmoke) } } - // Handle FAB visibility on scroll val nestedScrollConnection = remember { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { @@ -137,7 +136,6 @@ data class HomeViewState( isLoading = displayLoading ) } -// } } } @@ -174,7 +172,7 @@ private fun HomeContent( latestSmokes = latestSmokes, nestedScrollConnection = nestedScrollConnection, isLoading = isLoading, - onEdit = { id, date -> intent(HomeIntent.EditSmoke(id, date)) }, + onEdit = { id, instant -> intent(HomeIntent.EditSmoke(id, instant)) }, onDelete = { id -> intent(HomeIntent.DeleteSmoke(id)) } ) } @@ -283,7 +281,7 @@ private fun LatestSmokesSection( latestSmokes: List?, nestedScrollConnection: NestedScrollConnection, isLoading: Boolean, - onEdit: (String, LocalDateTime) -> Unit, + onEdit: (String, Instant) -> Unit, onDelete: (String) -> Unit ) { Text( @@ -324,7 +322,9 @@ private fun LatestSmokesSection( timeElapsedSincePreviousSmoke = smoke.timeElapsedSincePreviousSmoke, onDelete = { onDelete(smoke.id) }, fullDateTimeEdit = false, - onEdit = { date -> onEdit(smoke.id, date) } + onEdit = { editedInstant -> + onEdit(smoke.id, editedInstant) + } ) HorizontalDivider() } @@ -334,7 +334,6 @@ private fun LatestSmokesSection( } } - @CombinedPreviews @Composable private fun HomeViewLoadingPreview() { @@ -349,4 +348,4 @@ private fun HomeViewPreview() { SmokeAnalyticsTheme { HomeViewState().Compose({ _, _ -> }, {}) } -} +} \ No newline at end of file diff --git a/features/home/presentation/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/navigation/HomeNavigationGraph.kt b/features/home/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/navigation/HomeNavigationGraph.kt similarity index 100% rename from features/home/presentation/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/navigation/HomeNavigationGraph.kt rename to features/home/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/navigation/HomeNavigationGraph.kt diff --git a/features/home/presentation/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/navigation/HomeNavigator.kt b/features/home/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/navigation/HomeNavigator.kt similarity index 100% rename from features/home/presentation/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/navigation/HomeNavigator.kt rename to features/home/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/navigation/HomeNavigator.kt diff --git a/features/home/presentation/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/process/HomeProcessHolder.kt b/features/home/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/process/HomeProcessHolder.kt similarity index 99% rename from features/home/presentation/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/process/HomeProcessHolder.kt rename to features/home/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/process/HomeProcessHolder.kt index ce801fc3..b52c7af7 100644 --- a/features/home/presentation/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/process/HomeProcessHolder.kt +++ b/features/home/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/home/presentation/process/HomeProcessHolder.kt @@ -3,7 +3,7 @@ package com.feragusper.smokeanalytics.features.home.presentation.process import com.feragusper.smokeanalytics.features.home.domain.FetchSmokeCountListUseCase import com.feragusper.smokeanalytics.features.home.presentation.mvi.HomeIntent import com.feragusper.smokeanalytics.features.home.presentation.mvi.HomeResult -import com.feragusper.smokeanalytics.libraries.architecture.domain.extensions.timeElapsedSinceNow +import com.feragusper.smokeanalytics.libraries.architecture.domain.timeElapsedSinceNow import com.feragusper.smokeanalytics.libraries.architecture.presentation.extensions.catchAndLog import com.feragusper.smokeanalytics.libraries.architecture.presentation.process.MVIProcessHolder import com.feragusper.smokeanalytics.libraries.authentication.domain.FetchSessionUseCase diff --git a/features/home/presentation/src/main/res/drawable/ic_cigarette.xml b/features/home/presentation/mobile/src/main/res/drawable/ic_cigarette.xml similarity index 100% rename from features/home/presentation/src/main/res/drawable/ic_cigarette.xml rename to features/home/presentation/mobile/src/main/res/drawable/ic_cigarette.xml diff --git a/features/home/presentation/src/main/res/drawable/il_cigarette_background.xml b/features/home/presentation/mobile/src/main/res/drawable/il_cigarette_background.xml similarity index 100% rename from features/home/presentation/src/main/res/drawable/il_cigarette_background.xml rename to features/home/presentation/mobile/src/main/res/drawable/il_cigarette_background.xml diff --git a/features/home/presentation/src/main/res/values/strings.xml b/features/home/presentation/mobile/src/main/res/values/strings.xml similarity index 100% rename from features/home/presentation/src/main/res/values/strings.xml rename to features/home/presentation/mobile/src/main/res/values/strings.xml diff --git a/features/home/presentation/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewModelTest.kt b/features/home/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewModelTest.kt similarity index 99% rename from features/home/presentation/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewModelTest.kt rename to features/home/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewModelTest.kt index ceb270d4..4912a99d 100644 --- a/features/home/presentation/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewModelTest.kt +++ b/features/home/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewModelTest.kt @@ -5,7 +5,6 @@ import com.feragusper.smokeanalytics.features.home.domain.SmokeCountListResult import com.feragusper.smokeanalytics.features.home.presentation.mvi.HomeIntent import com.feragusper.smokeanalytics.features.home.presentation.mvi.HomeResult import com.feragusper.smokeanalytics.features.home.presentation.process.HomeProcessHolder -import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers diff --git a/features/home/presentation/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/process/HomeProcessHolderTest.kt b/features/home/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/process/HomeProcessHolderTest.kt similarity index 96% rename from features/home/presentation/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/process/HomeProcessHolderTest.kt rename to features/home/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/process/HomeProcessHolderTest.kt index c98f5a0f..e2a3aade 100644 --- a/features/home/presentation/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/process/HomeProcessHolderTest.kt +++ b/features/home/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/process/HomeProcessHolderTest.kt @@ -6,9 +6,6 @@ import com.feragusper.smokeanalytics.features.home.presentation.mvi.HomeIntent import com.feragusper.smokeanalytics.features.home.presentation.mvi.HomeResult import com.feragusper.smokeanalytics.libraries.authentication.domain.FetchSessionUseCase import com.feragusper.smokeanalytics.libraries.authentication.domain.Session -import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.AddSmokeUseCase -import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.DeleteSmokeUseCase -import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.EditSmokeUseCase import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.SyncWithWearUseCase import io.mockk.Runs import io.mockk.coEvery diff --git a/libraries/authentication/data/.gitignore b/features/home/presentation/web/.gitignore similarity index 100% rename from libraries/authentication/data/.gitignore rename to features/home/presentation/web/.gitignore diff --git a/features/home/presentation/web/build.gradle.kts b/features/home/presentation/web/build.gradle.kts new file mode 100644 index 00000000..be294844 --- /dev/null +++ b/features/home/presentation/web/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + kotlin("multiplatform") + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.compose.compiler) +} + +kotlin { + js(IR) { browser() } + + sourceSets { + val jsMain by getting { + dependencies { + implementation(project(":libraries:architecture:domain")) + implementation(project(":features:home:domain")) + implementation(project(":libraries:smokes:domain")) + implementation(project(":libraries:authentication:domain")) + implementation(project(":libraries:logging")) + + implementation(libs.kotlinx.coroutines.core) + + implementation(compose.runtime) + implementation(compose.html.core) + } + } + } +} \ No newline at end of file diff --git a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/EditSmokeDialogWeb.kt b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/EditSmokeDialogWeb.kt new file mode 100644 index 00000000..1fb33a8b --- /dev/null +++ b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/EditSmokeDialogWeb.kt @@ -0,0 +1,116 @@ +package com.feragusper.smokeanalytics.features.home.presentation.web + +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 kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import org.jetbrains.compose.web.attributes.InputType +import org.jetbrains.compose.web.dom.Br +import org.jetbrains.compose.web.dom.Button +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.H3 +import org.jetbrains.compose.web.dom.Input +import org.jetbrains.compose.web.dom.Label +import org.jetbrains.compose.web.dom.Text + +@Composable +internal fun EditSmokeDialogWeb( + initialInstant: Instant, + fullDateTimeEdit: Boolean, + onDismiss: () -> Unit, + onConfirm: (Instant) -> Unit, +) { + val timeZone = remember { TimeZone.currentSystemDefault() } + + var dateValue by remember(initialInstant) { + mutableStateOf( + initialInstant.toDateInputValue( + timeZone + ) + ) + } + var timeValue by remember(initialInstant) { + mutableStateOf( + initialInstant.toTimeInputValue( + timeZone + ) + ) + } + + Div( + attrs = { + style { + property("position", "fixed") + property("inset", "0") + property("background", "rgba(0,0,0,0.4)") + property("display", "flex") + property("align-items", "center") + property("justify-content", "center") + property("z-index", "9999") + } + onClick { onDismiss() } // click outside closes + } + ) { + Div( + attrs = { + style { + property("background", "white") + property("padding", "16px") + property("border-radius", "12px") + property("min-width", "320px") + property("max-width", "90vw") + } + onClick { it.stopPropagation() } // prevent backdrop click + } + ) { + H3 { Text("Edit smoke") } + + if (fullDateTimeEdit) { + Label(forId = "date") { Text("Date") } + Input( + type = InputType.Date, + attrs = { + id("date") + value(dateValue) + onInput { dateValue = it.value } + } + ) + Br() + Br() + } + + Label(forId = "time") { Text("Time") } + Input( + type = InputType.Time, + attrs = { + id("time") + value(timeValue) + onInput { timeValue = it.value } + } + ) + + Br() + Br() + + Div { + Button(attrs = { onClick { onDismiss() } }) { Text("Cancel") } + Text(" ") + Button( + attrs = { + onClick { + val baseDate = if (fullDateTimeEdit) { + dateValue + } else { + initialInstant.toDateInputValue(timeZone) // keep original day + } + onConfirm(dateTimeInputsToInstant(baseDate, timeValue, timeZone)) + } + } + ) { Text("OK") } + } + } + } +} \ No newline at end of file diff --git a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeIntent.kt b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeIntent.kt new file mode 100644 index 00000000..e10ec9fe --- /dev/null +++ b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeIntent.kt @@ -0,0 +1,15 @@ +package com.feragusper.smokeanalytics.features.home.presentation.web + +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke +import kotlinx.datetime.Instant + +// comments in English only where needed +sealed interface HomeIntent { + data object FetchSmokes : HomeIntent + data object RefreshFetchSmokes : HomeIntent + data object AddSmoke : HomeIntent + data class EditSmoke(val id: String, val date: Instant) : HomeIntent + data class DeleteSmoke(val id: String) : HomeIntent + data object OnClickHistory : HomeIntent + data class TickTimeSinceLastCigarette(val lastCigarette: Smoke?) : HomeIntent +} \ No newline at end of file diff --git a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeProcessHolder.kt b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeProcessHolder.kt new file mode 100644 index 00000000..850257d0 --- /dev/null +++ b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeProcessHolder.kt @@ -0,0 +1,104 @@ +package com.feragusper.smokeanalytics.features.home.presentation.web + +import com.feragusper.smokeanalytics.features.home.domain.FetchSmokeCountListUseCase +import com.feragusper.smokeanalytics.libraries.architecture.domain.timeElapsedSinceNow +import com.feragusper.smokeanalytics.libraries.authentication.domain.FetchSessionUseCase +import com.feragusper.smokeanalytics.libraries.authentication.domain.Session +import com.feragusper.smokeanalytics.libraries.logging.AppLogger +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.AddSmokeUseCase +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.DeleteSmokeUseCase +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.EditSmokeUseCase +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flow + +class HomeProcessHolder( + private val addSmokeUseCase: AddSmokeUseCase, + private val editSmokeUseCase: EditSmokeUseCase, + private val deleteSmokeUseCase: DeleteSmokeUseCase, + private val fetchSmokeCountListUseCase: FetchSmokeCountListUseCase, + private val fetchSessionUseCase: FetchSessionUseCase, +) { + + fun processIntent(intent: HomeIntent): Flow = when (intent) { + HomeIntent.FetchSmokes -> processFetchSmokes(isRefresh = false) + HomeIntent.RefreshFetchSmokes -> processFetchSmokes(isRefresh = true) + HomeIntent.AddSmoke -> processAddSmoke() + is HomeIntent.EditSmoke -> processEditSmoke(intent) + is HomeIntent.DeleteSmoke -> processDeleteSmoke(intent) + HomeIntent.OnClickHistory -> flow { emit(HomeResult.GoToHistory) } + is HomeIntent.TickTimeSinceLastCigarette -> flow { + emit( + HomeResult.UpdateTimeSinceLastCigarette( + intent.lastCigarette?.date?.timeElapsedSinceNow() ?: (0L to 0L) + ) + ) + } + } + + private fun processFetchSmokes(isRefresh: Boolean): Flow = flow { + repeat(5) { attempt -> + when (fetchSessionUseCase()) { + is Session.Anonymous -> { + emit(HomeResult.NotLoggedIn) + + // Auth restoration on JS can be async; retry a few times on initial load. + if (!isRefresh && attempt < 4) { + delay(300) + return@repeat + } else { + return@flow + } + } + + is Session.LoggedIn -> { + emit(if (isRefresh) HomeResult.RefreshLoading else HomeResult.Loading) + emit(HomeResult.FetchSmokesSuccess(fetchSmokeCountListUseCase())) + return@flow + } + } + } + }.catch { + emit(HomeResult.FetchSmokesError) + } + + private fun processAddSmoke(): Flow = flow { + AppLogger.d { "AddSmoke intent received" } + + when (val session = fetchSessionUseCase()) { + is Session.Anonymous -> { + AppLogger.w { "AddSmoke blocked: user is anonymous" } + emit(HomeResult.Error.NotLoggedIn) + emit(HomeResult.GoToAuthentication) + } + + is Session.LoggedIn -> { + AppLogger.i { "User logged in, adding smoke..." } + emit(HomeResult.Loading) + addSmokeUseCase() + AppLogger.i { "Smoke added successfully" } + emit(HomeResult.AddSmokeSuccess) + } + } + }.catch { e -> + AppLogger.e { "Error adding smoke: ${e.message}" } + emit(HomeResult.Error.Generic) + } + + private fun processEditSmoke(intent: HomeIntent.EditSmoke): Flow = flow { + emit(HomeResult.Loading) + editSmokeUseCase(intent.id, intent.date) + emit(HomeResult.EditSmokeSuccess) + }.catch { + emit(HomeResult.Error.Generic) + } + + private fun processDeleteSmoke(intent: HomeIntent.DeleteSmoke): Flow = flow { + emit(HomeResult.Loading) + deleteSmokeUseCase(intent.id) + emit(HomeResult.DeleteSmokeSuccess) + }.catch { + emit(HomeResult.Error.Generic) + } +} \ No newline at end of file diff --git a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeResult.kt b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeResult.kt new file mode 100644 index 00000000..1b1efe38 --- /dev/null +++ b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeResult.kt @@ -0,0 +1,37 @@ +package com.feragusper.smokeanalytics.features.home.presentation.web + +import com.feragusper.smokeanalytics.features.home.domain.SmokeCountListResult + +sealed interface HomeResult { + + data object Loading : HomeResult + + data object RefreshLoading : HomeResult + + data object NotLoggedIn : HomeResult + + data object GoToAuthentication : HomeResult + + data object GoToHistory : HomeResult + + data object AddSmokeSuccess : HomeResult + + data object EditSmokeSuccess : HomeResult + + data object DeleteSmokeSuccess : HomeResult + + sealed interface Error : HomeResult { + data object Generic : Error + data object NotLoggedIn : Error + } + + data class FetchSmokesSuccess( + val smokeCountListResult: SmokeCountListResult + ) : HomeResult + + data object FetchSmokesError : HomeResult + + data class UpdateTimeSinceLastCigarette( + val timeSinceLastCigarette: Pair + ) : HomeResult +} \ No newline at end of file diff --git a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeViewState.kt b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeViewState.kt new file mode 100644 index 00000000..d3ede4ec --- /dev/null +++ b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeViewState.kt @@ -0,0 +1,19 @@ +package com.feragusper.smokeanalytics.features.home.presentation.web + +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke + +data class HomeViewState( + val displayLoading: Boolean = false, + val displayRefreshLoading: Boolean = false, + val smokesPerDay: Int? = null, + val smokesPerWeek: Int? = null, + val smokesPerMonth: Int? = null, + val timeSinceLastCigarette: Pair? = null, + val latestSmokes: List? = null, + val error: HomeError? = null, +) { + sealed interface HomeError { + data object Generic : HomeError + data object NotLoggedIn : HomeError + } +} \ No newline at end of file diff --git a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebDependencies.kt b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebDependencies.kt new file mode 100644 index 00000000..a7c1e371 --- /dev/null +++ b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebDependencies.kt @@ -0,0 +1,29 @@ +package com.feragusper.smokeanalytics.features.home.presentation.web + +import com.feragusper.smokeanalytics.features.home.domain.FetchSmokeCountListUseCase +import com.feragusper.smokeanalytics.libraries.authentication.domain.FetchSessionUseCase +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.AddSmokeUseCase +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.DeleteSmokeUseCase +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.EditSmokeUseCase + +class HomeWebDependencies( + val homeProcessHolder: HomeProcessHolder, +) + +fun createHomeWebDependencies( + fetchSessionUseCase: FetchSessionUseCase, + fetchSmokeCountListUseCase: FetchSmokeCountListUseCase, + addSmokeUseCase: AddSmokeUseCase, + editSmokeUseCase: EditSmokeUseCase, + deleteSmokeUseCase: DeleteSmokeUseCase, +): HomeWebDependencies { + return HomeWebDependencies( + homeProcessHolder = HomeProcessHolder( + addSmokeUseCase = addSmokeUseCase, + editSmokeUseCase = editSmokeUseCase, + deleteSmokeUseCase = deleteSmokeUseCase, + fetchSmokeCountListUseCase = fetchSmokeCountListUseCase, + fetchSessionUseCase = fetchSessionUseCase, + ) + ) +} \ No newline at end of file diff --git a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebScreen.kt b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebScreen.kt new file mode 100644 index 00000000..ac05393a --- /dev/null +++ b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebScreen.kt @@ -0,0 +1,195 @@ +package com.feragusper.smokeanalytics.features.home.presentation.web + +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 com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import org.jetbrains.compose.web.attributes.disabled +import org.jetbrains.compose.web.dom.Button +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.H2 +import org.jetbrains.compose.web.dom.Hr +import org.jetbrains.compose.web.dom.P +import org.jetbrains.compose.web.dom.Text + +@Composable +fun HomeWebScreen( + deps: HomeWebDependencies, + onNavigateToAuth: () -> Unit, + onNavigateToHistory: () -> Unit, +) { + val store = remember(deps) { HomeWebStore(processHolder = deps.homeProcessHolder) } + + LaunchedEffect(store) { store.start() } + + val state by store.state.collectAsState() + + state.Render( + onNavigateToHistory = onNavigateToHistory, + onIntent = { intent -> + when (intent) { + HomeIntent.OnClickHistory -> onNavigateToHistory() +// HomeIntent.GoToAuthentication -> onNavigateToAuth() + else -> store.send(intent) + } + } + ) +} + +@Composable +fun HomeViewState.Render( + onNavigateToHistory: () -> Unit, + onIntent: (HomeIntent) -> Unit, +) { + Div { + H2 { Text("Home") } + + if (displayLoading) { + P { Text("Loading...") } + } + + Div { + Button( + attrs = { + if (displayLoading) disabled() + onClick { onIntent(HomeIntent.AddSmoke) } + } + ) { Text("Add smoke") } + + Text(" ") + + Button( + attrs = { + if (displayLoading) disabled() + onClick { onIntent(HomeIntent.RefreshFetchSmokes) } + } + ) { Text("Refresh") } + } + + Hr() + + P { Text("Today: ${smokesPerDay ?: "--"}") } + P { Text("Week: ${smokesPerWeek ?: "--"}") } + P { Text("Month: ${smokesPerMonth ?: "--"}") } + + val since = timeSinceLastCigarette?.let { (h, m) -> "${h}h ${m}m" } ?: "--" + P { Text("Since last: $since") } + + Hr() + + var editing by remember { mutableStateOf(null) } + + latestSmokes?.let { smokes -> + Hr() + H2 { Text("Smoked today") } + + if (smokes.isEmpty()) { + P { Text("No smokes yet") } + } else { + smokes.forEach { smoke -> + val local = smoke.date.toLocalDateTime(TimeZone.currentSystemDefault()) + Div { + P { + val hh = local.hour.toString().padStart(2, '0') + val mm = local.minute.toString().padStart(2, '0') + + Text("$hh:$mm") + Text(" (id=${smoke.id})") + } + + Button( + attrs = { + if (displayLoading) disabled() + onClick { editing = smoke } + } + ) { Text("Edit") } + + Text(" ") + + Button( + attrs = { + if (displayLoading) disabled() + onClick { onIntent(HomeIntent.DeleteSmoke(smoke.id)) } + } + ) { Text("Delete") } + + Hr() + } + } + } + } + + editing?.let { smoke -> + EditSmokeDialogWeb( + initialInstant = smoke.date, + fullDateTimeEdit = false, // igual que mobile Home (solo hora) + onDismiss = { editing = null }, + onConfirm = { newInstant -> + editing = null + onIntent(HomeIntent.EditSmoke(smoke.id, newInstant)) + } + ) + } + + Button(attrs = { onClick { onIntent(HomeIntent.OnClickHistory) } }) { + Text("History") + } + + if (error != null) { + Hr() + P { + Text( + when (error) { + HomeResult.Error.NotLoggedIn -> "Not logged in" + HomeResult.Error.Generic -> "Something went wrong" + else -> "Unknown error" + } + ) + } + } + } +} + +internal fun Instant.toDateInputValue(timeZone: TimeZone): String { + val ldt = toLocalDateTime(timeZone) + val mm = ldt.monthNumber.toString().padStart(2, '0') + val dd = ldt.dayOfMonth.toString().padStart(2, '0') + return "${ldt.year}-$mm-$dd" +} + +internal fun Instant.toTimeInputValue(timeZone: TimeZone): String { + val ldt = toLocalDateTime(timeZone) + val hh = ldt.hour.toString().padStart(2, '0') + val mm = ldt.minute.toString().padStart(2, '0') + return "$hh:$mm" +} + +internal fun dateTimeInputsToInstant( + dateValue: String, + timeValue: String, + timeZone: TimeZone, +): Instant { + val date = LocalDate.parse(dateValue) // "YYYY-MM-DD" + val time = LocalTime.parse(timeValue) // "HH:MM" + val ldt = LocalDateTime( + year = date.year, + monthNumber = date.monthNumber, + dayOfMonth = date.dayOfMonth, + hour = time.hour, + minute = time.minute, + second = 0, + nanosecond = 0, + ) + return ldt.toInstant(timeZone) +} \ No newline at end of file diff --git a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebStore.kt b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebStore.kt new file mode 100644 index 00000000..cb1789f2 --- /dev/null +++ b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebStore.kt @@ -0,0 +1,111 @@ +package com.feragusper.smokeanalytics.features.home.presentation.web + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch + +class HomeWebStore( + private val processHolder: HomeProcessHolder, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), +) { + private val intents = Channel(capacity = Channel.Factory.BUFFERED) + + private val _state = MutableStateFlow(HomeViewState()) + val state: StateFlow = _state.asStateFlow() + + fun send(intent: HomeIntent) { + intents.trySend(intent) + } + + fun start() { + scope.launch { + intents + .receiveAsFlow() + .flatMapLatest { intent -> processHolder.processIntent(intent) } + .collect { result -> reduce(result) } + } + + // bootstrap + send(HomeIntent.FetchSmokes) + } + + private fun reduce(result: HomeResult) { + val previous = _state.value + val newState = when (result) { + HomeResult.Loading -> previous.copy( + displayLoading = true, + displayRefreshLoading = false, + error = null, + ) + + HomeResult.RefreshLoading -> previous.copy( + displayLoading = false, + displayRefreshLoading = true, + error = null, + ) + + HomeResult.NotLoggedIn -> previous.copy( + displayLoading = false, + displayRefreshLoading = false, + error = null, + smokesPerDay = 0, + smokesPerWeek = 0, + smokesPerMonth = 0, + timeSinceLastCigarette = 0L to 0L, + latestSmokes = emptyList(), + ) + + is HomeResult.FetchSmokesSuccess -> previous.copy( + displayLoading = false, + displayRefreshLoading = false, + error = null, + smokesPerDay = result.smokeCountListResult.countByToday, + smokesPerWeek = result.smokeCountListResult.countByWeek, + smokesPerMonth = result.smokeCountListResult.countByMonth, + latestSmokes = result.smokeCountListResult.todaysSmokes, + timeSinceLastCigarette = result.smokeCountListResult.timeSinceLastCigarette, + ) + + is HomeResult.UpdateTimeSinceLastCigarette -> previous.copy( + timeSinceLastCigarette = result.timeSinceLastCigarette + ) + + HomeResult.AddSmokeSuccess, + HomeResult.EditSmokeSuccess, + HomeResult.DeleteSmokeSuccess -> { + send(HomeIntent.FetchSmokes) + previous + } + + is HomeResult.Error -> previous.copy( + displayLoading = false, + displayRefreshLoading = false, + error = result.toHomeError(), + ) + + HomeResult.FetchSmokesError -> previous.copy( + displayLoading = false, + displayRefreshLoading = false, + error = HomeViewState.HomeError.Generic, + ) + + HomeResult.GoToAuthentication, + HomeResult.GoToHistory -> previous // en web lo resolvemos arriba (router) o por callbacks + } + + _state.value = newState + } + + private fun HomeResult.Error.toHomeError(): HomeViewState.HomeError = + when (this) { + HomeResult.Error.Generic -> HomeViewState.HomeError.Generic + HomeResult.Error.NotLoggedIn -> HomeViewState.HomeError.NotLoggedIn + } +} \ No newline at end of file diff --git a/libraries/authentication/presentation/.gitignore b/features/settings/presentation/mobile/.gitignore similarity index 100% rename from libraries/authentication/presentation/.gitignore rename to features/settings/presentation/mobile/.gitignore diff --git a/features/settings/presentation/build.gradle.kts b/features/settings/presentation/mobile/build.gradle.kts similarity index 95% rename from features/settings/presentation/build.gradle.kts rename to features/settings/presentation/mobile/build.gradle.kts index 2767a7d3..f783dc13 100644 --- a/features/settings/presentation/build.gradle.kts +++ b/features/settings/presentation/mobile/build.gradle.kts @@ -26,15 +26,15 @@ android { dependencies { // Architecture and presentation layers - implementation(project(":libraries:architecture:presentation")) + implementation(project(":libraries:architecture:presentation:mobile")) // Design system for consistent theming and UI components implementation(project(":libraries:design")) // Authentication modules for managing user sessions - implementation(project(":libraries:authentication:presentation")) + implementation(project(":libraries:authentication:presentation:mobile")) implementation(project(":libraries:authentication:domain")) - implementation(project(":libraries:authentication:data")) + implementation(project(":libraries:authentication:data:mobile")) // Core AndroidX libraries and Compose dependencies implementation(libs.bundles.androidx.base) diff --git a/features/settings/presentation/src/androidTest/java/com/feragusper/smokeanalytics/features/settings/presentation/SettingsViewTest.kt b/features/settings/presentation/mobile/src/androidTest/java/com/feragusper/smokeanalytics/features/settings/presentation/SettingsViewTest.kt similarity index 100% rename from features/settings/presentation/src/androidTest/java/com/feragusper/smokeanalytics/features/settings/presentation/SettingsViewTest.kt rename to features/settings/presentation/mobile/src/androidTest/java/com/feragusper/smokeanalytics/features/settings/presentation/SettingsViewTest.kt diff --git a/features/settings/presentation/src/main/AndroidManifest.xml b/features/settings/presentation/mobile/src/main/AndroidManifest.xml similarity index 100% rename from features/settings/presentation/src/main/AndroidManifest.xml rename to features/settings/presentation/mobile/src/main/AndroidManifest.xml diff --git a/features/settings/presentation/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/SettingsView.kt b/features/settings/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/SettingsView.kt similarity index 100% rename from features/settings/presentation/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/SettingsView.kt rename to features/settings/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/SettingsView.kt diff --git a/features/settings/presentation/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/SettingsViewModel.kt b/features/settings/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/SettingsViewModel.kt similarity index 100% rename from features/settings/presentation/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/SettingsViewModel.kt rename to features/settings/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/SettingsViewModel.kt diff --git a/features/settings/presentation/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/mvi/SettingsIntent.kt b/features/settings/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/mvi/SettingsIntent.kt similarity index 100% rename from features/settings/presentation/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/mvi/SettingsIntent.kt rename to features/settings/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/mvi/SettingsIntent.kt diff --git a/features/settings/presentation/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/mvi/SettingsResult.kt b/features/settings/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/mvi/SettingsResult.kt similarity index 100% rename from features/settings/presentation/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/mvi/SettingsResult.kt rename to features/settings/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/mvi/SettingsResult.kt diff --git a/features/settings/presentation/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/mvi/compose/SettingsViewState.kt b/features/settings/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/mvi/compose/SettingsViewState.kt similarity index 100% rename from features/settings/presentation/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/mvi/compose/SettingsViewState.kt rename to features/settings/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/mvi/compose/SettingsViewState.kt diff --git a/features/settings/presentation/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/navigation/SettingsNavigationGraph.kt b/features/settings/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/navigation/SettingsNavigationGraph.kt similarity index 100% rename from features/settings/presentation/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/navigation/SettingsNavigationGraph.kt rename to features/settings/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/navigation/SettingsNavigationGraph.kt diff --git a/features/settings/presentation/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/navigation/SettingsNavigator.kt b/features/settings/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/navigation/SettingsNavigator.kt similarity index 100% rename from features/settings/presentation/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/navigation/SettingsNavigator.kt rename to features/settings/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/navigation/SettingsNavigator.kt diff --git a/features/settings/presentation/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/process/SettingsProcessHolder.kt b/features/settings/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/process/SettingsProcessHolder.kt similarity index 100% rename from features/settings/presentation/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/process/SettingsProcessHolder.kt rename to features/settings/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/settings/presentation/process/SettingsProcessHolder.kt diff --git a/features/settings/presentation/src/main/res/drawable/ic_person.xml b/features/settings/presentation/mobile/src/main/res/drawable/ic_person.xml similarity index 100% rename from features/settings/presentation/src/main/res/drawable/ic_person.xml rename to features/settings/presentation/mobile/src/main/res/drawable/ic_person.xml diff --git a/features/settings/presentation/src/main/res/values/strings.xml b/features/settings/presentation/mobile/src/main/res/values/strings.xml similarity index 100% rename from features/settings/presentation/src/main/res/values/strings.xml rename to features/settings/presentation/mobile/src/main/res/values/strings.xml diff --git a/features/settings/presentation/src/test/java/com/feragusper/smokeanalytics/features/settings/presentation/SettingsViewModelTest.kt b/features/settings/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/settings/presentation/SettingsViewModelTest.kt similarity index 100% rename from features/settings/presentation/src/test/java/com/feragusper/smokeanalytics/features/settings/presentation/SettingsViewModelTest.kt rename to features/settings/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/settings/presentation/SettingsViewModelTest.kt diff --git a/features/settings/presentation/src/test/java/com/feragusper/smokeanalytics/features/settings/presentation/process/SettingsProcessHolderTest.kt b/features/settings/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/settings/presentation/process/SettingsProcessHolderTest.kt similarity index 100% rename from features/settings/presentation/src/test/java/com/feragusper/smokeanalytics/features/settings/presentation/process/SettingsProcessHolderTest.kt rename to features/settings/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/settings/presentation/process/SettingsProcessHolderTest.kt diff --git a/libraries/smokes/data/.gitignore b/features/settings/presentation/web/.gitignore similarity index 100% rename from libraries/smokes/data/.gitignore rename to features/settings/presentation/web/.gitignore diff --git a/features/settings/presentation/web/build.gradle.kts b/features/settings/presentation/web/build.gradle.kts new file mode 100644 index 00000000..31e7e0c5 --- /dev/null +++ b/features/settings/presentation/web/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + kotlin("multiplatform") + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.compose.compiler) +} + +kotlin { + js(IR) { + browser() + binaries.executable() + } + + sourceSets { + val jsMain by getting { + dependencies { + implementation(compose.runtime) + implementation(compose.web.core) + + implementation(project(":libraries:authentication:domain")) + implementation(project(":libraries:authentication:presentation:web")) + } + } + + val jsTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + } +} \ No newline at end of file diff --git a/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsIntent.kt b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsIntent.kt new file mode 100644 index 00000000..62320e86 --- /dev/null +++ b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsIntent.kt @@ -0,0 +1,6 @@ +package com.feragusper.smokeanalytics.features.settings.presentation.web + +sealed interface SettingsIntent { + data object FetchUser : SettingsIntent + data object SignOut : SettingsIntent +} \ No newline at end of file diff --git a/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsProcessHolder.kt b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsProcessHolder.kt new file mode 100644 index 00000000..f13e9978 --- /dev/null +++ b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsProcessHolder.kt @@ -0,0 +1,36 @@ +package com.feragusper.smokeanalytics.features.settings.presentation.web + +import com.feragusper.smokeanalytics.libraries.authentication.domain.FetchSessionUseCase +import com.feragusper.smokeanalytics.libraries.authentication.domain.Session +import com.feragusper.smokeanalytics.libraries.authentication.domain.SignOutUseCase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flow + +class SettingsProcessHolder( + private val fetchSessionUseCase: FetchSessionUseCase, + private val signOutUseCase: SignOutUseCase, +) { + fun processIntent(intent: SettingsIntent): Flow = when (intent) { + SettingsIntent.FetchUser -> processFetchUser() + SettingsIntent.SignOut -> processSignOut() + } + + private fun processFetchUser(): Flow = flow { + emit(SettingsResult.Loading) + when (val session = fetchSessionUseCase()) { + is Session.Anonymous -> emit(SettingsResult.UserLoggedOut) + is Session.LoggedIn -> emit(SettingsResult.UserLoggedIn(session.user.email)) + } + }.catch { + emit(SettingsResult.ErrorGeneric) + } + + private fun processSignOut(): Flow = flow { + emit(SettingsResult.Loading) + signOutUseCase() + emit(SettingsResult.UserLoggedOut) + }.catch { + emit(SettingsResult.ErrorGeneric) + } +} \ No newline at end of file diff --git a/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsResult.kt b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsResult.kt new file mode 100644 index 00000000..949ba8c7 --- /dev/null +++ b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsResult.kt @@ -0,0 +1,8 @@ +package com.feragusper.smokeanalytics.features.settings.presentation.web + +sealed interface SettingsResult { + data object Loading : SettingsResult + data class UserLoggedIn(val email: String?) : SettingsResult + data object UserLoggedOut : SettingsResult + data object ErrorGeneric : SettingsResult +} \ No newline at end of file diff --git a/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsViewState.kt b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsViewState.kt new file mode 100644 index 00000000..4304cc49 --- /dev/null +++ b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsViewState.kt @@ -0,0 +1,7 @@ +package com.feragusper.smokeanalytics.features.settings.presentation.web + +data class SettingsViewState( + val displayLoading: Boolean = false, + val currentEmail: String? = null, + val errorMessage: String? = null, +) \ No newline at end of file diff --git a/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebDependencies.kt b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebDependencies.kt new file mode 100644 index 00000000..a66e9e63 --- /dev/null +++ b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebDependencies.kt @@ -0,0 +1,20 @@ +package com.feragusper.smokeanalytics.features.settings.presentation.web + +import com.feragusper.smokeanalytics.libraries.authentication.domain.FetchSessionUseCase +import com.feragusper.smokeanalytics.libraries.authentication.domain.SignOutUseCase + +class SettingsWebDependencies( + val processHolder: SettingsProcessHolder, +) + +fun createSettingsWebDependencies( + fetchSessionUseCase: FetchSessionUseCase, + signOutUseCase: SignOutUseCase, +): SettingsWebDependencies { + return SettingsWebDependencies( + processHolder = SettingsProcessHolder( + fetchSessionUseCase = fetchSessionUseCase, + signOutUseCase = signOutUseCase, + ) + ) +} \ No newline at end of file diff --git a/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebScreen.kt b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebScreen.kt new file mode 100644 index 00000000..a7972912 --- /dev/null +++ b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebScreen.kt @@ -0,0 +1,76 @@ +package com.feragusper.smokeanalytics.features.settings.presentation.web + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import com.feragusper.smokeanalytics.libraries.authentication.presentation.compose.GoogleSignInComponentWeb +import org.jetbrains.compose.web.attributes.disabled +import org.jetbrains.compose.web.dom.Button +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.H2 +import org.jetbrains.compose.web.dom.Hr +import org.jetbrains.compose.web.dom.P +import org.jetbrains.compose.web.dom.Text + +@Composable +fun SettingsWebScreen( + deps: SettingsWebDependencies, +) { + val store = remember(deps) { SettingsWebStore(processHolder = deps.processHolder) } + + LaunchedEffect(store) { store.start() } + + val state by store.state.collectAsState() + + state.Render( + onIntent = { store.send(it) } + ) +} + +@Composable +private fun SettingsViewState.Render( + onIntent: (SettingsIntent) -> Unit, +) { + Div { + H2 { Text("Settings") } + + if (displayLoading) { + P { Text("Loading...") } + return@Div + } + + if (currentEmail != null) { + P { Text("Signed in as: $currentEmail") } + + Hr() + + Button( + attrs = { + if (displayLoading) disabled() + onClick { onIntent(SettingsIntent.SignOut) } + } + ) { + Text("Sign out") + } + } else { + P { Text("You are not signed in") } + + Hr() + + GoogleSignInComponentWeb( + onSignInSuccess = { onIntent(SettingsIntent.FetchUser) }, + onSignInError = { t -> + // Keep it simple: show a generic message, or you can push it into Store if you want + console.error("Sign-in error", t) + } + ) + } + + errorMessage?.let { + Hr() + P { Text(it) } + } + } +} \ No newline at end of file diff --git a/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebStore.kt b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebStore.kt new file mode 100644 index 00000000..bbcb71d6 --- /dev/null +++ b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebStore.kt @@ -0,0 +1,66 @@ +package com.feragusper.smokeanalytics.features.settings.presentation.web + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch + +class SettingsWebStore( + private val processHolder: SettingsProcessHolder, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), +) { + private val intents = Channel(capacity = Channel.Factory.BUFFERED) + + private val _state = MutableStateFlow(SettingsViewState()) + val state: StateFlow = _state.asStateFlow() + + fun send(intent: SettingsIntent) { + intents.trySend(intent) + } + + fun start() { + scope.launch { + intents + .receiveAsFlow() + .flatMapLatest { intent -> processHolder.processIntent(intent) } + .collect { result -> reduce(result) } + } + + send(SettingsIntent.FetchUser) + } + + private fun reduce(result: SettingsResult) { + val previous = _state.value + val newState = when (result) { + SettingsResult.Loading -> previous.copy( + displayLoading = true, + errorMessage = null, + ) + + is SettingsResult.UserLoggedIn -> previous.copy( + displayLoading = false, + currentEmail = result.email, + errorMessage = null, + ) + + SettingsResult.UserLoggedOut -> previous.copy( + displayLoading = false, + currentEmail = null, + errorMessage = null, + ) + + SettingsResult.ErrorGeneric -> previous.copy( + displayLoading = false, + errorMessage = "Something went wrong", + ) + } + + _state.value = newState + } +} \ No newline at end of file diff --git a/features/stats/presentation/mobile/.gitignore b/features/stats/presentation/mobile/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/features/stats/presentation/mobile/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/stats/presentation/build.gradle.kts b/features/stats/presentation/mobile/build.gradle.kts similarity index 99% rename from features/stats/presentation/build.gradle.kts rename to features/stats/presentation/mobile/build.gradle.kts index cbdd6f7e..ca7ab30c 100644 --- a/features/stats/presentation/build.gradle.kts +++ b/features/stats/presentation/mobile/build.gradle.kts @@ -29,7 +29,7 @@ android { dependencies { // Architecture and presentation layers - implementation(project(":libraries:architecture:presentation")) + implementation(project(":libraries:architecture:presentation:mobile")) // Design system for consistent theming and UI components implementation(project(":libraries:design")) diff --git a/features/stats/presentation/src/main/AndroidManifest.xml b/features/stats/presentation/mobile/src/main/AndroidManifest.xml similarity index 100% rename from features/stats/presentation/src/main/AndroidManifest.xml rename to features/stats/presentation/mobile/src/main/AndroidManifest.xml diff --git a/features/stats/presentation/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/StatsView.kt b/features/stats/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/StatsView.kt similarity index 100% rename from features/stats/presentation/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/StatsView.kt rename to features/stats/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/StatsView.kt diff --git a/features/stats/presentation/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/StatsViewModel.kt b/features/stats/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/StatsViewModel.kt similarity index 100% rename from features/stats/presentation/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/StatsViewModel.kt rename to features/stats/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/StatsViewModel.kt diff --git a/features/stats/presentation/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/mvi/StatsIntent.kt b/features/stats/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/mvi/StatsIntent.kt similarity index 93% rename from features/stats/presentation/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/mvi/StatsIntent.kt rename to features/stats/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/mvi/StatsIntent.kt index 6836be83..662c5204 100644 --- a/features/stats/presentation/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/mvi/StatsIntent.kt +++ b/features/stats/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/mvi/StatsIntent.kt @@ -1,7 +1,7 @@ package com.feragusper.smokeanalytics.features.stats.presentation.mvi import com.feragusper.smokeanalytics.libraries.architecture.presentation.mvi.MVIIntent -import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokeStatsUseCase.PeriodType +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokeStatsUseCase /** * Defines user intentions that can trigger actions within the Stats feature. @@ -26,6 +26,6 @@ sealed class StatsIntent : MVIIntent { val year: Int, val month: Int, val day: Int, - val period: PeriodType + val period: FetchSmokeStatsUseCase.PeriodType ) : StatsIntent() } diff --git a/features/stats/presentation/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/mvi/StatsResult.kt b/features/stats/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/mvi/StatsResult.kt similarity index 100% rename from features/stats/presentation/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/mvi/StatsResult.kt rename to features/stats/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/mvi/StatsResult.kt diff --git a/features/stats/presentation/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/mvi/compose/StatsViewState.kt b/features/stats/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/mvi/compose/StatsViewState.kt similarity index 96% rename from features/stats/presentation/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/mvi/compose/StatsViewState.kt rename to features/stats/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/mvi/compose/StatsViewState.kt index 9e18022f..5bd659f6 100644 --- a/features/stats/presentation/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/mvi/compose/StatsViewState.kt +++ b/features/stats/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/mvi/compose/StatsViewState.kt @@ -31,7 +31,7 @@ import com.feragusper.smokeanalytics.libraries.architecture.presentation.mvi.MVI import com.feragusper.smokeanalytics.libraries.design.compose.CombinedPreviews import com.feragusper.smokeanalytics.libraries.design.compose.theme.SmokeAnalyticsTheme import com.feragusper.smokeanalytics.libraries.smokes.domain.model.SmokeStats -import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokeStatsUseCase.PeriodType +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokeStatsUseCase import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom import com.patrykandpatrick.vico.compose.cartesian.axis.rememberStart @@ -303,12 +303,12 @@ fun rememberXAxisFormatter(stats: Map): CartesianValueFormatter { } -fun StatsViewState.StatsPeriod.toDomainPeriodType(): PeriodType { +fun StatsViewState.StatsPeriod.toDomainPeriodType(): FetchSmokeStatsUseCase.PeriodType { return when (this) { - StatsViewState.StatsPeriod.DAY -> PeriodType.DAY - StatsViewState.StatsPeriod.WEEK -> PeriodType.WEEK - StatsViewState.StatsPeriod.MONTH -> PeriodType.MONTH - StatsViewState.StatsPeriod.YEAR -> PeriodType.YEAR + StatsViewState.StatsPeriod.DAY -> FetchSmokeStatsUseCase.PeriodType.DAY + StatsViewState.StatsPeriod.WEEK -> FetchSmokeStatsUseCase.PeriodType.WEEK + StatsViewState.StatsPeriod.MONTH -> FetchSmokeStatsUseCase.PeriodType.MONTH + StatsViewState.StatsPeriod.YEAR -> FetchSmokeStatsUseCase.PeriodType.YEAR } } diff --git a/features/stats/presentation/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/navigation/StatsNavigationGraph.kt b/features/stats/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/navigation/StatsNavigationGraph.kt similarity index 100% rename from features/stats/presentation/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/navigation/StatsNavigationGraph.kt rename to features/stats/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/navigation/StatsNavigationGraph.kt diff --git a/features/stats/presentation/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/navigation/StatsNavigator.kt b/features/stats/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/navigation/StatsNavigator.kt similarity index 100% rename from features/stats/presentation/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/navigation/StatsNavigator.kt rename to features/stats/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/navigation/StatsNavigator.kt diff --git a/features/stats/presentation/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/process/StatsProcessHolder.kt b/features/stats/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/process/StatsProcessHolder.kt similarity index 100% rename from features/stats/presentation/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/process/StatsProcessHolder.kt rename to features/stats/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/features/stats/presentation/process/StatsProcessHolder.kt diff --git a/features/stats/presentation/src/main/res/drawable/ill_chart.xml b/features/stats/presentation/mobile/src/main/res/drawable/ill_chart.xml similarity index 100% rename from features/stats/presentation/src/main/res/drawable/ill_chart.xml rename to features/stats/presentation/mobile/src/main/res/drawable/ill_chart.xml diff --git a/features/stats/presentation/src/test/java/com/feragusper/smokeanalytics/features/stats/presentation/StatsViewModelTest.kt b/features/stats/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/stats/presentation/StatsViewModelTest.kt similarity index 97% rename from features/stats/presentation/src/test/java/com/feragusper/smokeanalytics/features/stats/presentation/StatsViewModelTest.kt rename to features/stats/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/stats/presentation/StatsViewModelTest.kt index e3aebe4d..c5486352 100644 --- a/features/stats/presentation/src/test/java/com/feragusper/smokeanalytics/features/stats/presentation/StatsViewModelTest.kt +++ b/features/stats/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/stats/presentation/StatsViewModelTest.kt @@ -6,7 +6,6 @@ import com.feragusper.smokeanalytics.features.stats.presentation.mvi.StatsResult import com.feragusper.smokeanalytics.features.stats.presentation.mvi.compose.StatsViewState import com.feragusper.smokeanalytics.features.stats.presentation.process.StatsProcessHolder import com.feragusper.smokeanalytics.libraries.smokes.domain.model.SmokeStats -import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokeStatsUseCase.PeriodType import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers diff --git a/features/stats/presentation/src/test/java/com/feragusper/smokeanalytics/features/stats/presentation/process/StatsProcessHolderTest.kt b/features/stats/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/stats/presentation/process/StatsProcessHolderTest.kt similarity index 96% rename from features/stats/presentation/src/test/java/com/feragusper/smokeanalytics/features/stats/presentation/process/StatsProcessHolderTest.kt rename to features/stats/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/stats/presentation/process/StatsProcessHolderTest.kt index b8909412..2b1b72b1 100644 --- a/features/stats/presentation/src/test/java/com/feragusper/smokeanalytics/features/stats/presentation/process/StatsProcessHolderTest.kt +++ b/features/stats/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/stats/presentation/process/StatsProcessHolderTest.kt @@ -4,7 +4,6 @@ import app.cash.turbine.test import com.feragusper.smokeanalytics.features.stats.presentation.mvi.StatsIntent import com.feragusper.smokeanalytics.features.stats.presentation.mvi.StatsResult import com.feragusper.smokeanalytics.libraries.smokes.domain.model.SmokeStats -import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokeStatsUseCase import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk diff --git a/features/stats/presentation/web/.gitignore b/features/stats/presentation/web/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/features/stats/presentation/web/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/stats/presentation/web/build.gradle.kts b/features/stats/presentation/web/build.gradle.kts new file mode 100644 index 00000000..22d34371 --- /dev/null +++ b/features/stats/presentation/web/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + kotlin("multiplatform") + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.compose.compiler) +} + +kotlin { + js(IR) { + browser() + } + + sourceSets { + val jsMain by getting { + dependencies { + // --- Architecture / state handling --- + implementation(project(":libraries:architecture:domain")) + + // --- Domain --- + implementation(project(":libraries:smokes:domain")) + + // --- Coroutines --- + implementation(libs.kotlinx.coroutines.core) + + // --- Compose Web --- + implementation(compose.runtime) + implementation(compose.html.core) + + implementation(npm("chart.js", "4.4.1")) + } + } + + val jsTest by getting + } +} \ No newline at end of file diff --git a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/ChartJs.kt b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/ChartJs.kt new file mode 100644 index 00000000..1d675988 --- /dev/null +++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/ChartJs.kt @@ -0,0 +1,13 @@ +@file:JsModule("chart.js/auto") +@file:JsNonModule + +package com.feragusper.smokeanalytics.features.stats.presentation.web + +import org.w3c.dom.CanvasRenderingContext2D + +external class Chart( + ctx: CanvasRenderingContext2D, + config: dynamic, +) { + fun destroy() +} \ No newline at end of file diff --git a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsIntent.kt b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsIntent.kt new file mode 100644 index 00000000..c5960e2f --- /dev/null +++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsIntent.kt @@ -0,0 +1,12 @@ +package com.feragusper.smokeanalytics.features.stats.presentation.web + +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokeStatsUseCase + +sealed interface StatsIntent { + data class LoadStats( + val year: Int, + val month: Int, + val day: Int, + val period: FetchSmokeStatsUseCase.PeriodType, + ) : StatsIntent +} \ No newline at end of file diff --git a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsProcessHolder.kt b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsProcessHolder.kt new file mode 100644 index 00000000..dfe28f14 --- /dev/null +++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsProcessHolder.kt @@ -0,0 +1,28 @@ +package com.feragusper.smokeanalytics.features.stats.presentation.web + +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokeStatsUseCase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flow + +class StatsProcessHolder( + private val fetchSmokeStatsUseCase: FetchSmokeStatsUseCase, +) { + + fun processIntent(intent: StatsIntent): Flow = when (intent) { + is StatsIntent.LoadStats -> processLoadStats(intent) + } + + private fun processLoadStats(intent: StatsIntent.LoadStats): Flow = flow { + emit(StatsResult.Loading) + val stats = fetchSmokeStatsUseCase( + year = intent.year, + month = intent.month, + day = intent.day, + periodType = intent.period, + ) + emit(StatsResult.Success(stats)) + }.catch { e -> + emit(StatsResult.Error(e)) + } +} \ No newline at end of file diff --git a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsResult.kt b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsResult.kt new file mode 100644 index 00000000..cbbe9883 --- /dev/null +++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsResult.kt @@ -0,0 +1,9 @@ +package com.feragusper.smokeanalytics.features.stats.presentation.web + +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.SmokeStats + +sealed interface StatsResult { + data object Loading : StatsResult + data class Success(val stats: SmokeStats) : StatsResult + data class Error(val error: Throwable) : StatsResult +} \ No newline at end of file diff --git a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsViewState.kt b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsViewState.kt new file mode 100644 index 00000000..28481ebe --- /dev/null +++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsViewState.kt @@ -0,0 +1,13 @@ +package com.feragusper.smokeanalytics.features.stats.presentation.web + +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.SmokeStats + +data class StatsViewState( + val displayLoading: Boolean = false, + val stats: SmokeStats? = null, + val error: StatsError? = null, +) { + sealed interface StatsError { + data object Generic : StatsError + } +} \ No newline at end of file diff --git a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebDependencies.kt b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebDependencies.kt new file mode 100644 index 00000000..69a0e5b0 --- /dev/null +++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebDependencies.kt @@ -0,0 +1,15 @@ +package com.feragusper.smokeanalytics.features.stats.presentation.web + +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokeStatsUseCase + +class StatsWebDependencies( + val processHolder: StatsProcessHolder, +) + +fun createStatsWebDependencies( + fetchSmokeStatsUseCase: FetchSmokeStatsUseCase, +): StatsWebDependencies { + return StatsWebDependencies( + processHolder = StatsProcessHolder(fetchSmokeStatsUseCase), + ) +} \ No newline at end of file diff --git a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebScreen.kt b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebScreen.kt new file mode 100644 index 00000000..1e25fc66 --- /dev/null +++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebScreen.kt @@ -0,0 +1,339 @@ +package com.feragusper.smokeanalytics.features.stats.presentation.web + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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 com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokeStatsUseCase +import kotlinx.datetime.Clock +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime +import org.jetbrains.compose.web.attributes.InputType +import org.jetbrains.compose.web.attributes.disabled +import org.jetbrains.compose.web.dom.Button +import org.jetbrains.compose.web.dom.Canvas +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.H2 +import org.jetbrains.compose.web.dom.Input +import org.jetbrains.compose.web.dom.Span +import org.jetbrains.compose.web.dom.Text + +@Composable +fun StatsWebScreen( + deps: StatsWebDependencies, +) { + val store = remember(deps) { StatsWebStore(processHolder = deps.processHolder) } + + LaunchedEffect(store) { store.start() } + + val state by store.state.collectAsState() + + var currentPeriod by remember { mutableStateOf(StatsPeriod.WEEK) } + var selectedDate by remember { + val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + mutableStateOf(today) + } + + LaunchedEffect(currentPeriod, selectedDate) { + store.send( + StatsIntent.LoadStats( + year = selectedDate.year, + month = selectedDate.monthNumber, + day = selectedDate.dayOfMonth, + period = currentPeriod.toDomainPeriodType(), + ) + ) + } + + StatsWebContent( + state = state, + currentPeriod = currentPeriod, + selectedDate = selectedDate, + onPeriodChange = { currentPeriod = it }, + onDateChange = { selectedDate = it }, + onReload = { + store.send( + StatsIntent.LoadStats( + year = selectedDate.year, + month = selectedDate.monthNumber, + day = selectedDate.dayOfMonth, + period = currentPeriod.toDomainPeriodType(), + ) + ) + } + ) +} + +private enum class StatsPeriod { DAY, WEEK, MONTH, YEAR } + +private fun StatsPeriod.toDomainPeriodType(): FetchSmokeStatsUseCase.PeriodType = when (this) { + StatsPeriod.DAY -> FetchSmokeStatsUseCase.PeriodType.DAY + StatsPeriod.WEEK -> FetchSmokeStatsUseCase.PeriodType.WEEK + StatsPeriod.MONTH -> FetchSmokeStatsUseCase.PeriodType.MONTH + StatsPeriod.YEAR -> FetchSmokeStatsUseCase.PeriodType.YEAR +} + +@Composable +private fun StatsWebContent( + state: StatsViewState, + currentPeriod: StatsPeriod, + selectedDate: LocalDate, + onPeriodChange: (StatsPeriod) -> Unit, + onDateChange: (LocalDate) -> Unit, + onReload: () -> Unit, +) { + val tz = remember { TimeZone.currentSystemDefault() } + + Div { + H2 { Text("Stats") } + + // Tabs + Div { + StatsPeriod.entries.forEach { p -> + val isSelected = p == currentPeriod + Button(attrs = { + if (state.displayLoading) disabled() + onClick { onPeriodChange(p) } + }) { + Text(if (isSelected) "[$p]" else p.name) + } + Span { Text(" ") } + } + } + + // Header navigation (← label →) + optional date input + Div { + Button(attrs = { + if (state.displayLoading) disabled() + onClick { onDateChange(selectedDate.shift(currentPeriod, -1, tz)) } + }) { Text("←") } + + Span { Text(" ") } + + Text(selectedDate.headerLabel(currentPeriod)) + + Span { Text(" ") } + + Button(attrs = { + if (state.displayLoading) disabled() + onClick { onDateChange(selectedDate.shift(currentPeriod, +1, tz)) } + }) { Text("→") } + + Span { Text(" ") } + + Input( + type = InputType.Date, + attrs = { + value(selectedDate.toHtmlDate()) + if (state.displayLoading) disabled() + onInput { e -> + val picked = (e.value ?: "").toLocalDateOrNull() ?: return@onInput + onDateChange(picked) + } + } + ) + + Span { Text(" ") } + + Button(attrs = { + if (state.displayLoading) disabled() + onClick { onReload() } + }) { Text("Reload") } + } + + if (state.displayLoading) { + Div { Text("Loading...") } + } + + if (state.error != null) { + Div { Text("Something went wrong") } + } + + state.stats?.let { stats -> + val chartId = remember(currentPeriod) { "statsChart_${currentPeriod.name}" } + + Div { + Canvas(attrs = { + id(chartId) + attr("width", "900") + attr("height", "360") + }) + } + + when (currentPeriod) { + StatsPeriod.DAY -> LineChartJs( + canvasId = chartId, + title = "Today", + data = stats.hourly + ) + + StatsPeriod.WEEK -> BarChartJs( + canvasId = chartId, + title = "Week", + data = stats.weekly + ) + + StatsPeriod.MONTH -> BarChartJs( + canvasId = chartId, + title = "Month", + data = stats.monthly + ) + + StatsPeriod.YEAR -> BarChartJs( + canvasId = chartId, + title = "Year", + data = stats.yearly + ) + } + } + } +} + +@Composable +private fun LineChartJs( + canvasId: String, + title: String, + data: Map, +) { + val labels = remember(data) { data.keys.toList() } + val values = remember(data) { data.values.map { it as Number } } + + val chartHolder = remember { mutableStateOf(null) } + + DisposableEffect(canvasId, labels, values) { + chartHolder.value?.destroy() + + val ctx = canvas2dContext(canvasId) + + val config = jsObject() + config["type"] = "line" + + val dataObj = jsObject() + dataObj["labels"] = labels.toTypedArray() + dataObj["datasets"] = arrayOf(lineDataset(title, values)) + config["data"] = dataObj + + val options = jsObject() + options["responsive"] = true + options["maintainAspectRatio"] = false + config["options"] = options + + chartHolder.value = Chart(ctx, config) + + onDispose { + chartHolder.value?.destroy() + chartHolder.value = null + } + } +} + +@Composable +private fun BarChartJs( + canvasId: String, + title: String, + data: Map, +) { + val labels = remember(data) { data.keys.toList() } + val values = remember(data) { data.values.map { it as Number } } + + val chartHolder = remember { mutableStateOf(null) } + + DisposableEffect(canvasId, labels, values) { + chartHolder.value?.destroy() + + val ctx = canvas2dContext(canvasId) + + val config = jsObject() + config["type"] = "bar" + + val dataObj = jsObject() + dataObj["labels"] = labels.toTypedArray() + dataObj["datasets"] = arrayOf(barDataset(title, values)) + config["data"] = dataObj + + val options = jsObject() + options["responsive"] = true + options["maintainAspectRatio"] = false + config["options"] = options + + chartHolder.value = Chart(ctx, config) + + onDispose { + chartHolder.value?.destroy() + chartHolder.value = null + } + } +} + +private fun LocalDate.shift(period: StatsPeriod, amount: Int, tz: TimeZone): LocalDate { + val unit = when (period) { + StatsPeriod.DAY -> DateTimeUnit.DAY + StatsPeriod.WEEK -> DateTimeUnit.WEEK + StatsPeriod.MONTH -> DateTimeUnit.MONTH + StatsPeriod.YEAR -> DateTimeUnit.YEAR + } + return this.plus(amount, unit) +} + +private fun LocalDate.headerLabel(period: StatsPeriod): String = when (period) { + StatsPeriod.DAY -> toUiDate() + StatsPeriod.WEEK -> "Week of ${toUiDate()}" + StatsPeriod.MONTH -> "${monthNumber.toString().padStart(2, '0')}/${year}" + StatsPeriod.YEAR -> year.toString() +} + +private fun LocalDate.toHtmlDate(): String { + val y = year.toString().padStart(4, '0') + val m = monthNumber.toString().padStart(2, '0') + val d = dayOfMonth.toString().padStart(2, '0') + return "$y-$m-$d" +} + +private fun LocalDate.toUiDate(): String { + val d = dayOfMonth.toString().padStart(2, '0') + val m = monthNumber.toString().padStart(2, '0') + return "$d/$m/$year" +} + +private fun String.toLocalDateOrNull(): LocalDate? { + if (length != 10) return null + val y = substring(0, 4).toIntOrNull() ?: return null + val m = substring(5, 7).toIntOrNull() ?: return null + val d = substring(8, 10).toIntOrNull() ?: return null + return runCatching { LocalDate(y, m, d) }.getOrNull() +} + +private fun jsObject(): dynamic = js("({})") + +private fun lineDataset( + title: String, + values: List, +): dynamic { + val ds = jsObject() + ds["label"] = title + ds["data"] = values.toTypedArray() + ds["tension"] = 0.25 + ds["borderWidth"] = 2 + ds["pointRadius"] = 2 + ds["fill"] = false + return ds +} + +private fun barDataset( + title: String, + values: List, +): dynamic { + val ds = jsObject() + ds["label"] = title + ds["data"] = values.toTypedArray() + ds["borderWidth"] = 1 + ds["fill"] = false + return ds +} \ No newline at end of file diff --git a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebStore.kt b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebStore.kt new file mode 100644 index 00000000..d1767d48 --- /dev/null +++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebStore.kt @@ -0,0 +1,57 @@ +package com.feragusper.smokeanalytics.features.stats.presentation.web + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch + +class StatsWebStore( + private val processHolder: StatsProcessHolder, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), +) { + private val intents = Channel(capacity = Channel.Factory.BUFFERED) + + private val _state = MutableStateFlow(StatsViewState()) + val state: StateFlow = _state.asStateFlow() + + fun send(intent: StatsIntent) { + intents.trySend(intent) + } + + fun start() { + scope.launch { + intents + .receiveAsFlow() + .flatMapLatest(processHolder::processIntent) + .collect(::reduce) + } + } + + private fun reduce(result: StatsResult) { + val previous = _state.value + _state.value = when (result) { + StatsResult.Loading -> previous.copy( + displayLoading = true, + error = null, + ) + + is StatsResult.Success -> previous.copy( + displayLoading = false, + stats = result.stats, + error = null, + ) + + is StatsResult.Error -> previous.copy( + displayLoading = false, + stats = null, + error = StatsViewState.StatsError.Generic, + ) + } + } +} \ No newline at end of file diff --git a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/canvas2dContext.kt b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/canvas2dContext.kt new file mode 100644 index 00000000..40a751a3 --- /dev/null +++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/canvas2dContext.kt @@ -0,0 +1,11 @@ +package com.feragusper.smokeanalytics.features.stats.presentation.web + +import kotlinx.browser.document +import org.w3c.dom.CanvasRenderingContext2D +import org.w3c.dom.HTMLCanvasElement + +fun canvas2dContext(canvasId: String): CanvasRenderingContext2D { + val canvas = document.getElementById(canvasId) as? HTMLCanvasElement + ?: error("Canvas not found: $canvasId") + return canvas.getContext("2d") as CanvasRenderingContext2D +} \ No newline at end of file diff --git a/firebase.json b/firebase.json new file mode 100644 index 00000000..300e8fe7 --- /dev/null +++ b/firebase.json @@ -0,0 +1,16 @@ +{ + "hosting": [ + { + "target": "staging", + "public": "apps/web/build/firebaseHosting", + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], + "rewrites": [{ "source": "**", "destination": "/index.html" }] + }, + { + "target": "prod", + "public": "apps/web/build/firebaseHosting", + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], + "rewrites": [{ "source": "**", "destination": "/index.html" }] + } + ] +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 32b56f91..1c2838d9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -40,4 +40,6 @@ kapt.use.worker.api=true android.lifecycleProcessor.incremental=true # Enable on-demand project configuration to speed up the configuration phase. -org.gradle.configureondemand=true \ No newline at end of file +org.gradle.configureondemand=true + +org.jetbrains.compose.experimental.jscanvas.enabled=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3e501ec1..ef15a362 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,9 +8,12 @@ androidxCoreKtx = "1.17.0" androidxNavigationCompose = "2.9.5" animatedNavigationBar = "1.0.0" composeShimmer = "1.3.3" +composeMultiplatform = "1.8.0" coroutinesTest = "1.10.2" credentials = "1.5.0" espressoCore = "3.7.0" +firebaseApp = "1.13.0" +firebaseAuth = "1.13.0" firebaseBOM = "34.4.0" generativeai = "0.9.0" googleid = "1.1.1" @@ -24,8 +27,12 @@ javaxInject = "1" junit = "1.3.0" junit4 = "4.13.2" junitBOM = "6.0.0" +kermit = "2.0.8" kluent = "1.73" kotlin = "2.2.20" +kotlinxDatetime = "0.6.1" +kotlinxCoroutines = "1.8.1" +kotlinx-serialization = "1.7.3" kover = "0.9.2" material3 = "1.4.0" mockk = "1.14.6" @@ -73,10 +80,12 @@ animated-navigation-bar = { module = "com.exyte:animated-navigation-bar", versio app-cash-turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" } coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesTest" } +firebase-app = { module = "dev.gitlive:firebase-app", version.ref = "firebaseApp" } firebase-auth = { module = "com.google.firebase:firebase-auth" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBOM" } firebase-firestore = { module = "com.google.firebase:firebase-firestore" } generativeai = { module = "com.google.ai.client.generativeai:generativeai", version.ref = "generativeai" } +gitlive-firebase-auth = { module = "dev.gitlive:firebase-auth", version.ref = "firebaseAuth" } hilt = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-gradle-plugin = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } @@ -92,9 +101,13 @@ junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api" } junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine" } junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params" } junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine" } +kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } kluent = { module = "org.amshove.kluent:kluent", version.ref = "kluent" } kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kover-gradle-plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } mockkAndroid = { module = "io.mockk:mockk-android", version.ref = "mockk" } @@ -109,7 +122,10 @@ vico-views = { group = "com.patrykandpatrick.vico", name = "views", version.ref # Plugins [plugins] +buildkonfig = { id = "com.codingfeline.buildkonfig", version = "0.15.2" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } # Bundles [bundles] diff --git a/libraries/architecture/domain/build.gradle.kts b/libraries/architecture/domain/build.gradle.kts index 9d6537c2..ab576fc3 100644 --- a/libraries/architecture/domain/build.gradle.kts +++ b/libraries/architecture/domain/build.gradle.kts @@ -1,20 +1,54 @@ -// build.gradle.kts for libraries:architecture:domain - plugins { - // Apply the Java Library plugin for modular Java code. - `java-lib` + kotlin("multiplatform") } -dependencies { - // Use Javax Inject for dependency injection annotations. - implementation(libs.javax.inject) - // Use JUnit BOM to manage consistent versions of JUnit dependencies. - testImplementation(platform(libs.junit.bom)) - // Include additional test dependencies bundled together. - testImplementation(libs.bundles.test) +kotlin { + jvm() + + js(IR) { + browser() + binaries.library() + } + + @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) + wasmJs { + browser() + binaries.library() + } + + sourceSets { + val commonMain by getting { + dependencies { + api(libs.kotlinx.datetime) + } + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + + val jvmMain by getting + val jvmTest by getting { + dependencies { + implementation(libs.junit.jupiter.api) + implementation(libs.junit.jupiter.params) + runtimeOnly(libs.junit.jupiter.engine) + + implementation(libs.kluent) + implementation(libs.coroutines.test) + implementation(libs.app.cash.turbine) + } + } + + val jsMain by getting + val jsTest by getting + + val wasmJsMain by getting + val wasmJsTest by getting + } } -// Configure the test task to use JUnit Platform (JUnit 5). -tasks.test { +tasks.withType().configureEach { useJUnitPlatform() -} +} \ No newline at end of file diff --git a/libraries/architecture/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/architecture/domain/DateExtensions.kt b/libraries/architecture/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/architecture/domain/DateExtensions.kt new file mode 100644 index 00000000..e951b545 --- /dev/null +++ b/libraries/architecture/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/architecture/domain/DateExtensions.kt @@ -0,0 +1,78 @@ +package com.feragusper.smokeanalytics.libraries.architecture.domain + +import kotlinx.datetime.Clock +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.isoDayNumber +import kotlinx.datetime.minus +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime +import kotlinx.datetime.until + +private val defaultTimeZone: TimeZone + get() = TimeZone.currentSystemDefault() + +private fun todayLocalDate(timeZone: TimeZone = defaultTimeZone): LocalDate = + Clock.System.now().toLocalDateTime(timeZone).date + +fun lastInstantToday(timeZone: TimeZone = defaultTimeZone): Instant = + todayLocalDate(timeZone).plus(1, DateTimeUnit.DAY).atStartOfDayIn(timeZone) + +fun firstInstantThisMonth(timeZone: TimeZone = defaultTimeZone): Instant { + val today = todayLocalDate(timeZone) + val firstDay = LocalDate(today.year, today.month, 1) + return firstDay.atStartOfDayIn(timeZone) +} + +fun Instant.isToday(timeZone: TimeZone = defaultTimeZone): Boolean = + isBetweenInstants( + instant = this, + after = todayLocalDate(timeZone).atStartOfDayIn(timeZone), + before = lastInstantToday(timeZone), + ) + +fun Instant.isThisWeek(timeZone: TimeZone = defaultTimeZone): Boolean { + val today = todayLocalDate(timeZone) + val daysFromMonday = (today.dayOfWeek.isoDayNumber - 1) + val monday = today.minus(daysFromMonday, DateTimeUnit.DAY) + val start = monday.atStartOfDayIn(timeZone) + val end = monday.plus(7, DateTimeUnit.DAY).atStartOfDayIn(timeZone) + return isBetweenInstants(this, start, end) +} + +fun Instant.isThisMonth(timeZone: TimeZone = defaultTimeZone): Boolean { + val start = firstInstantThisMonth(timeZone) + val today = todayLocalDate(timeZone) + val nextMonthStart = LocalDate( + year = if (today.monthNumber == 12) today.year + 1 else today.year, + monthNumber = if (today.monthNumber == 12) 1 else today.monthNumber + 1, + dayOfMonth = 1 + ).atStartOfDayIn(timeZone) + + return isBetweenInstants(this, start, nextMonthStart) +} + +fun Instant?.timeElapsedSinceNow( + timeZone: TimeZone = defaultTimeZone +): Pair = + Clock.System.now().timeAfter(this, timeZone) + +fun Instant.timeAfter( + other: Instant?, + timeZone: TimeZone = defaultTimeZone +): Pair { + if (other == null) return 0L to 0L + + val diffMinutes = other.until(this, DateTimeUnit.MINUTE, timeZone) + val hours = diffMinutes / 60 + val minutes = diffMinutes % 60 + return hours to minutes +} + +fun Instant.utcMillis(): Long = toEpochMilliseconds() + +private fun isBetweenInstants(instant: Instant, after: Instant, before: Instant): Boolean = + instant >= after && instant < before \ No newline at end of file diff --git a/libraries/architecture/domain/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/domain/extensions/DateExtensions.kt b/libraries/architecture/domain/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/domain/extensions/DateExtensions.kt deleted file mode 100644 index ce1f4bd0..00000000 --- a/libraries/architecture/domain/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/domain/extensions/DateExtensions.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.architecture.domain.extensions - -import java.time.DayOfWeek -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.ZoneId -import java.time.ZoneOffset -import java.time.format.DateTimeFormatter -import java.util.Date -import java.util.concurrent.TimeUnit - -/** - * Returns the start of the current day as a [LocalDateTime]. - */ -private fun firstInstantToday() = LocalDate.now().atStartOfDay() - -/** - * Returns the start of the next day as a [LocalDateTime]. - */ -fun lastInstantToday(): LocalDateTime = firstInstantToday().plusDays(1) - -/** - * Returns the first moment of the current month as a [LocalDateTime]. - */ -fun firstInstantThisMonth(): LocalDateTime = firstInstantToday().withDayOfMonth(1) - -/** - * Checks if a [LocalDateTime] is within the current day. - */ -fun LocalDateTime.isToday() = isBetweenDates( - this, - firstInstantToday(), - lastInstantToday() -) - -/** - * Checks if a [LocalDateTime] is within the current week. - */ -fun LocalDateTime.isThisWeek() = isBetweenDates( - this, - firstInstantThisWeek(), - lastInstantThisWeek() -) - -/** - * Checks if a [LocalDateTime] is within the current month. - */ -fun LocalDateTime.isThisMonth() = isBetweenDates( - this, - firstInstantThisMonth(), - lastInstantThisMonth() -) - -/** - * Calculates the time elapsed since a given [LocalDateTime] until now. - * - * @return A pair of Long values representing hours and minutes elapsed. - */ -fun LocalDateTime?.timeElapsedSinceNow(): Pair = LocalDateTime.now().timeAfter(this) - -/** - * Calculates the time after a given [LocalDateTime]. - * - * @param date The date from which to calculate the time after. - * @return A pair of Long values representing hours and minutes. - */ -fun LocalDateTime.timeAfter(date: LocalDateTime?): Pair = date?.let { dateNotNull -> - dateNotNull.until(this, java.time.temporal.ChronoUnit.MILLIS).let { diff -> - TimeUnit.MILLISECONDS.toHours(diff) % 24 to TimeUnit.MILLISECONDS.toMinutes(diff) % 60 - } -} ?: (0L to 0L) - -/** - * Formats a [LocalDateTime] to a string of format "HH:mm". - */ -fun LocalDateTime.timeFormatted(): String = DateTimeFormatter.ofPattern("HH:mm").format(this) - -/** - * Formats a [LocalDateTime] to a string representing a full date in the format "EEEE, MMMM dd". - */ -fun LocalDateTime.dateFormatted(): String = - DateTimeFormatter.ofPattern("EEEE, MMMM dd").format(this) - -/** - * Converts a [LocalDateTime] to milliseconds since the Unix epoch, assuming UTC timezone. - */ -fun LocalDateTime.utcMillis() = this - .toEpochSecond(ZoneOffset.UTC) - .times(1000) - -/** - * Converts a [LocalDateTime] to a [Date]. - */ -fun LocalDateTime.toDate(): Date = Date.from(this.atZone(ZoneId.systemDefault()).toInstant()) - -/** - * Converts a [Date] to a [LocalDateTime]. - */ -fun Date.toLocalDateTime(): LocalDateTime = - LocalDateTime.ofInstant(this.toInstant(), ZoneId.systemDefault()) - -/** - * Returns the start of the current week, adjusted to the first instance of Monday, as a [LocalDateTime]. - */ -private fun firstInstantThisWeek() = firstInstantToday().with(DayOfWeek.MONDAY) - -/** - * Returns the end of the current week, adjusted to the first instance after Sunday, as a [LocalDateTime]. - */ -private fun lastInstantThisWeek() = firstInstantToday().with(DayOfWeek.SUNDAY).plusDays(1) - -/** - * Returns the last moment of the current month as a [LocalDateTime]. - */ -private fun lastInstantThisMonth() = with(firstInstantToday()) { - withDayOfMonth(month.length(toLocalDate().isLeapYear)) -} - -/** - * Checks if a [LocalDateTime] is between two other [LocalDateTime] instances. - * - * @param date The date to check. - * @param after The start date for the range. - * @param before The end date for the range. - * @return True if [date] is after [after] and before [before]. - */ -private fun isBetweenDates(date: LocalDateTime, after: LocalDateTime, before: LocalDateTime) = - date.isAfter(after) && date.isBefore(before) diff --git a/libraries/architecture/presentation/mobile/.gitignore b/libraries/architecture/presentation/mobile/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/libraries/architecture/presentation/mobile/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libraries/architecture/presentation/build.gradle.kts b/libraries/architecture/presentation/mobile/build.gradle.kts similarity index 100% rename from libraries/architecture/presentation/build.gradle.kts rename to libraries/architecture/presentation/mobile/build.gradle.kts diff --git a/libraries/architecture/presentation/src/main/AndroidManifest.xml b/libraries/architecture/presentation/mobile/src/main/AndroidManifest.xml similarity index 100% rename from libraries/architecture/presentation/src/main/AndroidManifest.xml rename to libraries/architecture/presentation/mobile/src/main/AndroidManifest.xml diff --git a/libraries/architecture/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/MVIViewModel.kt b/libraries/architecture/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/MVIViewModel.kt similarity index 100% rename from libraries/architecture/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/MVIViewModel.kt rename to libraries/architecture/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/MVIViewModel.kt diff --git a/libraries/architecture/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/extensions/AppInfo.kt b/libraries/architecture/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/extensions/AppInfo.kt similarity index 100% rename from libraries/architecture/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/extensions/AppInfo.kt rename to libraries/architecture/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/extensions/AppInfo.kt diff --git a/libraries/architecture/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/extensions/ErrorHandling.kt b/libraries/architecture/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/extensions/ErrorHandling.kt similarity index 100% rename from libraries/architecture/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/extensions/ErrorHandling.kt rename to libraries/architecture/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/extensions/ErrorHandling.kt diff --git a/libraries/architecture/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/mvi/MVIIntent.kt b/libraries/architecture/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/mvi/MVIIntent.kt similarity index 100% rename from libraries/architecture/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/mvi/MVIIntent.kt rename to libraries/architecture/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/mvi/MVIIntent.kt diff --git a/libraries/architecture/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/mvi/MVIResult.kt b/libraries/architecture/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/mvi/MVIResult.kt similarity index 100% rename from libraries/architecture/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/mvi/MVIResult.kt rename to libraries/architecture/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/mvi/MVIResult.kt diff --git a/libraries/architecture/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/mvi/MVIViewState.kt b/libraries/architecture/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/mvi/MVIViewState.kt similarity index 100% rename from libraries/architecture/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/mvi/MVIViewState.kt rename to libraries/architecture/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/mvi/MVIViewState.kt diff --git a/libraries/architecture/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/navigation/MVINavigator.kt b/libraries/architecture/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/navigation/MVINavigator.kt similarity index 100% rename from libraries/architecture/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/navigation/MVINavigator.kt rename to libraries/architecture/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/navigation/MVINavigator.kt diff --git a/libraries/architecture/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/process/MVIProcessHolder.kt b/libraries/architecture/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/process/MVIProcessHolder.kt similarity index 100% rename from libraries/architecture/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/process/MVIProcessHolder.kt rename to libraries/architecture/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/architecture/presentation/process/MVIProcessHolder.kt diff --git a/libraries/architecture/presentation/web/.gitignore b/libraries/architecture/presentation/web/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/libraries/architecture/presentation/web/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libraries/architecture/presentation/web/build.gradle.kts b/libraries/architecture/presentation/web/build.gradle.kts new file mode 100644 index 00000000..3adefc0d --- /dev/null +++ b/libraries/architecture/presentation/web/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + kotlin("multiplatform") + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.compose.compiler) +} + +kotlin { + js(IR) { browser() } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(compose.runtime) + } + } + } +} \ No newline at end of file diff --git a/libraries/architecture/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/architecture/presentation/MviStore.kt b/libraries/architecture/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/architecture/presentation/MviStore.kt new file mode 100644 index 00000000..666667a3 --- /dev/null +++ b/libraries/architecture/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/architecture/presentation/MviStore.kt @@ -0,0 +1,32 @@ +package com.feragusper.smokeanalytics.libraries.architecture.presentation + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +abstract class MviStore( + private val scope: CoroutineScope, + initialState: S, +) { + private val intents = Channel(Channel.BUFFERED) + private val _state = MutableStateFlow(initialState) + val state: StateFlow = _state.asStateFlow() + + fun dispatch(intent: I) { + intents.trySend(intent) + } + + protected abstract fun transformer(intent: I): Flow + protected abstract fun reducer(previous: S, result: R): S + + fun start() { + scope.launch { + intents + .receiveAsFlow() + .flatMapConcat { intent -> transformer(intent) } + .scan(_state.value) { prev, result -> reducer(prev, result) } + .collect { _state.value = it } + } + } +} \ No newline at end of file diff --git a/libraries/authentication/data/mobile/.gitignore b/libraries/authentication/data/mobile/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/libraries/authentication/data/mobile/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libraries/authentication/data/build.gradle.kts b/libraries/authentication/data/mobile/build.gradle.kts similarity index 100% rename from libraries/authentication/data/build.gradle.kts rename to libraries/authentication/data/mobile/build.gradle.kts diff --git a/libraries/authentication/data/src/main/AndroidManifest.xml b/libraries/authentication/data/mobile/src/main/AndroidManifest.xml similarity index 100% rename from libraries/authentication/data/src/main/AndroidManifest.xml rename to libraries/authentication/data/mobile/src/main/AndroidManifest.xml diff --git a/libraries/authentication/data/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImpl.kt b/libraries/authentication/data/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImpl.kt similarity index 96% rename from libraries/authentication/data/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImpl.kt rename to libraries/authentication/data/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImpl.kt index dd649dc1..a2f3b27a 100644 --- a/libraries/authentication/data/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImpl.kt +++ b/libraries/authentication/data/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImpl.kt @@ -20,7 +20,7 @@ class AuthenticationRepositoryImpl @Inject constructor( /** * Signs out the current user from Firebase Authentication. */ - override fun signOut() = firebaseAuth.signOut() + override suspend fun signOut() = firebaseAuth.signOut() /** * Fetches the current session state from Firebase Authentication, converting it into a [Session] domain model. diff --git a/libraries/authentication/data/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryModule.kt b/libraries/authentication/data/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryModule.kt similarity index 100% rename from libraries/authentication/data/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryModule.kt rename to libraries/authentication/data/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryModule.kt diff --git a/libraries/authentication/data/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/data/FirebaseAuthModule.kt b/libraries/authentication/data/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/data/FirebaseAuthModule.kt similarity index 100% rename from libraries/authentication/data/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/data/FirebaseAuthModule.kt rename to libraries/authentication/data/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/data/FirebaseAuthModule.kt diff --git a/libraries/authentication/data/src/test/java/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImplTest.kt b/libraries/authentication/data/mobile/src/test/java/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImplTest.kt similarity index 100% rename from libraries/authentication/data/src/test/java/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImplTest.kt rename to libraries/authentication/data/mobile/src/test/java/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImplTest.kt diff --git a/libraries/authentication/data/web/.gitignore b/libraries/authentication/data/web/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/libraries/authentication/data/web/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libraries/authentication/data/web/build.gradle.kts b/libraries/authentication/data/web/build.gradle.kts new file mode 100644 index 00000000..44575f69 --- /dev/null +++ b/libraries/authentication/data/web/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + kotlin("multiplatform") + // Si tu setup ya usa compose multiplatform en web: + // id("org.jetbrains.compose") +} + +kotlin { + js(IR) { + browser() + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(project(":libraries:authentication:domain")) + implementation("dev.gitlive:firebase-auth:1.13.0") // o la versión que uses en el resto + } + } + } +} \ No newline at end of file diff --git a/libraries/authentication/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImpl.kt b/libraries/authentication/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImpl.kt new file mode 100644 index 00000000..695f67f0 --- /dev/null +++ b/libraries/authentication/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImpl.kt @@ -0,0 +1,30 @@ +package com.feragusper.smokeanalytics.libraries.authentication.data + +import com.feragusper.smokeanalytics.libraries.authentication.domain.AuthenticationRepository +import com.feragusper.smokeanalytics.libraries.authentication.domain.Session +import dev.gitlive.firebase.Firebase +import dev.gitlive.firebase.auth.FirebaseAuth +import dev.gitlive.firebase.auth.FirebaseUser +import dev.gitlive.firebase.auth.auth + +class AuthenticationRepositoryImpl( + private val firebaseAuth: FirebaseAuth = Firebase.auth, +) : AuthenticationRepository { + + override suspend fun signOut() { + firebaseAuth.signOut() + } + + override fun fetchSession(): Session = firebaseAuth.toSession() +} + +private fun FirebaseAuth.toSession(): Session = when (currentUser) { + null -> Session.Anonymous + else -> Session.LoggedIn(currentUser?.toUser() ?: error("User is null")) +} + +private fun FirebaseUser.toUser(): Session.User = Session.User( + id = uid, + email = email, + displayName = displayName, +) \ No newline at end of file diff --git a/libraries/authentication/domain/build.gradle.kts b/libraries/authentication/domain/build.gradle.kts index 30f87fd2..bf17eee6 100644 --- a/libraries/authentication/domain/build.gradle.kts +++ b/libraries/authentication/domain/build.gradle.kts @@ -1,18 +1,56 @@ plugins { - // Use the custom java-lib plugin for a modular Java/Kotlin library. - `java-lib` + kotlin("multiplatform") } -dependencies { - // Use javax.inject for dependency injection annotations. - implementation(libs.javax.inject) +kotlin { + jvm() + + js(IR) { + browser() + binaries.library() + } + + @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) + wasmJs { + browser() + binaries.library() + } + + sourceSets { + val commonMain by getting { + dependencies { + // No platform deps here + } + } + + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + + val jvmMain by getting { + // No javax.inject needed anymore in domain + } - // Unit testing dependencies. - testImplementation(platform(libs.junit.bom)) - testImplementation(libs.bundles.test) + val jvmTest by getting { + dependencies { + implementation(libs.bundles.test) + } + } + + val jsMain by getting + val jsTest by getting + + val wasmJsMain by getting + val wasmJsTest by getting + } } -tasks.test { - // Configure the test task to use JUnit Platform (JUnit 5). - useJUnitPlatform() +dependencies { + add("jvmTestImplementation", platform(libs.junit.bom)) } + +tasks.withType().configureEach { + useJUnitPlatform() +} \ No newline at end of file diff --git a/libraries/authentication/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/AuthenticationRepository.kt b/libraries/authentication/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/AuthenticationRepository.kt new file mode 100644 index 00000000..477f992b --- /dev/null +++ b/libraries/authentication/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/AuthenticationRepository.kt @@ -0,0 +1,6 @@ +package com.feragusper.smokeanalytics.libraries.authentication.domain + +interface AuthenticationRepository { + suspend fun signOut() + fun fetchSession(): Session +} \ No newline at end of file diff --git a/libraries/authentication/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/FetchSessionUseCase.kt b/libraries/authentication/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/FetchSessionUseCase.kt new file mode 100644 index 00000000..6a5e8123 --- /dev/null +++ b/libraries/authentication/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/FetchSessionUseCase.kt @@ -0,0 +1,7 @@ +package com.feragusper.smokeanalytics.libraries.authentication.domain + +class FetchSessionUseCase( + private val authenticationRepository: AuthenticationRepository +) { + operator fun invoke(): Session = authenticationRepository.fetchSession() +} \ No newline at end of file diff --git a/libraries/authentication/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/Session.kt b/libraries/authentication/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/Session.kt new file mode 100644 index 00000000..ec0fc99e --- /dev/null +++ b/libraries/authentication/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/Session.kt @@ -0,0 +1,14 @@ +package com.feragusper.smokeanalytics.libraries.authentication.domain + +sealed interface Session { + + object Anonymous : Session + + data class LoggedIn(val user: User) : Session + + data class User( + val id: String, + val email: String?, + val displayName: String? + ) +} \ No newline at end of file diff --git a/libraries/authentication/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/SignOutUseCase.kt b/libraries/authentication/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/SignOutUseCase.kt new file mode 100644 index 00000000..a7aef798 --- /dev/null +++ b/libraries/authentication/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/SignOutUseCase.kt @@ -0,0 +1,7 @@ +package com.feragusper.smokeanalytics.libraries.authentication.domain + +class SignOutUseCase( + private val authenticationRepository: AuthenticationRepository +) { + suspend operator fun invoke() = authenticationRepository.signOut() +} \ No newline at end of file diff --git a/libraries/authentication/domain/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/domain/AuthenticationRepository.kt b/libraries/authentication/domain/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/domain/AuthenticationRepository.kt deleted file mode 100644 index f452a0a1..00000000 --- a/libraries/authentication/domain/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/domain/AuthenticationRepository.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.authentication.domain - -/** - * Defines the contract for authentication operations. This interface abstracts the underlying authentication - * mechanism and provides a clear API for sign-out and session fetch operations. - */ -interface AuthenticationRepository { - /** - * Signs out the current user session. - */ - fun signOut() - - /** - * Fetches the current session state. - * - * @return The current [Session] state. - */ - fun fetchSession(): Session -} diff --git a/libraries/authentication/domain/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/domain/FetchSessionUseCase.kt b/libraries/authentication/domain/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/domain/FetchSessionUseCase.kt deleted file mode 100644 index 36e9c632..00000000 --- a/libraries/authentication/domain/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/domain/FetchSessionUseCase.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.authentication.domain - -import javax.inject.Inject - -/** - * Use case for fetching the current session state. This use case abstracts the details of how the session - * state is retrieved, making it easy to call from the presentation layer. - * - * @property authenticationRepository The repository responsible for authentication operations. - */ -class FetchSessionUseCase @Inject constructor( - private val authenticationRepository: AuthenticationRepository -) { - - /** - * Invokes the use case to retrieve the current session state. - * - * @return The current [Session] state. - */ - operator fun invoke() = authenticationRepository.fetchSession() -} diff --git a/libraries/authentication/domain/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/domain/Session.kt b/libraries/authentication/domain/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/domain/Session.kt deleted file mode 100644 index b2527306..00000000 --- a/libraries/authentication/domain/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/domain/Session.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.authentication.domain - -/** - * Represents the state of a user session. This sealed interface is used to differentiate between logged-in - * and anonymous sessions, providing a type-safe way to handle session states. - */ -sealed interface Session { - - /** - * Represents an anonymous session, where no user is currently signed in. - */ - object Anonymous : Session - - /** - * Represents a logged-in session, including information about the user. - * - * @param user The [User] information for the logged-in session. - */ - data class LoggedIn(val user: User) : Session - - /** - * Represents a user with an ID, email, and display name. This data class encapsulates the - * user information available in a logged-in session. - * - * @param id The unique identifier for the user. - * @param email The user's email address, if available. - * @param displayName The user's display name, if available. - */ - data class User( - val id: String, - val email: String?, - val displayName: String? - ) -} diff --git a/libraries/authentication/domain/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/domain/SignOutUseCase.kt b/libraries/authentication/domain/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/domain/SignOutUseCase.kt deleted file mode 100644 index d5615326..00000000 --- a/libraries/authentication/domain/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/domain/SignOutUseCase.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.authentication.domain - -import javax.inject.Inject - -/** - * Use case for signing out the current user. This use case abstracts the details of how the sign-out - * operation is performed, allowing for easy invocation from the presentation layer. - * - * @property authenticationRepository The repository responsible for authentication operations. - */ -class SignOutUseCase @Inject constructor( - private val authenticationRepository: AuthenticationRepository -) { - - /** - * Invokes the use case to perform the sign-out operation. - */ - operator fun invoke() = authenticationRepository.signOut() -} diff --git a/libraries/authentication/presentation/mobile/.gitignore b/libraries/authentication/presentation/mobile/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/libraries/authentication/presentation/mobile/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libraries/authentication/presentation/build.gradle.kts b/libraries/authentication/presentation/mobile/build.gradle.kts similarity index 100% rename from libraries/authentication/presentation/build.gradle.kts rename to libraries/authentication/presentation/mobile/build.gradle.kts diff --git a/libraries/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/presentation/compose/GoogleSignInComponent.kt b/libraries/authentication/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/presentation/compose/GoogleSignInComponent.kt similarity index 100% rename from libraries/authentication/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/presentation/compose/GoogleSignInComponent.kt rename to libraries/authentication/presentation/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/presentation/compose/GoogleSignInComponent.kt diff --git a/libraries/authentication/presentation/src/main/res/drawable/google.png b/libraries/authentication/presentation/mobile/src/main/res/drawable/google.png similarity index 100% rename from libraries/authentication/presentation/src/main/res/drawable/google.png rename to libraries/authentication/presentation/mobile/src/main/res/drawable/google.png diff --git a/libraries/authentication/presentation/src/main/res/values/strings.xml b/libraries/authentication/presentation/mobile/src/main/res/values/strings.xml similarity index 100% rename from libraries/authentication/presentation/src/main/res/values/strings.xml rename to libraries/authentication/presentation/mobile/src/main/res/values/strings.xml diff --git a/libraries/authentication/presentation/web/.gitignore b/libraries/authentication/presentation/web/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/libraries/authentication/presentation/web/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libraries/authentication/presentation/web/build.gradle.kts b/libraries/authentication/presentation/web/build.gradle.kts new file mode 100644 index 00000000..db796153 --- /dev/null +++ b/libraries/authentication/presentation/web/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + kotlin("multiplatform") + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.compose.compiler) +} + +kotlin { + js(IR) { + browser() + } + + sourceSets { + val jsMain by getting { + dependencies { + // ─────────── Domain ─────────── + implementation(project(":libraries:authentication:domain")) + + // ─────────── Coroutines ─────────── + implementation(libs.kotlinx.coroutines.core) + + // ─────────── Compose Web (DOM) ─────────── + implementation(compose.runtime) + implementation(compose.html.core) + + // ─────────── Firebase (GitLive, JS) ─────────── + implementation("dev.gitlive:firebase-auth:1.13.0") + implementation("dev.gitlive:firebase-app:1.13.0") + } + } + + val jsTest by getting + } +} \ No newline at end of file diff --git a/libraries/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/presentation/compose/GoogleSignInComponentWeb.kt b/libraries/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/presentation/compose/GoogleSignInComponentWeb.kt new file mode 100644 index 00000000..b04b2b03 --- /dev/null +++ b/libraries/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/presentation/compose/GoogleSignInComponentWeb.kt @@ -0,0 +1,60 @@ +package com.feragusper.smokeanalytics.libraries.authentication.presentation.compose + +import androidx.compose.runtime.Composable +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 dev.gitlive.firebase.Firebase +import dev.gitlive.firebase.auth.auth +import dev.gitlive.firebase.auth.externals.GoogleAuthProvider +import dev.gitlive.firebase.auth.externals.signInWithPopup +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.await +import kotlinx.coroutines.launch +import org.jetbrains.compose.web.attributes.disabled +import org.jetbrains.compose.web.dom.Button +import org.jetbrains.compose.web.dom.Text + +@Composable +fun GoogleSignInComponentWeb( + onSignInSuccess: () -> Unit, + onSignInError: (Throwable) -> Unit, +) { + val scope = rememberCoroutineScope() + var loading by remember { mutableStateOf(false) } + + Button( + attrs = { + if (loading) disabled() + onClick { + scope.launch(start = CoroutineStart.UNDISPATCHED) { + loading = true + try { + runCatching { + val auth = Firebase.auth + val provider = GoogleAuthProvider() + signInWithPopup(auth.js, provider).await() + }.onSuccess { + onSignInSuccess() + }.onFailure { t -> + val code = (t.asDynamic().code as? String) + val msg = (t.asDynamic().message as? String) ?: t.message + onSignInError( + RuntimeException( + "${code ?: "unknown"}: ${msg ?: "Unknown"}", + t + ) + ) + } + } finally { + loading = false + } + } + } + } + ) { + Text(if (loading) "Signing in..." else "Sign in with Google") + } +} \ No newline at end of file diff --git a/libraries/logging/.gitignore b/libraries/logging/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/libraries/logging/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libraries/logging/build.gradle.kts b/libraries/logging/build.gradle.kts new file mode 100644 index 00000000..8ad3544b --- /dev/null +++ b/libraries/logging/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + kotlin("multiplatform") +} + +kotlin { + // Android / JVM + jvm() + + // Web (Compose Web / JS IR) + js(IR) { + browser() + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(libs.kermit) + } + } + + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + } +} \ No newline at end of file diff --git a/libraries/logging/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/logging /AppLogger.kt b/libraries/logging/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/logging /AppLogger.kt new file mode 100644 index 00000000..e1c01d35 --- /dev/null +++ b/libraries/logging/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/logging /AppLogger.kt @@ -0,0 +1,13 @@ +package com.feragusper.smokeanalytics.libraries.logging + +import co.touchlab.kermit.Logger + +object AppLogger { + + private val log = Logger.withTag("SmokeAnalytics") + + fun d(message: () -> String) = log.d(message()) + fun i(message: () -> String) = log.i(message()) + fun w(message: () -> String) = log.w(message()) + fun e(message: () -> String) = log.e(message()) +} \ No newline at end of file diff --git a/libraries/smokes/data/mobile/.gitignore b/libraries/smokes/data/mobile/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/libraries/smokes/data/mobile/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libraries/smokes/data/build.gradle.kts b/libraries/smokes/data/mobile/build.gradle.kts similarity index 100% rename from libraries/smokes/data/build.gradle.kts rename to libraries/smokes/data/mobile/build.gradle.kts diff --git a/libraries/smokes/data/src/main/AndroidManifest.xml b/libraries/smokes/data/mobile/src/main/AndroidManifest.xml similarity index 100% rename from libraries/smokes/data/src/main/AndroidManifest.xml rename to libraries/smokes/data/mobile/src/main/AndroidManifest.xml diff --git a/libraries/smokes/data/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeEntity.kt b/libraries/smokes/data/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeEntity.kt new file mode 100644 index 00000000..a78a5849 --- /dev/null +++ b/libraries/smokes/data/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeEntity.kt @@ -0,0 +1,15 @@ +package com.feragusper.smokeanalytics.libraries.smokes.data + +/** + * Firestore entity for a smoke event. + * + * Schema (Android + Web): + * - timestampMillis: Double (epoch millis) + */ +data class SmokeEntity( + val timestampMillis: Double = 0.0 +) { + object Fields { + const val TIMESTAMP_MILLIS = "timestampMillis" + } +} \ No newline at end of file diff --git a/libraries/smokes/data/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImpl.kt b/libraries/smokes/data/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImpl.kt new file mode 100644 index 00000000..f1f0ea2f --- /dev/null +++ b/libraries/smokes/data/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImpl.kt @@ -0,0 +1,108 @@ +package com.feragusper.smokeanalytics.libraries.smokes.data + +import com.feragusper.smokeanalytics.libraries.architecture.domain.firstInstantThisMonth +import com.feragusper.smokeanalytics.libraries.architecture.domain.isThisMonth +import com.feragusper.smokeanalytics.libraries.architecture.domain.isThisWeek +import com.feragusper.smokeanalytics.libraries.architecture.domain.isToday +import com.feragusper.smokeanalytics.libraries.architecture.domain.lastInstantToday +import com.feragusper.smokeanalytics.libraries.architecture.domain.timeAfter +import com.feragusper.smokeanalytics.libraries.smokes.data.SmokeRepositoryImpl.FirestoreCollection.Companion.SMOKES +import com.feragusper.smokeanalytics.libraries.smokes.data.SmokeRepositoryImpl.FirestoreCollection.Companion.USERS +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.SmokeCount +import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.firestore.DocumentSnapshot +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.Query +import com.google.firebase.firestore.Query.Direction +import kotlinx.coroutines.tasks.await +import kotlinx.datetime.Instant +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SmokeRepositoryImpl @Inject constructor( + private val firebaseFirestore: FirebaseFirestore, + private val firebaseAuth: FirebaseAuth, +) : SmokeRepository { + + interface FirestoreCollection { + companion object { + const val USERS = "users" + const val SMOKES = "smokes" + } + } + + override suspend fun addSmoke(date: Instant) { + smokesQuery().add( + SmokeEntity(timestampMillis = date.toEpochMilliseconds().toDouble()) + ).await() + } + + override suspend fun editSmoke(id: String, date: Instant) { + smokesQuery() + .document(id) + .set(SmokeEntity(timestampMillis = date.toEpochMilliseconds().toDouble())) + .await() + } + + override suspend fun deleteSmoke(id: String) { + smokesQuery() + .document(id) + .delete() + .await() + } + + override suspend fun fetchSmokes( + startDate: Instant?, + endDate: Instant? + ): List { + val startMillis = (startDate ?: firstInstantThisMonth()).toEpochMilliseconds().toDouble() + val endMillis = (endDate ?: lastInstantToday()).toEpochMilliseconds().toDouble() + + var query: Query = smokesQuery() + .orderBy(SmokeEntity.Fields.TIMESTAMP_MILLIS, Direction.DESCENDING) + .whereGreaterThanOrEqualTo(SmokeEntity.Fields.TIMESTAMP_MILLIS, startMillis) + .whereLessThan(SmokeEntity.Fields.TIMESTAMP_MILLIS, endMillis) + + val result = query.get().await() + + val instants = result.documents.mapNotNull { it.getInstant() } + + return result.documents.mapIndexedNotNull { index, document -> + val currentInstant = instants.getOrNull(index) ?: return@mapIndexedNotNull null + val previousInstant = instants.getOrNull(index + 1) + + Smoke( + id = document.id, + date = currentInstant, + timeElapsedSincePreviousSmoke = currentInstant.timeAfter(previousInstant) + ) + } + } + + override suspend fun fetchSmokeCount(): SmokeCount { + return fetchSmokes().toSmokeCountListResult() + } + + private fun List.toSmokeCountListResult() = SmokeCount( + today = filterToday(), + week = filterThisWeek().size, + month = filterThisMonth().size, + lastSmoke = firstOrNull(), + ) + + private fun List.filterToday() = filter { it.date.isToday() } + private fun List.filterThisWeek() = filter { it.date.isThisWeek() } + private fun List.filterThisMonth() = filter { it.date.isThisMonth() } + + private fun smokesQuery() = firebaseAuth.currentUser?.uid?.let { uid -> + firebaseFirestore.collection("$USERS/$uid/$SMOKES") + } ?: throw IllegalStateException("User not logged in") + + private fun DocumentSnapshot.getInstant(): Instant? { + val millis = getDouble(SmokeEntity.Fields.TIMESTAMP_MILLIS) ?: return null + return Instant.fromEpochMilliseconds(millis.toLong()) + } +} \ No newline at end of file diff --git a/libraries/smokes/data/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/di/FirestoreModule.kt b/libraries/smokes/data/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/di/FirestoreModule.kt similarity index 100% rename from libraries/smokes/data/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/di/FirestoreModule.kt rename to libraries/smokes/data/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/di/FirestoreModule.kt diff --git a/libraries/smokes/data/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/di/SmokeRepositoryModule.kt b/libraries/smokes/data/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/di/SmokeRepositoryModule.kt similarity index 100% rename from libraries/smokes/data/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/di/SmokeRepositoryModule.kt rename to libraries/smokes/data/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/di/SmokeRepositoryModule.kt diff --git a/libraries/smokes/data/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImplTest.kt b/libraries/smokes/data/mobile/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImplTest.kt similarity index 99% rename from libraries/smokes/data/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImplTest.kt rename to libraries/smokes/data/mobile/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImplTest.kt index 2dc7fbf9..6a43fc03 100644 --- a/libraries/smokes/data/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImplTest.kt +++ b/libraries/smokes/data/mobile/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImplTest.kt @@ -4,7 +4,6 @@ import com.feragusper.smokeanalytics.libraries.architecture.domain.extensions.ti import com.feragusper.smokeanalytics.libraries.architecture.domain.extensions.toLocalDateTime import com.feragusper.smokeanalytics.libraries.smokes.data.SmokeRepositoryImpl.FirestoreCollection.Companion.SMOKES import com.feragusper.smokeanalytics.libraries.smokes.data.SmokeRepositoryImpl.FirestoreCollection.Companion.USERS -import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke import com.google.android.gms.tasks.Task import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser diff --git a/libraries/smokes/data/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeEntity.kt b/libraries/smokes/data/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeEntity.kt deleted file mode 100644 index eb169db1..00000000 --- a/libraries/smokes/data/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeEntity.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.smokes.data - -import java.util.Date - -/** - * A data class representing a smoke entry in the database. - * - * @property date The timestamp when the smoke occurred. - */ -data class SmokeEntity(val date: Date) \ No newline at end of file diff --git a/libraries/smokes/data/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImpl.kt b/libraries/smokes/data/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImpl.kt deleted file mode 100644 index 3b217494..00000000 --- a/libraries/smokes/data/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImpl.kt +++ /dev/null @@ -1,183 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.smokes.data - -import com.feragusper.smokeanalytics.libraries.architecture.domain.extensions.firstInstantThisMonth -import com.feragusper.smokeanalytics.libraries.architecture.domain.extensions.isThisMonth -import com.feragusper.smokeanalytics.libraries.architecture.domain.extensions.isThisWeek -import com.feragusper.smokeanalytics.libraries.architecture.domain.extensions.isToday -import com.feragusper.smokeanalytics.libraries.architecture.domain.extensions.lastInstantToday -import com.feragusper.smokeanalytics.libraries.architecture.domain.extensions.timeAfter -import com.feragusper.smokeanalytics.libraries.architecture.domain.extensions.toDate -import com.feragusper.smokeanalytics.libraries.architecture.domain.extensions.toLocalDateTime -import com.feragusper.smokeanalytics.libraries.smokes.data.SmokeRepositoryImpl.FirestoreCollection.Companion.SMOKES -import com.feragusper.smokeanalytics.libraries.smokes.data.SmokeRepositoryImpl.FirestoreCollection.Companion.USERS -import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke -import com.feragusper.smokeanalytics.libraries.smokes.domain.model.SmokeCount -import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository -import com.google.firebase.auth.FirebaseAuth -import com.google.firebase.firestore.DocumentSnapshot -import com.google.firebase.firestore.FirebaseFirestore -import com.google.firebase.firestore.Query -import com.google.firebase.firestore.Query.Direction -import kotlinx.coroutines.tasks.await -import java.time.LocalDateTime -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Implementation of [SmokeRepository] that interacts with Firebase Firestore - * to perform CRUD operations on smoke data for the authenticated user. - * - * @property firebaseFirestore The instance of [FirebaseFirestore] for database operations. - * @property firebaseAuth The instance of [FirebaseAuth] for authentication details. - */ -@Singleton -class SmokeRepositoryImpl @Inject constructor( - private val firebaseFirestore: FirebaseFirestore, - private val firebaseAuth: FirebaseAuth, -) : SmokeRepository { - - /** - * Constants for Firestore collection paths. - */ - interface FirestoreCollection { - companion object { - const val USERS = "users" - const val SMOKES = "smokes" - } - } - - /** - * Adds a new smoke event to Firestore for the current user. - * - * @param date The date and time of the smoke event. - */ - override suspend fun addSmoke(date: LocalDateTime) { - smokesQuery().add(SmokeEntity(date.toDate())).await() - } - - /** - * Edits an existing smoke event in Firestore. - * - * @param id The ID of the smoke event to edit. - * @param date The new date and time for the smoke event. - */ - override suspend fun editSmoke(id: String, date: LocalDateTime) { - smokesQuery() - .document(id) - .set(SmokeEntity(date.toDate())) - .await() - } - - /** - * Deletes a smoke event from Firestore. - * - * @param id The ID of the smoke event to delete. - */ - override suspend fun deleteSmoke(id: String) { - smokesQuery() - .document(id) - .delete() - .await() - } - - /** - * Fetches the list of smoke events for the current user, optionally filtered by date range. - * - * @param startDate The start date for filtering smoke events (inclusive). - * @param endDate The end date for filtering smoke events (exclusive). - * @return A list of [Smoke] objects. - */ - override suspend fun fetchSmokes( - startDate: LocalDateTime?, - endDate: LocalDateTime? - ): List { - val baseQuery = smokesQuery() - - var query: Query = baseQuery.orderBy(SmokeEntity::date.name, Direction.DESCENDING) - - query = query.whereGreaterThanOrEqualTo( - SmokeEntity::date.name, - (startDate?.toLocalDate()?.atStartOfDay() ?: firstInstantThisMonth()).toDate() - ) - query = query.whereLessThan( - SmokeEntity::date.name, - (endDate?.plusDays(1)?.toLocalDate()?.atStartOfDay() ?: lastInstantToday()).toDate() - ) - - val result = query.get().await() - - return result.documents.mapIndexedNotNull { index, document -> - val currentDate = document.getDate() - val previousDate = result.documents.getOrNull(index + 1)?.getDate() - - // Calculate time elapsed since the previous smoke event - val timeElapsedSincePreviousSmoke = if (previousDate != null) { - currentDate.timeAfter(previousDate) - } else { - Pair(0L, 0L) // Handle first smoke event case - } - - Smoke( - id = document.id, - date = currentDate, - timeElapsedSincePreviousSmoke = timeElapsedSincePreviousSmoke - ) - } - } - - /** - * Fetches the smoke count statistics for the current user. - * - * @return A [SmokeCount] object containing counts for today, week, and month. - */ - override suspend fun fetchSmokeCount(): SmokeCount { - return fetchSmokes().toSmokeCountListResult() - } - - /** - * Maps a list of [Smoke] objects to a [SmokeCount] object. - * - * @return A [SmokeCount] containing the counts for today, week, and month. - */ - private fun List.toSmokeCountListResult() = SmokeCount( - today = filterToday(), - week = filterThisWeek().size, - month = filterThisMonth().size, - lastSmoke = firstOrNull(), - ) - - /** - * Filters the smoke events for today. - */ - private fun List.filterToday() = filter { it.date.isToday() } - - /** - * Filters the smoke events for the current week. - */ - private fun List.filterThisWeek() = filter { it.date.isThisWeek() } - - /** - * Filters the smoke events for the current month. - */ - private fun List.filterThisMonth() = filter { it.date.isThisMonth() } - - /** - * Helper method to retrieve the Firestore collection reference for the current user's smokes. - * - * @throws IllegalStateException if the user is not logged in. - * @return The Firestore collection reference. - */ - private fun smokesQuery() = firebaseAuth.currentUser?.uid?.let { - firebaseFirestore.collection("$USERS/$it/$SMOKES") - } ?: throw IllegalStateException("User not logged in") - - /** - * Extension function to convert a Firestore [DocumentSnapshot] to a [LocalDateTime]. - * - * @throws IllegalStateException if the date is not found in the document. - * @return The [LocalDateTime] representation of the date. - */ - private fun DocumentSnapshot.getDate() = - getDate(Smoke::date.name)?.toLocalDateTime() - ?: throw IllegalStateException("Date not found") -} diff --git a/libraries/smokes/data/web/.gitignore b/libraries/smokes/data/web/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/libraries/smokes/data/web/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libraries/smokes/data/web/build.gradle.kts b/libraries/smokes/data/web/build.gradle.kts new file mode 100644 index 00000000..e539b3be --- /dev/null +++ b/libraries/smokes/data/web/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + kotlin("multiplatform") + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + js(IR) { + browser() + binaries.library() + } + + sourceSets { + val jsMain by getting { + dependencies { + implementation(project(":libraries:smokes:domain")) + implementation(project(":libraries:architecture:domain")) + implementation("dev.gitlive:firebase-auth:2.1.0") + implementation("dev.gitlive:firebase-firestore:2.1.0") + implementation(libs.kotlinx.serialization.json) + } + } + } +} \ No newline at end of file diff --git a/libraries/smokes/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeEntity.kt b/libraries/smokes/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeEntity.kt new file mode 100644 index 00000000..cfa729a6 --- /dev/null +++ b/libraries/smokes/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeEntity.kt @@ -0,0 +1,18 @@ +package com.feragusper.smokeanalytics.libraries.smokes.data + +import kotlinx.serialization.Serializable + +/** + * Firestore entity for a smoke event. + * + * Schema (Android + Web): + * - timestampMillis: Double (epoch millis) + */ +@Serializable +data class SmokeEntity( + val timestampMillis: Double = 0.0 +) { + object Fields { + const val TIMESTAMP_MILLIS = "timestampMillis" + } +} \ No newline at end of file diff --git a/libraries/smokes/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImpl.kt b/libraries/smokes/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImpl.kt new file mode 100644 index 00000000..8c2cd09b --- /dev/null +++ b/libraries/smokes/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImpl.kt @@ -0,0 +1,112 @@ +package com.feragusper.smokeanalytics.libraries.smokes.data + +import com.feragusper.smokeanalytics.libraries.architecture.domain.firstInstantThisMonth +import com.feragusper.smokeanalytics.libraries.architecture.domain.isThisMonth +import com.feragusper.smokeanalytics.libraries.architecture.domain.isThisWeek +import com.feragusper.smokeanalytics.libraries.architecture.domain.isToday +import com.feragusper.smokeanalytics.libraries.architecture.domain.lastInstantToday +import com.feragusper.smokeanalytics.libraries.architecture.domain.timeAfter +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.SmokeCount +import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository +import dev.gitlive.firebase.Firebase +import dev.gitlive.firebase.auth.FirebaseAuth +import dev.gitlive.firebase.auth.auth +import dev.gitlive.firebase.firestore.Direction +import dev.gitlive.firebase.firestore.DocumentSnapshot +import dev.gitlive.firebase.firestore.FirebaseFirestore +import dev.gitlive.firebase.firestore.firestore +import kotlinx.datetime.Instant + +class SmokeRepositoryImpl( + private val firebaseFirestore: FirebaseFirestore = Firebase.firestore, + private val firebaseAuth: FirebaseAuth = Firebase.auth, +) : SmokeRepository { + + interface FirestoreCollection { + companion object { + const val USERS = "users" + const val SMOKES = "smokes" + } + } + + override suspend fun addSmoke(date: Instant) { + smokesCollection().add( + SmokeEntity(timestampMillis = date.toEpochMilliseconds().toDouble()) + ) + } + + override suspend fun editSmoke(id: String, date: Instant) { + smokesCollection() + .document(id) + .set(SmokeEntity(timestampMillis = date.toEpochMilliseconds().toDouble())) + } + + override suspend fun deleteSmoke(id: String) { + smokesCollection() + .document(id) + .delete() + } + + override suspend fun fetchSmokes( + start: Instant?, + end: Instant?, + ): List { + val startMillis = (start ?: firstInstantThisMonth()).toEpochMilliseconds().toDouble() + val endMillis = (end ?: lastInstantToday()).toEpochMilliseconds().toDouble() + + val result = smokesCollection() + .orderBy(SmokeEntity.Fields.TIMESTAMP_MILLIS, Direction.DESCENDING) + .where { + SmokeEntity.Fields.TIMESTAMP_MILLIS greaterThanOrEqualTo startMillis + } + .where { + SmokeEntity.Fields.TIMESTAMP_MILLIS lessThan endMillis + } + .get() + + val instants = result.documents.mapNotNull { it.getInstant() } + + return result.documents.mapIndexedNotNull { index, document -> + val currentInstant = instants.getOrNull(index) ?: return@mapIndexedNotNull null + val previousInstant = instants.getOrNull(index + 1) + + Smoke( + id = document.id, + date = currentInstant, + timeElapsedSincePreviousSmoke = currentInstant.timeAfter(previousInstant) + ) + } + } + + override suspend fun fetchSmokeCount(): SmokeCount { + return fetchSmokes().toSmokeCountListResult() + } + + private fun List.toSmokeCountListResult() = SmokeCount( + today = filterToday(), + week = filterThisWeek().size, + month = filterThisMonth().size, + lastSmoke = firstOrNull(), + ) + + private fun List.filterToday() = filter { it.date.isToday() } + private fun List.filterThisWeek() = filter { it.date.isThisWeek() } + private fun List.filterThisMonth() = filter { it.date.isThisMonth() } + + private fun smokesCollection() = firebaseAuth.currentUser?.uid?.let { uid -> + firebaseFirestore.collection("${FirestoreCollection.USERS}/$uid/${FirestoreCollection.SMOKES}") + } ?: throw IllegalStateException("User not logged in") + + private fun DocumentSnapshot.getInstant(): Instant? { + val millis = getOrNull(SmokeEntity.Fields.TIMESTAMP_MILLIS) ?: return null + return Instant.fromEpochMilliseconds(millis.toLong()) + } + + private inline fun DocumentSnapshot.getOrNull(field: String): T? = + try { + get(field) + } catch (_: Throwable) { + null + } +} \ No newline at end of file diff --git a/libraries/smokes/domain/build.gradle.kts b/libraries/smokes/domain/build.gradle.kts index 1c50e02d..24d56617 100644 --- a/libraries/smokes/domain/build.gradle.kts +++ b/libraries/smokes/domain/build.gradle.kts @@ -1,21 +1,48 @@ plugins { - // Use the custom java-lib plugin for a modular Java/Kotlin library. - `java-lib` + id("kmp-lib") +} + +kotlin { + + jvm() + js(IR) { browser() } + wasmJs { browser() } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(project(":libraries:architecture:domain")) + implementation(libs.kotlinx.datetime) + } + } + + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + + val jvmMain by getting { + dependencies { + implementation(project(":libraries:wear:domain")) + implementation(libs.javax.inject) // solo si todavĆ­a usĆ”s @Inject en código jvm especĆ­fico + } + } + + val jvmTest by getting { + dependencies { + implementation(libs.bundles.test) + } + } + + // wasm/otros targets los sigue manejando tu plugin `kmp-lib` + } } dependencies { - // Include the architecture domain module for shared domain logic. - implementation(project(":libraries:architecture:domain")) - implementation(project(":libraries:wear:domain")) - // Use javax.inject for dependency injection annotations. - implementation(libs.javax.inject) - - // Unit testing dependencies. - testImplementation(platform(libs.junit.bom)) - testImplementation(libs.bundles.test) + add("jvmTestImplementation", platform(libs.junit.bom)) } -tasks.test { - // Configure the test task to use JUnit Platform (JUnit 5). +tasks.withType().configureEach { useJUnitPlatform() -} +} \ No newline at end of file diff --git a/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/model/Smoke.kt b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/model/Smoke.kt new file mode 100644 index 00000000..dd13ae5b --- /dev/null +++ b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/model/Smoke.kt @@ -0,0 +1,10 @@ +// commonMain +package com.feragusper.smokeanalytics.libraries.smokes.domain.model + +import kotlinx.datetime.Instant + +data class Smoke( + val id: String, + val date: Instant, + val timeElapsedSincePreviousSmoke: Pair = 0L to 0L, +) \ No newline at end of file diff --git a/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeCount.kt b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeCount.kt new file mode 100644 index 00000000..9b7a3442 --- /dev/null +++ b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeCount.kt @@ -0,0 +1,9 @@ +// commonMain +package com.feragusper.smokeanalytics.libraries.smokes.domain.model + +data class SmokeCount( + val today: List, + val week: Int, + val month: Int, + val lastSmoke: Smoke?, +) \ No newline at end of file diff --git a/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeStats.kt b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeStats.kt new file mode 100644 index 00000000..e3f12aac --- /dev/null +++ b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeStats.kt @@ -0,0 +1,150 @@ +package com.feragusper.smokeanalytics.libraries.smokes.domain.model + +import kotlinx.datetime.Clock +import kotlinx.datetime.DatePeriod +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.periodUntil +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime + +data class SmokeStats( + val daily: Map, + val weekly: Map, + val monthly: Map, + val yearly: Map, + val hourly: Map, + val totalMonth: Int, + val totalWeek: Int, + val totalDay: Int, + val dailyAverage: Float +) { + companion object { + + fun from( + smokes: List, + year: Int, + month: Int, // 1..12 + day: Int?, // 1..31 or null + timeZone: TimeZone = TimeZone.currentSystemDefault(), + now: Instant = Clock.System.now(), + ): SmokeStats { + val monthStart = LocalDate(year, month, 1) + val nextMonthStart = monthStart.plus(DatePeriod(months = 1)) + val daysInMonth = monthStart.daysUntil(nextMonthStart) + + val monthStartInstant = monthStart.atStartOfDayIn(timeZone) + val nextMonthStartInstant = nextMonthStart.atStartOfDayIn(timeZone) + + val monthSmokes = smokes + .asSequence() + .map { it to it.date.toLocalDateTime(timeZone) } + .filter { (_, dt) -> dt.date.year == year && dt.date.monthNumber == month } + .toList() + + // Daily: "1".."31" + val dailyStats = (1..daysInMonth).associate { it.toString() to 0 }.toMutableMap() + monthSmokes + .groupBy { (_, dt) -> dt.date.dayOfMonth.toString() } + .forEach { (k, v) -> dailyStats[k] = v.size } + + // Weekly: "Mon".."Sun" (fixed labels; locale lo hacĆ©s en UI si querĆ©s) + val weeklyLabels = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") + val weeklyStats = weeklyLabels.associateWith { 0 }.toMutableMap() + monthSmokes + .groupBy { (_, dt) -> dt.dayOfWeek.toShortLabel() } + .forEach { (k, v) -> weeklyStats[k] = v.size } + + // Monthly: "W1".."W5" + val weekOfMonthStats = (1..5).associate { "W$it" to 0 }.toMutableMap() + monthSmokes + .groupBy { (_, dt) -> + val weekOfMonth = ((dt.date.dayOfMonth - 1) / 7) + 1 + "W$weekOfMonth" + } + .forEach { (k, v) -> weekOfMonthStats[k] = v.size } + + // Yearly: "Jan".."Dec" (fixed labels; locale en UI) + val yearlyLabels = listOf( + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ) + val yearlyStats = yearlyLabels.associateWith { 0 }.toMutableMap() + smokes + .asSequence() + .map { it.date.toLocalDateTime(timeZone).date } + .filter { it.year == year } + .groupBy { it.monthNumber } + .forEach { (monthNumber, list) -> + yearlyStats[yearlyLabels[monthNumber - 1]] = list.size + } + + // Hourly: "00:00".."23:00" + val hourlyStats = (0..23).associate { hour -> + hour.toTwoDigits() + ":00" to 0 + }.toMutableMap() + + val daySmokes = if (day != null) { + monthSmokes.filter { (_, dt) -> dt.date.dayOfMonth == day } + } else emptyList() + + daySmokes + .groupBy { (_, dt) -> dt.time.hour.toTwoDigits() + ":00" } + .forEach { (k, v) -> hourlyStats[k] = v.size } + + val totalMonth = monthSmokes.size + + // totalWeek: Ćŗltima semana ā€œrollingā€ respecto a `now` (7 dĆ­as hacia atrĆ”s) + val totalWeek = run { + val nowDateTime = now.toLocalDateTime(timeZone) + val start = nowDateTime.date.plus(DatePeriod(days = -6)).atStartOfDayIn(timeZone) + val end = nowDateTime.date.plus(DatePeriod(days = 1)).atStartOfDayIn(timeZone) + smokes.count { it.date >= start && it.date < end } + } + + val totalDay = daySmokes.size + val dailyAverage = if (daysInMonth > 0) totalMonth.toFloat() / daysInMonth else 0f + + return SmokeStats( + daily = dailyStats, + weekly = weeklyStats, + monthly = weekOfMonthStats, + yearly = yearlyStats, + hourly = hourlyStats, + totalMonth = totalMonth, + totalWeek = totalWeek, + totalDay = totalDay, + dailyAverage = dailyAverage + ) + } + + private fun LocalDate.daysUntil(other: LocalDate): Int = + this.periodUntil(other).days + + private fun DayOfWeek.toShortLabel(): String = when (this) { + DayOfWeek.MONDAY -> "Mon" + DayOfWeek.TUESDAY -> "Tue" + DayOfWeek.WEDNESDAY -> "Wed" + DayOfWeek.THURSDAY -> "Thu" + DayOfWeek.FRIDAY -> "Fri" + DayOfWeek.SATURDAY -> "Sat" + DayOfWeek.SUNDAY -> "Sun" + else -> "" + } + + private fun Int.toTwoDigits(): String = if (this < 10) "0$this" else this.toString() + } +} \ No newline at end of file diff --git a/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/repository/SmokeRepository.kt b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/repository/SmokeRepository.kt new file mode 100644 index 00000000..616b19fc --- /dev/null +++ b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/repository/SmokeRepository.kt @@ -0,0 +1,22 @@ +// commonMain +package com.feragusper.smokeanalytics.libraries.smokes.domain.repository + +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.SmokeCount +import kotlinx.datetime.Instant + +interface SmokeRepository { + + suspend fun addSmoke(timestamp: Instant) + + suspend fun fetchSmokes( + start: Instant? = null, + end: Instant? = null, + ): List + + suspend fun fetchSmokeCount(): SmokeCount + + suspend fun editSmoke(id: String, timestamp: Instant) + + suspend fun deleteSmoke(id: String) +} \ No newline at end of file diff --git a/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/AddSmokeUseCase.kt b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/AddSmokeUseCase.kt new file mode 100644 index 00000000..6e00e57c --- /dev/null +++ b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/AddSmokeUseCase.kt @@ -0,0 +1,15 @@ +// commonMain +package com.feragusper.smokeanalytics.libraries.smokes.domain.usecase + +import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +class AddSmokeUseCase( + private val smokeRepository: SmokeRepository, +) { + + suspend operator fun invoke(timestamp: Instant = Clock.System.now()) { + smokeRepository.addSmoke(timestamp) + } +} \ No newline at end of file diff --git a/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/DeleteSmokeUseCase.kt b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/DeleteSmokeUseCase.kt new file mode 100644 index 00000000..d494aab2 --- /dev/null +++ b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/DeleteSmokeUseCase.kt @@ -0,0 +1,13 @@ +// commonMain +package com.feragusper.smokeanalytics.libraries.smokes.domain.usecase + +import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository + +class DeleteSmokeUseCase( + private val smokeRepository: SmokeRepository, +) { + + suspend operator fun invoke(id: String) { + smokeRepository.deleteSmoke(id) + } +} \ No newline at end of file diff --git a/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/EditSmokeUseCase.kt b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/EditSmokeUseCase.kt new file mode 100644 index 00000000..c362ee47 --- /dev/null +++ b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/EditSmokeUseCase.kt @@ -0,0 +1,14 @@ +// commonMain +package com.feragusper.smokeanalytics.libraries.smokes.domain.usecase + +import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository +import kotlinx.datetime.Instant + +class EditSmokeUseCase( + private val smokeRepository: SmokeRepository, +) { + + suspend operator fun invoke(id: String, timestamp: Instant) { + smokeRepository.editSmoke(id, timestamp) + } +} \ No newline at end of file diff --git a/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokeStatsUseCase.kt b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokeStatsUseCase.kt new file mode 100644 index 00000000..f365f137 --- /dev/null +++ b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokeStatsUseCase.kt @@ -0,0 +1,69 @@ +package com.feragusper.smokeanalytics.libraries.smokes.domain.usecase + +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.SmokeStats +import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository +import kotlinx.datetime.DatePeriod +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.isoDayNumber +import kotlinx.datetime.plus + +class FetchSmokeStatsUseCase( + private val smokeRepository: SmokeRepository, + private val timeZone: TimeZone = TimeZone.currentSystemDefault(), +) { + + suspend operator fun invoke( + year: Int, + month: Int, + day: Int?, + periodType: PeriodType + ): SmokeStats { + val (start, endExclusive) = when (periodType) { + PeriodType.DAY -> { + requireNotNull(day) { "day is required for PeriodType.DAY" } + val date = LocalDate(year, month, day) + date.atStartOfDayIn(timeZone) to date.plus(DatePeriod(days = 1)) + .atStartOfDayIn(timeZone) + } + + PeriodType.WEEK -> { + requireNotNull(day) { "day is required for PeriodType.WEEK" } + val date = LocalDate(year, month, day) + val startOfWeek = date.minusDaysToMonday() + val endOfWeek = startOfWeek.plus(DatePeriod(days = 7)) + startOfWeek.atStartOfDayIn(timeZone) to endOfWeek.atStartOfDayIn(timeZone) + } + + PeriodType.MONTH -> { + val date = LocalDate(year, month, 1) + val endOfMonth = date.plus(DatePeriod(months = 1)) + date.atStartOfDayIn(timeZone) to endOfMonth.atStartOfDayIn(timeZone) + } + + PeriodType.YEAR -> { + val date = LocalDate(year, 1, 1) + val endOfYear = date.plus(DatePeriod(years = 1)) + date.atStartOfDayIn(timeZone) to endOfYear.atStartOfDayIn(timeZone) + } + } + + val smokes = smokeRepository.fetchSmokes(start, endExclusive) + return SmokeStats.from( + smokes = smokes, + year = year, + month = month, + day = day, + timeZone = timeZone + ) + } + + enum class PeriodType { DAY, WEEK, MONTH, YEAR } + + private fun LocalDate.minusDaysToMonday(): LocalDate { + // kotlinx.datetime DayOfWeek: MONDAY=1 .. SUNDAY=7 + val delta = this.dayOfWeek.isoDayNumber - 1 + return this.plus(DatePeriod(days = -delta)) + } +} \ No newline at end of file diff --git a/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokesUseCase.kt b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokesUseCase.kt new file mode 100644 index 00000000..7235f166 --- /dev/null +++ b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokesUseCase.kt @@ -0,0 +1,15 @@ +// commonMain +package com.feragusper.smokeanalytics.libraries.smokes.domain.usecase + +import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository +import kotlinx.datetime.Instant + +class FetchSmokesUseCase( + private val smokeRepository: SmokeRepository, +) { + + suspend operator fun invoke( + start: Instant? = null, + end: Instant? = null, + ) = smokeRepository.fetchSmokes(start, end) +} \ No newline at end of file diff --git a/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/SyncWithWearUseCase.kt b/libraries/smokes/domain/src/jvmMain/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/SyncWithWearUseCase.kt similarity index 100% rename from libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/SyncWithWearUseCase.kt rename to libraries/smokes/domain/src/jvmMain/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/SyncWithWearUseCase.kt diff --git a/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/model/Smoke.kt b/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/model/Smoke.kt deleted file mode 100644 index 5027a9ca..00000000 --- a/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/model/Smoke.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.smokes.domain.model - -import java.time.LocalDateTime - -/** - * Represents a single smoke event within the domain, encapsulating its unique identifier, the date and time - * it occurred, and the time elapsed since the previous smoke event. - * - * @property id A unique identifier for the smoke event. - * @property date The date and time when the smoke event occurred. - * @property timeElapsedSincePreviousSmoke A pair representing the hours and minutes elapsed since the previous smoke event. - */ -data class Smoke( - val id: String, - val date: LocalDateTime, - val timeElapsedSincePreviousSmoke: Pair -) diff --git a/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeCount.kt b/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeCount.kt deleted file mode 100644 index db9b9fa7..00000000 --- a/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeCount.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.smokes.domain.model - -/** - * Represents the count of smoke events categorized by time periods, including today, week, and month. - * It also keeps track of the most recent smoke event. - * - * @property today A list of [Smoke] events that occurred today. - * @property week The total count of smoke events for the current week. - * @property month The total count of smoke events for the current month. - * @property lastSmoke The most recent [Smoke] event, or null if no events have occurred. - */ -data class SmokeCount( - val today: List, - val week: Int, - val month: Int, - val lastSmoke: Smoke?, -) diff --git a/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeStats.kt b/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeStats.kt deleted file mode 100644 index b7af8d50..00000000 --- a/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeStats.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.smokes.domain.model - -import java.time.DayOfWeek -import java.time.LocalDate -import java.time.LocalTime -import java.time.Month -import java.time.YearMonth -import java.time.format.DateTimeFormatter -import java.time.format.TextStyle -import java.util.Locale - -/** - * Represents statistical data for smoke events over different time frames (daily, weekly, monthly, and yearly). - * - * @property daily Number of cigarettes smoked per day of the month. - * @property weekly Number of cigarettes smoked per day of the week (e.g., Mon, Tue, Wed). - * @property monthly Number of cigarettes smoked per week of the month (Week 1, Week 2, ...). - * @property yearly Number of cigarettes smoked per month of the year (Jan, Feb, ...). - * @property hourly Number of cigarettes smoked per hour of the day ("00:00", "01:00", ...). - * @property totalMonth Total number of cigarettes smoked in the month. - * @property totalWeek Total number of cigarettes smoked in the current week. - * @property totalDay Total number of cigarettes smoked today. - * @property dailyAverage Average number of cigarettes smoked per day in the month. - */ -data class SmokeStats( - val daily: Map, - val weekly: Map, - val monthly: Map, - val yearly: Map, - val hourly: Map, - val totalMonth: Int, - val totalWeek: Int, - val totalDay: Int, - val dailyAverage: Float -) { - companion object { - fun from(smokes: List, year: Int, month: Int, day: Int?): SmokeStats { - val yearMonth = YearMonth.of(year, month) - val totalDaysInMonth = yearMonth.lengthOfMonth() - val startOfMonth = LocalDate.of(year, month, 1) - val endOfMonth = LocalDate.of(year, month, totalDaysInMonth) - - val filteredSmokes = - smokes.filter { it.date.year == year && it.date.monthValue == month } - - // Daily stats (1–31) - val dailyStats = (1..totalDaysInMonth).associate { it.toString() to 0 }.toMutableMap() - filteredSmokes.groupBy { it.date.dayOfMonth.toString() } - .mapValues { it.value.size } - .forEach { (day, count) -> dailyStats[day] = count } - - // Weekly stats (Mon–Sun) - val weeklyStats = DayOfWeek.entries - .associate { it.getDisplayName(TextStyle.SHORT, Locale.getDefault()) to 0 } - .toMutableMap() - filteredSmokes.groupBy { - it.date.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault()) - } - .mapValues { it.value.size } - .forEach { (dayOfWeek, count) -> weeklyStats[dayOfWeek] = count } - - // Monthly stats (Week 1–5) - val weekOfMonthStats = (1..5).associate { "W$it" to 0 }.toMutableMap() - filteredSmokes.groupBy { - val weekOfMonth = (it.date.dayOfMonth - 1) / 7 + 1 - "W$weekOfMonth" - } - .mapValues { it.value.size } - .forEach { (week, count) -> weekOfMonthStats[week] = count } - - // Yearly stats (Jan–Dec) - val yearlyStats = Month.entries - .associate { it.getDisplayName(TextStyle.SHORT, Locale.getDefault()) to 0 } - .toMutableMap() - smokes.filter { it.date.year == year } - .groupBy { it.date.month.getDisplayName(TextStyle.SHORT, Locale.getDefault()) } - .mapValues { it.value.size } - .forEach { (monthName, count) -> yearlyStats[monthName] = count } - - // Hourly stats (00:00–23:00) - val hourlyStats = (0..23).associate { - LocalTime.of(it, 0).format(DateTimeFormatter.ofPattern("HH:00")) to 0 - }.toMutableMap() - - val dailyFilteredSmokes = if (day != null) { - filteredSmokes.filter { it.date.dayOfMonth == day } - } else emptyList() - - dailyFilteredSmokes.groupBy { - it.date.toLocalTime().format(DateTimeFormatter.ofPattern("HH:00")) - } - .mapValues { it.value.size } - .forEach { (hour, count) -> hourlyStats[hour] = count } - - val totalMonth = filteredSmokes.size - val totalWeek = filteredSmokes.count { - it.date.toLocalDate() in startOfMonth..endOfMonth - } - val totalDay = dailyFilteredSmokes.size - val dailyAverage = if (totalDaysInMonth > 0) - totalMonth.toFloat() / totalDaysInMonth - else 0f - - return SmokeStats( - daily = dailyStats, - weekly = weeklyStats, - monthly = weekOfMonthStats, - yearly = yearlyStats, - hourly = hourlyStats, - totalMonth = totalMonth, - totalWeek = totalWeek, - totalDay = totalDay, - dailyAverage = dailyAverage - ) - } - } - -} diff --git a/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/repository/SmokeRepository.kt b/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/repository/SmokeRepository.kt deleted file mode 100644 index 70dddad2..00000000 --- a/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/repository/SmokeRepository.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.smokes.domain.repository - -import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke -import com.feragusper.smokeanalytics.libraries.smokes.domain.model.SmokeCount -import java.time.LocalDateTime - -/** - * Interface for the repository managing smoke data. It abstracts the operations related to managing - * smoke events, allowing for flexibility in the data source implementation. - */ -interface SmokeRepository { - - /** - * Adds a new smoke event at the specified date and time. - * - * @param date The [LocalDateTime] when the smoke event occurred. - */ - suspend fun addSmoke(date: LocalDateTime) - - /** - * Fetches smoke events for a given date range. - * - * @param startDate The [LocalDateTime] for the start of the date range (optional). - * @param endDate The [LocalDateTime] for the end of the date range (optional). - * @return A list of [Smoke] objects representing the fetched smoke events. - */ - suspend fun fetchSmokes( - startDate: LocalDateTime? = null, - endDate: LocalDateTime? = null - ): List - - /** - * Fetches the count of smoke events, categorized by day, week, and month. - * - * @return A [SmokeCount] object containing the aggregated smoke event data. - */ - suspend fun fetchSmokeCount(): SmokeCount - - /** - * Edits an existing smoke event with a new date and time. - * - * @param id The unique identifier of the smoke event to be edited. - * @param date The new [LocalDateTime] for the smoke event. - */ - suspend fun editSmoke(id: String, date: LocalDateTime) - - /** - * Deletes a smoke event by its unique identifier. - * - * @param id The unique identifier of the smoke event to be deleted. - */ - suspend fun deleteSmoke(id: String) -} diff --git a/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/AddSmokeUseCase.kt b/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/AddSmokeUseCase.kt deleted file mode 100644 index d3c91380..00000000 --- a/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/AddSmokeUseCase.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.smokes.domain.usecase - -import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository -import java.time.LocalDateTime -import javax.inject.Inject - -/** - * Use case for adding a new smoke event to the system. This operation encapsulates the business logic - * for creating and storing a new smoke event. - * - * @property smokeRepository The [SmokeRepository] used for adding the smoke event. - */ -class AddSmokeUseCase @Inject constructor( - private val smokeRepository: SmokeRepository, -) { - - /** - * Invokes the use case to add a new smoke event. - * - * @param date The [LocalDateTime] when the smoke event occurred, defaults to the current time. - */ - suspend operator fun invoke(date: LocalDateTime = LocalDateTime.now()) { - // Save the smoke event first - smokeRepository.addSmoke(date) - } -} diff --git a/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/DeleteSmokeUseCase.kt b/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/DeleteSmokeUseCase.kt deleted file mode 100644 index d442dd40..00000000 --- a/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/DeleteSmokeUseCase.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.smokes.domain.usecase - -import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository -import javax.inject.Inject - -/** - * Use case for deleting an existing smoke event from the system. This encapsulates the logic for - * removing a smoke event by its unique identifier. - * - * @property smokeRepository The [SmokeRepository] used for deleting the smoke event. - */ -class DeleteSmokeUseCase @Inject constructor( - private val smokeRepository: SmokeRepository, -) { - - /** - * Invokes the use case to delete a smoke event by its ID. - * - * @param id The unique identifier of the smoke event to be deleted. - */ - suspend operator fun invoke(id: String) { - smokeRepository.deleteSmoke(id) - } -} diff --git a/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/EditSmokeUseCase.kt b/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/EditSmokeUseCase.kt deleted file mode 100644 index 66920a8c..00000000 --- a/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/EditSmokeUseCase.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.smokes.domain.usecase - -import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository -import java.time.LocalDateTime -import javax.inject.Inject - -/** - * Use case for editing the details of an existing smoke event. This allows for updating the timestamp - * of a smoke event post-creation. - * - * @property smokeRepository The [SmokeRepository] used for editing the smoke event. - */ -class EditSmokeUseCase @Inject constructor( - private val smokeRepository: SmokeRepository, -) { - - /** - * Invokes the use case to edit a smoke event's date and time. - * - * @param id The unique identifier of the smoke event to be edited. - * @param date The new [LocalDateTime] for the smoke event. - */ - suspend operator fun invoke(id: String, date: LocalDateTime) { - smokeRepository.editSmoke(id, date) - } -} diff --git a/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokeStatsUseCase.kt b/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokeStatsUseCase.kt deleted file mode 100644 index 11c1b460..00000000 --- a/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokeStatsUseCase.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.smokes.domain.usecase - -import com.feragusper.smokeanalytics.libraries.smokes.domain.model.SmokeStats -import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository -import java.time.LocalDate -import javax.inject.Inject - -/** - * Use case for fetching smoke statistics for a given month. - * - * This use case allows retrieving aggregated data on smoking habits, including daily, - * weekly, and monthly statistics. - * - * @property smokeRepository The [SmokeRepository] used for fetching smoke data. - */ -class FetchSmokeStatsUseCase @Inject constructor( - private val smokeRepository: SmokeRepository -) { - /** - * Fetches the smoke statistics for a given period. - * - * @param year The year of the desired statistics. - * @param month The month (1-12) if applicable. - * @param day The day (1-31) if applicable. - * @param periodType The type of period (day, week, month, year). - * @return A [SmokeStats] object containing aggregated data. - */ - suspend operator fun invoke( - year: Int, - month: Int, - day: Int, - periodType: PeriodType - ): SmokeStats { - val (startDate, endDate) = when (periodType) { - PeriodType.DAY -> { - val date = LocalDate.of(year, month, day) - date.atStartOfDay() to date.plusDays(1).atStartOfDay() - } - - PeriodType.WEEK -> { - val date = LocalDate.of(year, month, day) - val startOfWeek = date.minusDays(date.dayOfWeek.value.toLong() - 1) - val endOfWeek = startOfWeek.plusDays(7) - startOfWeek.atStartOfDay() to endOfWeek.atStartOfDay() - } - - PeriodType.MONTH -> { - val date = LocalDate.of(year, month, 1) - val endOfMonth = date.plusMonths(1) - date.atStartOfDay() to endOfMonth.atStartOfDay() - } - - PeriodType.YEAR -> { - val date = LocalDate.of(year, 1, 1) - val endOfYear = date.plusYears(1) - date.atStartOfDay() to endOfYear.atStartOfDay() - } - } - - val smokes = smokeRepository.fetchSmokes(startDate, endDate) - return SmokeStats.from(smokes, year, month, day) - } - - /** - * Defines the different types of periods that can be used for statistics aggregation. - */ - enum class PeriodType { - DAY, WEEK, MONTH, YEAR - } -} diff --git a/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokesUseCase.kt b/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokesUseCase.kt deleted file mode 100644 index 29f36a57..00000000 --- a/libraries/smokes/domain/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokesUseCase.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.smokes.domain.usecase - -import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository -import java.time.LocalDateTime -import javax.inject.Inject - -/** - * Use case for fetching a list of smoke events, optionally filtered by a start date. This operation - * encapsulates the logic for querying smoke events based on the specified criteria. - * - * @property smokeRepository The [SmokeRepository] used for fetching the smoke events. - */ -class FetchSmokesUseCase @Inject constructor( - private val smokeRepository: SmokeRepository -) { - - /** - * Invokes the use case to fetch smoke events, optionally starting from a specific date. - * - * @param startDate The optional [LocalDateTime] to filter smoke events from. - * @param endDate The optional [LocalDateTime] to filter smoke events up to. - * @return A list of [com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke] events. - */ - suspend operator fun invoke( - startDate: LocalDateTime? = null, - endDate: LocalDateTime? = null - ) = smokeRepository.fetchSmokes(startDate, endDate) -} diff --git a/libraries/smokes/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/presentation/compose/DatePickerDialog.kt b/libraries/smokes/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/presentation/compose/DatePickerDialog.kt index 4d3d7d30..0368e017 100644 --- a/libraries/smokes/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/presentation/compose/DatePickerDialog.kt +++ b/libraries/smokes/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/presentation/compose/DatePickerDialog.kt @@ -9,61 +9,53 @@ import androidx.compose.material3.Text import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource -import com.feragusper.smokeanalytics.libraries.architecture.domain.extensions.utcMillis import com.feragusper.smokeanalytics.libraries.smokes.presentation.R -import java.time.Instant -import java.time.LocalDateTime -import java.time.ZoneId +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant /** - * Presents a date picker dialog to the user, allowing for the selection of a date from a calendar-style UI. - * It's built using Material 3 components and provides customization for initial date selection and date selection constraints. + * Presents a date picker dialog to the user, allowing for the selection of a date. * - * @param initialDate The initial date to be shown when the date picker is first displayed. - * @param onConfirm Callback function invoked with the selected [LocalDateTime] when the user confirms their choice. - * @param onDismiss Callback function invoked when the date picker dialog is dismissed without a date selection. + * This composable is Android-only, but works with [Instant] to stay consistent + * with domain and data layers. + * + * @param initialDate The initial date shown in the picker. + * @param onConfirm Callback invoked with the selected [Instant]. + * @param onDismiss Callback invoked when the dialog is dismissed. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun DatePickerDialog( - initialDate: LocalDateTime, - onConfirm: (LocalDateTime) -> Unit, + initialDate: Instant, + onConfirm: (Instant) -> Unit, onDismiss: () -> Unit, ) { - // Remembering the state of the DatePicker, initializing with the passed `initialDate`. val datePickerState = rememberDatePickerState( - initialSelectedDateMillis = initialDate.utcMillis(), // Convert initialDate to milliseconds. + initialSelectedDateMillis = initialDate.toEpochMilliseconds(), selectableDates = object : SelectableDates { - // Custom constraint to limit selectable dates to the current and past dates only. override fun isSelectableDate(utcTimeMillis: Long): Boolean { - return utcTimeMillis <= System.currentTimeMillis() + return utcTimeMillis <= Clock.System.now().toEpochMilliseconds() } } ) - // Building the DatePickerDialog with specified buttons and actions. DatePickerDialog( onDismissRequest = onDismiss, confirmButton = { Button(onClick = { - // Handling date confirmation, converting the selected milliseconds back to LocalDateTime and invoking `onConfirm`. - datePickerState.selectedDateMillis?.let { millis -> - Instant.ofEpochMilli(millis) - .atZone(ZoneId.systemDefault()) - .toLocalDateTime() - }?.let(onConfirm) + datePickerState.selectedDateMillis + ?.let { Instant.fromEpochMilliseconds(it) } + ?.let(onConfirm) }) { Text(text = stringResource(id = R.string.smokes_date_time_picker_button_ok)) } }, dismissButton = { - // Handling dialog dismissal. Button(onClick = onDismiss) { Text(text = stringResource(id = R.string.smokes_date_time_picker_button_cancel)) } } ) { - // The actual DatePicker composable with the remembered state. DatePicker(state = datePickerState) } -} +} \ No newline at end of file diff --git a/libraries/smokes/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/presentation/compose/SwipeToDismissRow.kt b/libraries/smokes/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/presentation/compose/SwipeToDismissRow.kt index 421ddd29..0989ac60 100644 --- a/libraries/smokes/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/presentation/compose/SwipeToDismissRow.kt +++ b/libraries/smokes/presentation/src/main/java/com/feragusper/smokeanalytics/libraries/smokes/presentation/compose/SwipeToDismissRow.kt @@ -38,31 +38,37 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import com.feragusper.smokeanalytics.libraries.architecture.domain.extensions.timeFormatted import com.feragusper.smokeanalytics.libraries.smokes.presentation.R import de.charlex.compose.RevealDirection import de.charlex.compose.RevealSwipe import de.charlex.compose.rememberRevealState -import java.time.LocalDateTime +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime /** * A row item for displaying smoke event details with swipe-to-dismiss functionality. * It allows users to delete the event by swiping and edit the event's time by tapping. * - * @param date The date and time of the smoke event. + * All time values are represented as [Instant] to stay consistent with domain/data and Web. + * + * @param date The date/time of the smoke event. * @param timeElapsedSincePreviousSmoke The time elapsed since the previous smoke event. - * @param onDelete Callback invoked when the item is swiped to dismiss, indicating deletion. + * @param onDelete Callback invoked when the item is deleted. * @param fullDateTimeEdit Flag indicating if both date and time can be edited. If false, only time is editable. - * @param onEdit Callback invoked with a new LocalDateTime when the user edits the event's time (or date and time). + * @param onEdit Callback invoked with a new [Instant] when the user edits the event. */ @Composable fun SwipeToDismissRow( - date: LocalDateTime, + date: Instant, timeElapsedSincePreviousSmoke: Pair, onDelete: () -> Unit, fullDateTimeEdit: Boolean, - onEdit: (LocalDateTime) -> Unit, + onEdit: (Instant) -> Unit, ) { + val timeZone = TimeZone.currentSystemDefault() val shape = MaterialTheme.shapes.medium val state = rememberRevealState( @@ -119,25 +125,33 @@ fun SwipeToDismissRow( ) { SmokeItem( date = date, + timeZone = timeZone, timeAfterPrevious = timeElapsedSincePreviousSmoke, ) } if (showDatePicker) { - var selectedDateTime = date + var selectedDateTime: LocalDateTime = date.toLocalDateTime(timeZone) if (fullDateTimeEdit) { DateTimePickerDialog( initialDateTime = date, - onDismiss = { - showDatePicker = false - }, - onDateSelected = { dateSelected -> - selectedDateTime = dateSelected + onDismiss = { showDatePicker = false }, + onDateSelected = { selectedInstant -> + selectedDateTime = selectedInstant.toLocalDateTime(timeZone) }, onTimeSelected = { hour, minutes -> showDatePicker = false - onEdit(selectedDateTime.toLocalDate().atTime(hour, minutes)) + val updatedLocalDateTime = LocalDateTime( + year = selectedDateTime.year, + monthNumber = selectedDateTime.monthNumber, + dayOfMonth = selectedDateTime.dayOfMonth, + hour = hour, + minute = minutes, + second = selectedDateTime.second, + nanosecond = selectedDateTime.nanosecond, + ) + onEdit(updatedLocalDateTime.toInstant(timeZone)) } ) } else { @@ -145,11 +159,19 @@ fun SwipeToDismissRow( initialDate = date, onConfirm = { hour, minutes -> showDatePicker = false - onEdit(selectedDateTime.toLocalDate().atTime(hour, minutes)) - }, - onDismiss = { - showDatePicker = false + val base = selectedDateTime + val updatedLocalDateTime = LocalDateTime( + year = base.year, + monthNumber = base.monthNumber, + dayOfMonth = base.dayOfMonth, + hour = hour, + minute = minutes, + second = base.second, + nanosecond = base.nanosecond, + ) + onEdit(updatedLocalDateTime.toInstant(timeZone)) }, + onDismiss = { showDatePicker = false }, ) } } @@ -157,9 +179,12 @@ fun SwipeToDismissRow( @Composable private fun SmokeItem( - date: LocalDateTime, + date: Instant, + timeZone: TimeZone, timeAfterPrevious: Pair, ) { + val local = date.toLocalDateTime(timeZone) + Row( modifier = Modifier .background(color = MaterialTheme.colorScheme.background) @@ -171,10 +196,11 @@ private fun SmokeItem( verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( - text = date.timeFormatted(), + text = "%02d:%02d".format(local.hour, local.minute), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onBackground ) + val (hours, minutes) = timeAfterPrevious Text( text = "${stringResource(id = R.string.smokes_smoked_after)} ${ @@ -195,30 +221,20 @@ private fun SmokeItem( color = MaterialTheme.colorScheme.onBackground ) } - } } /** * Displays a dialog allowing the user to select both a date and a time, with the selections made in two steps. - * First, the date is chosen, followed by the time. - * - * @param initialDateTime The initial date and time to display in the picker. - * @param onDismiss Callback invoked when the dialog is dismissed without a selection. - * @param onDateSelected Callback invoked when a date is selected, before the time selection step. - * @param onTimeSelected Callback invoked with the selected hour and minute after both date and time have been chosen. */ @Composable fun DateTimePickerDialog( - initialDateTime: LocalDateTime, + initialDateTime: Instant, onDismiss: () -> Unit, - onDateSelected: (LocalDateTime) -> Unit, + onDateSelected: (Instant) -> Unit, onTimeSelected: (Int, Int) -> Unit, ) { - - var dateTimeDialogType by remember { - mutableStateOf(DateTimeDialogType.Date) - } + var dateTimeDialogType by remember { mutableStateOf(DateTimeDialogType.Date) } when (dateTimeDialogType) { DateTimeDialogType.Date -> { @@ -235,9 +251,7 @@ fun DateTimePickerDialog( DateTimeDialogType.Time -> { TimePickerDialog( initialDate = initialDateTime, - onConfirm = { hour, minute -> - onTimeSelected(hour, minute) - }, + onConfirm = { hour, minute -> onTimeSelected(hour, minute) }, onDismiss = onDismiss, ) } @@ -245,29 +259,25 @@ fun DateTimePickerDialog( } /** - * Displays a dialog for time selection, providing an interface for choosing an hour and minute. - * - * @param initialDate The initial date and time to display in the picker. - * @param onDismiss Callback invoked when the dialog is dismissed without making a selection. - * @param onConfirm Callback invoked with the selected hour and minute upon confirmation. + * Displays a dialog for time selection. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun TimePickerDialog( - initialDate: LocalDateTime, + initialDate: Instant, onDismiss: () -> Unit, onConfirm: (Int, Int) -> Unit, ) { + val local = initialDate.toLocalDateTime(TimeZone.currentSystemDefault()) + val timePickerState = rememberTimePickerState( - initialHour = initialDate.hour, - initialMinute = initialDate.minute, + initialHour = local.hour, + initialMinute = local.minute, ) Dialog( onDismissRequest = onDismiss, - properties = DialogProperties( - usePlatformDefaultWidth = false - ), + properties = DialogProperties(usePlatformDefaultWidth = false), ) { Surface( shape = MaterialTheme.shapes.extraLarge, @@ -291,18 +301,16 @@ fun TimePickerDialog( text = stringResource(id = R.string.smokes_date_time_picker_title), style = MaterialTheme.typography.labelMedium ) - TimePicker( - state = timePickerState - ) + + TimePicker(state = timePickerState) + Row( modifier = Modifier .height(40.dp) .fillMaxWidth() ) { Spacer(modifier = Modifier.weight(1f)) - Button(onClick = { - onDismiss() - }) { + Button(onClick = onDismiss) { Text(text = stringResource(id = R.string.smokes_date_time_picker_button_cancel)) } Button(onClick = { @@ -316,11 +324,7 @@ fun TimePickerDialog( } } -/** - * Enum representing the dialog type for date and time selection. - */ private enum class DateTimeDialogType { Date, Time -} - +} \ No newline at end of file diff --git a/libraries/wear/data/build.gradle.kts b/libraries/wear/data/build.gradle.kts index 19b6e831..4295488a 100644 --- a/libraries/wear/data/build.gradle.kts +++ b/libraries/wear/data/build.gradle.kts @@ -2,7 +2,6 @@ plugins { `android-lib` id("kotlin-kapt") id("dagger.hilt.android.plugin") - alias(libs.plugins.compose.compiler) } android { @@ -14,10 +13,17 @@ dependencies { implementation(project(":libraries:architecture:domain")) implementation(project(":libraries:architecture:common")) implementation(project(":libraries:wear:domain")) + implementation(libs.bundles.androidx.base) implementation(libs.timber) implementation(libs.play.services.wearable) - implementation(libs.androidx.compose.runtime) + implementation(libs.hilt) kapt(libs.hilt.compiler) + + // Only if used + // implementation(libs.androidx.compose.runtime) + + testImplementation(platform(libs.junit.bom)) + testImplementation(libs.bundles.test) } \ No newline at end of file diff --git a/libraries/wear/data/src/main/java/com/feragusper/smokeanalytics/libraries/wear/data/WearSyncManagerImpl.kt b/libraries/wear/data/src/main/java/com/feragusper/smokeanalytics/libraries/wear/data/WearSyncManagerImpl.kt index 1f8c9815..da7e1a47 100644 --- a/libraries/wear/data/src/main/java/com/feragusper/smokeanalytics/libraries/wear/data/WearSyncManagerImpl.kt +++ b/libraries/wear/data/src/main/java/com/feragusper/smokeanalytics/libraries/wear/data/WearSyncManagerImpl.kt @@ -2,7 +2,7 @@ package com.feragusper.smokeanalytics.libraries.wear.data import android.content.Context import com.feragusper.smokeanalytics.libraries.architecture.common.coroutines.DispatcherProvider -import com.feragusper.smokeanalytics.libraries.architecture.domain.extensions.utcMillis +import com.feragusper.smokeanalytics.libraries.architecture.domain.utcMillis import com.feragusper.smokeanalytics.libraries.smokes.domain.model.SmokeCount import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository import com.feragusper.smokeanalytics.libraries.wear.domain.WearSyncManager @@ -17,8 +17,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock import timber.log.Timber -import java.time.LocalDateTime import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -68,7 +68,7 @@ class WearSyncManagerImpl( coroutineScope.launch(dispatcherProvider.io()) { when (messageEvent.path) { WearPaths.REQUEST_SMOKES -> syncWithWear() - WearPaths.ADD_SMOKE -> smokeRepository.addSmoke(LocalDateTime.now()) + WearPaths.ADD_SMOKE -> smokeRepository.addSmoke(Clock.System.now()) } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index a8142488..cd7069c8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,30 +1,17 @@ -/* - * settings.gradle.kts - * This file configures plugin management and dependency resolution for the project. - * It also sets the root project name and includes all the subprojects. - */ - pluginManagement { repositories { - // Google's Maven repository for Android dependencies google() - // Maven Central repository for general Java/Kotlin libraries mavenCentral() - // Gradle Plugin Portal for accessing Gradle plugins gradlePluginPortal() } } dependencyResolutionManagement { - // Ensure that only the repositories defined here are used, - // and fail if any project defines its own repositories. @Suppress("UnstableApiUsage") - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositoriesMode.set(RepositoriesMode.PREFER_PROJECT) @Suppress("UnstableApiUsage") repositories { - // Google's Maven repository for Android dependencies google() - // Maven Central repository for general Java/Kotlin libraries mavenCentral() } } @@ -35,15 +22,21 @@ rootProject.name = "SmokeAnalytics" // Include application modules include(":apps:mobile") include(":apps:wear") +include(":apps:web") // Include feature modules -include(":features:authentication:presentation") +include(":features:authentication:presentation:mobile") +include(":features:authentication:presentation:web") include(":features:devtools:presentation") -include(":features:history:presentation") +include(":features:history:presentation:mobile") +include(":features:history:presentation:web") include(":features:home:domain") -include(":features:home:presentation") -include(":features:settings:presentation") -include(":features:stats:presentation") +include(":features:home:presentation:mobile") +include(":features:home:presentation:web") +include(":features:settings:presentation:mobile") +include(":features:settings:presentation:web") +include(":features:stats:presentation:mobile") +include(":features:stats:presentation:web") include(":features:chatbot:presentation") include(":features:chatbot:domain") include(":features:chatbot:data") @@ -51,12 +44,17 @@ include(":features:chatbot:data") // Include library modules include(":libraries:architecture:common") include(":libraries:architecture:domain") -include(":libraries:architecture:presentation") -include(":libraries:authentication:data") +include(":libraries:architecture:presentation:mobile") +include(":libraries:architecture:presentation:web") +include(":libraries:authentication:data:mobile") +include(":libraries:authentication:data:web") include(":libraries:authentication:domain") -include(":libraries:authentication:presentation") +include(":libraries:authentication:presentation:mobile") +include(":libraries:authentication:presentation:web") include(":libraries:design") -include(":libraries:smokes:data") +include(":libraries:logging") +include(":libraries:smokes:data:mobile") +include(":libraries:smokes:data:web") include(":libraries:smokes:domain") include(":libraries:smokes:presentation") include(":libraries:wear:data") From 054e209e20eab449af065658e8dabba1575eb08f Mon Sep 17 00:00:00 2001 From: Fernando Perez Date: Sun, 4 Jan 2026 11:36:01 +0100 Subject: [PATCH 03/12] Update settings.gradle.kts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- settings.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index cd7069c8..51f63fd6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -8,7 +8,7 @@ pluginManagement { dependencyResolutionManagement { @Suppress("UnstableApiUsage") - repositoriesMode.set(RepositoriesMode.PREFER_PROJECT) + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) @Suppress("UnstableApiUsage") repositories { google() From 8bb49afc836f2414a6a633155d8c91f996aedba6 Mon Sep 17 00:00:00 2001 From: feragusper Date: Sun, 4 Jan 2026 11:39:01 +0100 Subject: [PATCH 04/12] refactor(FirebaseWebInit): clean up initialization code and remove commented-out SDK configuration --- .../smokeanalytics/FirebaseWebInit.kt | 37 +------------------ 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/FirebaseWebInit.kt b/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/FirebaseWebInit.kt index e996c66e..a06b3f35 100644 --- a/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/FirebaseWebInit.kt +++ b/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/FirebaseWebInit.kt @@ -14,20 +14,7 @@ object FirebaseWebInit { * Initializes Firebase with the provided options. */ fun init() { - // Avoid double init in HMR / recomposition scenarios runCatching { -// Firebase.initialize( -// options = FirebaseOptions( -// apiKey = "AIzaSyCQsNHxeSiaXTr5KugYx4AmMxpflL_O9lI", -// authDomain = "smoke-analytics-staging.firebaseapp.com", -// projectId = "smoke-analytics-staging", -// storageBucket = "smoke-analytics-staging.firebasestorage.app", // optional if you use storage -// applicationId = "1:1016019974225:web:ed48cf5c4e50e5357ee070", -// //messagingSenderId: "1016019974225", -// //measurementId: "G-7MPPG1QTDD" -// ) -// ) - Firebase.initialize( options = FirebaseOptions( apiKey = BuildKonfig.FIREBASE_API_KEY, @@ -39,26 +26,4 @@ object FirebaseWebInit { ) } } -} - -//// Import the functions you need from the SDKs you need -//import { initializeApp } from "firebase/app"; -//import { getAnalytics } from "firebase/analytics"; -//// TODO: Add SDKs for Firebase products that you want to use -//// https://firebase.google.com/docs/web/setup#available-libraries -// -//// Your web app's Firebase configuration -//// For Firebase JS SDK v7.20.0 and later, measurementId is optional -//const firebaseConfig = { -// apiKey: "AIzaSyC4P6TscDf8CgRFvup2uouvixEVRklnYkc", -// authDomain: "smoke-analytics.firebaseapp.com", -// projectId: "smoke-analytics", -// storageBucket: "smoke-analytics.firebasestorage.app", -// messagingSenderId: "235081091876", -// appId: "1:235081091876:web:1f590358b355fa999141b1", -// measurementId: "G-QKWQM4SMN8" -//}; -// -//// Initialize Firebase -//const app = initializeApp(firebaseConfig); -//const analytics = getAnalytics(app); \ No newline at end of file +} \ No newline at end of file From d2ec62c4f3519048420ad72a460824b74d1bf4c8 Mon Sep 17 00:00:00 2001 From: feragusper Date: Sun, 4 Jan 2026 14:26:38 +0100 Subject: [PATCH 05/12] feat(settings): add exclusive content for Node distributions in dependency resolution --- settings.gradle.kts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/settings.gradle.kts b/settings.gradle.kts index 51f63fd6..9a1f2ab1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,10 +9,28 @@ pluginManagement { dependencyResolutionManagement { @Suppress("UnstableApiUsage") repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + @Suppress("UnstableApiUsage") repositories { google() mavenCentral() + + // Kotlin/JS downloads Node distributions via an Ivy repository. + // With FAIL_ON_PROJECT_REPOS, it must be declared here. + exclusiveContent { + forRepository { + ivy("https://nodejs.org/dist") { + name = "NodeDistributions" + patternLayout { + artifact("v[revision]/[artifact](-v[revision]-[classifier]).[ext]") + } + metadataSources { artifact() } + } + } + filter { + includeGroup("org.nodejs") + } + } } } From 0d9311ffc7fea480ec645e6aa4d9510490b47ac0 Mon Sep 17 00:00:00 2001 From: feragusper Date: Sun, 4 Jan 2026 14:36:39 +0100 Subject: [PATCH 06/12] refactor(home): reorganize package structure and enhance documentation for Home intents and processes --- .../feragusper/smokeanalytics/WebAppGraph.kt | 2 +- .../presentation/web/EditSmokeDialogWeb.kt | 8 +++ .../home/presentation/web/HomeIntent.kt | 15 ------ .../home/presentation/web/HomeViewState.kt | 24 +++++++++ .../presentation/web/HomeWebDependencies.kt | 31 +++-------- .../home/presentation/web/HomeWebScreen.kt | 18 +++++-- .../home/presentation/web/mvi/HomeIntent.kt | 52 +++++++++++++++++++ .../presentation/web/{ => mvi}/HomeResult.kt | 2 +- .../web/{ => mvi}/HomeWebStore.kt | 4 +- .../web/{ => process}/HomeProcessHolder.kt | 19 ++++++- 10 files changed, 128 insertions(+), 47 deletions(-) delete mode 100644 features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeIntent.kt create mode 100644 features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/mvi/HomeIntent.kt rename features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/{ => mvi}/HomeResult.kt (99%) rename features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/{ => mvi}/HomeWebStore.kt (95%) rename features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/{ => process}/HomeProcessHolder.kt (84%) diff --git a/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/WebAppGraph.kt b/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/WebAppGraph.kt index fcd923f7..9d96376b 100644 --- a/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/WebAppGraph.kt +++ b/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/WebAppGraph.kt @@ -1,7 +1,7 @@ package com.feragusper.smokeanalytics import com.feragusper.smokeanalytics.features.home.domain.FetchSmokeCountListUseCase -import com.feragusper.smokeanalytics.features.home.presentation.web.HomeProcessHolder +import com.feragusper.smokeanalytics.features.home.presentation.web.process.HomeProcessHolder import com.feragusper.smokeanalytics.libraries.authentication.data.AuthenticationRepositoryImpl import com.feragusper.smokeanalytics.libraries.authentication.domain.AuthenticationRepository import com.feragusper.smokeanalytics.libraries.authentication.domain.FetchSessionUseCase diff --git a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/EditSmokeDialogWeb.kt b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/EditSmokeDialogWeb.kt index 1fb33a8b..be83f115 100644 --- a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/EditSmokeDialogWeb.kt +++ b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/EditSmokeDialogWeb.kt @@ -16,6 +16,14 @@ import org.jetbrains.compose.web.dom.Input import org.jetbrains.compose.web.dom.Label import org.jetbrains.compose.web.dom.Text +/** + * Displays a dialog for editing a smoke. + * + * @param initialInstant The initial instant to be displayed in the dialog. + * @param fullDateTimeEdit Whether to allow full date and time editing. + * @param onDismiss Callback to be invoked when the dialog is dismissed. + * @param onConfirm Callback to be invoked when the user confirms the changes. + */ @Composable internal fun EditSmokeDialogWeb( initialInstant: Instant, diff --git a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeIntent.kt b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeIntent.kt deleted file mode 100644 index e10ec9fe..00000000 --- a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeIntent.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.feragusper.smokeanalytics.features.home.presentation.web - -import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke -import kotlinx.datetime.Instant - -// comments in English only where needed -sealed interface HomeIntent { - data object FetchSmokes : HomeIntent - data object RefreshFetchSmokes : HomeIntent - data object AddSmoke : HomeIntent - data class EditSmoke(val id: String, val date: Instant) : HomeIntent - data class DeleteSmoke(val id: String) : HomeIntent - data object OnClickHistory : HomeIntent - data class TickTimeSinceLastCigarette(val lastCigarette: Smoke?) : HomeIntent -} \ No newline at end of file diff --git a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeViewState.kt b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeViewState.kt index d3ede4ec..a3b98e58 100644 --- a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeViewState.kt +++ b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeViewState.kt @@ -2,6 +2,18 @@ package com.feragusper.smokeanalytics.features.home.presentation.web import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke +/** + * Represents the state of the Home screen. + * + * @property displayLoading Whether the loading indicator should be displayed. + * @property displayRefreshLoading Whether the refresh loading indicator should be displayed. + * @property smokesPerDay The number of smokes smoked today. + * @property smokesPerWeek The number of smokes smoked this week. + * @property smokesPerMonth The number of smokes smoked this month. + * @property timeSinceLastCigarette The time since the last cigarette. + * @property latestSmokes The latest smokes smoked. + * @property error The error that occurred during the last operation. + */ data class HomeViewState( val displayLoading: Boolean = false, val displayRefreshLoading: Boolean = false, @@ -12,8 +24,20 @@ data class HomeViewState( val latestSmokes: List? = null, val error: HomeError? = null, ) { + + /** + * Represents the errors that can occur in the Home screen. + */ sealed interface HomeError { + + /** + * Represents a generic error. + */ data object Generic : HomeError + + /** + * Represents an error when the user is not logged in. + */ data object NotLoggedIn : HomeError } } \ No newline at end of file diff --git a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebDependencies.kt b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebDependencies.kt index a7c1e371..6a557631 100644 --- a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebDependencies.kt +++ b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebDependencies.kt @@ -1,29 +1,12 @@ package com.feragusper.smokeanalytics.features.home.presentation.web -import com.feragusper.smokeanalytics.features.home.domain.FetchSmokeCountListUseCase -import com.feragusper.smokeanalytics.libraries.authentication.domain.FetchSessionUseCase -import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.AddSmokeUseCase -import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.DeleteSmokeUseCase -import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.EditSmokeUseCase +import com.feragusper.smokeanalytics.features.home.presentation.web.process.HomeProcessHolder +/** + * Represents the dependencies required by the [HomeWebScreen]. + * + * @property homeProcessHolder The process holder for the home screen. + */ class HomeWebDependencies( val homeProcessHolder: HomeProcessHolder, -) - -fun createHomeWebDependencies( - fetchSessionUseCase: FetchSessionUseCase, - fetchSmokeCountListUseCase: FetchSmokeCountListUseCase, - addSmokeUseCase: AddSmokeUseCase, - editSmokeUseCase: EditSmokeUseCase, - deleteSmokeUseCase: DeleteSmokeUseCase, -): HomeWebDependencies { - return HomeWebDependencies( - homeProcessHolder = HomeProcessHolder( - addSmokeUseCase = addSmokeUseCase, - editSmokeUseCase = editSmokeUseCase, - deleteSmokeUseCase = deleteSmokeUseCase, - fetchSmokeCountListUseCase = fetchSmokeCountListUseCase, - fetchSessionUseCase = fetchSessionUseCase, - ) - ) -} \ No newline at end of file +) \ No newline at end of file diff --git a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebScreen.kt b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebScreen.kt index ac05393a..17e6f9ad 100644 --- a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebScreen.kt +++ b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebScreen.kt @@ -7,6 +7,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import com.feragusper.smokeanalytics.features.home.presentation.web.mvi.HomeIntent +import com.feragusper.smokeanalytics.features.home.presentation.web.mvi.HomeResult +import com.feragusper.smokeanalytics.features.home.presentation.web.mvi.HomeWebStore import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate @@ -23,10 +26,15 @@ import org.jetbrains.compose.web.dom.Hr import org.jetbrains.compose.web.dom.P import org.jetbrains.compose.web.dom.Text +/** + * Represents the dependencies required by the [HomeWebScreen]. + * + * @param deps Dependencies required by the [HomeWebScreen]. + * @param onNavigateToHistory Callback to navigate to the history screen. + */ @Composable fun HomeWebScreen( deps: HomeWebDependencies, - onNavigateToAuth: () -> Unit, onNavigateToHistory: () -> Unit, ) { val store = remember(deps) { HomeWebStore(processHolder = deps.homeProcessHolder) } @@ -36,20 +44,22 @@ fun HomeWebScreen( val state by store.state.collectAsState() state.Render( - onNavigateToHistory = onNavigateToHistory, onIntent = { intent -> when (intent) { HomeIntent.OnClickHistory -> onNavigateToHistory() -// HomeIntent.GoToAuthentication -> onNavigateToAuth() else -> store.send(intent) } } ) } +/** + * Represents the dependencies required by the [HomeWebScreen]. + * + * @param onIntent Callback to send intents to the [HomeWebStore]. + */ @Composable fun HomeViewState.Render( - onNavigateToHistory: () -> Unit, onIntent: (HomeIntent) -> Unit, ) { Div { diff --git a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/mvi/HomeIntent.kt b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/mvi/HomeIntent.kt new file mode 100644 index 00000000..eed1c5eb --- /dev/null +++ b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/mvi/HomeIntent.kt @@ -0,0 +1,52 @@ +package com.feragusper.smokeanalytics.features.home.presentation.web.mvi + +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke +import kotlinx.datetime.Instant + +/** + * Represents the intents that can be sent to the Home screen. + */ +sealed interface HomeIntent { + + /** + * Represents the intent to fetch smokes. + */ + data object FetchSmokes : HomeIntent + + /** + * Represents the intent to refresh the fetch smokes operation. + */ + data object RefreshFetchSmokes : HomeIntent + + /** + * Represents the intent to add a smoke. + */ + data object AddSmoke : HomeIntent + + /** + * Represents the intent to edit a smoke. + * + * @property id The ID of the smoke to edit. + * @property date The new date of the smoke. + */ + data class EditSmoke(val id: String, val date: Instant) : HomeIntent + + /** + * Represents the intent to delete a smoke. + * + * @property id The ID of the smoke to delete. + */ + data class DeleteSmoke(val id: String) : HomeIntent + + /** + * Represents the intent to navigate to the history screen. + */ + data object OnClickHistory : HomeIntent + + /** + * Represents the intent to tick the time since last cigarette. + * + * @property lastCigarette The last cigarette smoked. + */ + data class TickTimeSinceLastCigarette(val lastCigarette: Smoke?) : HomeIntent +} \ No newline at end of file diff --git a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeResult.kt b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/mvi/HomeResult.kt similarity index 99% rename from features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeResult.kt rename to features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/mvi/HomeResult.kt index 1b1efe38..497592c1 100644 --- a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeResult.kt +++ b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/mvi/HomeResult.kt @@ -1,4 +1,4 @@ -package com.feragusper.smokeanalytics.features.home.presentation.web +package com.feragusper.smokeanalytics.features.home.presentation.web.mvi import com.feragusper.smokeanalytics.features.home.domain.SmokeCountListResult diff --git a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebStore.kt b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/mvi/HomeWebStore.kt similarity index 95% rename from features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebStore.kt rename to features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/mvi/HomeWebStore.kt index cb1789f2..2de67573 100644 --- a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebStore.kt +++ b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/mvi/HomeWebStore.kt @@ -1,5 +1,7 @@ -package com.feragusper.smokeanalytics.features.home.presentation.web +package com.feragusper.smokeanalytics.features.home.presentation.web.mvi +import com.feragusper.smokeanalytics.features.home.presentation.web.HomeViewState +import com.feragusper.smokeanalytics.features.home.presentation.web.process.HomeProcessHolder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeProcessHolder.kt b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/process/HomeProcessHolder.kt similarity index 84% rename from features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeProcessHolder.kt rename to features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/process/HomeProcessHolder.kt index 850257d0..9e2c4365 100644 --- a/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeProcessHolder.kt +++ b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/process/HomeProcessHolder.kt @@ -1,6 +1,8 @@ -package com.feragusper.smokeanalytics.features.home.presentation.web +package com.feragusper.smokeanalytics.features.home.presentation.web.process import com.feragusper.smokeanalytics.features.home.domain.FetchSmokeCountListUseCase +import com.feragusper.smokeanalytics.features.home.presentation.web.mvi.HomeIntent +import com.feragusper.smokeanalytics.features.home.presentation.web.mvi.HomeResult import com.feragusper.smokeanalytics.libraries.architecture.domain.timeElapsedSinceNow import com.feragusper.smokeanalytics.libraries.authentication.domain.FetchSessionUseCase import com.feragusper.smokeanalytics.libraries.authentication.domain.Session @@ -13,6 +15,15 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flow +/** + * Represents the process holder for the Home screen. + * + * @property addSmokeUseCase The use case for adding a smoke. + * @property editSmokeUseCase The use case for editing a smoke. + * @property deleteSmokeUseCase The use case for deleting a smoke. + * @property fetchSmokeCountListUseCase The use case for fetching smoke count list. + * @property fetchSessionUseCase The use case for fetching the session. + */ class HomeProcessHolder( private val addSmokeUseCase: AddSmokeUseCase, private val editSmokeUseCase: EditSmokeUseCase, @@ -21,6 +32,12 @@ class HomeProcessHolder( private val fetchSessionUseCase: FetchSessionUseCase, ) { + /** + * Processes the given intent and returns a flow of results. + * + * @param intent The intent to process. + * @return A flow of results. + */ fun processIntent(intent: HomeIntent): Flow = when (intent) { HomeIntent.FetchSmokes -> processFetchSmokes(isRefresh = false) HomeIntent.RefreshFetchSmokes -> processFetchSmokes(isRefresh = true) From 5da5465e96ded8569051cdcfd6a3e1387b4415e7 Mon Sep 17 00:00:00 2001 From: feragusper Date: Sun, 4 Jan 2026 14:36:48 +0100 Subject: [PATCH 07/12] Revert "feat(settings): add exclusive content for Node distributions in dependency resolution" This reverts commit d2ec62c4f3519048420ad72a460824b74d1bf4c8. --- settings.gradle.kts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index 9a1f2ab1..51f63fd6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,28 +9,10 @@ pluginManagement { dependencyResolutionManagement { @Suppress("UnstableApiUsage") repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - @Suppress("UnstableApiUsage") repositories { google() mavenCentral() - - // Kotlin/JS downloads Node distributions via an Ivy repository. - // With FAIL_ON_PROJECT_REPOS, it must be declared here. - exclusiveContent { - forRepository { - ivy("https://nodejs.org/dist") { - name = "NodeDistributions" - patternLayout { - artifact("v[revision]/[artifact](-v[revision]-[classifier]).[ext]") - } - metadataSources { artifact() } - } - } - filter { - includeGroup("org.nodejs") - } - } } } From 856f85a004bb69567c4a5018f1e8def04b99412d Mon Sep 17 00:00:00 2001 From: feragusper Date: Sun, 4 Jan 2026 14:36:53 +0100 Subject: [PATCH 08/12] Revert "Update settings.gradle.kts" This reverts commit 054e209e20eab449af065658e8dabba1575eb08f. --- settings.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index 51f63fd6..cd7069c8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -8,7 +8,7 @@ pluginManagement { dependencyResolutionManagement { @Suppress("UnstableApiUsage") - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositoriesMode.set(RepositoriesMode.PREFER_PROJECT) @Suppress("UnstableApiUsage") repositories { google() From 78822e207fde74f4eae99484bf7e497c25ecabce Mon Sep 17 00:00:00 2001 From: feragusper Date: Sun, 4 Jan 2026 14:59:19 +0100 Subject: [PATCH 09/12] refactor(AppRoot): reorder imports and clean up navigation logic for HomeWebScreen --- .../src/jsMain/kotlin/com/feragusper/smokeanalytics/AppRoot.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/AppRoot.kt b/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/AppRoot.kt index d2deb660..98918b33 100644 --- a/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/AppRoot.kt +++ b/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/AppRoot.kt @@ -7,9 +7,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.feragusper.smokeanalytics.features.authentication.presentation.AuthenticationWebScreen import com.feragusper.smokeanalytics.features.authentication.presentation.createAuthenticationWebDependencies -import com.feragusper.smokeanalytics.features.history.presentation.process.HistoryProcessHolder import com.feragusper.smokeanalytics.features.history.presentation.HistoryWebDependencies import com.feragusper.smokeanalytics.features.history.presentation.HistoryWebScreen +import com.feragusper.smokeanalytics.features.history.presentation.process.HistoryProcessHolder import com.feragusper.smokeanalytics.features.home.presentation.web.HomeWebDependencies import com.feragusper.smokeanalytics.features.home.presentation.web.HomeWebScreen import com.feragusper.smokeanalytics.features.settings.presentation.web.SettingsWebScreen @@ -54,7 +54,6 @@ fun AppRoot(graph: WebAppGraph) { when (tab) { WebTab.Home -> HomeWebScreen( deps = homeDeps, - onNavigateToAuth = { route = WebRoute.Auth }, onNavigateToHistory = { route = WebRoute.History }, ) From 65eca624b428c62da048a6dceeb0fc226369a782 Mon Sep 17 00:00:00 2001 From: feragusper Date: Sun, 4 Jan 2026 15:14:40 +0100 Subject: [PATCH 10/12] feat(settings): enhance Settings screen with MVI architecture and add documentation --- .../presentation/mvi/HistoryWebStore.kt | 18 ++++++++++ .../presentation/web/SettingsIntent.kt | 6 ---- .../presentation/web/SettingsResult.kt | 8 ----- .../presentation/web/SettingsViewState.kt | 7 ++++ .../web/SettingsWebDependencies.kt | 14 ++++++++ .../presentation/web/SettingsWebScreen.kt | 7 ++++ .../presentation/web/mvi/SettingsIntent.kt | 17 ++++++++++ .../presentation/web/mvi/SettingsResult.kt | 28 ++++++++++++++++ .../web/{ => mvi}/SettingsWebStore.kt | 22 ++++++++++++- .../{ => process}/SettingsProcessHolder.kt | 17 +++++++++- .../stats/presentation/web/ChartJs.kt | 9 +++++ .../stats/presentation/web/StatsResult.kt | 9 ----- .../stats/presentation/web/StatsViewState.kt | 15 +++++++++ .../presentation/web/StatsWebDependencies.kt | 13 ++++++++ .../stats/presentation/web/StatsWebScreen.kt | 2 ++ .../stats/presentation/web/canvas2dContext.kt | 7 ++++ .../presentation/web/{ => mvi}/StatsIntent.kt | 14 +++++++- .../stats/presentation/web/mvi/StatsResult.kt | 28 ++++++++++++++++ .../web/{ => mvi}/StatsWebStore.kt | 22 ++++++++++++- .../web/{ => process}/StatsProcessHolder.kt | 4 ++- .../architecture/presentation/MviStore.kt | 31 ++++++++++++++++- .../data/AuthenticationRepositoryImpl.kt | 11 +++++++ .../domain/AuthenticationRepository.kt | 13 ++++++++ .../compose/GoogleSignInComponentWeb.kt | 6 ++++ .../libraries/logging /AppLogger.kt | 26 +++++++++++++++ .../libraries/smokes/data/SmokeEntity.kt | 9 +++-- .../smokes/data/SmokeRepositoryImpl.kt | 24 ++++++++++++++ .../domain/repository/SmokeRepository.kt | 33 ++++++++++++++++++- 28 files changed, 387 insertions(+), 33 deletions(-) delete mode 100644 features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsIntent.kt delete mode 100644 features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsResult.kt create mode 100644 features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/mvi/SettingsIntent.kt create mode 100644 features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/mvi/SettingsResult.kt rename features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/{ => mvi}/SettingsWebStore.kt (77%) rename features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/{ => process}/SettingsProcessHolder.kt (71%) delete mode 100644 features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsResult.kt rename features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/{ => mvi}/StatsIntent.kt (51%) create mode 100644 features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/mvi/StatsResult.kt rename features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/{ => mvi}/StatsWebStore.kt (75%) rename features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/{ => process}/StatsProcessHolder.kt (83%) diff --git a/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryWebStore.kt b/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryWebStore.kt index d31b656c..1cdb0286 100644 --- a/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryWebStore.kt +++ b/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryWebStore.kt @@ -14,6 +14,12 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.datetime.Clock +/** + * Represents the store for the History screen. + * + * @property processHolder The process holder for the History screen. + * @property scope The coroutine scope for the store. + */ class HistoryWebStore( private val processHolder: HistoryProcessHolder, private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), @@ -21,12 +27,24 @@ class HistoryWebStore( private val intents = Channel(capacity = Channel.Factory.BUFFERED) private val _state = MutableStateFlow(HistoryViewState()) + + /** + * The current state of the History screen. + */ val state: StateFlow = _state.asStateFlow() + /** + * Sends an intent to the store. + * + * @param intent The intent to send. + */ fun send(intent: HistoryIntent) { intents.trySend(intent) } + /** + * Starts the store. + */ fun start() { scope.launch { intents diff --git a/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsIntent.kt b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsIntent.kt deleted file mode 100644 index 62320e86..00000000 --- a/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsIntent.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.feragusper.smokeanalytics.features.settings.presentation.web - -sealed interface SettingsIntent { - data object FetchUser : SettingsIntent - data object SignOut : SettingsIntent -} \ No newline at end of file diff --git a/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsResult.kt b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsResult.kt deleted file mode 100644 index 949ba8c7..00000000 --- a/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsResult.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.feragusper.smokeanalytics.features.settings.presentation.web - -sealed interface SettingsResult { - data object Loading : SettingsResult - data class UserLoggedIn(val email: String?) : SettingsResult - data object UserLoggedOut : SettingsResult - data object ErrorGeneric : SettingsResult -} \ No newline at end of file diff --git a/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsViewState.kt b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsViewState.kt index 4304cc49..cc5a0c35 100644 --- a/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsViewState.kt +++ b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsViewState.kt @@ -1,5 +1,12 @@ package com.feragusper.smokeanalytics.features.settings.presentation.web +/** + * Represents the state of the Settings screen. + * + * @property displayLoading Whether the loading indicator should be displayed. + * @property currentEmail The current email of the user. + * @property errorMessage The error message to be displayed. + */ data class SettingsViewState( val displayLoading: Boolean = false, val currentEmail: String? = null, diff --git a/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebDependencies.kt b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebDependencies.kt index a66e9e63..45709e2e 100644 --- a/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebDependencies.kt +++ b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebDependencies.kt @@ -1,12 +1,26 @@ package com.feragusper.smokeanalytics.features.settings.presentation.web +import com.feragusper.smokeanalytics.features.settings.presentation.web.process.SettingsProcessHolder import com.feragusper.smokeanalytics.libraries.authentication.domain.FetchSessionUseCase import com.feragusper.smokeanalytics.libraries.authentication.domain.SignOutUseCase +/** + * Represents the dependencies for the Settings screen. + * + * @property processHolder The process holder for the Settings screen. + */ class SettingsWebDependencies( val processHolder: SettingsProcessHolder, ) +/** + * Creates the dependencies for the Settings screen. + * + * @param fetchSessionUseCase The use case for fetching the session. + * @param signOutUseCase The use case for signing out. + * + * @return The dependencies for the Settings screen. + */ fun createSettingsWebDependencies( fetchSessionUseCase: FetchSessionUseCase, signOutUseCase: SignOutUseCase, diff --git a/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebScreen.kt b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebScreen.kt index a7972912..aabb5e17 100644 --- a/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebScreen.kt +++ b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebScreen.kt @@ -5,6 +5,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import com.feragusper.smokeanalytics.features.settings.presentation.web.mvi.SettingsIntent +import com.feragusper.smokeanalytics.features.settings.presentation.web.mvi.SettingsWebStore import com.feragusper.smokeanalytics.libraries.authentication.presentation.compose.GoogleSignInComponentWeb import org.jetbrains.compose.web.attributes.disabled import org.jetbrains.compose.web.dom.Button @@ -14,6 +16,11 @@ import org.jetbrains.compose.web.dom.Hr import org.jetbrains.compose.web.dom.P import org.jetbrains.compose.web.dom.Text +/** + * Screen for the Settings screen. + * + * @param deps The dependencies for the Settings screen. + */ @Composable fun SettingsWebScreen( deps: SettingsWebDependencies, diff --git a/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/mvi/SettingsIntent.kt b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/mvi/SettingsIntent.kt new file mode 100644 index 00000000..94e5039b --- /dev/null +++ b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/mvi/SettingsIntent.kt @@ -0,0 +1,17 @@ +package com.feragusper.smokeanalytics.features.settings.presentation.web.mvi + +/** + * Represents the intents that can be sent to the Settings screen. + */ +sealed interface SettingsIntent { + + /** + * Represents the intent to fetch the user. + */ + data object FetchUser : SettingsIntent + + /** + * Represents the intent to sign out. + */ + data object SignOut : SettingsIntent +} \ No newline at end of file diff --git a/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/mvi/SettingsResult.kt b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/mvi/SettingsResult.kt new file mode 100644 index 00000000..1177c297 --- /dev/null +++ b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/mvi/SettingsResult.kt @@ -0,0 +1,28 @@ +package com.feragusper.smokeanalytics.features.settings.presentation.web.mvi + +/** + * Represents the results that can be returned from the Settings screen. + */ +sealed interface SettingsResult { + /** + * Represents the result of a loading operation. + */ + data object Loading : SettingsResult + + /** + * Represents the result of a successful fetch of the user. + * + * @property email The email of the user. + */ + data class UserLoggedIn(val email: String?) : SettingsResult + + /** + * Represents the result of a successful sign out. + */ + data object UserLoggedOut : SettingsResult + + /** + * Represents the result of a generic error. + */ + data object ErrorGeneric : SettingsResult +} \ No newline at end of file diff --git a/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebStore.kt b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/mvi/SettingsWebStore.kt similarity index 77% rename from features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebStore.kt rename to features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/mvi/SettingsWebStore.kt index bbcb71d6..28bc695c 100644 --- a/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebStore.kt +++ b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/mvi/SettingsWebStore.kt @@ -1,5 +1,7 @@ -package com.feragusper.smokeanalytics.features.settings.presentation.web +package com.feragusper.smokeanalytics.features.settings.presentation.web.mvi +import com.feragusper.smokeanalytics.features.settings.presentation.web.SettingsViewState +import com.feragusper.smokeanalytics.features.settings.presentation.web.process.SettingsProcessHolder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -11,6 +13,12 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +/** + * Represents the store for the Settings screen. + * + * @property processHolder The process holder for the Settings screen. + * @property scope The coroutine scope for the store. + */ class SettingsWebStore( private val processHolder: SettingsProcessHolder, private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), @@ -18,12 +26,24 @@ class SettingsWebStore( private val intents = Channel(capacity = Channel.Factory.BUFFERED) private val _state = MutableStateFlow(SettingsViewState()) + + /** + * The current state of the Settings screen. + */ val state: StateFlow = _state.asStateFlow() + /** + * Sends an intent to the store. + * + * @param intent The intent to send. + */ fun send(intent: SettingsIntent) { intents.trySend(intent) } + /** + * Starts the store. + */ fun start() { scope.launch { intents diff --git a/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsProcessHolder.kt b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/process/SettingsProcessHolder.kt similarity index 71% rename from features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsProcessHolder.kt rename to features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/process/SettingsProcessHolder.kt index f13e9978..4e794414 100644 --- a/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsProcessHolder.kt +++ b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/process/SettingsProcessHolder.kt @@ -1,5 +1,7 @@ -package com.feragusper.smokeanalytics.features.settings.presentation.web +package com.feragusper.smokeanalytics.features.settings.presentation.web.process +import com.feragusper.smokeanalytics.features.settings.presentation.web.mvi.SettingsIntent +import com.feragusper.smokeanalytics.features.settings.presentation.web.mvi.SettingsResult import com.feragusper.smokeanalytics.libraries.authentication.domain.FetchSessionUseCase import com.feragusper.smokeanalytics.libraries.authentication.domain.Session import com.feragusper.smokeanalytics.libraries.authentication.domain.SignOutUseCase @@ -7,10 +9,23 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flow +/** + * Represents the process holder for the Settings screen. + * + * @property fetchSessionUseCase The use case for fetching the session. + * @property signOutUseCase The use case for signing out. + */ class SettingsProcessHolder( private val fetchSessionUseCase: FetchSessionUseCase, private val signOutUseCase: SignOutUseCase, ) { + + /** + * Processes the given intent and returns a flow of results. + * + * @param intent The intent to process. + * @return A flow of results. + */ fun processIntent(intent: SettingsIntent): Flow = when (intent) { SettingsIntent.FetchUser -> processFetchUser() SettingsIntent.SignOut -> processSignOut() diff --git a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/ChartJs.kt b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/ChartJs.kt index 1d675988..c3faca2f 100644 --- a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/ChartJs.kt +++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/ChartJs.kt @@ -5,9 +5,18 @@ package com.feragusper.smokeanalytics.features.stats.presentation.web import org.w3c.dom.CanvasRenderingContext2D +/** + * Represents the Chart class. + * + * @param ctx The 2D context of the canvas. + * @param config The configuration of the chart. + */ external class Chart( ctx: CanvasRenderingContext2D, config: dynamic, ) { + /** + * Destroys the chart. + */ fun destroy() } \ No newline at end of file diff --git a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsResult.kt b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsResult.kt deleted file mode 100644 index cbbe9883..00000000 --- a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsResult.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.feragusper.smokeanalytics.features.stats.presentation.web - -import com.feragusper.smokeanalytics.libraries.smokes.domain.model.SmokeStats - -sealed interface StatsResult { - data object Loading : StatsResult - data class Success(val stats: SmokeStats) : StatsResult - data class Error(val error: Throwable) : StatsResult -} \ No newline at end of file diff --git a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsViewState.kt b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsViewState.kt index 28481ebe..aa08cb62 100644 --- a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsViewState.kt +++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsViewState.kt @@ -2,12 +2,27 @@ package com.feragusper.smokeanalytics.features.stats.presentation.web import com.feragusper.smokeanalytics.libraries.smokes.domain.model.SmokeStats +/** + * Represents the state of the Stats screen. + * + * @property displayLoading Whether the loading indicator should be displayed. + * @property stats The stats. + * @property error The error. + */ data class StatsViewState( val displayLoading: Boolean = false, val stats: SmokeStats? = null, val error: StatsError? = null, ) { + + /** + * Represents the errors that can occur in the Stats screen. + */ sealed interface StatsError { + + /** + * Represents the generic error. + */ data object Generic : StatsError } } \ No newline at end of file diff --git a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebDependencies.kt b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebDependencies.kt index 69a0e5b0..29fd6f28 100644 --- a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebDependencies.kt +++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebDependencies.kt @@ -1,11 +1,24 @@ package com.feragusper.smokeanalytics.features.stats.presentation.web +import com.feragusper.smokeanalytics.features.stats.presentation.web.process.StatsProcessHolder import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokeStatsUseCase +/** + * Represents the dependencies for the Stats screen. + * + * @property processHolder The process holder for the Stats screen. + */ class StatsWebDependencies( val processHolder: StatsProcessHolder, ) +/** + * Creates the dependencies for the Stats screen. + * + * @param fetchSmokeStatsUseCase The use case for fetching the smoke stats. + * + * @return The dependencies for the Stats screen. + */ fun createStatsWebDependencies( fetchSmokeStatsUseCase: FetchSmokeStatsUseCase, ): StatsWebDependencies { diff --git a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebScreen.kt b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebScreen.kt index 1e25fc66..61bd9132 100644 --- a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebScreen.kt +++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebScreen.kt @@ -8,6 +8,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import com.feragusper.smokeanalytics.features.stats.presentation.web.mvi.StatsIntent +import com.feragusper.smokeanalytics.features.stats.presentation.web.mvi.StatsWebStore import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokeStatsUseCase import kotlinx.datetime.Clock import kotlinx.datetime.DateTimeUnit diff --git a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/canvas2dContext.kt b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/canvas2dContext.kt index 40a751a3..a8e13f2b 100644 --- a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/canvas2dContext.kt +++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/canvas2dContext.kt @@ -4,6 +4,13 @@ import kotlinx.browser.document import org.w3c.dom.CanvasRenderingContext2D import org.w3c.dom.HTMLCanvasElement +/** + * Returns the 2D context of the canvas with the given ID. + * + * @param canvasId The ID of the canvas. + * + * @return The 2D context of the canvas. + */ fun canvas2dContext(canvasId: String): CanvasRenderingContext2D { val canvas = document.getElementById(canvasId) as? HTMLCanvasElement ?: error("Canvas not found: $canvasId") diff --git a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsIntent.kt b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/mvi/StatsIntent.kt similarity index 51% rename from features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsIntent.kt rename to features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/mvi/StatsIntent.kt index c5960e2f..89dde0df 100644 --- a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsIntent.kt +++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/mvi/StatsIntent.kt @@ -1,8 +1,20 @@ -package com.feragusper.smokeanalytics.features.stats.presentation.web +package com.feragusper.smokeanalytics.features.stats.presentation.web.mvi import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokeStatsUseCase +/** + * Represents the intents that can be sent to the Stats screen. + */ sealed interface StatsIntent { + + /** + * Represents the intent to load the stats. + * + * @property year The year of the stats. + * @property month The month of the stats. + * @property day The day of the stats. + * @property period The period of the stats. + */ data class LoadStats( val year: Int, val month: Int, diff --git a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/mvi/StatsResult.kt b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/mvi/StatsResult.kt new file mode 100644 index 00000000..aaed0ff4 --- /dev/null +++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/mvi/StatsResult.kt @@ -0,0 +1,28 @@ +package com.feragusper.smokeanalytics.features.stats.presentation.web.mvi + +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.SmokeStats + +/** + * Represents the results that can be returned from the Stats screen. + */ +sealed interface StatsResult { + + /** + * Represents the result of a loading operation. + */ + data object Loading : StatsResult + + /** + * Represents the result of a successful fetch of the stats. + * + * @property stats The stats. + */ + data class Success(val stats: SmokeStats) : StatsResult + + /** + * Represents the result of a generic error. + * + * @property error The error. + */ + data class Error(val error: Throwable) : StatsResult +} \ No newline at end of file diff --git a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebStore.kt b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/mvi/StatsWebStore.kt similarity index 75% rename from features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebStore.kt rename to features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/mvi/StatsWebStore.kt index d1767d48..f408d63b 100644 --- a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebStore.kt +++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/mvi/StatsWebStore.kt @@ -1,5 +1,7 @@ -package com.feragusper.smokeanalytics.features.stats.presentation.web +package com.feragusper.smokeanalytics.features.stats.presentation.web.mvi +import com.feragusper.smokeanalytics.features.stats.presentation.web.StatsViewState +import com.feragusper.smokeanalytics.features.stats.presentation.web.process.StatsProcessHolder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -11,6 +13,12 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +/** + * Represents the store for the Stats screen. + * + * @property processHolder The process holder for the Stats screen. + * @property scope The coroutine scope for the store. + */ class StatsWebStore( private val processHolder: StatsProcessHolder, private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), @@ -18,12 +26,24 @@ class StatsWebStore( private val intents = Channel(capacity = Channel.Factory.BUFFERED) private val _state = MutableStateFlow(StatsViewState()) + + /** + * The current state of the Stats screen. + */ val state: StateFlow = _state.asStateFlow() + /** + * Sends an intent to the store. + * + * @param intent The intent to send. + */ fun send(intent: StatsIntent) { intents.trySend(intent) } + /** + * Starts the store. + */ fun start() { scope.launch { intents diff --git a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsProcessHolder.kt b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/process/StatsProcessHolder.kt similarity index 83% rename from features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsProcessHolder.kt rename to features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/process/StatsProcessHolder.kt index dfe28f14..0db73e23 100644 --- a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsProcessHolder.kt +++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/process/StatsProcessHolder.kt @@ -1,5 +1,7 @@ -package com.feragusper.smokeanalytics.features.stats.presentation.web +package com.feragusper.smokeanalytics.features.stats.presentation.web.process +import com.feragusper.smokeanalytics.features.stats.presentation.web.mvi.StatsIntent +import com.feragusper.smokeanalytics.features.stats.presentation.web.mvi.StatsResult import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokeStatsUseCase import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch diff --git a/libraries/architecture/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/architecture/presentation/MviStore.kt b/libraries/architecture/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/architecture/presentation/MviStore.kt index 666667a3..5d30f722 100644 --- a/libraries/architecture/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/architecture/presentation/MviStore.kt +++ b/libraries/architecture/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/architecture/presentation/MviStore.kt @@ -2,17 +2,41 @@ package com.feragusper.smokeanalytics.libraries.architecture.presentation import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.scan import kotlinx.coroutines.launch +/** + * Represents a MVI store. + * + * @param I The type of intents. + * @param S The type of states. + * @param R The type of results. + * @property scope The coroutine scope. + * @property initialState The initial state. + */ abstract class MviStore( private val scope: CoroutineScope, initialState: S, ) { private val intents = Channel(Channel.BUFFERED) private val _state = MutableStateFlow(initialState) + + /** + * The current state. + */ val state: StateFlow = _state.asStateFlow() + /** + * Dispatches an intent. + * + * @param intent The intent to dispatch. + */ fun dispatch(intent: I) { intents.trySend(intent) } @@ -20,6 +44,11 @@ abstract class MviStore( protected abstract fun transformer(intent: I): Flow protected abstract fun reducer(previous: S, result: R): S + /** + * Starts the store. + * + * @return The store. + */ fun start() { scope.launch { intents diff --git a/libraries/authentication/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImpl.kt b/libraries/authentication/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImpl.kt index 695f67f0..70ecef13 100644 --- a/libraries/authentication/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImpl.kt +++ b/libraries/authentication/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImpl.kt @@ -7,14 +7,25 @@ import dev.gitlive.firebase.auth.FirebaseAuth import dev.gitlive.firebase.auth.FirebaseUser import dev.gitlive.firebase.auth.auth +/** + * Represents an authentication repository. + * + * @property firebaseAuth The Firebase authentication. + */ class AuthenticationRepositoryImpl( private val firebaseAuth: FirebaseAuth = Firebase.auth, ) : AuthenticationRepository { + /** + * @see AuthenticationRepository.signOut + */ override suspend fun signOut() { firebaseAuth.signOut() } + /** + * @see AuthenticationRepository.fetchSession + */ override fun fetchSession(): Session = firebaseAuth.toSession() } diff --git a/libraries/authentication/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/AuthenticationRepository.kt b/libraries/authentication/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/AuthenticationRepository.kt index 477f992b..49443f31 100644 --- a/libraries/authentication/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/AuthenticationRepository.kt +++ b/libraries/authentication/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/AuthenticationRepository.kt @@ -1,6 +1,19 @@ package com.feragusper.smokeanalytics.libraries.authentication.domain +/** + * Represents an authentication repository. + */ interface AuthenticationRepository { + + /** + * Signs out the current user. + */ suspend fun signOut() + + /** + * Fetches the current session. + * + * @return The current session. + */ fun fetchSession(): Session } \ No newline at end of file diff --git a/libraries/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/presentation/compose/GoogleSignInComponentWeb.kt b/libraries/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/presentation/compose/GoogleSignInComponentWeb.kt index b04b2b03..c98f3717 100644 --- a/libraries/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/presentation/compose/GoogleSignInComponentWeb.kt +++ b/libraries/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/presentation/compose/GoogleSignInComponentWeb.kt @@ -17,6 +17,12 @@ import org.jetbrains.compose.web.attributes.disabled import org.jetbrains.compose.web.dom.Button import org.jetbrains.compose.web.dom.Text +/** + * Represents a component for signing in with Google. + * + * @param onSignInSuccess The callback to invoke when the sign in is successful. + * @param onSignInError The callback to invoke when the sign in fails. + */ @Composable fun GoogleSignInComponentWeb( onSignInSuccess: () -> Unit, diff --git a/libraries/logging/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/logging /AppLogger.kt b/libraries/logging/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/logging /AppLogger.kt index e1c01d35..d0eb23bb 100644 --- a/libraries/logging/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/logging /AppLogger.kt +++ b/libraries/logging/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/logging /AppLogger.kt @@ -2,12 +2,38 @@ package com.feragusper.smokeanalytics.libraries.logging import co.touchlab.kermit.Logger +/** + * Represents an app logger. + */ object AppLogger { private val log = Logger.withTag("SmokeAnalytics") + /** + * Logs a debug message. + * + * @param message The message to log. + */ fun d(message: () -> String) = log.d(message()) + + /** + * Logs an info message. + * + * @param message The message to log. + */ fun i(message: () -> String) = log.i(message()) + + /** + * Logs a warning message. + * + * @param message The message to log. + */ fun w(message: () -> String) = log.w(message()) + + /** + * Logs an error message. + * + * @param message The message to log. + */ fun e(message: () -> String) = log.e(message()) } \ No newline at end of file diff --git a/libraries/smokes/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeEntity.kt b/libraries/smokes/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeEntity.kt index cfa729a6..e1d8b096 100644 --- a/libraries/smokes/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeEntity.kt +++ b/libraries/smokes/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeEntity.kt @@ -3,15 +3,18 @@ package com.feragusper.smokeanalytics.libraries.smokes.data import kotlinx.serialization.Serializable /** - * Firestore entity for a smoke event. + * Represents a smoke entity. * - * Schema (Android + Web): - * - timestampMillis: Double (epoch millis) + * @property timestampMillis The timestamp of the smoke. */ @Serializable data class SmokeEntity( val timestampMillis: Double = 0.0 ) { + + /** + * Represents the fields of the smoke entity. + */ object Fields { const val TIMESTAMP_MILLIS = "timestampMillis" } diff --git a/libraries/smokes/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImpl.kt b/libraries/smokes/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImpl.kt index 8c2cd09b..6c25fc15 100644 --- a/libraries/smokes/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImpl.kt +++ b/libraries/smokes/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImpl.kt @@ -18,11 +18,20 @@ import dev.gitlive.firebase.firestore.FirebaseFirestore import dev.gitlive.firebase.firestore.firestore import kotlinx.datetime.Instant +/** + * Represents a smoke repository. + * + * @property firebaseFirestore The Firebase Firestore. + * @property firebaseAuth The Firebase Auth. + */ class SmokeRepositoryImpl( private val firebaseFirestore: FirebaseFirestore = Firebase.firestore, private val firebaseAuth: FirebaseAuth = Firebase.auth, ) : SmokeRepository { + /** + * Represents the Firestore collection. + */ interface FirestoreCollection { companion object { const val USERS = "users" @@ -30,24 +39,36 @@ class SmokeRepositoryImpl( } } + /** + * @see SmokeRepository.addSmoke + */ override suspend fun addSmoke(date: Instant) { smokesCollection().add( SmokeEntity(timestampMillis = date.toEpochMilliseconds().toDouble()) ) } + /** + * @see SmokeRepository.editSmoke + */ override suspend fun editSmoke(id: String, date: Instant) { smokesCollection() .document(id) .set(SmokeEntity(timestampMillis = date.toEpochMilliseconds().toDouble())) } + /** + * @see SmokeRepository.deleteSmoke + */ override suspend fun deleteSmoke(id: String) { smokesCollection() .document(id) .delete() } + /** + * @see SmokeRepository.fetchSmokes + */ override suspend fun fetchSmokes( start: Instant?, end: Instant?, @@ -79,6 +100,9 @@ class SmokeRepositoryImpl( } } + /** + * @see SmokeRepository.fetchSmokeCount + */ override suspend fun fetchSmokeCount(): SmokeCount { return fetchSmokes().toSmokeCountListResult() } diff --git a/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/repository/SmokeRepository.kt b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/repository/SmokeRepository.kt index 616b19fc..564625d2 100644 --- a/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/repository/SmokeRepository.kt +++ b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/repository/SmokeRepository.kt @@ -1,22 +1,53 @@ -// commonMain package com.feragusper.smokeanalytics.libraries.smokes.domain.repository import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke import com.feragusper.smokeanalytics.libraries.smokes.domain.model.SmokeCount import kotlinx.datetime.Instant +/** + * Represents a smoke repository. + */ interface SmokeRepository { + /** + * Adds a smoke. + * + * @param timestamp The timestamp of the smoke. + */ suspend fun addSmoke(timestamp: Instant) + /** + * Fetches the smokes. + * + * @param start The start date. + * @param end The end date. + * + * @return The smokes. + */ suspend fun fetchSmokes( start: Instant? = null, end: Instant? = null, ): List + /** + * Fetches the smoke count. + * + * @return The smoke count. + */ suspend fun fetchSmokeCount(): SmokeCount + /** + * Edits a smoke. + * + * @param id The id of the smoke. + * @param timestamp The timestamp of the smoke. + */ suspend fun editSmoke(id: String, timestamp: Instant) + /** + * Deletes a smoke. + * + * @param id The id of the smoke. + */ suspend fun deleteSmoke(id: String) } \ No newline at end of file From c331414395e0be9bd1d14309b162290a21f8703b Mon Sep 17 00:00:00 2001 From: feragusper Date: Mon, 5 Jan 2026 10:02:15 +0100 Subject: [PATCH 11/12] refactor(tests): reorganize test files and update dependencies for improved structure and functionality --- .../presentation/mobile/build.gradle.kts | 11 +- features/chatbot/data/build.gradle.kts | 3 + .../chatbot/data/ChatbotRepositoryImplTest.kt | 49 +-- features/chatbot/domain/build.gradle.kts | 8 +- .../chatbot/domain/ChatbotUseCaseTest.kt | 0 .../chatbot/presentation/build.gradle.kts | 4 + .../devtools/presentation/build.gradle.kts | 4 + .../presentation/mobile/build.gradle.kts | 4 + .../presentation/HistoryViewModelTest.kt | 289 ------------------ .../process/HistoryProcessHolderTest.kt | 26 +- features/home/domain/build.gradle.kts | 11 +- .../domain/FetchSmokeCountListUseCaseTest.kt | 0 .../home/domain/SmokeCountListResultTest.kt | 0 .../home/presentation/mobile/build.gradle.kts | 4 + .../home/presentation/HomeViewModelTest.kt | 145 +-------- .../process/HomeProcessHolderTest.kt | 37 ++- .../presentation/mobile/build.gradle.kts | 4 + .../presentation/mobile/build.gradle.kts | 4 + .../stats/presentation/StatsViewModelTest.kt | 22 +- .../process/StatsProcessHolderTest.kt | 1 + gradle.properties | 3 +- gradle/libs.versions.toml | 3 +- .../architecture/common/build.gradle.kts | 3 + .../extensions/LocalDateTimeExtensionsTest.kt | 82 ----- .../data/mobile/build.gradle.kts | 3 + .../data/AuthenticationRepositoryImplTest.kt | 18 +- .../authentication/domain/build.gradle.kts | 11 +- .../domain/FetchSessionUseCaseTest.kt | 43 +++ .../domain/SignOutUseCaseTest.kt | 29 ++ .../domain/FetchSessionUseCaseTest.kt | 68 ----- .../domain/SignOutUseCaseTest.kt | 27 -- libraries/smokes/data/mobile/build.gradle.kts | 3 + .../smokes/data/SmokeRepositoryImplTest.kt | 73 +++-- libraries/smokes/domain/build.gradle.kts | 3 +- .../libraries/smokes/domain/model/Smoke.kt | 8 +- .../smokes/domain/model/SmokeStatsTest.kt | 153 ++++++++++ .../domain/usecase/AddSmokeUseCaseTest.kt | 71 +++++ .../domain/usecase/DeleteSmokeUseCaseTest.kt | 53 ++++ .../domain/usecase/EditSmokeUseCaseTest.kt | 59 ++++ .../usecase/FetchSmokeStatsUseCaseTest.kt | 95 ++++++ .../domain/usecase/FetchSmokesUseCaseTest.kt | 67 ++++ .../smokes/domain/model/SmokeStatsTest.kt | 136 --------- .../domain/usecase/AddSmokeUseCaseTest.kt | 61 ---- .../domain/usecase/DeleteSmokeUseCaseTest.kt | 45 --- .../domain/usecase/EditSmokeUseCaseTest.kt | 47 --- .../usecase/FetchSmokeStatsUseCaseTest.kt | 123 -------- .../domain/usecase/FetchSmokesUseCaseTest.kt | 39 --- .../domain/usecase/SyncWithWearUseCaseTest.kt | 41 --- 48 files changed, 781 insertions(+), 1212 deletions(-) rename features/chatbot/domain/src/{test => commonTest}/java/com/feragusper/smokeanalytics/features/chatbot/domain/ChatbotUseCaseTest.kt (100%) delete mode 100644 features/history/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewModelTest.kt rename features/home/domain/src/{test => commonTest}/java/com/feragusper/smokeanalytics/features/home/domain/FetchSmokeCountListUseCaseTest.kt (100%) rename features/home/domain/src/{test => commonTest}/java/com/feragusper/smokeanalytics/features/home/domain/SmokeCountListResultTest.kt (100%) delete mode 100644 libraries/architecture/domain/src/test/java/com/feragusper/smokeanalytics/libraries/architecture/domain/extensions/LocalDateTimeExtensionsTest.kt create mode 100644 libraries/authentication/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/FetchSessionUseCaseTest.kt create mode 100644 libraries/authentication/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/SignOutUseCaseTest.kt delete mode 100644 libraries/authentication/domain/src/test/java/com/feragusper/smokeanalytics/libraries/authentication/domain/FetchSessionUseCaseTest.kt delete mode 100644 libraries/authentication/domain/src/test/java/com/feragusper/smokeanalytics/libraries/authentication/domain/SignOutUseCaseTest.kt create mode 100644 libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeStatsTest.kt create mode 100644 libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/AddSmokeUseCaseTest.kt create mode 100644 libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/DeleteSmokeUseCaseTest.kt create mode 100644 libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/EditSmokeUseCaseTest.kt create mode 100644 libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokeStatsUseCaseTest.kt create mode 100644 libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokesUseCaseTest.kt delete mode 100644 libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeStatsTest.kt delete mode 100644 libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/AddSmokeUseCaseTest.kt delete mode 100644 libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/DeleteSmokeUseCaseTest.kt delete mode 100644 libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/EditSmokeUseCaseTest.kt delete mode 100644 libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokeStatsUseCaseTest.kt delete mode 100644 libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokesUseCaseTest.kt delete mode 100644 libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/SyncWithWearUseCaseTest.kt diff --git a/features/authentication/presentation/mobile/build.gradle.kts b/features/authentication/presentation/mobile/build.gradle.kts index e4c7d751..7d50286d 100644 --- a/features/authentication/presentation/mobile/build.gradle.kts +++ b/features/authentication/presentation/mobile/build.gradle.kts @@ -48,7 +48,16 @@ dependencies { kapt(libs.hilt.compiler) // Unit testing dependencies - testImplementation(libs.bundles.test) + testImplementation(platform(libs.junit.bom)) + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + + // resto del bundle + testImplementation(libs.mockk) + testImplementation(libs.coroutines.test) + testImplementation(libs.kluent) + testImplementation(libs.app.cash.turbine) // Debug-specific dependencies for Compose UI tooling debugImplementation(libs.bundles.compose.debug) diff --git a/features/chatbot/data/build.gradle.kts b/features/chatbot/data/build.gradle.kts index d4f9d504..02b9d96d 100644 --- a/features/chatbot/data/build.gradle.kts +++ b/features/chatbot/data/build.gradle.kts @@ -45,4 +45,7 @@ dependencies { // Unit testing dependencies. testImplementation(platform(libs.junit.bom)) testImplementation(libs.bundles.test) + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) } diff --git a/features/chatbot/data/src/test/java/com/feragusper/smokeanalytics/features/chatbot/data/ChatbotRepositoryImplTest.kt b/features/chatbot/data/src/test/java/com/feragusper/smokeanalytics/features/chatbot/data/ChatbotRepositoryImplTest.kt index 3c688915..1c5470c2 100644 --- a/features/chatbot/data/src/test/java/com/feragusper/smokeanalytics/features/chatbot/data/ChatbotRepositoryImplTest.kt +++ b/features/chatbot/data/src/test/java/com/feragusper/smokeanalytics/features/chatbot/data/ChatbotRepositoryImplTest.kt @@ -1,5 +1,6 @@ package com.feragusper.smokeanalytics.features.chatbot.data +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke import com.google.ai.client.generativeai.GenerativeModel import com.google.ai.client.generativeai.type.GenerateContentResponse import io.mockk.coEvery @@ -7,10 +8,13 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Clock +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.Instant +import kotlinx.datetime.minus import org.amshove.kluent.shouldBeEqualTo import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import java.time.LocalDateTime @OptIn(ExperimentalCoroutinesApi::class) class ChatbotRepositoryImplTest { @@ -25,7 +29,6 @@ class ChatbotRepositoryImplTest { @Test fun `sendMessage should return generated text from Gemini`() = runTest { - // Given val userPrompt = "Hola" val responseText = "Ā”Hola! ĀæCómo estĆ”s hoy?" @@ -35,38 +38,24 @@ class ChatbotRepositoryImplTest { coEvery { gemini.generateContent(userPrompt) } returns response - // When val result = repository.sendMessage(userPrompt) - // Then result shouldBeEqualTo responseText } - @Test - fun `sendMessage should return fallback text when exception is thrown`() = runTest { - // Given - val prompt = "Hola" - coEvery { gemini.generateContent(prompt) } throws RuntimeException("error") - - // When - val result = repository.sendMessage(prompt) - - // Then - result shouldBeEqualTo "Ups, el coach tuvo un mal dĆ­a y no pudo responder." - } - @Test fun `sendInitialMessageWithContext should build and send correct prompt`() = runTest { - // Given val name = "Fer" - val now = LocalDateTime.now() - val smokes = List(10) { + val now: Instant = Clock.System.now() + + val smokes = List(10) { index -> Smoke( - id = "$it", - date = now.minusMinutes(it.toLong()), + id = "$index", + date = now.minus(index.toLong(), DateTimeUnit.MINUTE), timeElapsedSincePreviousSmoke = 5L to 0L ) } + val expectedResponse = "Ā”Vamos Fer! EstĆ”s avanzando." val response: GenerateContentResponse = mockk { @@ -75,25 +64,9 @@ class ChatbotRepositoryImplTest { coEvery { gemini.generateContent(any()) } returns response - // When val result = repository.sendInitialMessageWithContext(name, smokes) - // Then result shouldBeEqualTo expectedResponse } - @Test - fun `sendInitialMessageWithContext should return fallback text on error`() = runTest { - // Given - val name = "Fer" - val smokes = emptyList() - - coEvery { gemini.generateContent(any()) } throws RuntimeException("fail") - - // When - val result = repository.sendInitialMessageWithContext(name, smokes) - - // Then - result shouldBeEqualTo "No se pudo generar un mensaje motivacional. ProbĆ” mĆ”s tarde." - } } \ No newline at end of file diff --git a/features/chatbot/domain/build.gradle.kts b/features/chatbot/domain/build.gradle.kts index f60387b1..48027074 100644 --- a/features/chatbot/domain/build.gradle.kts +++ b/features/chatbot/domain/build.gradle.kts @@ -1,4 +1,4 @@ -plugins { id("kmp-lib") } // tu convención KMP (jvm + wasmJs + kover/sonar) +plugins { id("kmp-lib") } kotlin { sourceSets { @@ -17,12 +17,13 @@ kotlin { } val commonTest by getting { - dependencies { implementation(kotlin("test")) } + dependencies { + implementation(kotlin("test")) + } } val jvmMain by getting { dependencies { - // Si necesitĆ”s @Inject del lado JVM: implementation(libs.javax.inject) implementation(project(":libraries:smokes:domain")) } @@ -38,7 +39,6 @@ kotlin { implementation(libs.app.cash.turbine) } } - // wasmJsMain / wasmJsTest quedan vacĆ­os por ahora } } diff --git a/features/chatbot/domain/src/test/java/com/feragusper/smokeanalytics/features/chatbot/domain/ChatbotUseCaseTest.kt b/features/chatbot/domain/src/commonTest/java/com/feragusper/smokeanalytics/features/chatbot/domain/ChatbotUseCaseTest.kt similarity index 100% rename from features/chatbot/domain/src/test/java/com/feragusper/smokeanalytics/features/chatbot/domain/ChatbotUseCaseTest.kt rename to features/chatbot/domain/src/commonTest/java/com/feragusper/smokeanalytics/features/chatbot/domain/ChatbotUseCaseTest.kt diff --git a/features/chatbot/presentation/build.gradle.kts b/features/chatbot/presentation/build.gradle.kts index ec8d0b05..9763c7b6 100644 --- a/features/chatbot/presentation/build.gradle.kts +++ b/features/chatbot/presentation/build.gradle.kts @@ -54,5 +54,9 @@ dependencies { implementation(libs.vico.views) // Unit testing dependencies + testImplementation(platform(libs.junit.bom)) testImplementation(libs.bundles.test) + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) } diff --git a/features/devtools/presentation/build.gradle.kts b/features/devtools/presentation/build.gradle.kts index da559e6c..07b06a3f 100644 --- a/features/devtools/presentation/build.gradle.kts +++ b/features/devtools/presentation/build.gradle.kts @@ -50,7 +50,11 @@ dependencies { kapt(libs.hilt.compiler) // Unit testing dependencies + testImplementation(platform(libs.junit.bom)) testImplementation(libs.bundles.test) + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) // Debug-specific dependencies for Compose UI tooling debugImplementation(libs.bundles.compose.debug) diff --git a/features/history/presentation/mobile/build.gradle.kts b/features/history/presentation/mobile/build.gradle.kts index 2454d9bc..e849bb19 100644 --- a/features/history/presentation/mobile/build.gradle.kts +++ b/features/history/presentation/mobile/build.gradle.kts @@ -59,7 +59,11 @@ dependencies { implementation(libs.compose.shimmer) // Unit testing dependencies + testImplementation(platform(libs.junit.bom)) testImplementation(libs.bundles.test) + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) // Debug-specific dependencies for Compose UI tooling debugImplementation(libs.bundles.compose.debug) diff --git a/features/history/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewModelTest.kt b/features/history/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewModelTest.kt deleted file mode 100644 index 73c0b276..00000000 --- a/features/history/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewModelTest.kt +++ /dev/null @@ -1,289 +0,0 @@ -package com.feragusper.smokeanalytics.features.history.presentation - -import app.cash.turbine.test -import com.feragusper.smokeanalytics.features.history.presentation.mvi.HistoryIntent -import com.feragusper.smokeanalytics.features.history.presentation.mvi.HistoryResult -import com.feragusper.smokeanalytics.features.history.presentation.process.HistoryProcessHolder -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkStatic -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.amshove.kluent.shouldBeEqualTo -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import java.time.LocalDateTime - -@OptIn(ExperimentalCoroutinesApi::class) -class HistoryViewModelTest { - - private var processHolder: HistoryProcessHolder = mockk() - private val intentResults = MutableStateFlow(HistoryResult.Loading) - - @BeforeEach - fun setUp() { - Dispatchers.setMain(Dispatchers.Unconfined) - val date: LocalDateTime = mockk() - mockkStatic(LocalDateTime::class) - every { LocalDateTime.now() } returns date - every { processHolder.processIntent(HistoryIntent.FetchSmokes(date)) } returns intentResults - } - - @AfterEach - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `GIVEN fetch smokes result WHEN viewmodel is created THEN it shows smoke list`() = runTest { - val date: LocalDateTime = mockk() - val smokeList: List = listOf(mockk()) - - every { processHolder.processIntent(HistoryIntent.FetchSmokes(date)) } returns intentResults - - val viewModel = HistoryViewModel(processHolder) - - viewModel.states().test { - // Ensure that the initial state shows loading - awaitItem().displayLoading shouldBeEqualTo true - - // Emit success result - intentResults.emit(HistoryResult.FetchSmokesSuccess(date, smokeList)) - - // Verify final state - awaitItem().apply { - displayLoading shouldBeEqualTo false - error shouldBeEqualTo null - smokes shouldBeEqualTo smokeList - selectedDate shouldBeEqualTo date - } - } - } - - @Test - fun `GIVEN fetch smokes error WHEN viewmodel is created THEN it shows fetch smokes error`() = - runTest { - val date: LocalDateTime = mockk() - - every { processHolder.processIntent(HistoryIntent.FetchSmokes(date)) } returns intentResults - - val viewModel = HistoryViewModel(processHolder) - - viewModel.states().test { - // Ensure that the initial state shows loading - awaitItem().displayLoading shouldBeEqualTo true - - // Emit error result - intentResults.emit(HistoryResult.FetchSmokesError) - - // Verify final state - awaitItem().apply { - displayLoading shouldBeEqualTo false - error shouldBeEqualTo HistoryResult.Error.Generic - } - } - } - - @Test - fun `GIVEN add smoke success and fetch smokes success WHEN add smoke is sent THEN it hides loading and shows success`() = - runTest { - val date: LocalDateTime = mockk() - val smokeList: List = listOf(mockk()) - - every { processHolder.processIntent(HistoryIntent.AddSmoke(date)) } returns intentResults - - val viewModel = HistoryViewModel(processHolder) - - viewModel.intents().trySend(HistoryIntent.AddSmoke(date)) - - viewModel.states().test { - // Expect initial loading state - awaitItem().displayLoading shouldBeEqualTo true - - // Emit AddSmokeSuccess - intentResults.emit(HistoryResult.AddSmokeSuccess) - - // Emit FetchSmokesSuccess - intentResults.emit( - HistoryResult.FetchSmokesSuccess( - smokes = smokeList, - selectedDate = date - ) - ) - - // Expect final state with updated smoke list and no loading - awaitItem().let { - it.displayLoading shouldBeEqualTo false - it.error shouldBeEqualTo null - it.smokes shouldBeEqualTo smokeList - it.selectedDate shouldBeEqualTo date - } - } - } - - @Test - fun `GIVEN edit smoke success and fetch smokes success WHEN edit smoke is sent THEN it hides loading and shows success`() = - runTest { - val id = "123" - val date: LocalDateTime = mockk() - val smokeList: List = listOf(mockk()) - - every { - processHolder.processIntent(HistoryIntent.EditSmoke(id, date)) - } returns intentResults - - val viewModel = HistoryViewModel(processHolder) - - viewModel.intents().trySend(HistoryIntent.EditSmoke(id, date)) - - viewModel.states().test { - // Expect initial loading state - awaitItem().displayLoading shouldBeEqualTo true - - // Emit EditSmokeSuccess - intentResults.emit(HistoryResult.EditSmokeSuccess) - - // Emit FetchSmokesSuccess - intentResults.emit( - HistoryResult.FetchSmokesSuccess( - smokes = smokeList, - selectedDate = date - ) - ) - - // Expect final state with updated smoke list and no loading - awaitItem().let { - it.displayLoading shouldBeEqualTo false - it.error shouldBeEqualTo null - it.smokes shouldBeEqualTo smokeList - it.selectedDate shouldBeEqualTo date - } - } - } - - @Test - fun `GIVEN delete smoke success and fetch smokes success WHEN delete smoke is sent THEN it hides loading and shows success`() = - runTest { - val id = "123" - val date: LocalDateTime = mockk() - val smokeList: List = listOf(mockk()) - - every { processHolder.processIntent(HistoryIntent.DeleteSmoke(id)) } returns intentResults - - val viewModel = HistoryViewModel(processHolder) - - viewModel.intents().trySend(HistoryIntent.DeleteSmoke(id)) - - viewModel.states().test { - // Expect initial loading state - awaitItem().displayLoading shouldBeEqualTo true - - // Emit DeleteSmokeSuccess - intentResults.emit(HistoryResult.DeleteSmokeSuccess) - - // Emit FetchSmokesSuccess - intentResults.emit( - HistoryResult.FetchSmokesSuccess( - smokes = smokeList, - selectedDate = date - ) - ) - - // Expect final state with updated smoke list and no loading - awaitItem().let { - it.displayLoading shouldBeEqualTo false - it.error shouldBeEqualTo null - it.smokes shouldBeEqualTo smokeList - it.selectedDate shouldBeEqualTo date - } - } - } - - @Test - fun `GIVEN add smoke error result WHEN add smoke is sent THEN it hides loading and shows error`() = - runTest { - val date: LocalDateTime = mockk() - - every { processHolder.processIntent(HistoryIntent.AddSmoke(date)) } returns intentResults - - val viewModel = HistoryViewModel(processHolder) - - viewModel.intents().trySend(HistoryIntent.AddSmoke(date)) - - viewModel.states().test { - // Expect initial loading state - awaitItem().displayLoading shouldBeEqualTo true - - // Emit Error.Generic - intentResults.emit(HistoryResult.Error.Generic) - - // Expect final state with error set and no loading - awaitItem().let { - it.displayLoading shouldBeEqualTo false - it.error shouldBeEqualTo HistoryResult.Error.Generic - } - } - } - - @Test - fun `GIVEN edit smoke error result WHEN edit smoke is sent THEN it hides loading and shows error`() = - runTest { - val id = "123" - val date: LocalDateTime = mockk() - - every { - processHolder.processIntent( - HistoryIntent.EditSmoke(id, date) - ) - } returns intentResults - - val viewModel = HistoryViewModel(processHolder) - - viewModel.intents().trySend(HistoryIntent.EditSmoke(id, date)) - - viewModel.states().test { - // Expect initial loading state - awaitItem().displayLoading shouldBeEqualTo true - - // Emit Error.Generic - intentResults.emit(HistoryResult.Error.Generic) - - // Expect final state with error set and no loading - awaitItem().let { - it.displayLoading shouldBeEqualTo false - it.error shouldBeEqualTo HistoryResult.Error.Generic - } - } - } - - @Test - fun `GIVEN delete smoke error result WHEN delete smoke is sent THEN it hides loading and shows error`() = - runTest { - val id = "123" - every { processHolder.processIntent(HistoryIntent.DeleteSmoke(id)) } returns intentResults - - val viewModel = HistoryViewModel(processHolder) - - viewModel.intents().trySend(HistoryIntent.DeleteSmoke(id)) - - viewModel.states().test { - // Expect initial loading state - awaitItem().displayLoading shouldBeEqualTo true - - // Emit error - intentResults.emit(HistoryResult.Error.Generic) - - // Expect final state with error set and no loading - awaitItem().let { - it.displayLoading shouldBeEqualTo false - it.error shouldBeEqualTo HistoryResult.Error.Generic - } - } - } - -} diff --git a/features/history/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/process/HistoryProcessHolderTest.kt b/features/history/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/process/HistoryProcessHolderTest.kt index a1e44f66..2f20da43 100644 --- a/features/history/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/process/HistoryProcessHolderTest.kt +++ b/features/history/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/process/HistoryProcessHolderTest.kt @@ -5,6 +5,10 @@ import com.feragusper.smokeanalytics.features.history.presentation.mvi.HistoryIn import com.feragusper.smokeanalytics.features.history.presentation.mvi.HistoryResult import com.feragusper.smokeanalytics.libraries.authentication.domain.FetchSessionUseCase import com.feragusper.smokeanalytics.libraries.authentication.domain.Session +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.AddSmokeUseCase +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.DeleteSmokeUseCase +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.EditSmokeUseCase +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokesUseCase import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.SyncWithWearUseCase import io.mockk.Runs import io.mockk.coEvery @@ -14,20 +18,24 @@ import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant import org.amshove.kluent.shouldBe import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test -import java.time.LocalDateTime @OptIn(ExperimentalCoroutinesApi::class) class HistoryProcessHolderTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var processHolder: HistoryProcessHolder private lateinit var results: Flow @@ -40,7 +48,8 @@ class HistoryProcessHolderTest { @BeforeEach fun setUp() { - Dispatchers.setMain(Dispatchers.Unconfined) + Dispatchers.setMain(testDispatcher) + processHolder = HistoryProcessHolder( addSmokeUseCase, editSmokeUseCase, @@ -50,7 +59,6 @@ class HistoryProcessHolderTest { syncWithWearUseCase ) - // Default mock behavior coEvery { syncWithWearUseCase.invoke() } just Runs } @@ -70,7 +78,7 @@ class HistoryProcessHolderTest { @Test fun `WHEN adding smoke THEN returns Loading, Success and syncs with Wear`() = runTest { - val date: LocalDateTime = mockk() + val date: Instant = Clock.System.now() coEvery { addSmokeUseCase(date) } just Runs results = processHolder.processIntent(HistoryIntent.AddSmoke(date)) @@ -86,7 +94,7 @@ class HistoryProcessHolderTest { @Test fun `WHEN editing smoke THEN returns Loading, Success and syncs with Wear`() = runTest { val id = "id" - val date: LocalDateTime = mockk() + val date: Instant = Clock.System.now() coEvery { editSmokeUseCase(id, date) } just Runs results = processHolder.processIntent(HistoryIntent.EditSmoke(id, date)) @@ -126,14 +134,16 @@ class HistoryProcessHolderTest { @Test fun `WHEN adding smoke THEN returns NotLoggedIn Error and GoToAuthentication`() = runTest { - results = processHolder.processIntent(HistoryIntent.AddSmoke(mockk())) + val date: Instant = Clock.System.now() + + results = processHolder.processIntent(HistoryIntent.AddSmoke(date)) results.test { awaitItem() shouldBe HistoryResult.Error.NotLoggedIn awaitItem() shouldBe HistoryResult.GoToAuthentication - coVerify(exactly = 0) { syncWithWearUseCase.invoke() } // Ensure sync is not called + coVerify(exactly = 0) { syncWithWearUseCase.invoke() } cancelAndIgnoreRemainingEvents() } } } -} +} \ No newline at end of file diff --git a/features/home/domain/build.gradle.kts b/features/home/domain/build.gradle.kts index c97f05ec..70f5cd0c 100644 --- a/features/home/domain/build.gradle.kts +++ b/features/home/domain/build.gradle.kts @@ -1,4 +1,4 @@ -plugins { id("kmp-lib") } // tu convención: jvm + wasmJs + kover/sonar +plugins { id("kmp-lib") } kotlin { sourceSets { @@ -6,7 +6,6 @@ kotlin { dependencies { implementation(project(":libraries:architecture:domain")) implementation(project(":libraries:smokes:domain")) - // ā›”ļø No metas javax.inject acĆ” (es JVM-only) } } @@ -17,12 +16,13 @@ kotlin { } val commonTest by getting { - dependencies { implementation(kotlin("test")) } + dependencies { + implementation(kotlin("test")) + } } val jvmMain by getting { dependencies { - // Si usĆ”s @Inject en constructores del dominio: implementation(libs.javax.inject) } } @@ -38,12 +38,9 @@ kotlin { implementation(libs.app.cash.turbine) } } - - // wasmJsMain / wasmJsTest se quedan vacĆ­os por ahora } } -// (Opcional) Si venĆ­as usando BOM de JUnit: dependencies { add("jvmTestImplementation", platform(libs.junit.bom)) } diff --git a/features/home/domain/src/test/java/com/feragusper/smokeanalytics/features/home/domain/FetchSmokeCountListUseCaseTest.kt b/features/home/domain/src/commonTest/java/com/feragusper/smokeanalytics/features/home/domain/FetchSmokeCountListUseCaseTest.kt similarity index 100% rename from features/home/domain/src/test/java/com/feragusper/smokeanalytics/features/home/domain/FetchSmokeCountListUseCaseTest.kt rename to features/home/domain/src/commonTest/java/com/feragusper/smokeanalytics/features/home/domain/FetchSmokeCountListUseCaseTest.kt diff --git a/features/home/domain/src/test/java/com/feragusper/smokeanalytics/features/home/domain/SmokeCountListResultTest.kt b/features/home/domain/src/commonTest/java/com/feragusper/smokeanalytics/features/home/domain/SmokeCountListResultTest.kt similarity index 100% rename from features/home/domain/src/test/java/com/feragusper/smokeanalytics/features/home/domain/SmokeCountListResultTest.kt rename to features/home/domain/src/commonTest/java/com/feragusper/smokeanalytics/features/home/domain/SmokeCountListResultTest.kt diff --git a/features/home/presentation/mobile/build.gradle.kts b/features/home/presentation/mobile/build.gradle.kts index 558553d4..762eed56 100644 --- a/features/home/presentation/mobile/build.gradle.kts +++ b/features/home/presentation/mobile/build.gradle.kts @@ -58,7 +58,11 @@ dependencies { implementation(libs.compose.shimmer) // Unit testing dependencies + testImplementation(platform(libs.junit.bom)) testImplementation(libs.bundles.test) + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) // Debug-specific dependencies for Compose UI tooling debugImplementation(libs.bundles.compose.debug) diff --git a/features/home/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewModelTest.kt b/features/home/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewModelTest.kt index 4912a99d..955a4ca6 100644 --- a/features/home/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewModelTest.kt +++ b/features/home/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewModelTest.kt @@ -1,7 +1,6 @@ package com.feragusper.smokeanalytics.features.home.presentation import app.cash.turbine.test -import com.feragusper.smokeanalytics.features.home.domain.SmokeCountListResult import com.feragusper.smokeanalytics.features.home.presentation.mvi.HomeIntent import com.feragusper.smokeanalytics.features.home.presentation.mvi.HomeResult import com.feragusper.smokeanalytics.features.home.presentation.process.HomeProcessHolder @@ -10,101 +9,43 @@ import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant import org.amshove.kluent.shouldBeEqualTo +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import java.time.LocalDateTime @OptIn(ExperimentalCoroutinesApi::class) class HomeViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var viewModel: HomeViewModel private val processHolder: HomeProcessHolder = mockk() private val intentResults = MutableStateFlow(HomeResult.Loading) @BeforeEach fun setUp() { - Dispatchers.setMain(Dispatchers.Unconfined) + Dispatchers.setMain(testDispatcher) every { processHolder.processIntent(HomeIntent.FetchSmokes) } returns intentResults viewModel = HomeViewModel(processHolder) } - @Test - fun `GIVEN fetch smokes result WHEN viewmodel is created THEN it shows smoke counts`() = - runTest { - val hours = 20L - val minutes = 10L - val smokesPerDay = 1 - val smokesPerWeek = 2 - val smokesPerMonth = 3 - val latestSmokes: List = listOf(mockk()) - - intentResults.emit( - HomeResult.FetchSmokesSuccess( - mockk().apply { - every { countByToday } returns smokesPerDay - every { countByWeek } returns smokesPerWeek - every { countByMonth } returns smokesPerMonth - every { todaysSmokes } returns latestSmokes - every { timeSinceLastCigarette } returns (hours to minutes) - } - ) - ) - - viewModel.states().test { - awaitItem().apply { - displayLoading shouldBeEqualTo false - error shouldBeEqualTo null - smokesPerDay shouldBeEqualTo smokesPerDay - smokesPerWeek shouldBeEqualTo smokesPerWeek - smokesPerMonth shouldBeEqualTo smokesPerMonth - latestSmokes shouldBeEqualTo latestSmokes - timeSinceLastCigarette shouldBeEqualTo (hours to minutes) - } - } - } - - @Test - fun `GIVEN update time since last cigarette result WHEN emitted THEN it updates the time`() = - runTest { - val timeSinceLastCigarette: Pair = mockk() - intentResults.emit(HomeResult.UpdateTimeSinceLastCigarette(timeSinceLastCigarette)) - - viewModel.states().test { - awaitItem().timeSinceLastCigarette shouldBeEqualTo timeSinceLastCigarette - } - } - - @Test - fun `GIVEN fetch smokes error WHEN emitted THEN it shows an error state`() = - runTest { - intentResults.emit(HomeResult.FetchSmokesError) - - viewModel.states().test { - awaitItem().apply { - displayLoading shouldBeEqualTo false - error shouldBeEqualTo HomeResult.Error.Generic - } - } - } - - @Test - fun `GIVEN loading result WHEN emitted THEN it shows loading`() = - runTest { - intentResults.emit(HomeResult.Loading) - - viewModel.states().test { - awaitItem().displayLoading shouldBeEqualTo true - } - } + @AfterEach + fun tearDown() { + Dispatchers.resetMain() + } @Test fun `GIVEN edit smoke success and fetch smokes success WHEN edit smoke is sent THEN it updates state correctly`() = runTest { val id = "123" - val date: LocalDateTime = mockk() + val date: Instant = Clock.System.now() every { processHolder.processIntent( @@ -150,62 +91,4 @@ class HomeViewModelTest { } } - @Test - fun `GIVEN add smoke error WHEN add smoke is sent THEN it shows an error`() = - runTest { - every { processHolder.processIntent(HomeIntent.AddSmoke) } returns intentResults - - viewModel.intents().trySend(HomeIntent.AddSmoke) - intentResults.emit(HomeResult.Error.Generic) - - viewModel.states().test { - awaitItem().apply { - displayLoading shouldBeEqualTo false - error shouldBeEqualTo HomeResult.Error.Generic - } - } - } - - @Test - fun `GIVEN edit smoke error WHEN edit smoke is sent THEN it shows an error`() = - runTest { - val id = "123" - val date: LocalDateTime = mockk() - - every { - processHolder.processIntent( - HomeIntent.EditSmoke( - id, - date - ) - ) - } returns intentResults - - viewModel.intents().trySend(HomeIntent.EditSmoke(id, date)) - intentResults.emit(HomeResult.Error.Generic) - - viewModel.states().test { - awaitItem().apply { - displayLoading shouldBeEqualTo false - error shouldBeEqualTo HomeResult.Error.Generic - } - } - } - - @Test - fun `GIVEN delete smoke error WHEN delete smoke is sent THEN it shows an error`() = - runTest { - val id = "123" - every { processHolder.processIntent(HomeIntent.DeleteSmoke(id)) } returns intentResults - - viewModel.intents().trySend(HomeIntent.DeleteSmoke(id)) - intentResults.emit(HomeResult.Error.Generic) - - viewModel.states().test { - awaitItem().apply { - displayLoading shouldBeEqualTo false - error shouldBeEqualTo HomeResult.Error.Generic - } - } - } -} +} \ No newline at end of file diff --git a/features/home/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/process/HomeProcessHolderTest.kt b/features/home/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/process/HomeProcessHolderTest.kt index e2a3aade..158cefe3 100644 --- a/features/home/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/process/HomeProcessHolderTest.kt +++ b/features/home/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/process/HomeProcessHolderTest.kt @@ -6,6 +6,9 @@ import com.feragusper.smokeanalytics.features.home.presentation.mvi.HomeIntent import com.feragusper.smokeanalytics.features.home.presentation.mvi.HomeResult import com.feragusper.smokeanalytics.libraries.authentication.domain.FetchSessionUseCase import com.feragusper.smokeanalytics.libraries.authentication.domain.Session +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.AddSmokeUseCase +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.DeleteSmokeUseCase +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.EditSmokeUseCase import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.SyncWithWearUseCase import io.mockk.Runs import io.mockk.coEvery @@ -14,18 +17,24 @@ import io.mockk.just import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant import org.amshove.kluent.shouldBeEqualTo +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test -import java.time.LocalDateTime @OptIn(ExperimentalCoroutinesApi::class) class HomeProcessHolderTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var processHolder: HomeProcessHolder private val addSmokeUseCase: AddSmokeUseCase = mockk() @@ -37,7 +46,8 @@ class HomeProcessHolderTest { @BeforeEach fun setUp() { - Dispatchers.setMain(Dispatchers.Unconfined) + Dispatchers.setMain(testDispatcher) + processHolder = HomeProcessHolder( addSmokeUseCase = addSmokeUseCase, editSmokeUseCase = editSmokeUseCase, @@ -47,13 +57,18 @@ class HomeProcessHolderTest { syncWithWearUseCase = syncWithWearUseCase ) - // Default mock behavior for sync coEvery { syncWithWearUseCase.invoke() } just Runs } + @AfterEach + fun tearDown() { + Dispatchers.resetMain() + } + @Nested @DisplayName("GIVEN user is logged in") inner class UserIsLoggedIn { + @BeforeEach fun setUp() { coEvery { fetchSessionUseCase() } returns mockk() @@ -62,7 +77,6 @@ class HomeProcessHolderTest { @Test fun `WHEN adding smoke THEN it returns success and syncs with Wear`() = runTest { coEvery { addSmokeUseCase.invoke(any()) } just Runs - coEvery { syncWithWearUseCase.invoke() } just Runs processHolder.processIntent(HomeIntent.AddSmoke).test { awaitItem() shouldBeEqualTo HomeResult.Loading @@ -75,7 +89,7 @@ class HomeProcessHolderTest { @Test fun `WHEN editing smoke THEN it returns success and syncs with Wear`() = runTest { val id = "id" - val date: LocalDateTime = mockk() + val date: Instant = Clock.System.now() coEvery { editSmokeUseCase(id, date) } just Runs processHolder.processIntent(HomeIntent.EditSmoke(id, date)).test { @@ -106,7 +120,7 @@ class HomeProcessHolderTest { processHolder.processIntent(HomeIntent.AddSmoke).test { awaitItem() shouldBeEqualTo HomeResult.Loading awaitItem() shouldBeEqualTo HomeResult.Error.Generic - coVerify(exactly = 0) { syncWithWearUseCase.invoke() } // Ensure sync is not called + coVerify(exactly = 0) { syncWithWearUseCase.invoke() } awaitComplete() } } @@ -114,13 +128,13 @@ class HomeProcessHolderTest { @Test fun `WHEN editing smoke fails THEN it returns error`() = runTest { val id = "id" - val date: LocalDateTime = mockk() + val date: Instant = Clock.System.now() coEvery { editSmokeUseCase(id, date) } throws IllegalStateException("Error") processHolder.processIntent(HomeIntent.EditSmoke(id, date)).test { awaitItem() shouldBeEqualTo HomeResult.Loading awaitItem() shouldBeEqualTo HomeResult.Error.Generic - coVerify(exactly = 0) { syncWithWearUseCase.invoke() } // Ensure sync is not called + coVerify(exactly = 0) { syncWithWearUseCase.invoke() } awaitComplete() } } @@ -133,7 +147,7 @@ class HomeProcessHolderTest { processHolder.processIntent(HomeIntent.DeleteSmoke(id)).test { awaitItem() shouldBeEqualTo HomeResult.Loading awaitItem() shouldBeEqualTo HomeResult.Error.Generic - coVerify(exactly = 0) { syncWithWearUseCase.invoke() } // Ensure sync is not called + coVerify(exactly = 0) { syncWithWearUseCase.invoke() } awaitComplete() } } @@ -142,6 +156,7 @@ class HomeProcessHolderTest { @Nested @DisplayName("GIVEN user is not logged in") inner class UserIsNotLoggedIn { + @BeforeEach fun setUp() { coEvery { fetchSessionUseCase() } returns mockk() @@ -152,7 +167,7 @@ class HomeProcessHolderTest { processHolder.processIntent(HomeIntent.AddSmoke).test { awaitItem() shouldBeEqualTo HomeResult.Error.NotLoggedIn awaitItem() shouldBeEqualTo HomeResult.GoToAuthentication - coVerify(exactly = 0) { syncWithWearUseCase.invoke() } // Ensure sync is not called + coVerify(exactly = 0) { syncWithWearUseCase.invoke() } awaitComplete() } } @@ -165,4 +180,4 @@ class HomeProcessHolderTest { } } } -} +} \ No newline at end of file diff --git a/features/settings/presentation/mobile/build.gradle.kts b/features/settings/presentation/mobile/build.gradle.kts index f783dc13..527cc2c7 100644 --- a/features/settings/presentation/mobile/build.gradle.kts +++ b/features/settings/presentation/mobile/build.gradle.kts @@ -54,7 +54,11 @@ dependencies { implementation(libs.compose.shimmer) // Unit testing dependencies + testImplementation(platform(libs.junit.bom)) testImplementation(libs.bundles.test) + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) // Debug-specific dependencies for Compose UI tooling debugImplementation(libs.bundles.compose.debug) diff --git a/features/stats/presentation/mobile/build.gradle.kts b/features/stats/presentation/mobile/build.gradle.kts index ca7ab30c..35711bb7 100644 --- a/features/stats/presentation/mobile/build.gradle.kts +++ b/features/stats/presentation/mobile/build.gradle.kts @@ -56,5 +56,9 @@ dependencies { implementation(libs.vico.views) // Unit testing dependencies + testImplementation(platform(libs.junit.bom)) testImplementation(libs.bundles.test) + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) } diff --git a/features/stats/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/stats/presentation/StatsViewModelTest.kt b/features/stats/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/stats/presentation/StatsViewModelTest.kt index c5486352..b87823b7 100644 --- a/features/stats/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/stats/presentation/StatsViewModelTest.kt +++ b/features/stats/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/stats/presentation/StatsViewModelTest.kt @@ -6,6 +6,7 @@ import com.feragusper.smokeanalytics.features.stats.presentation.mvi.StatsResult import com.feragusper.smokeanalytics.features.stats.presentation.mvi.compose.StatsViewState import com.feragusper.smokeanalytics.features.stats.presentation.process.StatsProcessHolder import com.feragusper.smokeanalytics.libraries.smokes.domain.model.SmokeStats +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokeStatsUseCase import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers @@ -43,7 +44,12 @@ class StatsViewModelTest { val viewModel = StatsViewModel(processHolder) viewModel.intents().trySend( - StatsIntent.LoadStats(year = 2025, month = 3, day = 2, period = PeriodType.WEEK) + StatsIntent.LoadStats( + year = 2025, + month = 3, + day = 2, + period = FetchSmokeStatsUseCase.PeriodType.WEEK + ) ) viewModel.states().test { @@ -77,7 +83,12 @@ class StatsViewModelTest { val viewModel = StatsViewModel(processHolder) viewModel.intents().trySend( - StatsIntent.LoadStats(year = 2025, month = 3, day = 2, period = PeriodType.WEEK) + StatsIntent.LoadStats( + year = 2025, + month = 3, + day = 2, + period = FetchSmokeStatsUseCase.PeriodType.WEEK + ) ) viewModel.states().test { @@ -97,7 +108,12 @@ class StatsViewModelTest { val viewModel = StatsViewModel(processHolder) viewModel.intents().trySend( - StatsIntent.LoadStats(year = 2025, month = 3, day = 2, period = PeriodType.WEEK) + StatsIntent.LoadStats( + year = 2025, + month = 3, + day = 2, + period = FetchSmokeStatsUseCase.PeriodType.WEEK + ) ) viewModel.states().test { diff --git a/features/stats/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/stats/presentation/process/StatsProcessHolderTest.kt b/features/stats/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/stats/presentation/process/StatsProcessHolderTest.kt index 2b1b72b1..b8909412 100644 --- a/features/stats/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/stats/presentation/process/StatsProcessHolderTest.kt +++ b/features/stats/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/stats/presentation/process/StatsProcessHolderTest.kt @@ -4,6 +4,7 @@ import app.cash.turbine.test import com.feragusper.smokeanalytics.features.stats.presentation.mvi.StatsIntent import com.feragusper.smokeanalytics.features.stats.presentation.mvi.StatsResult import com.feragusper.smokeanalytics.libraries.smokes.domain.model.SmokeStats +import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokeStatsUseCase import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk diff --git a/gradle.properties b/gradle.properties index 1c2838d9..56c7ae5b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -42,4 +42,5 @@ android.lifecycleProcessor.incremental=true # Enable on-demand project configuration to speed up the configuration phase. org.gradle.configureondemand=true -org.jetbrains.compose.experimental.jscanvas.enabled=true \ No newline at end of file +org.jetbrains.compose.experimental.jscanvas.enabled=true +android.lint.useK2Uast=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ef15a362..d1ff761e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,7 +26,7 @@ horologistTiles = "0.7.15" javaxInject = "1" junit = "1.3.0" junit4 = "4.13.2" -junitBOM = "6.0.0" +junitBOM = "5.11.4" kermit = "2.0.8" kluent = "1.73" kotlin = "2.2.20" @@ -101,6 +101,7 @@ junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api" } junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine" } junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params" } junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } kluent = { module = "org.amshove.kluent:kluent", version.ref = "kluent" } kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } diff --git a/libraries/architecture/common/build.gradle.kts b/libraries/architecture/common/build.gradle.kts index 333da894..a9835fab 100644 --- a/libraries/architecture/common/build.gradle.kts +++ b/libraries/architecture/common/build.gradle.kts @@ -26,4 +26,7 @@ dependencies { // Unit testing dependencies. testImplementation(platform(libs.junit.bom)) testImplementation(libs.bundles.test) + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) } \ No newline at end of file diff --git a/libraries/architecture/domain/src/test/java/com/feragusper/smokeanalytics/libraries/architecture/domain/extensions/LocalDateTimeExtensionsTest.kt b/libraries/architecture/domain/src/test/java/com/feragusper/smokeanalytics/libraries/architecture/domain/extensions/LocalDateTimeExtensionsTest.kt deleted file mode 100644 index e47e993b..00000000 --- a/libraries/architecture/domain/src/test/java/com/feragusper/smokeanalytics/libraries/architecture/domain/extensions/LocalDateTimeExtensionsTest.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.architecture.domain.extensions - -import org.amshove.kluent.internal.assertEquals -import org.amshove.kluent.shouldBe -import org.junit.jupiter.api.Test -import java.time.LocalDateTime -import java.time.Month - -class LocalDateTimeExtensionsTest { - - /** - * Verifies that isToday returns true when the date is today. - */ - @Test - fun `GIVEN the date is today WHEN isToday is called THEN it should return true`() { - val today = LocalDateTime.now() - today.isToday() shouldBe true - } - - /** - * Verifies that isToday returns false when the date is not today. - */ - @Test - fun `GIVEN the date is not today WHEN isToday is called THEN it should return false`() { - val yesterday = LocalDateTime.now().minusDays(1) - yesterday.isToday() shouldBe false - } - - /** - * Verifies that isThisWeek returns true when the date is within this week. - */ - @Test - fun `GIVEN the date is this week WHEN isThisWeek is called THEN it should return true`() { - val thisWeek = LocalDateTime.now() - thisWeek.isThisWeek() shouldBe true - } - - /** - * Verifies that isThisWeek returns false when the date is not within this week. - */ - @Test - fun `GIVEN the date is not this week WHEN isThisWeek is called THEN it should return false`() { - val lastWeek = LocalDateTime.now().minusWeeks(1) - lastWeek.isThisWeek() shouldBe false - } - - /** - * Verifies that timeElapsedSinceNow correctly calculates the time difference since now. - */ - @Test - fun `GIVEN a date in the past WHEN timeElapsedSinceNow is called THEN it should return the correct time difference`() { - val pastDate = LocalDateTime.now().minusHours(2).minusMinutes(30) - val (hours, minutes) = pastDate.timeElapsedSinceNow() - - hours shouldBe 2 - minutes shouldBe 30 - } - - /** - * Verifies that timeFormatted returns the correct format "HH:mm". - */ - @Test - fun `GIVEN a LocalDateTime WHEN timeFormatted is called THEN it should return the correct format`() { - val date = LocalDateTime.of(2025, Month.MARCH, 1, 14, 30, 0, 0) - val formattedTime = date.timeFormatted() - - // Compare the actual string values - assertEquals("14:30", formattedTime) - } - - /** - * Verifies that dateFormatted returns the correct format "EEEE, MMMM dd". - */ - @Test - fun `GIVEN a LocalDateTime WHEN dateFormatted is called THEN it should return the correct date format`() { - val date = LocalDateTime.of(2025, Month.MARCH, 1, 14, 30, 0, 0) - val formattedDate = date.dateFormatted() - - assertEquals(formattedDate, "Saturday, March 01") - } - -} diff --git a/libraries/authentication/data/mobile/build.gradle.kts b/libraries/authentication/data/mobile/build.gradle.kts index 71996687..9a362b80 100644 --- a/libraries/authentication/data/mobile/build.gradle.kts +++ b/libraries/authentication/data/mobile/build.gradle.kts @@ -32,4 +32,7 @@ dependencies { // Unit testing dependencies. testImplementation(platform(libs.junit.bom)) testImplementation(libs.bundles.test) + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) } diff --git a/libraries/authentication/data/mobile/src/test/java/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImplTest.kt b/libraries/authentication/data/mobile/src/test/java/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImplTest.kt index 45a3f3dd..e584bece 100644 --- a/libraries/authentication/data/mobile/src/test/java/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImplTest.kt +++ b/libraries/authentication/data/mobile/src/test/java/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImplTest.kt @@ -6,6 +6,7 @@ import com.google.firebase.auth.FirebaseUser import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBe import org.amshove.kluent.shouldBeEqualTo import org.junit.jupiter.api.Test @@ -55,16 +56,17 @@ class AuthenticationRepositoryImplTest { * Verifies that the signOut method correctly calls the signOut function from FirebaseAuth. */ @Test - fun `GIVEN signOut is called WHEN signOut is invoked THEN it should call firebaseAuth signOut`() { - // Arrange: Set up a mock for firebaseAuth and ensure signOut is called - every { firebaseAuth.signOut() } returns Unit + fun `GIVEN signOut is called WHEN signOut is invoked THEN it should call firebaseAuth signOut`() = + runTest { + // Arrange: Set up a mock for firebaseAuth and ensure signOut is called + every { firebaseAuth.signOut() } returns Unit - // Act: Call the signOut method - authenticationRepository.signOut() + // Act: Call the signOut method + authenticationRepository.signOut() - // Assert: Verify that the signOut method was called - verify { firebaseAuth.signOut() } - } + // Assert: Verify that the signOut method was called + verify { firebaseAuth.signOut() } + } /** * Verifies that when an error occurs during fetching session, it should be handled properly. diff --git a/libraries/authentication/domain/build.gradle.kts b/libraries/authentication/domain/build.gradle.kts index bf17eee6..d56e469a 100644 --- a/libraries/authentication/domain/build.gradle.kts +++ b/libraries/authentication/domain/build.gradle.kts @@ -17,21 +17,16 @@ kotlin { } sourceSets { - val commonMain by getting { - dependencies { - // No platform deps here - } - } + val commonMain by getting val commonTest by getting { dependencies { implementation(kotlin("test")) + implementation(libs.coroutines.test) } } - val jvmMain by getting { - // No javax.inject needed anymore in domain - } + val jvmMain by getting val jvmTest by getting { dependencies { diff --git a/libraries/authentication/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/FetchSessionUseCaseTest.kt b/libraries/authentication/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/FetchSessionUseCaseTest.kt new file mode 100644 index 00000000..43cdd1ea --- /dev/null +++ b/libraries/authentication/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/FetchSessionUseCaseTest.kt @@ -0,0 +1,43 @@ +package com.feragusper.smokeanalytics.libraries.authentication.domain + +import kotlin.test.Test +import kotlin.test.assertEquals + +class FetchSessionUseCaseTest { + + private class FakeAuthenticationRepository( + private val session: Session + ) : AuthenticationRepository { + + override fun fetchSession(): Session = session + + override suspend fun signOut() { + // no-op + } + } + + @Test + fun `returns Anonymous when repository returns Anonymous`() { + val repo = FakeAuthenticationRepository(Session.Anonymous) + val useCase = FetchSessionUseCase(repo) + + val result = useCase() + + assertEquals(Session.Anonymous, result) + } + + @Test + fun `returns LoggedIn when repository returns LoggedIn`() { + val user = Session.User( + id = "123", + email = "user@test.com", + displayName = "Fer" + ) + val repo = FakeAuthenticationRepository(Session.LoggedIn(user)) + val useCase = FetchSessionUseCase(repo) + + val result = useCase() + + assertEquals(Session.LoggedIn(user), result) + } +} \ No newline at end of file diff --git a/libraries/authentication/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/SignOutUseCaseTest.kt b/libraries/authentication/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/SignOutUseCaseTest.kt new file mode 100644 index 00000000..36779f1c --- /dev/null +++ b/libraries/authentication/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/SignOutUseCaseTest.kt @@ -0,0 +1,29 @@ +package com.feragusper.smokeanalytics.libraries.authentication.domain + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertTrue + +class SignOutUseCaseTest { + + private class FakeAuthenticationRepository : AuthenticationRepository { + + var signOutCalled = false + + override fun fetchSession(): Session = Session.Anonymous + + override suspend fun signOut() { + signOutCalled = true + } + } + + @Test + fun `WHEN invoke is executed THEN signOut is called`() = runTest { + val repository = FakeAuthenticationRepository() + val useCase = SignOutUseCase(repository) + + useCase() + + assertTrue(repository.signOutCalled) + } +} \ No newline at end of file diff --git a/libraries/authentication/domain/src/test/java/com/feragusper/smokeanalytics/libraries/authentication/domain/FetchSessionUseCaseTest.kt b/libraries/authentication/domain/src/test/java/com/feragusper/smokeanalytics/libraries/authentication/domain/FetchSessionUseCaseTest.kt deleted file mode 100644 index c5bb09f6..00000000 --- a/libraries/authentication/domain/src/test/java/com/feragusper/smokeanalytics/libraries/authentication/domain/FetchSessionUseCaseTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.authentication.domain - -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.amshove.kluent.shouldBeEqualTo -import org.junit.jupiter.api.Test - -/** - * Unit tests for [FetchSessionUseCase] to ensure the correct behavior of session fetching logic. - */ -class FetchSessionUseCaseTest { - - private val authenticationRepository: AuthenticationRepository = - mockk(relaxed = true) // Create a mock of AuthenticationRepository - private val fetchSessionUseCase = FetchSessionUseCase(authenticationRepository) - - /** - * Verifies that the use case correctly returns an [Session.Anonymous] when the repository indicates - * that the current session is anonymous. - */ - @Test - fun `GIVEN the session is anonymous WHEN invoke is executed THEN it should return anonymous session`() { - // Mock the repository to return an anonymous session - every { authenticationRepository.fetchSession() } returns Session.Anonymous - - // Assert that the use case correctly returns an anonymous session. - fetchSessionUseCase().shouldBeEqualTo(Session.Anonymous) - } - - /** - * Verifies that the use case correctly returns a [Session.LoggedIn] session with the correct user details - * when the repository indicates that the session is not anonymous. - */ - @Test - fun `GIVEN the session is logged in WHEN invoke is executed THEN it should return a logged-in session with user details`() { - val userId = "123" - val userEmail = "user@example.com" - val userDisplayName = "John Doe" - - // Mock the repository to return a logged-in session with mock user details. - every { authenticationRepository.fetchSession() } returns Session.LoggedIn( - Session.User( - userId, - userEmail, - userDisplayName - ) - ) - - // Create the expected result - val expectedSession = Session.LoggedIn(Session.User(userId, userEmail, userDisplayName)) - - // Assert that the returned session matches the expected session using `shouldBeEqualTo` for value equality. - fetchSessionUseCase().shouldBeEqualTo(expectedSession) - } - - /** - * Verifies that the use case calls the repository's fetchSession method. - */ - @Test - fun `GIVEN any scenario WHEN invoke is executed THEN fetchSession should be called`() { - // Act: invoke the use case - fetchSessionUseCase() - - // Assert: verify that fetchSession was called on the repository. - verify { authenticationRepository.fetchSession() } - } -} \ No newline at end of file diff --git a/libraries/authentication/domain/src/test/java/com/feragusper/smokeanalytics/libraries/authentication/domain/SignOutUseCaseTest.kt b/libraries/authentication/domain/src/test/java/com/feragusper/smokeanalytics/libraries/authentication/domain/SignOutUseCaseTest.kt deleted file mode 100644 index 1421033d..00000000 --- a/libraries/authentication/domain/src/test/java/com/feragusper/smokeanalytics/libraries/authentication/domain/SignOutUseCaseTest.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.authentication.domain - -import io.mockk.mockk -import io.mockk.verify -import org.junit.jupiter.api.Test - -/** - * Unit tests for [SignOutUseCase] to ensure the correct behavior of the sign-out operation. - */ -class SignOutUseCaseTest { - - private val authenticationRepository: AuthenticationRepository = - mockk(relaxed = true) // Create a relaxed mock of AuthenticationRepository - private val signOutUseCase = SignOutUseCase(authenticationRepository) - - /** - * Verifies that the use case calls the signOut method of the repository. - */ - @Test - fun `GIVEN a valid scenario WHEN invoke is executed THEN signOut should be called`() { - // Act: invoke the use case - signOutUseCase() - - // Assert: verify that the signOut method was called on the repository. - verify { authenticationRepository.signOut() } - } -} diff --git a/libraries/smokes/data/mobile/build.gradle.kts b/libraries/smokes/data/mobile/build.gradle.kts index eee52db1..c2827335 100644 --- a/libraries/smokes/data/mobile/build.gradle.kts +++ b/libraries/smokes/data/mobile/build.gradle.kts @@ -28,4 +28,7 @@ dependencies { testImplementation(platform(libs.junit.bom)) testImplementation(libs.bundles.test) + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) } diff --git a/libraries/smokes/data/mobile/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImplTest.kt b/libraries/smokes/data/mobile/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImplTest.kt index 6a43fc03..f58bbd70 100644 --- a/libraries/smokes/data/mobile/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImplTest.kt +++ b/libraries/smokes/data/mobile/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImplTest.kt @@ -1,9 +1,9 @@ package com.feragusper.smokeanalytics.libraries.smokes.data -import com.feragusper.smokeanalytics.libraries.architecture.domain.extensions.timeAfter -import com.feragusper.smokeanalytics.libraries.architecture.domain.extensions.toLocalDateTime +import com.feragusper.smokeanalytics.libraries.architecture.domain.timeAfter import com.feragusper.smokeanalytics.libraries.smokes.data.SmokeRepositoryImpl.FirestoreCollection.Companion.SMOKES import com.feragusper.smokeanalytics.libraries.smokes.data.SmokeRepositoryImpl.FirestoreCollection.Companion.USERS +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke import com.google.android.gms.tasks.Task import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser @@ -17,15 +17,13 @@ import io.mockk.every import io.mockk.mockk import io.mockk.slot import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant import org.amshove.kluent.internal.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows -import java.time.LocalDateTime -import java.time.ZoneId -import java.util.Date class SmokeRepositoryImplTest { @@ -40,8 +38,13 @@ class SmokeRepositoryImplTest { fun `GIVEN the user is null WHEN add smoke is called THEN it should throw an illegal state exception`() = runTest { every { firebaseAuth.currentUser } returns null - - assertThrows { smokeRepository.addSmoke(LocalDateTime.now()) } + assertThrows { + smokeRepository.addSmoke( + Instant.fromEpochMilliseconds( + 0 + ) + ) + } } @Nested @@ -50,11 +53,12 @@ class SmokeRepositoryImplTest { private val id1 = "id1" private val id2 = "id2" - private val localDateTime1 = LocalDateTime.of(2023, 1, 1, 12, 0) - private val localDateTime2 = LocalDateTime.of(2023, 1, 1, 10, 0) + private val instant1 = + Instant.fromEpochMilliseconds(1_672_574_400_000) // 2023-01-01T12:00:00Z-ish depending TZ, but ok for test + private val instant2 = Instant.fromEpochMilliseconds(1_672_567_200_000) // earlier private val timeAfterNothing = 0L to 0L - private val timeAfter2 = localDateTime1.timeAfter(localDateTime2) + private val timeAfter2 = instant1.timeAfter(instant2) private val uid = "uid" @@ -70,7 +74,7 @@ class SmokeRepositoryImplTest { every { collectionReference.orderBy( - "date", + SmokeEntity.Fields.TIMESTAMP_MILLIS, Query.Direction.DESCENDING ) } returns collectionReference @@ -81,18 +85,18 @@ class SmokeRepositoryImplTest { runTest { mockFetchSmokes() - val result = smokeRepository.fetchSmokes(localDateTime1, localDateTime2) + val result = smokeRepository.fetchSmokes(instant1, instant2) assertEquals( listOf( Smoke( id = id1, - date = localDateTime1, + date = instant1, timeElapsedSincePreviousSmoke = timeAfter2 ), Smoke( id = id2, - date = localDateTime2, + date = instant2, timeElapsedSincePreviousSmoke = timeAfterNothing ) ), @@ -102,7 +106,7 @@ class SmokeRepositoryImplTest { @Test fun `GIVEN user is logged in WHEN add smoke is called THEN it should finish`() = runTest { - val date = LocalDateTime.of(2023, 1, 1, 12, 0) + val date = Instant.fromEpochMilliseconds(1_672_574_400_000) val smokeEntitySlot = slot() every { collectionReference.add(capture(smokeEntitySlot)) } answers { @@ -118,15 +122,21 @@ class SmokeRepositoryImplTest { smokeRepository.addSmoke(date) assertTrue(smokeEntitySlot.isCaptured) - assertEquals(date, smokeEntitySlot.captured.date.toLocalDateTime()) + assertEquals( + date.toEpochMilliseconds().toDouble(), + smokeEntitySlot.captured.timestampMillis + ) } @Test fun `GIVEN user is logged in WHEN edit smoke is called THEN it should finish`() = runTest { val id = "id" - val date = Date.from(localDateTime1.atZone(ZoneId.systemDefault()).toInstant()) + val date = instant1 + + val documentRef = mockk() + every { collectionReference.document(id) } returns documentRef - every { collectionReference.document(id).set(SmokeEntity(date)) } answers { + every { documentRef.set(any()) } answers { mockk>().apply { every { isComplete } returns true every { isSuccessful } returns true @@ -136,7 +146,7 @@ class SmokeRepositoryImplTest { } } - smokeRepository.editSmoke(id, localDateTime1) + smokeRepository.editSmoke(id, date) } @Test @@ -144,7 +154,10 @@ class SmokeRepositoryImplTest { runTest { val id = "id" - every { collectionReference.document(id).delete() } answers { + val documentRef = mockk() + every { collectionReference.document(id) } returns documentRef + + every { documentRef.delete() } answers { mockk>().apply { every { isComplete } returns true every { isSuccessful } returns true @@ -162,7 +175,10 @@ class SmokeRepositoryImplTest { val finalQuery = mockk(relaxed = true) every { - collectionReference.orderBy("date", Query.Direction.DESCENDING) + collectionReference.orderBy( + SmokeEntity.Fields.TIMESTAMP_MILLIS, + Query.Direction.DESCENDING + ) } returns query every { @@ -181,8 +197,8 @@ class SmokeRepositoryImplTest { every { result } answers { mockk().apply { every { documents } returns listOf( - mockDocumentSnapshot(id1, localDateTime1), - mockDocumentSnapshot(id2, localDateTime2) + mockDocumentSnapshot(id1, instant1), + mockDocumentSnapshot(id2, instant2) ) } } @@ -191,15 +207,12 @@ class SmokeRepositoryImplTest { } } - private fun mockDocumentSnapshot(id: String, date: LocalDateTime): DocumentSnapshot { + private fun mockDocumentSnapshot(id: String, date: Instant): DocumentSnapshot { return mockk().apply { every { this@apply.id } returns id - every { getDate("date") } answers { - Date.from( - date.atZone(ZoneId.systemDefault()).toInstant() - ) - } + every { getDouble(SmokeEntity.Fields.TIMESTAMP_MILLIS) } returns date.toEpochMilliseconds() + .toDouble() } } } -} +} \ No newline at end of file diff --git a/libraries/smokes/domain/build.gradle.kts b/libraries/smokes/domain/build.gradle.kts index 24d56617..f9010580 100644 --- a/libraries/smokes/domain/build.gradle.kts +++ b/libraries/smokes/domain/build.gradle.kts @@ -19,6 +19,7 @@ kotlin { val commonTest by getting { dependencies { implementation(kotlin("test")) + implementation(libs.coroutines.test) } } @@ -34,8 +35,6 @@ kotlin { implementation(libs.bundles.test) } } - - // wasm/otros targets los sigue manejando tu plugin `kmp-lib` } } diff --git a/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/model/Smoke.kt b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/model/Smoke.kt index dd13ae5b..32c9f977 100644 --- a/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/model/Smoke.kt +++ b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/model/Smoke.kt @@ -1,8 +1,14 @@ -// commonMain package com.feragusper.smokeanalytics.libraries.smokes.domain.model import kotlinx.datetime.Instant +/** + * Represents a smoke. + * + * @property id The id of the smoke. + * @property date The date of the smoke. + * @property timeElapsedSincePreviousSmoke The time elapsed since the previous smoke. + */ data class Smoke( val id: String, val date: Instant, diff --git a/libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeStatsTest.kt b/libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeStatsTest.kt new file mode 100644 index 00000000..2ae466f2 --- /dev/null +++ b/libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeStatsTest.kt @@ -0,0 +1,153 @@ +package com.feragusper.smokeanalytics.libraries.smokes.domain.model + +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlin.test.Test +import kotlin.test.assertEquals + +class SmokeStatsTest { + + private val tz = TimeZone.UTC + + private fun createSmokeEvents(): List { + return listOf( + Smoke("1", Instant.parse("2023-03-01T12:00:00Z"), 0L to 0L), // Wed + Smoke("2", Instant.parse("2023-03-02T13:00:00Z"), 0L to 0L), // Thu + Smoke("3", Instant.parse("2023-03-08T14:30:00Z"), 1L to 30L), // Wed + Smoke("4", Instant.parse("2023-03-15T16:15:00Z"), 2L to 0L), // Wed + Smoke("5", Instant.parse("2023-03-22T10:00:00Z"), 2L to 30L), // Wed + Smoke("6", Instant.parse("2023-03-29T11:00:00Z"), 3L to 0L), // Wed + ) + } + + @Test + fun `GIVEN smoke events WHEN from is called THEN it should return correct weekly statistics`() { + val smokes = createSmokeEvents() + + val stats = SmokeStats.from( + smokes = smokes, + year = 2023, + month = 3, + day = null, + timeZone = tz, + now = Instant.parse("2023-03-15T00:00:00Z") + ) + + assertEquals(5, stats.weekly["Wed"]) + assertEquals(1, stats.weekly["Thu"]) + assertEquals(0, stats.weekly["Mon"]) + assertEquals(0, stats.weekly["Tue"]) + assertEquals(0, stats.weekly["Fri"]) + assertEquals(0, stats.weekly["Sat"]) + assertEquals(0, stats.weekly["Sun"]) + } + + @Test + fun `GIVEN smoke events WHEN from is called THEN it should return correct monthly statistics`() { + val smokes = createSmokeEvents() + + val stats = SmokeStats.from( + smokes = smokes, + year = 2023, + month = 3, + day = null, + timeZone = tz, + now = Instant.parse("2023-03-15T00:00:00Z") + ) + + assertEquals(2, stats.monthly["W1"]) // days 1..7 + assertEquals(1, stats.monthly["W2"]) // days 8..14 + assertEquals(1, stats.monthly["W3"]) // days 15..21 + assertEquals(1, stats.monthly["W4"]) // days 22..28 + assertEquals(1, stats.monthly["W5"]) // days 29..31 + } + + @Test + fun `GIVEN smoke events WHEN from is called THEN it should return correct yearly statistics`() { + val smokes = createSmokeEvents() + + val stats = SmokeStats.from( + smokes = smokes, + year = 2023, + month = 3, + day = null, + timeZone = tz, + now = Instant.parse("2023-03-15T00:00:00Z") + ) + + assertEquals(0, stats.yearly["Jan"]) + assertEquals(0, stats.yearly["Feb"]) + assertEquals(6, stats.yearly["Mar"]) + assertEquals(0, stats.yearly["Apr"]) + assertEquals(0, stats.yearly["May"]) + assertEquals(0, stats.yearly["Jun"]) + } + + @Test + fun `GIVEN smoke events WHEN from is called THEN it should return correct hourly statistics for a given day`() { + val smokes = createSmokeEvents() + + val stats = SmokeStats.from( + smokes = smokes, + year = 2023, + month = 3, + day = 1, + timeZone = tz, + now = Instant.parse("2023-03-15T00:00:00Z") + ) + + assertEquals(1, stats.hourly["12:00"]) + assertEquals(0, stats.hourly["13:00"]) + assertEquals(0, stats.hourly["14:00"]) + } + + @Test + fun `GIVEN smoke events WHEN from is called THEN it should return correct total month statistics`() { + val smokes = createSmokeEvents() + + val stats = SmokeStats.from( + smokes = smokes, + year = 2023, + month = 3, + day = null, + timeZone = tz, + now = Instant.parse("2023-03-15T00:00:00Z") + ) + + assertEquals(6, stats.totalMonth) + } + + @Test + fun `GIVEN now within last 7 days window WHEN from is called THEN it should compute rolling totalWeek`() { + val smokes = createSmokeEvents() + + // Rolling window: start = 2023-03-16T00:00Z (now date 22 minus 6 days), end = 2023-03-23T00:00Z + // In that range we only have: 2023-03-22T10:00Z (id "5") => 1 smoke + val stats = SmokeStats.from( + smokes = smokes, + year = 2023, + month = 3, + day = null, + timeZone = tz, + now = Instant.parse("2023-03-22T08:00:00Z") + ) + + assertEquals(1, stats.totalWeek) + } + + @Test + fun `GIVEN day is provided WHEN from is called THEN totalDay should match daySmokes`() { + val smokes = createSmokeEvents() + + val stats = SmokeStats.from( + smokes = smokes, + year = 2023, + month = 3, + day = 2, + timeZone = tz, + now = Instant.parse("2023-03-15T00:00:00Z") + ) + + assertEquals(1, stats.totalDay) + } +} \ No newline at end of file diff --git a/libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/AddSmokeUseCaseTest.kt b/libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/AddSmokeUseCaseTest.kt new file mode 100644 index 00000000..2bf38dac --- /dev/null +++ b/libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/AddSmokeUseCaseTest.kt @@ -0,0 +1,71 @@ +package com.feragusper.smokeanalytics.libraries.smokes.domain.usecase + +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke +import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class AddSmokeUseCaseTest { + + private lateinit var repository: FakeSmokeRepository + private lateinit var useCase: AddSmokeUseCase + + @BeforeTest + fun setUp() { + repository = FakeSmokeRepository() + useCase = AddSmokeUseCase(repository) + } + + @Test + fun `GIVEN a smoke event WHEN invoke is executed THEN it should call addSmoke`() = + runTest { + // Act + useCase.invoke() + + // Assert + assertEquals(1, repository.addSmokeCalls) + assertNotNull(repository.lastAddedSmoke) + } + + @Test + fun `GIVEN a specific date WHEN invoke is executed THEN it should call addSmoke with that date`() = + runTest { + // Arrange + val specificInstant = Instant.parse("2023-03-01T12:00:00Z") + + // Act + useCase.invoke(specificInstant) + + // Assert + assertEquals(1, repository.addSmokeCalls) + assertEquals(specificInstant, repository.lastAddedSmoke) + } + + private class FakeSmokeRepository : SmokeRepository { + + var addSmokeCalls = 0 + private set + + var lastAddedSmoke: Instant? = null + private set + + override suspend fun addSmoke(date: Instant) { + addSmokeCalls++ + lastAddedSmoke = date + } + + override suspend fun editSmoke(id: String, date: Instant) = Unit + override suspend fun deleteSmoke(id: String) = Unit + override suspend fun fetchSmokes( + startDate: Instant?, + endDate: Instant? + ): List = emptyList() + + override suspend fun fetchSmokeCount() = + error("Not needed for this test") + } +} \ No newline at end of file diff --git a/libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/DeleteSmokeUseCaseTest.kt b/libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/DeleteSmokeUseCaseTest.kt new file mode 100644 index 00000000..a686be97 --- /dev/null +++ b/libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/DeleteSmokeUseCaseTest.kt @@ -0,0 +1,53 @@ +package com.feragusper.smokeanalytics.libraries.smokes.domain.usecase + +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke +import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class DeleteSmokeUseCaseTest { + + private lateinit var repository: FakeSmokeRepository + private lateinit var useCase: DeleteSmokeUseCase + + @BeforeTest + fun setUp() { + repository = FakeSmokeRepository() + useCase = DeleteSmokeUseCase(repository) + } + + @Test + fun `GIVEN a smoke event id WHEN invoke is executed THEN it should call deleteSmoke`() = + runTest { + val smokeId = "id" + + useCase.invoke(smokeId) + + assertEquals(1, repository.deleteSmokeCalls) + assertEquals(smokeId, repository.lastDeletedSmokeId) + } +} + +private class FakeSmokeRepository : SmokeRepository { + + var deleteSmokeCalls = 0 + private set + + var lastDeletedSmokeId: String? = null + private set + + override suspend fun deleteSmoke(id: String) { + deleteSmokeCalls++ + lastDeletedSmokeId = id + } + + override suspend fun addSmoke(date: Instant) = Unit + override suspend fun editSmoke(id: String, date: Instant) = Unit + override suspend fun fetchSmokes(startDate: Instant?, endDate: Instant?): List = + emptyList() + + override suspend fun fetchSmokeCount() = error("Not needed for this test") +} \ No newline at end of file diff --git a/libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/EditSmokeUseCaseTest.kt b/libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/EditSmokeUseCaseTest.kt new file mode 100644 index 00000000..04edf8b8 --- /dev/null +++ b/libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/EditSmokeUseCaseTest.kt @@ -0,0 +1,59 @@ +package com.feragusper.smokeanalytics.libraries.smokes.domain.usecase + +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke +import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class EditSmokeUseCaseTest { + + private lateinit var repository: FakeSmokeRepository + private lateinit var useCase: EditSmokeUseCase + + @BeforeTest + fun setUp() { + repository = FakeSmokeRepository() + useCase = EditSmokeUseCase(repository) + } + + @Test + fun `GIVEN a smoke event ID and date WHEN invoke is executed THEN it should call editSmoke`() = + runTest { + val smokeId = "id" + val newDate = Instant.parse("2023-03-01T12:00:00Z") + + useCase.invoke(smokeId, newDate) + + assertEquals(1, repository.editSmokeCalls) + assertEquals(smokeId, repository.lastEditedSmokeId) + assertEquals(newDate, repository.lastEditedSmokeDate) + } + + private class FakeSmokeRepository : SmokeRepository { + + var editSmokeCalls = 0 + private set + + var lastEditedSmokeId: String? = null + private set + + var lastEditedSmokeDate: Instant? = null + private set + + override suspend fun editSmoke(id: String, date: Instant) { + editSmokeCalls++ + lastEditedSmokeId = id + lastEditedSmokeDate = date + } + + override suspend fun addSmoke(date: Instant) = Unit + override suspend fun deleteSmoke(id: String) = Unit + override suspend fun fetchSmokes(startDate: Instant?, endDate: Instant?): List = + emptyList() + + override suspend fun fetchSmokeCount() = error("Not needed for this test") + } +} \ No newline at end of file diff --git a/libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokeStatsUseCaseTest.kt b/libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokeStatsUseCaseTest.kt new file mode 100644 index 00000000..cfee6936 --- /dev/null +++ b/libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokeStatsUseCaseTest.kt @@ -0,0 +1,95 @@ +package com.feragusper.smokeanalytics.libraries.smokes.domain.usecase + +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.SmokeCount +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.SmokeStats +import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant +import kotlin.test.Test +import kotlin.test.assertEquals + +class FetchSmokeStatsUseCaseTest { + + private val repository = FakeSmokeRepository() + private val useCase = FetchSmokeStatsUseCase(repository) + + @Test + fun `GIVEN a day period WHEN invoke is executed THEN it should return correct smoke statistics for the day`() = + runTest { + val smokeList = listOf( + Smoke("1", Instant.parse("2023-03-01T12:00:00Z"), 0L to 0L), + Smoke("2", Instant.parse("2023-03-01T14:00:00Z"), 0L to 0L) + ) + repository.smokesToReturn = smokeList + + val result = useCase.invoke(2023, 3, 1, FetchSmokeStatsUseCase.PeriodType.DAY) + + val expectedStats = SmokeStats.from(smokeList, 2023, 3, 1) + assertEquals(expectedStats, result) + } + + @Test + fun `GIVEN a week period WHEN invoke is executed THEN it should return correct smoke statistics for the week`() = + runTest { + val smokeList = listOf( + Smoke("1", Instant.parse("2023-03-01T12:00:00Z"), 0L to 0L), + Smoke("2", Instant.parse("2023-03-02T14:00:00Z"), 0L to 0L), + Smoke("3", Instant.parse("2023-03-05T13:00:00Z"), 0L to 0L) + ) + repository.smokesToReturn = smokeList + + val result = useCase.invoke(2023, 3, 1, FetchSmokeStatsUseCase.PeriodType.WEEK) + + val expectedStats = SmokeStats.from(smokeList, 2023, 3, 1) + assertEquals(expectedStats, result) + } + + @Test + fun `GIVEN a month period WHEN invoke is executed THEN it should return correct smoke statistics for the month`() = + runTest { + val smokeList = listOf( + Smoke("1", Instant.parse("2023-03-01T12:00:00Z"), 0L to 0L), + Smoke("2", Instant.parse("2023-03-05T13:00:00Z"), 0L to 0L), + Smoke("3", Instant.parse("2023-03-15T14:00:00Z"), 0L to 0L) + ) + repository.smokesToReturn = smokeList + + val result = useCase.invoke(2023, 3, 1, FetchSmokeStatsUseCase.PeriodType.MONTH) + + val expectedStats = SmokeStats.from(smokeList, 2023, 3, 1) + assertEquals(expectedStats, result) + } + + @Test + fun `GIVEN a year period WHEN invoke is executed THEN it should return correct smoke statistics for the year`() = + runTest { + val smokeList = listOf( + Smoke("1", Instant.parse("2023-03-01T12:00:00Z"), 0L to 0L), + Smoke("2", Instant.parse("2023-03-05T13:00:00Z"), 0L to 0L), + Smoke("3", Instant.parse("2023-04-15T14:00:00Z"), 0L to 0L) + ) + repository.smokesToReturn = smokeList + + val result = useCase.invoke(2023, 3, 1, FetchSmokeStatsUseCase.PeriodType.YEAR) + + val expectedStats = SmokeStats.from(smokeList, 2023, 3, 1) + assertEquals(expectedStats, result) + } + + private class FakeSmokeRepository : SmokeRepository { + var smokesToReturn: List = emptyList() + + override suspend fun fetchSmokes( + startDate: Instant?, + endDate: Instant? + ): List = smokesToReturn + + override suspend fun addSmoke(date: Instant) = Unit + override suspend fun editSmoke(id: String, date: Instant) = Unit + override suspend fun deleteSmoke(id: String) = Unit + override suspend fun fetchSmokeCount(): SmokeCount { + throw UnsupportedOperationException("Not needed for this test") + } + } +} \ No newline at end of file diff --git a/libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokesUseCaseTest.kt b/libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokesUseCaseTest.kt new file mode 100644 index 00000000..14795b31 --- /dev/null +++ b/libraries/smokes/domain/src/commonTest/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokesUseCaseTest.kt @@ -0,0 +1,67 @@ +package com.feragusper.smokeanalytics.libraries.smokes.domain.usecase + +import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke +import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant +import kotlin.test.Test +import kotlin.test.assertEquals + +class FetchSmokesUseCaseTest { + + @Test + fun `GIVEN fetch smokes by date answers WHEN invoke with date is executed THEN it should return the correct data`() = + runTest { + val startDate = Instant.parse("2023-03-01T00:00:00Z") + val endDate = Instant.parse("2023-03-31T23:59:59Z") + + val smokeList = listOf( + Smoke( + id = "1", + date = Instant.parse("2023-03-01T12:00:00Z"), + timeElapsedSincePreviousSmoke = 0L to 0L + ), + Smoke( + id = "2", + date = Instant.parse("2023-03-05T13:00:00Z"), + timeElapsedSincePreviousSmoke = 0L to 0L + ), + ) + + val repository = FakeSmokeRepository(fetchSmokesResult = smokeList) + val useCase = FetchSmokesUseCase(repository) + + val result = useCase.invoke(startDate, endDate) + + assertEquals(1, repository.fetchSmokesCalls) + assertEquals(startDate, repository.lastFetchStart) + assertEquals(endDate, repository.lastFetchEnd) + assertEquals(smokeList, result) + } + + private class FakeSmokeRepository( + private val fetchSmokesResult: List + ) : SmokeRepository { + + var fetchSmokesCalls = 0 + private set + + var lastFetchStart: Instant? = null + private set + + var lastFetchEnd: Instant? = null + private set + + override suspend fun fetchSmokes(startDate: Instant?, endDate: Instant?): List { + fetchSmokesCalls++ + lastFetchStart = startDate + lastFetchEnd = endDate + return fetchSmokesResult + } + + override suspend fun addSmoke(date: Instant) = Unit + override suspend fun editSmoke(id: String, date: Instant) = Unit + override suspend fun deleteSmoke(id: String) = Unit + override suspend fun fetchSmokeCount() = error("Not needed for this test") + } +} \ No newline at end of file diff --git a/libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeStatsTest.kt b/libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeStatsTest.kt deleted file mode 100644 index b133eb06..00000000 --- a/libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/model/SmokeStatsTest.kt +++ /dev/null @@ -1,136 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.smokes.domain.model - -import org.amshove.kluent.shouldBe -import org.amshove.kluent.shouldBeEqualTo -import org.junit.jupiter.api.Test -import java.time.LocalDateTime -import java.time.Month - -class SmokeStatsTest { - - // Helper function to create mock smoke events. - private fun createSmokeEvents(): List { - val smoke1 = - Smoke("1", LocalDateTime.of(2023, Month.MARCH, 1, 12, 0, 0, 0), Pair(0L, 0L)) // Week 1 - val smoke2 = - Smoke("2", LocalDateTime.of(2023, Month.MARCH, 2, 13, 0, 0, 0), Pair(0L, 0L)) // Week 1 - val smoke3 = Smoke( - "3", - LocalDateTime.of(2023, Month.MARCH, 8, 14, 30, 0, 0), - Pair(1L, 30L) - ) // Week 2 - val smoke4 = Smoke( - "4", - LocalDateTime.of(2023, Month.MARCH, 15, 16, 15, 0, 0), - Pair(2L, 0L) - ) // Week 3 - val smoke5 = Smoke( - "5", - LocalDateTime.of(2023, Month.MARCH, 22, 10, 0, 0, 0), - Pair(2L, 30L) - ) // Week 4 - val smoke6 = - Smoke("6", LocalDateTime.of(2023, Month.MARCH, 29, 11, 0, 0, 0), Pair(3L, 0L)) // Week 5 - - // Return the updated list of smokes - return listOf(smoke1, smoke2, smoke3, smoke4, smoke5, smoke6) - } - - @Test - fun `GIVEN smoke events WHEN from is called THEN it should return correct daily statistics`() { - // Arrange: Create mock smoke events - val smokes = createSmokeEvents() - - // Act: Call the 'from' method to calculate the statistics - val stats = SmokeStats.from(smokes, 2023, 3, null) - - // Assert: Verify the daily statistics - stats.daily["1"] shouldBe 1 // March 1st has 2 smokes - stats.daily["2"] shouldBe 1 // March 2nd has 1 smoke - stats.daily["3"] shouldBe 0 // March 3rd has 1 smoke - stats.daily["4"] shouldBe 0 // March 4th has 1 smoke - } - - @Test - fun `GIVEN smoke events WHEN from is called THEN it should return correct weekly statistics`() { - val smokes = - createSmokeEvents() // Assuming `createSmokeEvents` provides the necessary test data - - val stats = SmokeStats.from(smokes, 2023, 3, null) - - // Adjust the expected values based on the corrected week grouping - stats.weekly["Wed"] shouldBeEqualTo 5 // March 1st, 2nd, 8th, 15th, 22nd, 29th are Wednesdays - stats.weekly["Thu"] shouldBeEqualTo 1 // March 23rd is a Thursday - } - - @Test - fun `GIVEN smoke events WHEN from is called THEN it should return correct monthly statistics`() { - // Arrange: Create mock smoke events - val smokes = createSmokeEvents() - - // Act: Call the 'from' method to calculate the statistics - val stats = SmokeStats.from(smokes, 2023, 3, null) - - // Assert: Verify the monthly statistics (should return the number of smokes per week) - stats.monthly["W1"] shouldBe 2 // Week 1 (March 1st - 7th) - stats.monthly["W2"] shouldBe 1 // Week 2 (March 8th - 14th) - stats.monthly["W3"] shouldBe 1 // Week 3 (March 15th - 21st) - stats.monthly["W4"] shouldBe 1 // Week 4 (March 22nd - 28th) - stats.monthly["W5"] shouldBe 1 // Week 5 (March 29th - 31st) - } - - @Test - fun `GIVEN smoke events WHEN from is called THEN it should return correct yearly statistics`() { - // Arrange: Create mock smoke events - val smokes = createSmokeEvents() - - // Act: Call the 'from' method to calculate the statistics - val stats = SmokeStats.from(smokes, 2023, 3, null) - - // Assert: Verify the yearly statistics (should return the number of smokes per month) - stats.yearly["Jan"] shouldBe 0 // No smokes in January - stats.yearly["Feb"] shouldBe 0 // No smokes in February - stats.yearly["Mar"] shouldBe 6 // 5 smokes in March - stats.yearly["Apr"] shouldBe 0 // No smokes in April - stats.yearly["May"] shouldBe 0 // No smokes in May - stats.yearly["Jun"] shouldBe 0 // No smokes in June - } - - @Test - fun `GIVEN smoke events WHEN from is called THEN it should return correct hourly statistics`() { - // Arrange: Create mock smoke events - val smokes = createSmokeEvents() - - // Act: Call the 'from' method to calculate the statistics for a specific day - val stats = SmokeStats.from(smokes, 2023, 3, 1) - - // Assert: Verify the hourly statistics (should return the number of smokes per hour) - stats.hourly["12:00"] shouldBe 1 // 2 smokes on March 1st at 12:00 - stats.hourly["13:00"] shouldBe 0 // 1 smoke on March 1st at 13:00 - stats.hourly["14:00"] shouldBe 0 // No smoke at 14:00 - } - - @Test - fun `GIVEN smoke events WHEN from is called THEN it should return correct total month statistics`() { - // Arrange: Create mock smoke events - val smokes = createSmokeEvents() - - // Act: Call the 'from' method to calculate the total for the month - val stats = SmokeStats.from(smokes, 2023, 3, null) - - // Assert: Verify the total number of smokes in the month - stats.totalMonth shouldBe 6 // 5 total smokes in March - } - - @Test - fun `GIVEN smoke events WHEN from is called THEN it should return correct daily average statistics`() { - // Arrange: Create mock smoke events - val smokes = createSmokeEvents() - - // Act: Call the 'from' method to calculate the daily average for the month - val stats = SmokeStats.from(smokes, 2023, 3, null) - - // Assert: Verify the daily average calculation - stats.dailyAverage shouldBeEqualTo 6f / 31f // Total smokes in the month divided by the number of days - } -} diff --git a/libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/AddSmokeUseCaseTest.kt b/libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/AddSmokeUseCaseTest.kt deleted file mode 100644 index 1df666cc..00000000 --- a/libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/AddSmokeUseCaseTest.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.smokes.domain.usecase - -import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository -import io.mockk.Runs -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.just -import io.mockk.mockk -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import java.time.LocalDateTime - -class AddSmokeUseCaseTest { - - private val smokeRepository: SmokeRepository = mockk() - - private lateinit var addSmokeUseCase: AddSmokeUseCase - - /** - * Sets up the test by initializing the use case and configuring default mock behaviors. - */ - @BeforeEach - fun setUp() { - addSmokeUseCase = AddSmokeUseCase(smokeRepository) - - // Default mock behavior: Just execute without returning anything - coEvery { smokeRepository.addSmoke(any()) } just Runs - } - - /** - * Ensures that invoking the use case **without a specific date** calls `addSmoke()` on the repository - * and syncs with the Wear OS device. - */ - @Test - fun `GIVEN a smoke event WHEN invoke is executed THEN it should call addSmoke`() = - runTest { - // Act: invoke the use case with the default date - addSmokeUseCase.invoke() - - // Assert: verify that both `addSmoke()` and `syncWithWear()` were called - coVerify(exactly = 1) { smokeRepository.addSmoke(any()) } - } - - /** - * Ensures that invoking the use case with a **specific date** calls `addSmoke()` with that date - * and syncs with the Wear OS device. - */ - @Test - fun `GIVEN a specific date WHEN invoke is executed THEN it should call addSmoke with that date`() = - runTest { - // Arrange: define a specific date - val specificDate = LocalDateTime.of(2023, 3, 1, 12, 0, 0) - - // Act: invoke the use case with the specific date - addSmokeUseCase.invoke(specificDate) - - // Assert: verify that `addSmoke()` was called with the correct date - coVerify(exactly = 1) { smokeRepository.addSmoke(specificDate) } - } -} diff --git a/libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/DeleteSmokeUseCaseTest.kt b/libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/DeleteSmokeUseCaseTest.kt deleted file mode 100644 index b71a8897..00000000 --- a/libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/DeleteSmokeUseCaseTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.smokes.domain.usecase - -import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository -import io.mockk.Runs -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.just -import io.mockk.mockk -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test - -class DeleteSmokeUseCaseTest { - - private val smokeRepository: SmokeRepository = mockk() - - private lateinit var deleteSmokeUseCase: DeleteSmokeUseCase - - /** - * Sets up the test by initializing the use case and configuring default mock behaviors. - */ - @BeforeEach - fun setUp() { - deleteSmokeUseCase = DeleteSmokeUseCase(smokeRepository) - - // Default mock behavior: Just execute without returning anything - coEvery { smokeRepository.deleteSmoke(any()) } just Runs - } - - /** - * Ensures that invoking the use case **deletes the smoke event** and **syncs with Wear OS**. - */ - @Test - fun `GIVEN a smoke event id WHEN invoke is executed THEN it should call deleteSmoke`() = - runTest { - // Arrange: Define a smoke event ID - val smokeId = "id" - - // Act: Invoke the use case with the smoke ID - deleteSmokeUseCase.invoke(smokeId) - - // Assert: Verify that both `deleteSmoke()` and `syncWithWear()` were called - coVerify(exactly = 1) { smokeRepository.deleteSmoke(smokeId) } - } -} diff --git a/libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/EditSmokeUseCaseTest.kt b/libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/EditSmokeUseCaseTest.kt deleted file mode 100644 index 7b45240d..00000000 --- a/libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/EditSmokeUseCaseTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.smokes.domain.usecase - -import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository -import io.mockk.Runs -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.just -import io.mockk.mockk -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import java.time.LocalDateTime - -class EditSmokeUseCaseTest { - - private val smokeRepository: SmokeRepository = mockk() - - private lateinit var editSmokeUseCase: EditSmokeUseCase - - /** - * Sets up the test by initializing the use case and configuring default mock behaviors. - */ - @BeforeEach - fun setUp() { - editSmokeUseCase = EditSmokeUseCase(smokeRepository) - - // Default mock behavior: Just execute without returning anything - coEvery { smokeRepository.editSmoke(any(), any()) } just Runs - } - - /** - * Ensures that invoking the use case **edits the smoke event** and **syncs with Wear OS**. - */ - @Test - fun `GIVEN a smoke event ID and date WHEN invoke is executed THEN it should call editSmoke`() = - runTest { - // Arrange: Define an ID and a new date - val smokeId = "id" - val newDate = LocalDateTime.of(2023, 3, 1, 12, 0, 0) - - // Act: Invoke the use case with the smoke ID and new date - editSmokeUseCase.invoke(smokeId, newDate) - - // Assert: Verify that both `editSmoke()` and `syncWithWear()` were called correctly - coVerify(exactly = 1) { smokeRepository.editSmoke(smokeId, newDate) } - } -} diff --git a/libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokeStatsUseCaseTest.kt b/libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokeStatsUseCaseTest.kt deleted file mode 100644 index e3f9523b..00000000 --- a/libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokeStatsUseCaseTest.kt +++ /dev/null @@ -1,123 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.smokes.domain.usecase - -import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke -import com.feragusper.smokeanalytics.libraries.smokes.domain.model.SmokeStats -import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository -import io.mockk.coEvery -import io.mockk.mockk -import kotlinx.coroutines.test.runTest -import org.amshove.kluent.shouldBeEqualTo -import org.junit.jupiter.api.Test -import java.time.LocalDateTime -import java.time.Month - -class FetchSmokeStatsUseCaseTest { - - private val repository: SmokeRepository = mockk() // Create a mock of the SmokeRepository - private val useCase = - FetchSmokeStatsUseCase(repository) // Instantiate the use case with the mock - - /** - * Verifies that the use case correctly fetches smoke statistics for the specified day. - */ - @Test - fun `GIVEN a day period WHEN invoke is executed THEN it should return correct smoke statistics for the day`() = - runTest { - val smokeList = listOf( - Smoke("1", LocalDateTime.of(2023, Month.MARCH, 1, 12, 0, 0, 0), Pair(0L, 0L)), - Smoke("2", LocalDateTime.of(2023, Month.MARCH, 1, 14, 0, 0, 0), Pair(0L, 0L)) - ) - - // Mock the repository to return a predefined list of smoke events - coEvery { repository.fetchSmokes(any(), any()) } returns smokeList - - // Act: Call the use case to fetch smoke statistics for the day - val result = useCase.invoke(2023, 3, 1, FetchSmokeStatsUseCase.PeriodType.DAY) - - // Create the expected result (SmokeStats for that day) - val expectedStats = SmokeStats.from(smokeList, 2023, 3, 1) - - // Assert: Verify that the result matches the expected smoke statistics - result shouldBeEqualTo expectedStats - } - - /** - * Verifies that the use case correctly fetches smoke statistics for the specified week. - */ - @Test - fun `GIVEN a week period WHEN invoke is executed THEN it should return correct smoke statistics for the week`() = - runTest { - val smokeList = listOf( - Smoke("1", LocalDateTime.of(2023, Month.MARCH, 1, 12, 0, 0, 0), Pair(0L, 0L)), - Smoke("2", LocalDateTime.of(2023, Month.MARCH, 2, 14, 0, 0, 0), Pair(0L, 0L)), - Smoke("3", LocalDateTime.of(2023, Month.MARCH, 5, 13, 0, 0, 0), Pair(0L, 0L)) - ) - - // Mock the repository to return a predefined list of smoke events - coEvery { repository.fetchSmokes(any(), any()) } returns smokeList - - // Act: Call the use case to fetch smoke statistics for the week - val result = useCase.invoke(2023, 3, 1, FetchSmokeStatsUseCase.PeriodType.WEEK) - - // Create the expected result (SmokeStats for that week) - val expectedStats = SmokeStats.from(smokeList, 2023, 3, 1) - - // Assert: Verify that the result matches the expected smoke statistics - result shouldBeEqualTo expectedStats - } - - /** - * Verifies that the use case correctly fetches smoke statistics for the specified month. - */ - @Test - fun `GIVEN a month period WHEN invoke is executed THEN it should return correct smoke statistics for the month`() = - runTest { - val smokeList = listOf( - Smoke("1", LocalDateTime.of(2023, Month.MARCH, 1, 12, 0, 0, 0), Pair(0L, 0L)), - Smoke("2", LocalDateTime.of(2023, Month.MARCH, 5, 13, 0, 0, 0), Pair(0L, 0L)), - Smoke("3", LocalDateTime.of(2023, Month.MARCH, 15, 14, 0, 0, 0), Pair(0L, 0L)) - ) - - // Mock the repository to return a predefined list of smoke events - coEvery { repository.fetchSmokes(any(), any()) } returns smokeList - - // Act: Call the use case to fetch smoke statistics for the month - val result = useCase.invoke(2023, 3, 1, FetchSmokeStatsUseCase.PeriodType.MONTH) - - // Create the expected result (SmokeStats for that month) - val expectedStats = SmokeStats.from(smokeList, 2023, 3, 1) - - // Assert: Verify that the result matches the expected smoke statistics - result shouldBeEqualTo expectedStats - } - - /** - * Verifies that the use case correctly fetches smoke statistics for the specified year. - */ - @Test - fun `GIVEN a year period WHEN invoke is executed THEN it should return correct smoke statistics for the year`() = - runTest { - val smokeList = listOf( - Smoke("1", LocalDateTime.of(2023, Month.MARCH, 1, 12, 0, 0, 0), Pair(0L, 0L)), - Smoke("2", LocalDateTime.of(2023, Month.MARCH, 5, 13, 0, 0, 0), Pair(0L, 0L)), - Smoke("3", LocalDateTime.of(2023, Month.APRIL, 15, 14, 0, 0, 0), Pair(0L, 0L)) - ) - - // Mock the repository to return a predefined list of smoke events - coEvery { repository.fetchSmokes(any(), any()) } returns smokeList - - // Act: Call the use case to fetch smoke statistics for the year - val result = useCase.invoke(2023, 3, 1, FetchSmokeStatsUseCase.PeriodType.YEAR) - - // Create the expected result (SmokeStats for that year) - val expectedStats = SmokeStats.from(smokeList, 2023, 3, 1) - - // Adjust expected daily value, totalDay should now match the actual count of smokes in the year - expectedStats.totalDay shouldBeEqualTo 1 - - // Assert: Verify that the result matches the expected smoke statistics - result shouldBeEqualTo expectedStats - } - - -} \ No newline at end of file diff --git a/libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokesUseCaseTest.kt b/libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokesUseCaseTest.kt deleted file mode 100644 index af337c13..00000000 --- a/libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/FetchSmokesUseCaseTest.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.smokes.domain.usecase - -import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke -import com.feragusper.smokeanalytics.libraries.smokes.domain.repository.SmokeRepository -import io.mockk.coEvery -import io.mockk.mockk -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import java.time.LocalDateTime - -class FetchSmokesUseCaseTest { - - private val repository: SmokeRepository = mockk() // Create a mock of the SmokeRepository - private val useCase = FetchSmokesUseCase(repository) // Instantiate the use case with the mock - - /** - * Verifies that the use case correctly fetches smoke events from the repository. - */ - @Test - fun `GIVEN fetch smokes by date answers WHEN invoke with date is executed THEN it should return the correct data`() = - runTest { - val startDate = LocalDateTime.of(2023, 3, 1, 0, 0, 0, 0) - val endDate = LocalDateTime.of(2023, 3, 31, 23, 59, 59, 999999) - val smokeList = listOf( - Smoke("1", LocalDateTime.of(2023, 3, 1, 12, 0, 0, 0), Pair(0L, 0L)), - Smoke("2", LocalDateTime.of(2023, 3, 5, 13, 0, 0, 0), Pair(0L, 0L)) - ) - - // Mock the repository to return a predefined list of smoke events - coEvery { repository.fetchSmokes(startDate, endDate) } returns smokeList - - // Act: Call the use case to fetch the smoke events - val result = useCase.invoke(startDate, endDate) - - // Assert: Verify that the result matches the expected list of smoke events - assertEquals(smokeList, result) - } -} diff --git a/libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/SyncWithWearUseCaseTest.kt b/libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/SyncWithWearUseCaseTest.kt deleted file mode 100644 index 412c738a..00000000 --- a/libraries/smokes/domain/src/test/java/com/feragusper/smokeanalytics/libraries/smokes/domain/usecase/SyncWithWearUseCaseTest.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.feragusper.smokeanalytics.libraries.smokes.domain.usecase - -import com.feragusper.smokeanalytics.libraries.wear.domain.WearSyncManager -import io.mockk.Runs -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.just -import io.mockk.mockk -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test - -class SyncWithWearUseCaseTest { - - private val wearSyncManager: WearSyncManager.Mobile = mockk() - - private lateinit var syncWithWearUseCase: SyncWithWearUseCase - - /** - * Sets up the test by initializing the use case and configuring default mock behaviors. - */ - @BeforeEach - fun setUp() { - syncWithWearUseCase = SyncWithWearUseCase(wearSyncManager) - - // Default mock behavior: Just execute without returning anything - coEvery { wearSyncManager.syncWithWear() } just Runs - } - - /** - * Ensures that invoking the use case calls `syncWithWear()` on the WearSyncManager. - */ - @Test - fun `WHEN invoke is executed THEN it should call syncWithWear`() = runTest { - // Act: invoke the use case - syncWithWearUseCase.invoke() - - // Assert: verify that `syncWithWear()` was called once - coVerify(exactly = 1) { wearSyncManager.syncWithWear() } - } -} From 785ac8bb2dd6f99b71aff6d4977bdcc1169b474f Mon Sep 17 00:00:00 2001 From: feragusper Date: Mon, 5 Jan 2026 11:21:04 +0100 Subject: [PATCH 12/12] chore(integration): comment out SonarQube build and analysis step in CI configuration --- .github/workflows/integration.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 14b37617..4aa41f4e 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -61,12 +61,12 @@ jobs: - name: Run Gradle checks run: ./gradlew check --stacktrace - - name: Cache SonarQube packages - uses: actions/cache@v3 - with: - path: ~/.sonar/cache - key: ${{ runner.os }}-sonar - restore-keys: ${{ runner.os }}-sonar +# - name: Cache SonarQube packages +# uses: actions/cache@v3 +# with: +# path: ~/.sonar/cache +# key: ${{ runner.os }}-sonar +# restore-keys: ${{ runner.os }}-sonar - name: Cache Gradle packages uses: actions/cache@v3 @@ -75,8 +75,8 @@ jobs: key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} restore-keys: ${{ runner.os }}-gradle - - name: Build and analyze with SonarQube - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: ./gradlew sonar --stacktrace \ No newline at end of file +# - name: Build and analyze with SonarQube +# env: +# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +# SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} +# run: ./gradlew sonar --stacktrace \ No newline at end of file