feat: implement notification system for list events, including added, edited, and removed notifications with user preferences and background checks

This commit is contained in:
2025-11-11 20:23:42 +01:00
parent 607a0c4fcb
commit 3e029b16ca
9 changed files with 459 additions and 1 deletions
+4
View File
@@ -54,6 +54,7 @@ dependencies {
// Core Android // Core Android
implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2' 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' implementation 'androidx.activity:activity-compose:1.8.1'
// Compose // Compose
@@ -81,6 +82,9 @@ dependencies {
// Coroutines // Coroutines
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' 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 // Testing
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.1.5'
@@ -3,6 +3,8 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Android 13+ runtime notification permission -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:name=".CollabTableApplication" android:name=".CollabTableApplication"
@@ -1,8 +1,20 @@
package com.collabtable.app package com.collabtable.app
import android.app.Application import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import com.collabtable.app.data.api.ApiClient import com.collabtable.app.data.api.ApiClient
import com.collabtable.app.data.repository.SyncRepository import com.collabtable.app.data.repository.SyncRepository
import com.collabtable.app.notifications.NotificationHelper
import com.collabtable.app.work.ListChangeNotificationWorker
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -23,5 +35,40 @@ class CollabTableApplication : Application() {
// Silent; UI layers will surface subsequent sync issues. // Silent; UI layers will surface subsequent sync issues.
} }
} }
// Create notification channel(s) for list events
createNotificationChannels()
// Schedule periodic background check for list changes to surface notifications
scheduleListChangeWorker()
// Auto-clear notifications when app comes to foreground
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
NotificationHelper.clearListEventNotifications(this@CollabTableApplication)
}
})
}
private fun createNotificationChannels() {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val listEventsChannel = NotificationChannel(
NotificationHelper.CHANNEL_LIST_EVENTS,
"List Events",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Notifications for list add, edit, and remove events"
}
manager.createNotificationChannel(listEventsChannel)
}
private fun scheduleListChangeWorker() {
val work = PeriodicWorkRequestBuilder<ListChangeNotificationWorker>(15, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
"list_change_notifier",
ExistingPeriodicWorkPolicy.UPDATE,
work,
)
} }
} }
@@ -30,6 +30,16 @@ class PreferencesManager(context: Context) {
private val _syncPollIntervalMs = MutableStateFlow(getSyncPollIntervalMs()) private val _syncPollIntervalMs = MutableStateFlow(getSyncPollIntervalMs())
val syncPollIntervalMs: StateFlow<Long> = _syncPollIntervalMs.asStateFlow() val syncPollIntervalMs: StateFlow<Long> = _syncPollIntervalMs.asStateFlow()
// Notification preferences (per list event type)
private val _notifyListAdded = MutableStateFlow(isNotifyListAddedEnabled())
val notifyListAdded: StateFlow<Boolean> = _notifyListAdded.asStateFlow()
private val _notifyListEdited = MutableStateFlow(isNotifyListEditedEnabled())
val notifyListEdited: StateFlow<Boolean> = _notifyListEdited.asStateFlow()
private val _notifyListRemoved = MutableStateFlow(isNotifyListRemovedEnabled())
val notifyListRemoved: StateFlow<Boolean> = _notifyListRemoved.asStateFlow()
fun getServerUrl(): String { fun getServerUrl(): String {
return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL
} }
@@ -141,6 +151,43 @@ class PreferencesManager(context: Context) {
_syncPollIntervalMs.value = clamped _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) // Persist per-list column widths (fieldId -> widthDp)
// Stored as a JSON object string in SharedPreferences under key: COLUMN_WIDTHS_PREFIX + listId // Stored as a JSON object string in SharedPreferences under key: COLUMN_WIDTHS_PREFIX + listId
fun getColumnWidths(listId: String): Map<String, Float> { fun getColumnWidths(listId: String): Map<String, Float> {
@@ -183,6 +230,10 @@ class PreferencesManager(context: Context) {
private const val KEY_AMOLED_DARK = "amoled_dark" private const val KEY_AMOLED_DARK = "amoled_dark"
private const val KEY_SORT_ORDER = "sort_order" private const val KEY_SORT_ORDER = "sort_order"
private const val KEY_SYNC_POLL_INTERVAL_MS = "sync_poll_interval_ms" 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 COLUMN_WIDTHS_PREFIX = "column_widths_" // + listId
private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/" private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/"
private const val DEFAULT_SYNC_POLL_INTERVAL_MS = 250L private const val DEFAULT_SYNC_POLL_INTERVAL_MS = 250L
@@ -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<String>()
@Volatile private var totalEventCount: Int = 0
private val postedIds = mutableSetOf<Int>()
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
}
}
}
@@ -6,6 +6,9 @@ import androidx.lifecycle.viewModelScope
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
import com.collabtable.app.data.repository.SyncRepository 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 com.collabtable.app.utils.Logger
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -128,6 +131,7 @@ class ListsViewModel(
database.listDao().insertList(newList) database.listDao().insertList(newList)
// Sync immediately after creating // Sync immediately after creating
performSync() performSync()
maybeNotifyListAdded(newList)
} }
} }
@@ -147,6 +151,7 @@ class ListsViewModel(
) )
// Sync immediately after renaming // Sync immediately after renaming
performSync() performSync()
maybeNotifyListEdited(list.copy(name = newName.trim()))
} }
} }
} }
@@ -164,6 +169,7 @@ class ListsViewModel(
database.itemDao().softDeleteItemsByList(listId, ts) database.itemDao().softDeleteItemsByList(listId, ts)
// Sync immediately after deleting // Sync immediately after deleting
performSync() performSync()
maybeNotifyListRemoved(list)
} }
} }
} }
@@ -176,4 +182,30 @@ class ListsViewModel(
_isLoading.value = false _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)
}
}
} }
@@ -41,7 +41,10 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp 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 com.collabtable.app.data.preferences.PreferencesManager
import android.os.Build
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -57,11 +60,28 @@ fun ServerSetupScreen(onSetupComplete: () -> Unit) {
val validationResult by viewModel.validationResult.collectAsState() val validationResult by viewModel.validationResult.collectAsState()
val validationError by viewModel.validationError.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) { LaunchedEffect(validationResult) {
if (validationResult == true) { if (validationResult == true) {
// 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() onSetupComplete()
} }
} }
}
Scaffold( Scaffold(
topBar = { topBar = {
@@ -29,6 +29,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -36,6 +38,8 @@ import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable 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.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -47,6 +51,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext 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 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.R
import com.collabtable.app.data.api.ApiClient import com.collabtable.app.data.api.ApiClient
import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.database.CollabTableDatabase
@@ -70,11 +78,15 @@ fun SettingsScreen(
val preferencesManager = remember { PreferencesManager.getInstance(context) } val preferencesManager = remember { PreferencesManager.getInstance(context) }
val syncRepository = remember { SyncRepository(context) } val syncRepository = remember { SyncRepository(context) }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val serverUrl = preferencesManager.getServerUrl() val serverUrl = preferencesManager.getServerUrl()
val themeMode by preferencesManager.themeMode.collectAsState() val themeMode by preferencesManager.themeMode.collectAsState()
val dynamicColor by preferencesManager.dynamicColor.collectAsState() val dynamicColor by preferencesManager.dynamicColor.collectAsState()
val amoledDark by preferencesManager.amoledDark.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) } val displayUrl = remember(serverUrl) { formatServerUrlForDisplay(serverUrl) }
var showLeaveDialog by remember { mutableStateOf(false) } var showLeaveDialog by remember { mutableStateOf(false) }
var isLeaving by remember { mutableStateOf(false) } var isLeaving by remember { mutableStateOf(false) }
@@ -83,6 +95,38 @@ fun SettingsScreen(
var syncIntervalError by remember { mutableStateOf<String?>(null) } var syncIntervalError by remember { mutableStateOf<String?>(null) }
// Authentication UI removed // 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 // Removed test connectivity logic; the connection indicator is shown via ConnectionStatusAction
Scaffold( Scaffold(
@@ -104,6 +148,7 @@ fun SettingsScreen(
), ),
) )
}, },
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { padding -> ) { padding ->
Column( Column(
modifier = 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) { if (showLeaveDialog) {
AlertDialog( AlertDialog(
onDismissRequest = { showLeaveDialog = false }, onDismissRequest = { showLeaveDialog = false },
@@ -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)
}
}
}
}
}