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>
This commit is contained in:
@@ -8,15 +8,88 @@ 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
|
||||
val preferencesState: StateFlow<ApiResult<NotificationPreference>> = _preferencesState.asStateFlow()
|
||||
|
||||
private val _updateState = MutableStateFlow<ApiResult<NotificationPreference>>(ApiResult.Idle)
|
||||
val updateState: StateFlow<ApiResult<NotificationPreference>> = _updateState
|
||||
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 {
|
||||
@@ -42,7 +115,7 @@ class NotificationPreferencesViewModel : ViewModel() {
|
||||
taskDueSoonHour: Int? = null,
|
||||
taskOverdueHour: Int? = null,
|
||||
warrantyExpiringHour: Int? = null,
|
||||
dailyDigestHour: Int? = null
|
||||
dailyDigestHour: Int? = null,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_updateState.value = ApiResult.Loading
|
||||
@@ -58,12 +131,11 @@ class NotificationPreferencesViewModel : ViewModel() {
|
||||
taskDueSoonHour = taskDueSoonHour,
|
||||
taskOverdueHour = taskOverdueHour,
|
||||
warrantyExpiringHour = warrantyExpiringHour,
|
||||
dailyDigestHour = dailyDigestHour
|
||||
dailyDigestHour = dailyDigestHour,
|
||||
)
|
||||
val result = APILayer.updateNotificationPreferences(request)
|
||||
_updateState.value = when (result) {
|
||||
is ApiResult.Success -> {
|
||||
// Update the preferences state with the new values
|
||||
_preferencesState.value = ApiResult.Success(result.data)
|
||||
ApiResult.Success(result.data)
|
||||
}
|
||||
@@ -76,4 +148,41 @@ class NotificationPreferencesViewModel : ViewModel() {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user