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:
makiolaj committed 2026-10-14 15:33:29 +02:00
commit 3ec0403b00
22 files changed
+1220 -98

No files matched your search

+2
View File
@@ -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
@@ -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) {
+10
View File
@@ -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,
+5 -1
View File
@@ -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