diff --git a/CollabTableAndroid/app/build.gradle b/CollabTableAndroid/app/build.gradle
index e8d3108..d57dab8 100644
--- a/CollabTableAndroid/app/build.gradle
+++ b/CollabTableAndroid/app/build.gradle
@@ -54,6 +54,7 @@ dependencies {
// Core Android
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2'
+ implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
implementation 'androidx.activity:activity-compose:1.8.1'
// Compose
@@ -81,6 +82,9 @@ dependencies {
// Coroutines
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
+ // WorkManager for background checks/notifications
+ implementation 'androidx.work:work-runtime-ktx:2.9.0'
+
// Testing
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
diff --git a/CollabTableAndroid/app/src/main/AndroidManifest.xml b/CollabTableAndroid/app/src/main/AndroidManifest.xml
index 3966920..a6215ea 100644
--- a/CollabTableAndroid/app/src/main/AndroidManifest.xml
+++ b/CollabTableAndroid/app/src/main/AndroidManifest.xml
@@ -3,6 +3,8 @@
+
+
(15, TimeUnit.MINUTES)
+ .build()
+ WorkManager.getInstance(this).enqueueUniquePeriodicWork(
+ "list_change_notifier",
+ ExistingPeriodicWorkPolicy.UPDATE,
+ work,
+ )
}
}
diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt
index a6e0254..ac52ce9 100644
--- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt
+++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt
@@ -30,6 +30,16 @@ class PreferencesManager(context: Context) {
private val _syncPollIntervalMs = MutableStateFlow(getSyncPollIntervalMs())
val syncPollIntervalMs: StateFlow = _syncPollIntervalMs.asStateFlow()
+ // Notification preferences (per list event type)
+ private val _notifyListAdded = MutableStateFlow(isNotifyListAddedEnabled())
+ val notifyListAdded: StateFlow = _notifyListAdded.asStateFlow()
+
+ private val _notifyListEdited = MutableStateFlow(isNotifyListEditedEnabled())
+ val notifyListEdited: StateFlow = _notifyListEdited.asStateFlow()
+
+ private val _notifyListRemoved = MutableStateFlow(isNotifyListRemovedEnabled())
+ val notifyListRemoved: StateFlow = _notifyListRemoved.asStateFlow()
+
fun getServerUrl(): String {
return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL
}
@@ -141,6 +151,43 @@ class PreferencesManager(context: Context) {
_syncPollIntervalMs.value = clamped
}
+ // Last time we checked lists for background notifications
+ fun getLastListNotifyCheckTimestamp(): Long {
+ return prefs.getLong(KEY_LAST_LIST_NOTIFY_CHECK_TS, 0L)
+ }
+
+ fun setLastListNotifyCheckTimestamp(ts: Long) {
+ prefs.edit().putLong(KEY_LAST_LIST_NOTIFY_CHECK_TS, ts).apply()
+ }
+
+ // Notification settings accessors
+ private fun isNotifyListAddedEnabled(): Boolean {
+ return prefs.getBoolean(KEY_NOTIFY_LIST_ADDED, true)
+ }
+
+ fun setNotifyListAddedEnabled(enabled: Boolean) {
+ prefs.edit().putBoolean(KEY_NOTIFY_LIST_ADDED, enabled).apply()
+ _notifyListAdded.value = enabled
+ }
+
+ private fun isNotifyListEditedEnabled(): Boolean {
+ return prefs.getBoolean(KEY_NOTIFY_LIST_EDITED, true)
+ }
+
+ fun setNotifyListEditedEnabled(enabled: Boolean) {
+ prefs.edit().putBoolean(KEY_NOTIFY_LIST_EDITED, enabled).apply()
+ _notifyListEdited.value = enabled
+ }
+
+ private fun isNotifyListRemovedEnabled(): Boolean {
+ return prefs.getBoolean(KEY_NOTIFY_LIST_REMOVED, true)
+ }
+
+ fun setNotifyListRemovedEnabled(enabled: Boolean) {
+ prefs.edit().putBoolean(KEY_NOTIFY_LIST_REMOVED, enabled).apply()
+ _notifyListRemoved.value = enabled
+ }
+
// Persist per-list column widths (fieldId -> widthDp)
// Stored as a JSON object string in SharedPreferences under key: COLUMN_WIDTHS_PREFIX + listId
fun getColumnWidths(listId: String): Map {
@@ -183,6 +230,10 @@ class PreferencesManager(context: Context) {
private const val KEY_AMOLED_DARK = "amoled_dark"
private const val KEY_SORT_ORDER = "sort_order"
private const val KEY_SYNC_POLL_INTERVAL_MS = "sync_poll_interval_ms"
+ private const val KEY_NOTIFY_LIST_ADDED = "notify_list_added"
+ private const val KEY_NOTIFY_LIST_EDITED = "notify_list_edited"
+ private const val KEY_NOTIFY_LIST_REMOVED = "notify_list_removed"
+ private const val KEY_LAST_LIST_NOTIFY_CHECK_TS = "last_list_notify_check_ts"
private const val COLUMN_WIDTHS_PREFIX = "column_widths_" // + listId
private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/"
private const val DEFAULT_SYNC_POLL_INTERVAL_MS = 250L
diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/notifications/NotificationHelper.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/notifications/NotificationHelper.kt
new file mode 100644
index 0000000..0c5ff2d
--- /dev/null
+++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/notifications/NotificationHelper.kt
@@ -0,0 +1,93 @@
+package com.collabtable.app.notifications
+
+import android.app.NotificationManager
+import android.content.Context
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import com.collabtable.app.R
+
+object NotificationHelper {
+ const val CHANNEL_LIST_EVENTS = "list_events"
+ private const val GROUP_LIST_EVENTS = "group_list_events"
+ private const val SUMMARY_ID = 1000
+
+ // Keep a small rolling buffer of recent event lines for the summary
+ private val recentEventLines = ArrayDeque()
+ @Volatile private var totalEventCount: Int = 0
+ private val postedIds = mutableSetOf()
+
+ private fun builder(context: Context, title: String, text: String): NotificationCompat.Builder {
+ return NotificationCompat.Builder(context, CHANNEL_LIST_EVENTS)
+ .setSmallIcon(R.mipmap.ic_launcher)
+ .setContentTitle(title)
+ .setContentText(text)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setAutoCancel(true)
+ }
+
+ private fun notify(context: Context, id: Int, builder: NotificationCompat.Builder) {
+ with(NotificationManagerCompat.from(context)) {
+ notify(id, builder.build())
+ }
+ }
+
+ fun showListAdded(context: Context, listId: String, name: String) = showEvent(context, listId, "add", "List added", "\"$name\" was created")
+ fun showListEdited(context: Context, listId: String, name: String) = showEvent(context, listId, "edit", "List edited", "\"$name\" was renamed/updated")
+ fun showListRemoved(context: Context, listId: String, name: String) = showEvent(context, listId, "remove", "List removed", "\"$name\" was deleted")
+
+ private fun showEvent(context: Context, listId: String, type: String, title: String, text: String) {
+ val b = builder(context, title, text).setGroup(GROUP_LIST_EVENTS)
+ val id = (type + listId).hashCode()
+ synchronized(postedIds) { postedIds.add(id) }
+ notify(context, id, b)
+ recordEventLine(type, text)
+ postSummary(context)
+ }
+
+ private fun postSummary(context: Context) {
+ val inbox = NotificationCompat.InboxStyle()
+ synchronized(recentEventLines) {
+ recentEventLines.take(5).forEach { line -> inbox.addLine(line) }
+ inbox.setSummaryText("${totalEventCount} total event" + if (totalEventCount == 1) "" else "s")
+ }
+ val summary = NotificationCompat.Builder(context, CHANNEL_LIST_EVENTS)
+ .setSmallIcon(R.mipmap.ic_launcher)
+ .setContentTitle("List activity")
+ .setContentText("Recent list changes")
+ .setStyle(inbox)
+ .setGroup(GROUP_LIST_EVENTS)
+ .setGroupSummary(true)
+ .setAutoCancel(true)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setNumber(totalEventCount)
+ notify(context, SUMMARY_ID, summary)
+ }
+
+ private fun recordEventLine(type: String, text: String) {
+ val line = when (type) {
+ "add" -> "Added • $text"
+ "edit" -> "Edited • $text"
+ "remove" -> "Removed • $text"
+ else -> text
+ }
+ synchronized(recentEventLines) {
+ recentEventLines.addFirst(line)
+ // Keep only the last 8 lines to bound memory and UI clutter
+ while (recentEventLines.size > 8) recentEventLines.removeLast()
+ }
+ totalEventCount += 1
+ }
+
+ fun clearListEventNotifications(context: Context) {
+ val mgr = NotificationManagerCompat.from(context)
+ synchronized(postedIds) {
+ postedIds.forEach { id -> mgr.cancel(id) }
+ postedIds.clear()
+ }
+ mgr.cancel(SUMMARY_ID)
+ synchronized(recentEventLines) {
+ recentEventLines.clear()
+ totalEventCount = 0
+ }
+ }
+}
diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt
index f1a026d..abb0679 100644
--- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt
+++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt
@@ -6,6 +6,9 @@ import androidx.lifecycle.viewModelScope
import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.CollabList
import com.collabtable.app.data.repository.SyncRepository
+import com.collabtable.app.notifications.NotificationHelper
+import androidx.lifecycle.ProcessLifecycleOwner
+import androidx.lifecycle.Lifecycle
import com.collabtable.app.utils.Logger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -128,6 +131,7 @@ class ListsViewModel(
database.listDao().insertList(newList)
// Sync immediately after creating
performSync()
+ maybeNotifyListAdded(newList)
}
}
@@ -147,6 +151,7 @@ class ListsViewModel(
)
// Sync immediately after renaming
performSync()
+ maybeNotifyListEdited(list.copy(name = newName.trim()))
}
}
}
@@ -164,6 +169,7 @@ class ListsViewModel(
database.itemDao().softDeleteItemsByList(listId, ts)
// Sync immediately after deleting
performSync()
+ maybeNotifyListRemoved(list)
}
}
}
@@ -176,4 +182,30 @@ class ListsViewModel(
_isLoading.value = false
}
}
+
+ private fun isInForeground(): Boolean {
+ val state = ProcessLifecycleOwner.get().lifecycle.currentState
+ return state.isAtLeast(Lifecycle.State.STARTED)
+ }
+
+ private fun maybeNotifyListAdded(list: CollabList) {
+ val prefs = com.collabtable.app.data.preferences.PreferencesManager.getInstance(context)
+ if (prefs.notifyListAdded.value && !isInForeground()) {
+ NotificationHelper.showListAdded(context, list.id, list.name)
+ }
+ }
+
+ private fun maybeNotifyListEdited(list: CollabList) {
+ val prefs = com.collabtable.app.data.preferences.PreferencesManager.getInstance(context)
+ if (prefs.notifyListEdited.value && !isInForeground()) {
+ NotificationHelper.showListEdited(context, list.id, list.name)
+ }
+ }
+
+ private fun maybeNotifyListRemoved(list: CollabList) {
+ val prefs = com.collabtable.app.data.preferences.PreferencesManager.getInstance(context)
+ if (prefs.notifyListRemoved.value && !isInForeground()) {
+ NotificationHelper.showListRemoved(context, list.id, list.name)
+ }
+ }
}
diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupScreen.kt
index 0472a11..a979023 100644
--- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupScreen.kt
+++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupScreen.kt
@@ -41,7 +41,10 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import com.collabtable.app.data.preferences.PreferencesManager
+import android.os.Build
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -57,9 +60,26 @@ fun ServerSetupScreen(onSetupComplete: () -> Unit) {
val validationResult by viewModel.validationResult.collectAsState()
val validationError by viewModel.validationError.collectAsState()
+ val permissionLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ // If granted, enable all; else disable all
+ preferencesManager.setNotifyListAddedEnabled(granted)
+ preferencesManager.setNotifyListEditedEnabled(granted)
+ preferencesManager.setNotifyListRemovedEnabled(granted)
+ onSetupComplete()
+ }
+
LaunchedEffect(validationResult) {
if (validationResult == true) {
- onSetupComplete()
+ // On Android 13+ prompt for notifications; otherwise default to enabled
+ if (Build.VERSION.SDK_INT >= 33) {
+ permissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
+ } else {
+ preferencesManager.setNotifyListAddedEnabled(true)
+ preferencesManager.setNotifyListEditedEnabled(true)
+ preferencesManager.setNotifyListRemovedEnabled(true)
+ onSetupComplete()
+ }
}
}
diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt
index 2ac96f1..1839a55 100644
--- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt
+++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt
@@ -29,6 +29,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -36,6 +38,8 @@ import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -47,6 +51,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.core.content.ContextCompat
import com.collabtable.app.R
import com.collabtable.app.data.api.ApiClient
import com.collabtable.app.data.database.CollabTableDatabase
@@ -70,11 +78,15 @@ fun SettingsScreen(
val preferencesManager = remember { PreferencesManager.getInstance(context) }
val syncRepository = remember { SyncRepository(context) }
val coroutineScope = rememberCoroutineScope()
+ val snackbarHostState = remember { SnackbarHostState() }
val serverUrl = preferencesManager.getServerUrl()
val themeMode by preferencesManager.themeMode.collectAsState()
val dynamicColor by preferencesManager.dynamicColor.collectAsState()
val amoledDark by preferencesManager.amoledDark.collectAsState()
+ val notifyListAdded by preferencesManager.notifyListAdded.collectAsState()
+ val notifyListEdited by preferencesManager.notifyListEdited.collectAsState()
+ val notifyListRemoved by preferencesManager.notifyListRemoved.collectAsState()
val displayUrl = remember(serverUrl) { formatServerUrlForDisplay(serverUrl) }
var showLeaveDialog by remember { mutableStateOf(false) }
var isLeaving by remember { mutableStateOf(false) }
@@ -83,6 +95,38 @@ fun SettingsScreen(
var syncIntervalError by remember { mutableStateOf(null) }
// Authentication UI removed
+ // Permission launcher for Android 13+ notification permission
+ var pendingPermissionAction by remember { mutableStateOf<(() -> Unit)?>(null) }
+ val permissionLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ if (granted) {
+ pendingPermissionAction?.invoke()
+ } else {
+ coroutineScope.launch {
+ snackbarHostState.showSnackbar(
+ message = "Notification permission denied. Enable it in system settings to turn this on.",
+ )
+ }
+ }
+ pendingPermissionAction = null
+ }
+
+ fun ensureNotificationPermission(onGranted: () -> Unit) {
+ if (Build.VERSION.SDK_INT < 33) {
+ onGranted()
+ return
+ }
+ val granted =
+ ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
+ PackageManager.PERMISSION_GRANTED
+ if (granted) {
+ onGranted()
+ } else {
+ pendingPermissionAction = onGranted
+ permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+
// Removed test connectivity logic; the connection indicator is shown via ConnectionStatusAction
Scaffold(
@@ -104,6 +148,7 @@ fun SettingsScreen(
),
)
},
+ snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { padding ->
Column(
modifier =
@@ -372,6 +417,114 @@ fun SettingsScreen(
}
+ // Notifications section (placed after main content but before dialogs)
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(padding)
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(
+ text = "Notifications",
+ style = MaterialTheme.typography.titleMedium,
+ )
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ ) {
+ Column(
+ modifier = Modifier.padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text("List added")
+ Text(
+ text = "Notify when a new list/table is created",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Switch(
+ checked = notifyListAdded,
+ onCheckedChange = { checked ->
+ if (checked) {
+ ensureNotificationPermission {
+ preferencesManager.setNotifyListAddedEnabled(true)
+ }
+ } else {
+ preferencesManager.setNotifyListAddedEnabled(false)
+ }
+ },
+ )
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text("List edited")
+ Text(
+ text = "Notify when a list/table name changes",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Switch(
+ checked = notifyListEdited,
+ onCheckedChange = { checked ->
+ if (checked) {
+ ensureNotificationPermission {
+ preferencesManager.setNotifyListEditedEnabled(true)
+ }
+ } else {
+ preferencesManager.setNotifyListEditedEnabled(false)
+ }
+ },
+ )
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text("List removed")
+ Text(
+ text = "Notify when a list/table is deleted",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Switch(
+ checked = notifyListRemoved,
+ onCheckedChange = { checked ->
+ if (checked) {
+ ensureNotificationPermission {
+ preferencesManager.setNotifyListRemovedEnabled(true)
+ }
+ } else {
+ preferencesManager.setNotifyListRemovedEnabled(false)
+ }
+ },
+ )
+ }
+ Text(
+ text = "Notifications fire only when the app is in background or not visible.",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+
if (showLeaveDialog) {
AlertDialog(
onDismissRequest = { showLeaveDialog = false },
diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/work/ListChangeNotificationWorker.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/work/ListChangeNotificationWorker.kt
new file mode 100644
index 0000000..b04db8f
--- /dev/null
+++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/work/ListChangeNotificationWorker.kt
@@ -0,0 +1,56 @@
+package com.collabtable.app.work
+
+import android.content.Context
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import com.collabtable.app.data.database.CollabTableDatabase
+import com.collabtable.app.data.model.CollabList
+import com.collabtable.app.data.preferences.PreferencesManager
+import com.collabtable.app.notifications.NotificationHelper
+
+class ListChangeNotificationWorker(
+ appContext: Context,
+ workerParams: WorkerParameters,
+) : CoroutineWorker(appContext, workerParams) {
+ override suspend fun doWork(): Result {
+ val prefs = PreferencesManager.getInstance(applicationContext)
+ val db = CollabTableDatabase.getDatabase(applicationContext)
+
+ val since = prefs.getLastListNotifyCheckTimestamp()
+ val now = System.currentTimeMillis()
+ return try {
+ val changed = db.listDao().getListsUpdatedSince(since)
+ if (changed.isNotEmpty()) {
+ changed.forEach { list ->
+ emitNotificationsFor(list, prefs)
+ }
+ }
+ // Update checkpoint regardless to avoid duplicate spam
+ prefs.setLastListNotifyCheckTimestamp(now)
+ Result.success()
+ } catch (e: Exception) {
+ Result.retry()
+ }
+ }
+
+ private fun emitNotificationsFor(list: CollabList, prefs: PreferencesManager) {
+ // Determine event type
+ when {
+ list.isDeleted -> {
+ if (prefs.notifyListRemoved.value) {
+ NotificationHelper.showListRemoved(applicationContext, list.id, list.name)
+ }
+ }
+ list.createdAt == list.updatedAt -> {
+ if (prefs.notifyListAdded.value) {
+ NotificationHelper.showListAdded(applicationContext, list.id, list.name)
+ }
+ }
+ else -> {
+ if (prefs.notifyListEdited.value) {
+ NotificationHelper.showListEdited(applicationContext, list.id, list.name)
+ }
+ }
+ }
+ }
+}