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/.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
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 c7787a2c..74db12ad 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()
@@ -180,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.
@@ -196,8 +197,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/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..98918b33
--- /dev/null
+++ b/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/AppRoot.kt
@@ -0,0 +1,125 @@
+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.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
+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,
+ 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..a06b3f35
--- /dev/null
+++ b/apps/web/src/jsMain/kotlin/com/feragusper/smokeanalytics/FirebaseWebInit.kt
@@ -0,0 +1,29 @@
+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() {
+ runCatching {
+ 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,
+ )
+ )
+ }
+ }
+}
\ 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..9d96376b
--- /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.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
+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/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/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/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/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 83%
rename from features/authentication/presentation/build.gradle.kts
rename to features/authentication/presentation/mobile/build.gradle.kts
index 39908625..7d50286d 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
@@ -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/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..02b9d96d 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)
@@ -43,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/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..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
@@ -8,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 {
@@ -26,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?"
@@ -36,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 {
@@ -76,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 9b8b2fa6..48027074 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") }
-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 {
+ 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)
+ }
+ }
+ }
}
+
+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/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 96aabecf..9763c7b6 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"))
@@ -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 aafeecfe..07b06a3f 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)
@@ -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/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 84%
rename from features/history/presentation/build.gradle.kts
rename to features/history/presentation/mobile/build.gradle.kts
index ad35f308..e849bb19 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"))
@@ -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
@@ -57,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/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/process/HistoryProcessHolderTest.kt b/features/history/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/process/HistoryProcessHolderTest.kt
similarity index 92%
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..2f20da43 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
@@ -18,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
@@ -44,7 +48,8 @@ class HistoryProcessHolderTest {
@BeforeEach
fun setUp() {
- Dispatchers.setMain(Dispatchers.Unconfined)
+ Dispatchers.setMain(testDispatcher)
+
processHolder = HistoryProcessHolder(
addSmokeUseCase,
editSmokeUseCase,
@@ -54,7 +59,6 @@ class HistoryProcessHolderTest {
syncWithWearUseCase
)
- // Default mock behavior
coEvery { syncWithWearUseCase.invoke() } just Runs
}
@@ -74,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))
@@ -90,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))
@@ -130,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/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/history/presentation/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewModelTest.kt b/features/history/presentation/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewModelTest.kt
deleted file mode 100644
index dfd975db..00000000
--- a/features/history/presentation/src/test/java/com/feragusper/smokeanalytics/features/history/presentation/HistoryViewModelTest.kt
+++ /dev/null
@@ -1,290 +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 com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke
-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/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..1cdb0286
--- /dev/null
+++ b/features/history/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/history/presentation/mvi/HistoryWebStore.kt
@@ -0,0 +1,98 @@
+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
+
+/**
+ * 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),
+) {
+ 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
+ .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..70f5cd0c 100644
--- a/features/home/domain/build.gradle.kts
+++ b/features/home/domain/build.gradle.kts
@@ -1,23 +1,48 @@
-plugins {
- // Apply the Java Library plugin for this module.
- `java-lib`
-}
+plugins { id("kmp-lib") }
-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"))
+ }
+ }
+
+ @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 {
+ 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)
+ }
+ }
+ }
}
-tasks.test {
- // Use JUnit Platform for running tests.
- useJUnitPlatform()
+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/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/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 89%
rename from features/home/presentation/build.gradle.kts
rename to features/home/presentation/mobile/build.gradle.kts
index fe1bd38a..762eed56 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"))
@@ -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/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 40aab43b..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,11 +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))
}
@@ -102,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 {
@@ -135,7 +136,6 @@ data class HomeViewState(
isLoading = displayLoading
)
}
-// }
}
}
@@ -172,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)) }
)
}
@@ -281,7 +281,7 @@ private fun LatestSmokesSection(
latestSmokes: List?,
nestedScrollConnection: NestedScrollConnection,
isLoading: Boolean,
- onEdit: (String, LocalDateTime) -> Unit,
+ onEdit: (String, Instant) -> Unit,
onDelete: (String) -> Unit
) {
Text(
@@ -322,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()
}
@@ -332,7 +334,6 @@ private fun LatestSmokesSection(
}
}
-
@CombinedPreviews
@Composable
private fun HomeViewLoadingPreview() {
@@ -347,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/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
new file mode 100644
index 00000000..955a4ca6
--- /dev/null
+++ b/features/home/presentation/mobile/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewModelTest.kt
@@ -0,0 +1,94 @@
+package com.feragusper.smokeanalytics.features.home.presentation
+
+import app.cash.turbine.test
+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 io.mockk.every
+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
+
+@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(testDispatcher)
+ every { processHolder.processIntent(HomeIntent.FetchSmokes) } returns intentResults
+ viewModel = HomeViewModel(processHolder)
+ }
+
+ @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: Instant = Clock.System.now()
+
+ every {
+ processHolder.processIntent(
+ HomeIntent.EditSmoke(
+ id,
+ date
+ )
+ )
+ } returns intentResults
+
+ viewModel.intents().trySend(HomeIntent.EditSmoke(id, date))
+ intentResults.emit(HomeResult.EditSmokeSuccess)
+
+ viewModel.states().test {
+ awaitItem().displayLoading shouldBeEqualTo false
+ }
+ }
+
+ @Test
+ fun `GIVEN delete smoke success WHEN delete smoke is sent THEN it updates state correctly`() =
+ runTest {
+ val id = "123"
+ every { processHolder.processIntent(HomeIntent.DeleteSmoke(id)) } returns intentResults
+
+ viewModel.intents().trySend(HomeIntent.DeleteSmoke(id))
+ intentResults.emit(HomeResult.DeleteSmokeSuccess)
+
+ viewModel.states().test {
+ awaitItem().displayLoading shouldBeEqualTo false
+ }
+ }
+
+ @Test
+ fun `GIVEN add smoke success WHEN add smoke is sent THEN it updates state correctly`() =
+ runTest {
+ every { processHolder.processIntent(HomeIntent.AddSmoke) } returns intentResults
+
+ viewModel.intents().trySend(HomeIntent.AddSmoke)
+ intentResults.emit(HomeResult.AddSmokeSuccess)
+
+ viewModel.states().test {
+ awaitItem().displayLoading shouldBeEqualTo false
+ }
+ }
+
+}
\ No newline at end of file
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 92%
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..158cefe3 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
@@ -17,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()
@@ -40,7 +46,8 @@ class HomeProcessHolderTest {
@BeforeEach
fun setUp() {
- Dispatchers.setMain(Dispatchers.Unconfined)
+ Dispatchers.setMain(testDispatcher)
+
processHolder = HomeProcessHolder(
addSmokeUseCase = addSmokeUseCase,
editSmokeUseCase = editSmokeUseCase,
@@ -50,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()
@@ -65,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
@@ -78,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 {
@@ -109,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()
}
}
@@ -117,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()
}
}
@@ -136,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()
}
}
@@ -145,6 +156,7 @@ class HomeProcessHolderTest {
@Nested
@DisplayName("GIVEN user is not logged in")
inner class UserIsNotLoggedIn {
+
@BeforeEach
fun setUp() {
coEvery { fetchSessionUseCase() } returns mockk()
@@ -155,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()
}
}
@@ -168,4 +180,4 @@ class HomeProcessHolderTest {
}
}
}
-}
+}
\ No newline at end of file
diff --git a/features/home/presentation/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewModelTest.kt b/features/home/presentation/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewModelTest.kt
deleted file mode 100644
index ceb270d4..00000000
--- a/features/home/presentation/src/test/java/com/feragusper/smokeanalytics/features/home/presentation/HomeViewModelTest.kt
+++ /dev/null
@@ -1,212 +0,0 @@
-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
-import com.feragusper.smokeanalytics.libraries.smokes.domain.model.Smoke
-import io.mockk.every
-import io.mockk.mockk
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.test.runTest
-import kotlinx.coroutines.test.setMain
-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 HomeViewModelTest {
-
- private lateinit var viewModel: HomeViewModel
- private val processHolder: HomeProcessHolder = mockk()
- private val intentResults = MutableStateFlow(HomeResult.Loading)
-
- @BeforeEach
- fun setUp() {
- Dispatchers.setMain(Dispatchers.Unconfined)
- 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
- }
- }
-
- @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()
-
- every {
- processHolder.processIntent(
- HomeIntent.EditSmoke(
- id,
- date
- )
- )
- } returns intentResults
-
- viewModel.intents().trySend(HomeIntent.EditSmoke(id, date))
- intentResults.emit(HomeResult.EditSmokeSuccess)
-
- viewModel.states().test {
- awaitItem().displayLoading shouldBeEqualTo false
- }
- }
-
- @Test
- fun `GIVEN delete smoke success WHEN delete smoke is sent THEN it updates state correctly`() =
- runTest {
- val id = "123"
- every { processHolder.processIntent(HomeIntent.DeleteSmoke(id)) } returns intentResults
-
- viewModel.intents().trySend(HomeIntent.DeleteSmoke(id))
- intentResults.emit(HomeResult.DeleteSmokeSuccess)
-
- viewModel.states().test {
- awaitItem().displayLoading shouldBeEqualTo false
- }
- }
-
- @Test
- fun `GIVEN add smoke success WHEN add smoke is sent THEN it updates state correctly`() =
- runTest {
- every { processHolder.processIntent(HomeIntent.AddSmoke) } returns intentResults
-
- viewModel.intents().trySend(HomeIntent.AddSmoke)
- intentResults.emit(HomeResult.AddSmokeSuccess)
-
- viewModel.states().test {
- awaitItem().displayLoading shouldBeEqualTo false
- }
- }
-
- @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
- }
- }
- }
-}
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..be83f115
--- /dev/null
+++ b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/EditSmokeDialogWeb.kt
@@ -0,0 +1,124 @@
+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
+
+/**
+ * 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,
+ 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/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..a3b98e58
--- /dev/null
+++ b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeViewState.kt
@@ -0,0 +1,43 @@
+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,
+ val smokesPerDay: Int? = null,
+ val smokesPerWeek: Int? = null,
+ val smokesPerMonth: Int? = null,
+ val timeSinceLastCigarette: Pair? = null,
+ 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
new file mode 100644
index 00000000..6a557631
--- /dev/null
+++ b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebDependencies.kt
@@ -0,0 +1,12 @@
+package com.feragusper.smokeanalytics.features.home.presentation.web
+
+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,
+)
\ 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..17e6f9ad
--- /dev/null
+++ b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/HomeWebScreen.kt
@@ -0,0 +1,205 @@
+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.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
+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
+
+/**
+ * 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,
+ onNavigateToHistory: () -> Unit,
+) {
+ val store = remember(deps) { HomeWebStore(processHolder = deps.homeProcessHolder) }
+
+ LaunchedEffect(store) { store.start() }
+
+ val state by store.state.collectAsState()
+
+ state.Render(
+ onIntent = { intent ->
+ when (intent) {
+ HomeIntent.OnClickHistory -> onNavigateToHistory()
+ else -> store.send(intent)
+ }
+ }
+ )
+}
+
+/**
+ * Represents the dependencies required by the [HomeWebScreen].
+ *
+ * @param onIntent Callback to send intents to the [HomeWebStore].
+ */
+@Composable
+fun HomeViewState.Render(
+ 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/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/mvi/HomeResult.kt b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/mvi/HomeResult.kt
new file mode 100644
index 00000000..497592c1
--- /dev/null
+++ b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/mvi/HomeResult.kt
@@ -0,0 +1,37 @@
+package com.feragusper.smokeanalytics.features.home.presentation.web.mvi
+
+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/mvi/HomeWebStore.kt b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/mvi/HomeWebStore.kt
new file mode 100644
index 00000000..2de67573
--- /dev/null
+++ b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/mvi/HomeWebStore.kt
@@ -0,0 +1,113 @@
+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
+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/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/process/HomeProcessHolder.kt b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/process/HomeProcessHolder.kt
new file mode 100644
index 00000000..9e2c4365
--- /dev/null
+++ b/features/home/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/home/presentation/web/process/HomeProcessHolder.kt
@@ -0,0 +1,121 @@
+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
+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
+
+/**
+ * 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,
+ private val deleteSmokeUseCase: DeleteSmokeUseCase,
+ private val fetchSmokeCountListUseCase: FetchSmokeCountListUseCase,
+ 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)
+ 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/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 88%
rename from features/settings/presentation/build.gradle.kts
rename to features/settings/presentation/mobile/build.gradle.kts
index 2767a7d3..527cc2c7 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)
@@ -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/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/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..cc5a0c35
--- /dev/null
+++ b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsViewState.kt
@@ -0,0 +1,14 @@
+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,
+ 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..45709e2e
--- /dev/null
+++ b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebDependencies.kt
@@ -0,0 +1,34 @@
+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,
+): 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..aabb5e17
--- /dev/null
+++ b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/SettingsWebScreen.kt
@@ -0,0 +1,83 @@
+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.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
+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
+
+/**
+ * Screen for the Settings screen.
+ *
+ * @param deps The dependencies for the Settings screen.
+ */
+@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/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/mvi/SettingsWebStore.kt b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/mvi/SettingsWebStore.kt
new file mode 100644
index 00000000..28bc695c
--- /dev/null
+++ b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/mvi/SettingsWebStore.kt
@@ -0,0 +1,86 @@
+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
+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
+
+/**
+ * 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),
+) {
+ 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
+ .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/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/process/SettingsProcessHolder.kt b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/process/SettingsProcessHolder.kt
new file mode 100644
index 00000000..4e794414
--- /dev/null
+++ b/features/settings/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/settings/presentation/web/process/SettingsProcessHolder.kt
@@ -0,0 +1,51 @@
+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
+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()
+ }
+
+ 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/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 84%
rename from features/stats/presentation/build.gradle.kts
rename to features/stats/presentation/mobile/build.gradle.kts
index 5ffc384e..35711bb7 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"))
@@ -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
@@ -54,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/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 83%
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..b87823b7 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,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.PeriodType
+import com.feragusper.smokeanalytics.libraries.smokes.domain.usecase.FetchSmokeStatsUseCase
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
@@ -44,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 {
@@ -78,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 {
@@ -98,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/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 100%
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
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..c3faca2f
--- /dev/null
+++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/ChartJs.kt
@@ -0,0 +1,22 @@
+@file:JsModule("chart.js/auto")
+@file:JsNonModule
+
+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/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..aa08cb62
--- /dev/null
+++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsViewState.kt
@@ -0,0 +1,28 @@
+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
new file mode 100644
index 00000000..29fd6f28
--- /dev/null
+++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebDependencies.kt
@@ -0,0 +1,28 @@
+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 {
+ 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..61bd9132
--- /dev/null
+++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/StatsWebScreen.kt
@@ -0,0 +1,341 @@
+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.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
+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/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..a8e13f2b
--- /dev/null
+++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/canvas2dContext.kt
@@ -0,0 +1,18 @@
+package com.feragusper.smokeanalytics.features.stats.presentation.web
+
+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")
+ return canvas.getContext("2d") as CanvasRenderingContext2D
+}
\ No newline at end of file
diff --git a/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/mvi/StatsIntent.kt b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/mvi/StatsIntent.kt
new file mode 100644
index 00000000..89dde0df
--- /dev/null
+++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/mvi/StatsIntent.kt
@@ -0,0 +1,24 @@
+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,
+ 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/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/mvi/StatsWebStore.kt b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/mvi/StatsWebStore.kt
new file mode 100644
index 00000000..f408d63b
--- /dev/null
+++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/mvi/StatsWebStore.kt
@@ -0,0 +1,77 @@
+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
+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
+
+/**
+ * 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),
+) {
+ 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
+ .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/process/StatsProcessHolder.kt b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/process/StatsProcessHolder.kt
new file mode 100644
index 00000000..0db73e23
--- /dev/null
+++ b/features/stats/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/features/stats/presentation/web/process/StatsProcessHolder.kt
@@ -0,0 +1,30 @@
+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
+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/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..56c7ae5b 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -40,4 +40,7 @@ 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
+android.lint.useK2Uast=false
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index d15d3bc1..d1ff761e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,45 +1,52 @@
# 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"
+composeMultiplatform = "1.8.0"
coroutinesTest = "1.10.2"
-credentials = "1.1.0"
-espressoCore = "3.6.1"
-firebaseBOM = "33.12.0"
+credentials = "1.5.0"
+espressoCore = "3.7.0"
+firebaseApp = "1.13.0"
+firebaseAuth = "1.13.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 = "5.11.4"
+kermit = "2.0.8"
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"
+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"
+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 +56,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,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-auth = { module = "com.google.firebase:firebase-auth-ktx" }
+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" }
@@ -82,7 +93,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" }
@@ -90,9 +101,14 @@ 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 = "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" }
+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" }
@@ -107,7 +123,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/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/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/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/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/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..5d30f722
--- /dev/null
+++ b/libraries/architecture/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/architecture/presentation/MviStore.kt
@@ -0,0 +1,61 @@
+package com.feragusper.smokeanalytics.libraries.architecture.presentation
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.Channel
+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)
+ }
+
+ 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
+ .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 89%
rename from libraries/authentication/data/build.gradle.kts
rename to libraries/authentication/data/mobile/build.gradle.kts
index 71996687..9a362b80 100644
--- a/libraries/authentication/data/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/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 90%
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
index 389fcc9a..4fc756ed 100644
--- 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
@@ -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/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 88%
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
index 45a3f3dd..e584bece 100644
--- 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
@@ -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/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..70ecef13
--- /dev/null
+++ b/libraries/authentication/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/data/AuthenticationRepositoryImpl.kt
@@ -0,0 +1,41 @@
+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
+
+/**
+ * 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()
+}
+
+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..d56e469a 100644
--- a/libraries/authentication/domain/build.gradle.kts
+++ b/libraries/authentication/domain/build.gradle.kts
@@ -1,18 +1,51 @@
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
+
+ val commonTest by getting {
+ dependencies {
+ implementation(kotlin("test"))
+ implementation(libs.coroutines.test)
+ }
+ }
+
+ val jvmMain by getting
- // 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..49443f31
--- /dev/null
+++ b/libraries/authentication/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/domain/AuthenticationRepository.kt
@@ -0,0 +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/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/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/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/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/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 97%
rename from libraries/authentication/presentation/build.gradle.kts
rename to libraries/authentication/presentation/mobile/build.gradle.kts
index 9647acee..8f055869 100644
--- a/libraries/authentication/presentation/build.gradle.kts
+++ b/libraries/authentication/presentation/mobile/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/mobile/src/main/java/com/feragusper/smokeanalytics/libraries/authentication/presentation/compose/GoogleSignInComponent.kt
similarity index 99%
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
index ad5a4d23..558412fd 100644
--- 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
@@ -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/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..c98f3717
--- /dev/null
+++ b/libraries/authentication/presentation/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/authentication/presentation/compose/GoogleSignInComponentWeb.kt
@@ -0,0 +1,66 @@
+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
+
+/**
+ * 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,
+ 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..d0eb23bb
--- /dev/null
+++ b/libraries/logging/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/logging /AppLogger.kt
@@ -0,0 +1,39 @@
+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/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 85%
rename from libraries/smokes/data/build.gradle.kts
rename to libraries/smokes/data/mobile/build.gradle.kts
index eee52db1..c2827335 100644
--- a/libraries/smokes/data/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/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 75%
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..f58bbd70 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
@@ -1,7 +1,6 @@
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
@@ -18,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 {
@@ -41,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
@@ -51,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"
@@ -71,7 +74,7 @@ class SmokeRepositoryImplTest {
every {
collectionReference.orderBy(
- "date",
+ SmokeEntity.Fields.TIMESTAMP_MILLIS,
Query.Direction.DESCENDING
)
} returns collectionReference
@@ -82,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
)
),
@@ -103,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 {
@@ -119,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
@@ -137,7 +146,7 @@ class SmokeRepositoryImplTest {
}
}
- smokeRepository.editSmoke(id, localDateTime1)
+ smokeRepository.editSmoke(id, date)
}
@Test
@@ -145,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
@@ -163,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 {
@@ -182,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)
)
}
}
@@ -192,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/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..e1d8b096
--- /dev/null
+++ b/libraries/smokes/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeEntity.kt
@@ -0,0 +1,21 @@
+package com.feragusper.smokeanalytics.libraries.smokes.data
+
+import kotlinx.serialization.Serializable
+
+/**
+ * Represents a smoke entity.
+ *
+ * @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"
+ }
+}
\ 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..6c25fc15
--- /dev/null
+++ b/libraries/smokes/data/web/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/data/SmokeRepositoryImpl.kt
@@ -0,0 +1,136 @@
+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
+
+/**
+ * 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"
+ const val SMOKES = "smokes"
+ }
+ }
+
+ /**
+ * @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?,
+ ): 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)
+ )
+ }
+ }
+
+ /**
+ * @see SmokeRepository.fetchSmokeCount
+ */
+ 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..f9010580 100644
--- a/libraries/smokes/domain/build.gradle.kts
+++ b/libraries/smokes/domain/build.gradle.kts
@@ -1,21 +1,47 @@
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"))
+ implementation(libs.coroutines.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)
+ }
+ }
+ }
}
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..32c9f977
--- /dev/null
+++ b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/model/Smoke.kt
@@ -0,0 +1,16 @@
+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,
+ 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..564625d2
--- /dev/null
+++ b/libraries/smokes/domain/src/commonMain/kotlin/com/feragusper/smokeanalytics/libraries/smokes/domain/repository/SmokeRepository.kt
@@ -0,0 +1,53 @@
+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
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/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/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/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() }
- }
-}
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/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 d6382810..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,36 +38,41 @@ 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(
directions = setOf(RevealDirection.StartToEnd, RevealDirection.EndToStart),
- positionalThreshold = { it * 0.5f },
)
var showDatePicker by remember { mutableStateOf(false) }
@@ -100,7 +105,7 @@ fun SwipeToDismissRow(
hiddenContentEnd = {
IconButton(onClick = onDelete) {
Icon(
- imageVector = Icons.Default.Delete,
+ imageVector = Icons.Filled.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer
)
@@ -120,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 {
@@ -146,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 },
)
}
}
@@ -158,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)
@@ -172,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)} ${
@@ -196,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 -> {
@@ -236,9 +251,7 @@ fun DateTimePickerDialog(
DateTimeDialogType.Time -> {
TimePickerDialog(
initialDate = initialDateTime,
- onConfirm = { hour, minute ->
- onTimeSelected(hour, minute)
- },
+ onConfirm = { hour, minute -> onTimeSelected(hour, minute) },
onDismiss = onDismiss,
)
}
@@ -246,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,
@@ -292,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 = {
@@ -317,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")