Parcourir la source

Increment version to 1.2.1, enhance Umami analytics integration by adding user identification functionality, and refactor related components. Update MainApplication to initialize analytics and identify users on startup. Improve analytics repository and use cases to support user identification. Adjust UI components in Dashboard and Settings screens to track page views when visible. Refactor dependency injection for better organization.

Carles Sentis il y a 19 heures
Parent
commit
6b25fafe1b
25 fichiers modifiés avec 304 ajouts et 123 suppressions
  1. 2 2
      app/build.gradle.kts
  2. 0 1
      app/src/main/java/com/codeskraps/publicpool/MainActivity.kt
  3. 17 1
      app/src/main/java/com/codeskraps/publicpool/MainApplication.kt
  4. 142 7
      app/src/main/java/com/codeskraps/publicpool/data/remote/UmamiAnalyticsDataSource.kt
  5. 4 0
      app/src/main/java/com/codeskraps/publicpool/data/repository/AnalyticsRepositoryImpl.kt
  6. 4 5
      app/src/main/java/com/codeskraps/publicpool/di/AppModule.kt
  7. 25 10
      app/src/main/java/com/codeskraps/publicpool/di/DataModule.kt
  8. 22 12
      app/src/main/java/com/codeskraps/publicpool/di/DomainModule.kt
  9. 5 31
      app/src/main/java/com/codeskraps/publicpool/di/PresentationModule.kt
  10. 1 0
      app/src/main/java/com/codeskraps/publicpool/domain/repository/AnalyticsRepository.kt
  11. 11 0
      app/src/main/java/com/codeskraps/publicpool/domain/usecase/IdentifyUserUseCase.kt
  12. 7 8
      app/src/main/java/com/codeskraps/publicpool/presentation/dashboard/DashboardContent.kt
  13. 1 0
      app/src/main/java/com/codeskraps/publicpool/presentation/dashboard/DashboardMvi.kt
  14. 8 8
      app/src/main/java/com/codeskraps/publicpool/presentation/dashboard/DashboardScreenModel.kt
  15. 2 2
      app/src/main/java/com/codeskraps/publicpool/presentation/navigation/BottomTabs.kt
  16. 5 0
      app/src/main/java/com/codeskraps/publicpool/presentation/settings/SettingsContent.kt
  17. 1 0
      app/src/main/java/com/codeskraps/publicpool/presentation/settings/SettingsMvi.kt
  18. 17 1
      app/src/main/java/com/codeskraps/publicpool/presentation/settings/SettingsScreenModel.kt
  19. 10 22
      app/src/main/java/com/codeskraps/publicpool/presentation/wallet/WalletContent.kt
  20. 2 1
      app/src/main/java/com/codeskraps/publicpool/presentation/wallet/WalletMvi.kt
  21. 6 6
      app/src/main/java/com/codeskraps/publicpool/presentation/wallet/WalletScreenModel.kt
  22. 1 0
      app/src/main/java/com/codeskraps/publicpool/presentation/workers/WorkersMvi.kt
  23. 5 0
      app/src/main/java/com/codeskraps/publicpool/presentation/workers/WorkersScreen.kt
  24. 5 5
      app/src/main/java/com/codeskraps/publicpool/presentation/workers/WorkersScreenModel.kt
  25. 1 1
      gradle/libs.versions.toml

+ 2 - 2
app/build.gradle.kts

