feat: implement notification system for list events, including added, edited, and removed notifications with user preferences and background checks
This commit is contained in:
@@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+51
@@ -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
|
||||||
|
|||||||
+93
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -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 = {
|
||||||
|
|||||||
+153
@@ -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 },
|
||||||
|
|||||||
+56
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user