feat: enhance notification handling and improve code structure across multiple components

This commit is contained in:
2026-06-03 17:30:24 +02:00
parent 18dbcd372b
commit ebe5fc13c4
14 changed files with 270 additions and 188 deletions
@@ -0,0 +1,28 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ComplexCondition:Logger.kt$Logger$last != null &amp;&amp; last.level == level &amp;&amp; last.tag == tag &amp;&amp; last.message == message &amp;&amp; (now - last.timestamp) &lt;= DEDUPE_WINDOW_MS</ID>
<ID>ExplicitItLambdaParameter:ListsScreen.kt${ _, it -&gt; it.id }</ID>
<ID>ReturnCount:ListsViewModel.kt$ListsViewModel$suspend fun exportListToCsv( listId: String, listName: String, targetTreeUri: Uri? = null, ): Result&lt;String&gt;</ID>
<ID>ReturnCount:SyncRepository.kt$SyncRepository$private fun isNetworkAvailable(): Boolean</ID>
<ID>SwallowedException:ApiClient.kt$ApiClient$e: Exception</ID>
<ID>SwallowedException:ConnectionStatus.kt$e: Exception</ID>
<ID>SwallowedException:Field.kt$Field$e: Exception</ID>
<ID>SwallowedException:ListChangeNotificationWorker.kt$ListChangeNotificationWorker$e: Exception</ID>
<ID>SwallowedException:ListDetailScreen.kt$e: Exception</ID>
<ID>SwallowedException:PreferencesManager.kt$PreferencesManager$e: Exception</ID>
<ID>SwallowedException:ServerSetupViewModel.kt$ServerSetupViewModel$e: ConnectException</ID>
<ID>SwallowedException:ServerSetupViewModel.kt$ServerSetupViewModel$e: Exception</ID>
<ID>SwallowedException:ServerSetupViewModel.kt$ServerSetupViewModel$e: NetworkOnMainThreadException</ID>
<ID>SwallowedException:ServerSetupViewModel.kt$ServerSetupViewModel$e: SocketTimeoutException</ID>
<ID>SwallowedException:ServerSetupViewModel.kt$ServerSetupViewModel$e: UnknownHostException</ID>
<ID>UnusedParameter:ListDetailScreen.kt$onAlignmentChange: (String) -&gt; Unit = {}</ID>
<ID>UnusedParameter:ListDetailScreen.kt$onUpdateAlignment: (String, String) -&gt; Unit</ID>
<ID>UnusedParameter:ListsScreen.kt$onNavigateToLogs: () -&gt; Unit = {}</ID>
<ID>UnusedParameter:ListsScreen.kt$onNavigateToSettings: () -&gt; Unit</ID>
<ID>UnusedParameter:SettingsScreen.kt$onNavigateBack: () -&gt; Unit</ID>
<ID>UnusedPrivateMember:ListDetailScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun FilterSortDialog( fields: List&lt;Field&gt;, currentSortField: Field?, currentSortAscending: Boolean, currentGroupByField: Field?, currentFilterField: Field?, currentFilterValue: String, onDismiss: () -&gt; Unit, onApply: (sortField: Field?, sortAscending: Boolean, groupByField: Field?, filterField: Field?, filterValue: String) -&gt; Unit, onClearAll: () -&gt; Unit, )</ID>
<ID>UnusedPrivateMember:ListDetailViewModel.kt$ListDetailViewModel$private fun isInForeground(): Boolean</ID>
</CurrentIssues>
</SmellBaseline>
@@ -53,6 +53,7 @@ class CollabTableApplication : Application() {
NotificationHelper.clearListEventNotifications(this@CollabTableApplication) NotificationHelper.clearListEventNotifications(this@CollabTableApplication)
NotificationPoller.start(this@CollabTableApplication) NotificationPoller.start(this@CollabTableApplication)
} }
override fun onStop(owner: LifecycleOwner) { override fun onStop(owner: LifecycleOwner) {
NotificationPoller.stop() NotificationPoller.stop()
} }
@@ -6,8 +6,8 @@ import com.collabtable.app.data.model.Item
import com.collabtable.app.data.model.ItemValue import com.collabtable.app.data.model.ItemValue
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query import retrofit2.http.Query
data class SyncRequest( data class SyncRequest(
@@ -48,7 +48,11 @@ interface FieldDao {
suspend fun getFieldsUpdatedSince(since: Long): List<Field> suspend fun getFieldsUpdatedSince(since: Long): List<Field>
@Query("UPDATE fields SET `order` = :newOrder, updatedAt = :timestamp WHERE id = :fieldId") @Query("UPDATE fields SET `order` = :newOrder, updatedAt = :timestamp WHERE id = :fieldId")
suspend fun updateFieldOrder(fieldId: String, newOrder: Int, timestamp: Long) suspend fun updateFieldOrder(
fieldId: String,
newOrder: Int,
timestamp: Long,
)
// Reorder fields in a single transaction for clarity and consistency // Reorder fields in a single transaction for clarity and consistency
@Transaction @Transaction
@@ -16,8 +16,8 @@ class PreferencesManager(
private val _serverUrl = MutableStateFlow(getServerUrl()) private val _serverUrl = MutableStateFlow(getServerUrl())
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow() val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
private val _isFirstRun = MutableStateFlow(isFirstRun()) private val _isFirstRunFlow = MutableStateFlow(isFirstRun())
val isFirstRunFlow: StateFlow<Boolean> = _isFirstRun.asStateFlow() val isFirstRunFlow: StateFlow<Boolean> = _isFirstRunFlow.asStateFlow()
private val _themeMode = MutableStateFlow(getThemeMode()) private val _themeMode = MutableStateFlow(getThemeMode())
val themeMode: StateFlow<String> = _themeMode.asStateFlow() val themeMode: StateFlow<String> = _themeMode.asStateFlow()
@@ -67,7 +67,7 @@ class PreferencesManager(
fun setIsFirstRun(isFirstRun: Boolean) { fun setIsFirstRun(isFirstRun: Boolean) {
prefs.edit().putBoolean(KEY_FIRST_RUN, isFirstRun).apply() prefs.edit().putBoolean(KEY_FIRST_RUN, isFirstRun).apply()
_isFirstRun.value = isFirstRun _isFirstRunFlow.value = isFirstRun
} }
fun getServerPassword(): String? = prefs.getString(KEY_SERVER_PASSWORD, null) fun getServerPassword(): String? = prefs.getString(KEY_SERVER_PASSWORD, null)
@@ -272,7 +272,10 @@ class PreferencesManager(
fun getDeviceId(): String { fun getDeviceId(): String {
val existing = prefs.getString(KEY_DEVICE_ID, null) val existing = prefs.getString(KEY_DEVICE_ID, null)
if (!existing.isNullOrBlank()) return existing if (!existing.isNullOrBlank()) return existing
val id = java.util.UUID.randomUUID().toString() val id =
java.util.UUID
.randomUUID()
.toString()
prefs.edit().putString(KEY_DEVICE_ID, id).apply() prefs.edit().putString(KEY_DEVICE_ID, id).apply()
return id return id
} }
@@ -1,8 +1,12 @@
package com.collabtable.app.notifications package com.collabtable.app.notifications
import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.collabtable.app.R import com.collabtable.app.R
object NotificationHelper { object NotificationHelper {
@@ -34,6 +38,15 @@ object NotificationHelper {
id: Int, id: Int,
builder: NotificationCompat.Builder, builder: NotificationCompat.Builder,
) { ) {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
return
}
with(NotificationManagerCompat.from(context)) { with(NotificationManagerCompat.from(context)) {
notify(id, builder.build()) notify(id, builder.build())
} }
@@ -2,7 +2,6 @@ package com.collabtable.app.notifications
import android.content.Context import android.content.Context
import com.collabtable.app.data.api.ApiClient import com.collabtable.app.data.api.ApiClient
import com.collabtable.app.data.api.CollabTableApi
import com.collabtable.app.data.api.NotificationEvent import com.collabtable.app.data.api.NotificationEvent
import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.data.preferences.PreferencesManager
@@ -23,44 +22,48 @@ object NotificationPoller {
val prefs = PreferencesManager.getInstance(appCtx) val prefs = PreferencesManager.getInstance(appCtx)
val api = ApiClient.api val api = ApiClient.api
val db = CollabTableDatabase.getDatabase(appCtx) val db = CollabTableDatabase.getDatabase(appCtx)
job = CoroutineScope(Dispatchers.IO).launch { job =
while (isActive) { CoroutineScope(Dispatchers.IO).launch {
try { while (isActive) {
// Skip if password missing (auth disabled) try {
val pwd = prefs.getServerPassword()?.trim() // Skip if password missing (auth disabled)
if (pwd.isNullOrBlank() || pwd == "\$password") { val pwd = prefs.getServerPassword()?.trim()
delay(2_000) if (pwd.isNullOrBlank() || pwd == "\$password") {
continue delay(2_000)
} continue
val since = prefs.getLastListNotifyCheckTimestamp()
val resp = api.pollNotifications(since)
if (resp.isSuccessful) {
val body = resp.body()
val events = body?.notifications.orEmpty()
if (events.isNotEmpty()) {
// Map and emit based on preferences
events.forEach { ev ->
val listId = ev.listId ?: return@forEach
val list = db.listDao().getListById(listId) ?: return@forEach
handleEvent(appCtx, ev, list.name, prefs)
}
} }
// Advance checkpoint to server timestamp to avoid re-processing val since = prefs.getLastListNotifyCheckTimestamp()
val stamp = body?.serverTimestamp ?: System.currentTimeMillis() val resp = api.pollNotifications(since)
prefs.setLastListNotifyCheckTimestamp(stamp) if (resp.isSuccessful) {
} else if (resp.code() == 401) { val body = resp.body()
// Unauthorized; back off by advancing timestamp minimally to avoid tight loop val events = body?.notifications.orEmpty()
delay(5_000) if (events.isNotEmpty()) {
// Map and emit based on preferences
events.forEach { ev ->
val listId = ev.listId ?: return@forEach
val list = db.listDao().getListById(listId) ?: return@forEach
handleEvent(appCtx, ev, list.name, prefs)
}
}
// Advance checkpoint to server timestamp to avoid re-processing
val stamp = body?.serverTimestamp ?: System.currentTimeMillis()
prefs.setLastListNotifyCheckTimestamp(stamp)
} else if (resp.code() == 401) {
// Unauthorized; back off by advancing timestamp minimally to avoid tight loop
delay(5_000)
}
} catch (e: Exception) {
try {
Logger.w("NotifPoll", "Polling failed: ${e.message}")
} catch (_: Exception) {
}
// brief backoff
delay(1_500)
} }
} catch (e: Exception) { val interval = prefs.syncPollIntervalMs.value
try { Logger.w("NotifPoll", "Polling failed: ${e.message}") } catch (_: Exception) {} delay(interval)
// brief backoff
delay(1_500)
} }
val interval = prefs.syncPollIntervalMs.value
delay(interval)
} }
}
} }
fun stop() { fun stop() {
@@ -76,20 +79,24 @@ object NotificationPoller {
) { ) {
// Respect notification switches by grouping semantics // Respect notification switches by grouping semantics
when (ev.entityType.lowercase()) { when (ev.entityType.lowercase()) {
"list" -> when (ev.eventType.lowercase()) { "list" ->
"created" -> if (prefs.notifyListAdded.value) NotificationHelper.showListAdded(context, ev.listId ?: "", listName) when (ev.eventType.lowercase()) {
"updated" -> if (prefs.notifyListEdited.value) NotificationHelper.showListEdited(context, ev.listId ?: "", listName) "created" -> if (prefs.notifyListAdded.value) NotificationHelper.showListAdded(context, ev.listId ?: "", listName)
"deleted" -> if (prefs.notifyListRemoved.value) NotificationHelper.showListRemoved(context, ev.listId ?: "", listName) "updated" -> if (prefs.notifyListEdited.value) NotificationHelper.showListEdited(context, ev.listId ?: "", listName)
} "deleted" -> if (prefs.notifyListRemoved.value) NotificationHelper.showListRemoved(context, ev.listId ?: "", listName)
}
// Treat fields/items/value changes as content updates // Treat fields/items/value changes as content updates
"field", "item", "value" -> if (prefs.notifyListContentUpdated.value) { "field", "item", "value" ->
NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName) if (prefs.notifyListContentUpdated.value) {
}
else -> when (ev.eventType.lowercase()) {
"listcontentupdated" -> if (prefs.notifyListContentUpdated.value) {
NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName) NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName)
} }
} else ->
when (ev.eventType.lowercase()) {
"listcontentupdated" ->
if (prefs.notifyListContentUpdated.value) {
NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName)
}
}
} }
} }
} }
@@ -51,7 +51,6 @@ import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
@@ -226,6 +225,7 @@ fun ListDetailScreen(
floatingActionButton = {}, floatingActionButton = {},
) { padding -> ) { padding ->
// Apply filtering, sorting and grouping off the main thread and memoize the result // Apply filtering, sorting and grouping off the main thread and memoize the result
@Suppress("ProduceStateDoesNotAssignValue")
val transformed by produceState( val transformed by produceState(
initialValue = TransformedItems(emptyList(), mapOf("_all" to emptyList())), initialValue = TransformedItems(emptyList(), mapOf("_all" to emptyList())),
stableItems, stableItems,
@@ -235,7 +235,7 @@ fun ListDetailScreen(
filterField, filterField,
filterValue, filterValue,
) { ) {
value = val transformedItems =
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
transformItems( transformItems(
items = stableItems, items = stableItems,
@@ -246,6 +246,7 @@ fun ListDetailScreen(
filterValue = filterValue, filterValue = filterValue,
) )
} }
value = transformedItems
} }
val processedItems = transformed.processed val processedItems = transformed.processed
@@ -550,17 +551,19 @@ fun ListDetailScreen(
} }
stableItems.isEmpty() -> { stableItems.isEmpty() -> {
Box( Box(
modifier = Modifier modifier =
.fillMaxSize(), Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = stringResource(R.string.no_items), text = stringResource(R.string.no_items),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.padding(horizontal = 24.dp), .fillMaxWidth()
.padding(horizontal = 24.dp),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
} }
@@ -1046,8 +1049,7 @@ fun ItemRow(
.border( .border(
width = 1.dp, width = 1.dp,
color = MaterialTheme.colorScheme.outline, color = MaterialTheme.colorScheme.outline,
) ).fillMaxHeight()
.fillMaxHeight()
// Consistent minimum top/bottom padding regardless of row height // Consistent minimum top/bottom padding regardless of row height
.padding(horizontal = 8.dp, vertical = 8.dp), .padding(horizontal = 8.dp, vertical = 8.dp),
// Center horizontally based on alignment while always vertically centering // Center horizontally based on alignment while always vertically centering
@@ -1105,7 +1107,8 @@ fun ItemRow(
} }
com.collabtable.app.data.model.FieldType.SELECT, com.collabtable.app.data.model.FieldType.SELECT,
com.collabtable.app.data.model.FieldType.MULTI_SELECT -> { com.collabtable.app.data.model.FieldType.MULTI_SELECT,
-> {
Text( Text(
text = value?.value ?: "", text = value?.value ?: "",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -2365,9 +2368,10 @@ fun FieldInput(
com.collabtable.app.data.model.FieldType.MULTI_SELECT -> { com.collabtable.app.data.model.FieldType.MULTI_SELECT -> {
val options = field.getSelectOptions() val options = field.getSelectOptions()
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
val selectedOptions = remember(value) { val selectedOptions =
value.split(", ").filter { it.isNotBlank() }.toSet() remember(value) {
} value.split(", ").filter { it.isNotBlank() }.toSet()
}
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
expanded = expanded, expanded = expanded,
@@ -2402,11 +2406,12 @@ fun FieldInput(
} }
}, },
onClick = { onClick = {
val newSelected = if (isSelected) { val newSelected =
selectedOptions - option if (isSelected) {
} else { selectedOptions - option
selectedOptions + option } else {
} selectedOptions + option
}
val orderedNewSelected = options.filter { it in newSelected } val orderedNewSelected = options.filter { it in newSelected }
onValueChange(orderedNewSelected.joinToString(", ")) onValueChange(orderedNewSelected.joinToString(", "))
}, },
@@ -4083,11 +4088,3 @@ private fun RenameListDialog(
}, },
) )
} }
@@ -3,7 +3,9 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import android.Manifest import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build import android.os.Build
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -29,9 +31,6 @@ import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import android.net.Uri
import android.content.Intent
import androidx.documentfile.provider.DocumentFile
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
@@ -60,6 +59,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.documentfile.provider.DocumentFile
import com.collabtable.app.R 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
@@ -349,32 +349,39 @@ fun ListsScreen(
} }
// Export dialog (top-level) separate from create/edit/delete dialogs // Export dialog (top-level) separate from create/edit/delete dialogs
listToExport?.let { list -> listToExport?.let { list ->
val folderPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> val folderPicker =
if (uri != null) { rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
// Take persistable permission for potential reuse during this session if (uri != null) {
try { // Take persistable permission for potential reuse during this session
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION try {
context.contentResolver.takePersistableUriPermission(uri, flags) val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
} catch (_: Exception) { } context.contentResolver.takePersistableUriPermission(uri, flags)
exportTargetUri = uri } catch (_: Exception) {
}
exportTargetUri = uri
}
} }
}
ExportListDialog( ExportListDialog(
list = list, list = list,
selectedDirectoryLabel = exportTargetUri?.let { uri -> selectedDirectoryLabel =
DocumentFile.fromTreeUri(context, uri)?.name ?: uri.lastPathSegment ?: "(selected)" exportTargetUri?.let { uri ->
} ?: "App storage (default)", DocumentFile.fromTreeUri(context, uri)?.name ?: uri.lastPathSegment ?: "(selected)"
} ?: "App storage (default)",
onSelectDirectory = { folderPicker.launch(null) }, onSelectDirectory = { folderPicker.launch(null) },
onDismiss = { listToExport = null; exportTargetUri = null }, onDismiss = {
listToExport = null
exportTargetUri = null
},
onConfirmExport = { format -> onConfirmExport = { format ->
if (format == "CSV") { if (format == "CSV") {
coroutineScope.launch { coroutineScope.launch {
val result = viewModel.exportListToCsv(list.id, list.name, exportTargetUri) val result = viewModel.exportListToCsv(list.id, list.name, exportTargetUri)
result.onSuccess { path -> result
Toast.makeText(context, context.getString(R.string.export_success, path), Toast.LENGTH_LONG).show() .onSuccess { path ->
}.onFailure { Toast.makeText(context, context.getString(R.string.export_success, path), Toast.LENGTH_LONG).show()
Toast.makeText(context, context.getString(R.string.export_error), Toast.LENGTH_LONG).show() }.onFailure {
} Toast.makeText(context, context.getString(R.string.export_error), Toast.LENGTH_LONG).show()
}
listToExport = null listToExport = null
exportTargetUri = null exportTargetUri = null
} }
@@ -500,7 +507,6 @@ fun CreateListDialog(
} }
}, },
) )
} }
@Composable @Composable
@@ -612,7 +618,10 @@ private fun ExportListDialog(
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.format_csv)) }, text = { Text(stringResource(R.string.format_csv)) },
onClick = { selectedFormat = "CSV"; expanded = false }, onClick = {
selectedFormat = "CSV"
expanded = false
},
) )
} }
} }
@@ -1,6 +1,8 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import android.content.Context import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@@ -19,12 +21,10 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.UUID
import java.util.Locale import java.util.Locale
import java.util.UUID
class ListsViewModel( class ListsViewModel(
private val database: CollabTableDatabase, private val database: CollabTableDatabase,
@@ -239,34 +239,41 @@ class ListsViewModel(
targetTreeUri: Uri? = null, targetTreeUri: Uri? = null,
): Result<String> { ): Result<String> {
return try { return try {
// Load fields (ordered) and items with values // Load fields (ordered) and items with values
val fields: List<Field> = database.fieldDao().getFieldsForList(listId).first() val fields: List<Field> = database.fieldDao().getFieldsForList(listId).first()
val items: List<ItemWithValues> = database.itemDao().getItemsWithValuesForList(listId).first() val items: List<ItemWithValues> = database.itemDao().getItemsWithValuesForList(listId).first()
// Build header // Build header
val headers = fields.map { escapeCsv(it.name) } val headers = fields.map { escapeCsv(it.name) }
val fieldIdOrder = fields.map { it.id } val fieldIdOrder = fields.map { it.id }
val rows = items.map { iwv -> val rows =
val valueMap = iwv.values.associateBy { it.fieldId } items.map { iwv ->
fieldIdOrder.joinToString(",") { fid -> val valueMap = iwv.values.associateBy { it.fieldId }
val raw = valueMap[fid]?.value ?: "" fieldIdOrder.joinToString(",") { fid ->
escapeCsv(raw) val raw = valueMap[fid]?.value ?: ""
} escapeCsv(raw)
} }
}
val csv = buildString { val csv =
append(headers.joinToString(",")) buildString {
append('\n') append(headers.joinToString(","))
rows.forEachIndexed { idx, row -> append('\n')
append(row) rows.forEachIndexed { idx, row ->
if (idx != rows.lastIndex) append('\n') append(row)
} if (idx != rows.lastIndex) append('\n')
} }
}
val safeBase = listName.lowercase(Locale.US).replace("[^a-z0-9]+".toRegex(), "_").trim('_').ifBlank { "table" } val safeBase =
val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) listName
val fileName = "${safeBase}_${ts}.csv" .lowercase(Locale.US)
.replace("[^a-z0-9]+".toRegex(), "_")
.trim('_')
.ifBlank { "table" }
val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val fileName = "${safeBase}_$ts.csv"
if (targetTreeUri != null) { if (targetTreeUri != null) {
val docTree = DocumentFile.fromTreeUri(context, targetTreeUri) val docTree = DocumentFile.fromTreeUri(context, targetTreeUri)
if (docTree == null || !docTree.isDirectory || !docTree.canWrite()) { if (docTree == null || !docTree.isDirectory || !docTree.canWrite()) {
@@ -453,7 +453,7 @@ fun SettingsScreen(
modifier = Modifier.padding(12.dp), modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text( Text(
text = "View application logs", text = "View application logs",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -538,7 +538,8 @@ fun SettingsScreen(
onDismissRequest = { showLeaveDialog = false }, onDismissRequest = { showLeaveDialog = false },
title = { Text(stringResource(R.string.leave_server_dialog_title)) }, title = { Text(stringResource(R.string.leave_server_dialog_title)) },
text = { text = {
Text(stringResource(R.string.leave_server_dialog_body), Text(
stringResource(R.string.leave_server_dialog_body),
) )
}, },
confirmButton = { confirmButton = {
+1 -1
View File
@@ -26,7 +26,7 @@ function sleep(ms: number) {
async function initializeDatabaseWithRetry() { async function initializeDatabaseWithRetry() {
let attempt = 0; let attempt = 0;
while (true) { for (;;) {
attempt += 1; attempt += 1;
try { try {
+6 -2
View File
@@ -57,7 +57,9 @@ router.post('/values', async (req: Request, res: Response) => {
if (listId) { if (listId) {
await enqueueNotification(dbAdapter, (req as any).deviceId, 'listContentUpdated', 'value', id, listId, Date.now()); await enqueueNotification(dbAdapter, (req as any).deviceId, 'listContentUpdated', 'value', id, listId, Date.now());
} }
} catch {} } catch (notificationError) {
void notificationError;
}
res.json(itemValue); res.json(itemValue);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to save item value' }); res.status(500).json({ error: 'Failed to save item value' });
@@ -101,7 +103,9 @@ router.delete('/:id', async (req: Request, res: Response) => {
const item = await dbAdapter.queryOne('SELECT * FROM items WHERE id = ?', [req.params.id]); const item = await dbAdapter.queryOne('SELECT * FROM items WHERE id = ?', [req.params.id]);
const listId = item ? ((item as any).listId ?? (item as any).listid) : undefined; const listId = item ? ((item as any).listId ?? (item as any).listid) : undefined;
await enqueueNotification(dbAdapter, (req as any).deviceId, 'deleted', 'item', req.params.id, listId, updatedAt); await enqueueNotification(dbAdapter, (req as any).deviceId, 'deleted', 'item', req.params.id, listId, updatedAt);
} catch {} } catch (notificationError) {
void notificationError;
}
res.json({ message: 'Item deleted successfully' }); res.json({ message: 'Item deleted successfully' });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to delete item' }); res.status(500).json({ error: 'Failed to delete item' });
+12 -4
View File
@@ -167,7 +167,9 @@ router.post('/sync', async (req: Request, res: Response) => {
try { try {
const ev = list.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated'); const ev = list.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated');
await enqueueNotification(tx, deviceId, ev, 'list', list.id, list.id, listTs.updatedAt); await enqueueNotification(tx, deviceId, ev, 'list', list.id, list.id, listTs.updatedAt);
} catch {} } catch (notificationError) {
void notificationError;
}
// If a list was deleted, cascade the deletion to child records on server // If a list was deleted, cascade the deletion to child records on server
// - mark fields and items as deleted (tombstones) so other clients learn about them // - mark fields and items as deleted (tombstones) so other clients learn about them
// - remove item_values belonging to items under this list (no tombstone support for values) // - remove item_values belonging to items under this list (no tombstone support for values)
@@ -201,7 +203,9 @@ router.post('/sync', async (req: Request, res: Response) => {
try { try {
const ev = field.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated'); const ev = field.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated');
await enqueueNotification(tx, deviceId, ev, 'field', field.id, field.listId, fieldTs.updatedAt); await enqueueNotification(tx, deviceId, ev, 'field', field.id, field.listId, fieldTs.updatedAt);
} catch {} } catch (notificationError) {
void notificationError;
}
if (field.isDeleted) { if (field.isDeleted) {
// Remove item_values for this field // Remove item_values for this field
try { try {
@@ -239,7 +243,9 @@ router.post('/sync', async (req: Request, res: Response) => {
try { try {
const ev = item.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated'); const ev = item.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated');
await enqueueNotification(tx, deviceId, ev, 'item', item.id, item.listId, itemTs.updatedAt); await enqueueNotification(tx, deviceId, ev, 'item', item.id, item.listId, itemTs.updatedAt);
} catch {} } catch (notificationError) {
void notificationError;
}
// If an item was deleted, remove its values (no tombstone support for values) // If an item was deleted, remove its values (no tombstone support for values)
if (item.isDeleted) { if (item.isDeleted) {
try { try {
@@ -324,7 +330,9 @@ router.post('/sync', async (req: Request, res: Response) => {
if (lId) { if (lId) {
await enqueueNotification(tx, deviceId, 'listContentUpdated', 'value', value.id, lId, valueUpdatedAt); await enqueueNotification(tx, deviceId, 'listContentUpdated', 'value', value.id, lId, valueUpdatedAt);
} }
} catch {} } catch (notificationError) {
void notificationError;
}
} catch (err: any) { } catch (err: any) {
// Catch FK violation just in case race or deletion happened inside same sync // Catch FK violation just in case race or deletion happened inside same sync
if (err && err.code === '23503') { if (err && err.code === '23503') {