feat(reminders): reminder notifications — EVENT_REMINDER receiver, onboarding step, settings toggle (v1.4)
Calendula now posts event reminders itself (the Etar model): the provider schedules the alarms and broadcasts EVENT_REMINDER, but a calendar app must turn them into visible notifications — essential for users whose only calendar app this is. A manifest-registered, exported receiver (data scheme content://com.android.calendar) wakes us at reminder time; no foreground service, no own alarm scheduling. Delivery path (data/reminders/): EventReminderReceiver (Hilt, goAsync) → ReminderAlertStore queries CalendarAlerts for STATE_SCHEDULED rows with ALARM_TIME <= now → ReminderNotifier posts one notification per alert on a dedicated high-importance channel, then best-effort marks rows FIRED (needs WRITE_CALENDAR; without it a re-broadcast silently replaces — tag per alert + setOnlyAlertOnce). Swiped notifications never return: FIRED rows are never re-queried, so no dismiss-intent machinery. Research (AOSP CalendarAlarmManager): the provider creates alert rows only for METHOD_ALERT reminders, so the email-reminder filter happens upstream. Tapping opens the event's detail screen: MainActivity is singleTop now, parses eventId/begin/end extras (onCreate + onNewIntent) into Compose state, and CalendarHost consumes the key exactly like an event tap. Onboarding gained a one-time second step after the calendar grant (shared OnboardingScaffold extracted from PermissionScreen): explains delivery, warns that a second calendar app with notifications on duplicates reminders, requests POST_NOTIFICATIONS (dialog on API 33+ only; minSdk 29). "Not now" turns the feature off; reminders default ON. Settings mirrors the toggle in a new Notifications section with the duplicate hint, and re-requests the permission when enabling. Strings DE+EN. Deliberately deferred (roadmap): snooze/dismiss actions, BOOT_COMPLETED / exact-alarm scheduling, battery-exemption prompts. Tests: reminderTimeText (all-day UTC-midnight reading, exclusive end day, midnight-crossing ranges), reminders/onboarding pref round-trips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -65,7 +65,7 @@ guide here, not a contract — scope per slice is decided as we go.
|
|||||||
| v1.2 | Create event — form, FAB, last-used-calendar preselect | complete (shipped 2026-06-11) |
|
| v1.2 | Create event — form, FAB, last-used-calendar preselect | complete (shipped 2026-06-11) |
|
||||||
| v1.2.1 | Form polish after on-device review — card design system, optional fields + settings defaults, OptionCard dialogs, expressive motion | complete (shipped 2026-06-11) |
|
| v1.2.1 | Form polish after on-device review — card design system, optional fields + settings defaults, OptionCard dialogs, expressive motion | complete (shipped 2026-06-11) |
|
||||||
| v1.3 | Edit event — shared form, scoped recurring writes (this / following / all), recurrence picker | complete (shipped 2026-06-11) |
|
| v1.3 | Edit event — shared form, scoped recurring writes (this / following / all), recurrence picker | complete (shipped 2026-06-11) |
|
||||||
| v1.4 | Reminder notifications — see below | planned |
|
| v1.4 | Reminder notifications — see below | implemented (release awaits on-device review) |
|
||||||
| v2.0 | Quick-add, occurrence edit, conflict dialog, polish, release | planned |
|
| v2.0 | Quick-add, occurrence edit, conflict dialog, polish, release | planned |
|
||||||
|
|
||||||
## v1.4 — Reminder Notifications
|
## v1.4 — Reminder Notifications
|
||||||
|
|||||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Reminder notifications (v1.4): Calendula now delivers event reminders as
|
||||||
|
notifications itself — the system schedules them but posts nothing, so a
|
||||||
|
calendar app must (essential when Calendula is the only one installed).
|
||||||
|
Due reminders appear on a dedicated "Event reminders" channel; tapping one
|
||||||
|
opens the event's detail screen. Email reminders are never posted (the
|
||||||
|
provider only schedules alert-type reminders)
|
||||||
|
- A one-time onboarding step after the calendar grant introduces reminders,
|
||||||
|
requests the notification permission (Android 13+), and warns that a second
|
||||||
|
calendar app with notifications on will duplicate them. "Not now" leaves
|
||||||
|
the feature off
|
||||||
|
- Settings gained a "Notifications" section mirroring the choice: an event-
|
||||||
|
reminders toggle (default on) with the duplicate-reminders hint; turning it
|
||||||
|
on re-requests the notification permission when missing
|
||||||
|
|
||||||
## [1.3.0] — 2026-06-11
|
## [1.3.0] — 2026-06-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".CalendulaApp"
|
android:name=".CalendulaApp"
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
@@ -26,6 +28,20 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
||||||
|
no notification itself — a calendar app must (v1.4, Etar model).
|
||||||
|
Exported: the broadcast arrives from the provider's process. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".data.reminders.EventReminderReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.EVENT_REMINDER" />
|
||||||
|
<data
|
||||||
|
android:host="com.android.calendar"
|
||||||
|
android:scheme="content" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<!-- Persists the per-app language (M4) on API < 33, where the platform
|
<!-- Persists the per-app language (M4) on API < 33, where the platform
|
||||||
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||||
<service
|
<service
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula
|
package de.jeanlucmakiola.calendula
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
@@ -7,7 +9,10 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
@@ -18,9 +23,16 @@ import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
|||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
// The occurrence a reminder notification was tapped for (eventId, begin,
|
||||||
|
// end — the detail screen's key shape). singleTop + onNewIntent route a
|
||||||
|
// tap into the running activity; CalendarHost consumes and clears it.
|
||||||
|
private var requestedDetailKey by mutableStateOf<LongArray?>(null)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
requestedDetailKey = intent.detailKeyOrNull()
|
||||||
setContent {
|
setContent {
|
||||||
// One activity-scoped SettingsViewModel drives both the theme here
|
// One activity-scoped SettingsViewModel drives both the theme here
|
||||||
// and the Settings screen, so a theme change applies app-wide at once.
|
// and the Settings screen, so a theme change applies app-wide at once.
|
||||||
@@ -35,8 +47,51 @@ class MainActivity : ComponentActivity() {
|
|||||||
darkTheme = darkTheme,
|
darkTheme = darkTheme,
|
||||||
dynamicColor = settings.dynamicColor,
|
dynamicColor = settings.dynamicColor,
|
||||||
) {
|
) {
|
||||||
RootScreen(modifier = Modifier.fillMaxSize())
|
RootScreen(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
requestedDetailKey = requestedDetailKey,
|
||||||
|
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Intent.detailKeyOrNull(): LongArray? {
|
||||||
|
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||||
|
if (eventId == -1L) return null
|
||||||
|
return longArrayOf(
|
||||||
|
eventId,
|
||||||
|
getLongExtra(EXTRA_BEGIN_MILLIS, 0L),
|
||||||
|
getLongExtra(EXTRA_END_MILLIS, 0L),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
|
||||||
|
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
|
||||||
|
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intent opening the detail screen of one occurrence (reminder
|
||||||
|
* notifications). The synthetic data URI keys the intent so
|
||||||
|
* PendingIntents for different occurrences never collapse into one.
|
||||||
|
*/
|
||||||
|
fun eventDetailIntent(
|
||||||
|
context: Context,
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
endMillis: Long,
|
||||||
|
): Intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
data = "calendula://event/$eventId/$beginMillis".toUri()
|
||||||
|
putExtra(EXTRA_EVENT_ID, eventId)
|
||||||
|
putExtra(EXTRA_BEGIN_MILLIS, beginMillis)
|
||||||
|
putExtra(EXTRA_END_MILLIS, endMillis)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -37,6 +39,12 @@ abstract class DataBindModule {
|
|||||||
abstract fun bindCalendarRepository(
|
abstract fun bindCalendarRepository(
|
||||||
impl: CalendarRepositoryImpl,
|
impl: CalendarRepositoryImpl,
|
||||||
): CalendarRepository
|
): CalendarRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindReminderAlertStore(
|
||||||
|
impl: AndroidReminderAlertStore,
|
||||||
|
): ReminderAlertStore
|
||||||
}
|
}
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
|
|||||||
@@ -86,6 +86,31 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
||||||
|
* for users whose only calendar app this is, reminders are essential; the
|
||||||
|
* onboarding step and Settings warn about duplicates from a second app.
|
||||||
|
*/
|
||||||
|
val remindersEnabled: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[REMINDERS_ENABLED_KEY] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setRemindersEnabled(enabled: Boolean) {
|
||||||
|
store.edit { it[REMINDERS_ENABLED_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the one-time reminder onboarding step (after the calendar
|
||||||
|
* grant) has been shown — also true for users who tapped "not now".
|
||||||
|
*/
|
||||||
|
val reminderOnboardingDone: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[REMINDER_ONBOARDING_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setReminderOnboardingDone() {
|
||||||
|
store.edit { it[REMINDER_ONBOARDING_KEY] = true }
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||||
null -> DEFAULT_FORM_FIELDS
|
null -> DEFAULT_FORM_FIELDS
|
||||||
else -> stored.split(',')
|
else -> stored.split(',')
|
||||||
@@ -98,6 +123,8 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||||
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
||||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||||
|
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
|
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
internal val DEFAULT_FORM_FIELDS =
|
internal val DEFAULT_FORM_FIELDS =
|
||||||
setOf(EventFormField.Location, EventFormField.Description)
|
setOf(EventFormField.Location, EventFormField.Description)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Becomes the app that turns the calendar provider's reminder alarms into
|
||||||
|
* visible notifications (the Etar model — the provider broadcasts
|
||||||
|
* `EVENT_REMINDER` at reminder time but posts nothing itself).
|
||||||
|
*
|
||||||
|
* The broadcast's data URI only carries the alarm time, so it is ignored:
|
||||||
|
* we query every still-scheduled, due `CalendarAlerts` row ourselves, post
|
||||||
|
* them, and mark them fired. Posting happens before marking — a crash in
|
||||||
|
* between re-posts silently (same tag) rather than losing the reminder.
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class EventReminderReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
@Inject lateinit var alertStore: ReminderAlertStore
|
||||||
|
@Inject lateinit var notifier: ReminderNotifier
|
||||||
|
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
||||||
|
val readGranted = ContextCompat.checkSelfPermission(
|
||||||
|
context, Manifest.permission.READ_CALENDAR,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!readGranted || !notifier.canPost()) return
|
||||||
|
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
if (settingsPrefs.remindersEnabled.first()) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val due = alertStore.dueAlerts(now)
|
||||||
|
due.forEach(notifier::post)
|
||||||
|
alertStore.markFired(due.map { it.alertId }, now)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import android.util.Log
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One due row of the provider's `CalendarAlerts` table (a join with Events).
|
||||||
|
* Stays in the data layer: alerts feed the notification path only and never
|
||||||
|
* reach a screen, so there is no domain model for them.
|
||||||
|
*/
|
||||||
|
data class ReminderAlert(
|
||||||
|
val alertId: Long,
|
||||||
|
val eventId: Long,
|
||||||
|
val beginMillis: Long,
|
||||||
|
val endMillis: Long,
|
||||||
|
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||||
|
val title: String,
|
||||||
|
val location: String?,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seam over the `CalendarAlerts` table so the receiver logic can be exercised
|
||||||
|
* without a ContentResolver. The provider creates these rows itself — only
|
||||||
|
* for `METHOD_ALERT` reminders (verified in AOSP `CalendarAlarmManager`), so
|
||||||
|
* email reminders never show up here.
|
||||||
|
*/
|
||||||
|
interface ReminderAlertStore {
|
||||||
|
|
||||||
|
/** Alerts that are due (`ALARM_TIME` has passed) and still unhandled. */
|
||||||
|
fun dueAlerts(nowMillis: Long): List<ReminderAlert>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the given alerts handled (`STATE_FIRED`) so a later broadcast does
|
||||||
|
* not surface them again. Best effort: this write needs `WRITE_CALENDAR`,
|
||||||
|
* which the user may have declined — then re-broadcasts silently replace
|
||||||
|
* the already-posted notifications instead (same tag, alert-once).
|
||||||
|
*/
|
||||||
|
fun markFired(alertIds: List<Long>, nowMillis: Long)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AndroidReminderAlertStore @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : ReminderAlertStore {
|
||||||
|
|
||||||
|
override fun dueAlerts(nowMillis: Long): List<ReminderAlert> = context.contentResolver.query(
|
||||||
|
CalendarContract.CalendarAlerts.CONTENT_URI,
|
||||||
|
PROJECTION,
|
||||||
|
CalendarContract.CalendarAlerts.STATE + " = ? AND " +
|
||||||
|
CalendarContract.CalendarAlerts.ALARM_TIME + " <= ?",
|
||||||
|
arrayOf(
|
||||||
|
CalendarContract.CalendarAlerts.STATE_SCHEDULED.toString(),
|
||||||
|
nowMillis.toString(),
|
||||||
|
),
|
||||||
|
CalendarContract.CalendarAlerts.BEGIN + " ASC",
|
||||||
|
)?.use { c ->
|
||||||
|
buildList {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
add(
|
||||||
|
ReminderAlert(
|
||||||
|
alertId = c.getLong(0),
|
||||||
|
eventId = c.getLong(1),
|
||||||
|
beginMillis = c.getLong(2),
|
||||||
|
endMillis = c.getLong(3),
|
||||||
|
title = c.getString(4).orEmpty(),
|
||||||
|
location = c.getString(5)?.takeIf { it.isNotBlank() },
|
||||||
|
isAllDay = c.getInt(6) == 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
override fun markFired(alertIds: List<Long>, nowMillis: Long) {
|
||||||
|
if (alertIds.isEmpty()) return
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.CalendarAlerts.STATE, CalendarContract.CalendarAlerts.STATE_FIRED)
|
||||||
|
put(CalendarContract.CalendarAlerts.RECEIVED_TIME, nowMillis)
|
||||||
|
put(CalendarContract.CalendarAlerts.NOTIFY_TIME, nowMillis)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
context.contentResolver.update(
|
||||||
|
CalendarContract.CalendarAlerts.CONTENT_URI,
|
||||||
|
values,
|
||||||
|
CalendarContract.CalendarAlerts._ID +
|
||||||
|
" IN (" + alertIds.joinToString(",") + ")",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "Cannot mark alerts fired without WRITE_CALENDAR", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ReminderAlertStore"
|
||||||
|
val PROJECTION = arrayOf(
|
||||||
|
CalendarContract.CalendarAlerts._ID,
|
||||||
|
CalendarContract.CalendarAlerts.EVENT_ID,
|
||||||
|
CalendarContract.CalendarAlerts.BEGIN,
|
||||||
|
CalendarContract.CalendarAlerts.END,
|
||||||
|
CalendarContract.CalendarAlerts.TITLE,
|
||||||
|
CalendarContract.CalendarAlerts.EVENT_LOCATION,
|
||||||
|
CalendarContract.CalendarAlerts.ALL_DAY,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.Locale
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts one notification per due reminder alert on a dedicated channel.
|
||||||
|
* Tapping opens the event's detail screen; the tag is the alert id, so a
|
||||||
|
* re-broadcast of an alert we couldn't mark fired replaces its notification
|
||||||
|
* silently ([NotificationCompat.Builder.setOnlyAlertOnce]) instead of
|
||||||
|
* duplicating it.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ReminderNotifier @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||||
|
fun canPost(): Boolean {
|
||||||
|
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun post(alert: ReminderAlert) {
|
||||||
|
ensureChannel()
|
||||||
|
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
|
val time = reminderTimeText(
|
||||||
|
beginMillis = alert.beginMillis,
|
||||||
|
endMillis = alert.endMillis,
|
||||||
|
isAllDay = alert.isAllDay,
|
||||||
|
zone = ZoneId.systemDefault(),
|
||||||
|
locale = Locale.getDefault(),
|
||||||
|
)
|
||||||
|
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
|
.setWhen(alert.beginMillis)
|
||||||
|
.setShowWhen(false)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_EVENT)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setContentIntent(detailIntent(alert))
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
NotificationManagerCompat.from(context)
|
||||||
|
.notify(alert.alertId.toString(), NOTIFICATION_ID, notification)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
||||||
|
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
/* requestCode = */ alert.alertId.toInt(),
|
||||||
|
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Channel creation is idempotent; re-running refreshes the localized name. */
|
||||||
|
private fun ensureChannel() {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
context.getString(R.string.reminder_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
|
).apply {
|
||||||
|
description = context.getString(R.string.reminder_channel_description)
|
||||||
|
}
|
||||||
|
NotificationManagerCompat.from(context).createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ReminderNotifier"
|
||||||
|
const val CHANNEL_ID = "reminders"
|
||||||
|
|
||||||
|
// One id, distinct tags: the tag (alert id) already keys the
|
||||||
|
// notification, so a fixed id keeps cancellation/replacement simple.
|
||||||
|
const val NOTIFICATION_ID = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one line of time context in a reminder notification. Pure so it can be
|
||||||
|
* JVM-tested:
|
||||||
|
*
|
||||||
|
* - timed, same day: "09:30 – 10:00"
|
||||||
|
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time)
|
||||||
|
* - all-day, one day: "11 Jun 2026"
|
||||||
|
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
|
||||||
|
*
|
||||||
|
* All-day instances store UTC midnights with an exclusive end, so they are
|
||||||
|
* read in UTC and the end day is the last *covered* day.
|
||||||
|
*/
|
||||||
|
fun reminderTimeText(
|
||||||
|
beginMillis: Long,
|
||||||
|
endMillis: Long,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
zone: ZoneId,
|
||||||
|
locale: Locale,
|
||||||
|
): String {
|
||||||
|
if (isAllDay) {
|
||||||
|
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
|
// (atZone().toLocalDate() instead of LocalDate.ofInstant — API 34+)
|
||||||
|
val firstDay = Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
.minusDays(1)
|
||||||
|
.coerceAtLeast(firstDay)
|
||||||
|
return if (lastDay == firstDay) {
|
||||||
|
dateFormat.format(firstDay)
|
||||||
|
} else {
|
||||||
|
dateFormat.format(firstDay) + RANGE + dateFormat.format(lastDay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||||
|
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
|
||||||
|
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
||||||
|
return if (begin.toLocalDate() == end.toLocalDate()) {
|
||||||
|
timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
||||||
|
} else {
|
||||||
|
val dateTimeFormat = DateTimeFormatter
|
||||||
|
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
|
||||||
|
.withLocale(locale)
|
||||||
|
dateTimeFormat.format(begin) + RANGE + dateTimeFormat.format(end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val RANGE = " – "
|
||||||
@@ -8,6 +8,7 @@ import androidx.compose.animation.slideOutHorizontally
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -29,9 +30,18 @@ import kotlinx.datetime.LocalDate
|
|||||||
* Holds the active top-level view (spec M1) and swaps between the calendar
|
* Holds the active top-level view (spec M1) and swaps between the calendar
|
||||||
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
|
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
|
||||||
* pill in their top bars writes back here via [onSelectView].
|
* pill in their top bars writes back here via [onSelectView].
|
||||||
|
*
|
||||||
|
* [requestedDetailKey] is an externally requested occurrence (a tapped
|
||||||
|
* reminder notification routed through MainActivity): it opens the detail
|
||||||
|
* overlay exactly like an event tap and is cleared via [onDetailKeyConsumed]
|
||||||
|
* so a later recomposition can't re-open it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarHost(modifier: Modifier = Modifier) {
|
fun CalendarHost(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
requestedDetailKey: LongArray? = null,
|
||||||
|
onDetailKeyConsumed: () -> Unit = {},
|
||||||
|
) {
|
||||||
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
||||||
val onSelectView: (CalendarView) -> Unit = { view = it }
|
val onSelectView: (CalendarView) -> Unit = { view = it }
|
||||||
|
|
||||||
@@ -61,6 +71,15 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
|||||||
detailKey = key
|
detailKey = key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A tapped reminder notification asks for a specific occurrence.
|
||||||
|
LaunchedEffect(requestedDetailKey) {
|
||||||
|
if (requestedDetailKey != null) {
|
||||||
|
heldKey = requestedDetailKey
|
||||||
|
detailKey = requestedDetailKey
|
||||||
|
onDetailKeyConsumed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Settings (M4) is hoisted here so it overlays whichever calendar view is
|
// Settings (M4) is hoisted here so it overlays whichever calendar view is
|
||||||
// active and survives view switches. (The calendar filter now lives inline
|
// active and survives view switches. (The calendar filter now lives inline
|
||||||
// in the navigation drawer, so no overlay state is needed for it.)
|
// in the navigation drawer, so no overlay state is needed for it.)
|
||||||
|
|||||||
@@ -10,14 +10,22 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RootScreen(modifier: Modifier = Modifier) {
|
fun RootScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
requestedDetailKey: LongArray? = null,
|
||||||
|
onDetailKeyConsumed: () -> Unit = {},
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var hasPermission by remember {
|
var hasPermission by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
@@ -40,7 +48,23 @@ fun RootScreen(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasPermission) {
|
if (hasPermission) {
|
||||||
CalendarHost(modifier = modifier)
|
// Second onboarding gate (v1.4, one-time): reminder notifications.
|
||||||
|
// Null until DataStore's first emission — render nothing for that
|
||||||
|
// frame instead of flashing the wrong screen.
|
||||||
|
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
||||||
|
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
||||||
|
when (onboardingDone) {
|
||||||
|
true -> CalendarHost(
|
||||||
|
modifier = modifier,
|
||||||
|
requestedDetailKey = requestedDetailKey,
|
||||||
|
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||||
|
)
|
||||||
|
false -> ReminderOnboardingScreen(
|
||||||
|
onFinished = reminderOnboarding::finish,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
null -> {}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
PermissionScreen(
|
PermissionScreen(
|
||||||
onGranted = { hasPermission = true },
|
onGranted = { hasPermission = true },
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
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.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.colorResource
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/** MD3 8dp spacing scale shared by the onboarding screens. */
|
||||||
|
internal object OnboardingSpace {
|
||||||
|
val xs = 8.dp
|
||||||
|
val sm = 16.dp
|
||||||
|
val md = 24.dp
|
||||||
|
val lg = 32.dp
|
||||||
|
val xl = 48.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared onboarding shell (calendar grant, reminder step): a scrollable,
|
||||||
|
* centred hero + body with the call(s) to action pinned to the bottom (clear
|
||||||
|
* of the navigation bar). The content slot is centred horizontally; benefit
|
||||||
|
* rows fill the width so their own content left-aligns.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun OnboardingScaffold(
|
||||||
|
hero: @Composable () -> Unit,
|
||||||
|
actions: @Composable ColumnScope.() -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
body: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
bottomBar = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
content = actions,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = OnboardingSpace.md),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.xl))
|
||||||
|
hero()
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||||
|
body()
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.md))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */
|
||||||
|
@Composable
|
||||||
|
internal fun BrandHero(denied: Boolean) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(128.dp)
|
||||||
|
.clip(RoundedCornerShape(34.dp))
|
||||||
|
.background(colorResource(R.color.ic_launcher_background)),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
|
contentDescription = stringResource(R.string.app_name),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (denied) {
|
||||||
|
// A small lock badge sits over the corner to signal "blocked".
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.offset(x = 10.dp, y = 10.dp)
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.errorContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
|
||||||
|
@Composable
|
||||||
|
internal fun BenefitRow(icon: ImageVector, title: String, body: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.secondaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(OnboardingSpace.sm))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
text = body,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,25 +6,14 @@ import android.net.Uri
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
@@ -34,7 +23,6 @@ import androidx.compose.material3.Button
|
|||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -42,18 +30,13 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.colorResource
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
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.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
private val CALENDAR_PERMISSIONS = arrayOf(
|
private val CALENDAR_PERMISSIONS = arrayOf(
|
||||||
@@ -61,15 +44,6 @@ private val CALENDAR_PERMISSIONS = arrayOf(
|
|||||||
Manifest.permission.WRITE_CALENDAR,
|
Manifest.permission.WRITE_CALENDAR,
|
||||||
)
|
)
|
||||||
|
|
||||||
// MD3 8dp spacing scale, scoped to this screen.
|
|
||||||
private object Space {
|
|
||||||
val xs = 8.dp
|
|
||||||
val sm = 16.dp
|
|
||||||
val md = 24.dp
|
|
||||||
val lg = 32.dp
|
|
||||||
val xl = 48.dp
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PermissionScreen(
|
fun PermissionScreen(
|
||||||
onGranted: () -> Unit,
|
onGranted: () -> Unit,
|
||||||
@@ -118,7 +92,7 @@ private fun RationaleContent(
|
|||||||
onRequest: () -> Unit,
|
onRequest: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
PermissionScaffold(
|
OnboardingScaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
hero = { BrandHero(denied = false) },
|
hero = { BrandHero(denied = false) },
|
||||||
actions = {
|
actions = {
|
||||||
@@ -131,7 +105,7 @@ private fun RationaleContent(
|
|||||||
text = stringResource(R.string.permission_request_button),
|
text = stringResource(R.string.permission_request_button),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(Space.xs))
|
Spacer(Modifier.width(OnboardingSpace.xs))
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@@ -147,7 +121,7 @@ private fun RationaleContent(
|
|||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
letterSpacing = 2.sp,
|
letterSpacing = 2.sp,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.xs))
|
Spacer(Modifier.height(OnboardingSpace.xs))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.permission_rationale_title),
|
text = stringResource(R.string.permission_rationale_title),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
@@ -161,20 +135,20 @@ private fun RationaleContent(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(Space.xl))
|
Spacer(Modifier.height(OnboardingSpace.xl))
|
||||||
|
|
||||||
BenefitRow(
|
BenefitRow(
|
||||||
icon = Icons.Filled.Lock,
|
icon = Icons.Filled.Lock,
|
||||||
title = stringResource(R.string.permission_benefit_private_title),
|
title = stringResource(R.string.permission_benefit_private_title),
|
||||||
body = stringResource(R.string.permission_benefit_private_body),
|
body = stringResource(R.string.permission_benefit_private_body),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.sm))
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
BenefitRow(
|
BenefitRow(
|
||||||
icon = Icons.Filled.CalendarMonth,
|
icon = Icons.Filled.CalendarMonth,
|
||||||
title = stringResource(R.string.permission_benefit_sync_title),
|
title = stringResource(R.string.permission_benefit_sync_title),
|
||||||
body = stringResource(R.string.permission_benefit_sync_body),
|
body = stringResource(R.string.permission_benefit_sync_body),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.sm))
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
BenefitRow(
|
BenefitRow(
|
||||||
icon = Icons.Filled.VisibilityOff,
|
icon = Icons.Filled.VisibilityOff,
|
||||||
title = stringResource(R.string.permission_benefit_privacy_title),
|
title = stringResource(R.string.permission_benefit_privacy_title),
|
||||||
@@ -189,7 +163,7 @@ private fun DeniedContent(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
PermissionScaffold(
|
OnboardingScaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
hero = { BrandHero(denied = true) },
|
hero = { BrandHero(denied = true) },
|
||||||
actions = {
|
actions = {
|
||||||
@@ -231,122 +205,6 @@ private fun DeniedContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared onboarding shell: a scrollable, centred hero + body with the call(s) to
|
|
||||||
* action pinned to the bottom (clear of the navigation bar). The content slot is
|
|
||||||
* centred horizontally; benefit rows fill the width so their own content
|
|
||||||
* left-aligns.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun PermissionScaffold(
|
|
||||||
hero: @Composable () -> Unit,
|
|
||||||
actions: @Composable ColumnScope.() -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
body: @Composable ColumnScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier,
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
bottomBar = {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.navigationBarsPadding()
|
|
||||||
.padding(horizontal = Space.md, vertical = Space.sm),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
content = actions,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { innerPadding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = Space.md),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Spacer(Modifier.height(Space.xl))
|
|
||||||
hero()
|
|
||||||
Spacer(Modifier.height(Space.lg))
|
|
||||||
body()
|
|
||||||
Spacer(Modifier.height(Space.md))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */
|
|
||||||
@Composable
|
|
||||||
private fun BrandHero(denied: Boolean) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(128.dp)
|
|
||||||
.clip(RoundedCornerShape(34.dp))
|
|
||||||
.background(colorResource(R.color.ic_launcher_background)),
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
|
||||||
contentDescription = stringResource(R.string.app_name),
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (denied) {
|
|
||||||
// A small lock badge sits over the corner to signal "blocked".
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomEnd)
|
|
||||||
.offset(x = 10.dp, y = 10.dp)
|
|
||||||
.size(44.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.errorContainer),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Lock,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
|
|
||||||
@Composable
|
|
||||||
private fun BenefitRow(icon: ImageVector, title: String, body: String) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(44.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.secondaryContainer),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(Space.sm))
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
|
||||||
Text(
|
|
||||||
text = body,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PrivacyFootnote() {
|
private fun PrivacyFootnote() {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.NotificationsActive
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.Tune
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
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.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time onboarding step after the calendar grant (v1.4): explains that
|
||||||
|
* Calendula delivers reminder notifications itself, warns about duplicates
|
||||||
|
* when a second calendar app has notifications on, and requests
|
||||||
|
* `POST_NOTIFICATIONS` (a system dialog on API 33+ only; minSdk is 29).
|
||||||
|
*
|
||||||
|
* Reminders default ON: [onFinished] gets true from the primary action even
|
||||||
|
* if the system dialog is declined — the OS permission is the real gate, and
|
||||||
|
* the Settings toggle re-requests it. "Not now" turns the in-app toggle off.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ReminderOnboardingScreen(
|
||||||
|
onFinished: (remindersEnabled: Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { onFinished(true) }
|
||||||
|
|
||||||
|
OnboardingScaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
hero = { BellHero() },
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
} else {
|
||||||
|
onFinished(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.reminder_onboarding_enable_button),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = { onFinished(false) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.reminder_onboarding_skip_button))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.app_name).uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
letterSpacing = 2.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.xs))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.reminder_onboarding_title),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.reminder_onboarding_body),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.xl))
|
||||||
|
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.NotificationsActive,
|
||||||
|
title = stringResource(R.string.reminder_benefit_delivery_title),
|
||||||
|
body = stringResource(R.string.reminder_benefit_delivery_body),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.ContentCopy,
|
||||||
|
title = stringResource(R.string.reminder_benefit_duplicates_title),
|
||||||
|
body = stringResource(R.string.reminder_benefit_duplicates_body),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.Tune,
|
||||||
|
title = stringResource(R.string.reminder_benefit_reversible_title),
|
||||||
|
body = stringResource(R.string.reminder_benefit_reversible_body),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bell in the brand squircle — same silhouette as the permission hero. */
|
||||||
|
@Composable
|
||||||
|
private fun BellHero() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(128.dp)
|
||||||
|
.clip(RoundedCornerShape(34.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.NotificationsActive,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gates the one-time reminder onboarding step (v1.4) shown after the calendar
|
||||||
|
* grant. [onboardingDone] is null until DataStore's first emission so the
|
||||||
|
* step neither flashes for users who completed it nor gets skipped.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class ReminderOnboardingViewModel @Inject constructor(
|
||||||
|
private val prefs: SettingsPrefs,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
|
||||||
|
.map { done -> done as Boolean? }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Close the step, recording whether reminder notifications stay on. */
|
||||||
|
fun finish(remindersEnabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setRemindersEnabled(remindersEnabled)
|
||||||
|
prefs.setReminderOnboardingDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -42,6 +47,7 @@ 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 androidx.core.content.ContextCompat
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
@@ -128,6 +134,13 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
|
SectionHeader(stringResource(R.string.settings_section_notifications))
|
||||||
|
RemindersRow(
|
||||||
|
checked = state.remindersEnabled,
|
||||||
|
onCheckedChange = viewModel::setRemindersEnabled,
|
||||||
|
)
|
||||||
|
|
||||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
SectionHeader(stringResource(R.string.settings_section_language))
|
SectionHeader(stringResource(R.string.settings_section_language))
|
||||||
LanguageRow()
|
LanguageRow()
|
||||||
@@ -249,6 +262,55 @@ private fun DynamicColorRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reminder-notifications toggle (v1.4), mirroring the onboarding step.
|
||||||
|
* Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) —
|
||||||
|
* the pref is set either way; the OS permission is the real gate.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun RemindersRow(
|
||||||
|
checked: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { /* The pref is already on; a denial just leaves the OS gate shut. */ }
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_reminders),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_reminders_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
onCheckedChange(enabled)
|
||||||
|
val needsPermission = enabled &&
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context, Manifest.permission.POST_NOTIFICATIONS,
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
if (needsPermission) {
|
||||||
|
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AboutSection() {
|
private fun AboutSection() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|||||||
@@ -18,4 +18,6 @@ data class SettingsUiState(
|
|||||||
val weekStart: WeekStartPref = WeekStartPref.AUTO,
|
val weekStart: WeekStartPref = WeekStartPref.AUTO,
|
||||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||||
|
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||||
|
val remindersEnabled: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,13 +28,15 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.dynamicColor,
|
prefs.dynamicColor,
|
||||||
prefs.weekStart,
|
prefs.weekStart,
|
||||||
prefs.defaultFormFields,
|
prefs.defaultFormFields,
|
||||||
) { theme, dynamic, weekStart, formFields ->
|
prefs.remindersEnabled,
|
||||||
|
) { theme, dynamic, weekStart, formFields, reminders ->
|
||||||
SettingsUiState(
|
SettingsUiState(
|
||||||
themeMode = theme,
|
themeMode = theme,
|
||||||
dynamicColor = dynamic && dynamicColorAvailable,
|
dynamicColor = dynamic && dynamicColorAvailable,
|
||||||
dynamicColorAvailable = dynamicColorAvailable,
|
dynamicColorAvailable = dynamicColorAvailable,
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
defaultFormFields = formFields,
|
defaultFormFields = formFields,
|
||||||
|
remindersEnabled = reminders,
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@@ -57,4 +59,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
|
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
|
||||||
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setRemindersEnabled(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
app/src/main/res/drawable/ic_notification.xml
Normal file
12
app/src/main/res/drawable/ic_notification.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Monochrome status-bar mark: Material "event" calendar glyph. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM19,19H5V8h14V19zM7,10h5v5H7V10z" />
|
||||||
|
</vector>
|
||||||
@@ -159,6 +159,20 @@
|
|||||||
<!-- Geteilte Event-Strings -->
|
<!-- Geteilte Event-Strings -->
|
||||||
<string name="event_untitled">(Ohne Titel)</string>
|
<string name="event_untitled">(Ohne Titel)</string>
|
||||||
|
|
||||||
|
<!-- Erinnerungs-Benachrichtigungen (v1.4) -->
|
||||||
|
<string name="reminder_channel_name">Termin-Erinnerungen</string>
|
||||||
|
<string name="reminder_channel_description">Benachrichtigungen zu den Erinnerungszeiten deiner Termine</string>
|
||||||
|
<string name="reminder_onboarding_title">Keinen Termin mehr verpassen</string>
|
||||||
|
<string name="reminder_onboarding_body">Android zeigt Termin-Erinnerungen nicht von selbst — das muss eine Kalender-App tun. Überlass Calendula den Job.</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">Erinnerungen, zugestellt</string>
|
||||||
|
<string name="reminder_benefit_delivery_body">Jede Erinnerung an deinen Terminen kommt pünktlich als Benachrichtigung an.</string>
|
||||||
|
<string name="reminder_benefit_duplicates_title">Noch eine zweite Kalender-App?</string>
|
||||||
|
<string name="reminder_benefit_duplicates_body">Wenn eine andere App ebenfalls Erinnerungen zeigt, siehst du sie doppelt — schalte sie dort oder hier ab.</string>
|
||||||
|
<string name="reminder_benefit_reversible_title">Jederzeit änderbar</string>
|
||||||
|
<string name="reminder_benefit_reversible_body">Der Schalter liegt in den Einstellungen unter Benachrichtigungen.</string>
|
||||||
|
<string name="reminder_onboarding_enable_button">Erinnerungen einschalten</string>
|
||||||
|
<string name="reminder_onboarding_skip_button">Später</string>
|
||||||
|
|
||||||
<!-- View-Switcher (M1) -->
|
<!-- View-Switcher (M1) -->
|
||||||
<string name="view_month">Monat</string>
|
<string name="view_month">Monat</string>
|
||||||
<string name="view_week">Woche</string>
|
<string name="view_week">Woche</string>
|
||||||
@@ -183,6 +197,9 @@
|
|||||||
<string name="settings_week_start_sunday">Sonntag</string>
|
<string name="settings_week_start_sunday">Sonntag</string>
|
||||||
<string name="settings_section_event_form">Termin-Formular</string>
|
<string name="settings_section_event_form">Termin-Formular</string>
|
||||||
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
|
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
|
||||||
|
<string name="settings_section_notifications">Benachrichtigungen</string>
|
||||||
|
<string name="settings_reminders">Termin-Erinnerungen</string>
|
||||||
|
<string name="settings_reminders_hint">Erinnerungen doppelt? Eine andere Kalender-App zeigt sie ebenfalls — schalte sie in einer der beiden ab.</string>
|
||||||
<string name="settings_section_language">Sprache</string>
|
<string name="settings_section_language">Sprache</string>
|
||||||
<string name="settings_language">App-Sprache</string>
|
<string name="settings_language">App-Sprache</string>
|
||||||
<string name="settings_language_auto">Systemstandard</string>
|
<string name="settings_language_auto">Systemstandard</string>
|
||||||
|
|||||||
@@ -160,6 +160,20 @@
|
|||||||
<!-- Shared event strings -->
|
<!-- Shared event strings -->
|
||||||
<string name="event_untitled">(No title)</string>
|
<string name="event_untitled">(No title)</string>
|
||||||
|
|
||||||
|
<!-- Reminder notifications (v1.4) -->
|
||||||
|
<string name="reminder_channel_name">Event reminders</string>
|
||||||
|
<string name="reminder_channel_description">Notifications at the reminder times of your events</string>
|
||||||
|
<string name="reminder_onboarding_title">Never miss an event</string>
|
||||||
|
<string name="reminder_onboarding_body">Android doesn\'t show event reminders by itself — a calendar app has to. Let Calendula take that job.</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">Reminders, delivered</string>
|
||||||
|
<string name="reminder_benefit_delivery_body">Every reminder on your events arrives as a notification, right on time.</string>
|
||||||
|
<string name="reminder_benefit_duplicates_title">Using a second calendar app?</string>
|
||||||
|
<string name="reminder_benefit_duplicates_body">If another app also posts reminders, you\'ll see them twice — turn them off there or here.</string>
|
||||||
|
<string name="reminder_benefit_reversible_title">Change it anytime</string>
|
||||||
|
<string name="reminder_benefit_reversible_body">The switch lives in Settings, under Notifications.</string>
|
||||||
|
<string name="reminder_onboarding_enable_button">Turn on reminders</string>
|
||||||
|
<string name="reminder_onboarding_skip_button">Not now</string>
|
||||||
|
|
||||||
<!-- View switcher (M1) -->
|
<!-- View switcher (M1) -->
|
||||||
<string name="view_month">Month</string>
|
<string name="view_month">Month</string>
|
||||||
<string name="view_week">Week</string>
|
<string name="view_week">Week</string>
|
||||||
@@ -184,6 +198,9 @@
|
|||||||
<string name="settings_week_start_sunday">Sunday</string>
|
<string name="settings_week_start_sunday">Sunday</string>
|
||||||
<string name="settings_section_event_form">New event form</string>
|
<string name="settings_section_event_form">New event form</string>
|
||||||
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
||||||
|
<string name="settings_section_notifications">Notifications</string>
|
||||||
|
<string name="settings_reminders">Event reminders</string>
|
||||||
|
<string name="settings_reminders_hint">Seeing reminders twice? Another calendar app is posting them too — turn them off in one of the two.</string>
|
||||||
<string name="settings_section_language">Language</string>
|
<string name="settings_section_language">Language</string>
|
||||||
<string name="settings_language">App language</string>
|
<string name="settings_language">App language</string>
|
||||||
<string name="settings_language_auto">System default</string>
|
<string name="settings_language_auto">System default</string>
|
||||||
|
|||||||
@@ -100,6 +100,27 @@ class SettingsPrefsTest {
|
|||||||
assertThat(prefs.defaultFormFields.first()).containsExactly(EventFormField.Location)
|
assertThat(prefs.defaultFormFields.first()).containsExactly(EventFormField.Location)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reminders default to enabled, onboarding to not done`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.remindersEnabled.first()).isTrue()
|
||||||
|
assertThat(prefs.reminderOnboardingDone.first()).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reminders toggle round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setRemindersEnabled(false)
|
||||||
|
assertThat(prefs.remindersEnabled.first()).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reminder onboarding completes one-way`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setReminderOnboardingDone()
|
||||||
|
assertThat(prefs.reminderOnboardingDone.first()).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `explicit week-start prefs resolve regardless of locale`() {
|
fun `explicit week-start prefs resolve regardless of locale`() {
|
||||||
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
|
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class ReminderTimeTextTest {
|
||||||
|
|
||||||
|
private val berlin = ZoneId.of("Europe/Berlin")
|
||||||
|
|
||||||
|
private fun millisAt(dateTime: LocalDateTime, zone: ZoneId): Long =
|
||||||
|
dateTime.atZone(zone).toInstant().toEpochMilli()
|
||||||
|
|
||||||
|
private fun utcMidnight(date: LocalDate): Long =
|
||||||
|
date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timed event on one day shows just the time range`() {
|
||||||
|
val text = reminderTimeText(
|
||||||
|
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 9, 30), berlin),
|
||||||
|
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 10, 0), berlin),
|
||||||
|
isAllDay = false,
|
||||||
|
zone = berlin,
|
||||||
|
locale = Locale.GERMANY,
|
||||||
|
)
|
||||||
|
assertThat(text).isEqualTo("09:30 – 10:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timed event crossing midnight includes both dates`() {
|
||||||
|
val text = reminderTimeText(
|
||||||
|
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 23, 30), berlin),
|
||||||
|
endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 30), berlin),
|
||||||
|
isAllDay = false,
|
||||||
|
zone = berlin,
|
||||||
|
locale = Locale.GERMANY,
|
||||||
|
)
|
||||||
|
assertThat(text).contains("11.06.2026")
|
||||||
|
assertThat(text).contains("12.06.2026")
|
||||||
|
assertThat(text).contains("23:30")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day single day shows one date, read in UTC`() {
|
||||||
|
val text = reminderTimeText(
|
||||||
|
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
|
||||||
|
endMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
|
||||||
|
isAllDay = true,
|
||||||
|
// Zone must not matter for all-day events: UTC midnight is
|
||||||
|
// 02:00 in Berlin — naive local reading would shift the day.
|
||||||
|
zone = berlin,
|
||||||
|
locale = Locale.GERMANY,
|
||||||
|
)
|
||||||
|
assertThat(text).isEqualTo("11.06.2026")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day multi-day shows the last covered day, not the exclusive end`() {
|
||||||
|
val text = reminderTimeText(
|
||||||
|
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
|
||||||
|
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
|
||||||
|
isAllDay = true,
|
||||||
|
zone = berlin,
|
||||||
|
locale = Locale.GERMANY,
|
||||||
|
)
|
||||||
|
assertThat(text).isEqualTo("11.06.2026 – 12.06.2026")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `degenerate all-day range never renders an inverted span`() {
|
||||||
|
val day = utcMidnight(LocalDate.of(2026, 6, 11))
|
||||||
|
val text = reminderTimeText(
|
||||||
|
beginMillis = day,
|
||||||
|
endMillis = day,
|
||||||
|
isAllDay = true,
|
||||||
|
zone = berlin,
|
||||||
|
locale = Locale.GERMANY,
|
||||||
|
)
|
||||||
|
assertThat(text).isEqualTo("11.06.2026")
|
||||||
|
}
|
||||||
|
}
|
||||||
119
docs/superpowers/plans/2026-06-11-04-reminder-notifications.md
Normal file
119
docs/superpowers/plans/2026-06-11-04-reminder-notifications.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Calendula - Plan 04: Reminder Notifications (v1.4)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Calendula stellt Erinnerungen selbst als Notification zu (Etar-Modell).
|
||||||
|
Der Provider plant die Alarme und broadcastet
|
||||||
|
`android.intent.action.EVENT_REMINDER` — die sichtbare Notification postet er
|
||||||
|
**nicht**, das muss eine Kalender-App tun. Für Nutzer, deren einzige
|
||||||
|
Kalender-App Calendula ist, ist das essenziell, kein Nice-to-have.
|
||||||
|
`./gradlew lint test assembleDebug` bleibt grün; Release erst nach
|
||||||
|
On-Device-Review.
|
||||||
|
|
||||||
|
**Architecture:** Eigenes kleines Datenmodul `data/reminders/` neben
|
||||||
|
`data/calendar/` — der Receiver braucht weder Repository noch Flows. Schichtung
|
||||||
|
wie gehabt: `EventReminderReceiver` (Hilt-EntryPoint) →
|
||||||
|
`ReminderAlertStore` (Interface, Android-Impl auf `CalendarAlerts`) →
|
||||||
|
`ReminderNotifier` (NotificationManager). Domain bleibt pure Kotlin
|
||||||
|
(`ReminderAlert`-Modell, JVM-testbare Textformatierung).
|
||||||
|
|
||||||
|
**Recherche-Befunde (AOSP `CalendarAlarmManager` + Etar, 2026-06-11):**
|
||||||
|
|
||||||
|
1. Der Provider legt `CalendarAlerts`-Rows **nur für `METHOD_ALERT`-Reminder**
|
||||||
|
an (AOSP-Query: `AND method=1`). Der im Roadmap-Eintrag geforderte
|
||||||
|
METHOD-Filter (E-Mail überspringen) passiert also schon upstream — wir
|
||||||
|
filtern nicht doppelt. Calendula schreibt eigene Reminder ohnehin als
|
||||||
|
`METHOD_ALERT`.
|
||||||
|
2. Der Broadcast ist implizit (Action + `content://com.android.calendar/…`-URI,
|
||||||
|
Extra `alarmTime`). Etars Manifest-Receiver ist `exported="true"` mit
|
||||||
|
`<data android:scheme="content"/>` — das übernehmen wir (plus Host,
|
||||||
|
enger gefasst). Den URI-Inhalt werten wir nicht aus; wir queryen selbst
|
||||||
|
"fällig & noch SCHEDULED".
|
||||||
|
3. Etar postet aus dem Zustand `SCHEDULED ∪ FIRED` und verwaltet Dismiss über
|
||||||
|
eigene Services. Wir vereinfachen: nur `STATE_SCHEDULED AND alarmTime <= now`
|
||||||
|
posten, danach best-effort auf `FIRED` setzen (braucht `WRITE_CALENDAR`;
|
||||||
|
`SecurityException` wird geschluckt). Weggewischte Notifications kommen so
|
||||||
|
nie wieder, ohne deleteIntent-Maschinerie: FIRED-Rows fassen wir nicht an.
|
||||||
|
Re-Broadcasts ohne Write-Recht ersetzen still (Tag pro Alert +
|
||||||
|
`setOnlyAlertOnce`).
|
||||||
|
|
||||||
|
**Leitentscheidungen:**
|
||||||
|
|
||||||
|
1. **Kein eigenes Alarm-Scheduling** (kein `SCHEDULE_EXACT_ALARM`, kein
|
||||||
|
`BOOT_COMPLETED`, kein WorkManager): Zustellung hängt am Provider-Broadcast.
|
||||||
|
Etars Zusatz-Maschinerie (eigener AlarmScheduler) kommt erst, wenn sich
|
||||||
|
Zuverlässigkeit auf echten Geräten als Problem zeigt (Roadmap: bewusst
|
||||||
|
verschoben, ebenso Snooze-/Dismiss-Actions und Battery-Exemption).
|
||||||
|
2. **Toggle default ON, Onboarding-Schritt danach:** Nach dem Kalender-Grant
|
||||||
|
folgt ein zweiter Onboarding-Screen (gleiche Shell wie der Permission-
|
||||||
|
Screen): erklärt Reminder, warnt vor Duplikaten (zweite Kalender-App mit
|
||||||
|
aktiven Notifications), fragt `POST_NOTIFICATIONS` an (nur API 33+ zeigt
|
||||||
|
einen Dialog; minSdk 29). "Später" schaltet den Toggle aus. Der Schritt
|
||||||
|
erscheint genau einmal (`reminder_onboarding_done`-Pref) — auch für
|
||||||
|
v1.0–v1.3-Upgrader, die das Feature so entdecken.
|
||||||
|
3. **Settings-Spiegel:** Abschnitt "Erinnerungen" mit demselben Toggle +
|
||||||
|
Duplikat-Hinweis. Einschalten fordert `POST_NOTIFICATIONS` kontextuell an,
|
||||||
|
wenn sie fehlt.
|
||||||
|
4. **Tap öffnet das Event-Detail:** Notification-Intent trägt
|
||||||
|
eventId/begin/end; `MainActivity` wird `singleTop`, reicht den Key als
|
||||||
|
Compose-State an `CalendarHost` durch (gleiches LongArray-Key-Muster wie
|
||||||
|
der Detail-Overlay selbst).
|
||||||
|
5. **Ein Kanal, einfache Inhalte:** Kanal "Erinnerungen"
|
||||||
|
(`IMPORTANCE_HIGH`), pro Alert eine Notification (Tag = Alert-Id):
|
||||||
|
Titel = Eventtitel (Fallback "(Ohne Titel)"), Text = Zeitspanne
|
||||||
|
(ganztägig: Datum, UTC gelesen) + Ort. Kein Grouping/Summary, kein
|
||||||
|
Vollbild-Alarm.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
**Manifest / Resourcen:**
|
||||||
|
- [x] `POST_NOTIFICATIONS` ins Manifest; Receiver `.reminders.EventReminderReceiver`
|
||||||
|
`exported="true"`, Intent-Filter `EVENT_REMINDER` + `data scheme=content
|
||||||
|
host=com.android.calendar`; `MainActivity` → `launchMode="singleTop"`
|
||||||
|
- [x] Monochromes Notification-Icon `drawable/ic_notification.xml`
|
||||||
|
- [x] Strings DE+EN: Kanal, Onboarding-Copy, Settings-Abschnitt + Hinweis
|
||||||
|
|
||||||
|
**Prefs:**
|
||||||
|
- [x] `SettingsPrefs.remindersEnabled` (default **true**) + Setter;
|
||||||
|
`reminderOnboardingDone` (default false) + Setter; `SettingsPrefsTest`
|
||||||
|
|
||||||
|
**Data layer (`data/reminders/`):**
|
||||||
|
- [x] `ReminderAlert`-Modell (in `data/reminders/`, nicht domain — Alerts
|
||||||
|
erreichen nie einen Screen): alertId, eventId, begin/end als Millis,
|
||||||
|
title, location, isAllDay
|
||||||
|
- [x] `ReminderAlertStore` (Interface) + `AndroidReminderAlertStore`:
|
||||||
|
`dueAlerts(nowMillis)` = `CalendarAlerts` mit
|
||||||
|
`STATE_SCHEDULED AND ALARM_TIME <= now`;
|
||||||
|
`markFired(ids, nowMillis)` setzt STATE/RECEIVED_TIME/NOTIFY_TIME,
|
||||||
|
`SecurityException` → Log (Write-Recht optional)
|
||||||
|
- [x] `ReminderNotifier`: Kanal lazy anlegen, eine Notification pro Alert
|
||||||
|
(Tag = alertId, `setOnlyAlertOnce`, autoCancel, `when` = begin,
|
||||||
|
Category EVENT), Content-PendingIntent auf `MainActivity` mit
|
||||||
|
eventId/begin/end
|
||||||
|
- [x] Zeitspannen-Text als pure Funktion (JVM-testbar) + Test
|
||||||
|
|
||||||
|
**Receiver:**
|
||||||
|
- [x] `EventReminderReceiver` (`@AndroidEntryPoint`): Action prüfen,
|
||||||
|
`goAsync()`; raus, wenn Pref aus, READ_CALENDAR fehlt oder
|
||||||
|
Notifications systemseitig geblockt; sonst posten → `markFired`
|
||||||
|
|
||||||
|
**UI:**
|
||||||
|
- [x] Onboarding-Shell aus `PermissionScreen` extrahieren
|
||||||
|
(`OnboardingScaffold` + BenefitRow, intern wiederverwendet)
|
||||||
|
- [x] `NotificationOnboardingScreen` + ViewModel: Benefit-Rows (verpasst
|
||||||
|
nichts / Duplikat-Warnung), Primär-Button fordert `POST_NOTIFICATIONS`
|
||||||
|
(API 33+) und lässt den Toggle an, "Später" schaltet ihn aus; beide
|
||||||
|
setzen `reminder_onboarding_done`
|
||||||
|
- [x] `RootScreen`: Kalender-Gate → Reminder-Schritt (einmalig) → `CalendarHost`
|
||||||
|
- [x] `CalendarHost`: externer Detail-Key (Notification-Tap) wird wie ein
|
||||||
|
Event-Tap konsumiert; `MainActivity` parst Intent (onCreate +
|
||||||
|
onNewIntent) in Compose-State
|
||||||
|
- [x] Settings: Abschnitt "Benachrichtigungen" — Toggle (mit kontextuellem
|
||||||
|
Permission-Request beim Einschalten) + Duplikat-Hinweistext
|
||||||
|
|
||||||
|
**Abschluss:**
|
||||||
|
- [x] `./gradlew lint test assembleDebug` grün
|
||||||
|
- [x] CHANGELOG (`[Unreleased]`), ROADMAP-Status; **kein** Tag/Release vor
|
||||||
|
On-Device-Review
|
||||||
Reference in New Issue
Block a user