Compare commits
4 Commits
feat/edit-
...
f81f593017
| Author | SHA1 | Date | |
|---|---|---|---|
| f81f593017 | |||
| 8ddc11159b | |||
|
|
6736bae5ee | ||
|
|
21d1726d7a |
@@ -97,74 +97,32 @@
|
|||||||
<data android:mimeType="time/epoch" />
|
<data android:mimeType="time/epoch" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Open a .ics/.vcs file (file manager / email attachment / browser).
|
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
||||||
The three MIME types cover the common labels the same calendar
|
|
||||||
data arrives under: iCalendar 2.0 (text/calendar), the older
|
|
||||||
vCalendar 1.0 / .vcs (text/x-vcalendar), and application/ics some
|
|
||||||
mail apps emit — Android cross-products the scheme and mimeType
|
|
||||||
tags, so each MIME is accepted on both schemes (matches Etar). -->
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="content" />
|
<data android:scheme="content" android:mimeType="text/calendar" />
|
||||||
<data android:scheme="file" />
|
<data android:scheme="file" android:mimeType="text/calendar" />
|
||||||
<data android:mimeType="text/calendar" />
|
|
||||||
<data android:mimeType="text/x-vcalendar" />
|
|
||||||
<data android:mimeType="application/ics" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<!-- Receive a .ics/.vcs shared from another app (same MIME set). -->
|
<!-- Receive a .ics shared from another app. -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND" />
|
<action android:name="android.intent.action.SEND" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<data android:mimeType="text/calendar" />
|
<data android:mimeType="text/calendar" />
|
||||||
<data android:mimeType="text/x-vcalendar" />
|
|
||||||
<data android:mimeType="application/ics" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
|
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
|
||||||
to create a new event, the way the AOSP calendar accepts it:
|
to create a new event, the way the AOSP calendar accepts it:
|
||||||
ACTION_INSERT on the events dir mime type, carrying the new
|
ACTION_INSERT on the events dir mime type, carrying the new
|
||||||
event's fields as CalendarContract extras
|
event's fields as CalendarContract extras
|
||||||
(MainActivity.insertFormOrNull, issue #30). ACTION_EDIT on the
|
(MainActivity.insertFormOrNull, issue #30). -->
|
||||||
dir mime is AOSP's "edit a new event" — i.e. create — so it maps
|
|
||||||
to the same prefilled create form. -->
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.INSERT" />
|
<action android:name="android.intent.action.INSERT" />
|
||||||
<action android:name="android.intent.action.EDIT" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<data android:mimeType="vnd.android.cursor.dir/event" />
|
<data android:mimeType="vnd.android.cursor.dir/event" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Edit an existing event another app/assistant/widget points at:
|
|
||||||
ACTION_EDIT on content://com.android.calendar/events/<id>, the way
|
|
||||||
AOSP fires it. Opens the occurrence in the edit form (not the
|
|
||||||
read-only detail — that's the VIEW filter above). Matched by the
|
|
||||||
provider's item MIME type, like the VIEW filter. The occurrence's
|
|
||||||
times ride as EXTRA_EVENT_BEGIN_TIME / EXTRA_EVENT_END_TIME when
|
|
||||||
supplied (MainActivity.editEventKeyOrNull). -->
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.EDIT" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<data android:mimeType="vnd.android.cursor.item/event" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<!-- Open an existing event another app/widget points at (e.g. tapping
|
|
||||||
an event in the Todo Agenda widget): ACTION_VIEW on
|
|
||||||
content://com.android.calendar/events/<id>, the way AOSP fires it.
|
|
||||||
Matched by the provider's item MIME type, not the path — a
|
|
||||||
content: VIEW intent carries the resolved type
|
|
||||||
(vnd.android.cursor.item/event) and a path-only filter wouldn't
|
|
||||||
match it. The occurrence's times ride as EXTRA_EVENT_BEGIN_TIME /
|
|
||||||
EXTRA_EVENT_END_TIME when the launcher supplies them
|
|
||||||
(MainActivity.viewEventKeyOrNull, issue #48). -->
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
<data android:mimeType="vnd.android.cursor.item/event" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.app.shortcuts"
|
android:name="android.app.shortcuts"
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
|
||||||
import de.jeanlucmakiola.floret.components.DebugRibbon
|
import de.jeanlucmakiola.floret.components.DebugRibbon
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
@@ -70,11 +69,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// CalendarHost, which opens it in the create form for review.
|
// CalendarHost, which opens it in the create form for review.
|
||||||
private var requestedInsertForm by mutableStateOf<EventForm?>(null)
|
private var requestedInsertForm by mutableStateOf<EventForm?>(null)
|
||||||
|
|
||||||
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
|
|
||||||
// opens the occurrence in the edit form. Same occurrence-key shape as the
|
|
||||||
// detail channel; consumed once by CalendarHost.
|
|
||||||
private var requestedEditKey by mutableStateOf<LongArray?>(null)
|
|
||||||
|
|
||||||
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||||
// over the calendar on the next launch (the single-crash path). A startup
|
// over the calendar on the next launch (the single-crash path). A startup
|
||||||
// crash-loop is handled out of band, before setContent — see below.
|
// crash-loop is handled out of band, before setContent — see below.
|
||||||
@@ -96,11 +90,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
requestedDetailKey = intent.detailKeyOrNull()
|
||||||
requestedNav = intent.navRequestOrNull()
|
requestedNav = intent.navRequestOrNull()
|
||||||
requestedImportUri = intent.importUriOrNull()
|
requestedImportUri = intent.importUriOrNull()
|
||||||
requestedInsertForm = intent.insertFormOrNull()
|
requestedInsertForm = intent.insertFormOrNull()
|
||||||
requestedEditKey = intent.editEventKeyOrNull()
|
|
||||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
setContent {
|
setContent {
|
||||||
// One activity-scoped SettingsViewModel drives both the theme here
|
// One activity-scoped SettingsViewModel drives both the theme here
|
||||||
@@ -151,8 +144,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
onImportConsumed = { requestedImportUri = null },
|
onImportConsumed = { requestedImportUri = null },
|
||||||
requestedInsertForm = requestedInsertForm,
|
requestedInsertForm = requestedInsertForm,
|
||||||
onInsertConsumed = { requestedInsertForm = null },
|
onInsertConsumed = { requestedInsertForm = null },
|
||||||
requestedEditKey = requestedEditKey,
|
|
||||||
onEditKeyConsumed = { requestedEditKey = null },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// A persistent corner marker so a debug build is never
|
// A persistent corner marker so a debug build is never
|
||||||
@@ -187,11 +178,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
|
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
||||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||||
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
||||||
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -212,19 +202,13 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A prefilled new-event form from an external launch asking us to create an
|
* A prefilled new-event form from an external `ACTION_INSERT` launch — another
|
||||||
* event — another app or widget (e.g. Todo Agenda) firing `ACTION_INSERT`
|
* app or widget (e.g. Todo Agenda) asking us to create an event (issue #30).
|
||||||
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
|
* The new event's fields ride as CalendarContract extras; anything omitted
|
||||||
* event", i.e. create). The new event's fields ride as CalendarContract
|
* falls back to the in-app "new event" defaults in [buildInsertEventForm].
|
||||||
* extras; anything omitted falls back to the in-app "new event" defaults in
|
|
||||||
* [buildInsertEventForm].
|
|
||||||
*/
|
*/
|
||||||
private fun Intent.insertFormOrNull(): EventForm? {
|
private fun Intent.insertFormOrNull(): EventForm? {
|
||||||
// ACTION_EDIT on an existing event routes to the edit form instead
|
if (action != Intent.ACTION_INSERT) return null
|
||||||
// ([editEventKeyOrNull]); only an id-less EDIT is a create.
|
|
||||||
val isCreate = action == Intent.ACTION_INSERT ||
|
|
||||||
(action == Intent.ACTION_EDIT && editEventKeyOrNull() == null)
|
|
||||||
if (!isCreate) return null
|
|
||||||
return buildInsertEventForm(
|
return buildInsertEventForm(
|
||||||
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
||||||
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
|
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
|
||||||
@@ -309,55 +293,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The detail key for an external "open this event" — ACTION_VIEW on
|
|
||||||
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
|
|
||||||
* tapping an existing event in the Todo Agenda widget, issue #48). Reuses the
|
|
||||||
* same occurrence-key channel as reminder taps. The launcher passes the
|
|
||||||
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME` when
|
|
||||||
* it has them; a bare URI omits them, so we carry [NO_OCCURRENCE_TIME] and
|
|
||||||
* [EventDetailViewModel] falls back to the event row's own DTSTART/DTEND
|
|
||||||
* rather than rendering at the epoch.
|
|
||||||
*/
|
|
||||||
private fun Intent.viewEventKeyOrNull(): LongArray? {
|
|
||||||
if (action != Intent.ACTION_VIEW) return null
|
|
||||||
val uri = data ?: return null
|
|
||||||
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
|
||||||
val segments = uri.pathSegments
|
|
||||||
if (segments.firstOrNull() != "events") return null
|
|
||||||
val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
|
||||||
return longArrayOf(
|
|
||||||
eventId,
|
|
||||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
|
|
||||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The occurrence key for an external "edit this event" — `ACTION_EDIT` on
|
|
||||||
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
|
|
||||||
* an assistant, task app, or widget that wants to open the event for editing
|
|
||||||
* rather than viewing). Opens it in the edit form. Reuses the same
|
|
||||||
* occurrence-key channel as reminder/view taps; the caller passes the
|
|
||||||
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME`
|
|
||||||
* when it has them, otherwise we carry [NO_OCCURRENCE_TIME] and
|
|
||||||
* [EventEditViewModel.openForEdit] falls back to the event row's own
|
|
||||||
* DTSTART/DTEND. An id-less `ACTION_EDIT` is a create instead ([insertFormOrNull]).
|
|
||||||
*/
|
|
||||||
private fun Intent.editEventKeyOrNull(): LongArray? {
|
|
||||||
if (action != Intent.ACTION_EDIT) return null
|
|
||||||
val uri = data ?: return null
|
|
||||||
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
|
||||||
val segments = uri.pathSegments
|
|
||||||
if (segments.firstOrNull() != "events") return null
|
|
||||||
val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
|
||||||
return longArrayOf(
|
|
||||||
eventId,
|
|
||||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
|
|
||||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
// The calendar provider's authority/host. A date tap arrives as
|
// The calendar provider's authority/host. A date tap arrives as
|
||||||
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||||
|
|||||||
@@ -495,13 +495,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
return resolver.query(
|
return resolver.query(
|
||||||
uri,
|
uri,
|
||||||
InstanceProjection.COLUMNS,
|
InstanceProjection.COLUMNS,
|
||||||
// Hide cancelled occurrences: "delete only this event" writes a
|
null, null,
|
||||||
// cancelled exception for the one instance (#47). A NULL status is a
|
|
||||||
// normal, un-cancelled event, so it must survive the filter — a bare
|
|
||||||
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
|
|
||||||
"${CalendarContract.Instances.STATUS} IS NULL OR " +
|
|
||||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}",
|
|
||||||
null,
|
|
||||||
CalendarContract.Instances.BEGIN + " ASC",
|
CalendarContract.Instances.BEGIN + " ASC",
|
||||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||||
}
|
}
|
||||||
@@ -1192,25 +1186,15 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
|
|
||||||
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||||
// A cancelled exception row hides exactly this occurrence; the sync
|
// A cancelled exception row hides exactly this occurrence; the sync
|
||||||
// adapter turns it into an EXDATE/cancelled VEVENT upstream. It has to
|
// adapter turns it into an EXDATE/cancelled VEVENT upstream.
|
||||||
// carry the full time set (DTSTART + DURATION + zone), not just STATUS:
|
val values = ContentValues().apply {
|
||||||
// the provider only demotes the cloned exception to a single instance —
|
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, beginMillis)
|
||||||
// clearing the inherited RRULE — when it can derive that instance from
|
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
||||||
// those columns. A STATUS-only cancel left the RRULE standing and
|
}
|
||||||
// cancelled the whole series, wiping every other occurrence (#47), the
|
|
||||||
// same trap the edit path documents (Codeberg #16).
|
|
||||||
val row = querySeriesRow(eventId)
|
|
||||||
val values = buildOccurrenceCancelValues(
|
|
||||||
originalInstanceMillis = beginMillis,
|
|
||||||
dtStartMillis = beginMillis,
|
|
||||||
duration = row.duration,
|
|
||||||
timezone = row.timezone,
|
|
||||||
allDay = row.allDay,
|
|
||||||
)
|
|
||||||
val uri = ContentUris.withAppendedId(
|
val uri = ContentUris.withAppendedId(
|
||||||
CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId,
|
CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId,
|
||||||
)
|
)
|
||||||
resolver.insert(uri, values.toContentValues())
|
resolver.insert(uri, values)
|
||||||
?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis")
|
?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,33 +182,6 @@ internal fun buildOccurrenceExceptionValues(
|
|||||||
putAll(eventColorColumns(form.colorKey, form.color))
|
putAll(eventColorColumns(form.colorKey, form.color))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Column values for a *cancelled*-occurrence exception row ("delete only this
|
|
||||||
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
|
|
||||||
* provider clone the series row and cancel exactly this one instance.
|
|
||||||
*
|
|
||||||
* As with [buildOccurrenceExceptionValues], the occurrence must be anchored with
|
|
||||||
* DTSTART + DURATION so the provider derives a single instance and clears the
|
|
||||||
* inherited RRULE. A STATUS-only cancel skips that: the clone keeps the RRULE, so
|
|
||||||
* the *whole series* is cancelled and every other occurrence disappears
|
|
||||||
* (Codeberg #47). The occurrence's length/zone come straight from the series row
|
|
||||||
* — cancelling never changes them.
|
|
||||||
*/
|
|
||||||
internal fun buildOccurrenceCancelValues(
|
|
||||||
originalInstanceMillis: Long,
|
|
||||||
dtStartMillis: Long,
|
|
||||||
duration: String?,
|
|
||||||
timezone: String?,
|
|
||||||
allDay: Int,
|
|
||||||
): Map<String, Any?> = buildMap {
|
|
||||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, originalInstanceMillis)
|
|
||||||
put(CalendarContract.Events.DTSTART, dtStartMillis)
|
|
||||||
put(CalendarContract.Events.DURATION, duration)
|
|
||||||
put(CalendarContract.Events.EVENT_TIMEZONE, timezone)
|
|
||||||
put(CalendarContract.Events.ALL_DAY, allDay)
|
|
||||||
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
||||||
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
|||||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
|
||||||
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
||||||
@@ -75,8 +74,6 @@ fun CalendarHost(
|
|||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
requestedInsertForm: EventForm? = null,
|
requestedInsertForm: EventForm? = null,
|
||||||
onInsertConsumed: () -> Unit = {},
|
onInsertConsumed: () -> Unit = {},
|
||||||
requestedEditKey: LongArray? = null,
|
|
||||||
onEditKeyConsumed: () -> Unit = {},
|
|
||||||
viewModel: CalendarHostViewModel = hiltViewModel(),
|
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
// Wait for the persisted default view before seeding the stack, so the app
|
// Wait for the persisted default view before seeding the stack, so the app
|
||||||
@@ -175,9 +172,6 @@ fun CalendarHost(
|
|||||||
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
||||||
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
||||||
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
||||||
// Which channel filled [importForm]: an .ics file (prompt to apply the default
|
|
||||||
// reminder) or an ACTION_INSERT intent (apply it automatically) — #49.
|
|
||||||
var importFormSource by remember { mutableStateOf(ImportSource.File) }
|
|
||||||
// A restore (in-app "Restore from .ics" button) always runs the full import
|
// A restore (in-app "Restore from .ics" button) always runs the full import
|
||||||
// flow — picker + summary — even for a single-event file, because the intent
|
// flow — picker + summary — even for a single-event file, because the intent
|
||||||
// is "restore a backup", not "add this one event". An externally opened .ics
|
// is "restore a backup", not "add this one event". An externally opened .ics
|
||||||
@@ -196,7 +190,6 @@ fun CalendarHost(
|
|||||||
// reveals on top of whatever was open without extra dismissal.
|
// reveals on top of whatever was open without extra dismissal.
|
||||||
LaunchedEffect(requestedInsertForm) {
|
LaunchedEffect(requestedInsertForm) {
|
||||||
if (requestedInsertForm != null) {
|
if (requestedInsertForm != null) {
|
||||||
importFormSource = ImportSource.Insert
|
|
||||||
importForm = requestedInsertForm
|
importForm = requestedInsertForm
|
||||||
onInsertConsumed()
|
onInsertConsumed()
|
||||||
}
|
}
|
||||||
@@ -214,20 +207,6 @@ fun CalendarHost(
|
|||||||
importForm = null
|
importForm = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
|
|
||||||
// widget) opens the occurrence straight in the edit form. Drop any covering
|
|
||||||
// overlay first — the edit overlay sits below Settings/import in the Box, so
|
|
||||||
// without this it would open hidden underneath them. Same held-key pattern as
|
|
||||||
// a detail-screen "Edit" tap; a saved edit just returns to the calendar.
|
|
||||||
LaunchedEffect(requestedEditKey) {
|
|
||||||
if (requestedEditKey != null) {
|
|
||||||
dismissCoveringOverlays()
|
|
||||||
heldEditKey = requestedEditKey
|
|
||||||
editKey = requestedEditKey
|
|
||||||
onEditKeyConsumed()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A home-screen widget launch asks to open a date (→ day view), open an
|
// A home-screen widget launch asks to open a date (→ day view), open an
|
||||||
// event's detail, or start a create. Handled once and cleared, mirroring
|
// event's detail, or start a create. Handled once and cleared, mirroring
|
||||||
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
||||||
@@ -456,7 +435,6 @@ fun CalendarHost(
|
|||||||
onClose = { importUri = null },
|
onClose = { importUri = null },
|
||||||
onOpenSingle = { form ->
|
onOpenSingle = { form ->
|
||||||
importUri = null
|
importUri = null
|
||||||
importFormSource = ImportSource.File
|
|
||||||
importForm = form
|
importForm = form
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -465,7 +443,6 @@ fun CalendarHost(
|
|||||||
EventEditScreen(
|
EventEditScreen(
|
||||||
initialDateIso = null,
|
initialDateIso = null,
|
||||||
initialForm = form,
|
initialForm = form,
|
||||||
initialFormSource = importFormSource,
|
|
||||||
onClose = { importForm = null },
|
onClose = { importForm = null },
|
||||||
onSaved = { importForm = null },
|
onSaved = { importForm = null },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ fun RootScreen(
|
|||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
||||||
onInsertConsumed: () -> Unit = {},
|
onInsertConsumed: () -> Unit = {},
|
||||||
requestedEditKey: LongArray? = null,
|
|
||||||
onEditKeyConsumed: () -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var hasPermission by remember {
|
var hasPermission by remember {
|
||||||
@@ -90,8 +88,6 @@ fun RootScreen(
|
|||||||
onImportConsumed = onImportConsumed,
|
onImportConsumed = onImportConsumed,
|
||||||
requestedInsertForm = requestedInsertForm,
|
requestedInsertForm = requestedInsertForm,
|
||||||
onInsertConsumed = onInsertConsumed,
|
onInsertConsumed = onInsertConsumed,
|
||||||
requestedEditKey = requestedEditKey,
|
|
||||||
onEditKeyConsumed = onEditKeyConsumed,
|
|
||||||
)
|
)
|
||||||
false -> ReminderOnboardingScreen(
|
false -> ReminderOnboardingScreen(
|
||||||
onFinished = reminderOnboarding::finish,
|
onFinished = reminderOnboarding::finish,
|
||||||
|
|||||||
@@ -141,20 +141,13 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||||
val detail = repository.eventDetail(target.eventId)
|
val detail = repository.eventDetail(target.eventId)
|
||||||
// The Events row holds the series start; replace it with this
|
// The Events row holds the series start; replace it with this
|
||||||
// occurrence's time so recurring events render correctly. An external
|
// occurrence's time so recurring events render correctly.
|
||||||
// "open event" that names no occurrence ([NO_OCCURRENCE_TIME] — e.g. a
|
val corrected = detail.copy(
|
||||||
// bare content://.../events/<id> VIEW intent, issue #48) keeps the row's
|
instance = detail.instance.copy(
|
||||||
// own DTSTART/DTEND instead of overriding it to the epoch.
|
start = Instant.fromEpochMilliseconds(target.beginMillis),
|
||||||
val corrected = if (target.beginMillis == NO_OCCURRENCE_TIME) {
|
end = Instant.fromEpochMilliseconds(target.endMillis),
|
||||||
detail
|
),
|
||||||
} else {
|
)
|
||||||
detail.copy(
|
|
||||||
instance = detail.instance.copy(
|
|
||||||
start = Instant.fromEpochMilliseconds(target.beginMillis),
|
|
||||||
end = Instant.fromEpochMilliseconds(target.endMillis),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val calendar = repository.calendars().first()
|
val calendar = repository.calendars().first()
|
||||||
.firstOrNull { it.id == corrected.instance.calendarId }
|
.firstOrNull { it.id == corrected.instance.calendarId }
|
||||||
EventDetailUiState.Success(
|
EventDetailUiState.Success(
|
||||||
@@ -175,16 +168,6 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
||||||
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
|
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
|
||||||
|
|
||||||
companion object {
|
|
||||||
/**
|
|
||||||
* Sentinel begin/end for an "open this event" that names no occurrence —
|
|
||||||
* a bare `content://com.android.calendar/events/<id>` VIEW intent with no
|
|
||||||
* `EXTRA_EVENT_BEGIN_TIME` (issue #48). [loadDetail] then keeps the event
|
|
||||||
* row's own DTSTART/DTEND instead of overriding it to the epoch.
|
|
||||||
*/
|
|
||||||
const val NO_OCCURRENCE_TIME: Long = Long.MIN_VALUE
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
|
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ import androidx.compose.ui.graphics.isSpecified
|
|||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
@@ -183,14 +182,12 @@ fun EventEditScreen(
|
|||||||
editKey: LongArray? = null,
|
editKey: LongArray? = null,
|
||||||
initialStartMinutes: Int? = null,
|
initialStartMinutes: Int? = null,
|
||||||
initialForm: EventForm? = null,
|
initialForm: EventForm? = null,
|
||||||
initialFormSource: ImportSource = ImportSource.File,
|
|
||||||
viewModel: EventEditViewModel = hiltViewModel(),
|
viewModel: EventEditViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
||||||
when {
|
when {
|
||||||
// A prefilled open: a single-event .ics for review, or an external
|
// Single-event .ics open: the form arrives prefilled for review.
|
||||||
// ACTION_INSERT intent. The source drives how reminders are seeded.
|
initialForm != null -> viewModel.openImported(initialForm)
|
||||||
initialForm != null -> viewModel.openImported(initialForm, initialFormSource)
|
|
||||||
editKey != null -> viewModel.openForEdit(
|
editKey != null -> viewModel.openForEdit(
|
||||||
eventId = editKey[0],
|
eventId = editKey[0],
|
||||||
beginMillis = editKey[1],
|
beginMillis = editKey[1],
|
||||||
@@ -205,7 +202,6 @@ fun EventEditScreen(
|
|||||||
}
|
}
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val loadFailed by viewModel.loadFailed.collectAsStateWithLifecycle()
|
val loadFailed by viewModel.loadFailed.collectAsStateWithLifecycle()
|
||||||
val importReminderPrompt by viewModel.importReminderPrompt.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
// The form is intentionally forgotten on every close (cancel or save) so
|
// The form is intentionally forgotten on every close (cancel or save) so
|
||||||
// the next open starts clean; it survives rotation because openNew /
|
// the next open starts clean; it survives rotation because openNew /
|
||||||
@@ -336,56 +332,6 @@ fun EventEditScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A .ics import respects the file's reminders, but offers to swap in the
|
|
||||||
// configured default rather than silently deciding for the user (#49).
|
|
||||||
importReminderPrompt?.let { prompt ->
|
|
||||||
ImportReminderPromptDialog(
|
|
||||||
currentReminderCount = prompt.currentReminderCount,
|
|
||||||
onApply = viewModel::applyImportedReminderDefault,
|
|
||||||
onKeep = viewModel::dismissImportedReminderPrompt,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Offer to apply the settings default reminder to an event opened from a `.ics`
|
|
||||||
* file. The file's own reminders are kept unless the user accepts. A plain
|
|
||||||
* two-choice confirmation, so an [AlertDialog] (not a full-screen picker).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun ImportReminderPromptDialog(
|
|
||||||
currentReminderCount: Int,
|
|
||||||
onApply: () -> Unit,
|
|
||||||
onKeep: () -> Unit,
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onKeep,
|
|
||||||
title = { Text(stringResource(R.string.import_reminder_prompt_title)) },
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
if (currentReminderCount == 0) {
|
|
||||||
stringResource(R.string.import_reminder_prompt_body_none)
|
|
||||||
} else {
|
|
||||||
pluralStringResource(
|
|
||||||
R.plurals.import_reminder_prompt_body_existing,
|
|
||||||
currentReminderCount,
|
|
||||||
currentReminderCount,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onApply) {
|
|
||||||
Text(stringResource(R.string.import_reminder_prompt_apply))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onKeep) {
|
|
||||||
Text(stringResource(R.string.import_reminder_prompt_keep))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
|||||||
import de.jeanlucmakiola.calendula.domain.populatedFields
|
import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||||
import de.jeanlucmakiola.calendula.domain.problems
|
import de.jeanlucmakiola.calendula.domain.problems
|
||||||
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -51,36 +50,6 @@ import kotlin.time.Duration.Companion.hours
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
|
||||||
* Where a prefilled [EventEditViewModel.openImported] form came from — the two
|
|
||||||
* sources want different reminder handling (#49).
|
|
||||||
*/
|
|
||||||
enum class ImportSource {
|
|
||||||
/**
|
|
||||||
* An external `ACTION_INSERT` intent (another app/widget, e.g. Google Maps).
|
|
||||||
* It carries no reminders of its own, so the settings default is applied
|
|
||||||
* automatically, exactly like an in-app new event.
|
|
||||||
*/
|
|
||||||
Insert,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A parsed single-event `.ics` file. Its own reminders are respected; the
|
|
||||||
* settings default is offered through [EventEditViewModel.importReminderPrompt]
|
|
||||||
* rather than silently applied or suppressed.
|
|
||||||
*/
|
|
||||||
File,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A pending offer to swap an imported `.ics` event's reminders for the settings
|
|
||||||
* default (#49). [currentReminderCount] is what the file carried (0 or more);
|
|
||||||
* [defaultReminders] is what accepting would set.
|
|
||||||
*/
|
|
||||||
data class ImportReminderPrompt(
|
|
||||||
val currentReminderCount: Int,
|
|
||||||
val defaultReminders: List<Int>,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds the event form being composed. The form's calendar id resolves to
|
* Holds the event form being composed. The form's calendar id resolves to
|
||||||
* (user pick > last used > first writable); the resolved value is what the UI
|
* (user pick > last used > first writable); the resolved value is what the UI
|
||||||
@@ -109,16 +78,10 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// freezes the auto-applied default: switching calendars no longer overwrites
|
// freezes the auto-applied default: switching calendars no longer overwrites
|
||||||
// their choice. Reset with the form.
|
// their choice. Reset with the form.
|
||||||
private val _remindersTouched = MutableStateFlow(false)
|
private val _remindersTouched = MutableStateFlow(false)
|
||||||
// A one-time offer, raised when a .ics import opens, to replace the file's
|
|
||||||
// reminders with the settings default (#49). Null while there's nothing to ask.
|
|
||||||
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
|
|
||||||
|
|
||||||
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
||||||
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
||||||
|
|
||||||
/** Pending "apply your default reminder?" offer for a `.ics` import; null when none. */
|
|
||||||
val importReminderPrompt: StateFlow<ImportReminderPrompt?> = _importReminderPrompt.asStateFlow()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event being edited plus everything the form saw at load time.
|
* The event being edited plus everything the form saw at load time.
|
||||||
* For recurring events the write scope is chosen at save time; the
|
* For recurring events the write scope is chosen at save time; the
|
||||||
@@ -149,17 +112,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val allDay: List<Int>,
|
val allDay: List<Int>,
|
||||||
val timedOverrides: Map<Long, List<Int>>,
|
val timedOverrides: Map<Long, List<Int>>,
|
||||||
val allDayOverrides: Map<Long, List<Int>>,
|
val allDayOverrides: Map<Long, List<Int>>,
|
||||||
) {
|
)
|
||||||
/** The default reminders for an event on [calendarId] of the given kind. */
|
|
||||||
fun resolveFor(calendarId: Long?, isAllDay: Boolean): List<Int> = resolveDefaultReminder(
|
|
||||||
timedGlobal = timed,
|
|
||||||
allDayGlobal = allDay,
|
|
||||||
timedOverrides = timedOverrides,
|
|
||||||
allDayOverrides = allDayOverrides,
|
|
||||||
calendarId = calendarId,
|
|
||||||
isAllDay = isAllDay,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class ExternalInputs(
|
private data class ExternalInputs(
|
||||||
val writable: List<CalendarSource>,
|
val writable: List<CalendarSource>,
|
||||||
@@ -288,39 +241,18 @@ class EventEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed a fresh event from a prefilled [form] — a parsed single-event `.ics`
|
* Seed a fresh event from a parsed `.ics` file (the single-event "open into
|
||||||
* file ([ImportSource.File]) or an external `ACTION_INSERT` intent (another
|
* the create form" path). [form] already carries the file's fields; its
|
||||||
* app/widget creating an event, e.g. Google Maps' "add to calendar";
|
* [EventForm.calendarId] is null so the calendar still resolves to the
|
||||||
* [ImportSource.Insert]; #30, #49). [EventForm.calendarId] is null so the
|
* last-used/first-writable one, and reminders are frozen as touched so the
|
||||||
* calendar still resolves to the last-used/first-writable one.
|
* settings default never overwrites what the file specified. No-op when a
|
||||||
*
|
* form is already open, so the prefill survives configuration changes.
|
||||||
* Reminders are handled per [source], because the two paths mean different
|
|
||||||
* things by "no reminders":
|
|
||||||
* - [ImportSource.Insert] carries no reminder semantics, so the settings
|
|
||||||
* default is applied automatically like an in-app new event (a form that
|
|
||||||
* somehow did carry reminders keeps them, frozen).
|
|
||||||
* - [ImportSource.File] owns its reminders, so they're frozen as-is; if a
|
|
||||||
* settings default is configured and differs, [importReminderPrompt] offers
|
|
||||||
* to swap it in rather than silently deciding for the user.
|
|
||||||
*
|
|
||||||
* No-op when a form is already open, so the prefill survives configuration
|
|
||||||
* changes.
|
|
||||||
*/
|
*/
|
||||||
fun openImported(form: EventForm, source: ImportSource) {
|
fun openImported(form: EventForm) {
|
||||||
if (_form.value != null || _editTarget.value != null) return
|
if (_form.value != null || _editTarget.value != null) return
|
||||||
|
_remindersTouched.value = true
|
||||||
_revealed.value = form.populatedFields()
|
_revealed.value = form.populatedFields()
|
||||||
_form.value = form
|
_form.value = form
|
||||||
when (source) {
|
|
||||||
ImportSource.Insert ->
|
|
||||||
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
|
|
||||||
else applyDefaultReminder()
|
|
||||||
|
|
||||||
ImportSource.File -> {
|
|
||||||
// Respect the file's own reminders; never silently overwrite them.
|
|
||||||
_remindersTouched.value = true
|
|
||||||
maybePromptImportedReminderDefault(form)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -334,12 +266,26 @@ class EventEditViewModel @Inject constructor(
|
|||||||
private fun applyDefaultReminder(calendarId: Long? = null) {
|
private fun applyDefaultReminder(calendarId: Long? = null) {
|
||||||
if (_editTarget.value != null || _remindersTouched.value) return
|
if (_editTarget.value != null || _remindersTouched.value) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val defaults = reminderDefaults()
|
val defaults = combine(
|
||||||
|
settingsPrefs.defaultReminderMinutes,
|
||||||
|
settingsPrefs.defaultAllDayReminderMinutes,
|
||||||
|
settingsPrefs.perCalendarReminderOverride,
|
||||||
|
settingsPrefs.perCalendarAllDayReminderOverride,
|
||||||
|
) { timed, allDay, timedOv, allDayOv ->
|
||||||
|
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
||||||
|
}.first()
|
||||||
val targetId = calendarId ?: resolvedCalendarId.first()
|
val targetId = calendarId ?: resolvedCalendarId.first()
|
||||||
// Re-check after suspending: bail if the form closed or the user edited.
|
// Re-check after suspending: bail if the form closed or the user edited.
|
||||||
val form = _form.value ?: return@launch
|
val form = _form.value ?: return@launch
|
||||||
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||||
val reminders = defaults.resolveFor(targetId, form.isAllDay)
|
val reminders = resolveDefaultReminder(
|
||||||
|
timedGlobal = defaults.timed,
|
||||||
|
allDayGlobal = defaults.allDay,
|
||||||
|
timedOverrides = defaults.timedOverrides,
|
||||||
|
allDayOverrides = defaults.allDayOverrides,
|
||||||
|
calendarId = targetId,
|
||||||
|
isAllDay = form.isAllDay,
|
||||||
|
)
|
||||||
_form.value = form.copy(reminders = reminders)
|
_form.value = form.copy(reminders = reminders)
|
||||||
// Surface the section so an auto-applied default is visible and
|
// Surface the section so an auto-applied default is visible and
|
||||||
// removable, even when Reminders isn't a default-shown field.
|
// removable, even when Reminders isn't a default-shown field.
|
||||||
@@ -349,60 +295,10 @@ class EventEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Snapshot the four settings-default reminder flows into one value. */
|
|
||||||
private suspend fun reminderDefaults(): ReminderDefaults = combine(
|
|
||||||
settingsPrefs.defaultReminderMinutes,
|
|
||||||
settingsPrefs.defaultAllDayReminderMinutes,
|
|
||||||
settingsPrefs.perCalendarReminderOverride,
|
|
||||||
settingsPrefs.perCalendarAllDayReminderOverride,
|
|
||||||
) { timed, allDay, timedOv, allDayOv ->
|
|
||||||
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
|
||||||
}.first()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A `.ics` import respects the file's reminders, but an event opened from a
|
|
||||||
* file often has none while the user still expects their configured default.
|
|
||||||
* Rather than silently deciding, raise a one-time offer to swap in the
|
|
||||||
* settings default — but only when there's a real choice: a default is
|
|
||||||
* configured and it isn't already exactly what the file carried.
|
|
||||||
*/
|
|
||||||
private fun maybePromptImportedReminderDefault(form: EventForm) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val targetId = resolvedCalendarId.first()
|
|
||||||
val default = reminderDefaults().resolveFor(targetId, form.isAllDay)
|
|
||||||
// Bail if the form closed or became an edit while we resolved.
|
|
||||||
val current = _form.value ?: return@launch
|
|
||||||
if (_editTarget.value != null) return@launch
|
|
||||||
if (default.isEmpty() || default == current.reminders) return@launch
|
|
||||||
_importReminderPrompt.value = ImportReminderPrompt(
|
|
||||||
currentReminderCount = current.reminders.size,
|
|
||||||
defaultReminders = default,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Accept the import prompt: replace the file's reminders with the default. */
|
|
||||||
fun applyImportedReminderDefault() {
|
|
||||||
val prompt = _importReminderPrompt.value ?: return
|
|
||||||
_importReminderPrompt.value = null
|
|
||||||
// Already frozen as touched by openImported; this just swaps the values.
|
|
||||||
update { it.copy(reminders = prompt.defaultReminders) }
|
|
||||||
_revealed.value = _revealed.value + EventFormField.Reminders
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Decline the import prompt: keep the file's own reminders untouched. */
|
|
||||||
fun dismissImportedReminderPrompt() {
|
|
||||||
_importReminderPrompt.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load an existing event into the form. [beginMillis]/[endMillis] are the
|
* Load an existing event into the form. [beginMillis]/[endMillis] are the
|
||||||
* tapped occurrence's own times, like on the detail screen. An external
|
* tapped occurrence's own times, like on the detail screen. No-op while a
|
||||||
* "edit this event" (`ACTION_EDIT`) that names no occurrence passes
|
* form is open, so user edits survive configuration changes.
|
||||||
* [NO_OCCURRENCE_TIME]; the row's own DTSTART/DTEND is used then, so the
|
|
||||||
* form loads the event's real times instead of the epoch (mirrors the
|
|
||||||
* detail screen's #48 fallback). No-op while a form is open, so user edits
|
|
||||||
* survive configuration changes.
|
|
||||||
*/
|
*/
|
||||||
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
|
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
|
||||||
if (_form.value != null || _editTarget.value != null) return
|
if (_form.value != null || _editTarget.value != null) return
|
||||||
@@ -416,12 +312,8 @@ class EventEditViewModel @Inject constructor(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val zone = TimeZone.currentSystemDefault()
|
val zone = TimeZone.currentSystemDefault()
|
||||||
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
val snapshot = detail.toEditSnapshot(beginMillis, endMillis, zone)
|
||||||
?: detail.instance.start.toEpochMilliseconds()
|
_editTarget.value = EditTarget(eventId, snapshot, beginMillis, endMillis, zone)
|
||||||
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
|
||||||
?: detail.instance.end.toEpochMilliseconds()
|
|
||||||
val snapshot = detail.toEditSnapshot(begin, end, zone)
|
|
||||||
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone)
|
|
||||||
// Sections holding data must show even when not in the defaults.
|
// Sections holding data must show even when not in the defaults.
|
||||||
_revealed.value = snapshot.form.populatedFields()
|
_revealed.value = snapshot.form.populatedFields()
|
||||||
_form.value = snapshot.form
|
_form.value = snapshot.form
|
||||||
@@ -437,7 +329,6 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_editTarget.value = null
|
_editTarget.value = null
|
||||||
_loadFailed.value = false
|
_loadFailed.value = false
|
||||||
_remindersTouched.value = false
|
_remindersTouched.value = false
|
||||||
_importReminderPrompt.value = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unfold one optional field, picked in the "more fields" dialog. */
|
/** Unfold one optional field, picked in the "more fields" dialog. */
|
||||||
|
|||||||
@@ -310,7 +310,7 @@
|
|||||||
<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>
|
||||||
<!-- Hub category subtitles -->
|
<!-- Hub category subtitles -->
|
||||||
<string name="settings_appearance_subtitle">Design, Standartansicht, Wochenstart</string>
|
<string name="settings_appearance_subtitle">Design, Standardansicht, Wochenstart</string>
|
||||||
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
|
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
|
||||||
<string name="settings_notifications_subtitle">Termin-Erinnerungen</string>
|
<string name="settings_notifications_subtitle">Termin-Erinnerungen</string>
|
||||||
<string name="settings_section_about">Über</string>
|
<string name="settings_section_about">Über</string>
|
||||||
@@ -474,4 +474,20 @@
|
|||||||
<string name="special_dates_default_title_birthday">Geburtstag von {name} ({year})</string>
|
<string name="special_dates_default_title_birthday">Geburtstag von {name} ({year})</string>
|
||||||
<string name="special_dates_default_title_anniversary">Jahrestag von {name} ({year})</string>
|
<string name="special_dates_default_title_anniversary">Jahrestag von {name} ({year})</string>
|
||||||
<string name="special_dates_default_title_custom">{name}</string>
|
<string name="special_dates_default_title_custom">{name}</string>
|
||||||
|
<string name="settings_week_numbers">Kalenderwochen</string>
|
||||||
|
<string name="settings_week_numbers_summary">Kalenderwochen der Monatsansicht anzeigen</string>
|
||||||
|
<string name="calendars_export_title">Kalender exportieren</string>
|
||||||
|
<string name="calendars_export_hint">Wähle aus, welche Kalender in die .ics-Datei aufgenommen werden sollen.</string>
|
||||||
|
<string name="calendars_export_action">Exportieren</string>
|
||||||
|
<string name="calendars_restore_header">Wiederherstellen</string>
|
||||||
|
<string name="calendars_restore_action">Aus .ics-Datei wiederherstellen</string>
|
||||||
|
<string name="calendars_restore_hint">Importiere Ereignisse aus einem Backup oder einer anderen Kalender-App.</string>
|
||||||
|
<string name="import_done_dedup_note">Bereits im Kalender vorhandene Ereignisse wurden übersprungen.</string>
|
||||||
|
<string name="import_done_added_label">Hinzugefügt</string>
|
||||||
|
<string name="import_done_skipped_label">Duplikate</string>
|
||||||
|
<string name="import_button">Importieren</string>
|
||||||
|
<plurals name="import_title_count">
|
||||||
|
<item quantity="one">%d Ereignis wird importiert</item>
|
||||||
|
<item quantity="other">%d Ereignisse werden importiert</item>
|
||||||
|
</plurals>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -16,17 +16,17 @@
|
|||||||
<string name="state_failure_no_calendars">Nessun calendario configurato.</string>
|
<string name="state_failure_no_calendars">Nessun calendario configurato.</string>
|
||||||
<string name="state_failure_no_calendars_action">Apri le impostazioni calendario di sistema</string>
|
<string name="state_failure_no_calendars_action">Apri le impostazioni calendario di sistema</string>
|
||||||
<string name="state_failure_provider">Errore nella lettura del calendario.</string>
|
<string name="state_failure_provider">Errore nella lettura del calendario.</string>
|
||||||
<string name="permission_rationale_title">Guarda tutti i tuoi eventi, magnificamente</string>
|
<string name="permission_rationale_title">Gestisci magnificamente tutti i tuoi eventi</string>
|
||||||
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa che ti chiederà.</string>
|
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa di cui ha bisogno e nessuna informazione lascerà mai il tuo dispositivo.</string>
|
||||||
<string name="permission_request_button">Concedi l\'accesso al calendario</string>
|
<string name="permission_request_button">Concedi l\'accesso al calendario</string>
|
||||||
<string name="permission_denied_title">Accesso al calendario negato</string>
|
<string name="permission_denied_title">Accesso al calendario negato</string>
|
||||||
<string name="permission_denied_body">Calendula non può mostrare gli eventi senza accesso al calendario. Puoi concederlo dalle impostazioni di sistema.</string>
|
<string name="permission_denied_body">Calendula non può mostrare gli eventi senza accesso al calendario. Puoi concederlo dalle impostazioni di sistema.</string>
|
||||||
<string name="permission_open_settings_button">Apri le impostazioni di sistema</string>
|
<string name="permission_open_settings_button">Apri le impostazioni di sistema</string>
|
||||||
<string name="permission_retry_button">Riprova</string>
|
<string name="permission_retry_button">Riprova</string>
|
||||||
<string name="permission_benefit_private_title">Rimane sul tuo dispositivo</string>
|
<string name="permission_benefit_private_title">Rimane sul tuo dispositivo</string>
|
||||||
<string name="permission_benefit_private_body">I tuoi calendari sono letti localmente e non lasciano mai il tuo telefono.</string>
|
<string name="permission_benefit_private_body">I tuoi calendari sono letti localmente e non lasciano mai il tuo dispositivo.</string>
|
||||||
<string name="permission_benefit_sync_title">Tutti i tuoi calendari, insieme</string>
|
<string name="permission_benefit_sync_title">Tutti i tuoi calendari, insieme</string>
|
||||||
<string name="permission_benefit_sync_body">Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo semplicemente compare.</string>
|
<string name="permission_benefit_sync_body">Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo, semplicemente, compare.</string>
|
||||||
<string name="permission_benefit_privacy_title">Nessun tracking, per sempre</string>
|
<string name="permission_benefit_privacy_title">Nessun tracking, per sempre</string>
|
||||||
<string name="permission_benefit_privacy_body">Zero telemetria, zero analytics, zero pubblicità.</string>
|
<string name="permission_benefit_privacy_body">Zero telemetria, zero analytics, zero pubblicità.</string>
|
||||||
<string name="permission_privacy_footnote">Rimane sul tuo dispositivo - nessun accesso a internet</string>
|
<string name="permission_privacy_footnote">Rimane sul tuo dispositivo - nessun accesso a internet</string>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
<string name="month_action_settings">Impostazioni</string>
|
<string name="month_action_settings">Impostazioni</string>
|
||||||
<string name="month_a11y_today_prefix">Oggi</string>
|
<string name="month_a11y_today_prefix">Oggi</string>
|
||||||
<string name="week_today_action">Questa settimana</string>
|
<string name="week_today_action">Questa settimana</string>
|
||||||
<string name="week_number_label">Wk</string>
|
<string name="week_number_label">Sett.</string>
|
||||||
<string name="day_today_action">Oggi</string>
|
<string name="day_today_action">Oggi</string>
|
||||||
<string name="event_detail_back">Indietro</string>
|
<string name="event_detail_back">Indietro</string>
|
||||||
<string name="event_detail_edit">Modifica</string>
|
<string name="event_detail_edit">Modifica</string>
|
||||||
@@ -73,14 +73,14 @@
|
|||||||
<string name="event_edit_remove_reminder">Rimuovi promemoria</string>
|
<string name="event_edit_remove_reminder">Rimuovi promemoria</string>
|
||||||
<string name="event_edit_attendees">Partecipanti</string>
|
<string name="event_edit_attendees">Partecipanti</string>
|
||||||
<string name="event_edit_add_guest">Aggiungi partecipante</string>
|
<string name="event_edit_add_guest">Aggiungi partecipante</string>
|
||||||
<string name="event_edit_add_guest_hint">Aggiungi un partecipante via email…</string>
|
<string name="event_edit_add_guest_hint">Aggiungi un partecipante tramite indirizzo email…</string>
|
||||||
<string name="event_edit_add_guest_from_contacts">Aggiungi dai contatti</string>
|
<string name="event_edit_add_guest_from_contacts">Aggiungi dai contatti</string>
|
||||||
<string name="event_edit_location_from_contacts">Scegli un indirizzo dai contatti</string>
|
<string name="event_edit_location_from_contacts">Scegli un indirizzo dai contatti</string>
|
||||||
<string name="event_edit_remove_guest">Rimuovi partecipante</string>
|
<string name="event_edit_remove_guest">Rimuovi partecipante</string>
|
||||||
<string name="event_edit_attendee_required">Obbligatorio</string>
|
<string name="event_edit_attendee_required">Obbligatorio</string>
|
||||||
<string name="event_edit_attendee_optional">Facoltativo</string>
|
<string name="event_edit_attendee_optional">Facoltativo</string>
|
||||||
<string name="event_edit_attendees_note_synced">Calendula non manda gli inviti agli eventi. Il tuo account calendario potrà inviare le email ai partecipanti al momento della sincronizzazione.</string>
|
<string name="event_edit_attendees_note_synced">Calendula non manda gli inviti agli eventi. Il tuo account calendario potrà inviare le email ai partecipanti al momento della sincronizzazione.</string>
|
||||||
<string name="event_edit_attendees_note_local">Salvato sul dispositivo. Nessuno ha ricevuto notifiche.</string>
|
<string name="event_edit_attendees_note_local">Salvato sul dispositivo. Nessuno dei partecipanti ha ricevuto notifiche.</string>
|
||||||
<string name="event_edit_reminder_custom">Personalizza</string>
|
<string name="event_edit_reminder_custom">Personalizza</string>
|
||||||
<string name="reminder_unit_minutes">minuti</string>
|
<string name="reminder_unit_minutes">minuti</string>
|
||||||
<string name="reminder_unit_hours">ore</string>
|
<string name="reminder_unit_hours">ore</string>
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
<string name="event_edit_availability">Disponibilità</string>
|
<string name="event_edit_availability">Disponibilità</string>
|
||||||
<string name="event_edit_visibility">Visibilità</string>
|
<string name="event_edit_visibility">Visibilità</string>
|
||||||
<string name="event_edit_color">Colore</string>
|
<string name="event_edit_color">Colore</string>
|
||||||
<string name="event_edit_color_default">Colore calendario</string>
|
<string name="event_edit_color_default">Colore del calendario</string>
|
||||||
<string name="event_edit_color_custom">Colore personalizzato</string>
|
<string name="event_edit_color_custom">Colore personalizzato</string>
|
||||||
<string name="event_edit_color_reset">Reset</string>
|
<string name="event_edit_color_reset">Reset</string>
|
||||||
<string name="event_edit_color_unsupported">Non disponibile per questo calendario</string>
|
<string name="event_edit_color_unsupported">Non disponibile per questo calendario</string>
|
||||||
@@ -99,13 +99,13 @@
|
|||||||
<string name="event_edit_conflict_body">Durante la modifica l\'evento è stato cambiato, tramite sincronizzazione o tramite altra app. Cosa fare con le modifiche in corso?</string>
|
<string name="event_edit_conflict_body">Durante la modifica l\'evento è stato cambiato, tramite sincronizzazione o tramite altra app. Cosa fare con le modifiche in corso?</string>
|
||||||
<string name="event_edit_conflict_overwrite">Salva le modifiche</string>
|
<string name="event_edit_conflict_overwrite">Salva le modifiche</string>
|
||||||
<string name="event_edit_conflict_overwrite_hint">Sovrascrivi solo i campi modificati</string>
|
<string name="event_edit_conflict_overwrite_hint">Sovrascrivi solo i campi modificati</string>
|
||||||
<string name="event_edit_conflict_discard">Annulla le mie modifiche</string>
|
<string name="event_edit_conflict_discard">Annulla le modifiche</string>
|
||||||
<string name="event_edit_conflict_discard_hint">Mantieni l\'evento com\'è ora</string>
|
<string name="event_edit_conflict_discard_hint">Lascia l\'evento invariato</string>
|
||||||
<string name="event_edit_gone_title">Evento eliminato</string>
|
<string name="event_edit_gone_title">Evento eliminato</string>
|
||||||
<string name="event_edit_gone_body">Nel frattempo, questo evento è stato eliminato, ad esempio su un altro dispositivo. Le modifiche non possono più essere salvate.</string>
|
<string name="event_edit_gone_body">Nel frattempo, questo evento è stato eliminato, ad esempio su un altro dispositivo. Le modifiche non possono più essere salvate.</string>
|
||||||
<string name="event_edit_recurrence_none">Non si ripete</string>
|
<string name="event_edit_recurrence_none">Non si ripete</string>
|
||||||
<string name="event_edit_recurrence_custom">Personalizza</string>
|
<string name="event_edit_recurrence_custom">Personalizza</string>
|
||||||
<string name="event_edit_recurrence_every">La serie</string>
|
<string name="event_edit_recurrence_every">Ogni</string>
|
||||||
<string name="recurrence_unit_days">giorni</string>
|
<string name="recurrence_unit_days">giorni</string>
|
||||||
<string name="recurrence_unit_weeks">settimane</string>
|
<string name="recurrence_unit_weeks">settimane</string>
|
||||||
<string name="recurrence_unit_months">mesi</string>
|
<string name="recurrence_unit_months">mesi</string>
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
<string name="event_edit_recurrence_ends">Fine</string>
|
<string name="event_edit_recurrence_ends">Fine</string>
|
||||||
<string name="event_edit_recurrence_end_never">Mai</string>
|
<string name="event_edit_recurrence_end_never">Mai</string>
|
||||||
<string name="event_edit_recurrence_end_until">Fino alla data</string>
|
<string name="event_edit_recurrence_end_until">Fino alla data</string>
|
||||||
<string name="event_edit_recurrence_end_count">Dopo un certo numero di occorrenze</string>
|
<string name="event_edit_recurrence_end_count">Numero di occorrenze</string>
|
||||||
<string name="event_edit_recurrence_times">occorrenze</string>
|
<string name="event_edit_recurrence_times">occorrenze</string>
|
||||||
<string name="event_edit_error_recurrence_ends_before_start">Fine antecedente l\'inizio</string>
|
<string name="event_edit_error_recurrence_ends_before_start">Fine antecedente l\'inizio</string>
|
||||||
<string name="event_availability_busy">Occupato</string>
|
<string name="event_availability_busy">Occupato</string>
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
<string name="event_detail_location">Luogo</string>
|
<string name="event_detail_location">Luogo</string>
|
||||||
<string name="event_detail_description">Descrizione</string>
|
<string name="event_detail_description">Descrizione</string>
|
||||||
<string name="event_detail_attendees">Partecipanti</string>
|
<string name="event_detail_attendees">Partecipanti</string>
|
||||||
<string name="recurrence_daily">Ogni giorni</string>
|
<string name="recurrence_daily">Ogni giorno</string>
|
||||||
<string name="recurrence_weekly">Ogni settimana</string>
|
<string name="recurrence_weekly">Ogni settimana</string>
|
||||||
<string name="recurrence_monthly">Ogni mese</string>
|
<string name="recurrence_monthly">Ogni mese</string>
|
||||||
<string name="recurrence_yearly">Ogni anno</string>
|
<string name="recurrence_yearly">Ogni anno</string>
|
||||||
@@ -204,7 +204,7 @@
|
|||||||
<string name="reminder_benefit_reversible_body">Lo switch è nelle Impostazioni, sezione Notifiche.</string>
|
<string name="reminder_benefit_reversible_body">Lo switch è nelle Impostazioni, sezione Notifiche.</string>
|
||||||
<string name="reminder_onboarding_enable_button">Attiva i promemoria</string>
|
<string name="reminder_onboarding_enable_button">Attiva i promemoria</string>
|
||||||
<string name="reminder_onboarding_skip_button">Non ora</string>
|
<string name="reminder_onboarding_skip_button">Non ora</string>
|
||||||
<string name="reminder_action_snooze">Posponi</string>
|
<string name="reminder_action_snooze">Posticipa</string>
|
||||||
<string name="reminder_action_dismiss">Ignora</string>
|
<string name="reminder_action_dismiss">Ignora</string>
|
||||||
<string name="view_month">Mese</string>
|
<string name="view_month">Mese</string>
|
||||||
<string name="view_week">Settimana</string>
|
<string name="view_week">Settimana</string>
|
||||||
@@ -418,16 +418,16 @@
|
|||||||
<string name="settings_default_reminder">Promemoria standard</string>
|
<string name="settings_default_reminder">Promemoria standard</string>
|
||||||
<string name="settings_notifications_subtitle">Promemoria evento</string>
|
<string name="settings_notifications_subtitle">Promemoria evento</string>
|
||||||
<string name="shortcut_new_event_short">Nuovo evento</string>
|
<string name="shortcut_new_event_short">Nuovo evento</string>
|
||||||
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente</string>
|
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente.</string>
|
||||||
<string name="settings_font_headings">Carattere dell\'intestazione</string>
|
<string name="settings_font_headings">Carattere del titolo</string>
|
||||||
<string name="settings_font_body">Carattere del corpo</string>
|
<string name="settings_font_body">Carattere del corpo</string>
|
||||||
<string name="settings_font_system">Predefinito di sistema</string>
|
<string name="settings_font_system">Predefinito di sistema</string>
|
||||||
<string name="settings_font_choose_file">Scegli file…</string>
|
<string name="settings_font_choose_file">Scegli file…</string>
|
||||||
<string name="settings_font_custom_selected">Carattere personalizzato</string>
|
<string name="settings_font_custom_selected">Font personalizzato</string>
|
||||||
<string name="settings_font_import_failed">Impossibile leggere il file come font</string>
|
<string name="settings_font_import_failed">Impossibile leggere il file come font</string>
|
||||||
<string name="settings_section_views">Viste</string>
|
<string name="settings_section_views">Viste</string>
|
||||||
<string name="settings_quick_switch_header">Pulsante di cambio rapido</string>
|
<string name="settings_quick_switch_header">Pulsante di cambio rapido</string>
|
||||||
<string name="settings_quick_switch_hint">Scegli attraverso quali viste scorrere mediante il pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione.</string>
|
<string name="settings_quick_switch_hint">Scegli attraverso quali viste scorrere col pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione.</string>
|
||||||
<string name="settings_drawer_order_header">Menù di navigazione</string>
|
<string name="settings_drawer_order_header">Menù di navigazione</string>
|
||||||
<string name="settings_drawer_order_hint">Trascina per riordinare le viste elencate nel menù di navigazione.</string>
|
<string name="settings_drawer_order_hint">Trascina per riordinare le viste elencate nel menù di navigazione.</string>
|
||||||
<string name="reorder_drag_handle">Trascina per riordinare</string>
|
<string name="reorder_drag_handle">Trascina per riordinare</string>
|
||||||
@@ -463,4 +463,7 @@
|
|||||||
<string name="special_dates_calendar_custom">Date speciali</string>
|
<string name="special_dates_calendar_custom">Date speciali</string>
|
||||||
<string name="special_dates_default_title_birthday">Compleanno di {name} ({year})</string>
|
<string name="special_dates_default_title_birthday">Compleanno di {name} ({year})</string>
|
||||||
<string name="special_dates_default_title_anniversary">Anniversario di {name} ({year})</string>
|
<string name="special_dates_default_title_anniversary">Anniversario di {name} ({year})</string>
|
||||||
|
<string name="font_lora">Lora</string>
|
||||||
|
<string name="font_jetbrains_mono">JetBrains Mono</string>
|
||||||
|
<string name="special_dates_default_title_custom">{name}</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -117,16 +117,6 @@
|
|||||||
<string name="event_edit_gone_title">Event deleted</string>
|
<string name="event_edit_gone_title">Event deleted</string>
|
||||||
<string name="event_edit_gone_body">This event was deleted in the meantime, for example on another device. Your changes can no longer be saved.</string>
|
<string name="event_edit_gone_body">This event was deleted in the meantime, for example on another device. Your changes can no longer be saved.</string>
|
||||||
|
|
||||||
<!-- Event form — apply default reminder to an imported .ics event (#49) -->
|
|
||||||
<string name="import_reminder_prompt_title">Apply your default reminder?</string>
|
|
||||||
<string name="import_reminder_prompt_body_none">This event was imported without any reminder.</string>
|
|
||||||
<plurals name="import_reminder_prompt_body_existing">
|
|
||||||
<item quantity="one">This event was imported with %1$d reminder.</item>
|
|
||||||
<item quantity="other">This event was imported with %1$d reminders.</item>
|
|
||||||
</plurals>
|
|
||||||
<string name="import_reminder_prompt_apply">Apply default</string>
|
|
||||||
<string name="import_reminder_prompt_keep">Keep as-is</string>
|
|
||||||
|
|
||||||
<!-- Event form — recurrence picker (v1.3) -->
|
<!-- Event form — recurrence picker (v1.3) -->
|
||||||
<string name="event_edit_recurrence_none">Does not repeat</string>
|
<string name="event_edit_recurrence_none">Does not repeat</string>
|
||||||
<string name="event_edit_recurrence_custom">Custom</string>
|
<string name="event_edit_recurrence_custom">Custom</string>
|
||||||
|
|||||||
@@ -220,46 +220,6 @@ class EventWriteMapperTest {
|
|||||||
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
|
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- buildOccurrenceCancelValues ("delete only this event") ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `occurrence cancel anchors a single instance and cancels only it`() {
|
|
||||||
val values = buildOccurrenceCancelValues(
|
|
||||||
originalInstanceMillis = 1_700_000_000_000L,
|
|
||||||
dtStartMillis = 1_700_000_000_000L,
|
|
||||||
duration = "P3600S",
|
|
||||||
timezone = "Europe/Berlin",
|
|
||||||
allDay = 0,
|
|
||||||
)
|
|
||||||
assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME])
|
|
||||||
.isEqualTo(1_700_000_000_000L)
|
|
||||||
// DTSTART + DURATION make the provider derive a single instance and drop
|
|
||||||
// the inherited RRULE, so only this occurrence is cancelled — not the
|
|
||||||
// whole series (#47). DTEND is never sent (the provider rejects it).
|
|
||||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_700_000_000_000L)
|
|
||||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P3600S")
|
|
||||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
|
||||||
assertThat(values[CalendarContract.Events.STATUS])
|
|
||||||
.isEqualTo(CalendarContract.Events.STATUS_CANCELED)
|
|
||||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
|
|
||||||
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `all-day occurrence cancel keeps the all-day flag and utc zone`() {
|
|
||||||
val values = buildOccurrenceCancelValues(
|
|
||||||
originalInstanceMillis = 1_700_000_000_000L,
|
|
||||||
dtStartMillis = 1_700_000_000_000L,
|
|
||||||
duration = "P1D",
|
|
||||||
timezone = "UTC",
|
|
||||||
allDay = 1,
|
|
||||||
)
|
|
||||||
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
|
||||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
|
|
||||||
assertThat(values[CalendarContract.Events.STATUS])
|
|
||||||
.isEqualTo(CalendarContract.Events.STATUS_CANCELED)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- per-event colour ---
|
// --- per-event colour ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user