Compare commits

...

25 Commits

Author SHA1 Message Date
494e486998 Merge remote-tracking branch 'origin/release/v2.14.0' into feat/wrap-long-titles
All checks were successful
CI / ci (pull_request) Successful in 10m31s
2026-07-06 22:28:42 +02:00
3c73028c80 Wrap long event titles in the edit screen (#33)
The edit-screen title field was single-line, so long titles scrolled off
one line instead of wrapping. Make it multi-line so it wraps and grows
vertically, matching the detail screen and Google Calendar.

A title is still one logical line: strip any newline the IME's Enter key
or a paste would introduce in setTitle, so no line break reaches the
provider's TITLE column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 22:28:31 +02:00
2be1be19fe Merge pull request 'feat: week-of-year numbers in Month view (#25)' (!64) from feat/month-week-numbers into release/v2.14.0
All checks were successful
Translations / check (pull_request) Successful in 7s
CI / ci (pull_request) Successful in 10m43s
Reviewed-on: #64
2026-07-06 20:26:13 +00:00
1f050c2be9 feat: make Month week-number a full-height cell like the day cells
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 6m12s
Per on-device review, render the week number as a full-height tonal pill
mirroring the day cells' geometry (secondaryContainer tint, same rounded
shape and gap), with the number centred — so the gutter reads as part of the
grid rather than a floating chip. This diverges from the Week view's small
header chip, so revert the shared-badge extraction: restore WeekScreen's
private badge and drop ui/common/WeekNumberBadge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 22:24:11 +02:00
8b22e1b2af refactor: share WeekNumberBadge; use it in Month gutter, centered
Extract the Week view's calendar-week badge into a shared ui/common
component and reuse it for the Month grid's week-number gutter, so the two
views show week numbers in the exact same format. The gutter now centres the
badge vertically in each row (was pinned to the day-number band) and is
widened to seat the badge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 22:11:02 +02:00
a514b8b506 feat: show calendar-week numbers in Month view (#25)
Add an opt-in left gutter to the Month grid showing the ISO calendar-week
number, gated by a new "Week numbers" display setting (default off). The
number is computed on each row's first day — the same basis as the Week
view's badge — so the two views agree, and rendered as a low-emphasis
onSurfaceVariant label so it recedes across all six rows rather than
competing with the event bars. The weekday header reserves a matching
gutter so the day columns stay aligned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 22:02:44 +02:00
60fcc6b64c Merge pull request 'feat: restore events from .ics + per-calendar export selector (#32)' (!62) from feat/ics-restore into release/v2.14.0
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m54s
Reviewed-on: #62
2026-07-06 19:45:02 +00:00
d6bc660983 Merge remote-tracking branch 'origin/release/v2.14.0' into feat/ics-restore
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m46s
2026-07-06 21:39:27 +02:00
b449fff77c release: v2.14.0 — Day view on date-header tap
All checks were successful
CI / ci (pull_request) Successful in 10m12s
Bump versionName to 2.14.0 (versionCode 21400) and cut the changelog for
the day-view-on-date-header-tap feature (#37). Merging this to main
triggers the release pipeline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:34:40 +02:00
f0cc35f2ce test: cover the export calendar-id plumbing
The fake now records the calendarIds it receives; two repository tests
assert exportEvents forwards a chosen subset and defaults to null (all
eligible calendars), closing the coverage gap for the per-calendar
export selector.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:34:35 +02:00
2ce79942c4 fix: broaden restore availability and stabilize the export picker
- Restore is import, not export: offer it whenever any writable, non-
  managed calendar exists (local or synced), not only when there is a
  local calendar to back up. Previously the row lived inside the
  export-gated block and vanished for users with only a writable synced
  calendar, despite import supporting that target.
- Export-picker selection now uses rememberSaveable and is no longer
  keyed on the observer-driven calendars list, so a background provider
  re-emit (sync/recolor) can't silently reset the user's de-selections,
  and the choice survives rotation.
- Shared calendar picker: restore the displayName fallback for a synced
  calendar whose account name and type are both blank (was grouping them
  under an empty header).
- Drop imports left dead by the CalendarPickerGroups extraction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:34:35 +02:00
df426bb8df fix: scope import ViewModel per-uri so a second import re-parses
ImportScreen has no nav backstack, so an unkeyed hiltViewModel() resolved
to the Activity's ViewModelStore and was retained across imports. Its
one-shot `load` guard then showed the *previous* file's parsed state on
the next import — trivially reachable now that the in-app Restore button
lets you export→restore or restore twice in one session (worst case: the
picker still holds file A, so tapping Import writes A's events after you
picked B). Keying the VM by the file uri hands each distinct file a fresh
VM (fresh Loading state); the same uri (rotation) reuses it and holds the
result.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:34:15 +02:00
31f51554f9 feat: open Day view when tapping a date header in Week/Agenda
Tapping a date header in the Week (day-of-week column) and Agenda
(sticky section) views now drills into that date in Day view, mirroring
the Agenda widget's header behaviour. Both reuse the existing onOpenDay
callback (pendingDayIso + drillToDay) that Month already used, so the
back stack lands on Day with the tapped view as its parent.

Month already navigated on any cell tap (the transparent tap layer sits
above the day number), so no change was needed there — all four views
now behave consistently.

Closes #37

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:51:49 +02:00
e967007bdc fix: default import target to the first local calendar
The pre-selected target was calendars.first() (raw provider order), which
could land on a synced calendar mid-list while the picker shows local
calendars first. Default to the first local calendar so the checkmark lines
up with the top row; fall back to the first calendar when none are local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:41:51 +02:00
9f7427e72f feat: pin import action to the top bar, fold count into title
Move the multi-event import's confirm button into the app-bar actions so
it's reachable without scrolling past a long calendar list, and put the
count in the title ('Importing 5 events') instead of a separate 'N events
in this file' line. Hoists the selected target calendar to the screen so
the top-bar action can read it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:39:54 +02:00
79d9e0eaa0 feat: in-app restore always uses the full import flow
Route by entry point, not just event count. Opening a .ics from outside
still sends a single event straight to the prefilled create form (add one
event, e.g. a ticket). The in-app 'Restore from .ics' button passes
forceMany so even a single-event backup goes through the calendar picker +
summary — its intent is 'restore a backup', not 'add this event'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:29:04 +02:00
a98dd654a6 polish: explain skipped duplicates on the import-complete screen
The import de-dups by UID against the target calendar (idempotent restore),
so re-importing events already present shows a low 'Added' count. Add a note
under the title when any were skipped so the outcome doesn't read as broken.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:07:56 +02:00
a138e179dd feat: per-calendar export selector
Tapping Export with more than one exportable calendar now opens a picker
to choose which local calendars to include (all selected by default); a
single calendar exports straight through as before. Threads an optional
calendarIds filter through exportEvents/exportableEvents (null = all
eligible), so the auto-backup path is unaffected. The backup section is
now gated on there being at least one exportable (non-managed) calendar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:46:24 +02:00
94e3887345 fix: exclude managed special-dates calendars from import targets
Symmetric with the export change: the contact-derived, editor-locked
special-dates mirror calendars aren't a valid import destination, so drop
them from the target-calendar picker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:39:54 +02:00
f440d385fa fix: exclude managed special-dates calendars from .ics export
The contact special-dates mirror calendars (birthdays/anniversaries) are
derived from contacts and re-materialise from the contact sync, so backing
them up only duplicates events on restore. Skip managed calendars in
exportableEvents — covers both the manual export and the auto-backup, which
share this path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:38:21 +02:00
8fb4767888 feat: spice up the import-complete screen
Replace the plain centered text list with an M3 Expressive success state:
a tonal check badge that springs in, the headline, and big-number tonal
stat tiles for added / duplicate-skipped counts, with a full-width Done
button. Stat tiles carry the full-sentence plurals as accessibility
labels so TalkBack still reads 'Imported N events'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:32:20 +02:00
993d74502f chore: stop tracking local floret-kit scratch dir (added by mistake)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:25:20 +02:00
4a11c951ae refactor: one shared calendar picker with settings-page category headers
Extract CalendarPickerGroups into ui/common: the calendar-manager screen's
grouped-card system (device chip for local calendars, the owning app's
launcher icon per synced account, colour chip + check per calendar) as a
single reusable picker. Use it in both the event editor and the .ics import
screen so all 'which calendar' lists match.

Moves LeadingAvatar/SourceLogo/curatedSourcePackage out of CalendarsScreen
into common as the shared source of truth. Drops the redundant 'Add to
calendar' caption from the import picker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:23:39 +02:00
f25f308326 fix: import target picker uses the standard grouped-list format
The 'Add to calendar' picker rendered bare OptionCards with no calendar
colour and no account grouping. Reuse the same account-grouped GroupedRow
layout as the event editor's calendar picker — coloured chip per calendar,
account sub-headers, a check on the selected row — so it matches the rest
of the app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:11:45 +02:00
bff683a403 feat: restore events from .ics file in backup section (#32)
Add a 'Restore from .ics file' row to the Calendars backup section, next
to Export. It opens a SAF document picker and routes the picked Uri into
the existing import flow (parse, dedup by UID, target-calendar picker,
summary) via CalendarHost's importUri — the same path an externally
opened .ics already takes, so no new import machinery is needed.

Closes #32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 17:55:35 +02:00
26 changed files with 901 additions and 308 deletions

View File

@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.14.0] — 2026-07-06
### Added
- Tap a date header to open that day. In Week and Agenda view, tapping a date
header now opens that date in Day view — the same drill-in that Month view and
the agenda widget already offered, so every view behaves the same way. It makes
jumping to a specific day quicker: switch to Week, swipe to the week you want,
then tap the date to open it. Thanks to @ptab for the suggestion ([#37]).
## [2.13.1] — 2026-07-06
### Added
@@ -838,3 +847,4 @@ automatically, with zero telemetry and no internet permission.
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37

View File

@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 21301
versionName = "2.13.1"
versionCode = 21400
versionName = "2.14.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -71,9 +71,11 @@ interface CalendarDataSource {
/**
* Every master/one-off event of the writable local calendars, mapped for a
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
* rows are excluded (see [EventExportProjection]).
* rows are excluded (see [EventExportProjection]). When [calendarIds] is
* given, only those calendars are exported (still intersected with the
* eligible set); `null` exports every eligible calendar.
*/
fun exportableEvents(): List<IcsEvent>
fun exportableEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
/**
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
@@ -611,12 +613,19 @@ class AndroidCalendarDataSource @Inject constructor(
?: emptyList()
}
override fun exportableEvents(): List<IcsEvent> {
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
// Only the local calendars the app owns and can write — synced calendars
// already have a backup (their server). Map id → display name for the
// already have a backup (their server). Exclude the managed special-dates
// mirror calendars: their events are derived from contacts, not authored
// here, and re-materialise from the contact sync — backing them up would
// just duplicate them on restore. A non-null [calendarIds] narrows the
// export to the user's chosen subset. Map id → display name for the
// X-CALENDULA-CALENDAR tag a restore uses to fan back out.
val names = calendars()
.filter { it.isLocal && it.canModifyContents }
.filter {
it.isLocal && it.canModifyContents && !it.isManaged &&
(calendarIds == null || it.id in calendarIds)
}
.associate { it.id to it.displayName }
if (names.isEmpty()) return emptyList()

View File

@@ -41,8 +41,9 @@ interface CalendarRepository {
/**
* Every event of the writable local calendars, ready to serialise into a
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
* [calendarIds] narrows the export to a chosen subset; `null` exports all.
*/
suspend fun exportEvents(): List<IcsEvent>
suspend fun exportEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
/**
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose

View File

@@ -117,7 +117,8 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun deleteCalendar(id: Long) =
withContext(io) { dataSource.deleteCalendar(id) }
override suspend fun exportEvents() = withContext(io) { dataSource.exportableEvents() }
override suspend fun exportEvents(calendarIds: Set<Long>?) =
withContext(io) { dataSource.exportableEvents(calendarIds) }
override suspend fun importEvents(
targetCalendarId: Long,

View File

@@ -200,6 +200,19 @@ class SettingsPrefs @Inject constructor(
store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled }
}
/**
* Whether the Month grid shows the calendar-week (ISO) number in a left
* gutter (#25). Defaults to OFF — users opt in, since it narrows the day
* cells slightly. The Week view shows its number unconditionally.
*/
val showWeekNumbers: Flow<Boolean> = store.data.map { prefs ->
prefs[SHOW_WEEK_NUMBERS_KEY] ?: false
}
suspend fun setShowWeekNumbers(enabled: Boolean) {
store.edit { it[SHOW_WEEK_NUMBERS_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
@@ -659,6 +672,7 @@ class SettingsPrefs @Inject constructor(
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
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")

View File

@@ -172,9 +172,15 @@ fun CalendarHost(
// picker (many). A plain conditional overlay (no slide) — it's transient.
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
var importForm by remember { mutableStateOf<EventForm?>(null) }
// A restore (in-app "Restore from .ics" button) always runs the full import
// flow — picker + summary — even for a single-event file, because the intent
// is "restore a backup", not "add this one event". An externally opened .ics
// keeps routing a single event straight into the prefilled create form.
var importForceMany by remember { mutableStateOf(false) }
LaunchedEffect(requestedImportUri) {
if (requestedImportUri != null) {
importUri = requestedImportUri
importForceMany = false
onImportConsumed()
}
}
@@ -284,6 +290,7 @@ fun CalendarHost(
CalendarView.Week -> WeekScreen(
selectedView = currentView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
@@ -315,6 +322,7 @@ fun CalendarHost(
CalendarView.Agenda -> AgendaScreen(
selectedView = currentView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
@@ -412,7 +420,10 @@ fun CalendarHost(
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
CalendarsScreen(onBack = { showCalendars = false })
CalendarsScreen(
onBack = { showCalendars = false },
onImport = { importUri = it; importForceMany = true },
)
}
// Import flow for an opened/received .ics file. A single event routes
@@ -420,6 +431,7 @@ fun CalendarHost(
importUri?.let { uri ->
ImportScreen(
uri = uri,
forceMany = importForceMany,
onClose = { importUri = null },
onOpenSingle = { form ->
importUri = null

View File

@@ -93,6 +93,7 @@ private val zone = TimeZone.currentSystemDefault()
fun AgendaScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
@@ -191,6 +192,7 @@ fun AgendaScreen(
pastDisplay = pastDisplay,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
@@ -289,6 +291,7 @@ private fun AgendaContent(
pastDisplay: PastEventDisplay,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
when (state) {
@@ -318,6 +321,7 @@ private fun AgendaContent(
dimPast = pastDisplay == PastEventDisplay.DIM,
now = now,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
modifier = modifier,
)
}
@@ -333,6 +337,7 @@ private fun AgendaList(
dimPast: Boolean,
now: Instant,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
@@ -342,7 +347,7 @@ private fun AgendaList(
) {
days.forEach { day ->
stickyHeader(key = "header-${day.date}") {
AgendaDayHeader(date = day.date, today = today)
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
}
itemsIndexed(
items = day.events,
@@ -362,10 +367,16 @@ private fun AgendaList(
}
@Composable
private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
private fun AgendaDayHeader(
date: LocalDate,
today: LocalDate,
onOpenDay: (LocalDate) -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenDay(date) },
) {
Text(
text = agendaDayLabel(date, today),

View File

@@ -8,7 +8,6 @@ import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
@@ -24,7 +23,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@@ -37,6 +35,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.FileUpload
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PhoneAndroid
@@ -45,6 +44,7 @@ import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -66,6 +66,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -73,10 +74,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringArrayResource
@@ -85,7 +83,6 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.hilt.navigation.compose.hiltViewModel
@@ -96,6 +93,11 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker
import de.jeanlucmakiola.calendula.ui.common.positionOf
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
@@ -112,6 +114,16 @@ import java.time.LocalDate
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
// SAF mime filter for the restore picker. `.ics` files reach us under several
// mimes depending on the source app (our own export uses text/calendar; others
// hand them out as octet-stream or text/plain), so accept the common set rather
// than hide valid backups behind an over-tight filter.
private val RESTORE_MIME_TYPES = arrayOf(
"text/calendar",
"application/octet-stream",
"text/plain",
)
/**
* Calendar manager (reached from Settings). Lists the app's own device-only
* calendars with create / rename / recolor / delete (via a full-screen editor),
@@ -122,6 +134,7 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE
@Composable
fun CalendarsScreen(
onBack: () -> Unit,
onImport: (android.net.Uri) -> Unit,
viewModel: CalendarsViewModel = hiltViewModel(),
) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
@@ -168,6 +181,7 @@ fun CalendarsScreen(
onConsumeError = viewModel::consumeError,
backupResult = backupResult,
onExportBackup = viewModel::exportBackup,
onImport = onImport,
onConsumeBackupResult = viewModel::consumeBackupResult,
autoBackup = autoBackup,
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
@@ -190,7 +204,8 @@ private fun CalendarsList(
error: Boolean,
onConsumeError: () -> Unit,
backupResult: BackupResult?,
onExportBackup: (android.net.Uri) -> Unit,
onExportBackup: (android.net.Uri, Set<Long>?) -> Unit,
onImport: (android.net.Uri) -> Unit,
onConsumeBackupResult: () -> Unit,
autoBackup: AutoBackupUiState,
onSetAutoBackupEnabled: (Boolean) -> Unit,
@@ -218,10 +233,19 @@ private fun CalendarsList(
}
// SAF "create document" target for the backup file. The picked Uri is handed
// to the VM to stream the .ics into.
// to the VM to stream the .ics into. This launcher exports everything
// eligible (null); the per-calendar selector owns its own launcher.
val createBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/calendar"),
) { uri -> uri?.let(onExportBackup) }
) { uri -> uri?.let { onExportBackup(it, null) } }
var showExportPicker by rememberSaveable { mutableStateOf(false) }
// SAF "open document" picker for restoring events from a .ics file. The
// picked Uri is handed up to the host, which runs it through the same import
// flow as an externally opened .ics (parse, dedup by UID, target picker).
val openBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri -> uri?.let(onImport) }
// SAF folder picker for the automatic-backup destination; the VM persists the
// write grant so background runs can keep writing to it.
@@ -301,8 +325,13 @@ private fun CalendarsList(
}
// Backup — local calendars have no sync, so a .ics export is their only
// safety net. Offered only when there is something to back up.
if (local.isNotEmpty()) {
// safety net. Offered only when there is something exportable: the user's
// own local calendars (managed special-dates mirrors don't count).
val exportable = local.filter { it.canModifyContents && !it.isManaged }
// Restore/import can target any writable, non-managed calendar (local or
// synced), so its availability is broader than export's.
val canImport = (local + synced).any { it.canModifyContents && !it.isManaged }
if (exportable.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
SectionHeader(stringResource(R.string.calendars_backup_header))
HintText(stringResource(R.string.calendars_backup_hint))
@@ -313,7 +342,22 @@ private fun CalendarsList(
position = Position.Top,
leading = { LeadingAvatar(Icons.Default.FileDownload) },
onClick = {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
// With more than one exportable calendar, let the user choose
// which to include; a single one exports straight away.
if (exportable.size == 1) {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
} else {
showExportPicker = true
}
},
)
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
summary = stringResource(R.string.calendars_restore_hint),
position = Position.Middle,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = {
runCatching { openBackup.launch(RESTORE_MIME_TYPES) }
},
)
GroupedRow(
@@ -342,6 +386,19 @@ private fun CalendarsList(
)
HintText(backupStatusText(autoBackup.status))
}
} else if (canImport) {
// Nothing to back up (no writable local calendar), but events can
// still be restored into a writable calendar — offer restore on its
// own so it isn't hidden behind export eligibility.
Spacer(Modifier.height(16.dp))
SectionHeader(stringResource(R.string.calendars_restore_header))
HintText(stringResource(R.string.calendars_restore_hint))
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
position = Position.Alone,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
)
}
Spacer(Modifier.height(16.dp))
@@ -408,6 +465,86 @@ private fun CalendarsList(
onDismiss = { showInterval = false },
)
}
if (showExportPicker) {
ExportCalendarPicker(
calendars = local.filter { it.canModifyContents && !it.isManaged },
onExport = onExportBackup,
onDismiss = { showExportPicker = false },
)
}
}
/**
* Choose which local calendars to include in a one-time `.ics` export. Defaults
* to all selected; the Export action opens the SAF save dialog and hands back
* the picked file with the chosen calendar ids.
*/
@Composable
private fun ExportCalendarPicker(
calendars: List<CalendarSource>,
onExport: (android.net.Uri, Set<Long>?) -> Unit,
onDismiss: () -> Unit,
) {
// Seed once with everything selected and hold it across recomposition and
// rotation. NOT keyed on [calendars]: the list is observer-driven, so keying
// it would silently reset the user's de-selections whenever the provider
// re-emits (a background sync, a recolor). Ids that later vanish are harmless
// — the data layer intersects the chosen set with the eligible calendars.
var selected by rememberSaveable(
stateSaver = listSaver(
save = { it.toList() },
restore = { it.toSet() },
),
) {
mutableStateOf(calendars.map { it.id }.toSet())
}
val createBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/calendar"),
) { uri ->
if (uri != null) {
onExport(uri, selected)
onDismiss()
}
}
FullScreenPicker(
title = stringResource(R.string.calendars_export_title),
onDismiss = onDismiss,
) {
HintText(stringResource(R.string.calendars_export_hint))
calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id in selected
GroupedRow(
title = calendar.displayName,
summary = calendar.description,
position = positionOf(index, calendars.size),
leading = { CalendarColorChip(calendar.color) },
trailing = {
Checkbox(
checked = isSelected,
onCheckedChange = { checked ->
selected = if (checked) selected + calendar.id else selected - calendar.id
},
)
},
onClick = {
selected = if (isSelected) selected - calendar.id else selected + calendar.id
},
)
}
Button(
onClick = {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
},
enabled = selected.isNotEmpty(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
) {
Text(stringResource(R.string.calendars_export_action))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -741,66 +878,6 @@ private fun CalendarGroupMenu(
}
}
/**
* The source app's launcher icon (Google Calendar, DAVx5, Nextcloud, …) as a
* round 40dp chip, so each synced account is recognisable at a glance. We load
* whatever app owns the account from [PackageManager] rather than bundling brand
* logos — always accurate, nothing to license. Falls back to a neutral cloud
* chip when no installed app resolves for the account.
*/
@Composable
private fun SourceLogo(accountType: String) {
val context = LocalContext.current
val logo = remember(accountType) { sourceAppLogo(context, accountType) }
if (logo != null) {
Image(
bitmap = logo,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(40.dp)
.clip(CircleShape),
)
} else {
LeadingAvatar(Icons.Default.Cloud)
}
}
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
val pm = context.packageManager
val candidates = buildList {
curatedSourcePackage(accountType)?.let { add(it) }
AccountManager.get(context).authenticatorTypes
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
?.packageName
?.let { add(it) }
}
for (pkg in candidates) {
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
if (bitmap != null) return bitmap.asImageBitmap()
}
return null
}
/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */
@Composable
private fun LeadingAvatar(icon: ImageVector) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) {
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
}
@Composable
private fun SectionHeader(text: String) {
@@ -935,9 +1012,3 @@ private fun sourceAppIntent(context: Context, accountType: String): Intent {
return Intent(Settings.ACTION_SYNC_SETTINGS)
}
/** Preferred app for account types whose authenticator isn't the app to open. */
private fun curatedSourcePackage(accountType: String): String? = when {
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
else -> null
}

View File

@@ -107,11 +107,11 @@ class CalendarsViewModel @Inject constructor(
* document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
* in [backupResult] for a one-shot message.
*/
fun exportBackup(uri: Uri) {
fun exportBackup(uri: Uri, calendarIds: Set<Long>? = null) {
viewModelScope.launch {
_backupResult.value = try {
val count = withContext(io) {
val events = repository.exportEvents()
val events = repository.exportEvents(calendarIds)
icsExporter.writeDocument(
uri = uri,
content = IcsWriter().writeCalendar(events, Clock.System.now()),

View File

@@ -0,0 +1,180 @@
package de.jeanlucmakiola.calendula.ui.common
import android.accounts.AccountManager
import android.content.Context
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource
/**
* The app's single "which calendar" selection list, shared by the event editor
* and the .ics import screen. Renders the same grouped-card system as the
* calendar-manager screen: a category header per source — the device chip for
* the app's own calendars, the owning app's launcher icon for each synced
* account — with the calendars beneath it as a connected card, a colour chip on
* each and a check on the selected one. Emits into the caller's [ColumnScope]
* (a scrolling column), so the caller owns the surrounding chrome.
*/
@Composable
fun ColumnScope.CalendarPickerGroups(
calendars: List<CalendarSource>,
selectedId: Long?,
onSelect: (Long) -> Unit,
) {
val local = remember(calendars) { calendars.filter { it.isLocal } }
val syncedGroups = remember(calendars) {
calendars.filterNot { it.isLocal }
.groupBy { it.accountName.ifBlank { it.accountType }.ifBlank { it.displayName } }
.toList()
}
if (local.isNotEmpty()) {
CalendarPickerGroup(
title = stringResource(R.string.calendars_local_header),
leading = { LeadingAvatar(Icons.Default.PhoneAndroid) },
calendars = local,
selectedId = selectedId,
onSelect = onSelect,
)
}
syncedGroups.forEachIndexed { index, (account, cals) ->
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
CalendarPickerGroup(
title = account,
leading = { SourceLogo(cals.first().accountType) },
calendars = cals,
selectedId = selectedId,
onSelect = onSelect,
)
}
}
/** One account's category header (avatar + name) atop its selectable calendars. */
@Composable
private fun CalendarPickerGroup(
title: String,
leading: @Composable () -> Unit,
calendars: List<CalendarSource>,
selectedId: Long?,
onSelect: (Long) -> Unit,
) {
GroupedRow(
title = title,
position = Position.Top,
leading = leading,
)
calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id == selectedId
GroupedRow(
title = calendar.displayName,
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
selected = isSelected,
leading = { CalendarColorChip(calendar.color) },
trailing = if (isSelected) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
null
},
onClick = { onSelect(calendar.id) },
)
}
}
/**
* The source app's launcher icon (Google Calendar, DAVx5, Nextcloud, …) as a
* round 40dp chip, so each synced account is recognisable at a glance. We load
* whatever app owns the account from [android.content.pm.PackageManager] rather
* than bundling brand logos — always accurate, nothing to license. Falls back to
* a neutral cloud chip when no installed app resolves for the account.
*/
@Composable
fun SourceLogo(accountType: String) {
val context = LocalContext.current
val logo = remember(accountType) { sourceAppLogo(context, accountType) }
if (logo != null) {
Image(
bitmap = logo,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(40.dp)
.clip(CircleShape),
)
} else {
LeadingAvatar(Icons.Default.Cloud)
}
}
/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */
@Composable
fun LeadingAvatar(icon: ImageVector) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) {
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
}
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
val pm = context.packageManager
val candidates = buildList {
curatedSourcePackage(accountType)?.let { add(it) }
AccountManager.get(context).authenticatorTypes
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
?.packageName
?.let { add(it) }
}
for (pkg in candidates) {
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
if (bitmap != null) return bitmap.asImageBitmap()
}
return null
}
/** Preferred app for account types whose authenticator isn't the app to open. */
internal fun curatedSourcePackage(accountType: String): String? = when {
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
else -> null
}

View File

@@ -37,7 +37,6 @@ import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.EventAvailable
@@ -121,7 +120,7 @@ import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
@@ -520,13 +519,17 @@ private fun EventEditContent(
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
) {
// Title: borderless headline, mirroring the detail screen's title +
// accent bar instead of a boxed Material text field.
// accent bar instead of a boxed Material text field. Multi-line so long
// titles wrap instead of scrolling off one line (#33); newlines are
// stripped in setTitle, so this stays a single logical line — the Enter
// key just has no effect.
InlineField(
value = form.title,
onValueChange = viewModel::setTitle,
placeholder = stringResource(R.string.event_edit_title_hint),
textStyle = MaterialTheme.typography.headlineMedium
.copy(fontWeight = FontWeight.SemiBold),
singleLine = false,
enabled = !locked,
modifier = Modifier
.fillMaxWidth()
@@ -1975,50 +1978,14 @@ private fun CalendarPicker(
onSelect: (Long) -> Unit,
onDismiss: () -> Unit,
) {
// Group by owning account (name, else type, else the calendar's own name),
// preserving the provider's order within and across groups — the same
// grouping [groupByAccount] applies for the drawer filter.
val groups = remember(calendars) {
calendars
.groupBy {
it.accountName.takeIf(String::isNotBlank)
?: it.accountType.takeIf(String::isNotBlank)
?: it.displayName
}
.toList()
}
FullScreenPicker(
title = stringResource(R.string.event_detail_calendar),
onDismiss = onDismiss,
) {
groups.forEach { (account, cals) ->
Text(
text = account,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
)
cals.forEachIndexed { index, calendar ->
val isSelected = calendar.id == selectedId
GroupedRow(
title = calendar.displayName,
position = positionOf(index, cals.size),
selected = isSelected,
leading = { CalendarColorChip(calendar.color) },
trailing = if (isSelected) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
null
},
onClick = { onSelect(calendar.id) },
)
}
}
CalendarPickerGroups(
calendars = calendars,
selectedId = selectedId,
onSelect = onSelect,
)
}
}

View File

@@ -336,7 +336,11 @@ class EventEditViewModel @Inject constructor(
_revealed.value = _revealed.value + field
}
fun setTitle(value: String) = update { it.copy(title = value) }
// 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 { it.copy(title = value.replace("\n", "").replace("\r", "")) }
fun setLocation(value: String) = update { it.copy(location = value) }
fun setDescription(value: String) = update { it.copy(description = value) }
fun setAllDay(value: Boolean) {

View File

@@ -1,15 +1,27 @@
package de.jeanlucmakiola.calendula.ui.imports
import android.net.Uri
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
@@ -18,6 +30,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
@@ -30,6 +43,12 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -38,14 +57,16 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.OptionCard
/**
* Handles an opened/received `.ics` file. A single event is handed straight to
* the prefilled create form via [onOpenSingle]; several events show a target-
* calendar picker and import in bulk (dedup by UID), then a result summary.
* Empty/failed files show a short message and close.
* Empty/failed files show a short message and close. [forceMany] keeps a
* single-event file on the bulk path — used by the in-app restore, whose intent
* is "restore a backup" rather than "add this one event".
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -53,9 +74,16 @@ fun ImportScreen(
uri: Uri,
onClose: () -> Unit,
onOpenSingle: (EventForm) -> Unit,
viewModel: ImportViewModel = hiltViewModel(),
forceMany: Boolean = false,
// Key the VM by the file uri. This screen has no nav backstack, so an
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
// across imports — its one-shot `load` guard would then show the *previous*
// file's parsed state on the next import (a second restore, export→restore,
// etc.). Keying per uri hands each distinct file a fresh VM (fresh Loading
// state), while the same uri (rotation) reuses it and holds the result.
viewModel: ImportViewModel = hiltViewModel(key = uri.toString()),
) {
LaunchedEffect(uri) { viewModel.load(uri) }
LaunchedEffect(uri) { viewModel.load(uri, forceMany) }
val state by viewModel.state.collectAsStateWithLifecycle()
// A single event isn't shown here — it opens the create form for review.
@@ -63,18 +91,55 @@ fun ImportScreen(
(state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
}
// Hoisted target calendar so the always-visible top-bar Import action can
// read it without the user scrolling to a bottom button. Defaults to the
// first *local* calendar — the first row the picker shows ("Your calendars"
// group leads) — so the pre-selection lines up with the top of the list;
// falls back to the first calendar if there are no local ones. Re-defaults
// when the "many" list first arrives (keyed on it), then holds the pick.
val many = state as? ImportUiState.Many
val defaultTarget = many?.calendars?.let { cals ->
(cals.firstOrNull { it.isLocal } ?: cals.firstOrNull())?.id
}
var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) }
Scaffold(
modifier = Modifier
.predictiveBack(onBack = onClose)
.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.import_title)) },
title = {
Text(
if (many != null) {
pluralStringResource(
R.plurals.import_title_count,
many.events.size,
many.events.size,
)
} else {
stringResource(R.string.import_title)
},
)
},
navigationIcon = {
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
}
},
actions = {
// Only meaningful in the multi-event picker with a writable
// target; every other state has nothing to confirm here.
if (many != null && many.calendars.isNotEmpty()) {
Button(
onClick = { selected?.let(viewModel::import) },
enabled = selected != null,
modifier = Modifier.padding(end = 12.dp),
) {
Text(stringResource(R.string.import_button))
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
@@ -90,7 +155,7 @@ fun ImportScreen(
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
is ImportUiState.Many -> ManyContent(s, onImport = viewModel::import)
is ImportUiState.Many -> ManyContent(s, selected, onSelect = { selected = it })
is ImportUiState.Done -> DoneContent(s, onClose)
}
}
@@ -98,84 +163,160 @@ fun ImportScreen(
}
@Composable
private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) {
private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (Long) -> Unit) {
// No writable calendar to import into — tell the user honestly.
if (state.calendars.isEmpty()) {
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
return
}
var selected by rememberSaveable { mutableStateOf(state.calendars.first().id) }
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
.padding(top = 8.dp, bottom = 24.dp),
) {
Text(
pluralStringResource(R.plurals.import_event_count, state.events.size, state.events.size),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(vertical = 8.dp),
CalendarPickerGroups(
calendars = state.calendars,
selectedId = selected,
onSelect = onSelect,
)
Text(
stringResource(R.string.import_target_header),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
state.calendars.forEach { calendar ->
OptionCard(
label = calendar.displayName,
onClick = { selected = calendar.id },
selected = calendar.id == selected,
icon = null,
)
}
state.warnings.forEach { WarningText(it) }
Button(
onClick = { onImport(selected) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) {
Text(pluralStringResource(R.plurals.import_action, state.events.size, state.events.size))
if (state.warnings.isNotEmpty()) {
Column(
Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
state.warnings.forEach { WarningText(it) }
}
}
}
}
@Composable
private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
// A little expressive pop on the success badge — springs in on first show.
val badgeScale = remember { Animatable(0.7f) }
LaunchedEffect(Unit) {
badgeScale.animateTo(
targetValue = 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow,
),
)
}
Column(
Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.weight(1f))
Box(
Modifier
.size(112.dp)
.graphicsLayer {
scaleX = badgeScale.value
scaleY = badgeScale.value
}
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp),
)
}
Spacer(Modifier.height(24.dp))
Text(
stringResource(R.string.import_done_title),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(top = 24.dp),
)
Text(
pluralStringResource(
R.plurals.import_done_imported,
state.summary.imported,
state.summary.imported,
),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
if (state.summary.skippedDuplicate > 0) {
Spacer(Modifier.height(8.dp))
Text(
pluralStringResource(
R.plurals.import_done_skipped,
state.summary.skippedDuplicate,
state.summary.skippedDuplicate,
),
stringResource(R.string.import_done_dedup_note),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
Button(onClick = onClose, modifier = Modifier.padding(top = 12.dp)) {
Spacer(Modifier.height(24.dp))
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
ImportStatCard(
count = state.summary.imported,
label = stringResource(R.string.import_done_added_label),
contentDescription = pluralStringResource(
R.plurals.import_done_imported,
state.summary.imported,
state.summary.imported,
),
container = MaterialTheme.colorScheme.secondaryContainer,
onContainer = MaterialTheme.colorScheme.onSecondaryContainer,
)
if (state.summary.skippedDuplicate > 0) {
ImportStatCard(
count = state.summary.skippedDuplicate,
label = stringResource(R.string.import_done_skipped_label),
contentDescription = pluralStringResource(
R.plurals.import_done_skipped,
state.summary.skippedDuplicate,
state.summary.skippedDuplicate,
),
container = MaterialTheme.colorScheme.surfaceContainerHighest,
onContainer = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.weight(1f))
Button(
onClick = onClose,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.import_close))
}
}
}
/** A big-number tonal tile summarising one import outcome (added / skipped). */
@Composable
private fun RowScope.ImportStatCard(
count: Int,
label: String,
contentDescription: String,
container: Color,
onContainer: Color,
) {
Surface(
modifier = Modifier
.weight(1f)
.clearAndSetSemantics { this.contentDescription = contentDescription },
shape = RoundedCornerShape(24.dp),
color = container,
) {
Column(
Modifier.padding(vertical = 20.dp, horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
count.toString(),
style = MaterialTheme.typography.displaySmall,
color = onContainer,
)
Text(
label,
style = MaterialTheme.typography.labelLarge,
color = onContainer.copy(alpha = 0.85f),
)
}
}
}
@Composable
private fun WarningText(warning: IcsParseWarning) {
val text = when (warning) {

View File

@@ -66,8 +66,13 @@ class ImportViewModel @Inject constructor(
val state: StateFlow<ImportUiState> = _state.asStateFlow()
private var started = false
/** Read + parse [uri] once; subsequent calls (recomposition) are ignored. */
fun load(uri: Uri) {
/**
* Read + parse [uri] once; subsequent calls (recomposition) are ignored.
* When [forceMany] is set (an in-app restore), a single-event file still goes
* through the bulk picker + summary rather than the prefilled create form —
* a restore is "bring back a backup", not "add this one event".
*/
fun load(uri: Uri, forceMany: Boolean = false) {
if (started) return
started = true
viewModelScope.launch {
@@ -77,19 +82,21 @@ class ImportViewModel @Inject constructor(
_state.value = when {
parsed == null -> ImportUiState.Failed
parsed.events.isEmpty() -> ImportUiState.Empty
parsed.events.size == 1 -> ImportUiState.Single(
parsed.events.size == 1 && !forceMany -> ImportUiState.Single(
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
warnings = parsed.warnings,
)
else -> {
// A disabled calendar is removed from the app, so it can't be
// an import target — exclude it alongside the read-only ones.
// Managed special-dates calendars are contact-derived and
// editor-locked, so they're not a valid destination either.
val disabled = prefs.disabledCalendarIds.first()
ImportUiState.Many(
events = parsed.events,
warnings = parsed.warnings,
calendars = repository.calendars().first()
.filter { it.canModifyContents && it.id !in disabled },
.filter { it.canModifyContents && !it.isManaged && it.id !in disabled },
)
}
}

View File

@@ -108,6 +108,7 @@ fun MonthScreen(
val month by viewModel.month.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle()
// The instant before which an event counts as completed, or null when dimming
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
// screen while the setting is off (it stays null regardless of the tick).
@@ -211,11 +212,12 @@ fun MonthScreen(
.padding(innerPadding)
.fillMaxSize(),
) {
WeekdayHeader(weekStart = weekStart)
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
MonthContent(
state = state,
slideDir = slideDir,
showWeekNumbers = showWeekNumbers,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
@@ -231,6 +233,7 @@ fun MonthScreen(
private fun MonthContent(
state: MonthUiState,
slideDir: Int,
showWeekNumbers: Boolean,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
@@ -276,6 +279,7 @@ private fun MonthContent(
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is MonthUiState.Success -> MonthGrid(
state = s,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
)
}
@@ -325,7 +329,7 @@ private fun MonthTopBar(
}
@Composable
private fun WeekdayHeader(weekStart: DayOfWeek) {
private fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
val locale = currentLocale()
val days = remember(weekStart, locale) {
(0 until 7).map { offset ->
@@ -337,6 +341,8 @@ private fun WeekdayHeader(weekStart: DayOfWeek) {
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
// Reserve the gutter so the weekday labels stay over their day columns.
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER))
days.forEach { dow ->
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
@@ -354,6 +360,9 @@ private fun WeekdayHeader(weekStart: DayOfWeek) {
private val EVENT_ROW_HEIGHT = 20.dp
private val DAY_NUMBER_HEIGHT = 22.dp
/** Width of the optional left calendar-week gutter (#25); narrow, since it only
* seats a one- or two-digit week number in a full-height tonal pill. */
private val WEEK_NUMBER_GUTTER = 40.dp
private val DAY_NUMBER_GAP = 4.dp
private val CELL_TOP_PADDING = 6.dp
private val CELL_GAP = 2.dp
@@ -363,6 +372,7 @@ private const val MAX_EVENT_ROWS = 3
@Composable
private fun MonthGrid(
state: MonthUiState.Success,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
) {
Column(
@@ -376,6 +386,7 @@ private fun MonthGrid(
week = week,
today = state.today,
month = state.month,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
modifier = Modifier
.fillMaxWidth()
@@ -397,6 +408,7 @@ private fun MonthWeekRow(
week: MonthWeek,
today: LocalDate,
month: YearMonth,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -404,135 +416,182 @@ private fun MonthWeekRow(
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
BoxWithConstraints(modifier) {
val colW = maxWidth / 7
// Per-day background pills — same surfaceContainer rounded surface the
// week/day views use, so the three views share one visual language.
// Spanning bars draw on top of these, bridging cells, so they still read
// as one continuous event.
Row(Modifier.matchParentSize()) {
week.days.forEach { d ->
val inMonth = d.month == month.month && d.year == month.year
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.background(
color = if (inMonth) MaterialTheme.colorScheme.surfaceContainer
else MaterialTheme.colorScheme.surfaceContainerLow,
shape = CELL_SHAPE,
),
)
}
}
Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) {
Row(Modifier.fillMaxWidth()) {
week.days.forEach { d ->
DayNumberCell(
date = d,
isToday = d == today,
inMonth = d.month == month.month && d.year == month.year,
modifier = Modifier.weight(1f),
)
}
}
// Breathing room between the day number (and today's circle) and the
// first event row.
Spacer(Modifier.height(DAY_NUMBER_GAP))
Box(
Row(modifier) {
// Optional calendar-week gutter, sized so the seven day columns below
// divide the remaining width — the absolute bar offsets stay correct
// because they're measured inside the grid box, not the whole row.
if (showWeekNumbers) {
WeekNumberGutter(
weekStart = week.days.first(),
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.clipToBounds(),
) {
// Spanning bars on their shared lanes.
week.spans.filter { it.lane < shownLanes }.forEach { span ->
val cols = span.endCol - span.startCol + 1
MonthBar(
event = span.event,
dark = dark,
continuesLeft = span.continuesLeft,
continuesRight = span.continuesRight,
modifier = Modifier
.offset(
x = colW * span.startCol,
y = EVENT_ROW_HEIGHT * span.lane,
)
.width(colW * cols)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
.width(WEEK_NUMBER_GUTTER)
.fillMaxHeight(),
)
}
BoxWithConstraints(
Modifier
.weight(1f)
.fillMaxHeight(),
) {
val colW = maxWidth / 7
// Per-day background pills — same surfaceContainer rounded surface the
// week/day views use, so the three views share one visual language.
// Spanning bars draw on top of these, bridging cells, so they still read
// as one continuous event.
Row(Modifier.matchParentSize()) {
week.days.forEach { d ->
val inMonth = d.month == month.month && d.year == month.year
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.background(
color = if (inMonth) MaterialTheme.colorScheme.surfaceContainer
else MaterialTheme.colorScheme.surfaceContainerLow,
shape = CELL_SHAPE,
),
)
}
// Single-day timed pills + overflow, per column. Pills fill the
// lane slots no bar occupies on THIS day (top-most first), so a
// bar-free day isn't pushed down by a multi-day event that only
// sits on other days of the week.
week.days.forEachIndexed { col, d ->
val timed = week.timedByDay[d].orEmpty()
val occupied = week.spans
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
.map { it.lane }
.toSet()
val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied }
val pillsShown = timed.take(freeSlots.size)
pillsShown.forEachIndexed { i, ev ->
}
Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) {
Row(Modifier.fillMaxWidth()) {
week.days.forEach { d ->
DayNumberCell(
date = d,
isToday = d == today,
inMonth = d.month == month.month && d.year == month.year,
modifier = Modifier.weight(1f),
)
}
}
// Breathing room between the day number (and today's circle) and the
// first event row.
Spacer(Modifier.height(DAY_NUMBER_GAP))
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.clipToBounds(),
) {
// Spanning bars on their shared lanes.
week.spans.filter { it.lane < shownLanes }.forEach { span ->
val cols = span.endCol - span.startCol + 1
MonthBar(
event = ev,
event = span.event,
dark = dark,
continuesLeft = false,
continuesRight = false,
continuesLeft = span.continuesLeft,
continuesRight = span.continuesRight,
modifier = Modifier
.offset(
x = colW * col,
y = EVENT_ROW_HEIGHT * freeSlots[i],
x = colW * span.startCol,
y = EVENT_ROW_HEIGHT * span.lane,
)
.width(colW)
.width(colW * cols)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
)
}
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
if (hidden > 0) {
val hiddenColors = buildList {
week.spans
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
.forEach { add(it.event.color) }
timed.drop(pillsShown.size).forEach { add(it.color) }
}.distinct().take(3)
OverflowDots(
colors = hiddenColors,
extra = hidden - hiddenColors.size,
dark = dark,
modifier = Modifier
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
.width(colW)
.padding(horizontal = 3.dp),
)
// Single-day timed pills + overflow, per column. Pills fill the
// lane slots no bar occupies on THIS day (top-most first), so a
// bar-free day isn't pushed down by a multi-day event that only
// sits on other days of the week.
week.days.forEachIndexed { col, d ->
val timed = week.timedByDay[d].orEmpty()
val occupied = week.spans
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
.map { it.lane }
.toSet()
val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied }
val pillsShown = timed.take(freeSlots.size)
pillsShown.forEachIndexed { i, ev ->
MonthBar(
event = ev,
dark = dark,
continuesLeft = false,
continuesRight = false,
modifier = Modifier
.offset(
x = colW * col,
y = EVENT_ROW_HEIGHT * freeSlots[i],
)
.width(colW)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
)
}
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
if (hidden > 0) {
val hiddenColors = buildList {
week.spans
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
.forEach { add(it.event.color) }
timed.drop(pillsShown.size).forEach { add(it.color) }
}.distinct().take(3)
OverflowDots(
colors = hiddenColors,
extra = hidden - hiddenColors.size,
dark = dark,
modifier = Modifier
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
.width(colW)
.padding(horizontal = 3.dp),
)
}
}
}
}
}
// Tap layer: in month view a tap on any day opens that day. Padded and
// clipped to the background pill so the ripple matches it.
Row(Modifier.matchParentSize()) {
week.days.forEach { d ->
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.clip(CELL_SHAPE)
.clickable { onOpenDay(d) },
)
// Tap layer: in month view a tap on any day opens that day. Padded and
// clipped to the background pill so the ripple matches it.
Row(Modifier.matchParentSize()) {
week.days.forEach { d ->
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.clip(CELL_SHAPE)
.clickable { onOpenDay(d) },
)
}
}
}
}
}
/**
* Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the
* day cells' geometry, set apart by the secondaryContainer tint (matching the
* Week view's badge), with the ISO week number centred like a day number. The
* week is computed on the row's first day — the same basis as the Week view — so
* the two agree.
*/
@Composable
private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) {
val weekNumber = remember(weekStart) {
java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, weekStart.day)
.get(java.time.temporal.IsoFields.WEEK_OF_WEEK_BASED_YEAR)
}
val label = stringResource(R.string.week_number_label)
Box(
modifier = modifier
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.background(MaterialTheme.colorScheme.secondaryContainer, CELL_SHAPE)
.semantics { contentDescription = "$label $weekNumber" },
contentAlignment = Alignment.Center,
) {
Text(
text = weekNumber.toString(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
@Composable
private fun DayNumberCell(
date: LocalDate,

View File

@@ -67,6 +67,14 @@ class MonthViewModel @Inject constructor(
initialValue = false,
)
/** Whether to show the calendar-week number gutter (#25; display only). */
val showWeekNumbers: StateFlow<Boolean> = settingsPrefs.showWeekNumbers
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date

View File

@@ -613,6 +613,18 @@ private fun AppearanceScreen(
position = Position.Middle,
onClick = { showWeekStart = true },
)
GroupedRow(
title = stringResource(R.string.settings_week_numbers),
summary = stringResource(R.string.settings_week_numbers_summary),
position = Position.Middle,
trailing = {
Switch(
checked = state.showWeekNumbers,
onCheckedChange = viewModel::setShowWeekNumbers,
)
},
onClick = { viewModel.setShowWeekNumbers(!state.showWeekNumbers) },
)
GroupedRow(
title = stringResource(R.string.settings_time_format),
summary = timeFormatLabel(state.timeFormat),

View File

@@ -33,6 +33,8 @@ data class SettingsUiState(
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
/** Whether the month/week grids fade events that have already finished. */
val dimCompletedEvents: Boolean = false,
/** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */
val showWeekNumbers: 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). */

View File

@@ -116,9 +116,15 @@ class SettingsViewModel @Inject constructor(
prefs.agendaScreenRange,
prefs.agendaWidgetRange,
prefs.timeFormat,
prefs.showHourLines,
) { view, screenRange, widgetRange, timeFormat, showHourLines ->
ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines)
// Two grid-display toggles folded into one flow so they fit this
// group — the outer combine is already at its five-arg limit.
combine(prefs.showHourLines, prefs.showWeekNumbers, ::Pair),
) { view, screenRange, widgetRange, timeFormat, gridToggles ->
ViewSettings(
view, screenRange, widgetRange, timeFormat,
showHourLines = gridToggles.first,
showWeekNumbers = gridToggles.second,
)
},
combine(
prefs.agendaShowRangeBar,
@@ -140,6 +146,7 @@ class SettingsViewModel @Inject constructor(
agendaWidgetRange = views.agendaWidgetRange,
timeFormat = views.timeFormat,
showHourLines = views.showHourLines,
showWeekNumbers = views.showWeekNumbers,
agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle,
pastEventDisplay = misc.pastEventDisplay,
@@ -212,6 +219,7 @@ class SettingsViewModel @Inject constructor(
val agendaWidgetRange: AgendaRange,
val timeFormat: TimeFormatPref,
val showHourLines: Boolean,
val showWeekNumbers: Boolean,
)
private data class MiscSettings(
@@ -398,6 +406,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setShowHourLines(enabled) }
}
fun setShowWeekNumbers(enabled: Boolean) {
viewModelScope.launch { prefs.setShowWeekNumbers(enabled) }
}
fun setPastEventDisplay(mode: PastEventDisplay) {
viewModelScope.launch {
prefs.setPastEventDisplay(mode)

View File

@@ -128,6 +128,7 @@ private fun WeekUiState.Success.allDayStripHeight(): Dp {
fun WeekScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
@@ -249,6 +250,7 @@ fun WeekScreen(
onSwipePrev = goPrev,
onRetry = jumpToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
modifier = Modifier
.padding(innerPadding)
@@ -268,6 +270,7 @@ private fun WeekContent(
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -342,6 +345,7 @@ private fun WeekContent(
scrollState = scrollState,
allDayHeight = allDayHeight,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
)
}
@@ -355,6 +359,7 @@ private fun WeekSuccess(
scrollState: ScrollState,
allDayHeight: Dp,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
@@ -363,7 +368,7 @@ private fun WeekSuccess(
.fillMaxWidth()
.background(topSectionColor),
) {
WeekDayHeader(days = state.days, today = state.today)
WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay)
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
}
// Breathing room between the (colour-shifting) top section and the
@@ -427,7 +432,11 @@ private fun WeekTopBar(
}
@Composable
private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) {
private fun WeekDayHeader(
days: List<LocalDate>,
today: LocalDate,
onOpenDay: (LocalDate) -> Unit,
) {
val locale = currentLocale()
val weekStart = days.first()
val weekNumber = remember(weekStart) {
@@ -453,7 +462,10 @@ private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) {
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
val isToday = date == today
Column(
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.clickable { onOpenDay(date) },
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(

View File

@@ -287,6 +287,8 @@
<string name="settings_font_import_failed">Couldn\'t read that file as a font</string>
<string name="settings_week_start">Week starts on</string>
<string name="settings_week_start_auto">Automatic</string>
<string name="settings_week_numbers">Week numbers</string>
<string name="settings_week_numbers_summary">Show calendar-week numbers in month view</string>
<string name="settings_time_format">Time format</string>
<string name="settings_time_format_auto">Automatic</string>
<string name="settings_time_format_12h">12-hour (2:00 PM)</string>
@@ -428,6 +430,12 @@
<string name="calendars_backup_header">Backup</string>
<string name="calendars_backup_hint">Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy.</string>
<string name="calendars_backup_action">Export as .ics file</string>
<string name="calendars_export_title">Export calendars</string>
<string name="calendars_export_hint">Choose which calendars to include in the .ics file.</string>
<string name="calendars_export_action">Export</string>
<string name="calendars_restore_header">Restore</string>
<string name="calendars_restore_action">Restore from .ics file</string>
<string name="calendars_restore_hint">Import events from a backup or another calendar app.</string>
<string name="calendars_auto_backup">Automatic backup</string>
<string name="calendars_auto_backup_hint">Periodically export your local calendars to a folder as an .ics file.</string>
<string name="calendars_auto_backup_folder">Backup folder</string>
@@ -460,11 +468,19 @@
<string name="import_failed">Couldn\'t read this file.</string>
<string name="import_no_calendar">No writable calendar to import into. Create a local calendar first.</string>
<string name="import_done_title">Import complete</string>
<string name="import_done_dedup_note">Events already in the calendar were skipped.</string>
<string name="import_done_added_label">Added</string>
<string name="import_done_skipped_label">Duplicates</string>
<string name="import_close">Close</string>
<string name="import_warning_recurrence">Some changed occurrences of recurring events were skipped.</string>
<string name="import_warning_no_start">An event without a start time was skipped.</string>
<string name="import_warning_attendees">Guest lists weren\'t imported.</string>
<string name="import_warning_timezone">An unknown time zone fell back to your device\'s.</string>
<string name="import_button">Import</string>
<plurals name="import_title_count">
<item quantity="one">Importing %d event</item>
<item quantity="other">Importing %d events</item>
</plurals>
<plurals name="import_event_count">
<item quantity="one">%d event in this file.</item>
<item quantity="other">%d events in this file.</item>

View File

@@ -536,6 +536,30 @@ class CalendarRepositoryImplTest {
assertThat(fake.importedEvents.map { it.second }).containsExactly(3L, 3L)
}
@Test
fun `exportEvents forwards the chosen calendar-id subset to the data source`(
@TempDir tempDir: Path,
) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
repo.exportEvents(calendarIds = setOf(1L, 4L))
assertThat(fake.lastExportableEventsCalendarIds).containsExactly(1L, 4L)
}
@Test
fun `exportEvents defaults to null so every eligible calendar is exported`(
@TempDir tempDir: Path,
) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
repo.exportEvents()
assertThat(fake.lastExportableEventsCalendarIds).isNull()
}
private fun parsedEvent(uid: String?) = de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
uid = uid,
summary = "E",

View File

@@ -23,6 +23,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var eventDetailResult: (Long) -> EventDetail? = { null }
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
var exportableEventsResult: List<IcsEvent> = emptyList()
/** The [calendarIds] the last [exportableEvents] call received (null = all). */
var lastExportableEventsCalendarIds: Set<Long>? = null
/** UIDs the target calendar already holds, for import dedup. */
var existingUidsResult: Set<String> = emptySet()
/** Set to make the next write call throw. */
@@ -59,7 +61,10 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
eventColorPaletteResult(calendarId)
override fun exportableEvents(): List<IcsEvent> = exportableEventsResult
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
lastExportableEventsCalendarIds = calendarIds
return exportableEventsResult
}
override fun existingUids(calendarId: Long): Set<String> = existingUidsResult

View File

@@ -95,6 +95,14 @@ class SettingsPrefsTest {
assertThat(prefs.showHourLines.first()).isTrue()
}
@Test
fun `week numbers default off and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.showWeekNumbers.first()).isFalse()
prefs.setShowWeekNumbers(true)
assertThat(prefs.showWeekNumbers.first()).isTrue()
}
@Test
fun `autofocus event title defaults on and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))

View File

@@ -0,0 +1,7 @@
### Added
- Tap a date header to open that day. In Week and Agenda view, tapping a date
header now opens that date in Day view — the same drill-in that Month view and
the agenda widget already offered, so every view behaves the same way. It makes
jumping to a specific day quicker: switch to Week, swipe to the week you want,
then tap the date to open it. Thanks to @ptab for the suggestion ([#37]).