Compare commits
7 Commits
2ae4c818ba
...
feat/per-c
| Author | SHA1 | Date | |
|---|---|---|---|
| 225f4c3491 | |||
| 5d887524b1 | |||
| 048f407ba1 | |||
| a89560953d | |||
| 4e498de051 | |||
| f2fb3d6279 | |||
| c2d88e744e |
25
CHANGELOG.md
25
CHANGELOG.md
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Set more than one default reminder per calendar. A calendar's default
|
||||
reminders — and the global defaults under Settings → Notifications — can now
|
||||
hold several lead times instead of just one, so new events can start with, say,
|
||||
a reminder a week before *and* one on the day. The reminder pickers are now
|
||||
multi-select; per-calendar overrides can still inherit the global default or
|
||||
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
|
||||
|
||||
### Fixed
|
||||
- Disabled calendars no longer notify. Reminders for events in a calendar you
|
||||
have disabled (Settings → Calendars) are now suppressed instead of still
|
||||
popping up — matching how a disabled calendar's events already stay hidden
|
||||
everywhere else in the app ([#17]).
|
||||
- Editing a single occurrence of a recurring event works again. Choosing **Only
|
||||
this event** and saving a change to one event in a repeating series silently
|
||||
did nothing — the change was rejected and the edit form simply reappeared with
|
||||
nothing applied. The edited occurrence is now stored correctly, so the change
|
||||
lands on just that one event and leaves the rest of the series untouched
|
||||
([#16]).
|
||||
|
||||
## [2.12.0] — 2026-06-28
|
||||
|
||||
### Added
|
||||
@@ -729,3 +751,6 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#10]: https://codeberg.org/jlmakiola/calendula/issues/10
|
||||
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
|
||||
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
|
||||
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
|
||||
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
|
||||
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17
|
||||
|
||||
@@ -90,6 +90,8 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
// BuildConfig.DEBUG gates the in-app debug ribbon (see DebugRibbon).
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
|
||||
|
||||
10
app/src/debug/res/values/colors.xml
Normal file
10
app/src/debug/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Debug-only launcher-icon background. Production is slate (#5C6B7A); the
|
||||
debug build paints the adaptive-icon background burnt orange instead, so the
|
||||
debug icon reads at a glance as "not the real app" on the home screen. The
|
||||
off-white foreground mark contrasts on both. See drawable/ic_launcher_background.
|
||||
-->
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFB23B00</color>
|
||||
</resources>
|
||||
10
app/src/debug/res/values/strings.xml
Normal file
10
app/src/debug/res/values/strings.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Debug-build resource overrides. Merged on top of src/main for the `debug`
|
||||
build type only (release/releaseTest keep the production values), so the
|
||||
debug app is unmistakable on the launcher: its own label, alongside the
|
||||
real app thanks to the `.debug` applicationId suffix.
|
||||
-->
|
||||
<resources>
|
||||
<string name="app_name">Calendula Debug</string>
|
||||
</resources>
|
||||
@@ -8,6 +8,7 @@ import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -29,6 +30,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.DebugRibbon
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||
@@ -101,19 +103,24 @@ class MainActivity : AppCompatActivity() {
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = settings.dynamicColor,
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalUse24HourFormat provides use24Hour,
|
||||
LocalShowHourLines provides settings.showHourLines,
|
||||
) {
|
||||
RootScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||
widgetNavRequest = requestedNav,
|
||||
onWidgetNavConsumed = { requestedNav = null },
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = { requestedImportUri = null },
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CompositionLocalProvider(
|
||||
LocalUse24HourFormat provides use24Hour,
|
||||
LocalShowHourLines provides settings.showHourLines,
|
||||
) {
|
||||
RootScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||
widgetNavRequest = requestedNav,
|
||||
onWidgetNavConsumed = { requestedNav = null },
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = { requestedImportUri = null },
|
||||
)
|
||||
}
|
||||
// A persistent corner marker so a debug build is never
|
||||
// mistaken for the production app; compiled out of release.
|
||||
if (BuildConfig.DEBUG) DebugRibbon()
|
||||
}
|
||||
pendingCrashReport?.let { report ->
|
||||
CrashReportDialog(
|
||||
|
||||
@@ -118,8 +118,14 @@ internal fun buildEventUpdateValues(
|
||||
* provider clone the series row and apply these on top. Unlike the series
|
||||
* update there is no dirty check — the exception is a fresh row, so every
|
||||
* form-backed column is written (empty optionals as explicit NULLs, since the
|
||||
* clone starts from the parent's values). An exception is a single event:
|
||||
* DTEND, never RRULE/DURATION.
|
||||
* clone starts from the parent's values).
|
||||
*
|
||||
* The occurrence's length travels as DURATION, never DTEND: the provider
|
||||
* rejects DTEND on an exception outright (`CalendarProvider2`:
|
||||
* "Exceptions can't overwrite dtend") and derives the instance end from
|
||||
* DTSTART + DURATION itself, clearing the inherited RRULE in the process. This
|
||||
* matches how AOSP Calendar/Etar write exceptions; sending DTEND is what made
|
||||
* "only this event" fail on-device (Codeberg #16).
|
||||
*/
|
||||
internal fun buildOccurrenceExceptionValues(
|
||||
form: EventForm,
|
||||
@@ -131,7 +137,7 @@ internal fun buildOccurrenceExceptionValues(
|
||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||
|
||||
@@ -8,6 +8,7 @@ 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.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -16,6 +17,17 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The due alerts that should actually surface as notifications: everything
|
||||
* except alerts whose calendar the user has disabled in-app (mirroring the
|
||||
* event filtering in CalendarRepositoryImpl). The caller still marks the full
|
||||
* due set fired, so suppressed alerts are not re-broadcast by the provider.
|
||||
*/
|
||||
internal fun postableAlerts(
|
||||
due: List<ReminderAlert>,
|
||||
disabledCalendarIds: Set<Long>,
|
||||
): List<ReminderAlert> = due.filterNot { it.calendarId in disabledCalendarIds }
|
||||
|
||||
/**
|
||||
* Becomes the app that turns the calendar provider's reminder alarms into
|
||||
* visible notifications (the Etar model — the provider broadcasts
|
||||
@@ -32,6 +44,7 @@ class EventReminderReceiver : BroadcastReceiver() {
|
||||
@Inject lateinit var alertStore: ReminderAlertStore
|
||||
@Inject lateinit var notifier: ReminderNotifier
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
@Inject lateinit var calendarPrefs: CalendarPrefs
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
||||
@@ -46,7 +59,11 @@ class EventReminderReceiver : BroadcastReceiver() {
|
||||
if (settingsPrefs.remindersEnabled.first()) {
|
||||
val now = System.currentTimeMillis()
|
||||
val due = alertStore.dueAlerts(now)
|
||||
due.forEach { notifier.post(it) }
|
||||
val disabled = calendarPrefs.disabledCalendarIds.first()
|
||||
// Suppress reminders for disabled calendars, but still mark
|
||||
// every due alert fired so the provider stops re-broadcasting
|
||||
// the suppressed ones.
|
||||
postableAlerts(due, disabled).forEach { notifier.post(it) }
|
||||
alertStore.markFired(due.map { it.alertId }, now)
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -75,6 +75,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
|
||||
private const val EXTRA_ALERT_ID = "alert_id"
|
||||
private const val EXTRA_EVENT_ID = "event_id"
|
||||
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
||||
private const val EXTRA_BEGIN = "begin"
|
||||
private const val EXTRA_END = "end"
|
||||
private const val EXTRA_TITLE = "title"
|
||||
@@ -87,6 +88,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
this.action = action
|
||||
putExtra(EXTRA_ALERT_ID, alert.alertId)
|
||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||
putExtra(EXTRA_END, alert.endMillis)
|
||||
putExtra(EXTRA_TITLE, alert.title)
|
||||
@@ -113,6 +115,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
return ReminderAlert(
|
||||
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
|
||||
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
||||
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
|
||||
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
||||
endMillis = intent.getLongExtra(EXTRA_END, 0L),
|
||||
title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),
|
||||
|
||||
@@ -16,6 +16,7 @@ import javax.inject.Singleton
|
||||
data class ReminderAlert(
|
||||
val alertId: Long,
|
||||
val eventId: Long,
|
||||
val calendarId: Long,
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||
@@ -66,11 +67,12 @@ class AndroidReminderAlertStore @Inject constructor(
|
||||
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,
|
||||
calendarId = c.getLong(2),
|
||||
beginMillis = c.getLong(3),
|
||||
endMillis = c.getLong(4),
|
||||
title = c.getString(5).orEmpty(),
|
||||
location = c.getString(6)?.takeIf { it.isNotBlank() },
|
||||
isAllDay = c.getInt(7) == 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -102,6 +104,7 @@ class AndroidReminderAlertStore @Inject constructor(
|
||||
val PROJECTION = arrayOf(
|
||||
CalendarContract.CalendarAlerts._ID,
|
||||
CalendarContract.CalendarAlerts.EVENT_ID,
|
||||
CalendarContract.CalendarAlerts.CALENDAR_ID,
|
||||
CalendarContract.CalendarAlerts.BEGIN,
|
||||
CalendarContract.CalendarAlerts.END,
|
||||
CalendarContract.CalendarAlerts.TITLE,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
|
||||
/**
|
||||
* A Flutter-style "DEBUG" corner ribbon, drawn across the top-right corner of
|
||||
* the app. Deliberately a stark, un-themed marker (not a product component) so a
|
||||
* debug build is unmistakable at a glance — gate it on `BuildConfig.DEBUG` at the
|
||||
* call site so it never reaches a release build. Non-interactive: it's a plain
|
||||
* label with no pointer handler, so taps fall through to whatever is beneath it.
|
||||
*
|
||||
* Drop it in as the last child of a full-screen [androidx.compose.foundation.layout.Box]
|
||||
* so it overlays the UI.
|
||||
*/
|
||||
@Composable
|
||||
fun BoxScope.DebugRibbon() {
|
||||
Text(
|
||||
text = "DEBUG",
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.zIndex(1f)
|
||||
// Push the band out so its midline crosses the very corner, then
|
||||
// rotate it to the classic 45° ribbon.
|
||||
.offset(x = 36.dp, y = 24.dp)
|
||||
.rotate(45f)
|
||||
.background(Color(0xFFB23B00))
|
||||
.width(140.dp)
|
||||
.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
@@ -199,11 +199,12 @@ class EventWriteMapperTest {
|
||||
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
|
||||
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Berlin")
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
||||
assertThat(values[CalendarContract.Events.DTEND])
|
||||
.isEqualTo(1_781_164_800_000L + 5_400_000L)
|
||||
// A single occurrence never carries its own rule.
|
||||
// The occurrence's length travels as DURATION, never DTEND — the provider
|
||||
// rejects DTEND on an exception ("Exceptions can't overwrite dtend") and
|
||||
// derives the end from DTSTART + DURATION, clearing the inherited RRULE.
|
||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class PostableAlertsTest {
|
||||
|
||||
private fun alert(alertId: Long, calendarId: Long) = ReminderAlert(
|
||||
alertId = alertId,
|
||||
eventId = alertId * 10,
|
||||
calendarId = calendarId,
|
||||
beginMillis = 0L,
|
||||
endMillis = 0L,
|
||||
title = "Event $alertId",
|
||||
location = null,
|
||||
isAllDay = false,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `keeps alerts when no calendar is disabled`() {
|
||||
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 200))
|
||||
|
||||
val postable = postableAlerts(due, disabledCalendarIds = emptySet())
|
||||
|
||||
assertThat(postable).isEqualTo(due)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drops alerts for a disabled calendar`() {
|
||||
val keep = alert(1, calendarId = 100)
|
||||
val drop = alert(2, calendarId = 200)
|
||||
|
||||
val postable = postableAlerts(listOf(keep, drop), disabledCalendarIds = setOf(200))
|
||||
|
||||
assertThat(postable).containsExactly(keep)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drops every alert when all their calendars are disabled`() {
|
||||
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100))
|
||||
|
||||
val postable = postableAlerts(due, disabledCalendarIds = setOf(100))
|
||||
|
||||
assertThat(postable).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps multiple alerts from the same enabled calendar`() {
|
||||
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100))
|
||||
|
||||
val postable = postableAlerts(due, disabledCalendarIds = setOf(999))
|
||||
|
||||
assertThat(postable).isEqualTo(due)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user