Merge release/v2.14.0 into chore/floret-implementation
Brings the 2.14.0 feature set (Month week numbers, .ics restore + per-calendar export picker, Day view on date-header tap, wrapped long titles, Simplified Chinese) onto the floret-kit migration without resurrecting the inline component copies the kit now owns. Conflict resolutions (both were import-block collisions where the migration repointed to de.jeanlucmakiola.floret.* while release kept the old app-local imports): - CalendarsScreen.kt: keep the kit imports (CollapsingScaffold, DialogAmountField/DialogUnitDropdown, collapseExit/expandEnter, predictiveBack) and repoint FullScreenPicker + positionOf to the kit (both moved out of ui.common). Keep the genuinely app-local LeadingAvatar/SourceLogo/curatedSourcePackage; drop the renamed calendarCollapseExit/calendarExpandEnter (0 uses). - ImportScreen.kt: keep the app-local CalendarPickerGroups, use the kit's predictiveBack, drop the now-unused OptionCard import. Semantic fixup: CalendarPickerGroups.kt (new in 2.14.0) relied on same-package resolution of GroupedRow/Position, which the migration moved to the kit — added the explicit floret.components imports. Verified: :app:compileDebugKotlin, ./gradlew test (app + kit), and scripts/check_translations.py all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -73,9 +73,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
|
||||
@@ -613,12 +615,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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -118,7 +118,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,
|
||||
|
||||
@@ -205,6 +205,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
|
||||
@@ -664,6 +677,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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,12 +93,17 @@ 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.LeadingAvatar
|
||||
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
@@ -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.
|
||||
@@ -302,8 +326,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))
|
||||
@@ -314,7 +343,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(
|
||||
@@ -343,6 +387,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))
|
||||
@@ -409,6 +466,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)
|
||||
@@ -742,66 +879,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) {
|
||||
@@ -936,9 +1013,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
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
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -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
|
||||
@@ -122,7 +121,7 @@ import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.identity.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.floret.components.DialogAmountField
|
||||
@@ -521,13 +520,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()
|
||||
@@ -1978,50 +1981,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.floret.components.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) {
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -564,6 +564,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),
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user