@@ -15,8 +15,8 @@ android {
         applicationId = "com.codeskraps.publicpool"
         minSdk = 26
         targetSdk = 35
-        versionCode = 3
-        versionName = "1.2"
+        versionCode = 4
+        versionName = "1.2.1"
 
         testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
         vectorDrawables {

+ 0 - 1
app/src/main/java/com/codeskraps/publicpool/MainActivity.kt

@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.RowScope
 import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
 import androidx.compose.material3.HorizontalDivider
 import androidx.compose.material3.Icon

+ 17 - 1
app/src/main/java/com/codeskraps/publicpool/MainApplication.kt

@@ -5,10 +5,14 @@ import com.codeskraps.publicpool.di.appModule
 import com.codeskraps.publicpool.di.dataModule
 import com.codeskraps.publicpool.di.domainModule
 import com.codeskraps.publicpool.di.presentationModule
+import com.codeskraps.publicpool.domain.usecase.GetWalletAddressUseCase
+import com.codeskraps.publicpool.domain.usecase.IdentifyUserUseCase
 import com.codeskraps.publicpool.domain.usecase.InitializeAnalyticsUseCase
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.firstOrNull
 import kotlinx.coroutines.launch
 import org.koin.android.ext.koin.androidContext
 import org.koin.android.ext.koin.androidLogger
@@ -20,6 +24,8 @@ class MainApplication : Application() {
     
     private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
     private val initializeAnalyticsUseCase: InitializeAnalyticsUseCase by inject(InitializeAnalyticsUseCase::class.java)
+    private val getWalletAddressUseCase: GetWalletAddressUseCase by inject(GetWalletAddressUseCase::class.java)
+    private val identifyUserUseCase: IdentifyUserUseCase by inject(IdentifyUserUseCase::class.java)
     
     override fun onCreate() {
         super.onCreate()
@@ -36,9 +42,19 @@ class MainApplication : Application() {
             )
         }
         
-        // Initialize analytics
+        // Initialize analytics and identify user
         applicationScope.launch {
+            // First initialize analytics
             initializeAnalyticsUseCase()
+            
+            // Then retrieve wallet address and identify user if available
+            getWalletAddressUseCase()
+                .catch { /* Ignore errors during startup */ }
+                .firstOrNull()
+                ?.let { walletAddress ->
+                    // Identify user with their wallet address
+                    identifyUserUseCase(walletAddress)
+                }
         }
     }
 } 

+ 142 - 7
app/src/main/java/com/codeskraps/publicpool/data/remote/UmamiAnalyticsDataSource.kt

@@ -1,12 +1,30 @@
 package com.codeskraps.publicpool.data.remote
 
+import android.annotation.SuppressLint
 import android.content.Context
 import android.webkit.WebView
 import androidx.webkit.WebViewClientCompat
+import com.codeskraps.publicpool.di.AppReadinessState
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.withContext
+import kotlinx.coroutines.suspendCancellableCoroutine
 
-class UmamiAnalyticsDataSource(private val context: Context) {
+data class UmamiConfig(
+    val scriptUrl: String,
+    val websiteId: String,
+    val baseUrl: String
+)
+
+@SuppressLint("SetJavaScriptEnabled")
+class UmamiAnalyticsDataSource(
+    private val context: Context,
+    private val appReadinessState: AppReadinessState,
+    private val config: UmamiConfig
+) {
+    
+    private var isInitialized = false
     
     private val webView: WebView by lazy {
         WebView(context).apply {
@@ -17,32 +35,149 @@ class UmamiAnalyticsDataSource(private val context: Context) {
     }
     
     private val umamiScript = """
-        <script defer src="https://umami.codeskraps.com/script.js" data-website-id="b3e6309f-9724-48e5-a1c6-11757de3fe83"></script>
+        <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(
-            "https://umami.codeskraps.com",
+            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
+        }
+        
+        // Set app as ready regardless of Umami state
+        // This ensures the app doesn't hang if analytics fails
+        appReadinessState.setReady()
     }
     
     suspend fun trackPageView(pageName: String) = withContext(Dispatchers.Main) {
+        if (!isInitialized) return@withContext
+        
         webView.evaluateJavascript(
-            "umami.trackView('$pageName')",
+            """
+            (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) {
-        val dataJson = eventData.entries.joinToString(",") { 
-            "\"${it.key}\": \"${it.value}\"" 
+        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(
-            "umami.trackEvent('$eventName', {$dataJson})",
+            """
+            (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
         )
     }

+ 4 - 0
app/src/main/java/com/codeskraps/publicpool/data/repository/AnalyticsRepositoryImpl.kt

@@ -18,4 +18,8 @@ class AnalyticsRepositoryImpl(
     override suspend fun trackEvent(eventName: String, eventData: Map<String, String>) {
         analyticsDataSource.trackEvent(eventName, eventData)
     }
+    
+    override suspend fun identifyUser(walletAddress: String?) {
+        analyticsDataSource.identifyUser(walletAddress)
+    }
 } 

+ 4 - 5
app/src/main/java/com/codeskraps/publicpool/di/AppModule.kt

@@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
 import androidx.datastore.preferences.core.Preferences
 import androidx.datastore.preferences.preferencesDataStore
 import org.koin.android.ext.koin.androidContext
+import org.koin.core.module.dsl.singleOf
 import org.koin.dsl.module
 
 // Define DataStore instance at the top level
@@ -13,10 +14,8 @@ val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "pu
 
 val appModule = module {
     // Provide DataStore instance
-    single<DataStore<Preferences>> {
-        androidContext().dataStore
-    }
-
+    single<DataStore<Preferences>> { androidContext().dataStore }
+    
     // Provide AppReadinessState as a singleton
-    single { AppReadinessState() }
+    singleOf(::AppReadinessState)
 } 

+ 25 - 10
app/src/main/java/com/codeskraps/publicpool/di/DataModule.kt

@@ -3,16 +3,22 @@ package com.codeskraps.publicpool.di
 import com.codeskraps.publicpool.data.remote.KtorApiService
 import com.codeskraps.publicpool.data.remote.KtorApiServiceImpl
 import com.codeskraps.publicpool.data.remote.UmamiAnalyticsDataSource
+import com.codeskraps.publicpool.data.remote.UmamiConfig
 import com.codeskraps.publicpool.data.repository.AnalyticsRepositoryImpl
 import com.codeskraps.publicpool.data.repository.PublicPoolRepositoryImpl
 import com.codeskraps.publicpool.domain.repository.AnalyticsRepository
 import com.codeskraps.publicpool.domain.repository.PublicPoolRepository
-import io.ktor.client.* // Ktor client
-import io.ktor.client.engine.android.* // Ktor Android engine
-import io.ktor.client.plugins.contentnegotiation.* // Ktor Content Negotiation
-import io.ktor.client.plugins.logging.* // Ktor Logging
-import io.ktor.serialization.kotlinx.json.* // Ktor Kotlinx Serialization
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.android.Android
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.logging.DEFAULT
+import io.ktor.client.plugins.logging.LogLevel
+import io.ktor.client.plugins.logging.Logger
+import io.ktor.client.plugins.logging.Logging
+import io.ktor.serialization.kotlinx.json.json
 import kotlinx.serialization.json.Json
+import org.koin.core.module.dsl.singleOf
+import org.koin.dsl.bind
 import org.koin.dsl.module
 
 val dataModule = module {
@@ -40,12 +46,21 @@ val dataModule = module {
     }
 
     // API Service Implementation
-    single<KtorApiService> { KtorApiServiceImpl(client = get()) }
+    singleOf(::KtorApiServiceImpl) bind KtorApiService::class
 
     // Repository Implementation
-    single<PublicPoolRepository> { PublicPoolRepositoryImpl(apiService = get(), dataStore = get()) }
+    singleOf(::PublicPoolRepositoryImpl) bind PublicPoolRepository::class
 
-    // Analytics
-    single { UmamiAnalyticsDataSource(get()) }
-    single<AnalyticsRepository> { AnalyticsRepositoryImpl(get()) }
+    // Analytics Configuration
+    single { 
+        UmamiConfig(
+            scriptUrl = "https://umami.codeskraps.com/script.js",
+            websiteId = "b3e6309f-9724-48e5-a1c6-11757de3fe83",
+            baseUrl = "https://umami.codeskraps.com"
+        )
+    }
+
+    // Analytics Implementation
+    singleOf(::UmamiAnalyticsDataSource)
+    singleOf(::AnalyticsRepositoryImpl) bind AnalyticsRepository::class
 } 

+ 22 - 12
app/src/main/java/com/codeskraps/publicpool/di/DomainModule.kt

@@ -1,24 +1,34 @@
 package com.codeskraps.publicpool.di
 
-import com.codeskraps.publicpool.domain.usecase.*
+import com.codeskraps.publicpool.domain.usecase.CalculateTwoHourAverageUseCase
+import com.codeskraps.publicpool.domain.usecase.GetBlockchainWalletInfoUseCase
+import com.codeskraps.publicpool.domain.usecase.GetBtcPriceUseCase
+import com.codeskraps.publicpool.domain.usecase.GetChartDataUseCase
+import com.codeskraps.publicpool.domain.usecase.GetClientInfoUseCase
+import com.codeskraps.publicpool.domain.usecase.GetNetworkInfoUseCase
+import com.codeskraps.publicpool.domain.usecase.GetWalletAddressUseCase
+import com.codeskraps.publicpool.domain.usecase.IdentifyUserUseCase
 import com.codeskraps.publicpool.domain.usecase.InitializeAnalyticsUseCase
+import com.codeskraps.publicpool.domain.usecase.SaveWalletAddressUseCase
 import com.codeskraps.publicpool.domain.usecase.TrackEventUseCase
 import com.codeskraps.publicpool.domain.usecase.TrackPageViewUseCase
+import org.koin.core.module.dsl.factoryOf
 import org.koin.dsl.module
 
 val domainModule = module {
     // Use Case providers
-    factory { GetWalletAddressUseCase(repository = get()) }
-    factory { SaveWalletAddressUseCase(repository = get()) }
-    factory { GetNetworkInfoUseCase(repository = get()) }
-    factory { GetClientInfoUseCase(repository = get()) }
-    factory { GetChartDataUseCase(repository = get()) }
-    factory { CalculateTwoHourAverageUseCase() }
-    factory { GetBlockchainWalletInfoUseCase(repository = get()) }
-    factory { GetBtcPriceUseCase(repository = get()) }
+    factoryOf(::GetWalletAddressUseCase)
+    factoryOf(::SaveWalletAddressUseCase)
+    factoryOf(::GetNetworkInfoUseCase)
+    factoryOf(::GetClientInfoUseCase)
+    factoryOf(::GetChartDataUseCase)
+    factoryOf(::CalculateTwoHourAverageUseCase)
+    factoryOf(::GetBlockchainWalletInfoUseCase)
+    factoryOf(::GetBtcPriceUseCase)
     
     // Analytics
-    factory { InitializeAnalyticsUseCase(get()) }
-    factory { TrackPageViewUseCase(get()) }
-    factory { TrackEventUseCase(get()) }
+    factoryOf(::InitializeAnalyticsUseCase)
+    factoryOf(::TrackPageViewUseCase)
+    factoryOf(::TrackEventUseCase)
+    factoryOf(::IdentifyUserUseCase)
 } 

+ 5 - 31
app/src/main/java/com/codeskraps/publicpool/di/PresentationModule.kt

@@ -4,38 +4,12 @@ import com.codeskraps.publicpool.presentation.dashboard.DashboardScreenModel
 import com.codeskraps.publicpool.presentation.settings.SettingsScreenModel
 import com.codeskraps.publicpool.presentation.wallet.WalletScreenModel // Import Wallet ScreenModel
 import com.codeskraps.publicpool.presentation.workers.WorkersScreenModel // Import Worker ScreenModel
+import org.koin.core.module.dsl.factoryOf
 import org.koin.dsl.module
 
 val presentationModule = module {
-    // Voyager ScreenModels (similar to ViewModels)
-    factory {
-        DashboardScreenModel(
-            getWalletAddressUseCase = get(),
-            getNetworkInfoUseCase = get(),
-            getClientInfoUseCase = get(),
-            getChartDataUseCase = get(),
-            calculateTwoHourAverageUseCase = get(),
-            trackPageViewUseCase = get(),
-            trackEventUseCase = get(),
-            appReadinessState = get()
-        )
-    }
-    factory { SettingsScreenModel(get(), get()) } // Inject use cases
-    factory {
-        WorkersScreenModel(
-            getWalletAddressUseCase = get(),
-            getClientInfoUseCase = get(),
-            trackPageViewUseCase = get(),
-            trackEventUseCase = get()
-        )
-    }
-    factory {
-        WalletScreenModel(
-            getWalletAddressUseCase = get(),
-            getBlockchainWalletInfoUseCase = get(),
-            getBtcPriceUseCase = get(),
-            trackPageViewUseCase = get(),
-            trackEventUseCase = get()
-        )
-    }
+    factoryOf(::DashboardScreenModel)
+    factoryOf(::SettingsScreenModel)
+    factoryOf(::WorkersScreenModel)
+    factoryOf(::WalletScreenModel)
 } 

+ 1 - 0
app/src/main/java/com/codeskraps/publicpool/domain/repository/AnalyticsRepository.kt

@@ -4,4 +4,5 @@ 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?)
 } 

+ 11 - 0
app/src/main/java/com/codeskraps/publicpool/domain/usecase/IdentifyUserUseCase.kt

@@ -0,0 +1,11 @@
+package com.codeskraps.publicpool.domain.usecase
+
+import com.codeskraps.publicpool.domain.repository.AnalyticsRepository
+
+class IdentifyUserUseCase(
+    private val analyticsRepository: AnalyticsRepository
+) {
+    suspend operator fun invoke(walletAddress: String?) {
+        analyticsRepository.identifyUser(walletAddress)
+    }
+} 

+ 7 - 8
app/src/main/java/com/codeskraps/publicpool/presentation/dashboard/DashboardContent.kt

@@ -32,19 +32,16 @@ import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.Text
 import androidx.compose.material3.TextButton
 import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.MutableState
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.toArgb
-import androidx.compose.ui.layout.onGloballyPositioned
-import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
@@ -68,12 +65,9 @@ import com.codeskraps.publicpool.presentation.navigation.getParentOrSelf
 import com.codeskraps.publicpool.ui.theme.PositiveGreen
 import com.codeskraps.publicpool.util.formatHashRate
 import com.codeskraps.publicpool.util.formatLargeNumber
-import com.codeskraps.publicpool.util.formatLargeNumber
 import kotlinx.coroutines.flow.collectLatest
 import java.text.NumberFormat
 import java.util.Locale
-import androidx.compose.material3.pulltorefresh.PullToRefreshBox
-import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
 
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
@@ -84,6 +78,11 @@ fun DashboardContent(screenModel: DashboardScreenModel) {
     val navigator = LocalNavigator.currentOrThrow
     val snackbarHostState = remember { SnackbarHostState() }
     
+    // Track page view when screen becomes visible
+    LaunchedEffect(Unit) {
+        screenModel.handleEvent(DashboardEvent.OnScreenVisible)
+    }
+    
     // Handle effects (navigation, snackbars)
     LaunchedEffect(key1 = screenModel.effect) {
         screenModel.effect.collectLatest { effect ->
@@ -289,7 +288,7 @@ fun InfoCard(
                             fontWeight = FontWeight.Bold,
                             color = MaterialTheme.colorScheme.onSurface
                         )
-                        if (secondaryValue != null && secondaryValue.isNotEmpty()) {
+                        if (!secondaryValue.isNullOrEmpty()) {
                             Text(
                                 text = secondaryValue,
                                 style = MaterialTheme.typography.bodySmall,

+ 1 - 0
app/src/main/java/com/codeskraps/publicpool/presentation/dashboard/DashboardMvi.kt

@@ -42,6 +42,7 @@ sealed interface DashboardEvent : UiEvent {
     data class NetworkInfoResult(val result: Result<NetworkInfo>) : DashboardEvent // Internal event
     data class ClientInfoResult(val result: Result<ClientInfo>) : DashboardEvent // Internal event
     data class ChartDataResult(val result: Result<List<ChartDataPoint>>) : DashboardEvent // Internal event
+    data object OnScreenVisible : DashboardEvent
 }
 
 // --- Effects ---

+ 8 - 8
app/src/main/java/com/codeskraps/publicpool/presentation/dashboard/DashboardScreenModel.kt

@@ -33,11 +33,6 @@ class DashboardScreenModel(
     private var dataLoadingJob: Job? = null
 
     init {
-        // Track page view
-        screenModelScope.launch {
-            trackPageViewUseCase("Dashboard")
-        }
-        
         // Start loading data immediately
         handleEvent(DashboardEvent.LoadData)
     }
@@ -59,6 +54,11 @@ class DashboardScreenModel(
                     trackEventUseCase("navigation", mapOf("to" to "settings", "from" to "dashboard"))
                 }
             }
+            DashboardEvent.OnScreenVisible -> {
+                screenModelScope.launch {
+                    trackPageViewUseCase("Dashboard")
+                }
+            }
             // Internal Events triggered by data loading flows/calls
             is DashboardEvent.WalletAddressLoaded -> processWalletAddress(event.address)
             is DashboardEvent.NetworkInfoResult -> processNetworkInfoResult(event.result)
@@ -89,7 +89,7 @@ class DashboardScreenModel(
         appReadinessState.setReady()
 
         mutableState.update { it.copy(walletAddress = address, isWalletLoading = false) }
-        if (address != null && address.isNotBlank()) {
+        if (!address.isNullOrBlank()) {
             // Wallet address available, fetch client-specific data
             fetchClientInfoAndChartData(address)
         } else {
@@ -116,7 +116,7 @@ class DashboardScreenModel(
         fetchNetworkInfo()
         state.value.walletAddress?.let { address ->
             if (address.isNotBlank()) {
-                fetchClientInfoAndChartData(address, isRefresh = true)
+                fetchClientInfoAndChartData(address)
             }
         }
     }
@@ -129,7 +129,7 @@ class DashboardScreenModel(
         }
     }
 
-    private fun fetchClientInfoAndChartData(address: String, isRefresh: Boolean = false) {
+    private fun fetchClientInfoAndChartData(address: String) {
         dataLoadingJob?.cancel() // Cancel previous loads before starting new ones
         dataLoadingJob = screenModelScope.launch {
             mutableState.update {

+ 2 - 2
app/src/main/java/com/codeskraps/publicpool/presentation/navigation/BottomTabs.kt

@@ -10,7 +10,7 @@ import androidx.compose.ui.res.stringResource
 import cafe.adriel.voyager.navigator.tab.Tab
 import cafe.adriel.voyager.navigator.tab.TabOptions
 import com.codeskraps.publicpool.R
-import com.codeskraps.publicpool.presentation.wallet.WalletScreen
+import com.codeskraps.publicpool.presentation.wallet.WalletContent
 import com.codeskraps.publicpool.presentation.workers.WorkersScreen
 
 internal data object DashboardTab : Tab {
@@ -75,6 +75,6 @@ internal data object WalletTab : Tab {
 
     @Composable
     override fun Content() {
-        WalletScreen.Content()
+        WalletContent.Content()
     }
 } 

+ 5 - 0
app/src/main/java/com/codeskraps/publicpool/presentation/settings/SettingsContent.kt

@@ -50,6 +50,11 @@ fun SettingsContent(screenModel: SettingsScreenModel) {
     val navigator = LocalNavigator.currentOrThrow // Get the navigator
     val focusManager = LocalFocusManager.current // Focus manager to hide keyboard
 
+    // Track page view when screen becomes visible
+    LaunchedEffect(Unit) {
+        screenModel.handleEvent(SettingsEvent.OnScreenVisible)
+    }
+
     // Resolve strings needed inside LaunchedEffect here
     val walletSavedMessage = stringResource(R.string.settings_toast_wallet_saved)
 

+ 1 - 0
app/src/main/java/com/codeskraps/publicpool/presentation/settings/SettingsMvi.kt

@@ -15,6 +15,7 @@ sealed interface SettingsEvent : UiEvent {
     data class WalletAddressChanged(val address: String) : SettingsEvent
     data object SaveWalletAddress : SettingsEvent
     data object LoadWalletAddress : SettingsEvent // To trigger initial load
+    data object OnScreenVisible : SettingsEvent
 }
 
 // --- Effects ---

+ 17 - 1
app/src/main/java/com/codeskraps/publicpool/presentation/settings/SettingsScreenModel.kt

@@ -3,14 +3,18 @@ package com.codeskraps.publicpool.presentation.settings
 import cafe.adriel.voyager.core.model.StateScreenModel
 import cafe.adriel.voyager.core.model.screenModelScope
 import com.codeskraps.publicpool.domain.usecase.GetWalletAddressUseCase
+import com.codeskraps.publicpool.domain.usecase.IdentifyUserUseCase
 import com.codeskraps.publicpool.domain.usecase.SaveWalletAddressUseCase
+import com.codeskraps.publicpool.domain.usecase.TrackPageViewUseCase
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.flow.*
 import kotlinx.coroutines.launch
 
 class SettingsScreenModel(
     private val getWalletAddressUseCase: GetWalletAddressUseCase,
-    private val saveWalletAddressUseCase: SaveWalletAddressUseCase
+    private val saveWalletAddressUseCase: SaveWalletAddressUseCase,
+    private val identifyUserUseCase: IdentifyUserUseCase,
+    private val trackPageViewUseCase: TrackPageViewUseCase
 ) : StateScreenModel<SettingsState>(SettingsState()) { // Initialize with default state
 
     private val _effect = Channel<SettingsEffect>()
@@ -29,6 +33,11 @@ class SettingsScreenModel(
             }
             SettingsEvent.SaveWalletAddress -> saveWalletAddress()
             SettingsEvent.LoadWalletAddress -> loadWalletAddress()
+            SettingsEvent.OnScreenVisible -> {
+                screenModelScope.launch {
+                    trackPageViewUseCase("Settings")
+                }
+            }
         }
     }
 
@@ -48,6 +57,9 @@ class SettingsScreenModel(
                             isLoading = false
                         )
                     }
+                    
+                    // Identify user with the loaded wallet address
+                    identifyUserUseCase(address)
                 }
         }
     }
@@ -58,6 +70,10 @@ class SettingsScreenModel(
                 // Use the current address from the state, explicitly allowing blank addresses
                 val addressToSave = mutableState.value.walletAddress
                 saveWalletAddressUseCase(addressToSave)
+                
+                // Identify user with the newly saved wallet address
+                identifyUserUseCase(addressToSave)
+                
                 _effect.send(SettingsEffect.WalletAddressSaved)
             } catch (e: Exception) {
                 // Handle error saving address

+ 10 - 22
app/src/main/java/com/codeskraps/publicpool/presentation/wallet/WalletScreen.kt → app/src/main/java/com/codeskraps/publicpool/presentation/wallet/WalletContent.kt

@@ -1,8 +1,5 @@
 package com.codeskraps.publicpool.presentation.wallet
 
-import android.content.ClipData
-import android.content.ClipboardManager
-import android.content.Context
 import android.os.Parcelable
 import android.widget.Toast
 import androidx.compose.foundation.layout.Arrangement
@@ -25,7 +22,6 @@ import androidx.compose.material3.Scaffold
 import androidx.compose.material3.Text
 import androidx.compose.material3.TopAppBar
 import androidx.compose.material3.pulltorefresh.PullToRefreshBox
-import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.collectAsState
@@ -56,8 +52,8 @@ import java.util.Currency
 import java.util.Locale
 
 @Parcelize
-data object WalletScreen : Screen, Parcelable {
-    private fun readResolve(): Any = WalletScreen
+data object WalletContent : Screen, Parcelable {
+    private fun readResolve(): Any = WalletContent
 
     @OptIn(ExperimentalMaterial3Api::class)
     @Composable
@@ -66,6 +62,11 @@ data object WalletScreen : Screen, Parcelable {
         val state by screenModel.state.collectAsState()
         val context = LocalContext.current
 
+        // Track page view when screen becomes visible
+        LaunchedEffect(Unit) {
+            screenModel.handleEvent(WalletEvent.OnScreenVisible)
+        }
+
         LaunchedEffect(key1 = screenModel.effect) {
             screenModel.effect.collectLatest { effect ->
                 when (effect) {
@@ -143,7 +144,6 @@ fun WalletDetailsContent(
     walletInfo: WalletInfo,
     btcPrice: CryptoPrice?
 ) {
-    val context = LocalContext.current
     val btcFormat = remember { "%.8f BTC" }
     val dateFormatter = remember { DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") }
     val displayCurrency = stringResource(R.string.currency_usd)
@@ -253,11 +253,11 @@ fun CurrentPriceCard(btcPrice: CryptoPrice?, currencyFormat: NumberFormat) {
 
 @Composable
 fun BalanceCard(
+    modifier: Modifier = Modifier,
     label: String,
     valueBtc: String,
     valueFiat: String? = null,
-    fiatCurrencyLabel: String? = null,
-    modifier: Modifier = Modifier
+    fiatCurrencyLabel: String? = null
 ) {
     AppCard(modifier = modifier) {
         Column(
@@ -319,16 +319,4 @@ fun TransactionItem(tx: WalletTransaction, dateFormatter: DateTimeFormatter) {
             )
         }
     }
-}
-
-// Helper function to copy text to clipboard
-fun copyToClipboard(context: Context, text: String) {
-    val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
-    // Use context.getString() for non-composable contexts
-    val label = context.getString(R.string.wallet_clipboard_label)
-    val message = context.getString(R.string.wallet_toast_address_copied)
-
-    val clip = ClipData.newPlainText(label, text)
-    clipboard.setPrimaryClip(clip)
-    Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
-} 
+}

+ 2 - 1
app/src/main/java/com/codeskraps/publicpool/presentation/wallet/WalletMvi.kt

@@ -1,7 +1,7 @@
 package com.codeskraps.publicpool.presentation.wallet
 
-import com.codeskraps.publicpool.domain.model.WalletInfo
 import com.codeskraps.publicpool.domain.model.CryptoPrice
+import com.codeskraps.publicpool.domain.model.WalletInfo
 import com.codeskraps.publicpool.presentation.common.UiEffect
 import com.codeskraps.publicpool.presentation.common.UiEvent
 import com.codeskraps.publicpool.presentation.common.UiState
@@ -25,6 +25,7 @@ sealed interface WalletEvent : UiEvent {
     data object LoadWalletDetails : WalletEvent
     data class WalletAddressLoaded(val address: String?) : WalletEvent // Internal
     data class PriceResult(val result: Result<CryptoPrice>) : WalletEvent // Internal for price
+    data object OnScreenVisible : WalletEvent
 }
 
 // --- Effects ---

+ 6 - 6
app/src/main/java/com/codeskraps/publicpool/presentation/wallet/WalletScreenModel.kt

@@ -24,11 +24,6 @@ class WalletScreenModel(
     val effect = _effect.receiveAsFlow()
 
     init {
-        // Track page view
-        screenModelScope.launch {
-            trackPageViewUseCase("Wallet")
-        }
-        
         // Start loading wallet address immediately
         handleEvent(WalletEvent.LoadWalletDetails)
     }
@@ -44,6 +39,11 @@ class WalletScreenModel(
                     }
                 }
             }
+            WalletEvent.OnScreenVisible -> {
+                screenModelScope.launch {
+                    trackPageViewUseCase("Wallet")
+                }
+            }
             is WalletEvent.WalletAddressLoaded -> processWalletAddress(event.address)
             is WalletEvent.PriceResult -> processPriceResult(event.result)
         }
@@ -69,7 +69,7 @@ class WalletScreenModel(
 
     private fun processWalletAddress(address: String?) {
         mutableState.update { it.copy(walletAddress = address, isWalletLoading = false) }
-        if (address != null && address.isNotBlank()) {
+        if (!address.isNullOrBlank()) {
             fetchWalletDetails(address)
         } else {
             // No wallet address, clear info and show message

+ 1 - 0
app/src/main/java/com/codeskraps/publicpool/presentation/workers/WorkersMvi.kt

@@ -19,6 +19,7 @@ data class WorkersState(
 sealed interface WorkersEvent : UiEvent {
     data object LoadWorkers : WorkersEvent
     data class WalletAddressLoaded(val address: String?) : WorkersEvent // Internal
+    data object OnScreenVisible : WorkersEvent
 }
 
 // --- Effects ---

+ 5 - 0
app/src/main/java/com/codeskraps/publicpool/presentation/workers/WorkersScreen.kt

@@ -67,6 +67,11 @@ data object WorkersScreen : Screen, Parcelable {
         val state by screenModel.state.collectAsState()
         val context = LocalContext.current
 
+        // Track page view when screen becomes visible
+        LaunchedEffect(Unit) {
+            screenModel.handleEvent(WorkersEvent.OnScreenVisible)
+        }
+
         LaunchedEffect(key1 = screenModel.effect) {
             screenModel.effect.collectLatest { effect ->
                 when (effect) {

+ 5 - 5
app/src/main/java/com/codeskraps/publicpool/presentation/workers/WorkersScreenModel.kt

@@ -24,11 +24,6 @@ class WorkersScreenModel(
     val effect = _effect.receiveAsFlow()
 
     init {
-        // Track page view
-        screenModelScope.launch {
-            trackPageViewUseCase("Workers")
-        }
-        
         // Start loading wallet address immediately
         handleEvent(WorkersEvent.LoadWorkers)
     }
@@ -44,6 +39,11 @@ class WorkersScreenModel(
                     }
                 }
             }
+            WorkersEvent.OnScreenVisible -> {
+                screenModelScope.launch {
+                    trackPageViewUseCase("Workers")
+                }
+            }
             is WorkersEvent.WalletAddressLoaded -> processWalletAddress(event.address)
         }
     }

+ 1 - 1
gradle/libs.versions.toml

@@ -13,7 +13,7 @@ coil = "2.6.0"
 logback = "1.3.14" # Or latest stable for Ktor logging
 kotlinxSerializationJson = "1.6.3" # Ensure compatibility
 voyager = "1.1.0-beta03" # Add Voyager version
-webkit = "1.6.1" # WebView for Umami analytics
+webkit = "1.13.0" # WebView for Umami analytics
 # --- End Added Versions ---
 coreKtx = "1.15.0"
 junit = "4.13.2"