Browse Source

Integrate Umami analytics and update project dependencies

- Added Umami analytics integration with a new core module for tracking events and user identification.
- Updated Gradle files to include the new core:umami module across various features.
- Introduced new dependencies for Moshi and updated existing ones for compatibility.
- Enhanced MainActivity and ViewModels to track analytics events during user interactions.
- Configured splash screen settings in themes.xml for improved user experience.
codeskraps 1 week ago
parent
commit
51fccf81d2
45 changed files with 956 additions and 167 deletions
  1. 1 0
      .idea/gradle.xml
  2. 1 1
      .idea/kotlinc.xml
  3. 3 1
      app/build.gradle.kts
  4. 79 17
      app/src/main/java/com/codeskraps/weather/ui/MainActivity.kt
  5. 4 0
      app/src/main/res/values/themes.xml
  6. 1 0
      build.gradle.kts
  7. 0 1
      core/local/build.gradle.kts
  8. 0 1
      core/location/build.gradle.kts
  9. 1 0
      core/umami/.gitignore
  10. 62 0
      core/umami/build.gradle.kts
  11. 0 0
      core/umami/consumer-rules.pro
  12. 21 0
      core/umami/proguard-rules.pro
  13. 24 0
      core/umami/src/androidTest/java/com/codeskraps/umami/ExampleInstrumentedTest.kt
  14. 5 0
      core/umami/src/main/AndroidManifest.xml
  15. 180 0
      core/umami/src/main/java/com/codeskraps/umami/data/remote/UmamiAnalyticsDataSource.kt
  16. 31 0
      core/umami/src/main/java/com/codeskraps/umami/data/repository/AnalyticsRepositoryImpl.kt
  17. 29 0
      core/umami/src/main/java/com/codeskraps/umami/data/repository/DeviceIdRepositoryImpl.kt
  18. 46 0
      core/umami/src/main/java/com/codeskraps/umami/di/UmamiModule.kt
  19. 8 0
      core/umami/src/main/java/com/codeskraps/umami/domain/AnalyticsRepository.kt
  20. 5 0
      core/umami/src/main/java/com/codeskraps/umami/domain/DeviceIdRepository.kt
  21. 17 0
      core/umami/src/test/java/com/codeskraps/umami/ExampleUnitTest.kt
  22. 1 1
      feature/common/build.gradle.kts
  23. 5 2
      feature/geocoding/build.gradle.kts
  24. 4 4
      feature/geocoding/src/main/java/com/codeskraps/feature/geocoding/data/mappers/GeocodingMappers.kt
  25. 41 2
      feature/geocoding/src/main/java/com/codeskraps/feature/geocoding/data/remote/GeocodingDto.kt
  26. 5 1
      feature/geocoding/src/main/java/com/codeskraps/feature/geocoding/di/FeatureModule.kt
  27. 57 5
      feature/geocoding/src/main/java/com/codeskraps/feature/geocoding/presentation/GeocodingViewModel.kt
  28. 2 5
      feature/geocoding/src/main/java/com/codeskraps/feature/geocoding/presentation/components/GeocodingScreen.kt
  29. 2 1
      feature/maps/build.gradle.kts
  30. 100 45
      feature/maps/src/main/java/com/codeskraps/maps/presentation/MapViewModel.kt
  31. 71 50
      feature/maps/src/main/java/com/codeskraps/maps/presentation/components/MapScreen.kt
  32. 1 0
      feature/maps/src/main/java/com/codeskraps/maps/presentation/mvi/MapAction.kt
  33. 1 0
      feature/maps/src/main/java/com/codeskraps/maps/presentation/mvi/MapEvent.kt
  34. 2 0
      feature/maps/src/main/java/com/codeskraps/maps/presentation/mvi/MapState.kt
  35. 3 1
      feature/weather/build.gradle.kts
  36. 1 0
      feature/weather/src/main/AndroidManifest.xml
  37. 11 11
      feature/weather/src/main/java/com/codeskraps/feature/weather/data/mappers/WeatherMappers.kt
  38. 15 2
      feature/weather/src/main/java/com/codeskraps/feature/weather/data/remote/WeatherDto.kt
  39. 7 4
      feature/weather/src/main/java/com/codeskraps/feature/weather/data/repository/WeatherRepositoryImpl.kt
  40. 26 0
      feature/weather/src/main/java/com/codeskraps/feature/weather/data/util/RetryUtil.kt
  41. 7 1
      feature/weather/src/main/java/com/codeskraps/feature/weather/di/FeatureModule.kt
  42. 54 4
      feature/weather/src/main/java/com/codeskraps/feature/weather/presentation/WeatherViewModel.kt
  43. 4 1
      feature/weather/src/main/java/com/codeskraps/feature/weather/presentation/components/WeatherForecast.kt
  44. 17 6
      gradle/libs.versions.toml
  45. 1 0
      settings.gradle.kts

+ 1 - 0
.idea/gradle.xml

@@ -26,6 +26,7 @@
             <option value="$PROJECT_DIR$/core" />
             <option value="$PROJECT_DIR$/core/local" />
             <option value="$PROJECT_DIR$/core/location" />
+            <option value="$PROJECT_DIR$/core/umami" />
             <option value="$PROJECT_DIR$/feature" />
             <option value="$PROJECT_DIR$/feature/common" />
             <option value="$PROJECT_DIR$/feature/geocoding" />

+ 1 - 1
.idea/kotlinc.xml

@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <project version="4">
   <component name="KotlinJpsPluginSettings">
-    <option name="version" value="2.1.20" />
+    <option name="version" value="2.0.21" />
   </component>
 </project>

+ 3 - 1
app/build.gradle.kts

@@ -1,11 +1,11 @@
 import com.codeskraps.weather.ConfigData
 
