From f401409517336b38dbdce24729fd2abfd2554e4c Mon Sep 17 00:00:00 2001 From: gabriel20xx Date: Sun, 26 Oct 2025 17:19:33 +0100 Subject: [PATCH] feat: add ConnectionStatusAction component for real-time server health monitoring in UI screens --- .../app/ui/components/ConnectionStatus.kt | 124 ++++++++++++++++++ .../app/ui/screens/ListDetailScreen.kt | 5 + .../collabtable/app/ui/screens/ListsScreen.kt | 4 + .../app/ui/screens/SettingsScreen.kt | 28 +--- 4 files changed, 135 insertions(+), 26 deletions(-) create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/components/ConnectionStatus.kt diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/components/ConnectionStatus.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/components/ConnectionStatus.kt new file mode 100644 index 0000000..d67cfc1 --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/components/ConnectionStatus.kt @@ -0,0 +1,124 @@ +package com.collabtable.app.ui.components + +import android.net.Uri +import android.os.Build +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.collabtable.app.data.preferences.PreferencesManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.util.concurrent.TimeUnit + +private val statusClient: OkHttpClient = + OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build() + +private fun isEmulator(): Boolean { + val fp = Build.FINGERPRINT.lowercase() + val model = Build.MODEL.lowercase() + val brand = Build.BRAND.lowercase() + val device = Build.DEVICE.lowercase() + val product = Build.PRODUCT.lowercase() + return fp.contains("generic") || fp.contains("emulator") || + model.contains("google_sdk") || model.contains("emulator") || model.contains("android sdk built for") || + brand.startsWith("generic") || device.startsWith("generic") || product.contains("sdk") +} + +private fun toHealthUrl(rawApiUrl: String): String { + // Ensure we hit the unauthenticated /health endpoint and remap localhost on emulator + return try { + val uri = Uri.parse(rawApiUrl) + val host = uri.host?.lowercase() + val needsRemap = isEmulator() && (host == "localhost" || host == "127.0.0.1" || host == "host.docker.internal") + val scheme = uri.scheme ?: "http" + val portPart = if (uri.port != -1) ":${uri.port}" else "" + val base = if (needsRemap) "$scheme://10.0.2.2$portPart" else "$scheme://${uri.host ?: ""}$portPart" + val path = (uri.encodedPath ?: "/").trimEnd('/') + val healthPath = if (path.endsWith("/api") || path.endsWith("/api/")) "/health" else "/health" + base + healthPath + } catch (e: Exception) { + rawApiUrl.replace("/api/", "/health").replace("/api", "/health") + } +} + +@Composable +fun ConnectionStatusAction( + prefs: PreferencesManager, + modifier: Modifier = Modifier, + showLatency: Boolean = true, +) { + var ok by remember { mutableStateOf(null) } + var latencyMs by remember { mutableStateOf(null) } + + val intervalMs by prefs.syncPollIntervalMs.collectAsState() + + // Periodic health check aligned to polling interval + LaunchedEffect(intervalMs) { + while (true) { + try { + val healthUrl = toHealthUrl(prefs.getServerUrl()) + val req = Request.Builder().url(healthUrl).get().build() + val start = System.nanoTime() + val resp = withContext(Dispatchers.IO) { statusClient.newCall(req).execute() } + val tookMs = (System.nanoTime() - start) / 1_000_000 + if (resp.isSuccessful) { + ok = true + latencyMs = tookMs + } else { + ok = false + } + resp.close() + } catch (e: Exception) { + ok = false + } + delay(intervalMs) + } + } + + val dotColor: Color = when (ok) { + true -> Color(0xFF2E7D32) // green + false -> MaterialTheme.colorScheme.error + null -> MaterialTheme.colorScheme.onSurfaceVariant + } + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (showLatency && ok == true && latencyMs != null) { + Text( + text = "${latencyMs} ms", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + Box( + modifier = + Modifier + .size(12.dp) + .background(dotColor, shape = androidx.compose.foundation.shape.CircleShape), + ) + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt index 9f3c04a..83b7845 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt @@ -75,6 +75,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.collabtable.app.R +import com.collabtable.app.data.preferences.PreferencesManager +import com.collabtable.app.ui.components.ConnectionStatusAction import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.model.Field import com.collabtable.app.data.model.ItemWithValues @@ -200,6 +202,9 @@ fun ListDetailScreen( } }, actions = { + val context = LocalContext.current + val prefs = remember { PreferencesManager.getInstance(context) } + ConnectionStatusAction(prefs = prefs) IconButton(onClick = { showManageColumnsDialog = true }) { Icon(Icons.Default.Settings, contentDescription = "Manage Columns") } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt index 5393e2e..2d03832 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt @@ -55,6 +55,7 @@ import com.collabtable.app.R import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.model.CollabList import com.collabtable.app.data.preferences.PreferencesManager +import com.collabtable.app.ui.components.ConnectionStatusAction import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.burnoutcrew.reorderable.ReorderableItem @@ -86,6 +87,9 @@ fun ListsScreen( TopAppBar( title = { Text(stringResource(R.string.lists)) }, actions = { + val context = LocalContext.current + val prefsLocal = remember { PreferencesManager.getInstance(context) } + ConnectionStatusAction(prefs = prefsLocal) IconButton(onClick = { viewModel.manualSync() }) { Icon(Icons.Default.Refresh, contentDescription = "Sync") } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt index dfc8519..75a260a 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color +import com.collabtable.app.ui.components.ConnectionStatusAction import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -142,32 +143,7 @@ fun SettingsScreen( } }, actions = { - // Connection status dot: green=ok, red=error, gray=unknown - val dotColor = when (testOk) { - true -> Color(0xFF2E7D32) - false -> MaterialTheme.colorScheme.error - null -> MaterialTheme.colorScheme.onSurfaceVariant - } - Row( - modifier = Modifier.padding(end = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - if (lastLatencyMs != null && testOk == true) { - Text( - text = "${'$'}{lastLatencyMs} ms", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onPrimaryContainer, - ) - } - Box( - modifier = - Modifier - .size(12.dp) - .clip(androidx.compose.foundation.shape.CircleShape) - .background(dotColor), - ) - } + ConnectionStatusAction(prefs = preferencesManager) }, colors = TopAppBarDefaults.topAppBarColors(