diff --git a/CHANGELOG.md b/CHANGELOG.md index f3803d8..937ba43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/androidTest/java/de/jeanlucmakiola/calendula/ui/detail/PhoneLinksPlatformTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/calendula/ui/detail/PhoneLinksPlatformTest.kt new file mode 100644 index 0000000..254ae5e --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/calendula/ui/detail/PhoneLinksPlatformTest.kt @@ -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() + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index bc098c9..25ced1c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -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 = 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") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 23b2b09..35066fe 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -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(null) } var heldKey by remember { mutableStateOf(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"), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt index 789e9a7..d3f6477 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt @@ -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 = prefs.tapOpensEditor + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = false, + ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CopyToClipboard.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CopyToClipboard.kt index f8920c5..8163de6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CopyToClipboard.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CopyToClipboard.kt @@ -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 }) + } + } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt index 500cade..319c65b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt @@ -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 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) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActionsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActionsViewModel.kt index f05a5e7..8837a3d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActionsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActionsViewModel.kt @@ -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 } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index c191839..c45564c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -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, + 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() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt index 6f6dd97..f4a7573 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt @@ -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 } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt index 9f60ab7..8dcace1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/PhoneLinks.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/PhoneLinks.kt new file mode 100644 index 0000000..ed562f9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/PhoneLinks.kt @@ -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 = emptyList(), + val location: List = emptyList(), + val description: List = 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("""(? = + 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 = 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, + webLinks: List, +): List { + val links = mutableListOf() + 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 == '*' } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index ffe5362..4415532 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt index 6a68d04..1be5d3b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index f66a971..57ea286 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -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(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 = _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 = 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 + } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 16111c1..0af6fd7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -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). */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 75ebca1..5bee5a8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt index 22ccb1a..1f8bf67 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt @@ -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) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 259e1e1..dd8bbd5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -118,6 +118,11 @@ New event Close + More actions + Unsaved changes + Save changes + Discard changes + Keep editing Save Add title @@ -518,6 +523,11 @@ Fade events that have already ended in month and week view Drag to reschedule Move an event by dragging it to another day or time + Events + Tapping an event opens + Details + Edit form + Events you can\'t edit and reminder notifications always open the details. Past events Show Dim diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/PhoneLinksTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/PhoneLinksTest.kt new file mode 100644 index 0000000..662236e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/PhoneLinksTest.kt @@ -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 { + 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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt index bc14943..cedd0ed 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt @@ -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, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f5d31dc..77fc57c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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