Compare commits
1 Commits
main
...
feat/edit-
| Author | SHA1 | Date | |
|---|---|---|---|
| c63dfddb88 |
@@ -97,32 +97,58 @@
|
|||||||
<data android:mimeType="time/epoch" />
|
<data android:mimeType="time/epoch" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
<!-- Open a .ics/.vcs 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" android:mimeType="text/calendar" />
|
<data android:scheme="content" />
|
||||||
<data android:scheme="file" android:mimeType="text/calendar" />
|
<data android:scheme="file" />
|
||||||
|
<data android:mimeType="text/calendar" />
|
||||||
|
<data android:mimeType="text/x-vcalendar" />
|
||||||
|
<data android:mimeType="application/ics" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<!-- Receive a .ics shared from another app. -->
|
<!-- Receive a .ics/.vcs shared from another app (same MIME set). -->
|
||||||
<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). -->
|
(MainActivity.insertFormOrNull, issue #30). ACTION_EDIT on the
|
||||||
|
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
|
<!-- Open an existing event another app/widget points at (e.g. tapping
|
||||||
an event in the Todo Agenda widget): ACTION_VIEW on
|
an event in the Todo Agenda widget): ACTION_VIEW on
|
||||||
content://com.android.calendar/events/<id>, the way AOSP fires it.
|
content://com.android.calendar/events/<id>, the way AOSP fires it.
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ 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.
|
||||||
@@ -95,6 +100,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
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
|
||||||
@@ -145,6 +151,8 @@ 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
|
||||||
@@ -183,6 +191,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -203,13 +212,19 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A prefilled new-event form from an external `ACTION_INSERT` launch — another
|
* A prefilled new-event form from an external launch asking us to create an
|
||||||
* app or widget (e.g. Todo Agenda) asking us to create an event (issue #30).
|
* event — another app or widget (e.g. Todo Agenda) firing `ACTION_INSERT`
|
||||||
* The new event's fields ride as CalendarContract extras; anything omitted
|
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
|
||||||
* falls back to the in-app "new event" defaults in [buildInsertEventForm].
|
* event", i.e. create). The new event's fields ride as CalendarContract
|
||||||
|
* extras; anything omitted falls back to the in-app "new event" defaults in
|
||||||
|
* [buildInsertEventForm].
|
||||||
*/
|
*/
|
||||||
private fun Intent.insertFormOrNull(): EventForm? {
|
private fun Intent.insertFormOrNull(): EventForm? {
|
||||||
if (action != Intent.ACTION_INSERT) return null
|
// ACTION_EDIT on an existing event routes to the edit form instead
|
||||||
|
// ([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),
|
||||||
@@ -318,6 +333,31 @@ class MainActivity : AppCompatActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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>.
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ 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
|
||||||
@@ -212,6 +214,20 @@ 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
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ 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 {
|
||||||
@@ -88,6 +90,8 @@ 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,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ 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
|
||||||
@@ -396,8 +397,12 @@ class EventEditViewModel @Inject constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 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. No-op while a
|
* tapped occurrence's own times, like on the detail screen. An external
|
||||||
* form is open, so user edits survive configuration changes.
|
* "edit this event" (`ACTION_EDIT`) that names no occurrence passes
|
||||||
|
* [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
|
||||||
@@ -411,8 +416,12 @@ class EventEditViewModel @Inject constructor(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val zone = TimeZone.currentSystemDefault()
|
val zone = TimeZone.currentSystemDefault()
|
||||||
val snapshot = detail.toEditSnapshot(beginMillis, endMillis, zone)
|
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
||||||
_editTarget.value = EditTarget(eventId, snapshot, beginMillis, endMillis, zone)
|
?: detail.instance.start.toEpochMilliseconds()
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user