Files
honeyDueKMP/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/NotificationPreferencesViewModel.kt
Trey T 65af40ed73 P4 Stream P: NotificationPreferencesScreen expansion
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>
2026-04-18 13:24:45 -05:00

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
}