feat: enhance notification handling and improve code structure across multiple components
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" ?>
|
||||||
|
<SmellBaseline>
|
||||||
|
<ManuallySuppressedIssues/>
|
||||||
|
<CurrentIssues>
|
||||||
|
<ID>ComplexCondition:Logger.kt$Logger$last != null && last.level == level && last.tag == tag && last.message == message && (now - last.timestamp) <= DEDUPE_WINDOW_MS</ID>
|
||||||
|
<ID>ExplicitItLambdaParameter:ListsScreen.kt${ _, it -> it.id }</ID>
|
||||||
|
<ID>ReturnCount:ListsViewModel.kt$ListsViewModel$suspend fun exportListToCsv( listId: String, listName: String, targetTreeUri: Uri? = null, ): Result<String></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) -> Unit = {}</ID>
|
||||||
|
<ID>UnusedParameter:ListDetailScreen.kt$onUpdateAlignment: (String, String) -> Unit</ID>
|
||||||
|
<ID>UnusedParameter:ListsScreen.kt$onNavigateToLogs: () -> Unit = {}</ID>
|
||||||
|
<ID>UnusedParameter:ListsScreen.kt$onNavigateToSettings: () -> Unit</ID>
|
||||||
|
<ID>UnusedParameter:SettingsScreen.kt$onNavigateBack: () -> Unit</ID>
|
||||||
|
<ID>UnusedPrivateMember:ListDetailScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun FilterSortDialog( fields: List<Field>, currentSortField: Field?, currentSortAscending: Boolean, currentGroupByField: Field?, currentFilterField: Field?, currentFilterValue: String, onDismiss: () -> Unit, onApply: (sortField: Field?, sortAscending: Boolean, groupByField: Field?, filterField: Field?, filterValue: String) -> Unit, onClearAll: () -> 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
|
||||||
|
|||||||
+7
-4
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+13
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-95
@@ -1,95 +1,102 @@
|
|||||||
package com.collabtable.app.notifications
|
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
|
import com.collabtable.app.utils.Logger
|
||||||
import com.collabtable.app.utils.Logger
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
object NotificationPoller {
|
||||||
object NotificationPoller {
|
@Volatile private var job: Job? = null
|
||||||
@Volatile private var job: Job? = null
|
|
||||||
|
fun start(context: Context) {
|
||||||
fun start(context: Context) {
|
if (job?.isActive == true) return
|
||||||
if (job?.isActive == true) return
|
val appCtx = context.applicationContext
|
||||||
val appCtx = context.applicationContext
|
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 =
|
||||||
job = CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
try {
|
try {
|
||||||
// Skip if password missing (auth disabled)
|
// Skip if password missing (auth disabled)
|
||||||
val pwd = prefs.getServerPassword()?.trim()
|
val pwd = prefs.getServerPassword()?.trim()
|
||||||
if (pwd.isNullOrBlank() || pwd == "\$password") {
|
if (pwd.isNullOrBlank() || pwd == "\$password") {
|
||||||
delay(2_000)
|
delay(2_000)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val since = prefs.getLastListNotifyCheckTimestamp()
|
val since = prefs.getLastListNotifyCheckTimestamp()
|
||||||
val resp = api.pollNotifications(since)
|
val resp = api.pollNotifications(since)
|
||||||
if (resp.isSuccessful) {
|
if (resp.isSuccessful) {
|
||||||
val body = resp.body()
|
val body = resp.body()
|
||||||
val events = body?.notifications.orEmpty()
|
val events = body?.notifications.orEmpty()
|
||||||
if (events.isNotEmpty()) {
|
if (events.isNotEmpty()) {
|
||||||
// Map and emit based on preferences
|
// Map and emit based on preferences
|
||||||
events.forEach { ev ->
|
events.forEach { ev ->
|
||||||
val listId = ev.listId ?: return@forEach
|
val listId = ev.listId ?: return@forEach
|
||||||
val list = db.listDao().getListById(listId) ?: return@forEach
|
val list = db.listDao().getListById(listId) ?: return@forEach
|
||||||
handleEvent(appCtx, ev, list.name, prefs)
|
handleEvent(appCtx, ev, list.name, prefs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Advance checkpoint to server timestamp to avoid re-processing
|
// Advance checkpoint to server timestamp to avoid re-processing
|
||||||
val stamp = body?.serverTimestamp ?: System.currentTimeMillis()
|
val stamp = body?.serverTimestamp ?: System.currentTimeMillis()
|
||||||
prefs.setLastListNotifyCheckTimestamp(stamp)
|
prefs.setLastListNotifyCheckTimestamp(stamp)
|
||||||
} else if (resp.code() == 401) {
|
} else if (resp.code() == 401) {
|
||||||
// Unauthorized; back off by advancing timestamp minimally to avoid tight loop
|
// Unauthorized; back off by advancing timestamp minimally to avoid tight loop
|
||||||
delay(5_000)
|
delay(5_000)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
try { Logger.w("NotifPoll", "Polling failed: ${e.message}") } catch (_: Exception) {}
|
try {
|
||||||
// brief backoff
|
Logger.w("NotifPoll", "Polling failed: ${e.message}")
|
||||||
delay(1_500)
|
} catch (_: Exception) {
|
||||||
}
|
}
|
||||||
val interval = prefs.syncPollIntervalMs.value
|
// brief backoff
|
||||||
delay(interval)
|
delay(1_500)
|
||||||
}
|
}
|
||||||
}
|
val interval = prefs.syncPollIntervalMs.value
|
||||||
}
|
delay(interval)
|
||||||
|
}
|
||||||
fun stop() {
|
}
|
||||||
job?.cancel()
|
}
|
||||||
job = null
|
|
||||||
}
|
fun stop() {
|
||||||
|
job?.cancel()
|
||||||
private fun handleEvent(
|
job = null
|
||||||
context: Context,
|
}
|
||||||
ev: NotificationEvent,
|
|
||||||
listName: String,
|
private fun handleEvent(
|
||||||
prefs: PreferencesManager,
|
context: Context,
|
||||||
) {
|
ev: NotificationEvent,
|
||||||
// Respect notification switches by grouping semantics
|
listName: String,
|
||||||
when (ev.entityType.lowercase()) {
|
prefs: PreferencesManager,
|
||||||
"list" -> when (ev.eventType.lowercase()) {
|
) {
|
||||||
"created" -> if (prefs.notifyListAdded.value) NotificationHelper.showListAdded(context, ev.listId ?: "", listName)
|
// Respect notification switches by grouping semantics
|
||||||
"updated" -> if (prefs.notifyListEdited.value) NotificationHelper.showListEdited(context, ev.listId ?: "", listName)
|
when (ev.entityType.lowercase()) {
|
||||||
"deleted" -> if (prefs.notifyListRemoved.value) NotificationHelper.showListRemoved(context, ev.listId ?: "", listName)
|
"list" ->
|
||||||
}
|
when (ev.eventType.lowercase()) {
|
||||||
// Treat fields/items/value changes as content updates
|
"created" -> if (prefs.notifyListAdded.value) NotificationHelper.showListAdded(context, ev.listId ?: "", listName)
|
||||||
"field", "item", "value" -> if (prefs.notifyListContentUpdated.value) {
|
"updated" -> if (prefs.notifyListEdited.value) NotificationHelper.showListEdited(context, ev.listId ?: "", listName)
|
||||||
NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName)
|
"deleted" -> if (prefs.notifyListRemoved.value) NotificationHelper.showListRemoved(context, ev.listId ?: "", listName)
|
||||||
}
|
}
|
||||||
else -> when (ev.eventType.lowercase()) {
|
// Treat fields/items/value changes as content updates
|
||||||
"listcontentupdated" -> if (prefs.notifyListContentUpdated.value) {
|
"field", "item", "value" ->
|
||||||
NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName)
|
if (prefs.notifyListContentUpdated.value) {
|
||||||
}
|
NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName)
|
||||||
}
|
}
|
||||||
}
|
else ->
|
||||||
}
|
when (ev.eventType.lowercase()) {
|
||||||
}
|
"listcontentupdated" ->
|
||||||
|
if (prefs.notifyListContentUpdated.value) {
|
||||||
|
NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+25
-28
@@ -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,
|
||||||
@@ -2394,19 +2398,20 @@ fun FieldInput(
|
|||||||
options.forEach { option ->
|
options.forEach { option ->
|
||||||
val isSelected = selectedOptions.contains(option)
|
val isSelected = selectedOptions.contains(option)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = {
|
text = {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Checkbox(checked = isSelected, onCheckedChange = null)
|
Checkbox(checked = isSelected, onCheckedChange = null)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(option)
|
Text(option)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+32
-23
@@ -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
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-27
@@ -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()) {
|
||||||
|
|||||||
+3
-2
@@ -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 = {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
Reference in New Issue
Block a user