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)
|
||||
NotificationPoller.start(this@CollabTableApplication)
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
NotificationPoller.stop()
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import com.collabtable.app.data.model.Item
|
||||
import com.collabtable.app.data.model.ItemValue
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Query
|
||||
|
||||
data class SyncRequest(
|
||||
|
||||
@@ -48,7 +48,11 @@ interface FieldDao {
|
||||
suspend fun getFieldsUpdatedSince(since: Long): List<Field>
|
||||
|
||||
@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
|
||||
@Transaction
|
||||
|
||||
+7
-4
@@ -16,8 +16,8 @@ class PreferencesManager(
|
||||
private val _serverUrl = MutableStateFlow(getServerUrl())
|
||||
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
|
||||
|
||||
private val _isFirstRun = MutableStateFlow(isFirstRun())
|
||||
val isFirstRunFlow: StateFlow<Boolean> = _isFirstRun.asStateFlow()
|
||||
private val _isFirstRunFlow = MutableStateFlow(isFirstRun())
|
||||
val isFirstRunFlow: StateFlow<Boolean> = _isFirstRunFlow.asStateFlow()
|
||||
|
||||
private val _themeMode = MutableStateFlow(getThemeMode())
|
||||
val themeMode: StateFlow<String> = _themeMode.asStateFlow()
|
||||
@@ -67,7 +67,7 @@ class PreferencesManager(
|
||||
|
||||
fun setIsFirstRun(isFirstRun: Boolean) {
|
||||
prefs.edit().putBoolean(KEY_FIRST_RUN, isFirstRun).apply()
|
||||
_isFirstRun.value = isFirstRun
|
||||
_isFirstRunFlow.value = isFirstRun
|
||||
}
|
||||
|
||||
fun getServerPassword(): String? = prefs.getString(KEY_SERVER_PASSWORD, null)
|
||||
@@ -272,7 +272,10 @@ class PreferencesManager(
|
||||
fun getDeviceId(): String {
|
||||
val existing = prefs.getString(KEY_DEVICE_ID, null)
|
||||
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()
|
||||
return id
|
||||
}
|
||||
|
||||
+13
@@ -1,8 +1,12 @@
|
||||
package com.collabtable.app.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.collabtable.app.R
|
||||
|
||||
object NotificationHelper {
|
||||
@@ -34,6 +38,15 @@ object NotificationHelper {
|
||||
id: Int,
|
||||
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)) {
|
||||
notify(id, builder.build())
|
||||
}
|
||||
|
||||
+102
-95
@@ -1,95 +1,102 @@
|
||||
package com.collabtable.app.notifications
|
||||
|
||||
import android.content.Context
|
||||
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.database.CollabTableDatabase
|
||||
import com.collabtable.app.data.preferences.PreferencesManager
|
||||
import com.collabtable.app.utils.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object NotificationPoller {
|
||||
@Volatile private var job: Job? = null
|
||||
|
||||
fun start(context: Context) {
|
||||
if (job?.isActive == true) return
|
||||
val appCtx = context.applicationContext
|
||||
val prefs = PreferencesManager.getInstance(appCtx)
|
||||
val api = ApiClient.api
|
||||
val db = CollabTableDatabase.getDatabase(appCtx)
|
||||
job = CoroutineScope(Dispatchers.IO).launch {
|
||||
while (isActive) {
|
||||
try {
|
||||
// Skip if password missing (auth disabled)
|
||||
val pwd = prefs.getServerPassword()?.trim()
|
||||
if (pwd.isNullOrBlank() || pwd == "\$password") {
|
||||
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 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)
|
||||
}
|
||||
val interval = prefs.syncPollIntervalMs.value
|
||||
delay(interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
}
|
||||
|
||||
private fun handleEvent(
|
||||
context: Context,
|
||||
ev: NotificationEvent,
|
||||
listName: String,
|
||||
prefs: PreferencesManager,
|
||||
) {
|
||||
// Respect notification switches by grouping semantics
|
||||
when (ev.entityType.lowercase()) {
|
||||
"list" -> when (ev.eventType.lowercase()) {
|
||||
"created" -> if (prefs.notifyListAdded.value) NotificationHelper.showListAdded(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
|
||||
"field", "item", "value" -> 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.collabtable.app.notifications
|
||||
|
||||
import android.content.Context
|
||||
import com.collabtable.app.data.api.ApiClient
|
||||
import com.collabtable.app.data.api.NotificationEvent
|
||||
import com.collabtable.app.data.database.CollabTableDatabase
|
||||
import com.collabtable.app.data.preferences.PreferencesManager
|
||||
import com.collabtable.app.utils.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object NotificationPoller {
|
||||
@Volatile private var job: Job? = null
|
||||
|
||||
fun start(context: Context) {
|
||||
if (job?.isActive == true) return
|
||||
val appCtx = context.applicationContext
|
||||
val prefs = PreferencesManager.getInstance(appCtx)
|
||||
val api = ApiClient.api
|
||||
val db = CollabTableDatabase.getDatabase(appCtx)
|
||||
job =
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
while (isActive) {
|
||||
try {
|
||||
// Skip if password missing (auth disabled)
|
||||
val pwd = prefs.getServerPassword()?.trim()
|
||||
if (pwd.isNullOrBlank() || pwd == "\$password") {
|
||||
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 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)
|
||||
}
|
||||
val interval = prefs.syncPollIntervalMs.value
|
||||
delay(interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
}
|
||||
|
||||
private fun handleEvent(
|
||||
context: Context,
|
||||
ev: NotificationEvent,
|
||||
listName: String,
|
||||
prefs: PreferencesManager,
|
||||
) {
|
||||
// Respect notification switches by grouping semantics
|
||||
when (ev.entityType.lowercase()) {
|
||||
"list" ->
|
||||
when (ev.eventType.lowercase()) {
|
||||
"created" -> if (prefs.notifyListAdded.value) NotificationHelper.showListAdded(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
|
||||
"field", "item", "value" ->
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
@@ -226,6 +225,7 @@ fun ListDetailScreen(
|
||||
floatingActionButton = {},
|
||||
) { padding ->
|
||||
// Apply filtering, sorting and grouping off the main thread and memoize the result
|
||||
@Suppress("ProduceStateDoesNotAssignValue")
|
||||
val transformed by produceState(
|
||||
initialValue = TransformedItems(emptyList(), mapOf("_all" to emptyList())),
|
||||
stableItems,
|
||||
@@ -235,7 +235,7 @@ fun ListDetailScreen(
|
||||
filterField,
|
||||
filterValue,
|
||||
) {
|
||||
value =
|
||||
val transformedItems =
|
||||
withContext(Dispatchers.Default) {
|
||||
transformItems(
|
||||
items = stableItems,
|
||||
@@ -246,6 +246,7 @@ fun ListDetailScreen(
|
||||
filterValue = filterValue,
|
||||
)
|
||||
}
|
||||
value = transformedItems
|
||||
}
|
||||
|
||||
val processedItems = transformed.processed
|
||||
@@ -550,17 +551,19 @@ fun ListDetailScreen(
|
||||
}
|
||||
stableItems.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_items),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
@@ -1046,8 +1049,7 @@ fun ItemRow(
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
.fillMaxHeight()
|
||||
).fillMaxHeight()
|
||||
// Consistent minimum top/bottom padding regardless of row height
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
// 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.MULTI_SELECT -> {
|
||||
com.collabtable.app.data.model.FieldType.MULTI_SELECT,
|
||||
-> {
|
||||
Text(
|
||||
text = value?.value ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -2365,9 +2368,10 @@ fun FieldInput(
|
||||
com.collabtable.app.data.model.FieldType.MULTI_SELECT -> {
|
||||
val options = field.getSelectOptions()
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val selectedOptions = remember(value) {
|
||||
value.split(", ").filter { it.isNotBlank() }.toSet()
|
||||
}
|
||||
val selectedOptions =
|
||||
remember(value) {
|
||||
value.split(", ").filter { it.isNotBlank() }.toSet()
|
||||
}
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
@@ -2394,19 +2398,20 @@ fun FieldInput(
|
||||
options.forEach { option ->
|
||||
val isSelected = selectedOptions.contains(option)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
text = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = isSelected, onCheckedChange = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(option)
|
||||
Text(option)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
val newSelected = if (isSelected) {
|
||||
selectedOptions - option
|
||||
} else {
|
||||
selectedOptions + option
|
||||
}
|
||||
val newSelected =
|
||||
if (isSelected) {
|
||||
selectedOptions - option
|
||||
} else {
|
||||
selectedOptions + option
|
||||
}
|
||||
val orderedNewSelected = options.filter { it in newSelected }
|
||||
onValueChange(orderedNewSelected.joinToString(", "))
|
||||
},
|
||||
@@ -4083,11 +4088,3 @@ private fun RenameListDialog(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+32
-23
@@ -3,7 +3,9 @@
|
||||
package com.collabtable.app.ui.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
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.List
|
||||
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.CircularProgressIndicator
|
||||
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.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.collabtable.app.R
|
||||
import com.collabtable.app.data.database.CollabTableDatabase
|
||||
import com.collabtable.app.data.model.CollabList
|
||||
@@ -349,32 +349,39 @@ fun ListsScreen(
|
||||
}
|
||||
// Export dialog (top-level) separate from create/edit/delete dialogs
|
||||
listToExport?.let { list ->
|
||||
val folderPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||
if (uri != null) {
|
||||
// Take persistable permission for potential reuse during this session
|
||||
try {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
context.contentResolver.takePersistableUriPermission(uri, flags)
|
||||
} catch (_: Exception) { }
|
||||
exportTargetUri = uri
|
||||
val folderPicker =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||
if (uri != null) {
|
||||
// Take persistable permission for potential reuse during this session
|
||||
try {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
context.contentResolver.takePersistableUriPermission(uri, flags)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
exportTargetUri = uri
|
||||
}
|
||||
}
|
||||
}
|
||||
ExportListDialog(
|
||||
list = list,
|
||||
selectedDirectoryLabel = exportTargetUri?.let { uri ->
|
||||
DocumentFile.fromTreeUri(context, uri)?.name ?: uri.lastPathSegment ?: "(selected)"
|
||||
} ?: "App storage (default)",
|
||||
selectedDirectoryLabel =
|
||||
exportTargetUri?.let { uri ->
|
||||
DocumentFile.fromTreeUri(context, uri)?.name ?: uri.lastPathSegment ?: "(selected)"
|
||||
} ?: "App storage (default)",
|
||||
onSelectDirectory = { folderPicker.launch(null) },
|
||||
onDismiss = { listToExport = null; exportTargetUri = null },
|
||||
onDismiss = {
|
||||
listToExport = null
|
||||
exportTargetUri = null
|
||||
},
|
||||
onConfirmExport = { format ->
|
||||
if (format == "CSV") {
|
||||
coroutineScope.launch {
|
||||
val result = viewModel.exportListToCsv(list.id, list.name, exportTargetUri)
|
||||
result.onSuccess { path ->
|
||||
Toast.makeText(context, context.getString(R.string.export_success, path), Toast.LENGTH_LONG).show()
|
||||
}.onFailure {
|
||||
Toast.makeText(context, context.getString(R.string.export_error), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
result
|
||||
.onSuccess { path ->
|
||||
Toast.makeText(context, context.getString(R.string.export_success, path), Toast.LENGTH_LONG).show()
|
||||
}.onFailure {
|
||||
Toast.makeText(context, context.getString(R.string.export_error), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
listToExport = null
|
||||
exportTargetUri = null
|
||||
}
|
||||
@@ -500,7 +507,6 @@ fun CreateListDialog(
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -612,7 +618,10 @@ private fun ExportListDialog(
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
DropdownMenuItem(
|
||||
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
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.ViewModel
|
||||
@@ -19,12 +21,10 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
class ListsViewModel(
|
||||
private val database: CollabTableDatabase,
|
||||
@@ -239,34 +239,41 @@ class ListsViewModel(
|
||||
targetTreeUri: Uri? = null,
|
||||
): Result<String> {
|
||||
return try {
|
||||
// Load fields (ordered) and items with values
|
||||
val fields: List<Field> = database.fieldDao().getFieldsForList(listId).first()
|
||||
val items: List<ItemWithValues> = database.itemDao().getItemsWithValuesForList(listId).first()
|
||||
// Load fields (ordered) and items with values
|
||||
val fields: List<Field> = database.fieldDao().getFieldsForList(listId).first()
|
||||
val items: List<ItemWithValues> = database.itemDao().getItemsWithValuesForList(listId).first()
|
||||
|
||||
// Build header
|
||||
val headers = fields.map { escapeCsv(it.name) }
|
||||
val fieldIdOrder = fields.map { it.id }
|
||||
// Build header
|
||||
val headers = fields.map { escapeCsv(it.name) }
|
||||
val fieldIdOrder = fields.map { it.id }
|
||||
|
||||
val rows = items.map { iwv ->
|
||||
val valueMap = iwv.values.associateBy { it.fieldId }
|
||||
fieldIdOrder.joinToString(",") { fid ->
|
||||
val raw = valueMap[fid]?.value ?: ""
|
||||
escapeCsv(raw)
|
||||
}
|
||||
}
|
||||
val rows =
|
||||
items.map { iwv ->
|
||||
val valueMap = iwv.values.associateBy { it.fieldId }
|
||||
fieldIdOrder.joinToString(",") { fid ->
|
||||
val raw = valueMap[fid]?.value ?: ""
|
||||
escapeCsv(raw)
|
||||
}
|
||||
}
|
||||
|
||||
val csv = buildString {
|
||||
append(headers.joinToString(","))
|
||||
append('\n')
|
||||
rows.forEachIndexed { idx, row ->
|
||||
append(row)
|
||||
if (idx != rows.lastIndex) append('\n')
|
||||
}
|
||||
}
|
||||
val csv =
|
||||
buildString {
|
||||
append(headers.joinToString(","))
|
||||
append('\n')
|
||||
rows.forEachIndexed { idx, row ->
|
||||
append(row)
|
||||
if (idx != rows.lastIndex) append('\n')
|
||||
}
|
||||
}
|
||||
|
||||
val safeBase = listName.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"
|
||||
val safeBase =
|
||||
listName
|
||||
.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) {
|
||||
val docTree = DocumentFile.fromTreeUri(context, targetTreeUri)
|
||||
if (docTree == null || !docTree.isDirectory || !docTree.canWrite()) {
|
||||
|
||||
+3
-2
@@ -453,7 +453,7 @@ fun SettingsScreen(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
Text(
|
||||
text = "View application logs",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -538,7 +538,8 @@ fun SettingsScreen(
|
||||
onDismissRequest = { showLeaveDialog = false },
|
||||
title = { Text(stringResource(R.string.leave_server_dialog_title)) },
|
||||
text = {
|
||||
Text(stringResource(R.string.leave_server_dialog_body),
|
||||
Text(
|
||||
stringResource(R.string.leave_server_dialog_body),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
|
||||
Reference in New Issue
Block a user