Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.mapgie.dash.notification

/**
* The system permissions a reminder needs to reach the user, and which of them
* the chosen delivery mode actually depends on.
*
* Kept Android-free (no Context) so the rule "what counts as missing for this
* style" is pinned by a plain JVM test. `PermissionHelper.reminderGrants` takes
* the live snapshot; the Memos list turns the result into its banner.
*
* Ordered by how badly the gap hurts: no notifications means nothing is ever
* shown, no exact alarms means it fires late, no full-screen means the Alarm
* style cannot ring (see below), no Do Not Disturb access means it is muted
* only while Do Not Disturb is on.
*/
enum class ReminderPermission(val label: String) {
NOTIFICATIONS("Notifications"),
EXACT_ALARMS("Exact alarms"),

/**
* Android 14+ lets the user revoke full-screen intents per app. Without it
* the Alarm style cannot launch its ring screen on a locked phone, so the
* alert falls back to a plain heads-up whose sound lands on the notification
* stream (LESSONS #52). On most phones that is silence: the alarm arrives,
* but it does not ring. The other styles never use a full-screen intent.
*/
FULL_SCREEN("Full-screen alarms"),

/** Only the Alarm style promises to sound through Do Not Disturb. */
DND_ACCESS("Do Not Disturb access"),
}

/** A snapshot of the four grants. Platforms below a permission's API level report it as granted. */
data class ReminderPermissionGrants(
val notifications: Boolean = true,
val exactAlarms: Boolean = true,
val fullScreen: Boolean = true,
val dndAccess: Boolean = true,
) {
/**
* The permissions the given delivery mode needs and does not have, most
* damaging first. Notifications and exact alarms matter to every style; the
* full-screen and Do Not Disturb grants only to the Alarm style, so a user on
* Notification or Silent is never nagged about them.
*/
fun missingFor(deliveryMode: String): List<ReminderPermission> {
val alarm = DeliveryMode.ringsOnAlarmStream(deliveryMode)
return buildList {
if (!notifications) add(ReminderPermission.NOTIFICATIONS)
if (!exactAlarms) add(ReminderPermission.EXACT_ALARMS)
if (alarm && !fullScreen) add(ReminderPermission.FULL_SCREEN)
if (alarm && !dndAccess) add(ReminderPermission.DND_ACCESS)
}
}

/**
* One line for the list-screen banner, or null when nothing the current style
* needs is missing. [plural] is the user's word for the feature ("memos",
* "reminders", "alarms"), lower case. A single gap is named with its effect;
* several are counted, since the Settings page they open lists each one.
*/
fun warningFor(deliveryMode: String, plural: String): String? {
val missing = missingFor(deliveryMode)
return when (missing.size) {
0 -> null
1 -> when (missing.single()) {
ReminderPermission.NOTIFICATIONS ->
"Notifications are off, so $plural cannot alert you. Tap to allow."
ReminderPermission.EXACT_ALARMS ->
"Exact alarms are off, so $plural may ring late. Tap to allow."
ReminderPermission.FULL_SCREEN ->
"Full-screen alarms are off, so $plural may ring silently. Tap to allow."
ReminderPermission.DND_ACCESS ->
"Do Not Disturb access is off, so $plural stay quiet during Do Not Disturb. Tap to allow."
}
else -> "${missing.size} permissions are off, so $plural may not ring. Tap to review."
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.app.NotificationManagerCompat
import com.mapgie.dash.notification.ReminderPermissionGrants

/**
* Settings deep links for the permissions AlarmScheduler and NotificationHelper depend on.
Expand Down Expand Up @@ -40,6 +41,14 @@ object PermissionHelper {
.canUseFullScreenIntent()
}

/** The four grants in one read, for [ReminderPermissionGrants.missingFor] and the list banners. */
fun reminderGrants(context: Context): ReminderPermissionGrants = ReminderPermissionGrants(
notifications = areNotificationsEnabled(context),
exactAlarms = canScheduleExactAlarms(context),
fullScreen = canUseFullScreenIntent(context),
dndAccess = isDndAccessGranted(context),
)

/** Opens this app's "Full-screen notifications" toggle (API 34+). */
fun fullScreenIntentSettingsIntent(context: Context): Intent =
Intent(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package com.mapgie.dash.ui.components.core

import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.mapgie.dash.ui.theme.Dimens
import com.mapgie.dash.ui.theme.LucideIcons
import com.mapgie.dash.ui.theme.StatusTone
import com.mapgie.dash.ui.theme.badgeContainerColor
import com.mapgie.dash.ui.theme.textColor

/**
* A tappable strip above a list warning that a system permission the screen's
* feature depends on is missing: alert glyph, one line of [text] naming the gap
* and its effect, and a chevron. Wears the shared ATTENTION (amber) tone, so it
* matches the "due soon" badges rather than the error red reserved for failures.
* The glyph and the wording carry the state; the tint is secondary.
*
* Tapping goes to wherever the gap can be fixed (the caller decides). Announced
* politely when it appears, since it usually shows on return from system settings.
*/
@Composable
fun PermissionBanner(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val container = StatusTone.ATTENTION.badgeContainerColor() ?: MaterialTheme.colorScheme.surfaceContainerHigh
val content = StatusTone.ATTENTION.textColor()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = modifier
.fillMaxWidth()
.padding(horizontal = Dimens.cardInset)
.clip(MaterialTheme.shapes.medium)
.background(container)
.semantics {
role = Role.Button
liveRegion = LiveRegionMode.Polite
}
.clickable(onClick = onClick)
.heightIn(min = 44.dp)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Icon(
imageVector = LucideIcons.CircleAlert,
contentDescription = null,
tint = content,
modifier = Modifier.size(18.dp),
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold),
color = content,
modifier = Modifier.weight(1f),
)
Icon(
imageVector = LucideIcons.ChevronRight,
contentDescription = null,
tint = content,
modifier = Modifier.size(18.dp),
)
}
}
12 changes: 11 additions & 1 deletion app/src/main/java/com/mapgie/dash/ui/navigation/DashNavGraph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import com.mapgie.dash.ui.screens.reminder.ReminderViewScreen
import com.mapgie.dash.ui.screens.reminder.reminderViewRoute
import com.mapgie.dash.ui.screens.reminders.RemindersListScreen
import com.mapgie.dash.ui.screens.settings.SettingsScreen
import com.mapgie.dash.ui.screens.settings.SettingsSubScreen
import com.mapgie.dash.ui.screens.tasks.TaskListScreen
import com.mapgie.dash.ui.theme.LocalTypeAccents
import com.mapgie.dash.ui.theme.LucideIcons
Expand Down Expand Up @@ -108,6 +109,9 @@ fun DashNavGraph(

var fabExpanded by remember { mutableStateOf(false) }
var pendingAddIntent by remember { mutableStateOf<AddMenuOption?>(null) }
// A sub-screen the Settings tab should open on arrival (e.g. the Memos
// permission banner sends the user to Reminders & alerts).
var pendingSettingsSubScreen by remember { mutableStateOf<SettingsSubScreen?>(null) }

val navUiState by navViewModel.uiState.collectAsStateWithLifecycle()
// The Memos/Reminders slot is always present, so the five-slot bar never
Expand Down Expand Up @@ -250,12 +254,18 @@ fun DashNavGraph(
composable(Screen.Reminders.route) {
RemindersListScreen(
pendingAddIntent = pendingAddIntent,
onPendingAddIntentConsumed = { pendingAddIntent = null }
onPendingAddIntentConsumed = { pendingAddIntent = null },
onOpenReminderSettings = {
pendingSettingsSubScreen = SettingsSubScreen.REMINDERS
navigateTo(Screen.Settings.route)
},
)
}
composable(Screen.Settings.route) {
SettingsScreen(
onNavigateToLicenses = { navController.navigate("licenses") },
pendingSubScreen = pendingSettingsSubScreen,
onPendingSubScreenConsumed = { pendingSettingsSubScreen = null },
)
}
composable("licenses") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
Expand All @@ -40,23 +41,29 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.mapgie.dash.data.model.AddMenuOption
import com.mapgie.dash.data.model.NEW_DRAFT_KEY
import com.mapgie.dash.data.model.ReminderAppearance
import com.mapgie.dash.data.model.ReminderDto
import com.mapgie.dash.data.model.ReminderSortKey
import com.mapgie.dash.data.model.Swatch
import com.mapgie.dash.permission.PermissionHelper
import com.mapgie.dash.ui.components.AddReminderSheet
import com.mapgie.dash.ui.components.ReminderCard
import com.mapgie.dash.ui.components.core.HeaderIconButton
import com.mapgie.dash.ui.components.core.LocalReminderLabel
import com.mapgie.dash.ui.components.core.PageHeader
import com.mapgie.dash.ui.components.core.PermissionBanner
import com.mapgie.dash.ui.components.core.SearchRow
import com.mapgie.dash.ui.components.core.SectionLabel
import com.mapgie.dash.ui.components.core.SortControls
Expand All @@ -76,18 +83,37 @@ import kotlinx.coroutines.launch
*
* The owner ("mine / all") header action the design shows is not built: memos
* carry no owner, so there is nothing to filter by.
*
* Above the filter row, a permission banner appears whenever a system grant the
* chosen notification style depends on is missing (full-screen alarms for the
* Alarm style, say), since a memo that fires silently is worse than none.
* Tapping it opens Settings › Reminders & alerts via [onOpenReminderSettings].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RemindersListScreen(
pendingAddIntent: AddMenuOption?,
onPendingAddIntentConsumed: () -> Unit,
onOpenReminderSettings: () -> Unit,
viewModel: RemindersListViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val snackbarHost = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()

// The system grants reminders depend on, re-read on every resume so a change
// made in system settings shows the moment the user comes back.
val context = LocalContext.current
var grants by remember { mutableStateOf(PermissionHelper.reminderGrants(context)) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) grants = PermissionHelper.reminderGrants(context)
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}

// Swipe-to-done leaves a brief Undo, so a stray swipe is one tap to reverse.
fun markReminderDoneWithUndo(reminder: ReminderDto) {
viewModel.setReminderDone(reminder.id, true)
Expand Down Expand Up @@ -199,6 +225,14 @@ fun RemindersListScreen(
return@Scaffold
}

grants.warningFor(uiState.deliveryMode, plural)?.let { warning ->
PermissionBanner(
text = warning,
onClick = onOpenReminderSettings,
modifier = Modifier.padding(top = 4.dp, bottom = 2.dp),
)
}

// Filter chips, then the sort pill pinned to the right.
Row(
modifier = Modifier
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import com.mapgie.dash.data.preferences.SettingsRepository
import com.mapgie.dash.data.repository.ChoreRepository
import com.mapgie.dash.data.repository.ReminderRepository
import com.mapgie.dash.data.repository.TaskRepository
import com.mapgie.dash.notification.DeliveryMode
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
Expand Down Expand Up @@ -61,6 +62,8 @@ data class ReminderUiState(
/** Settings › Categories styling and Settings › Colours axes, so a linked memo can wear its chore's or task's look. */
val catalog: CategoryCatalog = CategoryCatalog(),
val colourAxes: ChoreColourAxes = ChoreColourAxes(),
/** Settings › Reminders & alerts style; decides which missing permissions the list warns about. */
val deliveryMode: String = DeliveryMode.NOTIFICATION,
) {
val active: List<ReminderDto>
get() = sorted(reminders.filter { it.archivedAt == null && !it.isDone })
Expand Down Expand Up @@ -147,6 +150,7 @@ class RemindersListViewModel @Inject constructor(
reminderLabel = settings.reminderLabel,
sort = settings.reminderSort,
colourAxes = settings.colourAxes,
deliveryMode = settings.deliveryMode,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ import java.io.BufferedReader
import java.io.InputStreamReader
import kotlin.math.roundToInt

private enum class SettingsSubScreen {
enum class SettingsSubScreen {
NONE, CONNECTION, APPEARANCE, COLOURS, CATEGORIES, DISPLAY, QUICK_ADD, REMINDERS, WIDGET, ABOUT, HELP
}

Expand All @@ -106,13 +106,27 @@ private const val CHANGELOG_URL = "https://github.com/mapgie/choreDash-Android/b
/** Content inset for every settings page (18dp per the 3a/4a mock-ups). */
private val PageInset = 18.dp

/**
* The Settings tab. [pendingSubScreen] opens a sub-screen directly (the Memos
* permission banner lands on Reminders & alerts this way); it is consumed once
* applied so the tab's own back navigation works as usual afterwards.
*/
@Composable
fun SettingsScreen(
onNavigateToLicenses: () -> Unit,
pendingSubScreen: SettingsSubScreen? = null,
onPendingSubScreenConsumed: () -> Unit = {},
viewModel: SettingsViewModel = hiltViewModel()
) {
var subScreen by rememberSaveable { mutableStateOf(SettingsSubScreen.NONE) }

LaunchedEffect(pendingSubScreen) {
if (pendingSubScreen != null) {
subScreen = pendingSubScreen
onPendingSubScreenConsumed()
}
}

BackHandler(enabled = subScreen != SettingsSubScreen.NONE) {
subScreen = SettingsSubScreen.NONE
}
Expand Down Expand Up @@ -988,7 +1002,7 @@ private fun RemindersSubScreen(
SettingsHairline()
PermissionRow(
title = "Full-screen alarms",
subtitle = "Lets the Alarm style turn the screen on and ring",
subtitle = "Lets the Alarm style turn the screen on and ring. Without it, alarms can arrive silently",
granted = fullScreenAllowed,
icon = LucideIcons.Lamp,
onClick = { context.startActivity(PermissionHelper.fullScreenIntentSettingsIntent(context)) }
Expand Down
Loading
Loading