Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2670269776 | ||
|
|
bdcf2b3823 | ||
|
|
5c20c5f6eb | ||
|
|
c4bb7ae0b2 | ||
|
|
0c985a280c | ||
|
|
17ebb9fd42 | ||
|
|
cadc2b14db | ||
|
|
1fdca903be | ||
|
|
5e7658aa80 | ||
|
|
bea31032fb | ||
|
|
f7c1dbdbc4 | ||
|
|
a1ed010963 |
@@ -5,6 +5,24 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [2.20.4] — 2026-09-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **The app no longer reopens the event you last came in on.** Opening an event
|
||||||
|
from a reminder or a home-screen widget made the app return to that same
|
||||||
|
event on every later launch — and show an error screen once the event had
|
||||||
|
been deleted. Android hands an app the launch it was started with again each
|
||||||
|
time it is rebuilt, and Calendula was acting on it every time instead of
|
||||||
|
once ([#309]).
|
||||||
|
- **"Today" works in the agenda again.** Scrolling a few days ahead and tapping
|
||||||
|
today did nothing, because the agenda is browsed by scrolling rather than by
|
||||||
|
moving its date, and the button only moved the date. It now brings the list
|
||||||
|
back to today, and it stays offered while the list is scrolled away — before,
|
||||||
|
it was hidden in exactly the situation it is for ([#305]).
|
||||||
|
- **Calendula is filed under "Calendar & Agenda" in the self-hosted repo.**
|
||||||
|
Browsing by category in an F-Droid client set to prefer that repo listed the
|
||||||
|
app under "Time" instead ([#294]).
|
||||||
|
|
||||||
## [2.20.3] — 2026-09-12
|
## [2.20.3] — 2026-09-12
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -1693,3 +1711,6 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#239]: https://codeberg.org/jlmakiola/calendula/issues/239
|
[#239]: https://codeberg.org/jlmakiola/calendula/issues/239
|
||||||
[#297]: https://codeberg.org/jlmakiola/calendula/issues/297
|
[#297]: https://codeberg.org/jlmakiola/calendula/issues/297
|
||||||
[#298]: https://codeberg.org/jlmakiola/calendula/issues/298
|
[#298]: https://codeberg.org/jlmakiola/calendula/issues/298
|
||||||
|
[#309]: https://codeberg.org/jlmakiola/calendula/issues/309
|
||||||
|
[#305]: https://codeberg.org/jlmakiola/calendula/issues/305
|
||||||
|
[#294]: https://codeberg.org/jlmakiola/calendula/issues/294
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 22003
|
versionCode = 22004
|
||||||
versionName = "2.20.3"
|
versionName = "2.20.4"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,11 +113,26 @@ class MainActivity : AppCompatActivity() {
|
|||||||
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
||||||
Configuration.UI_MODE_NIGHT_YES
|
Configuration.UI_MODE_NIGHT_YES
|
||||||
applyEdgeToEdge()
|
applyEdgeToEdge()
|
||||||
|
// Android hands the intent that started the task back to onCreate every
|
||||||
|
// time the activity is recreated — on a rotation, or on a restart after
|
||||||
|
// the system reclaimed the process. Re-running a launch that opens one
|
||||||
|
// specific event is what made a single reminder or widget tap re-open
|
||||||
|
// that event on every later launch, an error screen once the event had
|
||||||
|
// been deleted (#309); the user left it long ago, and the activity
|
||||||
|
// restores where they actually were along with its saved state.
|
||||||
|
//
|
||||||
|
// Only those channels are held back. An .ics import or a prefilled
|
||||||
|
// create form keeps no saved state of its own, so re-reading the intent
|
||||||
|
// is what carries one through a recreation.
|
||||||
|
val replaying = savedInstanceState != null || intent.isRelaunch()
|
||||||
|
if (!replaying) {
|
||||||
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||||
|
requestedEditKey = intent.editEventKeyOrNull()
|
||||||
|
}
|
||||||
requestedNav = intent.navRequestOrNull()
|
requestedNav = intent.navRequestOrNull()
|
||||||
|
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
|
||||||
requestedImportUri = intent.importUriOrNull()
|
requestedImportUri = intent.importUriOrNull()
|
||||||
requestedInsert = intent.insertRequestOrNull()
|
requestedInsert = intent.insertRequestOrNull()
|
||||||
requestedEditKey = intent.editEventKeyOrNull()
|
|
||||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
setContent {
|
setContent {
|
||||||
// One activity-scoped SettingsViewModel drives both the theme here
|
// One activity-scoped SettingsViewModel drives both the theme here
|
||||||
@@ -242,6 +257,16 @@ class MainActivity : AppCompatActivity() {
|
|||||||
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this launch is the task being resumed rather than a fresh
|
||||||
|
* delivery — Android sets the flag when an activity is started from the
|
||||||
|
* recents list, where the intent it carries is the one the task was started
|
||||||
|
* with however long ago. `onNewIntent` is the channel a real new request
|
||||||
|
* arrives on while the task lives, so nothing is lost by ignoring these.
|
||||||
|
*/
|
||||||
|
private fun Intent.isRelaunch(): Boolean =
|
||||||
|
flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
|
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
|
||||||
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
|
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
|
||||||
|
|||||||
@@ -253,6 +253,13 @@ enum class FailureReason {
|
|||||||
PermissionRevoked,
|
PermissionRevoked,
|
||||||
NoCalendarsConfigured,
|
NoCalendarsConfigured,
|
||||||
AllCalendarsHidden,
|
AllCalendarsHidden,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendars exist and are switched on, but none can receive an event: every
|
||||||
|
* one is read-only, app-managed, or not synced to this device. Distinct from
|
||||||
|
* [AllCalendarsHidden], which a visibility switch fixes.
|
||||||
|
*/
|
||||||
|
NoImportTarget,
|
||||||
ProviderUnavailable,
|
ProviderUnavailable,
|
||||||
EventNotFound,
|
EventNotFound,
|
||||||
Unknown,
|
Unknown,
|
||||||
|
|||||||
@@ -166,14 +166,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
component != null -> {
|
component != null -> {
|
||||||
|
// A component whose END line never arrives ends at the next
|
||||||
|
// one's BEGIN, not at that one's END: reading to the far END
|
||||||
|
// would fold two events into one body, where the later
|
||||||
|
// properties overwrite the earlier and one event is lost.
|
||||||
val end = indexOfEnd(lines, i + 1, component)
|
val end = indexOfEnd(lines, i + 1, component)
|
||||||
|
val body = indexOfNextComponent(lines, i + 1, end)
|
||||||
parseComponent(
|
parseComponent(
|
||||||
body = lines.subList(i + 1, end),
|
body = lines.subList(i + 1, body),
|
||||||
fileCalendarName = calendarName,
|
fileCalendarName = calendarName,
|
||||||
warnings = warnings,
|
warnings = warnings,
|
||||||
isTask = component == "VTODO",
|
isTask = component == "VTODO",
|
||||||
)?.let(events::add)
|
)?.let(events::add)
|
||||||
i = end + 1
|
i = if (body < end) body else end + 1
|
||||||
}
|
}
|
||||||
line.isBegin("VTIMEZONE") -> {
|
line.isBegin("VTIMEZONE") -> {
|
||||||
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
||||||
@@ -445,6 +450,23 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
||||||
|
/**
|
||||||
|
* Where an unterminated component's body has to stop: the next top-level
|
||||||
|
* `VEVENT` / `VTODO` in `[from, end)`, or [end] when there is none. A
|
||||||
|
* nested `VALARM` is part of the body and is not a boundary.
|
||||||
|
*/
|
||||||
|
fun indexOfNextComponent(lines: List<String>, from: Int, end: Int): Int {
|
||||||
|
var i = from
|
||||||
|
while (i < end) {
|
||||||
|
val line = parseContentLine(lines[i])
|
||||||
|
if (line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return end
|
||||||
|
}
|
||||||
|
|
||||||
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
||||||
var i = from
|
var i = from
|
||||||
while (i < lines.size) {
|
while (i < lines.size) {
|
||||||
|
|||||||
@@ -197,10 +197,21 @@ fun CalendarHost(
|
|||||||
// is "restore a backup", not "add this one event". An externally opened .ics
|
// is "restore a backup", not "add this one event". An externally opened .ics
|
||||||
// keeps routing a single event straight into the prefilled create form.
|
// keeps routing a single event straight into the prefilled create form.
|
||||||
var importForceMany by remember { mutableStateOf(false) }
|
var importForceMany by remember { mutableStateOf(false) }
|
||||||
|
// Where a restore came from, so closing the import puts it back: the import
|
||||||
|
// overlays are declared under Backup & restore and the manager, so starting
|
||||||
|
// one has to close them — without this, finishing a restore drops you on the
|
||||||
|
// calendar and the next file means walking in through Settings again.
|
||||||
|
var backupAfterImport by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var calendarsAfterImport by rememberSaveable { mutableStateOf(false) }
|
||||||
|
// One import run. The import VM lives in the Activity's store, so this is
|
||||||
|
// what tells it a *re-import of the same file* is new work and not the run
|
||||||
|
// it already finished; a rotation keeps the number and keeps the result.
|
||||||
|
var importSession by rememberSaveable { mutableStateOf(0) }
|
||||||
LaunchedEffect(requestedImportUri) {
|
LaunchedEffect(requestedImportUri) {
|
||||||
if (requestedImportUri != null) {
|
if (requestedImportUri != null) {
|
||||||
importUri = requestedImportUri
|
importUri = requestedImportUri
|
||||||
importForceMany = false
|
importForceMany = false
|
||||||
|
importSession++
|
||||||
onImportConsumed()
|
onImportConsumed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -510,11 +521,28 @@ fun CalendarHost(
|
|||||||
importUri?.let { uri ->
|
importUri?.let { uri ->
|
||||||
ImportScreen(
|
ImportScreen(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
|
session = importSession,
|
||||||
forceMany = importForceMany,
|
forceMany = importForceMany,
|
||||||
onClose = { importUri = null },
|
onClose = {
|
||||||
onManageCalendars = { showCalendars = true },
|
importUri = null
|
||||||
|
// Back to the surface the restore started from, ready for
|
||||||
|
// the next file.
|
||||||
|
showCalendars = calendarsAfterImport
|
||||||
|
showBackup = backupAfterImport
|
||||||
|
calendarsAfterImport = false
|
||||||
|
backupAfterImport = false
|
||||||
|
},
|
||||||
|
onManageCalendars = {
|
||||||
|
// The manager is where this leads, so it must not be
|
||||||
|
// reopened underneath on close.
|
||||||
|
calendarsAfterImport = false
|
||||||
|
backupAfterImport = false
|
||||||
|
showCalendars = true
|
||||||
|
},
|
||||||
onOpenSingle = { form ->
|
onOpenSingle = { form ->
|
||||||
importUri = null
|
importUri = null
|
||||||
|
calendarsAfterImport = false
|
||||||
|
backupAfterImport = false
|
||||||
importFormSource = ImportSource.File
|
importFormSource = ImportSource.File
|
||||||
importForm = form
|
importForm = form
|
||||||
},
|
},
|
||||||
@@ -549,15 +577,29 @@ fun CalendarHost(
|
|||||||
visible = showBackup,
|
visible = showBackup,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
// Settings can open this screen without the manager underneath, so
|
||||||
|
// its failure states' "Manage calendars" way out has to open the
|
||||||
|
// manager rather than just pop — popping alone lands on Settings
|
||||||
|
// (#304). Coming from the manager the flag is already set, so this
|
||||||
|
// is the same pop-back there.
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalManageCalendars provides {
|
||||||
|
showCalendars = true
|
||||||
|
showBackup = false
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
BackupScreen(
|
BackupScreen(
|
||||||
onBack = { showBackup = false },
|
onBack = { showBackup = false },
|
||||||
// Restore runs the normal .ics import, and both this screen and
|
// Restore runs the normal .ics import, and both this screen
|
||||||
// the manager that can have opened it are declared above the
|
// and the manager that can have opened it are declared above
|
||||||
// import overlays — so both have to step aside.
|
// the import overlays — so both have to step aside.
|
||||||
onImport = {
|
onImport = {
|
||||||
importUri = it
|
importUri = it
|
||||||
importForceMany = true
|
importForceMany = true
|
||||||
|
importSession++
|
||||||
|
backupAfterImport = true
|
||||||
|
calendarsAfterImport = showCalendars
|
||||||
showBackup = false
|
showBackup = false
|
||||||
showCalendars = false
|
showCalendars = false
|
||||||
},
|
},
|
||||||
@@ -565,6 +607,7 @@ fun CalendarHost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Persists the view back stack across config change / process death by ordinal. */
|
/** Persists the view back stack across config change / process death by ordinal. */
|
||||||
private val viewStackSaver = listSaver<List<CalendarView>, Int>(
|
private val viewStackSaver = listSaver<List<CalendarView>, Int>(
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Coffee
|
import androidx.compose.material.icons.filled.Coffee
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
@@ -32,6 +34,8 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -106,6 +110,27 @@ fun AgendaScreen(
|
|||||||
}
|
}
|
||||||
val successState = state as? AgendaUiState.Success
|
val successState = state as? AgendaUiState.Success
|
||||||
|
|
||||||
|
// The agenda window always starts at the anchor, so moving through it is a
|
||||||
|
// list scroll, not an anchor change — "today" has to bring the list back to
|
||||||
|
// the top as well, and stay offered while it is scrolled away (#305).
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val scrolledAway by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A new window is rendered from its first day, so the list belongs at the
|
||||||
|
// top. Keyed on the anchor the days arrived with, not the one just
|
||||||
|
// requested: scrolling before the new rows compose leaves the list keyed to
|
||||||
|
// a row that reappears further down, and it follows it there.
|
||||||
|
LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) }
|
||||||
|
val jumpToToday = {
|
||||||
|
// Off today, moving the anchor is what resets the list (above); on today
|
||||||
|
// the window doesn't change, so the scroll back is the whole action.
|
||||||
|
if (isOnToday) scope.launch { listState.animateScrollToItem(0) }
|
||||||
|
viewModel.goToToday()
|
||||||
|
}
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
drawerContent = {
|
drawerContent = {
|
||||||
@@ -139,14 +164,15 @@ fun AgendaScreen(
|
|||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
onToday = viewModel::goToToday,
|
onToday = jumpToToday,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
CalendarFabColumn(
|
CalendarFabColumn(
|
||||||
todayVisible = !isOnToday && !todayInToolbar,
|
todayVisible = (!isOnToday || (scrolledAway && successState != null)) &&
|
||||||
|
!todayInToolbar,
|
||||||
todayText = stringResource(R.string.agenda_today_action),
|
todayText = stringResource(R.string.agenda_today_action),
|
||||||
onToday = viewModel::goToToday,
|
onToday = jumpToToday,
|
||||||
onCreate = { onCreateEvent(anchor, null) },
|
onCreate = { onCreateEvent(anchor, null) },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -195,6 +221,7 @@ fun AgendaScreen(
|
|||||||
state = state,
|
state = state,
|
||||||
pastDisplay = pastDisplay,
|
pastDisplay = pastDisplay,
|
||||||
showToday = showToday,
|
showToday = showToday,
|
||||||
|
listState = listState,
|
||||||
onRetry = viewModel::goToToday,
|
onRetry = viewModel::goToToday,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
@@ -298,6 +325,7 @@ internal fun AgendaContent(
|
|||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
listState: LazyListState = rememberLazyListState(),
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
AgendaUiState.Loading -> Box(modifier)
|
AgendaUiState.Loading -> Box(modifier)
|
||||||
@@ -326,12 +354,16 @@ internal fun AgendaContent(
|
|||||||
enabled = showToday && state.anchor == state.today,
|
enabled = showToday && state.anchor == state.today,
|
||||||
)
|
)
|
||||||
if (days.isEmpty()) {
|
if (days.isEmpty()) {
|
||||||
|
// Nothing to scroll: drop the position a previous list left, so
|
||||||
|
// the today FAB doesn't linger over an empty screen.
|
||||||
|
LaunchedEffect(Unit) { listState.scrollToItem(0) }
|
||||||
AgendaEmpty(modifier)
|
AgendaEmpty(modifier)
|
||||||
} else {
|
} else {
|
||||||
AgendaList(
|
AgendaList(
|
||||||
days = days,
|
days = days,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
zone = state.zone,
|
zone = state.zone,
|
||||||
|
listState = listState,
|
||||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||||
now = now,
|
now = now,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
@@ -349,6 +381,7 @@ private fun AgendaList(
|
|||||||
days: List<AgendaDay>,
|
days: List<AgendaDay>,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
|
listState: LazyListState,
|
||||||
dimPast: Boolean,
|
dimPast: Boolean,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
@@ -357,6 +390,7 @@ private fun AgendaList(
|
|||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
state = listState,
|
||||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import android.net.Uri
|
|||||||
import android.text.format.DateUtils
|
import android.text.format.DateUtils
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -18,6 +20,7 @@ import androidx.compose.material.icons.filled.Schedule
|
|||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
@@ -47,9 +50,10 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||||
@@ -80,18 +84,13 @@ fun BackupScreen(
|
|||||||
onImport: (Uri) -> Unit,
|
onImport: (Uri) -> Unit,
|
||||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
val state by viewModel.backupState.collectAsStateWithLifecycle()
|
||||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||||
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
// Export covers local calendars only; managed special-dates mirrors are
|
|
||||||
// rebuilt from contacts. Restore can target anything the import picker offers.
|
|
||||||
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
|
|
||||||
val canImport = calendars.any { it.isEventTarget }
|
|
||||||
|
|
||||||
// Exports everything eligible (null); the per-calendar selector owns its
|
// Exports everything eligible (null); the per-calendar selector owns its
|
||||||
// own launcher.
|
// own launcher.
|
||||||
val createBackup = rememberLauncherForActivityResult(
|
val createBackup = rememberLauncherForActivityResult(
|
||||||
@@ -132,31 +131,120 @@ fun BackupScreen(
|
|||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_backup),
|
title = stringResource(R.string.settings_section_backup),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
// Loading and failure fill the screen and centre themselves, which they
|
||||||
|
// can only do in an unscrolled column — the scrolling one measures them
|
||||||
|
// against an unbounded height and leaves them hanging under the header.
|
||||||
|
scrollable = state is BackupUiState.Ready,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
HintText(stringResource(R.string.calendars_backup_hint))
|
when (val s = state) {
|
||||||
|
BackupUiState.Loading -> BackupLoading()
|
||||||
|
is BackupUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onBack)
|
||||||
|
is BackupUiState.Ready -> BackupContent(
|
||||||
|
exportable = s.exportable,
|
||||||
|
canImport = s.canImport,
|
||||||
|
autoBackup = autoBackup,
|
||||||
|
viewModel = viewModel,
|
||||||
|
onRestore = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||||
|
onPickFolder = { runCatching { pickFolder.launch(null) } },
|
||||||
|
onExportAll = {
|
||||||
|
runCatching { createBackup.launch(defaultBackupName()) }
|
||||||
|
},
|
||||||
|
onExportPick = { showExportPicker = true },
|
||||||
|
onEditInterval = { showInterval = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (exportable.isNotEmpty()) {
|
// Gated on Ready rather than resetting the flag when the state leaves it:
|
||||||
|
// flipping it here would be a write during composition.
|
||||||
|
(state as? BackupUiState.Ready)?.let { ready ->
|
||||||
|
if (showExportPicker) {
|
||||||
|
ExportCalendarPicker(
|
||||||
|
calendars = ready.exportable,
|
||||||
|
onExport = viewModel::exportBackup,
|
||||||
|
onDismiss = { showExportPicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showInterval) {
|
||||||
|
BackupIntervalDialog(
|
||||||
|
currentMinutes = autoBackup.intervalMinutes,
|
||||||
|
onConfirm = viewModel::setAutoBackupIntervalMinutes,
|
||||||
|
onDismiss = { showInterval = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The screen's loading state, centred in the scaffold's content column. */
|
||||||
|
@Composable
|
||||||
|
private fun BackupLoading() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default file name for a one-shot export. */
|
||||||
|
private fun defaultBackupName(): String = "calendula-backup-${LocalDate.now()}.ics"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The working screen: export (when there are local calendars to export), restore,
|
||||||
|
* and the automatic-backup block. Restore is always on screen — when nothing can
|
||||||
|
* receive the events it says so and routes to the calendar manager rather than
|
||||||
|
* disappearing, which is what #304 reported as an invisible button.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BackupContent(
|
||||||
|
exportable: List<CalendarSource>,
|
||||||
|
canImport: Boolean,
|
||||||
|
autoBackup: AutoBackupUiState,
|
||||||
|
viewModel: CalendarsViewModel,
|
||||||
|
onRestore: () -> Unit,
|
||||||
|
onPickFolder: () -> Unit,
|
||||||
|
onExportAll: () -> Unit,
|
||||||
|
onExportPick: () -> Unit,
|
||||||
|
onEditInterval: () -> Unit,
|
||||||
|
) {
|
||||||
|
val manageCalendars = LocalManageCalendars.current
|
||||||
|
// Restore never depends on export eligibility, and never disappears: when no
|
||||||
|
// calendar can receive events it explains that and offers the way to fix it.
|
||||||
|
val restoreSummary = if (canImport) {
|
||||||
|
stringResource(R.string.calendars_restore_hint)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.calendars_restore_unavailable)
|
||||||
|
}
|
||||||
|
val onRestoreClick = if (canImport) onRestore else (manageCalendars ?: onRestore)
|
||||||
|
|
||||||
|
if (exportable.isEmpty()) {
|
||||||
|
SectionHeader(stringResource(R.string.calendars_restore_header))
|
||||||
|
HintText(restoreSummary)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_restore_action),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||||
|
onClick = onRestoreClick,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
HintText(stringResource(R.string.calendars_backup_hint))
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.calendars_backup_action),
|
title = stringResource(R.string.calendars_backup_action),
|
||||||
position = Position.Top,
|
position = Position.Top,
|
||||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||||
onClick = {
|
|
||||||
// A single exportable calendar skips the selector.
|
// A single exportable calendar skips the selector.
|
||||||
if (exportable.size == 1) {
|
onClick = if (exportable.size == 1) onExportAll else onExportPick,
|
||||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
|
||||||
} else {
|
|
||||||
showExportPicker = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.calendars_restore_action),
|
title = stringResource(R.string.calendars_restore_action),
|
||||||
summary = stringResource(R.string.calendars_restore_hint),
|
summary = restoreSummary,
|
||||||
position = Position.Middle,
|
position = Position.Middle,
|
||||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||||
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
onClick = onRestoreClick,
|
||||||
)
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.calendars_auto_backup),
|
title = stringResource(R.string.calendars_auto_backup),
|
||||||
@@ -174,44 +262,16 @@ fun BackupScreen(
|
|||||||
summary = rememberFolderName(autoBackup.folderUri)
|
summary = rememberFolderName(autoBackup.folderUri)
|
||||||
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
||||||
position = Position.Middle,
|
position = Position.Middle,
|
||||||
onClick = { runCatching { pickFolder.launch(null) } },
|
onClick = onPickFolder,
|
||||||
)
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.calendars_auto_backup_interval),
|
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||||
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||||
position = Position.Bottom,
|
position = Position.Bottom,
|
||||||
onClick = { showInterval = true },
|
onClick = onEditInterval,
|
||||||
)
|
)
|
||||||
HintText(backupStatusText(autoBackup.status))
|
HintText(backupStatusText(autoBackup.status))
|
||||||
}
|
}
|
||||||
} else if (canImport) {
|
|
||||||
// Nothing to back up, but restore is still possible — don't hide
|
|
||||||
// it behind export eligibility.
|
|
||||||
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) } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showExportPicker) {
|
|
||||||
ExportCalendarPicker(
|
|
||||||
calendars = exportable,
|
|
||||||
onExport = viewModel::exportBackup,
|
|
||||||
onDismiss = { showExportPicker = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (showInterval) {
|
|
||||||
BackupIntervalDialog(
|
|
||||||
currentMinutes = autoBackup.intervalMinutes,
|
|
||||||
onConfirm = viewModel::setAutoBackupIntervalMinutes,
|
|
||||||
onDismiss = { showInterval = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.calendars
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State of the Backup & restore screen (#69). Three states, because the calendar
|
||||||
|
* list it is derived from has all three: it arrives empty, it can throw, and
|
||||||
|
* "loaded but nothing to offer" is a real outcome the screen used to render as a
|
||||||
|
* blank page (#304).
|
||||||
|
*/
|
||||||
|
sealed interface BackupUiState {
|
||||||
|
data object Loading : BackupUiState
|
||||||
|
data class Failure(val reason: FailureReason) : BackupUiState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* At least one half of the screen works. [exportable] may be empty (restore
|
||||||
|
* only) and [canImport] may be false (export only), but never both — that is
|
||||||
|
* a [Failure].
|
||||||
|
*/
|
||||||
|
data class Ready(
|
||||||
|
val exportable: List<CalendarSource>,
|
||||||
|
val canImport: Boolean,
|
||||||
|
) : BackupUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the screen can offer for this calendar list.
|
||||||
|
*
|
||||||
|
* Export covers the app's own local calendars; managed special-dates mirrors are
|
||||||
|
* rebuilt from contacts, so they are excluded. Restore can target anything the
|
||||||
|
* import picker offers ([isEventTarget]).
|
||||||
|
*
|
||||||
|
* A failure is raised only when *neither* is possible — deliberately not
|
||||||
|
* [calendarListFailure] on its own, which would call an all-hidden device a
|
||||||
|
* failure while its local calendars are still perfectly exportable. When both
|
||||||
|
* halves are dead the list itself usually says why (no calendars, everything
|
||||||
|
* switched off); [FailureReason.NoImportTarget] covers the remaining case, where
|
||||||
|
* calendars exist and are visible but every one of them is read-only, managed or
|
||||||
|
* not synced to the device.
|
||||||
|
*/
|
||||||
|
fun backupUiState(calendars: List<CalendarSource>): BackupUiState {
|
||||||
|
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||||
|
val canImport = calendars.any { it.isEventTarget }
|
||||||
|
if (exportable.isEmpty() && !canImport) {
|
||||||
|
return BackupUiState.Failure(
|
||||||
|
calendarListFailure(calendars) ?: FailureReason.NoImportTarget,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return BackupUiState.Ready(exportable = exportable, canImport = canImport)
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -24,6 +25,7 @@ import kotlinx.coroutines.flow.catch
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -56,6 +58,22 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
initialValue = emptyList(),
|
initialValue = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Backup & restore screen's own view of that list, with the loading and
|
||||||
|
* failure states [calendars] flattens away — it starts empty and catches to
|
||||||
|
* empty, which that screen used to render as a blank page (#304).
|
||||||
|
*/
|
||||||
|
val backupState: StateFlow<BackupUiState> =
|
||||||
|
repository.calendars()
|
||||||
|
.map { backupUiState(it) }
|
||||||
|
.catch { emit(BackupUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = BackupUiState.Loading,
|
||||||
|
)
|
||||||
|
|
||||||
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
||||||
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
||||||
settingsPrefs.autoBackupEnabled,
|
settingsPrefs.autoBackupEnabled,
|
||||||
|
|||||||
@@ -4,19 +4,36 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.outlined.EditOff
|
||||||
|
import androidx.compose.material.icons.outlined.ErrorOutline
|
||||||
|
import androidx.compose.material.icons.outlined.EventBusy
|
||||||
|
import androidx.compose.material.icons.outlined.Lock
|
||||||
|
import androidx.compose.material.icons.outlined.SyncProblem
|
||||||
|
import androidx.compose.material.icons.outlined.VisibilityOff
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@@ -26,52 +43,150 @@ import de.jeanlucmakiola.calendula.domain.FailureReason
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-screen failure state shared by every calendar screen (spec §7).
|
* Full-screen failure state shared by every calendar screen (spec §7).
|
||||||
* One explanation line + one recovery action, never a toast.
|
* A tonal icon, one headline, one supporting line and one recovery action —
|
||||||
|
* never a toast.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
|
fun CalendarFailure(
|
||||||
|
reason: FailureReason,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val manageCalendars = LocalManageCalendars.current
|
val manageCalendars = LocalManageCalendars.current
|
||||||
val titleRes = when (reason) {
|
val copy = failureCopy(reason)
|
||||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
|
||||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
|
||||||
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden
|
|
||||||
FailureReason.ProviderUnavailable -> R.string.state_failure_provider
|
|
||||||
FailureReason.Unknown,
|
|
||||||
FailureReason.EventNotFound -> R.string.state_failure_unknown
|
|
||||||
}
|
|
||||||
val actionRes = when (reason) {
|
|
||||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action
|
|
||||||
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden_action
|
|
||||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission_action
|
|
||||||
else -> R.string.state_retry
|
|
||||||
}
|
|
||||||
val onAction: () -> Unit = when (reason) {
|
val onAction: () -> Unit = when (reason) {
|
||||||
FailureReason.NoCalendarsConfigured -> {
|
FailureReason.NoCalendarsConfigured -> {
|
||||||
{ context.startCalendarSetup() }
|
{ context.startCalendarSetup() }
|
||||||
}
|
}
|
||||||
FailureReason.AllCalendarsHidden -> manageCalendars ?: onRetry
|
FailureReason.AllCalendarsHidden,
|
||||||
|
FailureReason.NoImportTarget,
|
||||||
|
-> manageCalendars ?: onRetry
|
||||||
else -> onRetry
|
else -> onRetry
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(32.dp),
|
.padding(horizontal = 32.dp, vertical = 24.dp),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
|
FailureIcon(icon = copy.icon, isError = copy.isError)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(titleRes),
|
text = stringResource(copy.title),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(24.dp))
|
copy.body?.let { body ->
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(body),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
FilledTonalButton(onClick = onAction) {
|
FilledTonalButton(onClick = onAction) {
|
||||||
Text(stringResource(actionRes))
|
Text(stringResource(copy.action))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The state's mark: the reason's icon in a tonal circle above the headline. */
|
||||||
|
@Composable
|
||||||
|
private fun FailureIcon(icon: ImageVector, isError: Boolean) {
|
||||||
|
val container: Color
|
||||||
|
val content: Color
|
||||||
|
if (isError) {
|
||||||
|
container = MaterialTheme.colorScheme.errorContainer
|
||||||
|
content = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
} else {
|
||||||
|
container = MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
content = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(72.dp)
|
||||||
|
.background(container, CircleShape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = content,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a reason says and offers. Headline and supporting line are separate so
|
||||||
|
* the explanation reads as body text instead of a headline that runs over three
|
||||||
|
* lines; [isError] tints the mark for the two reasons that are an actual fault
|
||||||
|
* rather than a calendar the user can switch back on.
|
||||||
|
*/
|
||||||
|
private data class FailureCopy(
|
||||||
|
val icon: ImageVector,
|
||||||
|
@StringRes val title: Int,
|
||||||
|
@StringRes val body: Int?,
|
||||||
|
@StringRes val action: Int,
|
||||||
|
val isError: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun failureCopy(reason: FailureReason): FailureCopy = when (reason) {
|
||||||
|
FailureReason.PermissionRevoked -> FailureCopy(
|
||||||
|
icon = Icons.Outlined.Lock,
|
||||||
|
title = R.string.state_failure_permission,
|
||||||
|
body = R.string.state_failure_permission_body,
|
||||||
|
action = R.string.state_failure_permission_action,
|
||||||
|
)
|
||||||
|
FailureReason.NoCalendarsConfigured -> FailureCopy(
|
||||||
|
icon = Icons.Outlined.CalendarMonth,
|
||||||
|
title = R.string.state_failure_no_calendars,
|
||||||
|
body = R.string.state_failure_no_calendars_body,
|
||||||
|
action = R.string.state_failure_no_calendars_action,
|
||||||
|
)
|
||||||
|
FailureReason.AllCalendarsHidden -> FailureCopy(
|
||||||
|
icon = Icons.Outlined.VisibilityOff,
|
||||||
|
title = R.string.state_failure_all_hidden,
|
||||||
|
body = R.string.state_failure_all_hidden_body,
|
||||||
|
action = R.string.state_failure_all_hidden_action,
|
||||||
|
)
|
||||||
|
FailureReason.NoImportTarget -> FailureCopy(
|
||||||
|
icon = Icons.Outlined.EditOff,
|
||||||
|
title = R.string.state_failure_no_import_target,
|
||||||
|
body = R.string.state_failure_no_import_target_body,
|
||||||
|
action = R.string.state_failure_all_hidden_action,
|
||||||
|
)
|
||||||
|
FailureReason.EventNotFound -> FailureCopy(
|
||||||
|
icon = Icons.Outlined.EventBusy,
|
||||||
|
title = R.string.state_failure_event_not_found,
|
||||||
|
body = R.string.state_failure_event_not_found_body,
|
||||||
|
action = R.string.state_retry,
|
||||||
|
)
|
||||||
|
FailureReason.ProviderUnavailable -> FailureCopy(
|
||||||
|
icon = Icons.Outlined.SyncProblem,
|
||||||
|
title = R.string.state_failure_provider,
|
||||||
|
body = R.string.state_failure_provider_body,
|
||||||
|
action = R.string.state_retry,
|
||||||
|
isError = true,
|
||||||
|
)
|
||||||
|
FailureReason.Unknown -> FailureCopy(
|
||||||
|
icon = Icons.Outlined.ErrorOutline,
|
||||||
|
title = R.string.state_failure_unknown,
|
||||||
|
body = null,
|
||||||
|
action = R.string.state_retry,
|
||||||
|
isError = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps the headline and its supporting line at a readable measure. */
|
||||||
|
private val TEXT_MAX_WIDTH = 320.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens Settings → Calendars, the only screen that can switch a calendar back
|
* Opens Settings → Calendars, the only screen that can switch a calendar back
|
||||||
* on. Null outside the calendar host — screens without it never raise the
|
* on. Null outside the calendar host — screens without it never raise the
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import androidx.compose.ui.input.pointer.PointerEventPass
|
|||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.ceil
|
|
||||||
import kotlin.math.floor
|
import kotlin.math.floor
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -197,18 +196,26 @@ fun rememberTimelinePinchZoom(
|
|||||||
* names, jumping the whole column as a pinch drifts across each half pixel.
|
* names, jumping the whole column as a pinch drifts across each half pixel.
|
||||||
* Pinning the hour to whole pixels keeps every part of the timeline on one grid.
|
* Pinning the hour to whole pixels keeps every part of the timeline on one grid.
|
||||||
*
|
*
|
||||||
* The bounds themselves are pulled onto that grid too, each in the direction
|
* The ceiling is pulled onto that grid too, downwards, so it stays a height the
|
||||||
* that keeps its own promise — up for the fill floor, so no dead space opens
|
* pinch can actually land on.
|
||||||
* under midnight, down for the ceiling. A fractional bound would be a height the
|
*
|
||||||
* pinch can be held against but never actually land on, and the difference feeds
|
* [fillPx] is deliberately *not* rounded (#290). It is the one height the whole
|
||||||
* the focal anchor a scroll correction on every frame the fingers sit still.
|
* day exactly fills the viewport at, and it is the same value
|
||||||
|
* [TimelineScale.FitDay] resolves to — rounding it up by the fraction of a pixel
|
||||||
|
* that 24 hours don't divide the viewport into leaves the timeline a pixel per
|
||||||
|
* hour taller than its own viewport, so a pinched-all-the-way-out day still
|
||||||
|
* scrolls a hair and bounces off Android's overscroll stretch, while the
|
||||||
|
* identical FitDay preset sits still. Being the clamp result rather than a bound
|
||||||
|
* the gesture is merely held against, it is a height the pinch does land on: the
|
||||||
|
* next frame reads it back unchanged and the focal anchor is handed nothing to
|
||||||
|
* correct.
|
||||||
*/
|
*/
|
||||||
internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float =
|
internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float =
|
||||||
// Filling the viewport wins over the ceiling: on a screen tall enough for
|
// Filling the viewport wins over the ceiling: on a screen tall enough for
|
||||||
// the two to disagree, dead space is the worse of the two failures.
|
// the two to disagree, dead space is the worse of the two failures.
|
||||||
target.roundToInt().toFloat()
|
target.roundToInt().toFloat()
|
||||||
.coerceAtMost(floor(maxPx))
|
.coerceAtMost(floor(maxPx))
|
||||||
.coerceAtLeast(ceil(fillPx))
|
.coerceAtLeast(fillPx)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The scroll offset that keeps the moment under [centroidY] under it after the
|
* The scroll offset that keeps the moment under [centroidY] under it after the
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ fun EventDetailScreen(
|
|||||||
is EventDetailUiState.Failure -> CalendarFailure(
|
is EventDetailUiState.Failure -> CalendarFailure(
|
||||||
reason = s.reason,
|
reason = s.reason,
|
||||||
onRetry = viewModel::retry,
|
onRetry = viewModel::retry,
|
||||||
|
modifier = contentModifier,
|
||||||
)
|
)
|
||||||
is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier)
|
is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,19 +73,19 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
|
|||||||
@Composable
|
@Composable
|
||||||
fun ImportScreen(
|
fun ImportScreen(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
|
session: Int,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onOpenSingle: (EventForm) -> Unit,
|
onOpenSingle: (EventForm) -> Unit,
|
||||||
forceMany: Boolean = false,
|
forceMany: Boolean = false,
|
||||||
onManageCalendars: (() -> Unit)? = null,
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
// Key the VM by the file uri. This screen has no nav backstack, so an
|
viewModel: ImportViewModel = hiltViewModel(),
|
||||||
// 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, forceMany) }
|
// hiltViewModel() resolves to the Activity's store and is retained across
|
||||||
|
// imports, so the reload is driven by [session] rather than by a fresh VM:
|
||||||
|
// keying per uri looked right but handed a *re-import of the same file* the
|
||||||
|
// previous run's finished state, importing nothing (#304). A rotation keeps
|
||||||
|
// the session and so keeps the result.
|
||||||
|
LaunchedEffect(session) { viewModel.load(uri, forceMany, session) }
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// A single event isn't shown here — it opens the create form for review.
|
// A single event isn't shown here — it opens the create form for review.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
|||||||
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -69,18 +70,26 @@ class ImportViewModel @Inject constructor(
|
|||||||
private val parser = IcsParser()
|
private val parser = IcsParser()
|
||||||
private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Loading)
|
private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Loading)
|
||||||
val state: StateFlow<ImportUiState> = _state.asStateFlow()
|
val state: StateFlow<ImportUiState> = _state.asStateFlow()
|
||||||
private var started = false
|
private var loadedSession: Int? = null
|
||||||
|
private var loadJob: Job? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read + parse [uri] once; subsequent calls (recomposition) are ignored.
|
* Read + parse [uri] once per [session]; recomposition (and a rotation,
|
||||||
|
* which keeps the session) re-calls this and keeps the result. A new import
|
||||||
|
* of the *same* file is a new session, and has to parse and run again —
|
||||||
|
* keying on the uri alone showed the previous run's summary and imported
|
||||||
|
* nothing (#304).
|
||||||
|
*
|
||||||
* When [forceMany] is set (an in-app restore), a single-event file still goes
|
* 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 —
|
* through the bulk picker + summary rather than the prefilled create form —
|
||||||
* a restore is "bring back a backup", not "add this one event".
|
* a restore is "bring back a backup", not "add this one event".
|
||||||
*/
|
*/
|
||||||
fun load(uri: Uri, forceMany: Boolean = false) {
|
fun load(uri: Uri, forceMany: Boolean = false, session: Int = 0) {
|
||||||
if (started) return
|
if (loadedSession == session) return
|
||||||
started = true
|
loadedSession = session
|
||||||
viewModelScope.launch {
|
loadJob?.cancel()
|
||||||
|
_state.value = ImportUiState.Loading
|
||||||
|
loadJob = viewModelScope.launch {
|
||||||
val parsed = withContext(io) {
|
val parsed = withContext(io) {
|
||||||
importer.readText(uri)?.let(parser::parse)
|
importer.readText(uri)?.let(parser::parse)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
<string name="event_edit_timezone_search">Vyhledat časové pásmo</string>
|
<string name="event_edit_timezone_search">Vyhledat časové pásmo</string>
|
||||||
<string name="event_edit_timezone_recent">Nedávné</string>
|
<string name="event_edit_timezone_recent">Nedávné</string>
|
||||||
<string name="event_edit_timezone_all">Všechna časová pásma</string>
|
<string name="event_edit_timezone_all">Všechna časová pásma</string>
|
||||||
<string name="event_edit_timezone_none">Žádné časové pásmo neodpovídá “%1$s”</string>
|
<string name="event_edit_timezone_none">Žádné časové pásmo neodpovídá „%1$s“</string>
|
||||||
<string name="event_edit_timezone_local_time">%1$s vašeho času</string>
|
<string name="event_edit_timezone_local_time">%1$s vašeho času</string>
|
||||||
<string name="event_edit_color">Barva</string>
|
<string name="event_edit_color">Barva</string>
|
||||||
<string name="event_edit_color_default">Barva kalendáře</string>
|
<string name="event_edit_color_default">Barva kalendáře</string>
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
<string name="event_edit_color_unsupported">Pro tento kalendář není k dispozici</string>
|
<string name="event_edit_color_unsupported">Pro tento kalendář není k dispozici</string>
|
||||||
<string name="event_edit_color_unsupported_hint">Tento kalendář nemá žádnou sadu barev. Vlastní barvy kalendářů můžete povolit v nastavení.</string>
|
<string name="event_edit_color_unsupported_hint">Tento kalendář nemá žádnou sadu barev. Vlastní barvy kalendářů můžete povolit v nastavení.</string>
|
||||||
<string name="event_edit_color_sync_warning">Tato barva může být během příští synchronizace kalendáře ztracena nebo přepsána.</string>
|
<string name="event_edit_color_sync_warning">Tato barva může být během příští synchronizace kalendáře ztracena nebo přepsána.</string>
|
||||||
<string name="event_edit_conflict_title">Ke změně události došlo na jiném místě.</string>
|
<string name="event_edit_conflict_title">Ke změně události došlo na jiném místě</string>
|
||||||
<string name="event_edit_conflict_body">Během úpravy došlo ke změně události — v rámci synchronizace nebo pomocí jiné aplikací. Co se má stát s vašimi změnami?</string>
|
<string name="event_edit_conflict_body">Během úpravy došlo ke změně události — v rámci synchronizace nebo pomocí jiné aplikací. Co se má stát s vašimi změnami?</string>
|
||||||
<string name="event_edit_conflict_overwrite">Uložit změny</string>
|
<string name="event_edit_conflict_overwrite">Uložit změny</string>
|
||||||
<string name="event_edit_conflict_overwrite_hint">Pouze u upravených polí dojde k přepsání vnějšího zdroje</string>
|
<string name="event_edit_conflict_overwrite_hint">Pouze u upravených polí dojde k přepsání vnějšího zdroje</string>
|
||||||
@@ -190,7 +190,7 @@
|
|||||||
<string name="event_access_confidential_summary">Označeno jako utajené; záleží na daném účtu kalendáře, jak s tímto štítkem naloží</string>
|
<string name="event_access_confidential_summary">Označeno jako utajené; záleží na daném účtu kalendáře, jak s tímto štítkem naloží</string>
|
||||||
<string name="event_attendee_organizer">Pořadatel</string>
|
<string name="event_attendee_organizer">Pořadatel</string>
|
||||||
<string name="event_attendee_optional">Volitelné</string>
|
<string name="event_attendee_optional">Volitelné</string>
|
||||||
<string name="event_attendee_resource">Zdroj</string>
|
<string name="event_attendee_resource">Prostředky</string>
|
||||||
<string name="event_detail_self_response">Vaše odpověď: %1$s</string>
|
<string name="event_detail_self_response">Vaše odpověď: %1$s</string>
|
||||||
<string name="reminder_at_time">V době konání události</string>
|
<string name="reminder_at_time">V době konání události</string>
|
||||||
<string name="reminder_default">Výchozí připomínka</string>
|
<string name="reminder_default">Výchozí připomínka</string>
|
||||||
@@ -296,7 +296,7 @@
|
|||||||
<string name="search_back">Zpět</string>
|
<string name="search_back">Zpět</string>
|
||||||
<string name="search_clear">Vymazat</string>
|
<string name="search_clear">Vymazat</string>
|
||||||
<string name="search_idle_hint">Prohledávejte své události podle názvu, místa konání nebo poznámek.</string>
|
<string name="search_idle_hint">Prohledávejte své události podle názvu, místa konání nebo poznámek.</string>
|
||||||
<string name="search_empty">Žádná událost neodpovídá “%1$s”.</string>
|
<string name="search_empty">Žádná událost neodpovídá „%1$s“.</string>
|
||||||
<plurals name="search_selected_count">
|
<plurals name="search_selected_count">
|
||||||
<item quantity="one">%d označený</item>
|
<item quantity="one">%d označený</item>
|
||||||
<item quantity="few">%d označené</item>
|
<item quantity="few">%d označené</item>
|
||||||
@@ -435,7 +435,7 @@
|
|||||||
<item quantity="many">%d dnů</item>
|
<item quantity="many">%d dnů</item>
|
||||||
<item quantity="other">%d dnů</item>
|
<item quantity="other">%d dnů</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="settings_section_views">Pohledy</string>
|
<string name="settings_section_views">Zobrazení</string>
|
||||||
<string name="settings_month_header">Měsíční zobrazení</string>
|
<string name="settings_month_header">Měsíční zobrazení</string>
|
||||||
<string name="settings_month_view_style">Styl měsíčního zobrazení</string>
|
<string name="settings_month_view_style">Styl měsíčního zobrazení</string>
|
||||||
<string name="month_style_paged">Stránky</string>
|
<string name="month_style_paged">Stránky</string>
|
||||||
@@ -485,4 +485,212 @@
|
|||||||
<string name="settings_reliable_delivery_exempt">Výloučení z optimalizace baterie – připomínky chodí bez prodlení.</string>
|
<string name="settings_reliable_delivery_exempt">Výloučení z optimalizace baterie – připomínky chodí bez prodlení.</string>
|
||||||
<string name="settings_notifications_subtitle">Připomínky a doručení</string>
|
<string name="settings_notifications_subtitle">Připomínky a doručení</string>
|
||||||
<string name="settings_special_dates_reminders">Připomínky</string>
|
<string name="settings_special_dates_reminders">Připomínky</string>
|
||||||
|
<string name="settings_reliable_delivery">Spolehlivé doručení</string>
|
||||||
|
<string name="settings_snooze_duration">Doba odložení</string>
|
||||||
|
<string name="settings_section_calendars">Kalendáře</string>
|
||||||
|
<string name="settings_manage_calendars">Správa kalendářů</string>
|
||||||
|
<string name="settings_manage_calendars_hint">Vytvářet místní kalendáře; spravovat synchronizované kalendáře</string>
|
||||||
|
<string name="settings_section_language">Jazyk</string>
|
||||||
|
<string name="settings_language">Jazyk aplikace</string>
|
||||||
|
<string name="settings_language_auto">Výchozí nastavení systému</string>
|
||||||
|
<string name="settings_translate">Pomozte s překladem</string>
|
||||||
|
<string name="settings_translate_hint">Přidat nebo vylepšit jazyk na Weblate</string>
|
||||||
|
<string name="settings_group_look">Vzhled a chování</string>
|
||||||
|
<string name="settings_group_data">Data</string>
|
||||||
|
<string name="settings_group_app">Aplikace</string>
|
||||||
|
<string name="settings_group_about">O aplikaci</string>
|
||||||
|
<string name="settings_appearance_subtitle">Motiv, barvy, písma</string>
|
||||||
|
<string name="settings_views_subtitle">Výchozí zobrazení, rozložení a pořadí</string>
|
||||||
|
<string name="settings_event_form_subtitle">Výchozí pole a chování</string>
|
||||||
|
<string name="settings_special_dates_subtitle">Narozeniny a výročí kontaktů</string>
|
||||||
|
<string name="settings_widgets_subtitle">Widget s agendou, dlaždice rychlého nastavení</string>
|
||||||
|
<string name="settings_backup_subtitle">Export, import, automatické zálohování</string>
|
||||||
|
<string name="settings_section_widgets">Widgety a dlaždice</string>
|
||||||
|
<string name="settings_widgets_hint">Nastavení widgetů na domovské obrazovce a dlaždice rychlého nastavení. Widget přidáte dlouhým stisknutím domovské obrazovky.</string>
|
||||||
|
<string name="settings_section_backup">Záloha a obnovení</string>
|
||||||
|
<string name="settings_views_all_header">Všechny pohledy</string>
|
||||||
|
<string name="settings_week_day_header">Týden a den</string>
|
||||||
|
<string name="settings_default_view_hint">Pohled, který se otevře při spuštění aplikace.</string>
|
||||||
|
<string name="settings_week_start_hint">Počáteční den týdne ve všech zobrazeních a widgetech.</string>
|
||||||
|
<string name="settings_time_format_hint">Způsob zápisu času v celé aplikaci. Funkce „Automaticky“ se řídí nastavením vašeho systému.</string>
|
||||||
|
<string name="settings_past_events_hint">Jak program zachází s událostmi, které již skončily.</string>
|
||||||
|
<string name="settings_dynamic_color_summary">Aplikace převezme barvy z vaší tapety.</string>
|
||||||
|
<string name="settings_section_special_dates">Speciální data kontaktů</string>
|
||||||
|
<string name="settings_special_dates_enable">Zobrazit data kontaktů</string>
|
||||||
|
<string name="settings_special_dates_enable_hint">Zkopírujte data narozenin a další data svých kontaktů do místních kalendářů. Aplikace čte pouze kontakty v tomto zařízení – nikam se nic nenahrává, vaše kontakty zůstávají beze změny.</string>
|
||||||
|
<string name="settings_special_dates_type_birthday">Narozeniny</string>
|
||||||
|
<string name="settings_special_dates_type_anniversary">Výročí</string>
|
||||||
|
<string name="settings_special_dates_type_custom">Ostatní data</string>
|
||||||
|
<string name="settings_special_dates_template">Formát názvu</string>
|
||||||
|
<string name="settings_special_dates_template_hint">Pro kontakt použijte {name} a pro rok {year} (rok narození nebo rok zahájení výročí; pokud není znám, údaj se skryje).</string>
|
||||||
|
<string name="settings_special_dates_show_year">Zobrazit rok</string>
|
||||||
|
<string name="settings_special_dates_show_year_hint">Uvést {year} v názvu, je-li znám</string>
|
||||||
|
<string name="settings_special_dates_sync_now">Synchronizovat nyní</string>
|
||||||
|
<string name="settings_special_dates_never_synced">Zatím nesynchronizováno</string>
|
||||||
|
<string name="settings_special_dates_last_synced">Naposledy synchronizováno %1$s</string>
|
||||||
|
<string name="settings_special_dates_calendar_hint">Barvu a viditelnost jednotlivých kalendářů nastavte v nastavení kalendářů.</string>
|
||||||
|
<string name="settings_calendar_reminders_managed_hint">Nastavte v části Speciální data kontaktu</string>
|
||||||
|
<string name="settings_special_dates_paused_title">Pozastaveno</string>
|
||||||
|
<string name="settings_special_dates_paused_hint">Calendula už nemá přístup k vašim kontaktům, tudíž zůstávají bez aktualizace.</string>
|
||||||
|
<string name="settings_special_dates_grant">Udělit přístup</string>
|
||||||
|
<string name="settings_special_dates_disable_title">Vypnout data kontaktů?</string>
|
||||||
|
<string name="settings_special_dates_disable_type_message">Tímto se odstraní kalendář „%1$s“ a jeho události. Veškeré připomínky a poznámky, které jste do nich přidali, budou ztraceny.</string>
|
||||||
|
<string name="settings_special_dates_disable_confirm">Vypnout</string>
|
||||||
|
<string name="dialog_save">Uložit</string>
|
||||||
|
<string name="settings_section_about">O aplikaci</string>
|
||||||
|
<string name="settings_license">Licence</string>
|
||||||
|
<string name="settings_license_value">MIT</string>
|
||||||
|
<string name="settings_about_author">od Jean-Luca Makioly</string>
|
||||||
|
<string name="settings_about_source">Zdroj</string>
|
||||||
|
<string name="settings_licences">Licence open source</string>
|
||||||
|
<string name="settings_licences_subtitle">Projekty, na kterých je Calendula postavena</string>
|
||||||
|
<string name="settings_about_privacy">Zásady ochrany osobních údajů</string>
|
||||||
|
<string name="settings_about_support">Podpořte vývoj</string>
|
||||||
|
<string name="settings_about_version">Verze %1$s</string>
|
||||||
|
<string name="settings_about_logo_desc">Ikona aplikace</string>
|
||||||
|
<string name="settings_report_problem">Nahlásit problém</string>
|
||||||
|
<string name="settings_report_problem_hint">Odeslat hlášení o pádu nebo otevřít systém pro sledování chyb</string>
|
||||||
|
<string name="licences_title">Licence open source</string>
|
||||||
|
<string name="licences_intro">Calendula zahrnuje níže uvedené projekty. Klepnutím na libovolnou položku zobrazíte jeho zdrojový kód.</string>
|
||||||
|
<string name="licences_footer">Každý projekt je zde zahrnut v nezměněné podobě a pod uvedenou licencí.</string>
|
||||||
|
<string name="calendars_title">Kalendáře</string>
|
||||||
|
<string name="calendars_local_header">Vaše kalendáře</string>
|
||||||
|
<string name="calendars_local_empty">Zatím nejsou k dispozici žádné místní kalendáře. Vytvořte si kalendář, chcete-li události ukládat pouze do tohoto zařízení.</string>
|
||||||
|
<string name="calendars_add">Přidat kalendář</string>
|
||||||
|
<string name="calendars_visibility_hint">Vypnutím kalendáře jej na tomto zařízení skryjete – jeho události zmizí z aplikace a přestanou se zobrazovat připomínky. Jde o stejný přepínač, jaký používají i ostatní aplikace kalendáře, takže se kalendář skryje i v nich. Nic se nemaže, další zařízení to neovlivní a kalendář můžete kdykoli znovu zapnout.</string>
|
||||||
|
<string name="calendars_visibility_a11y">Zobrazit „%1$s“</string>
|
||||||
|
<string name="calendars_visibility_notice_title">Některé kalendáře jsou vypnuté</string>
|
||||||
|
<string name="calendars_visibility_notice_message">Calendula zobrazuje kalendáře, které jsou pro toto zařízení zapnuté; některé z vašich kalendářů jsou však vypnuté. Chcete-li zobrazit jejich události, zapněte je v nabídce Nastavení → Kalendáře.</string>
|
||||||
|
<string name="calendar_picker_missing_title">Chybí vám kalendář?</string>
|
||||||
|
<string name="calendar_picker_missing_summary">Může být vypnutý, nastavený na režim „pouze pro čtení“ nebo naplněný daty z vašich kontaktů – své kalendáře spravujte zde.</string>
|
||||||
|
<string name="calendars_state_read_only">Pouze pro čtení</string>
|
||||||
|
<string name="calendars_state_not_synced">Není synchronizováno s tímto zařízením</string>
|
||||||
|
<string name="calendars_state_managed">Vyplněno z vašich kontaktů</string>
|
||||||
|
<string name="calendars_managed_delete_locked">Tento kalendář se plní z vašich kontaktů, takže jej aplikace Calendula při příští synchronizaci znovu vytvoří. Chcete-li jej odstranit, vypněte možnost speciálních dat v nabídce Nastavení → Speciální data.</string>
|
||||||
|
<string name="calendars_synced_header">Synchronizované kalendáře</string>
|
||||||
|
<string name="calendars_synced_hint">Tyto kontakty pocházejí z účtů ve vašem zařízení. Vytvářejte je a upravujte v příslušné aplikaci.</string>
|
||||||
|
<string name="calendars_manage_in_app">Spravovat v aplikaci</string>
|
||||||
|
<string name="calendars_account_from_source">%1$s (%2$s)</string>
|
||||||
|
<string name="calendars_account_menu_a11y">Více možností pro %1$s</string>
|
||||||
|
<string name="calendars_enable_all">Povolit vše</string>
|
||||||
|
<string name="calendars_disable_all">Zakázat vše</string>
|
||||||
|
<string name="calendars_add_account">Přidat účet</string>
|
||||||
|
<string name="calendars_new_title">Nový kalendář</string>
|
||||||
|
<string name="calendars_edit_title">Upravit kalendář</string>
|
||||||
|
<string name="calendars_name_label">Jméno</string>
|
||||||
|
<string name="calendars_color_label">Barva</string>
|
||||||
|
<string name="calendars_description_hint">Přidat popis</string>
|
||||||
|
<string name="calendars_delete_confirm_title">Vymazat kalendář?</string>
|
||||||
|
<string name="calendars_delete_confirm_message">„%1$s“ a všechny jeho události budou z tohoto zařízení trvale odstraněny.</string>
|
||||||
|
<string name="calendars_write_error">Změnu se nepodařilo uložit.</string>
|
||||||
|
<string name="calendars_backup_header">Záloha</string>
|
||||||
|
<string name="calendars_backup_hint">Místní kalendáře nejsou nikde synchronizovány, proto je pro uchování kopie exportujte do souboru ve formátu .ics.</string>
|
||||||
|
<string name="calendars_backup_action">Exportovat jako soubor .ics</string>
|
||||||
|
<string name="calendars_export_title">Exportovat kalendáře</string>
|
||||||
|
<string name="calendars_export_hint">Vyberte, které kalendáře chcete zahrnout do souboru .ics.</string>
|
||||||
|
<string name="calendars_export_action">Exportovat</string>
|
||||||
|
<string name="calendars_restore_header">Obnovit</string>
|
||||||
|
<string name="calendars_restore_action">Obnovit ze souboru .ics</string>
|
||||||
|
<string name="calendars_restore_hint">Importuovat události ze zálohy nebo z jiné aplikace kalendáře.</string>
|
||||||
|
<string name="calendars_auto_backup">Automatické zálohování</string>
|
||||||
|
<string name="calendars_auto_backup_hint">Pravidelně exportujte své místní kalendáře do složky jako soubor .ics.</string>
|
||||||
|
<string name="calendars_auto_backup_folder">Složka pro zálohování</string>
|
||||||
|
<string name="calendars_auto_backup_folder_unset">Klepnutím vyberte složku</string>
|
||||||
|
<string name="calendars_auto_backup_interval">Interval</string>
|
||||||
|
<string name="calendars_auto_backup_every">Každý %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_interval_min">Minimálně 30 minut.</string>
|
||||||
|
<string name="calendars_auto_backup_status_never">Zatím není nastaveno žádné automatické zálohování</string>
|
||||||
|
<string name="calendars_auto_backup_status_ok">Poslední záloha: %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_status_failed">Poslední zálohování selhalo: %1$s</string>
|
||||||
|
<string name="backup_channel_name">Záloha</string>
|
||||||
|
<string name="backup_channel_description">Upozorní, pokud automatické zálohování opakovaně selže.</string>
|
||||||
|
<string name="backup_failed_title">Automatické zálohování selhalo</string>
|
||||||
|
<string name="backup_failed_text">Calendula nemohla zapsat záložní soubor. Zkontrolujte složku pro zálohování v nastavení.</string>
|
||||||
|
<string name="calendars_backup_failed">Zálohu se nepodařilo exportovat.</string>
|
||||||
|
<plurals name="calendars_backup_done">
|
||||||
|
<item quantity="one">%d událost exportována.</item>
|
||||||
|
<item quantity="few">%d události exportovány.</item>
|
||||||
|
<item quantity="many">%d událostí exportováno.</item>
|
||||||
|
<item quantity="other">%d událostí exportováno.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="import_title">Importovat události</string>
|
||||||
|
<string name="import_target_header">Přidat do kalendáře</string>
|
||||||
|
<string name="import_empty">V souboru nenalezeny žádné události.</string>
|
||||||
|
<string name="import_failed">Tento soubor se nepodařilo přečíst.</string>
|
||||||
|
<string name="import_no_calendar">Neexistuje žádný kalendář s právem zápisu, do kterého by bylo možné importovat. Nejprve vytvořte místní kalendář.</string>
|
||||||
|
<string name="import_done_title">Import dokončen</string>
|
||||||
|
<string name="import_done_dedup_note">Události, které již byly v kalendáři, byly přeskočeny.</string>
|
||||||
|
<string name="import_done_added_label">Přidáno</string>
|
||||||
|
<string name="import_done_skipped_label">Duplicity</string>
|
||||||
|
<string name="import_done_failed_label">Nepřidáno</string>
|
||||||
|
<string name="import_done_failed_note">Některé události se nepodařilo přidat, a proto byly vynechány.</string>
|
||||||
|
<string name="import_done_stopped_title">Import byl zastaven</string>
|
||||||
|
<string name="import_close">Zavřít</string>
|
||||||
|
<string name="import_warning_recurrence">Některé změněné výskyty opakujících se událostí byly přeskočeny.</string>
|
||||||
|
<string name="import_warning_no_start">Událost bez času zahájení byla přeskočena.</string>
|
||||||
|
<string name="import_warning_attendees">Seznamy hostů nebyly importovány.</string>
|
||||||
|
<string name="import_warning_timezone">Neznámé časové pásmo se přepnulo na časové pásmo vašeho zařízení.</string>
|
||||||
|
<string name="import_warning_tasks">Úkoly v tomto souboru byly přidány jako události.</string>
|
||||||
|
<string name="import_warning_recurrence_repaired">Chybné pravidlo pro opakování bylo opraveno.</string>
|
||||||
|
<string name="import_button">Importovat</string>
|
||||||
|
<plurals name="import_title_count">
|
||||||
|
<item quantity="one">Importována %d událost</item>
|
||||||
|
<item quantity="few">Importovány %d události</item>
|
||||||
|
<item quantity="many">Importováno %d událostí</item>
|
||||||
|
<item quantity="other">Importováno %d událostí</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_event_count">
|
||||||
|
<item quantity="one">V tomto souboru je %d událost.</item>
|
||||||
|
<item quantity="few">V tomto souboru jsou %d události.</item>
|
||||||
|
<item quantity="many">V tomto souboru je %d událostí.</item>
|
||||||
|
<item quantity="other">V tomto souboru je %d událostí.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_action">
|
||||||
|
<item quantity="one">Importovat %d událost</item>
|
||||||
|
<item quantity="few">Importovat %d události</item>
|
||||||
|
<item quantity="many">Importovat %d událostí</item>
|
||||||
|
<item quantity="other">Importovat %d událostí</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_stopped">
|
||||||
|
<item quantity="one">Kalendář přestal v průběhu procesu zpracovávat události. %d událost ze souboru nebyla přidána.</item>
|
||||||
|
<item quantity="few">Kalendář přestal v průběhu procesu zpracovávat události. %d události ze souboru nebyly přidány.</item>
|
||||||
|
<item quantity="many">Kalendář přestal v průběhu procesu zpracovávat události. %d událostí ze souboru nebylo přidáno.</item>
|
||||||
|
<item quantity="other">Kalendář přestal v průběhu procesu zpracovávat události. %d událostí ze souboru nebylo přidáno.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_imported">
|
||||||
|
<item quantity="one">%d událost importována.</item>
|
||||||
|
<item quantity="few">%d události importovány.</item>
|
||||||
|
<item quantity="many">%d událostí importováno.</item>
|
||||||
|
<item quantity="other">%d událostí importováno.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_skipped">
|
||||||
|
<item quantity="one">Přeskočena %d událost, která již v tomto kalendáři existuje.</item>
|
||||||
|
<item quantity="few">Přeskočeny %d události, která již v tomto kalendáři existují.</item>
|
||||||
|
<item quantity="many">Přeskočeno %d událostí, která již v tomto kalendáři existují.</item>
|
||||||
|
<item quantity="other">Přeskočeno %d událostí, která již v tomto kalendáři existují.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_failed">
|
||||||
|
<item quantity="one">%d událost nebylo možné přidat.</item>
|
||||||
|
<item quantity="few">%d události nebylo možné přidat.</item>
|
||||||
|
<item quantity="many">%d událostí nebylo možné přidat.</item>
|
||||||
|
<item quantity="other">%d událostí nebylo možné přidat.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="shortcut_new_event_short">Nová událost</string>
|
||||||
|
<string name="shortcut_new_event_long">Vytvořit novou událost</string>
|
||||||
|
<string name="qs_tile_new_event_label">Nová událost</string>
|
||||||
|
<string name="settings_qs_tile">Přidat dlaždici rychlého nastavení</string>
|
||||||
|
<string name="settings_qs_tile_hint">Přidat dlaždici „Nová událost“ do panelu rychlého nastavení.</string>
|
||||||
|
<string name="crash_dialog_title">%1$s selhalo</string>
|
||||||
|
<string name="crash_dialog_message">%1$s se minule neočekávaně ukončilo. Můžete pomoci problém vyřešit odesláním tohoto hlášení. Hlášení zůstane ve vašem zařízení, dokud se jej nerozhodnete odeslat, a neobsahuje žádné osobní údaje ani obsah kalendáře – pouze níže uvedené technické detaily.</string>
|
||||||
|
<string name="crash_dialog_report">Odeslat hlášení o selhání</string>
|
||||||
|
<string name="crash_dialog_dismiss">Později</string>
|
||||||
|
<string name="crash_report_clip_label">Hlášení o selhání %1$s</string>
|
||||||
|
<string name="crash_report_copied">Hlášení bylo zkopírováno do schránky</string>
|
||||||
|
<string name="crash_report_open_failed">Nepodařilo se otevřít systém pro hlášení problémů. Zpráva je zkopírována ve vaší schránce.</string>
|
||||||
|
<string name="special_dates_calendar_birthday">Narozeniny</string>
|
||||||
|
<string name="special_dates_calendar_anniversary">Výročí</string>
|
||||||
|
<string name="special_dates_calendar_custom">Speciální data</string>
|
||||||
|
<string name="special_dates_default_title_birthday">Narozeniny má {name} ({year})</string>
|
||||||
|
<string name="special_dates_default_title_anniversary">Výročí má {name} ({year})</string>
|
||||||
|
<string name="special_dates_default_title_custom">{name}</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -263,4 +263,44 @@
|
|||||||
<string name="onboarding_done_button">Otvoriť môj kalendár</string>
|
<string name="onboarding_done_button">Otvoriť môj kalendár</string>
|
||||||
<string name="reminder_channel_name">Pripomienky udalostí</string>
|
<string name="reminder_channel_name">Pripomienky udalostí</string>
|
||||||
<string name="reminder_channel_description">Notifikácie v časoch pripomienok vašich udalostí</string>
|
<string name="reminder_channel_description">Notifikácie v časoch pripomienok vašich udalostí</string>
|
||||||
|
<string name="agenda_today_action">Dnes</string>
|
||||||
|
<string name="agenda_header_today">Dnes</string>
|
||||||
|
<string name="search_back">Späť</string>
|
||||||
|
<string name="widget_new_event">Nová udalosť</string>
|
||||||
|
<string name="widget_prev_month">Predchádzajúci mesiac</string>
|
||||||
|
<string name="widget_next_month">Nasledujúci mesiac</string>
|
||||||
|
<string name="widget_today">Dnes</string>
|
||||||
|
<string name="filter_title">Kalendáre</string>
|
||||||
|
<string name="settings_title">Nastavenia</string>
|
||||||
|
<string name="settings_back">Späť</string>
|
||||||
|
<string name="back">Späť</string>
|
||||||
|
<string name="timeline_scale_custom">Vlastné</string>
|
||||||
|
<string name="agenda_range_day">Dnes</string>
|
||||||
|
<string name="state_failure_all_hidden">Všetky vaše kalendáre sú vypnuté.</string>
|
||||||
|
<string name="state_failure_all_hidden_action">Správa kalendárov</string>
|
||||||
|
<string name="field_copy_action">Kopírovať</string>
|
||||||
|
<string name="field_copied">Skopirovane do schránky</string>
|
||||||
|
<string name="field_copy_failed">Toto sa nepodarilo skopírovať</string>
|
||||||
|
<string name="event_detail_title">Názov</string>
|
||||||
|
<string name="reminder_after">%1$s po</string>
|
||||||
|
<string name="reminder_onboarding_title">Nikdy nezmeškajte udalosť</string>
|
||||||
|
<string name="reminder_onboarding_body">Samotný Android nezobrazuje pripomienky udalostí - to musí zabezpečiť aplikácia kalendáru. Nechajte túto prácu na Calendule.</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">Pripomienky, doručené</string>
|
||||||
|
<string name="reminder_benefit_delivery_body">Pripomienky k vašim udalostiam vám prídu ako notifikácia, vždy a načas.</string>
|
||||||
|
<string name="reminder_benefit_duplicates_title">Používate ďalšiu aplikáciu s kalendárom?</string>
|
||||||
|
<string name="reminder_onboarding_skip_button">Neskôr</string>
|
||||||
|
<plurals name="search_delete_title">
|
||||||
|
<item quantity="one">Odstrániť %d udalosť?</item>
|
||||||
|
<item quantity="few">Odstrániť %d udalosti?</item>
|
||||||
|
<item quantity="many">Odstrániť %d udalostí?</item>
|
||||||
|
<item quantity="other">Odstrániť %d udalostí?</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="search_delete_done">
|
||||||
|
<item quantity="one">%d udalosť odstránená</item>
|
||||||
|
<item quantity="few">%d udalosti odstránené</item>
|
||||||
|
<item quantity="many">%d odstránených udalostí</item>
|
||||||
|
<item quantity="other">%d odstránených udalostí</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="agenda_range_this_week">Tento týždeň</string>
|
||||||
|
<string name="agenda_range_custom">Vlastné…</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -11,12 +11,20 @@
|
|||||||
<string name="state_retry">Retry</string>
|
<string name="state_retry">Retry</string>
|
||||||
<string name="state_failure_unknown">Something went wrong.</string>
|
<string name="state_failure_unknown">Something went wrong.</string>
|
||||||
<string name="state_failure_permission">Calendar access is required.</string>
|
<string name="state_failure_permission">Calendar access is required.</string>
|
||||||
|
<string name="state_failure_permission_body">Calendula reads your events straight from the system calendar, so it needs calendar access to show anything.</string>
|
||||||
<string name="state_failure_permission_action">Grant access</string>
|
<string name="state_failure_permission_action">Grant access</string>
|
||||||
<string name="state_failure_no_calendars">No calendars configured.</string>
|
<string name="state_failure_no_calendars">No calendars configured.</string>
|
||||||
|
<string name="state_failure_no_calendars_body">Add a calendar account to this device and its calendars show up here.</string>
|
||||||
<string name="state_failure_no_calendars_action">Open system calendar settings</string>
|
<string name="state_failure_no_calendars_action">Open system calendar settings</string>
|
||||||
<string name="state_failure_all_hidden">All your calendars are switched off.</string>
|
<string name="state_failure_all_hidden">All your calendars are switched off.</string>
|
||||||
|
<string name="state_failure_all_hidden_body">Switch at least one calendar back on to see its events.</string>
|
||||||
<string name="state_failure_all_hidden_action">Manage calendars</string>
|
<string name="state_failure_all_hidden_action">Manage calendars</string>
|
||||||
|
<string name="state_failure_no_import_target">No calendar can take new events.</string>
|
||||||
|
<string name="state_failure_no_import_target_body">The calendars that are switched on are read-only, managed by another app, or not synced to this device. Switch on a calendar that can take events, or add a local one.</string>
|
||||||
|
<string name="state_failure_event_not_found">This event is no longer there.</string>
|
||||||
|
<string name="state_failure_event_not_found_body">It may have been deleted, or moved to a calendar that is switched off.</string>
|
||||||
<string name="state_failure_provider">Could not read the calendar.</string>
|
<string name="state_failure_provider">Could not read the calendar.</string>
|
||||||
|
<string name="state_failure_provider_body">The system calendar did not answer. Try again in a moment.</string>
|
||||||
|
|
||||||
<!-- Long-press a field to copy it (#195) -->
|
<!-- Long-press a field to copy it (#195) -->
|
||||||
<string name="field_copy_action">Copy</string>
|
<string name="field_copy_action">Copy</string>
|
||||||
@@ -679,6 +687,7 @@
|
|||||||
<string name="calendars_restore_header">Restore</string>
|
<string name="calendars_restore_header">Restore</string>
|
||||||
<string name="calendars_restore_action">Restore from .ics file</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_restore_hint">Import events from a backup or another calendar app.</string>
|
||||||
|
<string name="calendars_restore_unavailable">No calendar can take the imported events yet.</string>
|
||||||
<string name="calendars_auto_backup">Automatic backup</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_hint">Periodically export your local calendars to a folder as an .ics file.</string>
|
||||||
<string name="calendars_auto_backup_folder">Backup folder</string>
|
<string name="calendars_auto_backup_folder">Backup folder</string>
|
||||||
|
|||||||
@@ -36,12 +36,13 @@ class ModelsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `FailureReason enum has all six variants`() {
|
fun `FailureReason enum has all seven variants`() {
|
||||||
assertThat(FailureReason.values().toSet()).isEqualTo(
|
assertThat(FailureReason.values().toSet()).isEqualTo(
|
||||||
setOf(
|
setOf(
|
||||||
FailureReason.PermissionRevoked,
|
FailureReason.PermissionRevoked,
|
||||||
FailureReason.NoCalendarsConfigured,
|
FailureReason.NoCalendarsConfigured,
|
||||||
FailureReason.AllCalendarsHidden,
|
FailureReason.AllCalendarsHidden,
|
||||||
|
FailureReason.NoImportTarget,
|
||||||
FailureReason.ProviderUnavailable,
|
FailureReason.ProviderUnavailable,
|
||||||
FailureReason.EventNotFound,
|
FailureReason.EventNotFound,
|
||||||
FailureReason.Unknown,
|
FailureReason.Unknown,
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reading a Google Calendar export (Codeberg #304), whose dialect the parser had
|
||||||
|
* never been held to: `VTIMEZONE` blocks with `X-LIC-LOCATION`, `TZID`-qualified
|
||||||
|
* `DTSTART`/`EXDATE`, folded `DESCRIPTION` and `ATTENDEE` lines, `RECURRENCE-ID`
|
||||||
|
* overrides written as separate `VEVENT`s, and `@google.com` UIDs.
|
||||||
|
*
|
||||||
|
* The fixture is a trimmed copy of what "Settings → Import & export → Export"
|
||||||
|
* produces, CRLF and all. Note that Google hands that export out as a **zip**
|
||||||
|
* containing one `.ics` per calendar — the file here is one member of it.
|
||||||
|
*/
|
||||||
|
class IcsGoogleImportTest {
|
||||||
|
|
||||||
|
private val parser = IcsParser(TimeZone.of("Europe/Berlin"))
|
||||||
|
|
||||||
|
private val result: IcsParseResult = parser.parse(
|
||||||
|
checkNotNull(
|
||||||
|
javaClass.classLoader?.getResourceAsStream("ics/google-calendar-export.ics"),
|
||||||
|
).use { it.readBytes().toString(Charsets.UTF_8) },
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun event(summary: String) = result.events.single { it.summary == summary }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every master event imports and the VTIMEZONE block is not mistaken for one`() {
|
||||||
|
assertThat(result.events.map { it.summary })
|
||||||
|
.containsExactly("Team standup", "Day off", "Christmas break")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `X-WR-CALNAME names the calendar for all of them`() {
|
||||||
|
assertThat(result.events.map { it.calendarName }.distinct())
|
||||||
|
.containsExactly("jane@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a TZID-qualified start resolves against the device tz database`() {
|
||||||
|
val standup = event("Team standup")
|
||||||
|
assertThat(standup.isAllDay).isFalse()
|
||||||
|
assertThat(standup.zoneId).isEqualTo("Europe/Berlin")
|
||||||
|
// 10:00 CEST is 08:00 UTC.
|
||||||
|
assertThat(standup.start.toString()).isEqualTo("2026-09-15T08:00:00Z")
|
||||||
|
assertThat((standup.end - standup.start).inWholeMinutes).isEqualTo(90)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the recurrence rule and its TZID-qualified EXDATE survive`() {
|
||||||
|
val standup = event("Team standup")
|
||||||
|
assertThat(standup.recurrenceRule).isEqualTo("FREQ=WEEKLY;BYDAY=TU")
|
||||||
|
assertThat(standup.exDates).containsExactly("20260929T080000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the moved occurrence is skipped rather than imported as a duplicate`() {
|
||||||
|
// Google writes a RECURRENCE-ID override as its own VEVENT carrying the
|
||||||
|
// master's UID. Importing it would put a second "standup" in the
|
||||||
|
// calendar; Calendula models no overrides, so it is reported instead.
|
||||||
|
assertThat(result.events.map { it.summary }).doesNotContain("Team standup (moved)")
|
||||||
|
assertThat(result.warnings).contains(IcsParseWarning.ModifiedOccurrenceSkipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a folded DESCRIPTION is unfolded and unescaped`() {
|
||||||
|
assertThat(event("Team standup").description).isEqualTo(
|
||||||
|
"Weekly sync with the team.\nAgenda lives in the shared doc, see the link below.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an escaped comma in LOCATION comes back as a comma`() {
|
||||||
|
assertThat(event("Team standup").location).isEqualTo("Meeting room 2, 3rd floor")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `attendees are reported rather than silently dropped`() {
|
||||||
|
assertThat(result.warnings).contains(IcsParseWarning.AttendeesIgnored)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a timed VALARM becomes its lead time in minutes`() {
|
||||||
|
assertThat(event("Team standup").semanticReminderMinutes()).containsExactly(30)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day event keeps the single day the file gives it`() {
|
||||||
|
val dayOff = event("Day off")
|
||||||
|
assertThat(dayOff.isAllDay).isTrue()
|
||||||
|
assertThat((dayOff.end - dayOff.start).inWholeDays).isEqualTo(1)
|
||||||
|
assertThat(dayOff.availability).isEqualTo(Availability.Free)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day alarm comes back as whole days before`() {
|
||||||
|
// Google writes an all-day reminder as a whole-day offset from the
|
||||||
|
// event's UTC midnight; a day has to survive as a day.
|
||||||
|
assertThat(event("Day off").semanticReminderMinutes()).containsExactly(1440)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a multi-day all-day event keeps its exclusive DTEND span`() {
|
||||||
|
val christmas = event("Christmas break")
|
||||||
|
assertThat(christmas.isAllDay).isTrue()
|
||||||
|
assertThat((christmas.end - christmas.start).inWholeDays).isEqualTo(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no recurrence rule is repaired - Google writes them well-formed`() {
|
||||||
|
assertThat(result.warnings).doesNotContain(IcsParseWarning.RecurrenceRuleRepaired)
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component whose `END:` line never arrives (a download cut short, a producer
|
||||||
|
* that dropped a line). `indexOfEnd` falls back to the end of the file, so the
|
||||||
|
* block is read as if it had been closed.
|
||||||
|
*/
|
||||||
|
class IcsUnterminatedComponentTest {
|
||||||
|
|
||||||
|
private val parser = IcsParser(TimeZone.of("Europe/Berlin"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `file truncated mid-property keeps the event it had started`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
"""
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:truncated-1@fixture
|
||||||
|
SUMMARY:Cut off mid-file
|
||||||
|
DTSTART:20260924T100000Z
|
||||||
|
DTEND:20260924T1
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
assertEquals(1, result.events.size)
|
||||||
|
val event = result.events.single()
|
||||||
|
assertEquals("Cut off mid-file", event.summary)
|
||||||
|
// The truncated DTEND does not parse, so the event is zero length.
|
||||||
|
assertEquals(event.start, event.end)
|
||||||
|
assertEquals(emptySet<IcsParseWarning>(), result.warnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a missing END between two events does not merge them`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
"""
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:first@fixture
|
||||||
|
SUMMARY:First event
|
||||||
|
DTSTART:20260924T100000Z
|
||||||
|
DTEND:20260924T110000Z
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:second@fixture
|
||||||
|
SUMMARY:Second event
|
||||||
|
DTSTART:20260925T100000Z
|
||||||
|
DTEND:20260925T110000Z
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf("First event", "Second event"),
|
||||||
|
result.events.map { it.summary },
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf("first@fixture", "second@fixture"),
|
||||||
|
result.events.map { it.uid },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.calendars
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/** What the Backup & restore screen offers for a given calendar list (#304). */
|
||||||
|
class BackupUiStateTest {
|
||||||
|
|
||||||
|
private fun cal(
|
||||||
|
id: Long,
|
||||||
|
local: Boolean = false,
|
||||||
|
writable: Boolean = true,
|
||||||
|
visible: Boolean = true,
|
||||||
|
managed: Boolean = false,
|
||||||
|
syncs: Boolean = true,
|
||||||
|
) = CalendarSource(
|
||||||
|
id = id,
|
||||||
|
displayName = "Calendar $id",
|
||||||
|
accountName = "acc@example.com",
|
||||||
|
accountType = if (local) "LOCAL" else "com.google",
|
||||||
|
color = 0,
|
||||||
|
isVisibleInSystem = visible,
|
||||||
|
canModifyContents = writable,
|
||||||
|
isLocal = local,
|
||||||
|
isManaged = managed,
|
||||||
|
syncsEvents = syncs,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun failure(state: BackupUiState) = (state as BackupUiState.Failure).reason
|
||||||
|
|
||||||
|
private fun ready(state: BackupUiState) = state as BackupUiState.Ready
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no calendars at all reports the empty device`() {
|
||||||
|
assertThat(failure(backupUiState(emptyList())))
|
||||||
|
.isEqualTo(FailureReason.NoCalendarsConfigured)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a synced writable calendar can receive a restore`() {
|
||||||
|
val state = ready(backupUiState(listOf(cal(1L))))
|
||||||
|
assertThat(state.canImport).isTrue()
|
||||||
|
assertThat(state.exportable).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the reported case - only a calendar that cannot take events`() {
|
||||||
|
// #304: a Google account with calendar sync switched off leaves nothing
|
||||||
|
// exportable and nothing importable, which used to render a blank screen.
|
||||||
|
assertThat(failure(backupUiState(listOf(cal(1L, syncs = false)))))
|
||||||
|
.isEqualTo(FailureReason.NoImportTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a read-only subscription is no import target either`() {
|
||||||
|
assertThat(failure(backupUiState(listOf(cal(1L, writable = false)))))
|
||||||
|
.isEqualTo(FailureReason.NoImportTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `everything switched off is reported as hidden, not as a missing target`() {
|
||||||
|
// The remedy differs: a visibility switch fixes this one.
|
||||||
|
assertThat(failure(backupUiState(listOf(cal(1L, visible = false)))))
|
||||||
|
.isEqualTo(FailureReason.AllCalendarsHidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a hidden local calendar still exports`() {
|
||||||
|
// Export reads the provider directly, so system visibility is irrelevant
|
||||||
|
// to it — calling this a failure would hide a working action.
|
||||||
|
val state = ready(backupUiState(listOf(cal(1L, local = true, visible = false))))
|
||||||
|
assertThat(state.exportable).hasSize(1)
|
||||||
|
assertThat(state.canImport).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a managed special-dates mirror is neither exportable nor a target`() {
|
||||||
|
assertThat(failure(backupUiState(listOf(cal(1L, local = true, managed = true)))))
|
||||||
|
.isEqualTo(FailureReason.NoImportTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a local calendar alongside a synced one offers both halves`() {
|
||||||
|
val state = ready(backupUiState(listOf(cal(1L, local = true), cal(2L))))
|
||||||
|
assertThat(state.exportable.map { it.id }).containsExactly(1L)
|
||||||
|
assertThat(state.canImport).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
@@ -75,17 +76,16 @@ class TimelineZoomTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a pinch held against a fractional bound stays put`() {
|
fun `a pinch held against either bound stays put`() {
|
||||||
// A bound that is not a whole pixel is a height the pinch can be pushed
|
|
||||||
// against but never land on, so every frame of a held gesture would look
|
|
||||||
// like a scale change and hand the focal anchor a scroll correction.
|
|
||||||
val fillPx = 62.083f
|
val fillPx = 62.083f
|
||||||
val maxPx = 616.5f
|
val maxPx = 616.5f
|
||||||
|
|
||||||
val floor = pinchedHourHeightPx(target = 1f, fillPx, maxPx)
|
val floor = pinchedHourHeightPx(target = 1f, fillPx, maxPx)
|
||||||
val ceiling = pinchedHourHeightPx(target = 9_000f, fillPx, maxPx)
|
val ceiling = pinchedHourHeightPx(target = 9_000f, fillPx, maxPx)
|
||||||
|
|
||||||
assertThat(floor).isEqualTo(63f)
|
// The ceiling is pulled onto the pixel grid so it stays landable; the
|
||||||
|
// fill floor is landable as it is, being the clamp result itself.
|
||||||
|
assertThat(floor).isEqualTo(fillPx)
|
||||||
assertThat(ceiling).isEqualTo(616f)
|
assertThat(ceiling).isEqualTo(616f)
|
||||||
// Landing there and being pushed further must not move them again.
|
// Landing there and being pushed further must not move them again.
|
||||||
assertThat(pinchedHourHeightPx(floor * 0.9f, fillPx, maxPx)).isEqualTo(floor)
|
assertThat(pinchedHourHeightPx(floor * 0.9f, fillPx, maxPx)).isEqualTo(floor)
|
||||||
@@ -100,6 +100,35 @@ class TimelineZoomTest {
|
|||||||
assertThat(floor * 24).isAtLeast(viewport)
|
assertThat(floor * 24).isAtLeast(viewport)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pinching all the way out leaves nothing to scroll`() {
|
||||||
|
// #290: rounding the fill floor up to a whole pixel made the day one
|
||||||
|
// pixel per hour taller than the viewport it was supposed to fill, so a
|
||||||
|
// fully zoomed-out timeline still scrolled a hair and bounced off
|
||||||
|
// Android's overscroll stretch -- while FitDay, at the same zoom, sat
|
||||||
|
// still. 1490 is deliberately not divisible by 24.
|
||||||
|
val viewport = 1490f
|
||||||
|
val floor = pinchedHourHeightPx(target = 1f, fillPx = viewport / 24f, maxPx = 616f)
|
||||||
|
assertThat(floor * 24).isWithin(0.01f).of(viewport)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the pinch floor is the height FitDay resolves to`() {
|
||||||
|
// The inconsistency the issue is about: the two ways to reach "the whole
|
||||||
|
// day on one screen" have to arrive at the same height.
|
||||||
|
val density = Density(2.5f)
|
||||||
|
val viewport = 596.dp
|
||||||
|
with(density) {
|
||||||
|
val fitDay = TimelineScale.FitDay.hourHeight(viewport).toPx()
|
||||||
|
val pinched = pinchedHourHeightPx(
|
||||||
|
target = 1f,
|
||||||
|
fillPx = fillHourHeight(viewport).toPx(),
|
||||||
|
maxPx = MAX_PINCH_HOUR_HEIGHT.toPx(),
|
||||||
|
)
|
||||||
|
assertThat(pinched).isWithin(0.01f).of(fitDay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a settled pinch is what gets persisted`() {
|
fun `a settled pinch is what gets persisted`() {
|
||||||
var persisted: TimelineScale? = null
|
var persisted: TimelineScale? = null
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
PRODID:-//Google Inc//Google Calendar 70.9054//EN
|
||||||
|
VERSION:2.0
|
||||||
|
CALSCALE:GREGORIAN
|
||||||
|
METHOD:PUBLISH
|
||||||
|
X-WR-CALNAME:jane@example.com
|
||||||
|
X-WR-TIMEZONE:Europe/Berlin
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:Europe/Berlin
|
||||||
|
X-LIC-LOCATION:Europe/Berlin
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
TZOFFSETFROM:+0100
|
||||||
|
TZOFFSETTO:+0200
|
||||||
|
TZNAME:CEST
|
||||||
|
DTSTART:19700329T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:STANDARD
|
||||||
|
TZOFFSETFROM:+0200
|
||||||
|
TZOFFSETTO:+0100
|
||||||
|
TZNAME:CET
|
||||||
|
DTSTART:19701025T030000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
|
||||||
|
END:STANDARD
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTART;TZID=Europe/Berlin:20260915T100000
|
||||||
|
DTEND;TZID=Europe/Berlin:20260915T113000
|
||||||
|
RRULE:FREQ=WEEKLY;BYDAY=TU
|
||||||
|
EXDATE;TZID=Europe/Berlin:20260929T100000
|
||||||
|
DTSTAMP:20260912T183000Z
|
||||||
|
UID:0abcdef1234567890abcdef12345@google.com
|
||||||
|
CREATED:20260901T120000Z
|
||||||
|
DESCRIPTION:Weekly sync with the team.\nAgenda lives in the shared doc\, see
|
||||||
|
the link below.
|
||||||
|
LAST-MODIFIED:20260902T081500Z
|
||||||
|
LOCATION:Meeting room 2\, 3rd floor
|
||||||
|
SEQUENCE:0
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
SUMMARY:Team standup
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
ORGANIZER;CN=Jane Doe:mailto:jane@example.com
|
||||||
|
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=John:m
|
||||||
|
ailto:john@example.com
|
||||||
|
BEGIN:VALARM
|
||||||
|
ACTION:DISPLAY
|
||||||
|
DESCRIPTION:This is an event reminder
|
||||||
|
TRIGGER:-PT30M
|
||||||
|
END:VALARM
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTART;TZID=Europe/Berlin:20260929T140000
|
||||||
|
DTEND;TZID=Europe/Berlin:20260929T153000
|
||||||
|
DTSTAMP:20260912T183000Z
|
||||||
|
UID:0abcdef1234567890abcdef12345@google.com
|
||||||
|
RECURRENCE-ID;TZID=Europe/Berlin:20260929T100000
|
||||||
|
CREATED:20260901T120000Z
|
||||||
|
LAST-MODIFIED:20260910T091500Z
|
||||||
|
SEQUENCE:1
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
SUMMARY:Team standup (moved)
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTART;VALUE=DATE:20261024
|
||||||
|
DTEND;VALUE=DATE:20261025
|
||||||
|
DTSTAMP:20260912T183000Z
|
||||||
|
UID:1bcdef01234567890abcdef23456@google.com
|
||||||
|
CREATED:20260820T101500Z
|
||||||
|
LAST-MODIFIED:20260820T101500Z
|
||||||
|
SEQUENCE:0
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
SUMMARY:Day off
|
||||||
|
TRANSP:TRANSPARENT
|
||||||
|
BEGIN:VALARM
|
||||||
|
ACTION:DISPLAY
|
||||||
|
DESCRIPTION:This is an event reminder
|
||||||
|
TRIGGER:-P1D
|
||||||
|
END:VALARM
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTART;VALUE=DATE:20261224
|
||||||
|
DTEND;VALUE=DATE:20261227
|
||||||
|
DTSTAMP:20260912T183000Z
|
||||||
|
UID:2cdef012345678901abcdef34567@google.com
|
||||||
|
SEQUENCE:0
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
SUMMARY:Christmas break
|
||||||
|
TRANSP:TRANSPARENT
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: Privacy Policy — Calendula
|
title: Privacy Policy — Calendula
|
||||||
description: What Calendula does with your data. It collects nothing, sends nothing, and has no internet permission at all.
|
description: What Calendula does with your data. It collects nothing, sends nothing, and has no internet permission at all.
|
||||||
updated: 2026-07-28
|
updated: 2026-09-15
|
||||||
---
|
---
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -33,7 +33,7 @@ Calendula collects nothing, sends nothing, and has no user accounts. It has **no
|
|||||||
IT-Dienstleister | Jean-Luc Makiola
|
IT-Dienstleister | Jean-Luc Makiola
|
||||||
Mahlerstraße 10
|
Mahlerstraße 10
|
||||||
14772 Brandenburg an der Havel
|
14772 Brandenburg an der Havel
|
||||||
Email: [business@jeanlucmakiola.de](mailto:business@jeanlucmakiola.de)
|
Email: [support@jeanlucmakiola.de](mailto:support@jeanlucmakiola.de)
|
||||||
|
|
||||||
## 2. No data collection
|
## 2. No data collection
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Fixed
|
||||||
|
• The app no longer reopens the event you last came in on. Opening an event from a reminder or a widget made it return to that event on every later launch, or show an error screen once the event was gone.
|
||||||
|
• "Today" works in the Agenda again. It now scrolls the list back to today, and stays offered while you are scrolled away from it.
|
||||||
@@ -4,7 +4,7 @@ Name: Calendula
|
|||||||
Summary: A modern Material 3 Expressive calendar for Android.
|
Summary: A modern Material 3 Expressive calendar for Android.
|
||||||
|
|
||||||
Categories:
|
Categories:
|
||||||
- Time
|
- Calendar & Agenda
|
||||||
|
|
||||||
SourceCode: https://codeberg.org/jlmakiola/calendula
|
SourceCode: https://codeberg.org/jlmakiola/calendula
|
||||||
IssueTracker: https://codeberg.org/jlmakiola/calendula/issues
|
IssueTracker: https://codeberg.org/jlmakiola/calendula/issues
|
||||||
|
|||||||
Reference in New Issue
Block a user