Browse Source

Update README to include Google Play badge, increment version to 1.1, and implement Umami analytics integration. Add analytics repository and use cases, track page views and events in dashboard, wallet, and workers screens. Enhance UI with pull-to-refresh functionality and improve settings screen with developer information.

Carles Sentis 2 days ago
parent
commit
ef99f9e4a7
20 changed files with 426 additions and 121 deletions
  1. 2 0
      README.md
  2. 11 2
      app/build.gradle.kts
  3. 15 0
      app/src/main/java/com/codeskraps/publicpool/MainApplication.kt
  4. 51 0
      app/src/main/java/com/codeskraps/publicpool/data/remote/UmamiAnalyticsDataSource.kt
  5. 21 0
      app/src/main/java/com/codeskraps/publicpool/data/repository/AnalyticsRepositoryImpl.kt
  6. 7 0
      app/src/main/java/com/codeskraps/publicpool/di/DataModule.kt
  7. 8 0
      app/src/main/java/com/codeskraps/publicpool/di/DomainModule.kt
  8. 29 3
      app/src/main/java/com/codeskraps/publicpool/di/PresentationModule.kt
  9. 7 0
      app/src/main/java/com/codeskraps/publicpool/domain/repository/AnalyticsRepository.kt
  10. 11 0
      app/src/main/java/com/codeskraps/publicpool/domain/usecase/InitializeAnalyticsUseCase.kt
  11. 11 0
      app/src/main/java/com/codeskraps/publicpool/domain/usecase/TrackEventUseCase.kt
  12. 11 0
      app/src/main/java/com/codeskraps/publicpool/domain/usecase/TrackPageViewUseCase.kt
  13. 37 30
      app/src/main/java/com/codeskraps/publicpool/presentation/dashboard/DashboardContent.kt
  14. 23 2
      app/src/main/java/com/codeskraps/publicpool/presentation/dashboard/DashboardScreenModel.kt
  15. 17 3
      app/src/main/java/com/codeskraps/publicpool/presentation/settings/SettingsContent.kt
  16. 45 37
      app/src/main/java/com/codeskraps/publicpool/presentation/wallet/WalletScreen.kt
  17. 19 2
      app/src/main/java/com/codeskraps/publicpool/presentation/wallet/WalletScreenModel.kt
  18. 64 35
      app/src/main/java/com/codeskraps/publicpool/presentation/workers/WorkersScreen.kt
  19. 35 7
      app/src/main/java/com/codeskraps/publicpool/presentation/workers/WorkersScreenModel.kt
  20. 2 0
      gradle/libs.versions.toml

+ 2 - 0
README.md

@@ -12,6 +12,8 @@ Monitor your cryptocurrency mining activity on Public Pool directly from your An
 
 ## Installation
 
