feat: implement soft delete functionality for lists, fields, and items with cascading updates
This commit is contained in:
@@ -34,6 +34,12 @@ interface FieldDao {
|
||||
timestamp: Long,
|
||||
)
|
||||
|
||||
@Query("UPDATE fields SET isDeleted = 1, updatedAt = :timestamp WHERE listId = :listId")
|
||||
suspend fun softDeleteFieldsByList(
|
||||
listId: String,
|
||||
timestamp: Long,
|
||||
)
|
||||
|
||||
@Query("DELETE FROM fields WHERE id = :fieldId")
|
||||
suspend fun deleteField(fieldId: String)
|
||||
|
||||
|
||||
@@ -45,4 +45,10 @@ interface ItemDao {
|
||||
// Get all items updated since timestamp, including deleted ones for sync
|
||||
@Query("SELECT * FROM items WHERE updatedAt >= :since")
|
||||
suspend fun getItemsUpdatedSince(since: Long): List<Item>
|
||||
|
||||
@Query("UPDATE items SET isDeleted = 1, updatedAt = :timestamp WHERE listId = :listId")
|
||||
suspend fun softDeleteItemsByList(
|
||||
listId: String,
|
||||
timestamp: Long,
|
||||
)
|
||||
}
|
||||
|
||||
+39
-27
@@ -92,7 +92,6 @@ fun ListsScreen(
|
||||
val context = LocalContext.current
|
||||
val prefsLocal = remember { PreferencesManager.getInstance(context) }
|
||||
ConnectionStatusAction(prefs = prefsLocal)
|
||||
SortMenu(prefs = prefs)
|
||||
IconButton(onClick = onNavigateToSettings) {
|
||||
Icon(
|
||||
Icons.Default.Settings,
|
||||
@@ -201,32 +200,45 @@ fun ListsScreen(
|
||||
},
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.reorderable(reorderState),
|
||||
state = reorderState.listState,
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
) {
|
||||
itemsIndexed(working, key = { _, it -> it.id }) { _, list ->
|
||||
ReorderableItem(reorderState, key = list.id) { _ ->
|
||||
Box(modifier = Modifier.animateItemPlacement()) {
|
||||
ListItem(
|
||||
list = list,
|
||||
onListClick = { onNavigateToList(list.id) },
|
||||
onEditClick = { listToEdit = list },
|
||||
onDeleteClick = { listToDelete = list },
|
||||
dragHandle = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.DragHandle,
|
||||
contentDescription = "Reorder",
|
||||
modifier = Modifier.detectReorder(reorderState),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
)
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Controls above the list (align with table view UX)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SortMenu(prefs = prefs)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.reorderable(reorderState),
|
||||
state = reorderState.listState,
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
) {
|
||||
itemsIndexed(working, key = { _, it -> it.id }) { _, list ->
|
||||
ReorderableItem(reorderState, key = list.id) { _ ->
|
||||
Box(modifier = Modifier.animateItemPlacement()) {
|
||||
ListItem(
|
||||
list = list,
|
||||
onListClick = { onNavigateToList(list.id) },
|
||||
onEditClick = { listToEdit = list },
|
||||
onDeleteClick = { listToDelete = list },
|
||||
dragHandle = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.DragHandle,
|
||||
contentDescription = "Reorder",
|
||||
modifier = Modifier.detectReorder(reorderState),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -156,7 +156,12 @@ class ListsViewModel(
|
||||
val list = database.listDao().getListById(listId)
|
||||
if (list != null) {
|
||||
Logger.i("Tables", "🗑️ Deleting table: \"${list.name}\"")
|
||||
database.listDao().softDeleteList(listId, System.currentTimeMillis())
|
||||
val ts = System.currentTimeMillis()
|
||||
// Soft-delete the list, and cascade soft-deletes to its fields and items so sync uploads tombstones
|
||||
database.listDao().softDeleteList(listId, ts)
|
||||
// Cascade locally to ensure children are marked deleted and uploaded on next sync
|
||||
database.fieldDao().softDeleteFieldsByList(listId, ts)
|
||||
database.itemDao().softDeleteItemsByList(listId, ts)
|
||||
// Sync immediately after deleting
|
||||
performSync()
|
||||
}
|
||||
|
||||
+14
-154
@@ -1,7 +1,6 @@
|
||||
package com.collabtable.app.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -37,14 +36,12 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
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.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
|
||||
@@ -61,11 +58,7 @@ import com.collabtable.app.utils.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
import android.os.Build
|
||||
import android.net.Uri
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -90,50 +83,7 @@ fun SettingsScreen(
|
||||
var syncIntervalInput by remember(syncIntervalMs) { mutableStateOf(syncIntervalMs.toString()) }
|
||||
var syncIntervalError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Test connection state
|
||||
var isTesting by remember { mutableStateOf(false) }
|
||||
var testOk by remember { mutableStateOf<Boolean?>(null) }
|
||||
var testMessage by remember { mutableStateOf<String?>(null) }
|
||||
var lastLatencyMs by remember { mutableStateOf<Long?>(null) }
|
||||
|
||||
val testClient = remember {
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
fun toHealthUrl(rawApiUrl: String): String {
|
||||
// Ensure we hit the unauthenticated /health endpoint
|
||||
var url = rawApiUrl
|
||||
// Emu remap for local hosts
|
||||
try {
|
||||
val uri = Uri.parse(url)
|
||||
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"
|
||||
// Replace any /api or /api/ segment at end with /health
|
||||
val path = (uri.encodedPath ?: "/").trimEnd('/')
|
||||
val healthPath = if (path.endsWith("/api")) "/health" else if (path.endsWith("/api/")) "/health" else "/health"
|
||||
return base + healthPath
|
||||
} catch (e: Exception) {
|
||||
// Fallback: naive replace
|
||||
return rawApiUrl.replace("/api/", "/health").replace("/api", "/health")
|
||||
}
|
||||
}
|
||||
// Removed test connectivity logic; the connection indicator is shown via ConnectionStatusAction
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -146,9 +96,6 @@ fun SettingsScreen(
|
||||
},
|
||||
actions = {
|
||||
ConnectionStatusAction(prefs = preferencesManager)
|
||||
IconButton(onClick = onNavigateToLogs) {
|
||||
Icon(Icons.Default.List, contentDescription = "Logs")
|
||||
}
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
@@ -350,93 +297,28 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Connection test
|
||||
// Diagnostics / Logs
|
||||
Text(
|
||||
text = "Diagnostics",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Server connectivity",
|
||||
text = "View application logs",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
isTesting = true
|
||||
testOk = null
|
||||
testMessage = null
|
||||
lastLatencyMs = null
|
||||
coroutineScope.launch {
|
||||
val rawApi = preferencesManager.getServerUrl()
|
||||
val healthUrl = toHealthUrl(rawApi)
|
||||
val req = Request.Builder().url(healthUrl).get().build()
|
||||
val start = System.nanoTime()
|
||||
try {
|
||||
val resp = withContext(Dispatchers.IO) { testClient.newCall(req).execute() }
|
||||
val tookMs = (System.nanoTime() - start) / 1_000_000
|
||||
if (resp.isSuccessful) {
|
||||
testOk = true
|
||||
lastLatencyMs = tookMs
|
||||
testMessage = "Reachable (${tookMs} ms)"
|
||||
} else {
|
||||
testOk = false
|
||||
testMessage = "HTTP ${resp.code}"
|
||||
}
|
||||
resp.close()
|
||||
} catch (e: Exception) {
|
||||
testOk = false
|
||||
testMessage = e.message ?: "Connection error"
|
||||
} finally {
|
||||
isTesting = false
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isTesting,
|
||||
) {
|
||||
if (isTesting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Testing…")
|
||||
} else {
|
||||
Text("Test connection")
|
||||
}
|
||||
}
|
||||
when (testOk) {
|
||||
true -> Text(
|
||||
text = testMessage ?: "Reachable",
|
||||
color = Color(0xFF2E7D32),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
false -> Text(
|
||||
text = testMessage ?: "Not reachable",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
if (testOk == false) {
|
||||
Text(
|
||||
text = "Tip: On emulator use http://10.0.2.2:PORT/api/. On a device use your PC's LAN IP.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(onClick = onNavigateToLogs, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Default.List, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Open Logs")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -549,29 +431,7 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Perform a silent health check when entering the screen to set the indicator
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
val rawApi = preferencesManager.getServerUrl()
|
||||
val healthUrl = toHealthUrl(rawApi)
|
||||
val req = Request.Builder().url(healthUrl).get().build()
|
||||
val start = System.nanoTime()
|
||||
val resp = withContext(Dispatchers.IO) { testClient.newCall(req).execute() }
|
||||
val tookMs = (System.nanoTime() - start) / 1_000_000
|
||||
if (resp.isSuccessful) {
|
||||
testOk = true
|
||||
lastLatencyMs = tookMs
|
||||
testMessage = "Reachable (${tookMs} ms)"
|
||||
} else {
|
||||
testOk = false
|
||||
testMessage = "HTTP ${resp.code}"
|
||||
}
|
||||
resp.close()
|
||||
} catch (e: Exception) {
|
||||
testOk = false
|
||||
testMessage = e.message ?: "Connection error"
|
||||
}
|
||||
}
|
||||
// Test connectivity removed; connection status is shown in the top app bar indicator
|
||||
}
|
||||
private fun formatServerUrlForDisplay(raw: String): String {
|
||||
var s = raw.trim()
|
||||
|
||||
Reference in New Issue
Block a user