feat: add ConnectionStatusAction component for real-time server health monitoring in UI screens
This commit is contained in:
+124
@@ -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<Boolean?>(null) }
|
||||||
|
var latencyMs by remember { mutableStateOf<Long?>(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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -75,6 +75,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import com.collabtable.app.R
|
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.database.CollabTableDatabase
|
||||||
import com.collabtable.app.data.model.Field
|
import com.collabtable.app.data.model.Field
|
||||||
import com.collabtable.app.data.model.ItemWithValues
|
import com.collabtable.app.data.model.ItemWithValues
|
||||||
@@ -200,6 +202,9 @@ fun ListDetailScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val prefs = remember { PreferencesManager.getInstance(context) }
|
||||||
|
ConnectionStatusAction(prefs = prefs)
|
||||||
IconButton(onClick = { showManageColumnsDialog = true }) {
|
IconButton(onClick = { showManageColumnsDialog = true }) {
|
||||||
Icon(Icons.Default.Settings, contentDescription = "Manage Columns")
|
Icon(Icons.Default.Settings, contentDescription = "Manage Columns")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import com.collabtable.app.R
|
|||||||
import com.collabtable.app.data.database.CollabTableDatabase
|
import com.collabtable.app.data.database.CollabTableDatabase
|
||||||
import com.collabtable.app.data.model.CollabList
|
import com.collabtable.app.data.model.CollabList
|
||||||
import com.collabtable.app.data.preferences.PreferencesManager
|
import com.collabtable.app.data.preferences.PreferencesManager
|
||||||
|
import com.collabtable.app.ui.components.ConnectionStatusAction
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.burnoutcrew.reorderable.ReorderableItem
|
import org.burnoutcrew.reorderable.ReorderableItem
|
||||||
@@ -86,6 +87,9 @@ fun ListsScreen(
|
|||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.lists)) },
|
title = { Text(stringResource(R.string.lists)) },
|
||||||
actions = {
|
actions = {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val prefsLocal = remember { PreferencesManager.getInstance(context) }
|
||||||
|
ConnectionStatusAction(prefs = prefsLocal)
|
||||||
IconButton(onClick = { viewModel.manualSync() }) {
|
IconButton(onClick = { viewModel.manualSync() }) {
|
||||||
Icon(Icons.Default.Refresh, contentDescription = "Sync")
|
Icon(Icons.Default.Refresh, contentDescription = "Sync")
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-26
@@ -44,6 +44,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import com.collabtable.app.ui.components.ConnectionStatusAction
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -142,32 +143,7 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
// Connection status dot: green=ok, red=error, gray=unknown
|
ConnectionStatusAction(prefs = preferencesManager)
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
colors =
|
colors =
|
||||||
TopAppBarDefaults.topAppBarColors(
|
TopAppBarDefaults.topAppBarColors(
|
||||||
|
|||||||
Reference in New Issue
Block a user