+[![Get it on Google Play](https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png)](https://play.google.com/store/apps/details?id=com.codeskraps.publicpool)
+
 ## Contributing
 
 Contributions are welcome! Please feel free to submit pull requests or open issues on the project repository:

+ 11 - 2
app/build.gradle.kts

@@ -15,8 +15,8 @@ android {
         applicationId = "com.codeskraps.publicpool"
         minSdk = 26
         targetSdk = 35
-        versionCode = 1
-        versionName = "1.0"
+        versionCode = 2
+        versionName = "1.1"
 
         testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
         vectorDrawables {
@@ -54,6 +54,14 @@ android {
             excludes += "/META-INF/INDEX.LIST"
         }
     }
+    
+    applicationVariants.all {
+        val variant = this
+        variant.outputs.all {
+            val output = this as? com.android.build.gradle.internal.api.BaseVariantOutputImpl
+            output?.outputFileName = "PublicPool-${variant.name}-v${variant.versionName}-${variant.versionCode}.apk"
+        }
+    }
 }
 
 dependencies {
@@ -63,6 +71,7 @@ dependencies {
     implementation(libs.androidx.core.splashscreen)
     implementation(libs.androidx.lifecycle.runtime.ktx)
     implementation(libs.androidx.activity.compose)
+    implementation(libs.androidx.webkit) // WebView for analytics
 
     // Compose
     implementation(platform(libs.androidx.compose.bom))

+ 15 - 0
app/src/main/java/com/codeskraps/publicpool/MainApplication.kt

@@ -5,12 +5,22 @@ 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.InitializeAnalyticsUseCase
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
 import org.koin.android.ext.koin.androidContext
 import org.koin.android.ext.koin.androidLogger
 import org.koin.core.context.GlobalContext.startKoin
 import org.koin.core.logger.Level
+import org.koin.java.KoinJavaComponent.inject
 
 class MainApplication : Application() {
+    
+    private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+    private val initializeAnalyticsUseCase: InitializeAnalyticsUseCase by inject(InitializeAnalyticsUseCase::class.java)
+    
     override fun onCreate() {
         super.onCreate()
 
@@ -25,5 +35,10 @@ class MainApplication : Application() {
                 // Add presentationModule later for ViewModels
             )
         }
+        
+        // Initialize analytics
+        applicationScope.launch {
+            initializeAnalyticsUseCase()
+        }
     }
 } 

+ 51 - 0
app/src/main/java/com/codeskraps/publicpool/data/remote/UmamiAnalyticsDataSource.kt

@@ -0,0 +1,51 @@
+package com.codeskraps.publicpool.data.remote
+
+import android.content.Context
+import android.webkit.WebView
+import androidx.webkit.WebViewClientCompat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class UmamiAnalyticsDataSource(private val context: Context) {
+    
+    private val webView: WebView by lazy {
+        WebView(context).apply {
+            settings.javaScriptEnabled = true
+            webViewClient = UmamiWebViewClient()
+            loadUrl("about:blank")
+        }
+    }
+    
+    private val umamiScript = """
+        <script defer src="https://umami.codeskraps.com/script.js" data-website-id="b3e6309f-9724-48e5-a1c6-11757de3fe83"></script>
+    """.trimIndent()
+    
+    suspend fun initialize() = withContext(Dispatchers.Main) {
+        webView.loadDataWithBaseURL(
+            "https://umami.codeskraps.com",
+            "<html><head>$umamiScript</head><body></body></html>",
+            "text/html",
+            "UTF-8",
+            null
+        )
+    }
+    
+    suspend fun trackPageView(pageName: String) = withContext(Dispatchers.Main) {
+        webView.evaluateJavascript(
+            "umami.trackView('$pageName')",
+            null
+        )
+    }
+    
+    suspend fun trackEvent(eventName: String, eventData: Map<String, String> = emptyMap()) = withContext(Dispatchers.Main) {
+        val dataJson = eventData.entries.joinToString(",") { 
+            "\"${it.key}\": \"${it.value}\"" 
+        }
+        webView.evaluateJavascript(
+            "umami.trackEvent('$eventName', {$dataJson})",
+            null
+        )
+    }
+    
+    private class UmamiWebViewClient : WebViewClientCompat()
+} 

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

@@ -0,0 +1,21 @@
+package com.codeskraps.publicpool.data.repository
+
+import com.codeskraps.publicpool.data.remote.UmamiAnalyticsDataSource
+import com.codeskraps.publicpool.domain.repository.AnalyticsRepository
+
+class AnalyticsRepositoryImpl(
+    private val analyticsDataSource: UmamiAnalyticsDataSource
+) : AnalyticsRepository {
+    
+    override suspend fun initialize() {
+        analyticsDataSource.initialize()
+    }
+    
+    override suspend fun trackPageView(pageName: String) {
+        analyticsDataSource.trackPageView(pageName)
+    }
+    
+    override suspend fun trackEvent(eventName: String, eventData: Map<String, String>) {
+        analyticsDataSource.trackEvent(eventName, eventData)
+    }
+} 

+ 7 - 0
app/src/main/java/com/codeskraps/publicpool/di/DataModule.kt

@@ -2,7 +2,10 @@ 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.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
@@ -41,4 +44,8 @@ val dataModule = module {
 
     // Repository Implementation
     single<PublicPoolRepository> { PublicPoolRepositoryImpl(apiService = get(), dataStore = get()) }
+
+    // Analytics
+    single { UmamiAnalyticsDataSource(get()) }
+    single<AnalyticsRepository> { AnalyticsRepositoryImpl(get()) }
 } 

+ 8 - 0
app/src/main/java/com/codeskraps/publicpool/di/DomainModule.kt

@@ -1,6 +1,9 @@
 package com.codeskraps.publicpool.di
 
 import com.codeskraps.publicpool.domain.usecase.*
+import com.codeskraps.publicpool.domain.usecase.InitializeAnalyticsUseCase
+import com.codeskraps.publicpool.domain.usecase.TrackEventUseCase
+import com.codeskraps.publicpool.domain.usecase.TrackPageViewUseCase
 import org.koin.dsl.module
 
 val domainModule = module {
@@ -13,4 +16,9 @@ val domainModule = module {
     factory { CalculateTwoHourAverageUseCase() }
     factory { GetBlockchainWalletInfoUseCase(repository = get()) }
     factory { GetBtcPriceUseCase(repository = get()) }
+    
+    // Analytics
+    factory { InitializeAnalyticsUseCase(get()) }
+    factory { TrackPageViewUseCase(get()) }
+    factory { TrackEventUseCase(get()) }
 } 

+ 29 - 3
app/src/main/java/com/codeskraps/publicpool/di/PresentationModule.kt

@@ -8,8 +8,34 @@ import org.koin.dsl.module
 
 val presentationModule = module {
     // Voyager ScreenModels (similar to ViewModels)
-    factory { DashboardScreenModel(get(), get(), get(), get(), get(), get()) } // Add 6th `get()` for AppReadinessState
+    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(get(), get()) } // Provide WorkersScreenModel
-    factory { WalletScreenModel(get(), get(), get()) } // Provide WalletScreenModel (add 3rd get)
+    factory {
+        WorkersScreenModel(
+            getWalletAddressUseCase = get(),
+            getClientInfoUseCase = get(),
+            trackPageViewUseCase = get(),
+            trackEventUseCase = get()
+        )
+    }
+    factory {
+        WalletScreenModel(
+            getWalletAddressUseCase = get(),
+            getBlockchainWalletInfoUseCase = get(),
+            getBtcPriceUseCase = get(),
+            trackPageViewUseCase = get(),
+            trackEventUseCase = get()
+        )
+    }
 } 

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

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

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

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

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

@@ -0,0 +1,11 @@
+package com.codeskraps.publicpool.domain.usecase
+
+import com.codeskraps.publicpool.domain.repository.AnalyticsRepository
+
+class TrackEventUseCase(
+    private val analyticsRepository: AnalyticsRepository
+) {
+    suspend operator fun invoke(eventName: String, eventData: Map<String, String> = emptyMap()) {
+        analyticsRepository.trackEvent(eventName, eventData)
+    }
+} 

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

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

+ 37 - 30
app/src/main/java/com/codeskraps/publicpool/presentation/dashboard/DashboardContent.kt

@@ -1,6 +1,7 @@
 package com.codeskraps.publicpool.presentation.dashboard
 
 import android.util.Log
+import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
@@ -18,6 +19,7 @@ import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Info
 import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.AlertDialog
 import androidx.compose.material3.CircularProgressIndicator
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.Icon
@@ -28,6 +30,7 @@ import androidx.compose.material3.SnackbarDuration
 import androidx.compose.material3.SnackbarHost
 import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
 import androidx.compose.material3.TopAppBar
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
@@ -44,9 +47,7 @@ import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.viewinterop.AndroidView
 import cafe.adriel.voyager.navigator.LocalNavigator
-import cafe.adriel.voyager.navigator.Navigator
 import cafe.adriel.voyager.navigator.currentOrThrow
-import cafe.adriel.voyager.core.screen.Screen
 import com.anychart.APIlib
 import com.anychart.AnyChart
 import com.anychart.AnyChartView
@@ -60,17 +61,16 @@ import com.codeskraps.publicpool.R
 import com.codeskraps.publicpool.domain.model.ChartDataPoint
 import com.codeskraps.publicpool.presentation.common.AppCard
 import com.codeskraps.publicpool.presentation.navigation.SettingsScreen
+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 com.codeskraps.publicpool.presentation.navigation.getParentOrSelf
-import androidx.compose.foundation.clickable
-import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.Button
-import androidx.compose.material3.TextButton
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
+import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
 
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
@@ -80,7 +80,7 @@ fun DashboardContent(screenModel: DashboardScreenModel) {
     // Get the current navigator, which might be inside a tab
     val navigator = LocalNavigator.currentOrThrow
     val snackbarHostState = remember { SnackbarHostState() }
-
+    
     // Handle effects (navigation, snackbars)
     LaunchedEffect(key1 = screenModel.effect) {
         screenModel.effect.collectLatest { effect ->
@@ -121,39 +121,46 @@ fun DashboardContent(screenModel: DashboardScreenModel) {
             )
         }
     ) { paddingValues ->
-        Column(
+        PullToRefreshBox(
+            isRefreshing = state.isLoading,
+            onRefresh = { screenModel.handleEvent(DashboardEvent.RefreshData) },
             modifier = Modifier
                 .fillMaxSize()
                 .padding(paddingValues)
-                .padding(horizontal = 16.dp) // Add horizontal padding
-                .verticalScroll(rememberScrollState()) // Make column scrollable
         ) {
-            // Add padding between TopAppBar and first card row
-            Spacer(modifier = Modifier.height(16.dp))
+            Column(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .padding(horizontal = 16.dp) // Add horizontal padding
+                    .verticalScroll(rememberScrollState()) // Make column scrollable
+            ) {
+                // Add padding between TopAppBar and first card row
+                Spacer(modifier = Modifier.height(16.dp))
 
-            // Show message if no wallet address is set
-            if (!state.isWalletLoading && (state.walletAddress?.isBlank() != false)) {
-                AppCard(modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)) {
-                    Text(
-                        text = stringResource(R.string.dashboard_info_set_wallet),
-                        modifier = Modifier.padding(16.dp),
-                        style = MaterialTheme.typography.bodyLarge
-                    )
+                // Show message if no wallet address is set
+                if (!state.isWalletLoading && (state.walletAddress?.isBlank() != false)) {
+                    AppCard(modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)) {
+                        Text(
+                            text = stringResource(R.string.dashboard_info_set_wallet),
+                            modifier = Modifier.padding(16.dp),
+                            style = MaterialTheme.typography.bodyLarge
+                        )
+                    }
                 }
-            }
 
-            // Top Info Cards Row/Grid
-            TopInfoCards(state = state)
+                // Top Info Cards Row/Grid
+                TopInfoCards(state = state)
 
-            Spacer(modifier = Modifier.height(16.dp))
+                Spacer(modifier = Modifier.height(16.dp))
 
-            // Placeholder for Workers List (Add later if API provides worker data)
-            // WorkersSection(state = state)
+                // Placeholder for Workers List (Add later if API provides worker data)
+                // WorkersSection(state = state)
 
-            // Chart Section
-            ChartSection(state = state)
+                // Chart Section
+                ChartSection(state = state)
 
-            Spacer(modifier = Modifier.height(16.dp)) // Bottom padding
+                Spacer(modifier = Modifier.height(16.dp)) // Bottom padding
+            }
         }
     }
 }

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

@@ -8,6 +8,8 @@ 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.TrackPageViewUseCase
+import com.codeskraps.publicpool.domain.usecase.TrackEventUseCase
 import com.codeskraps.publicpool.di.AppReadinessState
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.channels.Channel
@@ -20,6 +22,8 @@ class DashboardScreenModel(
     private val getClientInfoUseCase: GetClientInfoUseCase,
     private val getChartDataUseCase: GetChartDataUseCase,
     private val calculateTwoHourAverageUseCase: CalculateTwoHourAverageUseCase,
+    private val trackPageViewUseCase: TrackPageViewUseCase,
+    private val trackEventUseCase: TrackEventUseCase,
     private val appReadinessState: AppReadinessState
 ) : StateScreenModel<DashboardState>(DashboardState()) {
 
@@ -29,6 +33,11 @@ class DashboardScreenModel(
     private var dataLoadingJob: Job? = null
 
     init {
+        // Track page view
+        screenModelScope.launch {
+            trackPageViewUseCase("Dashboard")
+        }
+        
         // Start loading data immediately
         handleEvent(DashboardEvent.LoadData)
     }
@@ -36,8 +45,20 @@ class DashboardScreenModel(
     fun handleEvent(event: DashboardEvent) {
         when (event) {
             DashboardEvent.LoadData -> loadInitialData()
-            DashboardEvent.RefreshData -> refreshData()
-            DashboardEvent.GoToSettings -> sendEffect(DashboardEffect.NavigateToSettings)
+            DashboardEvent.RefreshData -> {
+                refreshData()
+                // Track refresh event
+                screenModelScope.launch {
+                    trackEventUseCase("dashboard_refresh", mapOf("action" to "pull_to_refresh"))
+                }
+            }
+            DashboardEvent.GoToSettings -> {
+                sendEffect(DashboardEffect.NavigateToSettings)
+                // Track settings navigation
+                screenModelScope.launch {
+                    trackEventUseCase("navigation", mapOf("to" to "settings", "from" to "dashboard"))
+                }
+            }
             // Internal Events triggered by data loading flows/calls
             is DashboardEvent.WalletAddressLoaded -> processWalletAddress(event.address)
             is DashboardEvent.NetworkInfoResult -> processNetworkInfoResult(event.result)

+ 17 - 3
app/src/main/java/com/codeskraps/publicpool/presentation/settings/SettingsContent.kt

@@ -141,13 +141,27 @@ fun SettingsContent(screenModel: SettingsScreenModel) {
                             style = MaterialTheme.typography.bodyMedium
                         )
                         
+                        TextButton(
+                            onClick = {
+                                val intent = Intent(Intent.ACTION_VIEW)
+                                intent.data = "https://codeskraps.com".toUri()
+                                context.startActivity(intent)
+                            }
+                        ) {
+                            Text(
+                                text = "Developer: codeskraps.com",
+                                style = MaterialTheme.typography.bodyMedium,
+                                color = MaterialTheme.colorScheme.primary
+                            )
+                        }
+                        
                         Text(
-                            text = "Developer: codeskraps",
+                            text = "License: MIT",
                             style = MaterialTheme.typography.bodyMedium
                         )
                         
                         Text(
-                            text = "License: MIT",
+                            text = "Analytics: Self-hosted Umami",
                             style = MaterialTheme.typography.bodyMedium
                         )
                         
@@ -160,7 +174,7 @@ fun SettingsContent(screenModel: SettingsScreenModel) {
                             }
                         ) {
                             Text(
-                                text = "https://repo.codeskraps.com/codeskraps/PublicPoolAndroid",
+                                text = "Source Repository",
                                 style = MaterialTheme.typography.bodyMedium,
                                 color = MaterialTheme.colorScheme.primary,
                                 textAlign = TextAlign.Center

+ 45 - 37
app/src/main/java/com/codeskraps/publicpool/presentation/wallet/WalletScreen.kt

@@ -24,6 +24,8 @@ import androidx.compose.material3.MaterialTheme
 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
@@ -77,47 +79,53 @@ data object WalletScreen : Screen, Parcelable {
         Scaffold(
             topBar = { TopAppBar(title = { Text(stringResource(R.string.screen_title_wallet_details)) }) }
         ) { paddingValues ->
-            Box(
+            PullToRefreshBox(
+                isRefreshing = state.isWalletLoading || state.isLoading,
+                onRefresh = { screenModel.handleEvent(WalletEvent.LoadWalletDetails) },
                 modifier = Modifier
                     .fillMaxSize()
                     .padding(paddingValues)
             ) {
-                when {
-                    state.isWalletLoading || state.isLoading -> {
-                        // Combined loading indicator
-                        CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
-                    }
-                    state.walletAddress.isNullOrBlank() -> {
-                        // Use stringResource for message
-                        Text(
-                            text = stringResource(R.string.wallet_error_not_set),
-                            modifier = Modifier.align(Alignment.Center).padding(16.dp),
-                            textAlign = TextAlign.Center
-                        )
-                    }
-                    state.errorMessage != null -> {
-                        // Use stringResource for generic error, keep specific from state
-                        Text(
-                            text = state.errorMessage ?: stringResource(R.string.error_unknown),
-                            modifier = Modifier.align(Alignment.Center).padding(16.dp),
-                            color = MaterialTheme.colorScheme.error,
-                            textAlign = TextAlign.Center
-                        )
-                    }
-                    state.walletInfo == null -> {
-                         // Use stringResource for message
-                         Text(
-                            text = stringResource(R.string.wallet_error_load_failed),
-                            modifier = Modifier.align(Alignment.Center).padding(16.dp),
-                            textAlign = TextAlign.Center
-                        )
-                    }
-                    else -> {
-                        // Display Wallet Info and Transactions
-                        WalletDetailsContent(
-                            walletInfo = state.walletInfo!!,
-                            btcPrice = state.btcPrice
-                        )
+                Box(
+                    modifier = Modifier.fillMaxSize()
+                ) {
+                    when {
+                        state.isWalletLoading || state.isLoading -> {
+                            // Combined loading indicator
+                            CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
+                        }
+                        state.walletAddress.isNullOrBlank() -> {
+                            // Use stringResource for message
+                            Text(
+                                text = stringResource(R.string.wallet_error_not_set),
+                                modifier = Modifier.align(Alignment.Center).padding(16.dp),
+                                textAlign = TextAlign.Center
+                            )
+                        }
+                        state.errorMessage != null -> {
+                            // Use stringResource for generic error, keep specific from state
+                            Text(
+                                text = state.errorMessage ?: stringResource(R.string.error_unknown),
+                                modifier = Modifier.align(Alignment.Center).padding(16.dp),
+                                color = MaterialTheme.colorScheme.error,
+                                textAlign = TextAlign.Center
+                            )
+                        }
+                        state.walletInfo == null -> {
+                             // Use stringResource for message
+                             Text(
+                                text = stringResource(R.string.wallet_error_load_failed),
+                                modifier = Modifier.align(Alignment.Center).padding(16.dp),
+                                textAlign = TextAlign.Center
+                            )
+                        }
+                        else -> {
+                            // Display Wallet Info and Transactions
+                            WalletDetailsContent(
+                                walletInfo = state.walletInfo!!,
+                                btcPrice = state.btcPrice
+                            )
+                        }
                     }
                 }
             }

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

@@ -5,6 +5,8 @@ import cafe.adriel.voyager.core.model.screenModelScope
 import com.codeskraps.publicpool.domain.usecase.GetBlockchainWalletInfoUseCase
 import com.codeskraps.publicpool.domain.usecase.GetWalletAddressUseCase
 import com.codeskraps.publicpool.domain.usecase.GetBtcPriceUseCase
+import com.codeskraps.publicpool.domain.usecase.TrackPageViewUseCase
+import com.codeskraps.publicpool.domain.usecase.TrackEventUseCase
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.flow.*
 import kotlinx.coroutines.launch
@@ -13,20 +15,35 @@ import android.util.Log
 class WalletScreenModel(
     private val getWalletAddressUseCase: GetWalletAddressUseCase,
     private val getBlockchainWalletInfoUseCase: GetBlockchainWalletInfoUseCase,
-    private val getBtcPriceUseCase: GetBtcPriceUseCase
+    private val getBtcPriceUseCase: GetBtcPriceUseCase,
+    private val trackPageViewUseCase: TrackPageViewUseCase,
+    private val trackEventUseCase: TrackEventUseCase
 ) : StateScreenModel<WalletState>(WalletState()) {
 
     private val _effect = Channel<WalletEffect>()
     val effect = _effect.receiveAsFlow()
 
     init {
+        // Track page view
+        screenModelScope.launch {
+            trackPageViewUseCase("Wallet")
+        }
+        
         // Start loading wallet address immediately
         handleEvent(WalletEvent.LoadWalletDetails)
     }
 
     fun handleEvent(event: WalletEvent) {
         when (event) {
-            WalletEvent.LoadWalletDetails -> loadWalletAndDetails()
+            WalletEvent.LoadWalletDetails -> {
+                loadWalletAndDetails()
+                // Track refresh event when explicitly requested (not on initial load)
+                if (state.value.walletAddress != null) {
+                    screenModelScope.launch {
+                        trackEventUseCase("wallet_refresh", mapOf("action" to "pull_to_refresh"))
+                    }
+                }
+            }
             is WalletEvent.WalletAddressLoaded -> processWalletAddress(event.address)
             is WalletEvent.PriceResult -> processPriceResult(event.result)
         }

+ 64 - 35
app/src/main/java/com/codeskraps/publicpool/presentation/workers/WorkersScreen.kt

@@ -17,8 +17,6 @@ import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.items
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
 import androidx.compose.material3.CircularProgressIndicator
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.HorizontalDivider
@@ -28,6 +26,7 @@ import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Scaffold
 import androidx.compose.material3.Text
 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.collectAsState
@@ -43,6 +42,7 @@ import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
+import cafe.adriel.voyager.core.model.screenModelScope
 import cafe.adriel.voyager.core.screen.Screen
 import cafe.adriel.voyager.koin.koinScreenModel
 import com.codeskraps.publicpool.R
@@ -53,6 +53,7 @@ import com.codeskraps.publicpool.util.formatDifficulty
 import com.codeskraps.publicpool.util.formatHashRate
 import com.codeskraps.publicpool.util.formatRelativeTime
 import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
 import kotlinx.parcelize.Parcelize
 
 @Parcelize
@@ -79,43 +80,52 @@ data object WorkersScreen : Screen, Parcelable {
         Scaffold(
             topBar = { TopAppBar(title = { Text(stringResource(id = R.string.screen_title_workers)) }) }
         ) { paddingValues ->
-            Box(
+            PullToRefreshBox(
+                isRefreshing = state.isLoading,
+                onRefresh = { screenModel.handleEvent(WorkersEvent.LoadWorkers) },
                 modifier = Modifier
                     .fillMaxSize()
                     .padding(paddingValues)
             ) {
-                when {
-                    state.isLoading -> {
-                        Box(
-                            modifier = Modifier.fillMaxSize(),
-                            contentAlignment = Alignment.Center
-                        ) {
-                            CircularProgressIndicator()
+                Box(
+                    modifier = Modifier.fillMaxSize()
+                ) {
+                    when {
+                        state.isLoading -> {
+                            Box(
+                                modifier = Modifier.fillMaxSize(),
+                                contentAlignment = Alignment.Center
+                            ) {
+                                CircularProgressIndicator()
+                            }
                         }
-                    }
 
-                    state.errorMessage != null -> {
-                        Text(
-                            text = state.errorMessage ?: stringResource(R.string.error_unknown),
-                            modifier = Modifier.align(Alignment.Center)
-                        )
-                    }
+                        state.errorMessage != null -> {
+                            Text(
+                                text = state.errorMessage ?: stringResource(R.string.error_unknown),
+                                modifier = Modifier.align(Alignment.Center)
+                            )
+                        }
 
-                    state.groupedWorkers.isEmpty() && !state.isWalletLoading -> {
-                        Text(
-                            text = if (state.walletAddress.isNullOrBlank())
-                                stringResource(R.string.workers_no_wallet)
-                            else
-                                stringResource(R.string.workers_no_data),
-                            modifier = Modifier
-                                .align(Alignment.Center)
-                                .padding(16.dp),
-                            textAlign = TextAlign.Center
-                        )
-                    }
+                        state.groupedWorkers.isEmpty() && !state.isWalletLoading -> {
+                            Text(
+                                text = if (state.walletAddress.isNullOrBlank())
+                                    stringResource(R.string.workers_no_wallet)
+                                else
+                                    stringResource(R.string.workers_no_data),
+                                modifier = Modifier
+                                    .align(Alignment.Center)
+                                    .padding(16.dp),
+                                textAlign = TextAlign.Center
+                            )
+                        }
 
-                    else -> {
-                        WorkersList(groupedWorkers = state.groupedWorkers)
+                        else -> {
+                            WorkersList(
+                                groupedWorkers = state.groupedWorkers,
+                                screenModel = screenModel
+                            )
+                        }
                     }
                 }
             }
@@ -124,20 +134,36 @@ data object WorkersScreen : Screen, Parcelable {
 }
 
 @Composable
-fun WorkersList(groupedWorkers: Map<String, List<Worker>>) {
+fun WorkersList(
+    groupedWorkers: Map<String, List<Worker>>,
+    screenModel: WorkersScreenModel
+) {
     LazyColumn(
         modifier = Modifier.fillMaxSize(),
         contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
     ) {
         items(groupedWorkers.entries.toList(), key = { it.key }) { (workerName, sessions) ->
-            WorkerGroupCard(workerName = workerName, sessions = sessions)
+            WorkerGroupCard(
+                workerName = workerName, 
+                sessions = sessions,
+                onExpandCollapse = { isExpanded ->
+                    // Track worker group expansion/collapse
+                    screenModel.screenModelScope.launch {
+                        screenModel.trackWorkerGroupToggle(workerName, isExpanded)
+                    }
+                }
+            )
             Spacer(modifier = Modifier.height(8.dp))
         }
     }
 }
 
 @Composable
-fun WorkerGroupCard(workerName: String, sessions: List<Worker>) {
+fun WorkerGroupCard(
+    workerName: String, 
+    sessions: List<Worker>,
+    onExpandCollapse: (Boolean) -> Unit = {}
+) {
     var expanded by remember { mutableStateOf(false) }
 
     val totalHashRate = sessions.sumOf { it.hashRate ?: 0.0 }
@@ -155,7 +181,10 @@ fun WorkerGroupCard(workerName: String, sessions: List<Worker>) {
             Row(
                 modifier = Modifier
                     .fillMaxWidth()
-                    .clickable { expanded = !expanded },
+                    .clickable { 
+                        expanded = !expanded
+                        onExpandCollapse(expanded)
+                    },
                 verticalAlignment = Alignment.CenterVertically
             ) {
                 Icon(

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

@@ -2,32 +2,48 @@ package com.codeskraps.publicpool.presentation.workers
 
 import cafe.adriel.voyager.core.model.StateScreenModel
 import cafe.adriel.voyager.core.model.screenModelScope
-import com.codeskraps.publicpool.presentation.workers.WorkersEffect
-import com.codeskraps.publicpool.presentation.workers.WorkersEvent
-import com.codeskraps.publicpool.presentation.workers.WorkersState
-import com.codeskraps.publicpool.domain.model.Worker
 import com.codeskraps.publicpool.domain.usecase.GetClientInfoUseCase
 import com.codeskraps.publicpool.domain.usecase.GetWalletAddressUseCase
+import com.codeskraps.publicpool.domain.usecase.TrackEventUseCase
+import com.codeskraps.publicpool.domain.usecase.TrackPageViewUseCase
 import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 
 class WorkersScreenModel(
     private val getWalletAddressUseCase: GetWalletAddressUseCase,
-    private val getClientInfoUseCase: GetClientInfoUseCase
+    private val getClientInfoUseCase: GetClientInfoUseCase,
+    private val trackPageViewUseCase: TrackPageViewUseCase,
+    private val trackEventUseCase: TrackEventUseCase
 ) : StateScreenModel<WorkersState>(WorkersState()) {
 
     private val _effect = Channel<WorkersEffect>()
     val effect = _effect.receiveAsFlow()
 
     init {
+        // Track page view
+        screenModelScope.launch {
+            trackPageViewUseCase("Workers")
+        }
+        
         // Start loading wallet address immediately
         handleEvent(WorkersEvent.LoadWorkers)
     }
 
     fun handleEvent(event: WorkersEvent) {
         when (event) {
-            WorkersEvent.LoadWorkers -> loadWalletAndWorkers()
+            WorkersEvent.LoadWorkers -> {
+                loadWalletAndWorkers()
+                // Track refresh event when explicitly requested (not on initial load)
+                if (state.value.walletAddress != null) {
+                    screenModelScope.launch {
+                        trackEventUseCase("workers_refresh", mapOf("action" to "pull_to_refresh"))
+                    }
+                }
+            }
             is WorkersEvent.WalletAddressLoaded -> processWalletAddress(event.address)
         }
     }
@@ -88,4 +104,16 @@ class WorkersScreenModel(
             _effect.send(effectToSend)
         }
     }
+
+    // Method to track worker group expansion/collapse
+    suspend fun trackWorkerGroupToggle(workerName: String, isExpanded: Boolean) {
+        val action = if (isExpanded) "expand" else "collapse"
+        trackEventUseCase(
+            "worker_group_toggle", 
+            mapOf(
+                "worker_name" to workerName,
+                "action" to action
+            )
+        )
+    }
 } 

+ 2 - 0
gradle/libs.versions.toml

@@ -13,6 +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
 # --- End Added Versions ---
 coreKtx = "1.15.0"
 junit = "4.13.2"
@@ -66,6 +67,7 @@ voyager-koin = { group = "cafe.adriel.voyager", name = "voyager-koin", version.r
 voyager-transitions = { group = "cafe.adriel.voyager", name = "voyager-transitions", version.ref = "voyager" }
 voyager-tab-navigator = { group = "cafe.adriel.voyager", name = "voyager-tab-navigator", version.ref = "voyager" } # Add TabNavigator
 androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" }
+androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
 # --- End Added Libraries ---
 
 [plugins]