-@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
 plugins {
     alias(libs.plugins.android.application)
     alias(libs.plugins.org.jetbrains.kotlin.android)
     alias(libs.plugins.com.google.devtools.ksp)
     alias(libs.plugins.dagger.hilt)
+    alias(libs.plugins.compose.compiler)
 }
 
 android {
@@ -70,6 +70,7 @@ dependencies {
     implementation(project(mapOf("path" to ":feature:geocoding")))
     implementation(project(mapOf("path" to ":feature:weather")))
     implementation(project(mapOf("path" to ":feature:maps")))
+    implementation(project(mapOf("path" to ":core:umami")))
 
     implementation(libs.androidx.core.ktx)
     implementation(libs.androidx.lifecycle.runtime.ktx)
@@ -77,6 +78,7 @@ dependencies {
     implementation(libs.androidx.navigation.compose)
     implementation(libs.androidx.lifecycle.runtime.compose)
     implementation(libs.android.compose.material3)
+    implementation(libs.androidx.core.splashscreen)
     val composeBom = platform(libs.androidx.compose.bom)
     implementation(composeBom)
     implementation(libs.androidx.compose.ui)

+ 79 - 17
app/src/main/java/com/codeskraps/weather/ui/MainActivity.kt

@@ -1,42 +1,87 @@
 package com.codeskraps.weather.ui
 
 import android.os.Bundle
+import android.util.Log
+import android.view.View
 import androidx.activity.ComponentActivity
 import androidx.activity.compose.setContent
 import androidx.compose.animation.AnimatedContentTransitionScope
-import androidx.compose.animation.EnterTransition
 import androidx.compose.animation.core.tween
 import androidx.compose.runtime.getValue
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
 import androidx.hilt.navigation.compose.hiltViewModel
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import androidx.navigation.NavBackStackEntry
+import androidx.lifecycle.lifecycleScope
 import androidx.navigation.NavType
 import androidx.navigation.compose.NavHost
 import androidx.navigation.compose.composable
 import androidx.navigation.compose.rememberNavController
 import androidx.navigation.navArgument
-import com.codeskraps.maps.presentation.MapViewModel
-import com.codeskraps.maps.presentation.components.MapScreen
-import com.codeskraps.weather.ui.theme.WeatherTheme
 import com.codeskraps.feature.common.navigation.Screen
 import com.codeskraps.feature.geocoding.presentation.GeocodingViewModel
 import com.codeskraps.feature.geocoding.presentation.components.GeocodingScreen
 import com.codeskraps.feature.weather.presentation.WeatherViewModel
 import com.codeskraps.feature.weather.presentation.components.WeatherScreen
+import com.codeskraps.maps.presentation.MapViewModel
+import com.codeskraps.maps.presentation.components.MapScreen
+import com.codeskraps.umami.domain.AnalyticsRepository
 import com.codeskraps.weather.ui.theme.ScreenTransitions
+import com.codeskraps.weather.ui.theme.WeatherTheme
 import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.launch
+import javax.inject.Inject
 
 @AndroidEntryPoint
 class MainActivity : ComponentActivity() {
 
+    @Inject
+    lateinit var analyticsRepository: AnalyticsRepository
+
+    private var isAnalyticsInitialized = false
+
     override fun onCreate(savedInstanceState: Bundle?) {
+        val splashScreen = installSplashScreen()
         super.onCreate(savedInstanceState)
 
+        // Keep the splash screen visible until analytics initialization is complete
+        splashScreen.setKeepOnScreenCondition { !isAnalyticsInitialized }
+
+        // Initialize analytics in a coroutine
+        lifecycleScope.launch {
+            analyticsRepository.initialize()
+            isAnalyticsInitialized = true
+        }
+
+        // Set up the OnPreDrawListener to the root view
+        val content: View = findViewById(android.R.id.content)
+        content.viewTreeObserver.addOnPreDrawListener(
+            object : android.view.ViewTreeObserver.OnPreDrawListener {
+                override fun onPreDraw(): Boolean {
+                    // Check if analytics is initialized
+                    return if (isAnalyticsInitialized) {
+                        // The content is ready; remove the listener and start drawing
+                        content.viewTreeObserver.removeOnPreDrawListener(this)
+                        true
+                    } else {
+                        // The content isn't ready; suspend drawing
+                        false
+                    }
+                }
+            }
+        )
+
         setContent {
             WeatherTheme {
                 val navController = rememberNavController()
 
-                NavHost(navController = navController, startDestination = Screen.Weather.route) {
+                NavHost(
+                    navController = navController,
+                    startDestination = Screen.Weather.createRoute(
+                        name = "Current Location",
+                        lat = 0.0,
+                        long = 0.0
+                    )
+                ) {
                     composable(
                         route = Screen.Weather.route,
                         arguments = listOf(
@@ -49,7 +94,6 @@ class MainActivity : ComponentActivity() {
                         popEnterTransition = { ScreenTransitions.slideRightIntoContainer(this) },
                         popExitTransition = { ScreenTransitions.slideLeftOutOfContainer(this) }
                     ) {
-
                         val viewModel = hiltViewModel<WeatherViewModel>()
                         val state by viewModel.state.collectAsStateWithLifecycle()
 
@@ -84,22 +128,40 @@ class MainActivity : ComponentActivity() {
                     }
                     composable(
                         route = Screen.Map.route,
-                        enterTransition = { ScreenTransitions.slideRightIntoContainer(this) },
-                        exitTransition = { ScreenTransitions.slideRightOutOfContainer(this) },
-                        popEnterTransition = { ScreenTransitions.slideRightIntoContainer(this) },
-                        popExitTransition = { ScreenTransitions.slideRightOutOfContainer(this) }
+                        enterTransition = {
+                            slideIntoContainer(
+                                AnimatedContentTransitionScope.SlideDirection.Left,
+                                animationSpec = tween(700)
+                            )
+                        },
+                        exitTransition = {
+                            slideOutOfContainer(
+                                AnimatedContentTransitionScope.SlideDirection.Right,
+                                animationSpec = tween(700)
+                            )
+                        }
                     ) {
+                        Log.d("Navigation", "Attempting to navigate to Map screen")
                         val viewModel = hiltViewModel<MapViewModel>()
                         val state by viewModel.state.collectAsStateWithLifecycle()
-
                         MapScreen(
                             state = state,
-                            handleEvent = viewModel.state::handleEvent
-                        ) { route ->
-                            navController.navigate(route) {
-                                popUpTo(navController.graph.id) { inclusive = true }
+                            handleEvent = viewModel.state::handleEvent,
+                            action = viewModel.action,
+                            navRoute = { route ->
+                                when (route) {
+                                    "nav_up" -> {
+                                        Log.d("Navigation", "Map screen navigation up")
+                                        navController.navigateUp()
+                                    }
+
+                                    else -> {
+                                        Log.d("Navigation", "Map screen navigation to $route")
+                                        navController.navigate(route)
+                                    }
+                                }
                             }
-                        }
+                        )
                     }
                 }
             }

+ 4 - 0
app/src/main/res/values/themes.xml

@@ -2,5 +2,9 @@
 <resources xmlns:android="http://schemas.android.com/apk/res/android">
     <style name="Theme.Weather" parent="android:Theme.Material.Light.NoActionBar" >
         <item name="android:windowBackground">@color/window_background</item>
+        <item name="android:windowSplashScreenBackground">@color/window_background</item>
+        <item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
+        <item name="android:windowSplashScreenAnimationDuration">1000</item>
+        <item name="android:windowSplashScreenBehavior">icon_preferred</item>
     </style>
 </resources>

+ 1 - 0
build.gradle.kts

@@ -5,6 +5,7 @@ plugins {
     alias(libs.plugins.com.google.devtools.ksp) apply false
     alias(libs.plugins.dagger.hilt) apply false
     alias(libs.plugins.secrets.gradle.plugin) apply false
+    alias(libs.plugins.compose.compiler) apply false
 }
 
 tasks.register("clean", Delete::class) {

+ 0 - 1
core/local/build.gradle.kts

@@ -1,6 +1,5 @@
 import com.codeskraps.weather.ConfigData
 
-@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
 plugins {
     alias(libs.plugins.android.library)
     alias(libs.plugins.org.jetbrains.kotlin.android)

+ 0 - 1
core/location/build.gradle.kts

@@ -1,6 +1,5 @@
 import com.codeskraps.weather.ConfigData
 
-@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
 plugins {
     alias(libs.plugins.android.library)
     alias(libs.plugins.org.jetbrains.kotlin.android)

+ 1 - 0
core/umami/.gitignore

@@ -0,0 +1 @@
+/build

+ 62 - 0
core/umami/build.gradle.kts

@@ -0,0 +1,62 @@
+import com.codeskraps.weather.ConfigData
+
+plugins {
+    alias(libs.plugins.android.library)
+    alias(libs.plugins.org.jetbrains.kotlin.android)
+    alias(libs.plugins.com.google.devtools.ksp)
+    alias(libs.plugins.dagger.hilt)
+}
+
+android {
+    namespace = "com.codeskraps.umami"
+    compileSdk = ConfigData.compileSdk
+
+    defaultConfig {
+        minSdk = ConfigData.minSdk
+
+        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+        consumerProguardFiles("consumer-rules.pro")
+    }
+
+    buildTypes {
+        release {
+            isMinifyEnabled = ConfigData.isMinifyRelease
+            proguardFiles(
+                getDefaultProguardFile("proguard-android-optimize.txt"),
+                "proguard-rules.pro"
+            )
+        }
+        debug {
+            isMinifyEnabled = ConfigData.isMinifyDebug
+        }
+    }
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_19
+        targetCompatibility = JavaVersion.VERSION_19
+    }
+    kotlinOptions {
+        jvmTarget = JavaVersion.VERSION_19.toString()
+    }
+}
+
+dependencies {
+    implementation(libs.androidx.core.ktx)
+    implementation(libs.appcompat)
+    
+    // Coroutines
+    implementation(libs.kotlinx.coroutines.core)
+    implementation(libs.kotlinx.coroutines.android)
+    
+    // WebView
+    implementation(libs.androidx.webkit)
+
+    //Dagger - Hilt
+    implementation(libs.hilt.android)
+    ksp(libs.hilt.android.compiler)
+
+    // Testing
+    testImplementation(libs.junit.junit)
+    testImplementation(libs.coroutines.test)
+    androidTestImplementation(libs.androidx.junit)
+    androidTestImplementation(libs.espresso.core)
+}

+ 0 - 0
core/umami/consumer-rules.pro


+ 21 - 0
core/umami/proguard-rules.pro

@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+#   http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+#   public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile

+ 24 - 0
core/umami/src/androidTest/java/com/codeskraps/umami/ExampleInstrumentedTest.kt

@@ -0,0 +1,24 @@
+package com.codeskraps.umami
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+    @Test
+    fun useAppContext() {
+        // Context of the app under test.
+        val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+        assertEquals("com.codeskraps.umami.test", appContext.packageName)
+    }
+}

+ 5 - 0
core/umami/src/main/AndroidManifest.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <uses-permission android:name="android.permission.INTERNET" />
+</manifest>

+ 180 - 0
core/umami/src/main/java/com/codeskraps/umami/data/remote/UmamiAnalyticsDataSource.kt

@@ -0,0 +1,180 @@
+package com.codeskraps.umami.data.remote
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.webkit.WebView
+import androidx.webkit.WebViewClientCompat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.suspendCancellableCoroutine
+
+internal data class UmamiConfig(
+    val scriptUrl: String,
+    val websiteId: String,
+    val baseUrl: String
+)
+
+@SuppressLint("SetJavaScriptEnabled")
+internal class UmamiAnalyticsDataSource(
+    private val context: Context,
+    private val config: UmamiConfig
+) {
+
+    private var isInitialized = false
+
+    private val webView: WebView by lazy {
+        WebView(context).apply {
+            settings.javaScriptEnabled = true
+            webViewClient = UmamiWebViewClient()
+            loadUrl("about:blank")
+        }
+    }
+
+    private val umamiScript = """
+        <script defer src="${config.scriptUrl}" data-website-id="${config.websiteId}"></script>
+    """.trimIndent()
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    suspend fun initialize() = withContext(Dispatchers.Main) {
+        if (isInitialized) return@withContext
+
+        webView.loadDataWithBaseURL(
+            config.baseUrl,
+            "<html><head>$umamiScript</head><body></body></html>",
+            "text/html",
+            "UTF-8",
+            null
+        )
+
+        // Check if Umami is ready by evaluating JavaScript
+        var attempts = 0
+        val maxAttempts = 10 // Maximum number of attempts
+
+        while (attempts < maxAttempts) {
+            try {
+                val isUmamiReady = suspendCancellableCoroutine { continuation ->
+                    webView.evaluateJavascript(
+                        """
+                        (function() {
+                            return typeof umami !== 'undefined';
+                        })();
+                        """.trimIndent()
+                    ) { result ->
+                        continuation.resume(result.toBooleanStrictOrNull() ?: false, null)
+                    }
+                }
+
+                if (isUmamiReady) {
+                    isInitialized = true
+                    break
+                }
+            } catch (e: Exception) {
+                // Log error if needed
+            }
+
+            attempts++
+            delay(200) // Short delay between checks
+        }
+    }
+
+    suspend fun trackPageView(pageName: String) = withContext(Dispatchers.Main) {
+        if (!isInitialized) return@withContext
+
+        webView.evaluateJavascript(
+            """
+            (function() {
+                if (typeof umami === 'undefined') {
+                    console.error('Umami is not defined');
+                    return false;
+                }
+                
+                try {
+                    // Format the page name as a proper URL path
+                    const path = '$pageName'.startsWith('/') ? '$pageName' : '/$pageName';
+                    // Create a title from the page name (capitalize first letter, replace dashes with spaces)
+                    const title = '$pageName'
+                        .replace(/-/g, ' ')
+                        .replace(/\\b\\w/g, l => l.toUpperCase());
+                    
+                    umami.track({ 
+                        url: path, 
+                        title: title,
+                        website: '${config.websiteId}'
+                    });
+                    console.log('Page view tracked:', path, 'with title:', title);
+                    return true;
+                } catch (e) {
+                    console.error('Error tracking page view:', e);
+                    return false;
+                }
+            })();
+            """,
+            null
+        )
+    }
+
+    suspend fun trackEvent(eventName: String, eventData: Map<String, String> = emptyMap()) = withContext(Dispatchers.Main) {
+        if (!isInitialized) return@withContext
+
+        webView.evaluateJavascript(
+            """
+            (function() {
+                if (typeof umami === 'undefined') {
+                    console.error('Umami is not defined');
+                    return false;
+                }
+                
+                try {
+                    const data = ${eventData.entries.joinToString(",", "{", "}") {
+                "\"${it.key}\": \"${it.value}\""
+            }};
+                    umami.track('$eventName', data, '${config.websiteId}');
+                    console.log('Event tracked:', '$eventName', data);
+                    return true;
+                } catch (e) {
+                    console.error('Error tracking event:', e);
+                    return false;
+                }
+            })();
+            """,
+            null
+        )
+    }
+
+    suspend fun identifyUser(walletAddress: String?) = withContext(Dispatchers.Main) {
+        if (!isInitialized || walletAddress.isNullOrBlank()) return@withContext
+
+        // Anonymize the address by using only the first and last 4 characters
+        val addressLength = walletAddress.length
+        val anonymizedId = if (addressLength > 8) {
+            "${walletAddress.take(4)}...${walletAddress.takeLast(4)}"
+        } else {
+            walletAddress
+        }
+
+        webView.evaluateJavascript(
+            """
+            (function() {
+                if (typeof umami === 'undefined') {
+                    console.error('Umami is not defined');
+                    return false;
+                }
+                
+                try {
+                    umami.identify({ wallet_id: '$anonymizedId' }, '${config.websiteId}');
+                    console.log('User identified:', '$anonymizedId');
+                    return true;
+                } catch (e) {
+                    console.error('Error identifying user:', e);
+                    return false;
+                }
+            })();
+            """,
+            null
+        )
+    }
+
+    private class UmamiWebViewClient : WebViewClientCompat()
+}

+ 31 - 0
core/umami/src/main/java/com/codeskraps/umami/data/repository/AnalyticsRepositoryImpl.kt

@@ -0,0 +1,31 @@
+package com.codeskraps.umami.data.repository
+
+import com.codeskraps.umami.data.remote.UmamiAnalyticsDataSource
+import com.codeskraps.umami.domain.AnalyticsRepository
+import com.codeskraps.umami.domain.DeviceIdRepository
+import javax.inject.Inject
+
+internal class AnalyticsRepositoryImpl @Inject constructor(
+    private val analyticsDataSource: UmamiAnalyticsDataSource,
+    private val deviceIdRepository: DeviceIdRepository
+) : AnalyticsRepository {
+
+    override suspend fun initialize() {
+        analyticsDataSource.initialize()
+        // Identify the device as soon as analytics is initialized
+        val deviceId = deviceIdRepository.getOrCreateDeviceId()
+        identifyUser(deviceId)
+    }
+
+    override suspend fun trackPageView(pageName: String) {
+        analyticsDataSource.trackPageView(pageName)
+    }
+
+    override suspend fun trackEvent(eventName: String, eventData: Map<String, String>) {
+        analyticsDataSource.trackEvent(eventName, eventData)
+    }
+
+    override suspend fun identifyUser(walletAddress: String?) {
+        analyticsDataSource.identifyUser(walletAddress)
+    }
+}

+ 29 - 0
core/umami/src/main/java/com/codeskraps/umami/data/repository/DeviceIdRepositoryImpl.kt

@@ -0,0 +1,29 @@
+package com.codeskraps.umami.data.repository
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.codeskraps.umami.domain.DeviceIdRepository
+import java.util.UUID
+import javax.inject.Inject
+
+class DeviceIdRepositoryImpl @Inject constructor(
+    context: Context
+) : DeviceIdRepository {
+
+    private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+
+    override suspend fun getOrCreateDeviceId(): String {
+        return prefs.getString(KEY_DEVICE_ID, null) ?: generateAndSaveDeviceId()
+    }
+
+    private fun generateAndSaveDeviceId(): String {
+        val deviceId = UUID.randomUUID().toString()
+        prefs.edit().putString(KEY_DEVICE_ID, deviceId).apply()
+        return deviceId
+    }
+
+    companion object {
+        private const val PREFS_NAME = "weather_device_prefs"
+        private const val KEY_DEVICE_ID = "device_id"
+    }
+} 

+ 46 - 0
core/umami/src/main/java/com/codeskraps/umami/di/UmamiModule.kt

@@ -0,0 +1,46 @@
+package com.codeskraps.umami.di
+
+import android.app.Application
+import com.codeskraps.umami.data.remote.UmamiAnalyticsDataSource
+import com.codeskraps.umami.data.remote.UmamiConfig
+import com.codeskraps.umami.data.repository.AnalyticsRepositoryImpl
+import com.codeskraps.umami.data.repository.DeviceIdRepositoryImpl
+import com.codeskraps.umami.domain.AnalyticsRepository
+import com.codeskraps.umami.domain.DeviceIdRepository
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object CoreUmamiModule {
+
+    @Provides
+    @Singleton
+    fun providesDeviceIdRepository(
+        app: Application
+    ): DeviceIdRepository {
+        return DeviceIdRepositoryImpl(app)
+    }
+
+    @Provides
+    @Singleton
+    fun providesAnalyticsRepository(
+        app: Application,
+        deviceIdRepository: DeviceIdRepository
+    ): AnalyticsRepository {
+        return AnalyticsRepositoryImpl(
+            UmamiAnalyticsDataSource(
+                context = app,
+                config = UmamiConfig(
+                    scriptUrl = "https://umami.codeskraps.com/script.js",
+                    websiteId = "047c76b3-6f42-45f0-ba9b-772b056ccdc1",
+                    baseUrl = "https://umami.codeskraps.com"
+                )
+            ),
+            deviceIdRepository = deviceIdRepository
+        )
+    }
+}

+ 8 - 0
core/umami/src/main/java/com/codeskraps/umami/domain/AnalyticsRepository.kt

@@ -0,0 +1,8 @@
+package com.codeskraps.umami.domain
+
+interface AnalyticsRepository {
+    suspend fun initialize()
+    suspend fun trackPageView(pageName: String)
+    suspend fun trackEvent(eventName: String, eventData: Map<String, String> = emptyMap())
+    suspend fun identifyUser(walletAddress: String?)
+}

+ 5 - 0
core/umami/src/main/java/com/codeskraps/umami/domain/DeviceIdRepository.kt

@@ -0,0 +1,5 @@
+package com.codeskraps.umami.domain
+
+interface DeviceIdRepository {
+    suspend fun getOrCreateDeviceId(): String
+} 

+ 17 - 0
core/umami/src/test/java/com/codeskraps/umami/ExampleUnitTest.kt

@@ -0,0 +1,17 @@
+package com.codeskraps.umami
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+    @Test
+    fun addition_isCorrect() {
+        assertEquals(4, 2 + 2)
+    }
+}

+ 1 - 1
feature/common/build.gradle.kts

@@ -1,11 +1,11 @@
 import com.codeskraps.weather.ConfigData
 
-@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
 plugins {
     alias(libs.plugins.android.library)
     alias(libs.plugins.org.jetbrains.kotlin.android)
     alias(libs.plugins.com.google.devtools.ksp)
     alias(libs.plugins.dagger.hilt)
+    alias(libs.plugins.compose.compiler)
 }
 
 android {

+ 5 - 2
feature/geocoding/build.gradle.kts

@@ -1,11 +1,11 @@
 import com.codeskraps.weather.ConfigData
 
-@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
 plugins {
     alias(libs.plugins.android.library)
     alias(libs.plugins.org.jetbrains.kotlin.android)
     alias(libs.plugins.com.google.devtools.ksp)
     alias(libs.plugins.dagger.hilt)
+    alias(libs.plugins.compose.compiler)
 }
 
 android {
@@ -59,6 +59,7 @@ android {
 dependencies {
     implementation(project(mapOf("path" to ":feature:common")))
     implementation(project(mapOf("path" to ":core:local")))
+    implementation(project(mapOf("path" to ":core:umami")))
 
     implementation(libs.androidx.core.ktx)
     implementation(libs.androidx.lifecycle.runtime.ktx)
@@ -76,9 +77,11 @@ dependencies {
     implementation(libs.hilt.android)
     ksp(libs.hilt.android.compiler)
 
-    // Retrofit
+    // Retrofit & Moshi
     implementation(libs.retrofit.retrofit)
     implementation(libs.retrofit.converter.moshi)
+    implementation(libs.moshi.kotlin)
+    ksp(libs.moshi.kotlin.codegen)
 
     testImplementation(libs.junit.junit)
     androidTestImplementation(libs.androidx.junit)

+ 4 - 4
feature/geocoding/src/main/java/com/codeskraps/feature/geocoding/data/mappers/GeocodingMappers.kt

@@ -3,14 +3,14 @@ package com.codeskraps.feature.geocoding.data.mappers
 import com.codeskraps.core.local.domain.model.GeoLocation
 import com.codeskraps.feature.geocoding.data.remote.GeocodingDto
 
-fun GeocodingDto.toGeocoding(): List<com.codeskraps.core.local.domain.model.GeoLocation> {
+fun GeocodingDto.toGeocoding(): List<GeoLocation> {
     return results.map {
-        com.codeskraps.core.local.domain.model.GeoLocation(
+        GeoLocation(
             name = it.name,
             latitude = it.latitude,
             longitude = it.longitude,
-            country = it.country,
-            admin1 = it.admin1 ?: "",
+            country = it.country ?: "",
+            admin1 = it.admin1,
             cached = false
         )
     }

+ 41 - 2
feature/geocoding/src/main/java/com/codeskraps/feature/geocoding/data/remote/GeocodingDto.kt

@@ -1,5 +1,44 @@
 package com.codeskraps.feature.geocoding.data.remote
 
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
 data class GeocodingDto(
-    val results: List<GeoLocationDto>
-)
+    @Json(name = "results")
+    val results: List<Result>
+) {
+    @JsonClass(generateAdapter = true)
+    data class Result(
+        @Json(name = "id")
+        val id: Int,
+        @Json(name = "name")
+        val name: String,
+        @Json(name = "latitude")
+        val latitude: Double,
+        @Json(name = "longitude")
+        val longitude: Double,
+        @Json(name = "elevation")
+        val elevation: Double? = null,
+        @Json(name = "feature_code")
+        val featureCode: String? = null,
+        @Json(name = "country_code")
+        val countryCode: String? = null,
+        @Json(name = "admin1_id")
+        val admin1Id: Int? = null,
+        @Json(name = "admin2_id")
+        val admin2Id: Int? = null,
+        @Json(name = "timezone")
+        val timezone: String? = null,
+        @Json(name = "population")
+        val population: Int? = null,
+        @Json(name = "country_id")
+        val countryId: Int? = null,
+        @Json(name = "country")
+        val country: String? = null,
+        @Json(name = "admin1")
+        val admin1: String? = null,
+        @Json(name = "admin2")
+        val admin2: String? = null
+    )
+}

+ 5 - 1
feature/geocoding/src/main/java/com/codeskraps/feature/geocoding/di/FeatureModule.kt

@@ -1,6 +1,7 @@
 package com.codeskraps.feature.geocoding.di
 
 import com.codeskraps.feature.geocoding.data.remote.GeocodingApi
+import com.squareup.moshi.Moshi
 import dagger.Module
 import dagger.Provides
 import dagger.hilt.InstallIn
@@ -15,9 +16,12 @@ object FeatureModule {
 
     @Provides
     fun providesGeocodingApi(): GeocodingApi {
+        val moshi = Moshi.Builder()
+            .build()
+
         return Retrofit.Builder()
             .baseUrl("https://geocoding-api.open-meteo.com/")
-            .addConverterFactory(MoshiConverterFactory.create())
+            .addConverterFactory(MoshiConverterFactory.create(moshi))
             .build()
             .create()
     }

+ 57 - 5
feature/geocoding/src/main/java/com/codeskraps/feature/geocoding/presentation/GeocodingViewModel.kt

@@ -11,6 +11,7 @@ import com.codeskraps.feature.geocoding.repository.GeocodingRepository
 import com.codeskraps.feature.geocoding.presentation.mvi.GeoAction
 import com.codeskraps.feature.geocoding.presentation.mvi.GeoEvent
 import com.codeskraps.feature.geocoding.presentation.mvi.GeoState
+import com.codeskraps.umami.domain.AnalyticsRepository
 import dagger.hilt.android.lifecycle.HiltViewModel
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.delay
@@ -19,14 +20,35 @@ import javax.inject.Inject
 
 @HiltViewModel
 class GeocodingViewModel @Inject constructor(
-    private val localGeocodingRepository:LocalGeocodingRepository,
+    private val localGeocodingRepository: LocalGeocodingRepository,
     private val geocodingRepository: GeocodingRepository,
     private val localResources: LocalResourceRepository,
-    private val dispatcherProvider: DispatcherProvider
+    private val dispatcherProvider: DispatcherProvider,
+    private val analyticsRepository: AnalyticsRepository
 ) : StateReducerViewModel<GeoState, GeoEvent, GeoAction>(GeoState.initial) {
 
+    private companion object {
+        private const val ANALYTICS_GEO_SEARCH = "geocoding_search"
+        private const val ANALYTICS_GEO_SAVE = "geocoding_save"
+        private const val ANALYTICS_GEO_DELETE = "geocoding_delete"
+        private const val ANALYTICS_GEO_ERROR = "geocoding_error"
+        private const val ANALYTICS_GEO_CACHE_LOADED = "geocoding_cache_loaded"
+        
+        private const val PARAM_QUERY = "query"
+        private const val PARAM_RESULTS = "results_count"
+        private const val PARAM_LOCATION = "location"
+        private const val PARAM_ERROR = "error_message"
+        private const val PARAM_LOCATIONS_COUNT = "locations_count"
+    }
+
     private var searchJob: Job? = null
 
+    init {
+        viewModelScope.launch(dispatcherProvider.io) {
+            analyticsRepository.trackPageView("geocoding")
+        }
+    }
+
     override fun reduceState(
         currentState: GeoState,
         event: GeoEvent
@@ -48,7 +70,11 @@ class GeocodingViewModel @Inject constructor(
                     if (result.data.isNotEmpty()) {
                         val cachedGeoLocations = result.data.sortedBy { it.name }
                         state.handleEvent(GeoEvent.Loaded(cachedGeoLocations))
-
+                        
+                        analyticsRepository.trackEvent(
+                            ANALYTICS_GEO_CACHE_LOADED,
+                            mapOf(PARAM_LOCATIONS_COUNT to cachedGeoLocations.size.toString())
+                        )
                     } else {
                         state.handleEvent(GeoEvent.Error(localResources.getNoResultString()))
                     }
@@ -98,13 +124,23 @@ class GeocodingViewModel @Inject constructor(
             when (val result = geocodingRepository.getGeoLocation(query)) {
                 is Resource.Success -> {
                     if (result.data.isNotEmpty()) {
-                        state.handleEvent(GeoEvent.Loaded(result.data.map { mapped ->
+                        val mappedLocations = result.data.map { mapped ->
                             val cachedGeoLocations = loadCache()
                             val found = cachedGeoLocations.firstOrNull {
                                 it.longitude == mapped.longitude && it.latitude == mapped.latitude
                             }
                             mapped.copy(cached = found != null)
-                        }))
+                        }
+                        
+                        analyticsRepository.trackEvent(
+                            ANALYTICS_GEO_SEARCH,
+                            mapOf(
+                                PARAM_QUERY to query,
+                                PARAM_RESULTS to mappedLocations.size.toString()
+                            )
+                        )
+                        
+                        state.handleEvent(GeoEvent.Loaded(mappedLocations))
                     } else {
                         state.handleEvent(GeoEvent.Error(localResources.getNoResultString()))
                     }
@@ -126,6 +162,11 @@ class GeocodingViewModel @Inject constructor(
 
     private fun onSaveGeoLocation(currentState: GeoState, geoLocation: GeoLocation): GeoState {
         viewModelScope.launch(dispatcherProvider.io) {
+            analyticsRepository.trackEvent(
+                ANALYTICS_GEO_SAVE,
+                mapOf(PARAM_LOCATION to geoLocation.name)
+            )
+            
             val geoLocations = state.value.geoLocations
             when (localGeocodingRepository.saveCacheGeoLocation(geoLocation)) {
                 is Resource.Success -> {
@@ -146,6 +187,11 @@ class GeocodingViewModel @Inject constructor(
 
     private fun onDeleteGeoLocation(currentState: GeoState, geoLocation: GeoLocation): GeoState {
         viewModelScope.launch(dispatcherProvider.io) {
+            analyticsRepository.trackEvent(
+                ANALYTICS_GEO_DELETE,
+                mapOf(PARAM_LOCATION to geoLocation.name)
+            )
+            
             when (localGeocodingRepository.deleteCacheGeoLocation(geoLocation)) {
                 is Resource.Success -> {
                     val geoLocations = state.value.geoLocations
@@ -161,6 +207,12 @@ class GeocodingViewModel @Inject constructor(
     }
 
     private fun onHandleError(currentState: GeoState, message: String): GeoState {
+        viewModelScope.launch(dispatcherProvider.io) {
+            analyticsRepository.trackEvent(
+                ANALYTICS_GEO_ERROR,
+                mapOf(PARAM_ERROR to message)
+            )
+        }
         return currentState.copy(
             isLoading = false,
             error = message,

+ 2 - 5
feature/geocoding/src/main/java/com/codeskraps/feature/geocoding/presentation/components/GeocodingScreen.kt

@@ -19,11 +19,9 @@ import androidx.compose.foundation.text.KeyboardActions
 import androidx.compose.foundation.text.KeyboardOptions
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material.icons.filled.ArrowBack
 import androidx.compose.material.icons.filled.Favorite
 import androidx.compose.material.icons.filled.FavoriteBorder
 import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.Divider
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
@@ -36,7 +34,6 @@ import androidx.compose.material3.TopAppBarDefaults
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Alignment.Companion.CenterVertically
-import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.platform.LocalContext
@@ -56,7 +53,7 @@ import com.codeskraps.feature.geocoding.presentation.mvi.GeoEvent
 import com.codeskraps.feature.geocoding.presentation.mvi.GeoState
 import kotlinx.coroutines.flow.Flow
 
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
+@OptIn(ExperimentalMaterial3Api::class)
 @Composable
 fun GeocodingScreen(
     state: GeoState,
@@ -68,7 +65,7 @@ fun GeocodingScreen(
     val context = LocalContext.current
     val resources = context.resources
 
-    LifecycleResumeEffect {
+    LifecycleResumeEffect(Unit) {
         handleEvent(GeoEvent.LoadCache)
         onPauseOrDispose { }
     }

+ 2 - 1
feature/maps/build.gradle.kts

@@ -1,12 +1,12 @@
 import com.codeskraps.weather.ConfigData
 
-@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
 plugins {
     alias(libs.plugins.android.library)
     alias(libs.plugins.org.jetbrains.kotlin.android)
     alias(libs.plugins.com.google.devtools.ksp)
     alias(libs.plugins.dagger.hilt)
     alias(libs.plugins.secrets.gradle.plugin)
+    alias(libs.plugins.compose.compiler)
 }
 
 android {
@@ -67,6 +67,7 @@ dependencies {
     implementation(project(mapOf("path" to ":feature:common")))
     implementation(project(mapOf("path" to ":core:location")))
     implementation(project(mapOf("path" to ":core:local")))
+    implementation(project(mapOf("path" to ":core:umami")))
 
     implementation(libs.androidx.core.ktx)
     implementation(libs.androidx.lifecycle.runtime.ktx)

+ 100 - 45
feature/maps/src/main/java/com/codeskraps/maps/presentation/MapViewModel.kt

@@ -4,13 +4,14 @@ import androidx.lifecycle.viewModelScope
 import com.codeskraps.core.local.domain.model.GeoLocation
 import com.codeskraps.core.local.domain.repository.LocalGeocodingRepository
 import com.codeskraps.core.local.domain.repository.LocalResourceRepository
+import com.codeskraps.core.location.domain.LocationTracker
 import com.codeskraps.feature.common.dispatcher.DispatcherProvider
 import com.codeskraps.feature.common.mvi.StateReducerViewModel
 import com.codeskraps.feature.common.util.Resource
-import com.codeskraps.core.location.domain.LocationTracker
 import com.codeskraps.maps.presentation.mvi.MapAction
 import com.codeskraps.maps.presentation.mvi.MapEvent
 import com.codeskraps.maps.presentation.mvi.MapState
+import com.codeskraps.umami.domain.AnalyticsRepository
 import com.google.android.gms.maps.model.LatLng
 import dagger.hilt.android.lifecycle.HiltViewModel
 import kotlinx.coroutines.launch
@@ -22,63 +23,117 @@ class MapViewModel @Inject constructor(
     private val localGeocodingRepository: LocalGeocodingRepository,
     private val localResources: LocalResourceRepository,
     private val dispatcherProvider: DispatcherProvider,
+    private val analytics: AnalyticsRepository
 ) : StateReducerViewModel<MapState, MapEvent, MapAction>(MapState.initial) {
 
-    override fun reduceState(currentState: MapState, event: MapEvent): MapState {
-        return when (event) {
-            is MapEvent.Resume -> onResume(currentState)
-            is MapEvent.Location -> onLocation(currentState, event.location)
-            is MapEvent.LoadCache -> onLoadCache(currentState)
-            is MapEvent.Loaded -> onGeoLocationsLoaded(currentState, event.geoLocations)
-            is MapEvent.Error -> currentState.copy(error = event.message)
-        }
+    private companion object {
+        private const val TAG = "WeatherApp:MapViewModel"
+        private const val SCREEN_NAME = "map_screen"
+        private const val EVENT_LOCATION_UPDATED = "map_location_updated"
+        private const val EVENT_LOCATIONS_LOADED = "map_locations_loaded"
+        private const val EVENT_ERROR = "map_error"
+
+        private const val PARAM_LATITUDE = "latitude"
+        private const val PARAM_LONGITUDE = "longitude"
+        private const val PARAM_ERROR = "error_message"
+        private const val PARAM_LOCATIONS_COUNT = "locations_count"
     }
 
-    private fun onResume(currentState: MapState): MapState {
+    init {
+        android.util.Log.i(TAG, "Initializing MapViewModel")
         viewModelScope.launch(dispatcherProvider.io) {
-            locationTracker.getCurrentLocation()?.let {
-                state.handleEvent(MapEvent.Location(LatLng(it.latitude, it.longitude)))
-            }
+            analytics.trackPageView(SCREEN_NAME)
         }
-        state.handleEvent(MapEvent.LoadCache)
-        return currentState
     }
 
-    private fun onLocation(currentState: MapState, location: LatLng): MapState {
-        return currentState.copy(
-            location = location
-        )
-    }
-
-    private fun onLoadCache(currentState: MapState): MapState {
-        viewModelScope.launch(dispatcherProvider.io) {
-            when (val result = localGeocodingRepository.getCachedGeoLocation()) {
-                is Resource.Success -> {
-                    if (result.data.isNotEmpty()) {
-                        val cachedGeoLocations = result.data.sortedBy { it.name }
-                        state.handleEvent(MapEvent.Loaded(cachedGeoLocations))
-
-                    } else {
-                        state.handleEvent(MapEvent.Error(localResources.getNoResultString()))
-                    }
+    override fun reduceState(currentState: MapState, event: MapEvent): MapState {
+        android.util.Log.i(TAG, "Reducing state for event: $event, current state: $currentState")
+        
+        return when (event) {
+            MapEvent.Resume -> {
+                android.util.Log.i(TAG, "Handling Resume event")
+                viewModelScope.launch(dispatcherProvider.io) {
+                    locationTracker.getCurrentLocation()?.let {
+                        android.util.Log.i(TAG, "Current location obtained: ${it.latitude}, ${it.longitude}")
+                        state.handleEvent(MapEvent.Location(LatLng(it.latitude, it.longitude)))
+                    } ?: android.util.Log.w(TAG, "Failed to get current location")
+                    loadCachedLocations()
                 }
-
-                is Resource.Error -> {
-                    state.handleEvent(MapEvent.Error(localResources.getNoResultString()))
-                    actionChannel.send(MapAction.ShowToast(localResources.getIssueLoadingCache()))
+                currentState.copy(isLoading = true)
+            }
+            is MapEvent.Location -> {
+                android.util.Log.i(TAG, "Handling Location event: ${event.location}")
+                viewModelScope.launch(dispatcherProvider.io) {
+                    analytics.trackEvent(
+                        EVENT_LOCATION_UPDATED,
+                        mapOf(
+                            PARAM_LATITUDE to event.location.latitude.toString(),
+                            PARAM_LONGITUDE to event.location.longitude.toString()
+                        )
+                    )
+                }
+                currentState.copy(location = event.location)
+            }
+            MapEvent.LoadCache -> {
+                android.util.Log.i(TAG, "Handling LoadCache event")
+                viewModelScope.launch(dispatcherProvider.io) {
+                    loadCachedLocations()
+                }
+                currentState.copy(isLoading = true)
+            }
+            is MapEvent.Loaded -> {
+                android.util.Log.i(TAG, "Handling Loaded event with ${event.geoLocations.size} locations")
+                viewModelScope.launch(dispatcherProvider.io) {
+                    analytics.trackEvent(
+                        EVENT_LOCATIONS_LOADED,
+                        mapOf(PARAM_LOCATIONS_COUNT to event.geoLocations.size.toString())
+                    )
+                }
+                currentState.copy(
+                    geoLocations = event.geoLocations,
+                    error = null,
+                    isLoading = false
+                )
+            }
+            is MapEvent.Error -> {
+                android.util.Log.e(TAG, "Handling Error event: ${event.message}")
+                viewModelScope.launch(dispatcherProvider.io) {
+                    analytics.trackEvent(EVENT_ERROR, mapOf(PARAM_ERROR to event.message))
+                    actionChannel.send(MapAction.ShowToast(event.message))
                 }
+                currentState.copy(
+                    error = event.message,
+                    isLoading = false
+                )
+            }
+            MapEvent.NavigateUp -> {
+                android.util.Log.i(TAG, "Handling NavigateUp event")
+                viewModelScope.launch {
+                    actionChannel.send(MapAction.NavigateUp)
+                }
+                currentState
             }
         }
-        return currentState
     }
 
-    private fun onGeoLocationsLoaded(
-        currentState: MapState,
-        geoLocations: List<GeoLocation>
-    ): MapState {
-        return currentState.copy(
-            geoLocations = geoLocations,
-            error = null
-        )
+    private suspend fun loadCachedLocations() {
+        android.util.Log.i(TAG, "Loading cached locations")
+        when (val result = localGeocodingRepository.getCachedGeoLocation()) {
+            is Resource.Success -> {
+                if (result.data.isNotEmpty()) {
+                    android.util.Log.i(TAG, "Successfully loaded ${result.data.size} locations")
+                    val cachedGeoLocations = result.data.sortedBy { it.name }
+                    state.handleEvent(MapEvent.Loaded(cachedGeoLocations))
+                } else {
+                    android.util.Log.w(TAG, "No cached locations found")
+                    state.handleEvent(MapEvent.Error(localResources.getNoResultString()))
+                }
+            }
+            is Resource.Error -> {
+                android.util.Log.e(TAG, "Error loading cached locations: ${result.message}")
+                state.handleEvent(MapEvent.Error(localResources.getNoResultString()))
+                actionChannel.send(MapAction.ShowToast(localResources.getIssueLoadingCache()))
+            }
+        }
     }
 }

+ 71 - 50
feature/maps/src/main/java/com/codeskraps/maps/presentation/components/MapScreen.kt

@@ -1,7 +1,7 @@
 package com.codeskraps.maps.presentation.components
 
 import android.content.res.Resources
-import android.graphics.Bitmap
+import android.widget.Toast
 import androidx.activity.compose.BackHandler
 import androidx.annotation.ColorInt
 import androidx.annotation.DrawableRes
@@ -28,47 +28,67 @@ import androidx.compose.ui.graphics.toArgb
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.unit.dp
 import androidx.core.content.res.ResourcesCompat
+import androidx.core.graphics.createBitmap
 import androidx.core.graphics.drawable.DrawableCompat
 import androidx.lifecycle.compose.LifecycleResumeEffect
-import com.codeskraps.feature.common.R
+import com.codeskraps.feature.common.components.ObserveAsEvents
 import com.codeskraps.feature.common.navigation.Screen
+import com.codeskraps.feature.common.R
+import com.codeskraps.maps.presentation.mvi.MapAction
 import com.codeskraps.maps.presentation.mvi.MapEvent
 import com.codeskraps.maps.presentation.mvi.MapState
 import com.google.android.gms.maps.model.BitmapDescriptor
 import com.google.android.gms.maps.model.BitmapDescriptorFactory
 import com.google.android.gms.maps.model.CameraPosition
 import com.google.android.gms.maps.model.LatLng
-import com.google.maps.android.compose.CameraPositionState
 import com.google.maps.android.compose.GoogleMap
 import com.google.maps.android.compose.Marker
 import com.google.maps.android.compose.MarkerState
 import com.google.maps.android.compose.rememberCameraPositionState
+import kotlinx.coroutines.flow.Flow
 
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
 fun MapScreen(
     state: MapState,
     handleEvent: (MapEvent) -> Unit,
+    action: Flow<MapAction>,
     navRoute: (String) -> Unit
 ) {
     val context = LocalContext.current
     val resources = context.resources
+    val tag = "WeatherApp:MapScreen"
 
-    var cameraPositionState: CameraPositionState? = null
-    if (state.location != null) {
-        val location = state.location
-        cameraPositionState = rememberCameraPositionState {
-            position = CameraPosition.fromLatLngZoom(location, 10f)
+    ObserveAsEvents(action) { mapAction ->
+        when (mapAction) {
+            is MapAction.ShowToast -> {
+                android.util.Log.i(tag, "Showing toast: ${mapAction.message}")
+                Toast.makeText(context, mapAction.message, Toast.LENGTH_SHORT).show()
+            }
+            MapAction.NavigateUp -> {
+                android.util.Log.i(tag, "Received NavigateUp action")
+                navRoute("nav_up")
+            }
         }
     }
 
+    val cameraPositionState = rememberCameraPositionState {
+        position = state.location?.let {
+            CameraPosition.fromLatLngZoom(it, 10f)
+        } ?: CameraPosition.fromLatLngZoom(LatLng(51.5074, -0.1278), 10f)
+    }
+
     LifecycleResumeEffect(Unit) {
+        android.util.Log.i(tag, "Screen resumed, sending Resume event")
         handleEvent(MapEvent.Resume)
-        onPauseOrDispose {}
+        onPauseOrDispose {
+            android.util.Log.i(tag, "Screen paused/disposed")
+        }
     }
 
     BackHandler {
-        navRoute(Screen.Weather.route)
+        android.util.Log.i(tag, "Back pressed, sending NavigateUp event")
+        handleEvent(MapEvent.NavigateUp)
     }
 
     Scaffold(
@@ -82,7 +102,12 @@ fun MapScreen(
                     Text(resources.getString(R.string.map_location))
                 },
                 navigationIcon = {
-                    IconButton(onClick = { navRoute(Screen.Weather.route) }) {
+                    IconButton(
+                        onClick = {
+                            android.util.Log.i(tag, "Back button clicked, sending NavigateUp event")
+                            handleEvent(MapEvent.NavigateUp)
+                        }
+                    ) {
                         Icon(
                             Icons.AutoMirrored.Filled.ArrowBack,
                             tint = MaterialTheme.colorScheme.primary,
@@ -91,20 +116,22 @@ fun MapScreen(
                     }
                 },
                 actions = {
-                    if (cameraPositionState == null) {
+                    if (state.isLoading) {
                         CircularProgressIndicator(
                             modifier = Modifier.padding(15.dp)
                         )
                     } else {
-                        IconButton(onClick = {
-                            navRoute(
-                                Screen.Weather.createRoute(
+                        IconButton(
+                            onClick = {
+                                val route = Screen.Weather.createRoute(
                                     resources.getString(R.string.map_location),
                                     cameraPositionState.position.target.latitude,
                                     cameraPositionState.position.target.longitude
                                 )
-                            )
-                        }) {
+                                android.util.Log.i(tag, "Add location clicked, navigating to route: $route")
+                                navRoute(route)
+                            }
+                        ) {
                             Icon(
                                 imageVector = Icons.Default.Add,
                                 tint = MaterialTheme.colorScheme.primary,
@@ -116,39 +143,36 @@ fun MapScreen(
             )
         }
     ) { paddingValues ->
-
-        if (cameraPositionState != null) {
-            Box(Modifier.fillMaxSize()) {
-                GoogleMap(
-                    modifier = Modifier
-                        .fillMaxSize()
-                        .padding(paddingValues),
-                    cameraPositionState = cameraPositionState
-                ) {
-                    state.geoLocations.forEach { geoLocation ->
-                        Marker(
-                            state = MarkerState(
-                                position = LatLng(
-                                    geoLocation.latitude,
-                                    geoLocation.longitude
-                                )
-                            ),
-                            title = geoLocation.name,
-                            icon = vectorToBitmap(
-                                resources = resources,
-                                id = R.drawable.ic_location,
-                                color = MaterialTheme.colorScheme.surface.toArgb()
+        Box(Modifier.fillMaxSize()) {
+            GoogleMap(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .padding(paddingValues),
+                cameraPositionState = cameraPositionState
+            ) {
+                state.geoLocations.forEach { geoLocation ->
+                    Marker(
+                        state = MarkerState(
+                            position = LatLng(
+                                geoLocation.latitude,
+                                geoLocation.longitude
                             )
+                        ),
+                        title = geoLocation.name,
+                        icon = vectorToBitmap(
+                            resources = resources,
+                            id = R.drawable.ic_location,
+                            color = MaterialTheme.colorScheme.surface.toArgb()
                         )
-                    }
+                    )
                 }
-                Icon(
-                    imageVector = Icons.Default.Place,
-                    tint = MaterialTheme.colorScheme.surface,
-                    contentDescription = resources.getString(R.string.map_location),
-                    modifier = Modifier.align(Alignment.Center)
-                )
             }
+            Icon(
+                imageVector = Icons.Default.Place,
+                tint = MaterialTheme.colorScheme.surface,
+                contentDescription = resources.getString(R.string.map_location),
+                modifier = Modifier.align(Alignment.Center)
+            )
         }
     }
 }
@@ -159,10 +183,7 @@ private fun vectorToBitmap(
     @ColorInt color: Int
 ): BitmapDescriptor {
     val vectorDrawable = ResourcesCompat.getDrawable(resources, id, null)
-    val bitmap = Bitmap.createBitmap(
-        vectorDrawable!!.intrinsicWidth,
-        vectorDrawable.intrinsicHeight, Bitmap.Config.ARGB_8888
-    )
+    val bitmap = createBitmap(vectorDrawable!!.intrinsicWidth, vectorDrawable.intrinsicHeight)
     val canvas = android.graphics.Canvas(bitmap)
     vectorDrawable.setBounds(0, 0, canvas.width, canvas.height)
     DrawableCompat.setTint(vectorDrawable, color)

+ 1 - 0
feature/maps/src/main/java/com/codeskraps/maps/presentation/mvi/MapAction.kt

@@ -2,4 +2,5 @@ package com.codeskraps.maps.presentation.mvi
 
 sealed interface MapAction {
     data class ShowToast(val message: String) : MapAction
+    data object NavigateUp : MapAction
 }

+ 1 - 0
feature/maps/src/main/java/com/codeskraps/maps/presentation/mvi/MapEvent.kt

@@ -9,4 +9,5 @@ sealed interface MapEvent {
     data object LoadCache : MapEvent
     data class Loaded(val geoLocations: List<GeoLocation>) : MapEvent
     data class Error(val message: String) : MapEvent
+    data object NavigateUp : MapEvent
 }

+ 2 - 0
feature/maps/src/main/java/com/codeskraps/maps/presentation/mvi/MapState.kt

@@ -4,12 +4,14 @@ import com.codeskraps.core.local.domain.model.GeoLocation
 import com.google.android.gms.maps.model.LatLng
 
 data class MapState(
+    val isLoading: Boolean,
     val location: LatLng?,
     val geoLocations: List<GeoLocation>,
     val error: String?,
 ) {
     companion object {
         val initial = MapState(
+            isLoading = false,
             location = null,
             geoLocations = emptyList(),
             error = null

+ 3 - 1
feature/weather/build.gradle.kts

@@ -1,11 +1,11 @@
 import com.codeskraps.weather.ConfigData
 
-@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
 plugins {
     alias(libs.plugins.android.library)
     alias(libs.plugins.org.jetbrains.kotlin.android)
     alias(libs.plugins.com.google.devtools.ksp)
     alias(libs.plugins.dagger.hilt)
+    alias(libs.plugins.compose.compiler)
 }
 
 android {
@@ -60,6 +60,7 @@ dependencies {
     implementation(project(mapOf("path" to ":feature:common")))
     implementation(project(mapOf("path" to ":core:location")))
     implementation(project(mapOf("path" to ":core:local")))
+    implementation(project(mapOf("path" to ":core:umami")))
 
     implementation(libs.androidx.core.ktx)
     implementation(libs.androidx.lifecycle.runtime.ktx)
@@ -85,6 +86,7 @@ dependencies {
     // Retrofit
     implementation(libs.retrofit.retrofit)
     implementation(libs.retrofit.converter.moshi)
+    implementation(libs.moshi.kotlin)
 
     implementation(libs.coroutines.test)
     testImplementation(libs.junit.junit)

+ 1 - 0
feature/weather/src/main/AndroidManifest.xml

@@ -1,4 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
 <manifest xmlns:android="http://schemas.android.com/apk/res/android">
 
+    <uses-permission android:name="android.permission.INTERNET" />
 </manifest>

+ 11 - 11
feature/weather/src/main/java/com/codeskraps/feature/weather/data/mappers/WeatherMappers.kt

@@ -1,8 +1,8 @@
 package com.codeskraps.feature.weather.data.mappers
 
 import com.codeskraps.core.local.domain.model.GeoLocation
+import com.codeskraps.feature.weather.data.remote.HourlyDto
 import com.codeskraps.feature.weather.data.remote.SunDataDto
-import com.codeskraps.feature.weather.data.remote.WeatherDataDto
 import com.codeskraps.feature.weather.data.remote.WeatherDto
 import com.codeskraps.feature.weather.domain.model.SunData
 import com.codeskraps.feature.weather.domain.model.WeatherData
@@ -21,14 +21,14 @@ private data class IndexWeatherData(
     val data: WeatherData
 )
 
-fun WeatherDataDto.toWeatherDataMap(sunData: SunData): ImmutableMap<Int, ImmutableList<WeatherData>> {
+fun HourlyDto.toWeatherDataMap(sunData: SunData): ImmutableMap<Int, ImmutableList<WeatherData>> {
     return time.mapIndexed { index, time ->
         val localDateTime = LocalDateTime.parse(time, DateTimeFormatter.ISO_DATE_TIME)
-        val weatherCode = weatherCodes[index]
+        val weatherCode = weathercode[index]
         val sunrise = sunData.sunrise.first { it.dayOfMonth == localDateTime.dayOfMonth }
         val sunset = sunData.sunset.first { it.dayOfMonth == localDateTime.dayOfMonth }
         val isDay = isDay(localDateTime, sunrise, sunset)
-        val temperature = temperatures[index]
+        val temperature = temperature_2m[index]
         val isFreezing = isFreezing(temperature)
 
         IndexWeatherData(
@@ -36,9 +36,9 @@ fun WeatherDataDto.toWeatherDataMap(sunData: SunData): ImmutableMap<Int, Immutab
             data = WeatherData(
                 time = localDateTime,
                 temperatureCelsius = temperature,
-                pressure = pressures[index],
-                windSpeed = windSpeeds[index],
-                humidity = humidities[index],
+                pressure = pressure_msl[index],
+                windSpeed = windspeed_10m[index],
+                humidity = relativehumidity_2m[index],
                 weatherType = WeatherType.fromWMO(
                     weatherCode,
                     isDay,
@@ -71,11 +71,11 @@ private fun SunDataDto.sunData(): SunData {
 }
 
 fun WeatherDto.toWeatherInfo(): WeatherInfo {
-    val weatherDataMap = weatherData.toWeatherDataMap(sunData.sunData())
+    val weatherDataMap = hourly.toWeatherDataMap(daily.sunData())
     val now = LocalDateTime.now()
-    val currentWeatherData = weatherDataMap[0]?.find {
-        val hour = if (now.minute < 30) now.hour else now.hour + 1
-        it.time.hour == hour
+    val currentWeatherData = weatherDataMap[0]?.minByOrNull { weatherData ->
+        val timeDiff = kotlin.math.abs(weatherData.time.hour - now.hour)
+        if (timeDiff > 12) 24 - timeDiff else timeDiff  // Handle cases around midnight
     }
     return WeatherInfo(
         geoLocation = "",

+ 15 - 2
feature/weather/src/main/java/com/codeskraps/feature/weather/data/remote/WeatherDto.kt

@@ -4,7 +4,20 @@ import com.squareup.moshi.Json
 
 data class WeatherDto(
     @field:Json(name = "hourly")
-    val weatherData: WeatherDataDto,
+    val hourly: HourlyDto,
     @field:Json(name = "daily")
-    val sunData: SunDataDto
+    val daily: SunDataDto
+)
+
+data class HourlyDto(
+    val time: List<String>,
+    @field:Json(name = "temperature_2m")
+    val temperature_2m: List<Double>,
+    val weathercode: List<Int>,
+    @field:Json(name = "pressure_msl")
+    val pressure_msl: List<Double>,
+    @field:Json(name = "windspeed_10m")
+    val windspeed_10m: List<Double>,
+    @field:Json(name = "relativehumidity_2m")
+    val relativehumidity_2m: List<Double>
 )

+ 7 - 4
feature/weather/src/main/java/com/codeskraps/feature/weather/data/repository/WeatherRepositoryImpl.kt

@@ -4,6 +4,7 @@ import com.codeskraps.core.local.domain.repository.LocalResourceRepository
 import com.codeskraps.feature.common.util.Resource
 import com.codeskraps.feature.weather.data.mappers.toWeatherInfo
 import com.codeskraps.feature.weather.data.remote.WeatherApi
+import com.codeskraps.feature.weather.data.util.retryWithExponentialBackoff
 import com.codeskraps.feature.weather.domain.model.WeatherInfo
 import com.codeskraps.feature.weather.domain.repository.WeatherRepository
 import javax.inject.Inject
@@ -15,10 +16,12 @@ class WeatherRepositoryImpl @Inject constructor(
     override suspend fun getWeatherData(lat: Double, long: Double): Resource<WeatherInfo> {
         return try {
             Resource.Success(
-                data = api.getWeatherData(
-                    lat = lat,
-                    long = long
-                ).toWeatherInfo()
+                data = retryWithExponentialBackoff {
+                    api.getWeatherData(
+                        lat = lat,
+                        long = long
+                    ).toWeatherInfo()
+                }
             )
         } catch (e: Exception) {
             e.printStackTrace()

+ 26 - 0
feature/weather/src/main/java/com/codeskraps/feature/weather/data/util/RetryUtil.kt

@@ -0,0 +1,26 @@
+package com.codeskraps.feature.weather.data.util
+
+import kotlinx.coroutines.delay
+import kotlin.math.pow
+
+suspend fun <T> retryWithExponentialBackoff(
+    maxAttempts: Int = 3,
+    initialDelayMs: Long = 1000,
+    maxDelayMs: Long = 5000,
+    factor: Double = 2.0,
+    block: suspend () -> T
+): T {
+    var currentDelay = initialDelayMs
+    repeat(maxAttempts - 1) { attempt ->
+        try {
+            return block()
+        } catch (e: Exception) {
+            // On last attempt, throw the exception
+            if (attempt == maxAttempts - 2) throw e
+            
+            delay(currentDelay)
+            currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelayMs)
+        }
+    }
+    return block() // Last attempt
+} 

+ 7 - 1
feature/weather/src/main/java/com/codeskraps/feature/weather/di/FeatureModule.kt

@@ -1,6 +1,8 @@
 package com.codeskraps.feature.weather.di
 
 import com.codeskraps.feature.weather.data.remote.WeatherApi
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
 import dagger.Module
 import dagger.Provides
 import dagger.hilt.InstallIn
@@ -15,9 +17,13 @@ object FeatureModule {
 
     @Provides
     fun providesWeatherApi(): WeatherApi {
+        val moshi = Moshi.Builder()
+            .add(KotlinJsonAdapterFactory())
+            .build()
+
         return Retrofit.Builder()
             .baseUrl("https://api.open-meteo.com/")
-            .addConverterFactory(MoshiConverterFactory.create())
+            .addConverterFactory(MoshiConverterFactory.create(moshi))
             .build()
             .create()
     }

+ 54 - 4
feature/weather/src/main/java/com/codeskraps/feature/weather/presentation/WeatherViewModel.kt

@@ -16,6 +16,7 @@ import com.codeskraps.feature.weather.presentation.mvi.WeatherAction
 import com.codeskraps.feature.weather.presentation.mvi.WeatherEvent
 import com.codeskraps.feature.weather.presentation.mvi.WeatherState
 import com.codeskraps.core.location.domain.LocationTracker
+import com.codeskraps.umami.domain.AnalyticsRepository
 import dagger.hilt.android.lifecycle.HiltViewModel
 import kotlinx.coroutines.launch
 import javax.inject.Inject
@@ -27,9 +28,21 @@ class WeatherViewModel @Inject constructor(
     private val locationTracker: LocationTracker,
     private val localResource: LocalResourceRepository,
     private val dispatcherProvider: DispatcherProvider,
-    private val savedStateHandle: SavedStateHandle
+    private val savedStateHandle: SavedStateHandle,
+    private val analyticsRepository: AnalyticsRepository
 ) : StateReducerViewModel<WeatherState, WeatherEvent, WeatherAction>(WeatherState.initial) {
 
+    private companion object {
+        private const val ANALYTICS_WEATHER_LOAD = "weather_load"
+        private const val ANALYTICS_WEATHER_REFRESH = "weather_refresh"
+        private const val ANALYTICS_LOCATION_SAVE = "location_save"
+        private const val ANALYTICS_LOCATION_DELETE = "location_delete"
+        private const val ANALYTICS_ERROR = "weather_error"
+        
+        private const val PARAM_LOCATION = "location"
+        private const val PARAM_ERROR = "error_message"
+        private const val PARAM_IS_CURRENT_LOCATION = "is_current_location"
+    }
 
     private var currentLocationString: String = ""
 
@@ -43,7 +56,6 @@ class WeatherViewModel @Inject constructor(
         currentState: WeatherState,
         event: WeatherEvent
     ): WeatherState {
-        //Log.v("WeatherViewModel", "event: $event, state: ${state.value.weatherInfo?.geoLocation}")
         return when (event) {
             is WeatherEvent.LoadWeatherInfo -> onLoadWeatherInfo(currentState, event.geoLocation)
             is WeatherEvent.UpdateHourlyInfo -> onUpdateHourlyInfo(currentState, event.weatherInfo)
@@ -63,9 +75,21 @@ class WeatherViewModel @Inject constructor(
         geoLocation: WeatherLocation
     ): WeatherState {
         viewModelScope.launch(dispatcherProvider.io) {
-            val location = if (geoLocation.lat == .0 || geoLocation.long == .0) {
+            val isCurrentLocation = geoLocation.lat == .0 || geoLocation.long == .0
+            
+            // Track page view for location change
+            analyticsRepository.trackPageView("weather/${geoLocation.name}")
+            
+            analyticsRepository.trackEvent(
+                ANALYTICS_WEATHER_LOAD,
+                mapOf(
+                    PARAM_LOCATION to geoLocation.name,
+                    PARAM_IS_CURRENT_LOCATION to isCurrentLocation.toString()
+                )
+            )
+            
+            val location = if (isCurrentLocation) {
                 locationTracker.getCurrentLocation()?.let {
-
                     savedStateHandle.run {
                         remove<String>("name")
                         remove<String>("lat")
@@ -125,6 +149,11 @@ class WeatherViewModel @Inject constructor(
     private fun onRefresh(currentState: WeatherState): WeatherState {
         viewModelScope.launch(dispatcherProvider.io) {
             currentState.weatherInfo?.let { intLocation ->
+                analyticsRepository.trackEvent(
+                    ANALYTICS_WEATHER_REFRESH,
+                    mapOf(PARAM_LOCATION to intLocation.geoLocation)
+                )
+                
                 when (val result =
                     weatherRepository.getWeatherData(intLocation.latitude, intLocation.longitude)) {
                     is Resource.Success -> {
@@ -172,6 +201,11 @@ class WeatherViewModel @Inject constructor(
 
     private fun onResume(currentState: WeatherState): WeatherState {
         try {
+            // Track initial page view when screen resumes
+            viewModelScope.launch(dispatcherProvider.io) {
+                analyticsRepository.trackPageView("weather")
+            }
+            
             val name: String =
                 savedStateHandle.get<String>("name") ?: currentLocationString
             val lat: String = savedStateHandle.get<String>("lat") ?: ".0"
@@ -201,6 +235,11 @@ class WeatherViewModel @Inject constructor(
                     name = name.substring(0, name.lastIndexOf(","))
                 }
 
+                analyticsRepository.trackEvent(
+                    ANALYTICS_LOCATION_SAVE,
+                    mapOf(PARAM_LOCATION to name)
+                )
+
                 when (val result =
                     localGeocodingRepository.saveCacheGeoLocation(
                         weatherLocation.copy(name = name).toGeoLocation()
@@ -223,6 +262,11 @@ class WeatherViewModel @Inject constructor(
         weatherLocation: WeatherLocation
     ): WeatherState {
         viewModelScope.launch(dispatcherProvider.io) {
+            analyticsRepository.trackEvent(
+                ANALYTICS_LOCATION_DELETE,
+                mapOf(PARAM_LOCATION to weatherLocation.name)
+            )
+            
             when (val result =
                 localGeocodingRepository.deleteCacheGeoLocation(weatherLocation.toGeoLocation())) {
                 is Resource.Success -> state.handleEvent(WeatherEvent.CheckCache(weatherLocation))
@@ -233,6 +277,12 @@ class WeatherViewModel @Inject constructor(
     }
 
     private fun handleError(currentState: WeatherState, message: String): WeatherState {
+        viewModelScope.launch(dispatcherProvider.io) {
+            analyticsRepository.trackEvent(
+                ANALYTICS_ERROR,
+                mapOf(PARAM_ERROR to message)
+            )
+        }
         return currentState.copy(
             isLoading = false,
             error = message,

+ 4 - 1
feature/weather/src/main/java/com/codeskraps/feature/weather/presentation/components/WeatherForecast.kt

@@ -85,7 +85,10 @@ fun WeatherForecast(
         LazyRow(
             state = listState
         ) {
-            items(perDay.filter { it.time.plusHours(1) > LocalDateTime.now() }) { weatherData ->
+            val filteredData = perDay.filter { it.time.plusHours(1) > LocalDateTime.now() }
+            // If all data points would be filtered out, keep the most recent one
+            val displayData = if (filteredData.isEmpty()) listOf(perDay.maxBy { it.time }) else filteredData
+            items(displayData) { weatherData ->
                 HourlyWeatherDisplay(
                     weatherData = weatherData,
                     modifier = Modifier

+ 17 - 6
gradle/libs.versions.toml

@@ -1,30 +1,34 @@
 [versions]
 androidxComposeBom = "2025.03.01"
 androidGradlePlugin = "8.9.1"
+coreSplashscreen = "1.0.1"
+kotlinxCoroutinesAndroid = "1.8.1"
 ktx = "1.15.0"
 compose = "1.10.1"
 immutable = "0.3.7"
 location = "21.3.0"
-hilt = "2.51"
+hilt = "2.52"
 hilt-navigation = "1.2.0"
+moshiKotlin = "1.15.0"
+moshiKotlinCodegen = "1.15.0"
 navigationCompose = "2.8.9"
 lyfecyleCompose = "2.8.7"
 retrofit = "2.11.0"
 junit = "4.13.2"
 junittest = "1.2.1"
 espresso = "3.6.1"
-coroutines = "1.8.1"
 room = "2.6.1"
 material = "1.7.8"
 material3 = "1.3.1"
-org-jetbrains-kotlin-android = "2.1.20"
+org-jetbrains-kotlin-android = "2.0.21"
 appcompat = "1.7.0"
 com-google-android-material-material = "1.12.0"
-ksp = "2.1.20-1.0.32"
+ksp = "2.0.21-1.0.28"
 animationGraphicsAndroid = "1.7.8"
 play-services-maps = "19.1.0"
 maps-compose = "4.4.1"
 secretsGradlePlugin = "2.0.1"
+webkit = "1.13.0"
 
 
 [libraries]
@@ -37,6 +41,7 @@ androidx-compose-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-
 androidx-compose-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
 
 androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "ktx" }
+androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
 androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lyfecyleCompose" }
 androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "compose" }
 androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
@@ -45,7 +50,8 @@ android-compose-material = { group = "androidx.compose.material", name = "materi
 android-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
 
 kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "immutable" }
-
+kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" }
+kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesAndroid" }
 android-play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "location" }
 
 # Hilt
@@ -54,6 +60,8 @@ hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-comp
 hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-navigation" }
 
 # Retrofit
+moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshiKotlin" }
+moshi-kotlin-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshiKotlinCodegen" }
 retrofit-retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
 retrofit-converter-moshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit" }
 
@@ -68,15 +76,18 @@ maps-compose = { group = "com.google.maps.android", name = "maps-compose", versi
 junit-junit = { group = "junit", name = "junit", version.ref = "junit" }
 androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junittest" }
 espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
+coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesAndroid" }
 appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
 material = { group = "com.google.android.material", name = "material", version.ref = "com-google-android-material-material" }
 androidx-animation-graphics-android = { group = "androidx.compose.animation", name = "animation-graphics-android", version.ref = "animationGraphicsAndroid" }
 
+androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
+
 [plugins]
 android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
 android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" }
 org-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "org-jetbrains-kotlin-android" }
+compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "org-jetbrains-kotlin-android" }
 com-google-devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
 dagger-hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
 secrets-gradle-plugin = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secretsGradlePlugin" }

+ 1 - 0
settings.gradle.kts

@@ -21,3 +21,4 @@ include(":feature:geocoding")
 include(":feature:maps")
 include(":core:location")
 include(":core:local")
+include(":core:umami")