Merge remote-tracking branch 'origin/release/v2.23.0' into fix/387-dst-timeline
# Conflicts: # app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt # app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt
This commit is contained in:
commit
3ec0403b00
22 files changed
+1220
-98
No files matched your search
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Added
|
||||
- **Long press an event to select it.** In the day, 3-day and week views, a long press lifts the event and selects it. Keep your finger down and move to drag it straight away. A selected event has handles at its top and bottom: drag them to change when it starts or ends, in 15-minute steps and across midnight if you want. The bar at the top edits, duplicates, shares or deletes it, and tapping elsewhere or going back deselects it. Read-only events can be selected but not moved or resized ([#141]).
|
||||
- **Long press an empty slot to draft an event.** In the day, 3-day and week views, a long press on free time drops a block of your default event length there. Move it while still holding, or let go and drag it or its handles into place, then tap the tick to open a new event at that time. The ✕, a tap elsewhere or going back throws the draft away ([#153]).
|
||||
- **Tap a phone number in an event to call it.** Phone numbers in an event's title, location and description are now links. Tapping one opens your phone app with the number filled in, ready to call. A conference dial-in like "+1 646 558 8656,,850123#" brings its meeting ID along. Meeting IDs and PINs on their own, dates, times and amounts stay plain text. Long-pressing a field copies it, even when you press on a link ([#339]).
|
||||
- **Scroll back to earlier days in the agenda.** Scroll up past today to see earlier days' events, loaded a month at a time. Past days follow the past-events setting: Dim fades them, and with Hide the agenda still starts at today ([#367]).
|
||||
|
||||
### Fixed
|
||||
@@ -1861,3 +1862,4 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#373]: https://codeberg.org/jlmakiola/calendula/issues/373
|
||||
[#141]: https://codeberg.org/jlmakiola/calendula/issues/141
|
||||
[#153]: https://codeberg.org/jlmakiola/calendula/issues/153
|
||||
[#339]: https://codeberg.org/jlmakiola/calendula/issues/339
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package de.jeanlucmakiola.calendula.ui.detail
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* The platform phone matcher on real text. It reads the region off the SIM,
|
||||
* so these stick to international numbers and to text that must never match
|
||||
* anywhere — domestic formats depend on the device they run on.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PhoneLinksPlatformTest {
|
||||
|
||||
private fun numbers(text: String) = findPhoneLinks(text).map { it.number }
|
||||
|
||||
@Test
|
||||
fun internationalNumbers_areFound() {
|
||||
assertThat(numbers("Call +49 30 1234567 or +1 646-558-8656"))
|
||||
.containsExactly("+49301234567", "+16465588656")
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trunkZero_isDroppedWithOrWithoutSpace() {
|
||||
assertThat(numbers("+49 (0)151 2345678")).containsExactly("+491512345678")
|
||||
assertThat(numbers("+49 (0) 151 2345678")).containsExactly("+491512345678")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun neighbouringNumbers_doNotJoinTheNumber() {
|
||||
assertThat(numbers("Start 10:00 +49 151 2345678")).containsExactly("+491512345678")
|
||||
assertThat(numbers("Call +49 151 2345678 2 times")).containsExactly("+491512345678")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun conferenceDialIn_keepsMeetingId() {
|
||||
assertThat(numbers("One tap mobile: +16465588656,,85012345678# US"))
|
||||
.containsExactly("+16465588656,,85012345678#")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun meetingIds_areNotNumbers() {
|
||||
assertThat(numbers("Meeting ID: 850 1234 5678")).isEmpty()
|
||||
assertThat(numbers("Conference ID: 123 456 789#")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun datesAndTimes_areNotNumbers() {
|
||||
assertThat(numbers("2026-10-14 10:00")).isEmpty()
|
||||
assertThat(numbers("Am 14.10.2026 10:00 Uhr")).isEmpty()
|
||||
assertThat(numbers("14.10.26 - 16.10.26")).isEmpty()
|
||||
assertThat(numbers("10:00 - 11:30")).isEmpty()
|
||||
assertThat(numbers("Schuljahr 2025/2026, Season 2025-2026")).isEmpty()
|
||||
assertThat(numbers("1.10.2026 - 3.10.2026")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun amounts_areNotNumbers() {
|
||||
assertThat(numbers("Budget 1.250.000 EUR, 1 250 000 EUR")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isbn_isNotANumber() {
|
||||
assertThat(numbers("ISBN 978-3-16-148410-0")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extension_becomesAPause() {
|
||||
assertThat(numbers("+1 212 555 1234 ext. 567")).containsExactly("+12125551234,567")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun webexAccessCode_isNotANumber() {
|
||||
assertThat(numbers("Meeting number (access code): 2732 123 4567")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commaSeparatedNumbers_stayApart() {
|
||||
assertThat(numbers("+49 30 1234567,+49 151 2345678"))
|
||||
.containsExactly("+49301234567", "+491512345678")
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dottedTimes_areNotNumbers() {
|
||||
assertThat(numbers("Beginn 9.30 – 12.00 Uhr")).isEmpty()
|
||||
assertThat(numbers("10.00-11.30")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun schemelessUrlDigits_areNotNumbers() {
|
||||
assertThat(numbers("zoom.us/j/85012345678")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun webLinkDigits_areNotNumbers() {
|
||||
assertThat(numbers("https://zoom.us/j/85012345678?pwd=1")).isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -346,6 +346,19 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[DRAG_TO_RESCHEDULE_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether tapping an event in the calendar views opens the edit form instead
|
||||
* of the detail screen (#178). Defaults to OFF; read-only events and
|
||||
* reminder notifications always open the detail screen.
|
||||
*/
|
||||
val tapOpensEditor: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[TAP_OPENS_EDITOR_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setTapOpensEditor(enabled: Boolean) {
|
||||
store.edit { it[TAP_OPENS_EDITOR_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
||||
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
||||
@@ -1019,6 +1032,7 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val TIMELINE_SCALE_KEY = stringPreferencesKey("timeline_scale")
|
||||
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
|
||||
internal val DRAG_TO_RESCHEDULE_KEY = booleanPreferencesKey("drag_to_reschedule")
|
||||
internal val TAP_OPENS_EDITOR_KEY = booleanPreferencesKey("tap_opens_editor")
|
||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||
|
||||
@@ -60,6 +60,7 @@ import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventSelectionHost
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventActionFailureMessage
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberFabSelection
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGateOr
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||
@@ -193,7 +194,7 @@ fun CalendarHost(
|
||||
// placeholder — so the destination never loads a bogus id=0 on first frame.
|
||||
var detailKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||
var heldKey by remember { mutableStateOf<LongArray?>(null) }
|
||||
val onEventClick: (EventInstance) -> Unit = { event ->
|
||||
val onOpenDetail: (EventInstance) -> Unit = { event ->
|
||||
val key = longArrayOf(
|
||||
event.eventId,
|
||||
event.start.toEpochMilliseconds(),
|
||||
@@ -416,6 +417,18 @@ fun CalendarHost(
|
||||
heldEditKey = key
|
||||
editKey = key
|
||||
}
|
||||
// A tap opens the edit form instead of the detail screen when the user chose
|
||||
// so (#178) — only for events whose calendar takes writes.
|
||||
val tapOpensEditor = viewModel.tapOpensEditor.collectAsStateWithLifecycle().value
|
||||
val actionCalendars by eventActions.calendars.collectAsStateWithLifecycle()
|
||||
val requireWriteOr = rememberWriteGateOr()
|
||||
val onEventClick: (EventInstance) -> Unit = { event ->
|
||||
if (tapOpensEditor && actionCalendars[event.calendarId]?.canModifyContents == true) {
|
||||
requireWriteOr({ onEditEvent(event) }, { onOpenDetail(event) })
|
||||
} else {
|
||||
onOpenDetail(event)
|
||||
}
|
||||
}
|
||||
// Off by preference (#173) switches off the gesture, not the scope: blocks
|
||||
// register no drag, while the edit form and its TalkBack action stay put.
|
||||
val dragToReschedule = viewModel.dragToReschedule.collectAsStateWithLifecycle().value
|
||||
@@ -691,6 +704,7 @@ fun CalendarHost(
|
||||
onSaved = { createDateIso = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
visible = createDateIso != null,
|
||||
viewModel = hiltViewModel(key = "create"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -711,7 +725,21 @@ fun CalendarHost(
|
||||
detailKey = null
|
||||
},
|
||||
onManageCalendars = { showCalendars = true },
|
||||
onDuplicate = { form ->
|
||||
importFormSource = ImportSource.Insert
|
||||
importForm = form
|
||||
},
|
||||
// Opened straight from a tap (#178) there's no detail screen
|
||||
// below to show why, so open it.
|
||||
onLoadFailed = {
|
||||
editKey = null
|
||||
if (detailKey == null) {
|
||||
heldKey = key
|
||||
detailKey = key
|
||||
}
|
||||
},
|
||||
visible = editKey != null,
|
||||
viewModel = hiltViewModel(key = "edit"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -770,6 +798,7 @@ fun CalendarHost(
|
||||
onClose = { importForm = null },
|
||||
onSaved = { importForm = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
viewModel = hiltViewModel(key = "import"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -78,4 +78,12 @@ class CalendarHostViewModel @Inject constructor(
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
/** Whether tapping an event opens the edit form rather than the detail screen (#178). */
|
||||
val tapOpensEditor: StateFlow<Boolean> = prefs.tapOpensEditor
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
}
|
||||
@@ -2,14 +2,16 @@ package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.content.ClipData
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
@@ -67,9 +69,12 @@ fun rememberFieldCopier(snackbarHostState: SnackbarHostState): FieldCopier {
|
||||
*
|
||||
* [onTap] carries a field's existing tap action through, announced as
|
||||
* [onTapLabel]; a field without one stays unclickable rather than growing a
|
||||
* ripple that leads nowhere, and gets its long press announced through semantics instead. That branch merges the
|
||||
* node it sits on, so a screen reader lands on the field itself and finds the
|
||||
* action there — a bare container is never focused.
|
||||
* ripple that leads nowhere. The long press is announced through semantics,
|
||||
* merging the node it sits on, so a screen reader lands on the field itself
|
||||
* and finds the action there — a bare container is never focused.
|
||||
*
|
||||
* The press is caught ahead of the field's children, so a long press on a
|
||||
* link inside the text copies too instead of leaving that stretch dead.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.copyOnLongPress(
|
||||
@@ -80,27 +85,45 @@ fun Modifier.copyOnLongPress(
|
||||
onTapLabel: String? = null,
|
||||
): Modifier {
|
||||
val actionLabel = stringResource(R.string.field_copy_action)
|
||||
return if (onTap != null) {
|
||||
combinedClickable(
|
||||
onClick = onTap,
|
||||
onClickLabel = onTapLabel,
|
||||
onLongClickLabel = actionLabel,
|
||||
onLongClick = { copy(label, text) },
|
||||
)
|
||||
} else {
|
||||
val haptics = LocalHapticFeedback.current
|
||||
pointerInput(label, text, copy) {
|
||||
detectTapGestures(
|
||||
onLongPress = {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
copy(label, text)
|
||||
},
|
||||
)
|
||||
}.semantics(mergeDescendants = true) {
|
||||
val haptics = LocalHapticFeedback.current
|
||||
return longPressAheadOfChildren(label, text, copy) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
copy(label, text)
|
||||
}
|
||||
.then(if (onTap != null) Modifier.clickable(onClickLabel = onTapLabel, onClick = onTap) else Modifier)
|
||||
.semantics(mergeDescendants = true) {
|
||||
onLongClick(actionLabel) {
|
||||
copy(label, text)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run [onLongPress] once a press has held still for the long-press timeout,
|
||||
* watching on the initial pass — before the children see the events — and
|
||||
* then swallow the rest of that press, so neither a link under the finger nor
|
||||
* a tap handler fires on release.
|
||||
*/
|
||||
private fun Modifier.longPressAheadOfChildren(vararg keys: Any?, onLongPress: () -> Unit): Modifier =
|
||||
pointerInput(*keys) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||
val endedEarly = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
|
||||
while (true) {
|
||||
val change = awaitPointerEvent(PointerEventPass.Initial).changes
|
||||
.firstOrNull { it.id == down.id }
|
||||
val moved = change != null &&
|
||||
(change.position - down.position).getDistance() > viewConfiguration.touchSlop
|
||||
if (change == null || !change.pressed || moved) break
|
||||
}
|
||||
true
|
||||
}
|
||||
if (endedEarly != null) return@awaitEachGesture
|
||||
onLongPress()
|
||||
do {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
event.changes.forEach { it.consume() }
|
||||
} while (event.changes.any { it.pressed })
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -25,12 +26,14 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
||||
import de.jeanlucmakiola.floret.components.OptionCard
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
@@ -67,6 +70,28 @@ suspend fun CalendarRepository.deleteEvent(
|
||||
RecurringWriteScope.ThisAndFollowing -> deleteEventFromOccurrence(eventId, beginMillis)
|
||||
}
|
||||
|
||||
/** How a [tryDeleteEvent] ended. */
|
||||
enum class DeleteOutcome { Deleted, Gone, NeedsPermission, Failed }
|
||||
|
||||
/** [deleteEvent], with the provider's failures mapped to a [DeleteOutcome]. */
|
||||
suspend fun CalendarRepository.tryDeleteEvent(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
scope: RecurringWriteScope,
|
||||
): DeleteOutcome = try {
|
||||
deleteEvent(eventId, beginMillis, scope)
|
||||
DeleteOutcome.Deleted
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: SecurityException) {
|
||||
DeleteOutcome.NeedsPermission
|
||||
} catch (e: NoSuchEventException) {
|
||||
DeleteOutcome.Gone
|
||||
} catch (e: Exception) {
|
||||
Log.w("EventActions", "Delete failed (scope=$scope, eventId=$eventId)", e)
|
||||
DeleteOutcome.Failed
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an action that writes to the calendar, asking for `WRITE_CALENDAR` first
|
||||
* where the install only holds read access (v1.0 installs). A grant continues
|
||||
@@ -74,15 +99,22 @@ suspend fun CalendarRepository.deleteEvent(
|
||||
*/
|
||||
@Composable
|
||||
fun rememberWriteGate(): (() -> Unit) -> Unit {
|
||||
val gate = rememberWriteGateOr()
|
||||
return { action -> gate(action) {} }
|
||||
}
|
||||
|
||||
/** [rememberWriteGate], running the fallback instead when the user refuses. */
|
||||
@Composable
|
||||
fun rememberWriteGateOr(): (action: () -> Unit, fallback: () -> Unit) -> Unit {
|
||||
val context = LocalContext.current
|
||||
var pending by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
var pending by remember { mutableStateOf<Pair<() -> Unit, () -> Unit>?>(null) }
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) pending?.invoke()
|
||||
pending?.let { (action, fallback) -> if (granted) action() else fallback() }
|
||||
pending = null
|
||||
}
|
||||
return { action ->
|
||||
return { action, fallback ->
|
||||
val granted = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.WRITE_CALENDAR,
|
||||
@@ -90,7 +122,7 @@ fun rememberWriteGate(): (() -> Unit) -> Unit {
|
||||
if (granted) {
|
||||
action()
|
||||
} else {
|
||||
pending = action
|
||||
pending = action to fallback
|
||||
launcher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,17 +87,11 @@ class EventActionsViewModel @Inject constructor(
|
||||
_deletePrompt.value = null
|
||||
viewModelScope.launch {
|
||||
val event = prompt.event
|
||||
_failure.value = try {
|
||||
repository.deleteEvent(event.eventId, event.start.toEpochMilliseconds(), scope)
|
||||
null
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: SecurityException) {
|
||||
EventActionFailure.WriteDenied
|
||||
} catch (e: NoSuchEventException) {
|
||||
EventActionFailure.Gone
|
||||
} catch (e: Exception) {
|
||||
EventActionFailure.DeleteFailed
|
||||
_failure.value = when (repository.tryDeleteEvent(event.eventId, event.start.toEpochMilliseconds(), scope)) {
|
||||
DeleteOutcome.Deleted -> null
|
||||
DeleteOutcome.Gone -> EventActionFailure.Gone
|
||||
DeleteOutcome.NeedsPermission -> EventActionFailure.WriteDenied
|
||||
DeleteOutcome.Failed -> EventActionFailure.DeleteFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,15 +312,26 @@ private fun EventDetailContent(
|
||||
// cancelled event strikes through its title.
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
val titleLabel = stringResource(R.string.event_detail_title)
|
||||
val titleDecoration = if (detail.status == EventStatus.Cancelled) {
|
||||
TextDecoration.LineThrough
|
||||
} else {
|
||||
null
|
||||
}
|
||||
Text(
|
||||
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||
// A phone number in the title ("Call Anna 0151 …") dials too.
|
||||
text = if (instance.title.isBlank()) {
|
||||
AnnotatedString(stringResource(R.string.event_untitled))
|
||||
} else {
|
||||
linkify(
|
||||
instance.title,
|
||||
state.phoneLinks.title,
|
||||
MaterialTheme.colorScheme.primary,
|
||||
titleDecoration,
|
||||
)
|
||||
},
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textDecoration = if (detail.status == EventStatus.Cancelled) {
|
||||
TextDecoration.LineThrough
|
||||
} else {
|
||||
null
|
||||
},
|
||||
textDecoration = titleDecoration,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.then(
|
||||
@@ -437,7 +448,8 @@ private fun EventDetailContent(
|
||||
)
|
||||
}
|
||||
|
||||
// Location (conditional). URLs open in the browser, the rest in maps.
|
||||
// Location (conditional). URLs open in the browser, phone numbers in the
|
||||
// dialer, the rest in maps.
|
||||
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
|
||||
val context = LocalContext.current
|
||||
val locationLabel = stringResource(R.string.event_detail_location)
|
||||
@@ -447,7 +459,7 @@ private fun EventDetailContent(
|
||||
iconContentDescription = locationLabel,
|
||||
) {
|
||||
Text(
|
||||
text = linkifyUrls(location, MaterialTheme.colorScheme.primary),
|
||||
text = linkify(location, state.phoneLinks.location, MaterialTheme.colorScheme.primary),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
@@ -464,7 +476,7 @@ private fun EventDetailContent(
|
||||
}
|
||||
}
|
||||
|
||||
// Description (conditional). URLs are auto-linked.
|
||||
// Description (conditional). URLs and phone numbers are auto-linked.
|
||||
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||
val descriptionLabel = stringResource(R.string.event_detail_description)
|
||||
Spacer(Modifier.height(gap))
|
||||
@@ -477,7 +489,7 @@ private fun EventDetailContent(
|
||||
modifier = Modifier.copyOnLongPress(descriptionLabel, description, copyField),
|
||||
) {
|
||||
Text(
|
||||
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
|
||||
text = linkify(description, state.phoneLinks.description, MaterialTheme.colorScheme.primary),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
@@ -750,14 +762,25 @@ private fun foreignTimeZone(
|
||||
)
|
||||
}
|
||||
|
||||
/** Wrap http(s) URLs in [text] as links tinted [linkColor] that open in the browser. */
|
||||
/**
|
||||
* Wrap http(s) URLs in [text] as links that open in the browser, and its
|
||||
* [phoneLinks] as links that open the dialer, both tinted [linkColor]. A
|
||||
* link's underline joins [textDecoration] rather than replacing it, so a
|
||||
* cancelled title stays struck through across its links.
|
||||
*/
|
||||
@Composable
|
||||
private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString {
|
||||
private fun linkify(
|
||||
text: String,
|
||||
phoneLinks: List<PhoneLink>,
|
||||
linkColor: Color,
|
||||
textDecoration: TextDecoration? = null,
|
||||
): AnnotatedString {
|
||||
val context = LocalContext.current
|
||||
return remember(text, linkColor, context) {
|
||||
val styles = TextLinkStyles(
|
||||
style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
|
||||
)
|
||||
return remember(text, phoneLinks, linkColor, textDecoration, context) {
|
||||
val decoration = textDecoration
|
||||
?.let { TextDecoration.combine(listOf(it, TextDecoration.Underline)) }
|
||||
?: TextDecoration.Underline
|
||||
val styles = TextLinkStyles(style = SpanStyle(color = linkColor, textDecoration = decoration))
|
||||
buildAnnotatedString {
|
||||
append(text)
|
||||
for (link in findWebLinks(text)) {
|
||||
@@ -767,6 +790,13 @@ private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString {
|
||||
link.end,
|
||||
)
|
||||
}
|
||||
for (link in phoneLinks) {
|
||||
addLink(
|
||||
LinkAnnotation.Url("tel:${link.number}", styles) { dial(context, link.number) },
|
||||
link.start,
|
||||
link.end,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -834,7 +864,15 @@ private fun Instant.toJavaLocalDateTime(zid: ZoneId): java.time.LocalDateTime =
|
||||
|
||||
/** Open [url] in a browsable app, or say so when none can take it. */
|
||||
private fun openWebLink(context: Context, url: String) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE)
|
||||
startOrSayNoApp(context, Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE))
|
||||
}
|
||||
|
||||
/** Open the dialer with [number] filled in, or say so when none can take it. */
|
||||
private fun dial(context: Context, number: String) {
|
||||
startOrSayNoApp(context, Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", number, null)))
|
||||
}
|
||||
|
||||
private fun startOrSayNoApp(context: Context, intent: Intent) {
|
||||
if (runCatching { context.startActivity(intent) }.isFailure) {
|
||||
Toast.makeText(context, R.string.link_open_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ sealed interface EventDetailUiState {
|
||||
* the sync would just resurrect it. Contact dates are removed at the source.
|
||||
*/
|
||||
val isManaged: Boolean = false,
|
||||
/** Phone numbers to link in the title, location and description. */
|
||||
val phoneLinks: EventPhoneLinks = EventPhoneLinks(),
|
||||
) : EventDetailUiState
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.toDuplicateForm
|
||||
import de.jeanlucmakiola.calendula.ui.common.deleteEvent
|
||||
import de.jeanlucmakiola.calendula.ui.common.DeleteOutcome
|
||||
import de.jeanlucmakiola.calendula.ui.common.tryDeleteEvent
|
||||
import de.jeanlucmakiola.calendula.ui.common.stageEventShare
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -111,15 +112,11 @@ class EventDetailViewModel @Inject constructor(
|
||||
if (_deleteState.value == DeleteUiState.Deleting) return
|
||||
viewModelScope.launch {
|
||||
_deleteState.value = DeleteUiState.Deleting
|
||||
_deleteState.value = try {
|
||||
repository.deleteEvent(target.eventId, target.beginMillis, scope)
|
||||
DeleteUiState.Deleted
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: SecurityException) {
|
||||
DeleteUiState.NeedsPermission
|
||||
} catch (e: Exception) {
|
||||
DeleteUiState.Failed
|
||||
// Already gone is what a delete wants anyway.
|
||||
_deleteState.value = when (repository.tryDeleteEvent(target.eventId, target.beginMillis, scope)) {
|
||||
DeleteOutcome.Deleted, DeleteOutcome.Gone -> DeleteUiState.Deleted
|
||||
DeleteOutcome.NeedsPermission -> DeleteUiState.NeedsPermission
|
||||
DeleteOutcome.Failed -> DeleteUiState.Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,6 +169,13 @@ class EventDetailViewModel @Inject constructor(
|
||||
calendarName = calendar?.displayName,
|
||||
canModify = calendar?.canModifyContents == true,
|
||||
isManaged = calendar?.isManaged == true,
|
||||
// Found here, off the main thread and once per load: the platform
|
||||
// matcher loads its metadata on first use.
|
||||
phoneLinks = findEventPhoneLinks(
|
||||
title = corrected.instance.title,
|
||||
location = corrected.instance.location,
|
||||
description = corrected.description,
|
||||
),
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package de.jeanlucmakiola.calendula.ui.detail
|
||||
|
||||
import android.text.SpannableString
|
||||
import android.text.style.URLSpan
|
||||
import android.text.util.Linkify
|
||||
|
||||
/**
|
||||
* A phone number found in free text, spanning [start] until [end] (exclusive).
|
||||
* [number] is what the dialer gets: digits, a leading `+`, and any `,`/`#`
|
||||
* dial-in suffix — the separators that only made it readable are gone.
|
||||
*/
|
||||
data class PhoneLink(val start: Int, val end: Int, val number: String)
|
||||
|
||||
/** The phone numbers in an event's free-text fields, each against its own text. */
|
||||
data class EventPhoneLinks(
|
||||
val title: List<PhoneLink> = emptyList(),
|
||||
val location: List<PhoneLink> = emptyList(),
|
||||
val description: List<PhoneLink> = emptyList(),
|
||||
)
|
||||
|
||||
/** Where the platform matcher saw a phone number: [start] until [end] (exclusive). */
|
||||
internal data class PhoneMatch(val start: Int, val end: Int)
|
||||
|
||||
// A conference dial-in's pauses and meeting id right after the number:
|
||||
// "+16465588656,,85012345678#" or Zoom's ",,850…#,,,,*123456#". It opens on a
|
||||
// double comma or closes on "#", so "030 1234567,0151 2345678" stays a list.
|
||||
private val dialSuffixRegex = Regex("""^(?:,,+[\d*]+#?|,[\d*]+#)(?:,+[\d*]+#?)*""")
|
||||
|
||||
// Whitespace as invites write it: Java's \s misses the no-break space and the
|
||||
// invisible direction marks Meet and Outlook wrap around numbers.
|
||||
private const val GAP = """\s\u00A0\u200E\u200F\u202A-\u202E\u2066-\u2069"""
|
||||
|
||||
// A number right after one of these labels is an id to type in, not one to
|
||||
// call. English and German cover the Zoom/Teams/Meet/Webex invites seen so far.
|
||||
// "Conference number" is left out: invites use it for the dial-in itself.
|
||||
private val notAPhoneLabelRegex = Regex(
|
||||
"""(?:(?:meeting|webinar|besprechungs)[ -]?(?:id|number|nummer)|(?:conference|konferenz)[ -]?id|""" +
|
||||
"""\bpin|passcode|password|passwort|kenncode|access code|zugangscode)[$GAP:#.()]*$""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
|
||||
// A day.month.year (or year-month-day, or US month/day/year) date: exactly
|
||||
// three groups, so a French "01.23.45.67.89" chain doesn't read as one.
|
||||
private val dateRegex = Regex("""(?<![\d./-])(\d{1,4})([./-])(\d{1,2})\2(\d{4}|\d{2})(?![\d]|[./-]\d)""")
|
||||
|
||||
// Thousands groups. A short lead ("1.250.000", "12 500 000") is an amount; a
|
||||
// three-digit lead is one only beside a currency — "612 345 678" and
|
||||
// "612.345.678" are Spanish mobiles, "125.000.000 €" a budget.
|
||||
private val shortLeadAmountRegex = Regex("""[1-9]\d?(?:([. ])\d{3})(?:\1\d{3})*""")
|
||||
private val amountRegex = Regex("""[1-9]\d{0,2}(?:([. ])\d{3})(?:\1\d{3})*""")
|
||||
private val currencyBeforeRegex = Regex("""(?:[€$£]|\b(?:eur|euro|usd|chf|gbp))\s*$""", RegexOption.IGNORE_CASE)
|
||||
private val currencyAfterRegex = Regex("""^\s*(?:[€$£]|(?:eur|euro|usd|chf|gbp)\b)""", RegexOption.IGNORE_CASE)
|
||||
|
||||
// A short group set off by a space after a long one, with a word straight
|
||||
// after — "… 2345678 2 times" — is the prose's count, not the number's tail.
|
||||
// Without the word ("030 123456 0") or after a short group ("0221 1234 56")
|
||||
// it is the number's own.
|
||||
private val strayTailRegex = Regex("""(?<=\d{6})\s+\d{1,2}$""")
|
||||
private val wordAfterRegex = Regex("""[ \t\u00A0]+\p{L}""")
|
||||
|
||||
// "9.30 – 12.00", "10.00-11.30": times of day written the German way.
|
||||
private val dottedTimesRegex = Regex("""(\d{1,2})\.(\d{2})(?:\s*[-–]\s*(\d{1,2})\.(\d{2}))?""")
|
||||
|
||||
// "ext. 567", "x567", "Durchwahl 567": the dialer reaches an extension after a pause.
|
||||
private val extensionRegex = Regex("""\s*(?:ext\.?|extension|x|durchwahl|dw\.?)\s*(?=\d)""", RegexOption.IGNORE_CASE)
|
||||
|
||||
private val trunkPrefixRegex = Regex("""\(\s*0\s*\)""")
|
||||
private val strandedCountryCodeRegex = Regex("""\+\d{1,3}\s*\(\s*0\s*\)\s*$""")
|
||||
|
||||
// Short local numbers exist, but in event text a 5- or 6-digit run is far more
|
||||
// often a postcode, house number or booking reference.
|
||||
private const val MIN_DIGITS = 7
|
||||
|
||||
/**
|
||||
* Find the phone numbers in [text]. The platform's libphonenumber-backed
|
||||
* matcher does the finding — it knows the device's region and real number
|
||||
* lengths; [refinePhoneMatches] then fits what it found to an event's text.
|
||||
* Call off the main thread: the matcher loads its metadata on first use.
|
||||
* No match at all when the platform matcher fails.
|
||||
*/
|
||||
internal fun findPhoneLinks(text: String): List<PhoneLink> =
|
||||
refinePhoneMatches(text, platformPhoneMatches(text), findWebLinks(text))
|
||||
|
||||
/** [findPhoneLinks] for each of an event's free-text fields. */
|
||||
internal fun findEventPhoneLinks(title: String, location: String?, description: String?) = EventPhoneLinks(
|
||||
title = findPhoneLinks(title),
|
||||
location = location?.let(::findPhoneLinks).orEmpty(),
|
||||
description = description?.let(::findPhoneLinks).orEmpty(),
|
||||
)
|
||||
|
||||
// The matcher reads the region off the SIM, which throws on a device without
|
||||
// telephony; a field then just goes without phone links.
|
||||
private fun platformPhoneMatches(text: String): List<PhoneMatch> = runCatching {
|
||||
val spannable = SpannableString(text)
|
||||
if (!Linkify.addLinks(spannable, Linkify.PHONE_NUMBERS)) return emptyList()
|
||||
spannable.getSpans(0, spannable.length, URLSpan::class.java)
|
||||
.map { PhoneMatch(spannable.getSpanStart(it), spannable.getSpanEnd(it)) }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
/**
|
||||
* Turn the platform's [matches] in [text] into dialable links. The matcher is
|
||||
* lenient, so a match is dropped when it sits inside one of the [webLinks],
|
||||
* follows a meeting id or PIN label, holds a date, reads as an amount or is
|
||||
* too short. What's kept loses a stray count off its end, regains an
|
||||
* international "+49 (0)" prefix the matcher left behind, and carries a
|
||||
* conference dial-in suffix along.
|
||||
*/
|
||||
internal fun refinePhoneMatches(
|
||||
text: String,
|
||||
matches: List<PhoneMatch>,
|
||||
webLinks: List<WebLink>,
|
||||
): List<PhoneLink> {
|
||||
val links = mutableListOf<PhoneLink>()
|
||||
for (match in matches.sortedBy { it.start }) {
|
||||
var start = match.start
|
||||
var end = match.end
|
||||
val matched = text.substring(start, end)
|
||||
if (webLinks.any { start < it.end && it.start < end }) continue
|
||||
if (gluedIntoAWord(text, start, end)) continue
|
||||
val lineBefore = text.window(lineStart(text, start).coerceAtLeast(start - LOOK_BEHIND), start)
|
||||
if (notAPhoneLabelRegex.containsMatchIn(lineBefore)) continue
|
||||
if (dateRegex.findAll(matched).any { it.isDate() }) continue
|
||||
if (isDottedTimes(matched)) continue
|
||||
if (isAmount(text, start, end)) continue
|
||||
strayTailRegex.find(matched)?.let { tail ->
|
||||
if (wordAfterRegex.matchesAt(text, end)) end = start + tail.range.first
|
||||
}
|
||||
// "+49 (0)151 …": a matcher that stops at the "(0)" leaves the domestic
|
||||
// tail, which would dial without the country code.
|
||||
strandedCountryCodeRegex.find(text.window(start - LOOK_BEHIND, start))
|
||||
?.let { start -= it.value.length }
|
||||
val number = dialable(text.substring(start, end))
|
||||
if (number.count { it.isDigit() } < MIN_DIGITS) continue
|
||||
val suffix = dialSuffixRegex.find(text.window(end, end + LOOK_AHEAD))?.value.orEmpty()
|
||||
// A suffix swallowed what the matcher saw as the next number.
|
||||
if (links.lastOrNull()?.let { start < it.end } == true) continue
|
||||
links += PhoneLink(start, end + suffix.length, number + dialable(suffix))
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
// How far around a match the label, prefix, currency and suffix checks look.
|
||||
// Enough for any of them, and it keeps a long invite from being re-read whole
|
||||
// for every number in it.
|
||||
private const val LOOK_BEHIND = 40
|
||||
private const val LOOK_AHEAD = 60
|
||||
|
||||
private fun String.window(from: Int, to: Int): String =
|
||||
substring(from.coerceAtLeast(0), to.coerceAtMost(length))
|
||||
|
||||
/**
|
||||
* Digits run straight into a letter or a URL path — "zoom.us/j/850…" without
|
||||
* its https:// — which no web link covered. A phone number stands apart.
|
||||
*/
|
||||
private fun gluedIntoAWord(text: String, start: Int, end: Int): Boolean {
|
||||
val before = text.getOrNull(start - 1)
|
||||
val after = text.getOrNull(end)
|
||||
return (before != null && (before.isLetter() || before in "/=?&")) ||
|
||||
(after != null && (after.isLetter() || after == '/'))
|
||||
}
|
||||
|
||||
private fun isDottedTimes(matched: String): Boolean {
|
||||
val times = dottedTimesRegex.matchEntire(matched) ?: return false
|
||||
return times.groupValues.drop(1).chunked(2).all { (hour, minute) ->
|
||||
hour.isEmpty() || (hour.toInt() in 0..24 && minute.toInt() in 0..59)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAmount(text: String, start: Int, end: Int): Boolean {
|
||||
val matched = text.substring(start, end)
|
||||
if (shortLeadAmountRegex.matches(matched)) return true
|
||||
return amountRegex.matches(matched) &&
|
||||
(currencyBeforeRegex.containsMatchIn(text.window(start - LOOK_BEHIND, start)) ||
|
||||
currencyAfterRegex.containsMatchIn(text.window(end, end + LOOK_AHEAD)))
|
||||
}
|
||||
|
||||
private fun MatchResult.isDate(): Boolean {
|
||||
val (a, _, b, c) = destructured
|
||||
val first = a.toInt()
|
||||
val second = b.toInt()
|
||||
val last = c.toInt()
|
||||
return when {
|
||||
a.length == 4 -> second in 1..12 && last in 1..31 && c.length <= 2
|
||||
a.length > 2 -> false
|
||||
// Day first (most of the world) or month first (US).
|
||||
else -> (first in 1..31 && second in 1..12) || (first in 1..12 && second in 1..31)
|
||||
}
|
||||
}
|
||||
|
||||
private fun lineStart(text: String, index: Int): Int = text.lastIndexOf('\n', index - 1) + 1
|
||||
|
||||
/**
|
||||
* Strip [raw] down to what a dialer takes. An international number's "(0)"
|
||||
* trunk prefix — "+49 (0) 151 …" — is a domestic-only hint and would break the
|
||||
* call, so it goes too; an extension becomes a pause.
|
||||
*/
|
||||
private fun dialable(raw: String): String {
|
||||
val international = raw.trimStart('(').startsWith("+")
|
||||
val withoutTrunk = if (international) raw.replace(trunkPrefixRegex, "") else raw
|
||||
return withoutTrunk.replace(extensionRegex, ",")
|
||||
.filter { it.isDigit() || it == '+' || it == ',' || it == '#' || it == '*' }
|
||||
}
|
||||
@@ -36,6 +36,15 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import kotlinx.coroutines.launch
|
||||
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
|
||||
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
@@ -196,6 +205,10 @@ import kotlin.time.Instant
|
||||
* first asks how far the change reaches (this occurrence / this and
|
||||
* following / whole series) — then closes. The calendar picker offers only
|
||||
* writable calendars; when editing, the calendar is fixed.
|
||||
*
|
||||
* Leaving with unsaved changes asks to save, discard or keep editing (#178).
|
||||
* While editing, the overflow menu offers Duplicate (the form as it stands,
|
||||
* handed to [onDuplicate] as a new event), Share and Delete.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -209,6 +222,9 @@ fun EventEditScreen(
|
||||
initialForm: EventForm? = null,
|
||||
initialFormSource: ImportSource = ImportSource.File,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
onDuplicate: ((EventForm) -> Unit)? = null,
|
||||
onDeleted: () -> Unit = onSaved,
|
||||
onLoadFailed: () -> Unit = onClose,
|
||||
visible: Boolean = true,
|
||||
viewModel: EventEditViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -247,10 +263,19 @@ fun EventEditScreen(
|
||||
viewModel.reset()
|
||||
onClose()
|
||||
}
|
||||
// The event vanished between the detail screen and the edit tap — fall
|
||||
// back to the detail screen, which shows its own failure state.
|
||||
val hasUnsavedChanges by viewModel.hasUnsavedChanges.collectAsStateWithLifecycle()
|
||||
var showLeavePrompt by rememberSaveable { mutableStateOf(false) }
|
||||
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val requestClose = {
|
||||
if (hasUnsavedChanges) showLeavePrompt = true else close()
|
||||
}
|
||||
// The event vanished before the form could load it — fall back to the
|
||||
// detail screen, which shows its own failure state.
|
||||
LaunchedEffect(loadFailed) {
|
||||
if (loadFailed) close()
|
||||
if (loadFailed) {
|
||||
viewModel.reset()
|
||||
onLoadFailed()
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
@@ -274,14 +299,49 @@ fun EventEditScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val requireWrite = rememberWriteGate()
|
||||
val shareFailedMessage = stringResource(R.string.event_share_failed)
|
||||
val shareChooserTitle = stringResource(R.string.event_share_chooser_title)
|
||||
val onShareClick: () -> Unit = {
|
||||
scope.launch {
|
||||
val uri = viewModel.shareUri()
|
||||
val sent = uri != null && context.sendEventShare(uri, shareChooserTitle)
|
||||
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
|
||||
}
|
||||
}
|
||||
// The copy opens over this form; a form with nothing to lose steps aside.
|
||||
val onDuplicateClick: () -> Unit = {
|
||||
requireWrite {
|
||||
viewModel.duplicateForm()?.let { form ->
|
||||
onDuplicate?.invoke(form)
|
||||
if (!hasUnsavedChanges) close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val saveFailedMessage = stringResource(R.string.event_edit_save_failed)
|
||||
val writeDeniedMessage = stringResource(R.string.event_edit_write_denied)
|
||||
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
||||
val deleteDeniedMessage = stringResource(R.string.event_delete_write_denied)
|
||||
LaunchedEffect(state?.saveState) {
|
||||
when (state?.saveState) {
|
||||
SaveUiState.Saved -> {
|
||||
viewModel.reset()
|
||||
onSaved()
|
||||
}
|
||||
SaveUiState.Deleted -> {
|
||||
viewModel.reset()
|
||||
onDeleted()
|
||||
}
|
||||
SaveUiState.DeleteFailed -> {
|
||||
viewModel.consumeSaveResult()
|
||||
snackbarHostState.showSnackbar(deleteFailedMessage, duration = SnackbarDuration.Long)
|
||||
}
|
||||
SaveUiState.DeleteDenied -> {
|
||||
viewModel.consumeSaveResult()
|
||||
snackbarHostState.showSnackbar(deleteDeniedMessage, duration = SnackbarDuration.Long)
|
||||
}
|
||||
// A failed save leaves the form looking unchanged, so the snackbar is
|
||||
// the only sign anything happened — long rather than the default
|
||||
// flash (Codeberg #234: it read as "nothing happens at all").
|
||||
@@ -299,7 +359,7 @@ fun EventEditScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.predictiveBack(onBack = close, visible = visible),
|
||||
modifier = Modifier.predictiveBack(onBack = requestClose, visible = visible),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -307,7 +367,7 @@ fun EventEditScreen(
|
||||
// matching the detail screen's bare top bar.
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = close) {
|
||||
IconButton(onClick = requestClose) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.event_edit_close),
|
||||
@@ -315,6 +375,15 @@ fun EventEditScreen(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (state?.isEditing == true) {
|
||||
EditActionsMenu(
|
||||
canDelete = state?.isManaged == false,
|
||||
canDuplicate = onDuplicate != null,
|
||||
onDuplicate = onDuplicateClick,
|
||||
onShare = onShareClick,
|
||||
onDelete = { requireWrite { showDeleteDialog = true } },
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = onSaveClick,
|
||||
enabled = state != null && state?.saveState != SaveUiState.Saving,
|
||||
@@ -341,6 +410,32 @@ fun EventEditScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (showLeavePrompt) {
|
||||
UnsavedChangesDialog(
|
||||
onSave = {
|
||||
showLeavePrompt = false
|
||||
onSaveClick()
|
||||
},
|
||||
onDiscard = {
|
||||
showLeavePrompt = false
|
||||
close()
|
||||
},
|
||||
onDismiss = { showLeavePrompt = false },
|
||||
)
|
||||
}
|
||||
|
||||
val recurs = viewModel.editedEventRecurs()
|
||||
if (showDeleteDialog && recurs != null) {
|
||||
DeleteEventDialog(
|
||||
isRecurring = recurs,
|
||||
onConfirm = { deleteScope ->
|
||||
showDeleteDialog = false
|
||||
viewModel.delete(deleteScope)
|
||||
},
|
||||
onDismiss = { showDeleteDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (state?.saveState == SaveUiState.AwaitingScope) {
|
||||
SaveScopeDialog(
|
||||
recurrenceChanged = state?.recurrenceChanged == true,
|
||||
@@ -385,6 +480,90 @@ fun EventEditScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** Duplicate, Share and Delete for the event being edited, behind an overflow button. */
|
||||
@Composable
|
||||
private fun EditActionsMenu(
|
||||
canDelete: Boolean,
|
||||
canDuplicate: Boolean,
|
||||
onDuplicate: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(onClick = { open = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(R.string.event_edit_more_actions),
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||
if (canDuplicate) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.event_detail_duplicate)) },
|
||||
leadingIcon = { Icon(Icons.Default.ContentCopy, contentDescription = null) },
|
||||
onClick = {
|
||||
open = false
|
||||
onDuplicate()
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.event_detail_share)) },
|
||||
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) },
|
||||
onClick = {
|
||||
open = false
|
||||
onShare()
|
||||
},
|
||||
)
|
||||
// Managed special-dates events have no delete, as on the detail screen.
|
||||
if (canDelete) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.event_detail_delete)) },
|
||||
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) },
|
||||
onClick = {
|
||||
open = false
|
||||
onDelete()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asked when leaving the form with unsaved changes (#178). Dismissing keeps
|
||||
* editing; a form with nothing changed closes without asking.
|
||||
*/
|
||||
@Composable
|
||||
private fun UnsavedChangesDialog(
|
||||
onSave: () -> Unit,
|
||||
onDiscard: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.event_edit_unsaved_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_edit_unsaved_save),
|
||||
onClick = onSave,
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_edit_unsaved_discard),
|
||||
onClick = onDiscard,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.event_edit_unsaved_keep))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -77,6 +77,11 @@ sealed interface SaveUiState {
|
||||
data object Gone : SaveUiState
|
||||
data object Saving : SaveUiState
|
||||
data object Saved : SaveUiState
|
||||
/** The event was deleted from the form (#178). */
|
||||
data object Deleted : SaveUiState
|
||||
data object DeleteFailed : SaveUiState
|
||||
/** WRITE_CALENDAR was revoked before a delete from the form. */
|
||||
data object DeleteDenied : SaveUiState
|
||||
/** WRITE_CALENDAR was revoked between the tap and the provider call. */
|
||||
data object NeedsPermission : SaveUiState
|
||||
data object Failed : SaveUiState
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.edit
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -7,6 +8,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||
@@ -19,6 +21,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EditSnapshot
|
||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.endInstant
|
||||
import de.jeanlucmakiola.calendula.domain.nextFullHour
|
||||
@@ -36,6 +39,9 @@ import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||
import de.jeanlucmakiola.calendula.domain.problems
|
||||
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
||||
import de.jeanlucmakiola.calendula.ui.common.DeleteOutcome
|
||||
import de.jeanlucmakiola.calendula.ui.common.tryDeleteEvent
|
||||
import de.jeanlucmakiola.calendula.ui.common.stageEventShare
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -142,6 +148,7 @@ class EventEditViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
private val prefs: CalendarPrefs,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val icsExporter: IcsExporter,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
private val zoneSource: ZoneSource,
|
||||
) : ViewModel() {
|
||||
@@ -171,6 +178,9 @@ class EventEditViewModel @Inject constructor(
|
||||
// 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)
|
||||
// Set by the first change the user makes, so a form only holding its own
|
||||
// defaults or an import's prefill can be left without asking.
|
||||
private val _edited = MutableStateFlow(false)
|
||||
|
||||
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
||||
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
||||
@@ -191,6 +201,8 @@ class EventEditViewModel @Inject constructor(
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
val zone: TimeZone,
|
||||
/** The event as loaded, on the tapped occurrence's times — what Share exports. */
|
||||
val detail: EventDetail,
|
||||
) {
|
||||
val original: EventForm get() = snapshot.form
|
||||
}
|
||||
@@ -330,6 +342,18 @@ class EventEditViewModel @Inject constructor(
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether leaving now would lose something the user did (#178): an edit that
|
||||
* differs from the loaded event, or any change to a new or imported form.
|
||||
*/
|
||||
val hasUnsavedChanges: StateFlow<Boolean> = combine(_form, _editTarget, _edited) { form, target, edited ->
|
||||
when {
|
||||
form == null -> false
|
||||
target != null -> form != target.original
|
||||
else -> edited
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
|
||||
/**
|
||||
* First day of the week for ordering the recurrence weekday toggles — the
|
||||
* app's "week starts on" preference, matching the calendar views (Auto
|
||||
@@ -518,7 +542,7 @@ class EventEditViewModel @Inject constructor(
|
||||
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) }
|
||||
edit { it.copy(reminders = prompt.defaultReminders) }
|
||||
_revealed.value = _revealed.value + EventFormField.Reminders
|
||||
}
|
||||
|
||||
@@ -553,7 +577,13 @@ class EventEditViewModel @Inject constructor(
|
||||
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)
|
||||
val occurrence = detail.copy(
|
||||
instance = detail.instance.copy(
|
||||
start = Instant.fromEpochMilliseconds(begin),
|
||||
end = Instant.fromEpochMilliseconds(end),
|
||||
),
|
||||
)
|
||||
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone, occurrence)
|
||||
// Sections holding data must show even when not in the defaults.
|
||||
_revealed.value = snapshot.form.populatedFields()
|
||||
_form.value = snapshot.form
|
||||
@@ -571,6 +601,7 @@ class EventEditViewModel @Inject constructor(
|
||||
_remindersTouched.value = false
|
||||
_durationTouched.value = false
|
||||
_importReminderPrompt.value = null
|
||||
_edited.value = false
|
||||
}
|
||||
|
||||
/** Unfold one optional field, picked in the "more fields" dialog. */
|
||||
@@ -581,18 +612,18 @@ class EventEditViewModel @Inject constructor(
|
||||
// The title field wraps (multi-line) so long titles stay visible (#33), but
|
||||
// a title is one logical line: drop any newline the IME's Enter key or a
|
||||
// paste would introduce, so it never reaches the provider's TITLE column.
|
||||
fun setTitle(value: String) = update {
|
||||
fun setTitle(value: String) = edit {
|
||||
val cleaned = value.replace("\n", "").replace("\r", "")
|
||||
it.copy(title = capLength(cleaned, it.title))
|
||||
}
|
||||
// The location wraps too (#273) but is likewise one logical line. A pasted
|
||||
// multi-line address joins the way the contact picker's does (#146), so it
|
||||
// never runs together into "12 Main St10115 Berlin".
|
||||
fun setLocation(value: String) = update {
|
||||
fun setLocation(value: String) = edit {
|
||||
val cleaned = value.replace(LINE_BREAK, ", ")
|
||||
it.copy(location = capLength(cleaned, it.location))
|
||||
}
|
||||
fun setDescription(value: String) = update { it.copy(description = value) }
|
||||
fun setDescription(value: String) = edit { it.copy(description = value) }
|
||||
fun setAllDay(value: Boolean) {
|
||||
// Going all-day drops any pinned zone: the times become bare dates that
|
||||
// the provider stores UTC-anchored, so a zone could only misrepresent
|
||||
@@ -600,7 +631,7 @@ class EventEditViewModel @Inject constructor(
|
||||
// which is the same thing a fresh event gets.
|
||||
// A kept pass of a repeated hour stays, so switching back out of all-day
|
||||
// lands where the event was — unless dropping the pin changed the zone.
|
||||
update {
|
||||
edit {
|
||||
if (value) it.withTimezone(null, zoneSource.current()).copy(isAllDay = true) else it.copy(isAllDay = false)
|
||||
}
|
||||
// The default reminder differs for all-day vs timed; re-apply the
|
||||
@@ -617,7 +648,7 @@ class EventEditViewModel @Inject constructor(
|
||||
* time — only real picks are remembered, not the device fallback.
|
||||
*/
|
||||
fun setTimezone(zoneId: String?) {
|
||||
update { it.withTimezone(zoneId, zoneSource.current()) }
|
||||
edit { it.withTimezone(zoneId, zoneSource.current()) }
|
||||
if (zoneId != null) {
|
||||
viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } }
|
||||
}
|
||||
@@ -629,35 +660,35 @@ class EventEditViewModel @Inject constructor(
|
||||
* The event falls back to the new calendar's colour until re-picked.
|
||||
*/
|
||||
fun setCalendar(id: Long) {
|
||||
update { it.copy(calendarId = id, colorKey = null, color = null) }
|
||||
edit { it.copy(calendarId = id, colorKey = null, color = null) }
|
||||
// A fresh event re-inherits the new calendar's default reminder and
|
||||
// length unless the user has already hand-edited them (guarded inside).
|
||||
applyDefaultReminder(id)
|
||||
applyDefaultDuration(id)
|
||||
}
|
||||
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
||||
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
||||
fun setAvailability(value: Availability) = edit { it.copy(availability = value) }
|
||||
fun setAccessLevel(value: AccessLevel) = edit { it.copy(accessLevel = value) }
|
||||
|
||||
/** Pick a palette colour (round-trips via its key); [argb] is its swatch. */
|
||||
fun setColorKey(key: String, argb: Int) = update { it.copy(colorKey = key, color = argb) }
|
||||
fun setColorKey(key: String, argb: Int) = edit { it.copy(colorKey = key, color = argb) }
|
||||
|
||||
/** Pick a raw colour (local / opted-in calendars), clearing any palette key. */
|
||||
fun setColorRaw(argb: Int) = update { it.copy(colorKey = null, color = argb) }
|
||||
fun setColorRaw(argb: Int) = edit { it.copy(colorKey = null, color = argb) }
|
||||
|
||||
/** Clear the colour so the event inherits its calendar's. */
|
||||
fun clearColor() = update { it.copy(colorKey = null, color = null) }
|
||||
fun clearColor() = edit { it.copy(colorKey = null, color = null) }
|
||||
|
||||
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
|
||||
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
|
||||
fun setRecurrence(rrule: String?) = edit { it.copy(rrule = rrule) }
|
||||
|
||||
fun addReminder(minutes: Int) {
|
||||
_remindersTouched.value = true
|
||||
update { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) }
|
||||
edit { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) }
|
||||
}
|
||||
|
||||
fun removeReminder(minutes: Int) {
|
||||
_remindersTouched.value = true
|
||||
update { it.copy(reminders = it.reminders - minutes) }
|
||||
edit { it.copy(reminders = it.reminders - minutes) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -668,7 +699,7 @@ class EventEditViewModel @Inject constructor(
|
||||
fun addAttendee(email: String, name: String = "", optional: Boolean = false) {
|
||||
val trimmed = email.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
update { form ->
|
||||
edit { form ->
|
||||
val attendee = EventAttendee(email = trimmed, name = name.trim(), optional = optional)
|
||||
val rest = form.attendees.filterNot { it.email.equals(trimmed, ignoreCase = true) }
|
||||
form.copy(attendees = rest + attendee)
|
||||
@@ -676,10 +707,10 @@ class EventEditViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun removeAttendee(attendee: EventAttendee) =
|
||||
update { it.copy(attendees = it.attendees.filterNot { a -> a.email.equals(attendee.email, ignoreCase = true) }) }
|
||||
edit { it.copy(attendees = it.attendees.filterNot { a -> a.email.equals(attendee.email, ignoreCase = true) }) }
|
||||
|
||||
/** Flip a guest between required and optional (the per-row role toggle). */
|
||||
fun setAttendeeOptional(attendee: EventAttendee, optional: Boolean) = update { form ->
|
||||
fun setAttendeeOptional(attendee: EventAttendee, optional: Boolean) = edit { form ->
|
||||
form.copy(
|
||||
attendees = form.attendees.map { a ->
|
||||
if (a.email.equals(attendee.email, ignoreCase = true)) a.copy(optional = optional) else a
|
||||
@@ -836,16 +867,43 @@ class EventEditViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the event being edited recurs; null while not editing. */
|
||||
fun editedEventRecurs(): Boolean? = _editTarget.value?.let { !it.detail.rrule.isNullOrBlank() }
|
||||
|
||||
/** The form as it stands, as a fresh one-off event — Duplicate in the edit form (#178). */
|
||||
fun duplicateForm(): EventForm? = state.value?.form?.copy(rrule = null)
|
||||
|
||||
/** A staged `.ics` of the event as saved, or null when it can't be built. */
|
||||
suspend fun shareUri(): Uri? {
|
||||
val detail = _editTarget.value?.detail ?: return null
|
||||
return withContext(io) { icsExporter.stageEventShare(detail) }
|
||||
}
|
||||
|
||||
/** Delete the event being edited, as far as [scope] reaches; result lands in [saveState]. */
|
||||
fun delete(scope: RecurringWriteScope) {
|
||||
val target = _editTarget.value ?: return
|
||||
if (_saveState.value == SaveUiState.Saving) return
|
||||
viewModelScope.launch {
|
||||
_saveState.value = SaveUiState.Saving
|
||||
// Already gone is what a delete wants anyway.
|
||||
_saveState.value = when (repository.tryDeleteEvent(target.eventId, target.beginMillis, scope)) {
|
||||
DeleteOutcome.Deleted, DeleteOutcome.Gone -> SaveUiState.Deleted
|
||||
DeleteOutcome.NeedsPermission -> SaveUiState.DeleteDenied
|
||||
DeleteOutcome.Failed -> SaveUiState.DeleteFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset [saveState] after the screen handled a terminal result. */
|
||||
fun consumeSaveResult() {
|
||||
_saveState.value = SaveUiState.Idle
|
||||
}
|
||||
|
||||
private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = update { form ->
|
||||
private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = edit { form ->
|
||||
form.withStart(transform(form.start.dateTime), form.resolvedZone(zoneSource.current()), opened(form)?.start)
|
||||
}
|
||||
|
||||
private fun moveEnd(transform: (LocalDateTime) -> LocalDateTime) = update { form ->
|
||||
private fun moveEnd(transform: (LocalDateTime) -> LocalDateTime) = edit { form ->
|
||||
form.withEnd(transform(form.end.dateTime), form.resolvedZone(zoneSource.current()), opened(form)?.end)
|
||||
}
|
||||
|
||||
@@ -859,4 +917,12 @@ class EventEditViewModel @Inject constructor(
|
||||
private inline fun update(block: (EventForm) -> EventForm) {
|
||||
_form.value = _form.value?.let(block)
|
||||
}
|
||||
|
||||
/** A change the user made, as opposed to a default the form applied itself. */
|
||||
private inline fun edit(block: (EventForm) -> EventForm) {
|
||||
val current = _form.value ?: return
|
||||
val next = block(current)
|
||||
if (next != current) _edited.value = true
|
||||
_form.value = next
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,8 @@ data class SettingsUiState(
|
||||
val todayButtonInToolbar: Boolean = false,
|
||||
/** Whether events can be dragged to another slot to reschedule them (#68, #173). */
|
||||
val dragToReschedule: Boolean = true,
|
||||
/** Whether tapping an event opens the edit form instead of the detail screen (#178). */
|
||||
val tapOpensEditor: Boolean = false,
|
||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||
/** How far ahead the agenda widget shows events (v2.11). */
|
||||
|
||||
@@ -148,13 +148,16 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.softenCalendarColors,
|
||||
combine(
|
||||
prefs.todayButtonInToolbar,
|
||||
prefs.dragToReschedule,
|
||||
combine(prefs.dragToReschedule, prefs.tapOpensEditor, ::Pair),
|
||||
prefs.multiDayCount,
|
||||
::Triple,
|
||||
),
|
||||
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove, multiDay) ->
|
||||
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, gestures, multiDay) ->
|
||||
DisplayToggles(
|
||||
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove, multiDay,
|
||||
hourLines, weekNumbers, showToday, soften, todayInToolbar,
|
||||
dragToReschedule = gestures.first,
|
||||
tapOpensEditor = gestures.second,
|
||||
multiDayCount = multiDay,
|
||||
)
|
||||
},
|
||||
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
||||
@@ -166,6 +169,7 @@ class SettingsViewModel @Inject constructor(
|
||||
softenColors = toggles.softenColors,
|
||||
todayButtonInToolbar = toggles.todayButtonInToolbar,
|
||||
dragToReschedule = toggles.dragToReschedule,
|
||||
tapOpensEditor = toggles.tapOpensEditor,
|
||||
multiDayCount = toggles.multiDayCount,
|
||||
)
|
||||
},
|
||||
@@ -202,6 +206,7 @@ class SettingsViewModel @Inject constructor(
|
||||
softenColors = views.softenColors,
|
||||
todayButtonInToolbar = views.todayButtonInToolbar,
|
||||
dragToReschedule = views.dragToReschedule,
|
||||
tapOpensEditor = views.tapOpensEditor,
|
||||
multiDayCount = views.multiDayCount,
|
||||
agendaShowRangeBar = misc.showRangeBar,
|
||||
autofocusEventTitle = misc.autofocusEventTitle,
|
||||
@@ -305,6 +310,7 @@ class SettingsViewModel @Inject constructor(
|
||||
val softenColors: Boolean,
|
||||
val todayButtonInToolbar: Boolean,
|
||||
val dragToReschedule: Boolean,
|
||||
val tapOpensEditor: Boolean,
|
||||
val multiDayCount: Int,
|
||||
)
|
||||
|
||||
@@ -315,6 +321,7 @@ class SettingsViewModel @Inject constructor(
|
||||
val softenColors: Boolean,
|
||||
val todayButtonInToolbar: Boolean,
|
||||
val dragToReschedule: Boolean,
|
||||
val tapOpensEditor: Boolean,
|
||||
val multiDayCount: Int,
|
||||
)
|
||||
|
||||
@@ -562,6 +569,10 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setDragToReschedule(enabled) }
|
||||
}
|
||||
|
||||
fun setTapOpensEditor(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setTapOpensEditor(enabled) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the launcher label (#44). The card highlights at once, then settles
|
||||
* on what the component state reports; the writes are binder round-trips and
|
||||
|
||||
@@ -74,6 +74,7 @@ internal fun ViewsScreen(
|
||||
var showDefaultView by remember { mutableStateOf(false) }
|
||||
var showWeekStart by remember { mutableStateOf(false) }
|
||||
var showTimeFormat by remember { mutableStateOf(false) }
|
||||
var showTapOpens by remember { mutableStateOf(false) }
|
||||
var showPastEvents by remember { mutableStateOf(false) }
|
||||
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||
var showTimelineScale by remember { mutableStateOf(false) }
|
||||
@@ -125,7 +126,7 @@ internal fun ViewsScreen(
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_dim_completed),
|
||||
summary = stringResource(R.string.settings_dim_completed_summary),
|
||||
position = Position.Middle,
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.dimCompletedEvents,
|
||||
@@ -134,6 +135,17 @@ internal fun ViewsScreen(
|
||||
},
|
||||
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// How events respond to a tap and a long press (#178, #141).
|
||||
SectionHeader(stringResource(R.string.settings_events_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_tap_opens),
|
||||
summary = tapOpensLabel(state.tapOpensEditor),
|
||||
position = Position.Top,
|
||||
onClick = { showTapOpens = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_drag_to_reschedule),
|
||||
summary = stringResource(R.string.settings_drag_to_reschedule_summary),
|
||||
@@ -387,6 +399,18 @@ internal fun ViewsScreen(
|
||||
onDismiss = { showTimeFormat = false },
|
||||
)
|
||||
}
|
||||
if (showTapOpens) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_tap_opens),
|
||||
header = { PickerDescription(stringResource(R.string.settings_tap_opens_hint)) },
|
||||
predictiveBack = true,
|
||||
options = listOf(false, true),
|
||||
selected = state.tapOpensEditor,
|
||||
label = { tapOpensLabel(it) },
|
||||
onSelect = viewModel::setTapOpensEditor,
|
||||
onDismiss = { showTapOpens = false },
|
||||
)
|
||||
}
|
||||
if (showPastEvents) {
|
||||
PastEventsPicker(
|
||||
selected = state.pastEventDisplay,
|
||||
@@ -522,6 +546,11 @@ internal fun weekStartLabel(pref: WeekStartPref): String = when (pref) {
|
||||
.getDisplayName(JavaTextStyle.FULL, currentLocale())
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun tapOpensLabel(editor: Boolean): String = stringResource(
|
||||
if (editor) R.string.settings_tap_opens_editor else R.string.settings_tap_opens_details,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource(
|
||||
when (pref) {
|
||||
|
||||
@@ -118,6 +118,11 @@
|
||||
<!-- Event form (v1.2 create) -->
|
||||
<string name="event_edit_new_title">New event</string>
|
||||
<string name="event_edit_close">Close</string>
|
||||
<string name="event_edit_more_actions">More actions</string>
|
||||
<string name="event_edit_unsaved_title">Unsaved changes</string>
|
||||
<string name="event_edit_unsaved_save">Save changes</string>
|
||||
<string name="event_edit_unsaved_discard">Discard changes</string>
|
||||
<string name="event_edit_unsaved_keep">Keep editing</string>
|
||||
<string name="event_edit_save">Save</string>
|
||||
<string name="event_edit_title_hint">Add title</string>
|
||||
<!-- %1$s is the managing calendar's name, e.g. "Birthdays". -->
|
||||
@@ -518,6 +523,11 @@
|
||||
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
|
||||
<string name="settings_drag_to_reschedule">Drag to reschedule</string>
|
||||
<string name="settings_drag_to_reschedule_summary">Move an event by dragging it to another day or time</string>
|
||||
<string name="settings_events_header">Events</string>
|
||||
<string name="settings_tap_opens">Tapping an event opens</string>
|
||||
<string name="settings_tap_opens_details">Details</string>
|
||||
<string name="settings_tap_opens_editor">Edit form</string>
|
||||
<string name="settings_tap_opens_hint">Events you can\'t edit and reminder notifications always open the details.</string>
|
||||
<string name="settings_past_events">Past events</string>
|
||||
<string name="settings_past_events_show">Show</string>
|
||||
<string name="settings_past_events_dim">Dim</string>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package de.jeanlucmakiola.calendula.ui.detail
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The fitting applied on top of the platform matcher. What the matcher itself
|
||||
* finds is covered on a device by PhoneLinksPlatformTest.
|
||||
*/
|
||||
class PhoneLinksTest {
|
||||
|
||||
/** Refine a match the platform found at [found] in [text]. */
|
||||
private fun refine(text: String, found: String): List<PhoneLink> {
|
||||
val start = text.indexOf(found)
|
||||
return refinePhoneMatches(
|
||||
text,
|
||||
listOf(PhoneMatch(start, start + found.length)),
|
||||
findWebLinks(text),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a match keeps its span and dials only digits`() {
|
||||
val text = "Call Anna on 0151 234-5678 before"
|
||||
val link = refine(text, "0151 234-5678").single()
|
||||
assertThat(link.number).isEqualTo("01512345678")
|
||||
assertThat(text.substring(link.start, link.end)).isEqualTo("0151 234-5678")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an international number keeps its plus`() {
|
||||
assertThat(refine("Tel. +49 30 1234567", "+49 30 1234567").single().number)
|
||||
.isEqualTo("+49301234567")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the trunk zero of an international number is dropped`() {
|
||||
assertThat(refine("+49 (0) 151 2345678", "+49 (0) 151 2345678").single().number)
|
||||
.isEqualTo("+491512345678")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a country code stranded before the trunk zero is pulled back in`() {
|
||||
val text = "Mobil: +49 (0)151 2345678"
|
||||
val link = refine(text, "151 2345678").single()
|
||||
assertThat(link.number).isEqualTo("+491512345678")
|
||||
assertThat(text.substring(link.start, link.end)).isEqualTo("+49 (0)151 2345678")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a conference dial-in keeps its pauses and meeting id`() {
|
||||
val text = "One tap mobile: +16465588656,,85012345678#,,,,*123456# (US)"
|
||||
val link = refine(text, "+16465588656").single()
|
||||
assertThat(link.number).isEqualTo("+16465588656,,85012345678#,,,,*123456#")
|
||||
assertThat(text.substring(link.start, link.end))
|
||||
.isEqualTo("+16465588656,,85012345678#,,,,*123456#")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `meeting ids and PINs are not numbers to call`() {
|
||||
assertThat(refine("Meeting ID: 850 1234 5678", "850 1234 5678")).isEmpty()
|
||||
assertThat(refine("Conference ID: 123 456 789#", "123 456 789")).isEmpty()
|
||||
assertThat(refine("PIN: 123 456 789#", "123 456 789")).isEmpty()
|
||||
assertThat(refine("Konferenz-ID: 123 456 789#", "123 456 789")).isEmpty()
|
||||
assertThat(refine("Access code: 2345 678 9012", "2345 678 9012")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a label on an earlier line does not hide a number`() {
|
||||
assertThat(refine("Meeting ID: 850 1234 5678\nDial +1 646 558 8656", "+1 646 558 8656"))
|
||||
.hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a label earlier on the same line does not hide a later number`() {
|
||||
assertThat(refine("PIN 1234, call +1 646 558 8656", "+1 646 558 8656")).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a match holding a date is not a number`() {
|
||||
assertThat(refine("Am 14.10.2026 10:00 Uhr", "14.10.2026 10")).isEmpty()
|
||||
assertThat(refine("2026-10-14 10:00", "2026-10-14 10")).isEmpty()
|
||||
assertThat(refine("10/14/2026 9:00", "10/14/2026 9")).isEmpty()
|
||||
assertThat(refine("14.10.26 - 16.10.26", "14.10.26 - 16.10.26")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `amounts are not numbers`() {
|
||||
assertThat(refine("Budget 1.250.000 EUR", "1.250.000")).isEmpty()
|
||||
assertThat(refine("Budget 1 250 000 EUR", "1 250 000")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an area code in threes is still a number`() {
|
||||
assertThat(refine("030 123 456", "030 123 456")).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `numbers in dotted pairs are not dates`() {
|
||||
assertThat(refine("01.23.45.67.89", "01.23.45.67.89").single().number)
|
||||
.isEqualTo("0123456789")
|
||||
assertThat(refine("555.123.4567", "555.123.4567")).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stray count after the number is trimmed`() {
|
||||
val text = "Call +49 151 2345678 2 times"
|
||||
val link = refine(text, "+49 151 2345678 2").single()
|
||||
assertThat(link.number).isEqualTo("+491512345678")
|
||||
assertThat(text.substring(link.start, link.end)).isEqualTo("+49 151 2345678")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `short trailing groups of a real number stay`() {
|
||||
assertThat(refine("030 123 45 67", "030 123 45 67").single().number).isEqualTo("0301234567")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spaced threes without a currency are a number`() {
|
||||
assertThat(refine("Móvil 612 345 678", "612 345 678").single().number).isEqualTo("612345678")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a trailing pair after a short group stays`() {
|
||||
val text = "Büro 0221 1234 56"
|
||||
val link = refine(text, "0221 1234 56").single()
|
||||
assertThat(link.number).isEqualTo("0221123456")
|
||||
assertThat(text.substring(link.start, link.end)).isEqualTo("0221 1234 56")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an extension becomes a pause`() {
|
||||
assertThat(refine("+1 212 555 1234 ext. 567", "+1 212 555 1234 ext. 567").single().number)
|
||||
.isEqualTo("+12125551234,567")
|
||||
assertThat(refine("+1 212 555 1234 x567", "+1 212 555 1234 x567").single().number)
|
||||
.isEqualTo("+12125551234,567")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `postcodes and other short runs are not numbers`() {
|
||||
assertThat(refine("Hauptstraße 12, 10115 Berlin", "10115")).isEmpty()
|
||||
assertThat(refine("Raum 123456", "123456")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a Webex access code is not a number to call`() {
|
||||
assertThat(refine("Meeting number (access code): 2732 123 4567", "2732 123 4567")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a conference number is a number to call`() {
|
||||
assertThat(refine("Conference number: +49 30 1234567", "+49 30 1234567")).hasSize(1)
|
||||
assertThat(refine("Konferenznummer: +49 30 1234567", "+49 30 1234567")).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a comma-separated list stays separate numbers`() {
|
||||
val text = "Tel 030 1234567,0151 2345678"
|
||||
val first = text.indexOf("030")
|
||||
val second = text.indexOf("0151")
|
||||
val links = refinePhoneMatches(
|
||||
text,
|
||||
listOf(PhoneMatch(first, first + 11), PhoneMatch(second, text.length)),
|
||||
emptyList(),
|
||||
)
|
||||
assertThat(links.map { it.number }).containsExactly("0301234567", "01512345678").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single-comma suffix closed by a hash is a dial-in`() {
|
||||
assertThat(refine("+1 555 123 4567,123456#", "+1 555 123 4567").single().number)
|
||||
.isEqualTo("+15551234567,123456#")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `labels behind invisible spacing still hide an id`() {
|
||||
assertThat(refine("PIN: \u202A123 456 789\u202C#", "123 456 789")).isEmpty()
|
||||
assertThat(refine("Meeting ID:\u00A0850 1234 5678", "850 1234 5678")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dotted times of day are not numbers`() {
|
||||
assertThat(refine("Beginn 9.30 – 12.00 Uhr", "9.30 – 12.00")).isEmpty()
|
||||
assertThat(refine("10.00-11.30", "10.00-11.30")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a trailing digit with no word after it stays`() {
|
||||
assertThat(refine("Zentrale 030 123456 0", "030 123456 0").single().number)
|
||||
.isEqualTo("0301234560")
|
||||
assertThat(refine("0221 9876543 21\nnext line", "0221 9876543 21").single().number)
|
||||
.isEqualTo("0221987654321")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `digits in a URL without its scheme are not a number`() {
|
||||
assertThat(refine("zoom.us/j/85012345678", "85012345678")).isEmpty()
|
||||
assertThat(refine("room?id=0301234567", "0301234567")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dotted threes without a currency are a number`() {
|
||||
assertThat(refine("Móvil 612.345.678", "612.345.678")).hasSize(1)
|
||||
assertThat(refine("Budget 125.000.000 €", "125.000.000")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `digits inside a web link are not a number`() {
|
||||
assertThat(refine("https://x.example/030-1234567", "030-1234567")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a number beside a web link is still found`() {
|
||||
assertThat(refine("https://zoom.us/j/85012345678 or dial +1 646 558 8656", "+1 646 558 8656"))
|
||||
.hasSize(1)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
package de.jeanlucmakiola.calendula.ui.edit
|
||||
|
||||
import android.content.ContextWrapper
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.time.FakeZoneSource
|
||||
@@ -28,6 +31,7 @@ import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
@@ -107,7 +111,8 @@ class EventEditViewModelTest {
|
||||
): EventEditViewModel {
|
||||
val p = prefs(tempDir)
|
||||
val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher)
|
||||
return EventEditViewModel(repo, p, s, dispatcher, zones)
|
||||
// Only `shareUri()` touches the exporter, and nothing here shares.
|
||||
return EventEditViewModel(repo, p, s, IcsExporter(ContextWrapper(null)), dispatcher, zones)
|
||||
}
|
||||
|
||||
/** Keep [EventEditViewModel.state] hot so it computes while the test drives it. */
|
||||
@@ -420,6 +425,151 @@ class EventEditViewModelTest {
|
||||
assertThat(capLength(new, "")).isEqualTo("a".repeat(254))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an edit counts as unsaved only while it differs from the loaded event`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L))
|
||||
eventDetailResult = { detail(calendarId = 1L) }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.hasUnsavedChanges.value).isFalse()
|
||||
|
||||
vm.setTitle("Retro")
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.hasUnsavedChanges.value).isTrue()
|
||||
|
||||
vm.setTitle("Standup")
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.hasUnsavedChanges.value).isFalse()
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a new form holding only its defaults has nothing to lose`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openNew(LocalDate(2026, 10, 12), startMinutes = 9 * 60)
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.hasUnsavedChanges.value).isFalse()
|
||||
|
||||
vm.setTitle("Lunch")
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.hasUnsavedChanges.value).isTrue()
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting from the form removes the event and reports it`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L))
|
||||
eventDetailResult = { detail(calendarId = 1L) }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||
advanceUntilIdle()
|
||||
vm.delete(RecurringWriteScope.AllEvents)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(fake.deletedEventIds).containsExactly(42L)
|
||||
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Deleted)
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting an event that is already gone counts as deleted`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L))
|
||||
eventDetailResult = { detail(calendarId = 1L) }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||
advanceUntilIdle()
|
||||
fake.writeError = NoSuchEventException(42L)
|
||||
vm.delete(RecurringWriteScope.AllEvents)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Deleted)
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a delete without write access reports it as a delete`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L))
|
||||
eventDetailResult = { detail(calendarId = 1L) }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||
advanceUntilIdle()
|
||||
fake.writeError = SecurityException()
|
||||
vm.delete(RecurringWriteScope.AllEvents)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.DeleteDenied)
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank rrule does not make the edited event recurring`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L))
|
||||
eventDetailResult = { detail(calendarId = 1L, rrule = "") }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.editedEventRecurs()).isFalse()
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accepting the default reminders for an import counts as a change`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
|
||||
val s = settings(tempDir)
|
||||
s.setDefaultReminderMinutes(listOf(15))
|
||||
val vm = viewModel(tempDir, fake, s)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openImported(importedForm(end = LocalDateTime(2030, 1, 15, 10, 0)), ImportSource.File)
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.importReminderPrompt.value).isNotNull()
|
||||
assertThat(vm.hasUnsavedChanges.value).isFalse()
|
||||
|
||||
vm.applyImportedReminderDefault()
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.hasUnsavedChanges.value).isTrue()
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drafted block opens at its own instants across the fall-back hour`(
|
||||
@TempDir tempDir: Path,
|
||||
|
||||
@@ -119,7 +119,11 @@ gesture finishes from its scaled preview.
|
||||
switch only changes what they say.
|
||||
A tapped reminder notification routes through `MainActivity` (`singleTop` +
|
||||
`onNewIntent`) as an external detail key that `CalendarHost` consumes
|
||||
exactly like an event tap.
|
||||
like an event tap. With the "tapping an event opens" setting on the edit form
|
||||
(#178), a tap in the views or search opens the edit overlay directly for events
|
||||
in writable calendars; notifications and widgets always open the detail. The
|
||||
edit form guards its own back: unsaved changes ask to save, discard or keep
|
||||
editing.
|
||||
|
||||
### First-launch wizard
|
||||
|
||||
|
||||
Reference in new issue
Block a user