Per-category toggle + master toggle + system-settings shortcut matching iOS NotificationPreferencesView depth. DataStore-backed prefs, channel importance rewritten to NONE when category disabled. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
189 lines
7.2 KiB
Kotlin
189 lines
7.2 KiB
Kotlin
package com.tt.honeyDue.viewmodel
|
|
|
|
import androidx.lifecycle.ViewModel
|
|
import androidx.lifecycle.viewModelScope
|
|
import com.tt.honeyDue.models.NotificationPreference
|
|
import com.tt.honeyDue.models.UpdateNotificationPreferencesRequest
|
|
import com.tt.honeyDue.network.ApiResult
|
|
import com.tt.honeyDue.network.APILayer
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
import kotlinx.coroutines.flow.StateFlow
|
|
import kotlinx.coroutines.flow.asStateFlow
|
|
import kotlinx.coroutines.launch
|
|
|
|
/**
|
|
* Stable channel-id list used by the per-category notification UI. Keep
|
|
* in lockstep with the Android `NotificationChannels` object (the same
|
|
* four ids) — the parity is enforced by
|
|
* `NotificationPreferencesScreenTest.categoryKeys_matchNotificationChannels`.
|
|
*/
|
|
object NotificationCategoryKeys {
|
|
const val TASK_REMINDER = "task_reminder"
|
|
const val TASK_OVERDUE = "task_overdue"
|
|
const val RESIDENCE_INVITE = "residence_invite"
|
|
const val SUBSCRIPTION = "subscription"
|
|
|
|
val ALL: List<String> = listOf(
|
|
TASK_REMINDER,
|
|
TASK_OVERDUE,
|
|
RESIDENCE_INVITE,
|
|
SUBSCRIPTION,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Platform-agnostic façade around the per-category notification preference
|
|
* store. The Android implementation wires `loadAll` / `setCategory` /
|
|
* `setAll` to `NotificationPreferencesStore` (DataStore-backed); tests in
|
|
* `commonTest` wire them to an in-memory fake.
|
|
*
|
|
* Kept in the viewmodel package (not `ui.screens`) so it can be referenced
|
|
* from `commonTest` without pulling in Compose types.
|
|
*/
|
|
class NotificationCategoriesController(
|
|
private val loadAll: suspend () -> Map<String, Boolean>,
|
|
private val setCategory: suspend (String, Boolean) -> Unit,
|
|
private val setAll: suspend (Boolean) -> Unit,
|
|
) {
|
|
suspend fun load(): Map<String, Boolean> = loadAll()
|
|
|
|
suspend fun onCategoryToggle(channelId: String, enabled: Boolean) {
|
|
setCategory(channelId, enabled)
|
|
}
|
|
|
|
suspend fun onMasterToggle(enabled: Boolean) {
|
|
setAll(enabled)
|
|
}
|
|
|
|
companion object {
|
|
/**
|
|
* Compute the master toggle's visible state from the current
|
|
* per-category snapshot. Master is "on" iff every category is on
|
|
* AND the snapshot is non-empty (an empty snapshot is the
|
|
* "no data yet" state, which the UI renders as master-off).
|
|
*/
|
|
fun computeMasterState(snapshot: Map<String, Boolean>): Boolean =
|
|
snapshot.isNotEmpty() && snapshot.values.all { it }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Existing remote-preferences ViewModel (server-backed). Retained
|
|
* unchanged so the legacy Daily Digest / email prefs continue to work
|
|
* alongside the new per-category local toggles driven by
|
|
* [NotificationCategoriesController].
|
|
*/
|
|
class NotificationPreferencesViewModel : ViewModel() {
|
|
|
|
private val _preferencesState = MutableStateFlow<ApiResult<NotificationPreference>>(ApiResult.Idle)
|
|
val preferencesState: StateFlow<ApiResult<NotificationPreference>> = _preferencesState.asStateFlow()
|
|
|
|
private val _updateState = MutableStateFlow<ApiResult<NotificationPreference>>(ApiResult.Idle)
|
|
val updateState: StateFlow<ApiResult<NotificationPreference>> = _updateState.asStateFlow()
|
|
|
|
/**
|
|
* Per-category local toggle state, keyed by [NotificationCategoryKeys]
|
|
* channel ids. Backed on Android by `NotificationPreferencesStore` via
|
|
* [NotificationCategoriesController].
|
|
*/
|
|
private val _categoryState = MutableStateFlow<Map<String, Boolean>>(
|
|
NotificationCategoryKeys.ALL.associateWith { true },
|
|
)
|
|
val categoryState: StateFlow<Map<String, Boolean>> = _categoryState.asStateFlow()
|
|
|
|
fun loadPreferences() {
|
|
viewModelScope.launch {
|
|
_preferencesState.value = ApiResult.Loading
|
|
val result = APILayer.getNotificationPreferences()
|
|
_preferencesState.value = when (result) {
|
|
is ApiResult.Success -> ApiResult.Success(result.data)
|
|
is ApiResult.Error -> result
|
|
else -> ApiResult.Error("Unknown error")
|
|
}
|
|
}
|
|
}
|
|
|
|
fun updatePreference(
|
|
taskDueSoon: Boolean? = null,
|
|
taskOverdue: Boolean? = null,
|
|
taskCompleted: Boolean? = null,
|
|
taskAssigned: Boolean? = null,
|
|
residenceShared: Boolean? = null,
|
|
warrantyExpiring: Boolean? = null,
|
|
dailyDigest: Boolean? = null,
|
|
emailTaskCompleted: Boolean? = null,
|
|
taskDueSoonHour: Int? = null,
|
|
taskOverdueHour: Int? = null,
|
|
warrantyExpiringHour: Int? = null,
|
|
dailyDigestHour: Int? = null,
|
|
) {
|
|
viewModelScope.launch {
|
|
_updateState.value = ApiResult.Loading
|
|
val request = UpdateNotificationPreferencesRequest(
|
|
taskDueSoon = taskDueSoon,
|
|
taskOverdue = taskOverdue,
|
|
taskCompleted = taskCompleted,
|
|
taskAssigned = taskAssigned,
|
|
residenceShared = residenceShared,
|
|
warrantyExpiring = warrantyExpiring,
|
|
dailyDigest = dailyDigest,
|
|
emailTaskCompleted = emailTaskCompleted,
|
|
taskDueSoonHour = taskDueSoonHour,
|
|
taskOverdueHour = taskOverdueHour,
|
|
warrantyExpiringHour = warrantyExpiringHour,
|
|
dailyDigestHour = dailyDigestHour,
|
|
)
|
|
val result = APILayer.updateNotificationPreferences(request)
|
|
_updateState.value = when (result) {
|
|
is ApiResult.Success -> {
|
|
_preferencesState.value = ApiResult.Success(result.data)
|
|
ApiResult.Success(result.data)
|
|
}
|
|
is ApiResult.Error -> result
|
|
else -> ApiResult.Error("Unknown error")
|
|
}
|
|
}
|
|
}
|
|
|
|
fun resetUpdateState() {
|
|
_updateState.value = ApiResult.Idle
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Per-category (local) toggle state — wired to platform-specific store.
|
|
// ---------------------------------------------------------------------
|
|
|
|
/**
|
|
* Attach a [NotificationCategoriesController] and do an initial load
|
|
* so the screen can render in sync with the on-disk preferences. Safe
|
|
* to call multiple times; later calls replace the controller.
|
|
*/
|
|
fun attachCategoriesController(controller: NotificationCategoriesController) {
|
|
this.categoriesController = controller
|
|
viewModelScope.launch {
|
|
_categoryState.value = controller.load()
|
|
}
|
|
}
|
|
|
|
fun toggleCategory(channelId: String, enabled: Boolean) {
|
|
val controller = categoriesController ?: return
|
|
// Optimistic local update so the Switch flips immediately.
|
|
_categoryState.value = _categoryState.value.toMutableMap().apply {
|
|
put(channelId, enabled)
|
|
}
|
|
viewModelScope.launch {
|
|
controller.onCategoryToggle(channelId, enabled)
|
|
}
|
|
}
|
|
|
|
fun toggleMaster(enabled: Boolean) {
|
|
val controller = categoriesController ?: return
|
|
_categoryState.value = NotificationCategoryKeys.ALL.associateWith { enabled }
|
|
viewModelScope.launch {
|
|
controller.onMasterToggle(enabled)
|
|
}
|
|
}
|
|
|
|
private var categoriesController: NotificationCategoriesController? = null
|
|
}
|