Merge branch 'release/v2.17.0' into fix/search-allday-date
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -72,9 +72,14 @@ jobs:
|
|||||||
distribution: 'zulu'
|
distribution: 'zulu'
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
|
||||||
|
# Fully qualified on purpose. Codeberg resolves bare `uses:` refs against
|
||||||
|
# data.forgejo.org, Forgejo's own action mirror — actions/checkout,
|
||||||
|
# setup-java and cache all exist there, but android-actions/setup-android
|
||||||
|
# does not, and the job dies with "repository not found". Gitea's instance
|
||||||
|
# defaults to GitHub, which is why this never surfaced before the split.
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
if: steps.scope.outputs.code == 'true'
|
if: steps.scope.outputs.code == 'true'
|
||||||
uses: android-actions/setup-android@v3
|
uses: https://github.com/android-actions/setup-android@v3
|
||||||
with:
|
with:
|
||||||
# Default ("tools platform-tools") drags in the Android Emulator
|
# Default ("tools platform-tools") drags in the Android Emulator
|
||||||
# (~300 MB) which the build never uses.
|
# (~300 MB) which the build never uses.
|
||||||
@@ -27,6 +27,14 @@ jobs:
|
|||||||
# whether this push actually cuts a new release (no tag for it yet). Keeps the
|
# whether this push actually cuts a new release (no tag for it yet). Keeps the
|
||||||
# heavy job from running on every merge to main.
|
# heavy job from running on every merge to main.
|
||||||
detect:
|
detect:
|
||||||
|
# Gitea only. The workflow directory split already keeps this file invisible
|
||||||
|
# to Codeberg — Forgejo's lookup is first-match-wins, and .forgejo/workflows
|
||||||
|
# exists — but that only holds while .forgejo/ is non-empty. Move the last
|
||||||
|
# file out of it and Codeberg would fall back to .gitea/workflows and start
|
||||||
|
# running the release pipeline on the contributor-facing runner, with no
|
||||||
|
# secrets. repository_owner differs between the two forges regardless of
|
||||||
|
# URL, proxy or instance rename, so this closes it permanently.
|
||||||
|
if: github.repository_owner == 'makiolaj'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
outputs:
|
outputs:
|
||||||
is_release: ${{ steps.v.outputs.is_release }}
|
is_release: ${{ steps.v.outputs.is_release }}
|
||||||
@@ -41,8 +49,16 @@ jobs:
|
|||||||
- name: Resolve version and whether it is a new release
|
- name: Resolve version and whether it is a new release
|
||||||
id: v
|
id: v
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
# Tags are read from Codeberg, which is canonical — deliberately NOT
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
# from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
|
||||||
|
# is a push mirror, i.e. `git push --mirror`, which deletes refs the
|
||||||
|
# source does not have. A tag minted here on Gitea is therefore wiped
|
||||||
|
# by the next sync (Codeberg does not have it yet) and only reappears
|
||||||
|
# once the tag push at the end of this workflow propagates back.
|
||||||
|
# Asking Gitea inside that window would report "no tag" for a release
|
||||||
|
# that already shipped, and cut it a second time.
|
||||||
|
# Public repo, so this read needs no token.
|
||||||
|
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/calendula
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
||||||
@@ -60,15 +76,28 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
# A tag for this version already existing means the release shipped on
|
# A tag for this version already existing means the release shipped on
|
||||||
# an earlier push; do nothing. Absent => this merge cuts the release.
|
# an earlier push; do nothing. Absent => this merge cuts the release.
|
||||||
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
#
|
||||||
-H "Authorization: token $TOKEN" "$API/git/refs/tags/v$VERSION")
|
# Anything other than a clean 200/404 is treated as fatal rather than
|
||||||
if [ "$STATUS" = "200" ]; then
|
# as "no tag". A Codeberg outage or a network blip would otherwise
|
||||||
echo "Tag v$VERSION already exists — nothing to release."
|
# read as absent and re-cut a release that has already shipped —
|
||||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
# republishing to F-Droid and Play. Failing here is recoverable; a
|
||||||
else
|
# duplicate release is not.
|
||||||
echo "No tag for v$VERSION yet — cutting the release."
|
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
|
||||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
case "$STATUS" in
|
||||||
fi
|
200)
|
||||||
|
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
|
||||||
|
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||||
|
;;
|
||||||
|
404)
|
||||||
|
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
|
||||||
|
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
|
||||||
|
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
# Releases: build + sign + publish, then mint the tag and Gitea release.
|
# Releases: build + sign + publish, then mint the tag and Gitea release.
|
||||||
# Also runs on manual dispatch, where it skips the build and just re-signs and
|
# Also runs on manual dispatch, where it skips the build and just re-signs and
|
||||||
|
|||||||
@@ -29,14 +29,30 @@ jobs:
|
|||||||
- name: Run Renovate
|
- name: Run Renovate
|
||||||
run: renovate
|
run: renovate
|
||||||
env:
|
env:
|
||||||
# Self-hosted Gitea, not github.com.
|
# Renovate targets Codeberg (canonical) while still RUNNING on the
|
||||||
RENOVATE_PLATFORM: gitea
|
# Gitea runner. Moving the job to Codeberg would put a repo-write
|
||||||
RENOVATE_ENDPOINT: https://gitea.jeanlucmakiola.de/api/v1
|
# token on the contributor-facing runner, which is exactly what the
|
||||||
# Bot-account token (Gitea secret). Needs repo read/write + PR scope.
|
# .forgejo/ vs .gitea/ split exists to prevent — so the token stays
|
||||||
|
# where the other secrets live and only the API calls cross over.
|
||||||
|
#
|
||||||
|
# Platform is `forgejo`, not `gitea`: Codeberg runs Forgejo, and the
|
||||||
|
# pinned image ships a distinct forgejo platform module.
|
||||||
|
RENOVATE_PLATFORM: forgejo
|
||||||
|
RENOVATE_ENDPOINT: https://codeberg.org/api/v1
|
||||||
|
# Codeberg bot-account token (Gitea secret). Needs repo read/write +
|
||||||
|
# PR scope on jlmakiola/calendula.
|
||||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
# Scope to this repo only — no org-wide autodiscovery.
|
# Scope to this repo only — no org-wide autodiscovery.
|
||||||
RENOVATE_AUTODISCOVER: 'false'
|
RENOVATE_AUTODISCOVER: 'false'
|
||||||
RENOVATE_REPOSITORIES: '["makiolaj/calendula"]'
|
RENOVATE_REPOSITORIES: '["jlmakiola/calendula"]'
|
||||||
# Commits/PRs authored as the bot, not a real maintainer.
|
# Commits/PRs authored as the bot, not a real maintainer. This address
|
||||||
|
# must be a verified email on the Codeberg bot account, otherwise the
|
||||||
|
# commits show up unattributed there.
|
||||||
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@jeanlucmakiola.de>'
|
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@jeanlucmakiola.de>'
|
||||||
|
# Read-only github.com PAT (no scopes needed). Unaffected by the forge
|
||||||
|
# move — nearly every dependency is *released* on GitHub, and without
|
||||||
|
# this,
|
||||||
|
# changelog/release-note lookups hit the 60/h anonymous rate limit
|
||||||
|
# and PRs arrive with an empty "Release Notes" section.
|
||||||
|
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.GITHUB_COM_TOKEN }}
|
||||||
LOG_LEVEL: info
|
LOG_LEVEL: info
|
||||||
|
|||||||
2
.gitmodules
vendored
2
.gitmodules
vendored
@@ -1,3 +1,3 @@
|
|||||||
[submodule "floret-kit"]
|
[submodule "floret-kit"]
|
||||||
path = floret-kit
|
path = floret-kit
|
||||||
url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git
|
url = https://codeberg.org/jlmakiola/floret-kit.git
|
||||||
|
|||||||
31
CHANGELOG.md
31
CHANGELOG.md
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Settings → Calendars now says what is different about a calendar instead of
|
||||||
|
leaving you to guess. Ones you can only view — a subscribed calendar, a
|
||||||
|
calendar shared with you read-only — are marked **Read-only** ([#76]).
|
||||||
|
- Calendars your device isn't syncing are marked **Not synced**, moved to the
|
||||||
|
bottom of their account and left without a switch. None of their events are on
|
||||||
|
the device, so the switch they used to have could not have shown you anything
|
||||||
|
— the calendar simply looked broken. They are no longer offered when you pick
|
||||||
|
a calendar for a new or an imported event either: an event saved there would
|
||||||
|
never reach the account. Whether an account syncs a calendar stays that
|
||||||
|
account's own app's decision ([#78]).
|
||||||
|
- The birthday and anniversary calendars Calendula fills from your contacts are
|
||||||
|
marked **Filled from your contacts**, which is why they can't be picked for a
|
||||||
|
new event: anything you put there would be removed again on the next sync.
|
||||||
|
Deleting one is held back while special dates are switched on — Calendula
|
||||||
|
would simply create it again — and the calendar's editor says so; turn the
|
||||||
|
feature off under Settings → Special dates and the delete works as usual
|
||||||
|
([#76]).
|
||||||
|
- The calendar picker in the event form and in the .ics import screen now ends
|
||||||
|
with a **"Missing a calendar?"** row that opens Settings → Calendars, where
|
||||||
|
those marks then explain why a calendar isn't offered ([#76]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Calendula's source code now lives on **Codeberg**, where its issues already
|
||||||
|
were. The **Source code** and **License** links in Settings → About point
|
||||||
|
there, so reporting a bug and reading the code no longer land on two different
|
||||||
|
sites. Nothing about the app itself changes, and the F-Droid repository is
|
||||||
|
unaffected.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Search results now show an all-day event's real date. West of UTC — anywhere in
|
- Search results now show an all-day event's real date. West of UTC — anywhere in
|
||||||
the Americas, say — a search hit was dated one day early, disagreeing with the
|
the Americas, say — a search hit was dated one day early, disagreeing with the
|
||||||
@@ -1138,4 +1167,6 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#44]: https://codeberg.org/jlmakiola/calendula/issues/44
|
[#44]: https://codeberg.org/jlmakiola/calendula/issues/44
|
||||||
[#70]: https://codeberg.org/jlmakiola/calendula/issues/70
|
[#70]: https://codeberg.org/jlmakiola/calendula/issues/70
|
||||||
[#75]: https://codeberg.org/jlmakiola/calendula/issues/75
|
[#75]: https://codeberg.org/jlmakiola/calendula/issues/75
|
||||||
|
[#76]: https://codeberg.org/jlmakiola/calendula/issues/76
|
||||||
|
[#78]: https://codeberg.org/jlmakiola/calendula/issues/78
|
||||||
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
|
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
Reads, writes, and reminds — on top of the system calendar, with zero network access.</p>
|
Reads, writes, and reminds — on top of the system calendar, with zero network access.</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="https://gitea.jeanlucmakiola.de/makiolaj/calendula/actions"><img src="https://gitea.jeanlucmakiola.de/makiolaj/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
|
<a href="https://codeberg.org/jlmakiola/calendula/actions"><img src="https://codeberg.org/jlmakiola/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
|
||||||
<img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+">
|
<img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+">
|
||||||
<img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin + Compose">
|
<img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin + Compose">
|
||||||
<img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive">
|
<img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive">
|
||||||
|
|||||||
@@ -67,6 +67,14 @@ class CalendarVisibilityReconciler @Inject constructor(
|
|||||||
// drain and nothing left to decide, so don't pay for the query.
|
// drain and nothing left to decide, so don't pay for the query.
|
||||||
if (pending.isEmpty() && noticeSettled) return@withContext
|
if (pending.isEmpty() && noticeSettled) return@withContext
|
||||||
val calendars = dataSource.calendars()
|
val calendars = dataSource.calendars()
|
||||||
|
// An empty read means "couldn't read", not "no calendars": the data
|
||||||
|
// source turns a null cursor — a provider momentarily unavailable —
|
||||||
|
// into an empty list. Both decisions below are one-way, so taking
|
||||||
|
// that reading as the truth would drop the whole pending set without
|
||||||
|
// ever writing VISIBLE = 0 (switching the user's calendars back on,
|
||||||
|
// events and reminders with them) and settle the notice as "nothing
|
||||||
|
// to explain". Leave both to the next run.
|
||||||
|
if (calendars.isEmpty()) return@withContext
|
||||||
settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending))
|
settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending))
|
||||||
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
|
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
|
||||||
return@withContext
|
return@withContext
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ways a calendar can behave unlike a plain, writable one — each of them a
|
||||||
|
* reason it is missing from the event and import pickers, and each of them
|
||||||
|
* something the app knows and used to keep to itself (#76).
|
||||||
|
*/
|
||||||
|
enum class CalendarStateLabel {
|
||||||
|
/**
|
||||||
|
* A special-dates mirror the app fills from contacts. Writable and visible,
|
||||||
|
* yet no event target: anything authored here is deleted by the next sync,
|
||||||
|
* which is why it is the one exclusion with nothing else to give it away.
|
||||||
|
*/
|
||||||
|
MANAGED,
|
||||||
|
|
||||||
|
/** Contents can't be modified: a WebCal subscription, a read-only share. */
|
||||||
|
READ_ONLY,
|
||||||
|
|
||||||
|
/** The account holds the events, but this device isn't syncing them down. */
|
||||||
|
NOT_SYNCED,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the account this calendar belongs to keeps its events off the device
|
||||||
|
* (`Calendars.SYNC_EVENTS = 0`) — an "empty by construction" calendar: the rows
|
||||||
|
* simply aren't here, so nothing can display them and no reminder can fire.
|
||||||
|
*
|
||||||
|
* Device-local calendars are excluded deliberately. Nothing syncs them by
|
||||||
|
* definition, so the flag says nothing about them, and a local calendar from
|
||||||
|
* another app can hold real events at `sync_events = 0` — the same unsoundness
|
||||||
|
* that made the #75 migration guard wrong.
|
||||||
|
*/
|
||||||
|
val CalendarSource.isNotSynced: Boolean
|
||||||
|
get() = !syncsEvents && !isLocal
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a visibility switch on this calendar can change anything the user
|
||||||
|
* would see. It can't for a non-syncing one: there are no events on the device
|
||||||
|
* to reveal, so the switch would be a control that does nothing.
|
||||||
|
*/
|
||||||
|
val CalendarSource.hasVisibilitySwitch: Boolean
|
||||||
|
get() = !isNotSynced
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this calendar can be offered as a target for a new or imported event.
|
||||||
|
* The one predicate behind both pickers, so the states [CalendarStateLabel]
|
||||||
|
* names on a manager row are exactly the states that keep a calendar out of
|
||||||
|
* them (#76):
|
||||||
|
*
|
||||||
|
* - read-only has nowhere to write;
|
||||||
|
* - switched off would hide the event the moment it was saved;
|
||||||
|
* - a managed mirror has the next contact sync delete it;
|
||||||
|
* - a non-syncing one never carries the event up to the account, and
|
||||||
|
* `CalendarProvider2` wipes the calendar's rows outright when the
|
||||||
|
* subscription is switched back on — a saved event is a dead end either way.
|
||||||
|
*
|
||||||
|
* This is the test for *targets*. An event already living in an excluded
|
||||||
|
* calendar keeps it; the editor adds that calendar back to its picker.
|
||||||
|
*/
|
||||||
|
val CalendarSource.isEventTarget: Boolean
|
||||||
|
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced
|
||||||
|
|
||||||
|
/** Every state worth naming on this calendar's row, in reading order. */
|
||||||
|
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
|
||||||
|
if (isManaged) add(CalendarStateLabel.MANAGED)
|
||||||
|
if (!canModifyContents) add(CalendarStateLabel.READ_ONLY)
|
||||||
|
if (isNotSynced) add(CalendarStateLabel.NOT_SYNCED)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendar-manager order within one group: the ones you can actually act on
|
||||||
|
* first, the non-syncing ones after them. Stable otherwise, so the provider's
|
||||||
|
* display-name ordering survives.
|
||||||
|
*/
|
||||||
|
fun List<CalendarSource>.orderedForManager(): List<CalendarSource> =
|
||||||
|
sortedBy { it.isNotSynced }
|
||||||
@@ -415,6 +415,7 @@ fun CalendarHost(
|
|||||||
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
||||||
onClose = { createDateIso = null },
|
onClose = { createDateIso = null },
|
||||||
onSaved = { createDateIso = null },
|
onSaved = { createDateIso = null },
|
||||||
|
onManageCalendars = { showCalendars = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,6 +435,7 @@ fun CalendarHost(
|
|||||||
editKey = null
|
editKey = null
|
||||||
detailKey = null
|
detailKey = null
|
||||||
},
|
},
|
||||||
|
onManageCalendars = { showCalendars = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -450,18 +452,6 @@ fun CalendarHost(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calendar manager — slides over Settings.
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = showCalendars,
|
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
|
||||||
) {
|
|
||||||
CalendarsScreen(
|
|
||||||
onBack = { showCalendars = false },
|
|
||||||
onImport = { importUri = it; importForceMany = true },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import flow for an opened/received .ics file. A single event routes
|
// Import flow for an opened/received .ics file. A single event routes
|
||||||
// into the create form (prefilled, for review); many open the picker.
|
// into the create form (prefilled, for review); many open the picker.
|
||||||
importUri?.let { uri ->
|
importUri?.let { uri ->
|
||||||
@@ -469,6 +459,7 @@ fun CalendarHost(
|
|||||||
uri = uri,
|
uri = uri,
|
||||||
forceMany = importForceMany,
|
forceMany = importForceMany,
|
||||||
onClose = { importUri = null },
|
onClose = { importUri = null },
|
||||||
|
onManageCalendars = { showCalendars = true },
|
||||||
onOpenSingle = { form ->
|
onOpenSingle = { form ->
|
||||||
importUri = null
|
importUri = null
|
||||||
importFormSource = ImportSource.File
|
importFormSource = ImportSource.File
|
||||||
@@ -483,6 +474,27 @@ fun CalendarHost(
|
|||||||
initialFormSource = importFormSource,
|
initialFormSource = importFormSource,
|
||||||
onClose = { importForm = null },
|
onClose = { importForm = null },
|
||||||
onSaved = { importForm = null },
|
onSaved = { importForm = null },
|
||||||
|
onManageCalendars = { showCalendars = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendar manager — declared last so it covers every overlay that can
|
||||||
|
// open it: Settings, both event forms, and the .ics import picker (#76).
|
||||||
|
// Coming back from it leaves the caller exactly as it was, with the
|
||||||
|
// calendar list already refreshed by the provider's notification.
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showCalendars,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
CalendarsScreen(
|
||||||
|
onBack = { showCalendars = false },
|
||||||
|
// The manager opens the import too (restore from backup), and
|
||||||
|
// that way round it has to step aside: declared above the import
|
||||||
|
// overlays, it would otherwise cover the screen it just asked
|
||||||
|
// for. Closing it hands the user back to whatever opened the
|
||||||
|
// manager once the import is done.
|
||||||
|
onImport = { importUri = it; importForceMany = true; showCalendars = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import androidx.compose.material.icons.filled.CalendarMonth
|
|||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Cloud
|
import androidx.compose.material.icons.filled.Cloud
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.FileDownload
|
import androidx.compose.material.icons.filled.FileDownload
|
||||||
import androidx.compose.material.icons.filled.FileUpload
|
import androidx.compose.material.icons.filled.FileUpload
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
@@ -91,6 +92,12 @@ 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.CalendarStateLabel
|
||||||
|
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isNotSynced
|
||||||
|
import de.jeanlucmakiola.calendula.domain.orderedForManager
|
||||||
|
import de.jeanlucmakiola.calendula.domain.stateLabels
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
@@ -139,6 +146,7 @@ fun CalendarsScreen(
|
|||||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
|
val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle()
|
||||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
val error by viewModel.error.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()
|
||||||
@@ -157,6 +165,7 @@ fun CalendarsScreen(
|
|||||||
initialName = editing?.displayName.orEmpty(),
|
initialName = editing?.displayName.orEmpty(),
|
||||||
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
|
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
|
||||||
initialDescription = editing?.description.orEmpty(),
|
initialDescription = editing?.description.orEmpty(),
|
||||||
|
deleteLocked = editing != null && editing.id in deleteLockedIds,
|
||||||
onSave = { name, color, description ->
|
onSave = { name, color, description ->
|
||||||
val id = editorId
|
val id = editorId
|
||||||
if (id == null || id == NEW_CALENDAR_ID) {
|
if (id == null || id == NEW_CALENDAR_ID) {
|
||||||
@@ -305,7 +314,7 @@ private fun CalendarsList(
|
|||||||
val disabled = !calendar.isVisibleInSystem
|
val disabled = !calendar.isVisibleInSystem
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName,
|
||||||
summary = calendar.description,
|
summary = calendarRowSummary(calendar),
|
||||||
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
dimmed = disabled,
|
dimmed = disabled,
|
||||||
@@ -327,9 +336,9 @@ private fun CalendarsList(
|
|||||||
// safety net. Offered only when there is something exportable: the user's
|
// safety net. Offered only when there is something exportable: the user's
|
||||||
// own local calendars (managed special-dates mirrors don't count).
|
// own local calendars (managed special-dates mirrors don't count).
|
||||||
val exportable = local.filter { it.canModifyContents && !it.isManaged }
|
val exportable = local.filter { it.canModifyContents && !it.isManaged }
|
||||||
// Restore/import can target any writable, non-managed calendar (local or
|
// Restore/import can target any calendar the import picker would offer
|
||||||
// synced), so its availability is broader than export's.
|
// (local or synced), so its availability is broader than export's.
|
||||||
val canImport = (local + synced).any { it.canModifyContents && !it.isManaged }
|
val canImport = (local + synced).any { it.isEventTarget }
|
||||||
if (exportable.isNotEmpty()) {
|
if (exportable.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
SectionHeader(stringResource(R.string.calendars_backup_header))
|
SectionHeader(stringResource(R.string.calendars_backup_header))
|
||||||
@@ -412,7 +421,11 @@ private fun CalendarsList(
|
|||||||
.forEach { (account, cals) ->
|
.forEach { (account, cals) ->
|
||||||
val expanded = account !in collapsedAccounts
|
val expanded = account !in collapsedAccounts
|
||||||
val accountType = cals.first().accountType
|
val accountType = cals.first().accountType
|
||||||
val accountDisabled = cals.none { it.isVisibleInSystem }
|
// A non-syncing calendar has no switch, so it neither counts
|
||||||
|
// towards "the whole account is off" nor moves with toggle-all.
|
||||||
|
val switchable = cals.filter { it.hasVisibilitySwitch }
|
||||||
|
val accountDisabled = switchable.isNotEmpty() &&
|
||||||
|
switchable.none { it.isVisibleInSystem }
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
CalendarGroup(
|
CalendarGroup(
|
||||||
title = account,
|
title = account,
|
||||||
@@ -432,24 +445,36 @@ private fun CalendarsList(
|
|||||||
collapsedAccounts - account
|
collapsedAccounts - account
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
showToggleAll = true,
|
showToggleAll = switchable.isNotEmpty(),
|
||||||
allEnabled = cals.all { it.isVisibleInSystem },
|
allEnabled = switchable.all { it.isVisibleInSystem },
|
||||||
onToggleAll = { enabled -> onSetAccountVisible(cals.map { it.id }, enabled) },
|
onToggleAll = { enabled ->
|
||||||
|
onSetAccountVisible(switchable.map { it.id }, enabled)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
cals.forEachIndexed { index, calendar ->
|
// Calendars you can act on first; the ones this device isn't
|
||||||
val disabled = !calendar.isVisibleInSystem
|
// syncing sit at the bottom, dimmed and switchless.
|
||||||
|
val ordered = cals.orderedForManager()
|
||||||
|
ordered.forEachIndexed { index, calendar ->
|
||||||
|
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName,
|
||||||
position = if (index == cals.lastIndex) Position.Bottom else Position.Middle,
|
summary = calendarRowSummary(calendar),
|
||||||
|
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
|
||||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
dimmed = disabled,
|
dimmed = disabled,
|
||||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||||
trailing = {
|
trailing = if (calendar.hasVisibilitySwitch) {
|
||||||
EnableSwitch(
|
{
|
||||||
calendarName = calendar.displayName,
|
EnableSwitch(
|
||||||
enabled = !disabled,
|
calendarName = calendar.displayName,
|
||||||
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
enabled = calendar.isVisibleInSystem,
|
||||||
)
|
onToggle = { enabled ->
|
||||||
|
onSetVisible(calendar.id, enabled)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -557,6 +582,7 @@ private fun CalendarEditor(
|
|||||||
onSave: (name: String, color: Int, description: String?) -> Unit,
|
onSave: (name: String, color: Int, description: String?) -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
|
deleteLocked: Boolean = false,
|
||||||
) {
|
) {
|
||||||
var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) }
|
var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) }
|
||||||
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
||||||
@@ -590,11 +616,23 @@ private fun CalendarEditor(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (!isNew) {
|
if (!isNew) {
|
||||||
IconButton(onClick = { confirmDelete = true }) {
|
// Kept in place while the special-dates sync owns this
|
||||||
|
// calendar, rather than hidden: the button is where you
|
||||||
|
// expect it, disabled, with the card below saying why —
|
||||||
|
// and it comes back to life the moment the feature is
|
||||||
|
// off, when the delete would actually stick.
|
||||||
|
IconButton(
|
||||||
|
onClick = { confirmDelete = true },
|
||||||
|
enabled = !deleteLocked,
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Delete,
|
Icons.Default.Delete,
|
||||||
contentDescription = stringResource(R.string.event_detail_delete),
|
contentDescription = stringResource(R.string.event_detail_delete),
|
||||||
tint = MaterialTheme.colorScheme.error,
|
tint = if (deleteLocked) {
|
||||||
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -623,6 +661,19 @@ private fun CalendarEditor(
|
|||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
|
if (deleteLocked) {
|
||||||
|
EditorCard(
|
||||||
|
icon = Icons.Default.Info,
|
||||||
|
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
iconAtTop = true,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.calendars_managed_delete_locked),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) {
|
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) {
|
||||||
InlineTextField(
|
InlineTextField(
|
||||||
value = name,
|
value = name,
|
||||||
@@ -694,6 +745,27 @@ private fun CalendarEditor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The row's supporting line: the states that make this calendar behave unlike a
|
||||||
|
* plain writable one (#76), then its own description. Text rather than badges —
|
||||||
|
* a row can carry several of these at once next to a switch, which is exactly
|
||||||
|
* what M3 supporting text composes and a row of static chips doesn't.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun calendarRowSummary(calendar: CalendarSource): String? {
|
||||||
|
val states = calendar.stateLabels().map { label ->
|
||||||
|
stringResource(
|
||||||
|
when (label) {
|
||||||
|
CalendarStateLabel.MANAGED -> R.string.calendars_state_managed
|
||||||
|
CalendarStateLabel.READ_ONLY -> R.string.calendars_state_read_only
|
||||||
|
CalendarStateLabel.NOT_SYNCED -> R.string.calendars_state_not_synced
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val parts = states + listOfNotNull(calendar.description?.takeIf { it.isNotBlank() })
|
||||||
|
return parts.joinToString(" · ").ifEmpty { null }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The per-row on/off control, writing the system's `Calendars.VISIBLE`: checked
|
* The per-row on/off control, writing the system's `Calendars.VISIBLE`: checked
|
||||||
* = the calendar is shown, unchecked = it drops out of every surface (events,
|
* = the calendar is shown, unchecked = it drops out of every surface (events,
|
||||||
|
|||||||
@@ -74,6 +74,33 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
initialValue = AutoBackupUiState(),
|
initialValue = AutoBackupUiState(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Managed special-dates calendars whose deletion would not stick. While the
|
||||||
|
* feature is on, the sync owns every mirror: it recreates a missing one for
|
||||||
|
* an enabled type on the next pass and deletes the leftover of a disabled
|
||||||
|
* one (`SpecialDatesSyncEngine.reconcileCalendars`), so either way the
|
||||||
|
* delete would appear to work and then undo itself. Turning special dates
|
||||||
|
* off empties this set, and deleting a leftover mirror is a real delete from
|
||||||
|
* then on.
|
||||||
|
*
|
||||||
|
* Read off each calendar's own durable marker ([CalendarSource.isManaged],
|
||||||
|
* the `CAL_SYNC2` one the editor lock already trusts) rather than the stored
|
||||||
|
* ids, which are only rewritten on the next sync pass — a preferences loss
|
||||||
|
* would otherwise unlock a live mirror until then.
|
||||||
|
*/
|
||||||
|
val deleteLockedCalendarIds: StateFlow<Set<Long>> = combine(
|
||||||
|
calendars,
|
||||||
|
settingsPrefs.specialDatesEnabled,
|
||||||
|
) { sources, enabled ->
|
||||||
|
if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet()
|
||||||
|
}
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
private val _error = MutableStateFlow(false)
|
private val _error = MutableStateFlow(false)
|
||||||
val error: StateFlow<Boolean> = _error.asStateFlow()
|
val error: StateFlow<Boolean> = _error.asStateFlow()
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.filled.Cloud
|
import androidx.compose.material.icons.filled.Cloud
|
||||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -42,12 +44,19 @@ import de.jeanlucmakiola.floret.components.SelectedCheck
|
|||||||
* account — with the calendars beneath it as a connected card, a colour chip on
|
* account — with the calendars beneath it as a connected card, a colour chip on
|
||||||
* each and a check on the selected one. Emits into the caller's [ColumnScope]
|
* each and a check on the selected one. Emits into the caller's [ColumnScope]
|
||||||
* (a scrolling column), so the caller owns the surrounding chrome.
|
* (a scrolling column), so the caller owns the surrounding chrome.
|
||||||
|
*
|
||||||
|
* The list holds event *targets* only, so a calendar that is switched off,
|
||||||
|
* read-only or managed is silently absent — which reads as a missing calendar
|
||||||
|
* rather than an excluded one (#76). [onManageCalendars], when given, adds the
|
||||||
|
* footer row that names the possible reasons and opens the calendar manager,
|
||||||
|
* where each row then says which one applies.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ColumnScope.CalendarPickerGroups(
|
fun ColumnScope.CalendarPickerGroups(
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
selectedId: Long?,
|
selectedId: Long?,
|
||||||
onSelect: (Long) -> Unit,
|
onSelect: (Long) -> Unit,
|
||||||
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
||||||
val syncedGroups = remember(calendars) {
|
val syncedGroups = remember(calendars) {
|
||||||
@@ -75,6 +84,23 @@ fun ColumnScope.CalendarPickerGroups(
|
|||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (onManageCalendars != null) {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendar_picker_missing_title),
|
||||||
|
summary = stringResource(R.string.calendar_picker_missing_summary),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.VisibilityOff) },
|
||||||
|
trailing = {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onManageCalendars,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One account's category header (avatar + name) atop its selectable calendars. */
|
/** One account's category header (avatar + name) atop its selectable calendars. */
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ import androidx.compose.ui.graphics.isSpecified
|
|||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
@@ -193,6 +195,7 @@ fun EventEditScreen(
|
|||||||
initialStartMinutes: Int? = null,
|
initialStartMinutes: Int? = null,
|
||||||
initialForm: EventForm? = null,
|
initialForm: EventForm? = null,
|
||||||
initialFormSource: ImportSource = ImportSource.File,
|
initialFormSource: ImportSource = ImportSource.File,
|
||||||
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
viewModel: EventEditViewModel = hiltViewModel(),
|
viewModel: EventEditViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
||||||
@@ -309,6 +312,7 @@ fun EventEditScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding),
|
.padding(innerPadding),
|
||||||
|
onManageCalendars = onManageCalendars,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -500,6 +504,7 @@ private fun EventEditContent(
|
|||||||
state: EventEditUiState,
|
state: EventEditUiState,
|
||||||
viewModel: EventEditViewModel,
|
viewModel: EventEditViewModel,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val form = state.form
|
val form = state.form
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
@@ -509,6 +514,10 @@ private fun EventEditContent(
|
|||||||
// they're locked here; everything else (reminders, location, notes) is the
|
// they're locked here; everything else (reminders, location, notes) is the
|
||||||
// user's to edit.
|
// user's to edit.
|
||||||
val locked = state.isManaged
|
val locked = state.isManaged
|
||||||
|
// Read in the form's own window, not the picker's: the field holding focus
|
||||||
|
// lives here, so this is the controller that can put its keyboard away.
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
var picker by remember { mutableStateOf<PickerTarget?>(null) }
|
var picker by remember { mutableStateOf<PickerTarget?>(null) }
|
||||||
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -1114,6 +1123,17 @@ private fun EventEditContent(
|
|||||||
null -> Unit
|
null -> Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A full-screen picker over the form is a change of place, so the form's
|
||||||
|
// keyboard has no business following it there — least of all onto the
|
||||||
|
// calendar manager, which the picker can hand off to. The form's own field
|
||||||
|
// keeps its text; only focus and the IME go.
|
||||||
|
LaunchedEffect(showCalendarPicker) {
|
||||||
|
if (showCalendarPicker) {
|
||||||
|
focusManager.clearFocus(force = true)
|
||||||
|
keyboardController?.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showCalendarPicker) {
|
if (showCalendarPicker) {
|
||||||
CalendarPicker(
|
CalendarPicker(
|
||||||
calendars = state.calendars,
|
calendars = state.calendars,
|
||||||
@@ -1122,6 +1142,17 @@ private fun EventEditContent(
|
|||||||
viewModel.setCalendar(it)
|
viewModel.setCalendar(it)
|
||||||
showCalendarPicker = false
|
showCalendarPicker = false
|
||||||
},
|
},
|
||||||
|
// Close the picker on the way out. It is a Compose Dialog — its own
|
||||||
|
// window, always above the activity's content — so the manager would
|
||||||
|
// otherwise open behind it and the tap would look dead. The form
|
||||||
|
// stays standing underneath, its calendar row one tap from a picker
|
||||||
|
// that re-queries on open.
|
||||||
|
onManageCalendars = onManageCalendars?.let { openManager ->
|
||||||
|
{
|
||||||
|
showCalendarPicker = false
|
||||||
|
openManager()
|
||||||
|
}
|
||||||
|
},
|
||||||
onDismiss = { showCalendarPicker = false },
|
onDismiss = { showCalendarPicker = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2277,6 +2308,7 @@ private fun CalendarPicker(
|
|||||||
selectedId: Long?,
|
selectedId: Long?,
|
||||||
onSelect: (Long) -> Unit,
|
onSelect: (Long) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
FullScreenPicker(
|
FullScreenPicker(
|
||||||
title = stringResource(R.string.event_detail_calendar),
|
title = stringResource(R.string.event_detail_calendar),
|
||||||
@@ -2286,6 +2318,7 @@ private fun CalendarPicker(
|
|||||||
calendars = calendars,
|
calendars = calendars,
|
||||||
selectedId = selectedId,
|
selectedId = selectedId,
|
||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
|
onManageCalendars = onManageCalendars,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||||
import de.jeanlucmakiola.calendula.domain.populatedFields
|
import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||||
import de.jeanlucmakiola.calendula.domain.problems
|
import de.jeanlucmakiola.calendula.domain.problems
|
||||||
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||||
@@ -177,19 +178,16 @@ class EventEditViewModel @Inject constructor(
|
|||||||
repository.calendars().catch { emit(emptyList()) }
|
repository.calendars().catch { emit(emptyList()) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writable calendars — the only valid event targets. Calendars switched off
|
* The calendars a new event can be saved to ([isEventTarget]): writable,
|
||||||
* in Settings → Calendars are excluded, so you can't create into one you've
|
* switched on, not a contact-filled mirror, not a non-syncing subscription.
|
||||||
* turned off; a last-used preselect landing on a now-off calendar falls back
|
* A last-used preselect landing on an excluded calendar falls back to the
|
||||||
* to the first remaining writable one (handled by [resolvedCalendarId] and
|
* first remaining one (handled by [resolvedCalendarId] and [state]).
|
||||||
* [state]). Managed special-dates calendars are excluded too: their events
|
|
||||||
* are owned by the contact sync, which would delete any user event created
|
|
||||||
* there.
|
|
||||||
*
|
*
|
||||||
* This is the list of *targets*. An event already living in an excluded
|
* This is the list of *targets*. An event already living in an excluded
|
||||||
* calendar keeps it — [state] adds it back to the picker.
|
* calendar keeps it — [state] adds it back to the picker.
|
||||||
*/
|
*/
|
||||||
private val writableCalendars: Flow<List<CalendarSource>> = allCalendars.map { calendars ->
|
private val writableCalendars: Flow<List<CalendarSource>> = allCalendars.map { calendars ->
|
||||||
calendars.filter { it.canModifyContents && it.isVisibleInSystem && !it.isManaged }
|
calendars.filter { it.isEventTarget }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The target calendar id, resolved exactly as the form shows it. */
|
/** The target calendar id, resolved exactly as the form shows it. */
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ fun ImportScreen(
|
|||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onOpenSingle: (EventForm) -> Unit,
|
onOpenSingle: (EventForm) -> Unit,
|
||||||
forceMany: Boolean = false,
|
forceMany: Boolean = false,
|
||||||
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
// Key the VM by the file uri. This screen has no nav backstack, so an
|
// Key the VM by the file uri. This screen has no nav backstack, so an
|
||||||
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
|
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
|
||||||
// across imports — its one-shot `load` guard would then show the *previous*
|
// across imports — its one-shot `load` guard would then show the *previous*
|
||||||
@@ -155,7 +156,12 @@ fun ImportScreen(
|
|||||||
|
|
||||||
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
|
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
|
||||||
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
|
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
|
||||||
is ImportUiState.Many -> ManyContent(s, selected, onSelect = { selected = it })
|
is ImportUiState.Many -> ManyContent(
|
||||||
|
state = s,
|
||||||
|
selected = selected,
|
||||||
|
onSelect = { selected = it },
|
||||||
|
onManageCalendars = onManageCalendars,
|
||||||
|
)
|
||||||
is ImportUiState.Done -> DoneContent(s, onClose)
|
is ImportUiState.Done -> DoneContent(s, onClose)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,10 +169,24 @@ fun ImportScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (Long) -> Unit) {
|
private fun ManyContent(
|
||||||
// No writable calendar to import into — tell the user honestly.
|
state: ImportUiState.Many,
|
||||||
|
selected: Long?,
|
||||||
|
onSelect: (Long) -> Unit,
|
||||||
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
// No calendar to import into — tell the user honestly, and carry the same
|
||||||
|
// way out the picker's footer offers below. This is the state that footer
|
||||||
|
// exists for: every writable calendar being switched off, read-only or
|
||||||
|
// contact-filled is exactly what empties this list (#76).
|
||||||
if (state.calendars.isEmpty()) {
|
if (state.calendars.isEmpty()) {
|
||||||
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
|
CenteredMessage(
|
||||||
|
message = stringResource(R.string.import_no_calendar),
|
||||||
|
onClose = null,
|
||||||
|
actionLabel = stringResource(R.string.settings_manage_calendars)
|
||||||
|
.takeIf { onManageCalendars != null },
|
||||||
|
onAction = onManageCalendars,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +198,7 @@ private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (L
|
|||||||
calendars = state.calendars,
|
calendars = state.calendars,
|
||||||
selectedId = selected,
|
selectedId = selected,
|
||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
|
onManageCalendars = onManageCalendars,
|
||||||
)
|
)
|
||||||
if (state.warnings.isNotEmpty()) {
|
if (state.warnings.isNotEmpty()) {
|
||||||
Column(
|
Column(
|
||||||
@@ -333,7 +354,12 @@ private fun WarningText(warning: IcsParseWarning) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
|
private fun CenteredMessage(
|
||||||
|
message: String,
|
||||||
|
onClose: (() -> Unit)?,
|
||||||
|
actionLabel: String? = null,
|
||||||
|
onAction: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.padding(24.dp),
|
Modifier.padding(24.dp),
|
||||||
@@ -344,6 +370,9 @@ private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
|
|||||||
if (onClose != null) {
|
if (onClose != null) {
|
||||||
Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
|
Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
|
||||||
}
|
}
|
||||||
|
if (actionLabel != null && onAction != null) {
|
||||||
|
Button(onClick = onAction) { Text(actionLabel) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
|||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsParser
|
import de.jeanlucmakiola.calendula.domain.ics.IcsParser
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
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 kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -85,18 +86,14 @@ class ImportViewModel @Inject constructor(
|
|||||||
warnings = parsed.warnings,
|
warnings = parsed.warnings,
|
||||||
)
|
)
|
||||||
else -> {
|
else -> {
|
||||||
// A calendar switched off in Settings → Calendars is off
|
// The same targets the event form offers ([isEventTarget]):
|
||||||
// everywhere, so it can't be an import target — exclude it
|
// an import is a bulk create, so a calendar that can't hold
|
||||||
// alongside the read-only ones. Managed special-dates
|
// one event can't hold thirty.
|
||||||
// calendars are contact-derived and editor-locked, so
|
|
||||||
// they're not a valid destination either.
|
|
||||||
ImportUiState.Many(
|
ImportUiState.Many(
|
||||||
events = parsed.events,
|
events = parsed.events,
|
||||||
warnings = parsed.warnings,
|
warnings = parsed.warnings,
|
||||||
calendars = repository.calendars().first()
|
calendars = repository.calendars().first()
|
||||||
.filter {
|
.filter { it.isEventTarget },
|
||||||
it.canModifyContents && !it.isManaged && it.isVisibleInSystem
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import androidx.compose.material.icons.filled.Keyboard
|
|||||||
import androidx.compose.material.icons.filled.Language
|
import androidx.compose.material.icons.filled.Language
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
|
import androidx.compose.material.icons.filled.PrivacyTip
|
||||||
import androidx.compose.material.icons.filled.Translate
|
import androidx.compose.material.icons.filled.Translate
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material.icons.filled.UploadFile
|
import androidx.compose.material.icons.filled.UploadFile
|
||||||
@@ -408,7 +409,9 @@ private fun LanguageRow(position: Position) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun AboutCard() {
|
private fun AboutCard() {
|
||||||
// The card layout lives in floret-kit (components.AboutCard); Calendula
|
// The card layout lives in floret-kit (components.AboutCard); Calendula
|
||||||
// supplies its own logo, author and the source / licence / support links.
|
// supplies its own logo, author and the source / licence / privacy / support
|
||||||
|
// links. The privacy policy has to be reachable from inside the app, not just
|
||||||
|
// from the store listing, because Calendula touches calendar and contact data.
|
||||||
AboutCard(
|
AboutCard(
|
||||||
logo = { AppLogo() },
|
logo = { AppLogo() },
|
||||||
appName = stringResource(R.string.app_name),
|
appName = stringResource(R.string.app_name),
|
||||||
@@ -424,6 +427,11 @@ private fun AboutCard() {
|
|||||||
label = stringResource(R.string.settings_license),
|
label = stringResource(R.string.settings_license),
|
||||||
url = stringResource(R.string.about_license_url),
|
url = stringResource(R.string.about_license_url),
|
||||||
),
|
),
|
||||||
|
AboutLink(
|
||||||
|
icon = Icons.Default.PrivacyTip,
|
||||||
|
label = stringResource(R.string.settings_about_privacy),
|
||||||
|
url = stringResource(R.string.about_privacy_url),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
highlightLink = AboutLink(
|
highlightLink = AboutLink(
|
||||||
icon = Icons.Default.Favorite,
|
icon = Icons.Default.Favorite,
|
||||||
|
|||||||
@@ -53,11 +53,11 @@
|
|||||||
<string name="event_share_failed">تعذّر مشاركة هذا الحدث.</string>
|
<string name="event_share_failed">تعذّر مشاركة هذا الحدث.</string>
|
||||||
<string name="event_delete_title">حذف الحدث؟</string>
|
<string name="event_delete_title">حذف الحدث؟</string>
|
||||||
<string name="event_delete_body">الحدث أُزيل من تقويمك ومن كل جهاز تتم مزامنته معه.</string>
|
<string name="event_delete_body">الحدث أُزيل من تقويمك ومن كل جهاز تتم مزامنته معه.</string>
|
||||||
<string name="event_delete_recurring_title">حذف الحدث المتكرر</string>
|
<string name="event_delete_recurring_title">حذف الحدث المتكرّر</string>
|
||||||
<string name="event_delete_option_occurrence">فقط هذا الحدث</string>
|
<string name="event_delete_option_occurrence">فقط هذا الحدث</string>
|
||||||
<string name="event_delete_option_following">هذا وجميع الأحداث اللاحقة</string>
|
<string name="event_delete_option_following">هذا وجميع الأحداث اللاحقة</string>
|
||||||
<string name="event_delete_option_series">جميع الأحداث في السلسلة</string>
|
<string name="event_delete_option_series">جميع الأحداث في السلسلة</string>
|
||||||
<string name="event_edit_recurring_title">تعديل الحدث المتكرر</string>
|
<string name="event_edit_recurring_title">تعديل الحدث المتكرّر</string>
|
||||||
<string name="event_delete_failed">تعذّر حذف الحدث</string>
|
<string name="event_delete_failed">تعذّر حذف الحدث</string>
|
||||||
<string name="event_delete_write_denied">Calendula يحتاج إلى صلاحية للكتابة لحذف الأحداث</string>
|
<string name="event_delete_write_denied">Calendula يحتاج إلى صلاحية للكتابة لحذف الأحداث</string>
|
||||||
<string name="dialog_cancel">إلغاء</string>
|
<string name="dialog_cancel">إلغاء</string>
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
<string name="event_edit_conflict_discard_hint">الحدث يبقى كما هو الآن</string>
|
<string name="event_edit_conflict_discard_hint">الحدث يبقى كما هو الآن</string>
|
||||||
<string name="import_reminder_prompt_title">هل تريد تطبيق تذكيرك الافتراضي؟</string>
|
<string name="import_reminder_prompt_title">هل تريد تطبيق تذكيرك الافتراضي؟</string>
|
||||||
<string name="event_edit_gone_body">هذا الحدث حُذف في هذه الأثناء، على سبيل المثال على جهاز آخر. لم يعد من الممكن حفظ تغييراتك.</string>
|
<string name="event_edit_gone_body">هذا الحدث حُذف في هذه الأثناء، على سبيل المثال على جهاز آخر. لم يعد من الممكن حفظ تغييراتك.</string>
|
||||||
<string name="event_edit_more_fields">المزيد من الخيارات</string>
|
<string name="event_edit_more_fields">المزيد من الخانات</string>
|
||||||
<string name="event_access_public">علني</string>
|
<string name="event_access_public">علني</string>
|
||||||
<string name="event_access_default">الافتراضي</string>
|
<string name="event_access_default">الافتراضي</string>
|
||||||
<string name="event_availability_busy">مشغول</string>
|
<string name="event_availability_busy">مشغول</string>
|
||||||
@@ -153,7 +153,7 @@
|
|||||||
<string name="recurrence_with_until">%1$s حتى %2$s</string>
|
<string name="recurrence_with_until">%1$s حتى %2$s</string>
|
||||||
<string name="recurrence_with_count">%1$s، %2$d مرّات</string>
|
<string name="recurrence_with_count">%1$s، %2$d مرّات</string>
|
||||||
<string name="import_reminder_prompt_apply">طَبِّق الافتراضي</string>
|
<string name="import_reminder_prompt_apply">طَبِّق الافتراضي</string>
|
||||||
<string name="event_detail_recurring">حدث مُتكرر</string>
|
<string name="event_detail_recurring">حدث متكرّر</string>
|
||||||
<string name="recurrence_on_days">%1$s في %2$s</string>
|
<string name="recurrence_on_days">%1$s في %2$s</string>
|
||||||
<string name="event_attendee_unknown">—</string>
|
<string name="event_attendee_unknown">—</string>
|
||||||
<string name="event_attendee_accepted">تم القُبول</string>
|
<string name="event_attendee_accepted">تم القُبول</string>
|
||||||
@@ -166,7 +166,7 @@
|
|||||||
<string name="event_status_cancelled">ملغي</string>
|
<string name="event_status_cancelled">ملغي</string>
|
||||||
<string name="event_availability_free">متاح</string>
|
<string name="event_availability_free">متاح</string>
|
||||||
<string name="event_access_private">خاص</string>
|
<string name="event_access_private">خاص</string>
|
||||||
<string name="event_access_confidential">سري</string>
|
<string name="event_access_confidential">سرّي</string>
|
||||||
<string name="event_attendee_organizer">المُنظِّم</string>
|
<string name="event_attendee_organizer">المُنظِّم</string>
|
||||||
<string name="event_attendee_optional">اختياري</string>
|
<string name="event_attendee_optional">اختياري</string>
|
||||||
<string name="reminder_default">التذكير الافتراضي</string>
|
<string name="reminder_default">التذكير الافتراضي</string>
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
<string name="reminder_onboarding_body">Android لا يعرض تذكيرات الأحداث من نفسه — بل يجب أن يقوم تطبيق تقويم بذلك. دع Calendula يتولى هذه المهمة.</string>
|
<string name="reminder_onboarding_body">Android لا يعرض تذكيرات الأحداث من نفسه — بل يجب أن يقوم تطبيق تقويم بذلك. دع Calendula يتولى هذه المهمة.</string>
|
||||||
<string name="reminder_benefit_delivery_body">كل تذكير لأحداثك يصِل كإشعار، في الوقت المحدد تمامًا.</string>
|
<string name="reminder_benefit_delivery_body">كل تذكير لأحداثك يصِل كإشعار، في الوقت المحدد تمامًا.</string>
|
||||||
<string name="reminder_benefit_duplicates_title">هل تستخدم تطبيق تقويم ثانٍ؟</string>
|
<string name="reminder_benefit_duplicates_title">هل تستخدم تطبيق تقويم ثانٍ؟</string>
|
||||||
<string name="reminder_benefit_duplicates_body">إذا كان تطبيق آخر أيضًا ينشر تذكيرات، فستراهم مرتين — قم بإيقاف تشغيلهم هناك أو هنا.</string>
|
<string name="reminder_benefit_duplicates_body">إذا تطبيق آخر أيضًا ينشر تذكيرات، فستراهم مرتين — قم بإيقاف تشغيلهم هناك أو هنا.</string>
|
||||||
<string name="reminder_benefit_reversible_title">يمكنك تغييره في أي وقت</string>
|
<string name="reminder_benefit_reversible_title">يمكنك تغييره في أي وقت</string>
|
||||||
<string name="reminder_onboarding_enable_button">شَغِّل التذكيرات</string>
|
<string name="reminder_onboarding_enable_button">شَغِّل التذكيرات</string>
|
||||||
<string name="reminder_onboarding_skip_button">ليس الآن</string>
|
<string name="reminder_onboarding_skip_button">ليس الآن</string>
|
||||||
@@ -200,7 +200,7 @@
|
|||||||
<string name="search_hint">البحث عن الأحداث</string>
|
<string name="search_hint">البحث عن الأحداث</string>
|
||||||
<string name="search_clear">مسح</string>
|
<string name="search_clear">مسح</string>
|
||||||
<string name="agenda_no_more_today">لا مزيد من الأحداث اليوم</string>
|
<string name="agenda_no_more_today">لا مزيد من الأحداث اليوم</string>
|
||||||
<string name="search_empty">لا توجد أحداث مطابقة ”%1$s“.</string>
|
<string name="search_empty">لا أحداث مطابقة ”%1$s“.</string>
|
||||||
<string name="search_idle_hint">ابحث عن أحداثك بالعنوان، الموقع أو الملاحظات.</string>
|
<string name="search_idle_hint">ابحث عن أحداثك بالعنوان، الموقع أو الملاحظات.</string>
|
||||||
<string name="search_back">الرجوع</string>
|
<string name="search_back">الرجوع</string>
|
||||||
<string name="search_action">البحث</string>
|
<string name="search_action">البحث</string>
|
||||||
@@ -217,7 +217,7 @@
|
|||||||
<string name="back">رجوع</string>
|
<string name="back">رجوع</string>
|
||||||
<string name="settings_section_appearance">المظهر</string>
|
<string name="settings_section_appearance">المظهر</string>
|
||||||
<string name="settings_dynamic_color">اللون الديناميكي</string>
|
<string name="settings_dynamic_color">اللون الديناميكي</string>
|
||||||
<string name="settings_dynamic_color_unavailable">يتطلب Android 12 أو أحدث</string>
|
<string name="settings_dynamic_color_unavailable">يتطلب أندرويد ١٢ أو أحدث</string>
|
||||||
<string name="settings_default_view">طريقة العرض الافتراضية</string>
|
<string name="settings_default_view">طريقة العرض الافتراضية</string>
|
||||||
<string name="settings_soften_colors">ألوان تقويم ناعمة</string>
|
<string name="settings_soften_colors">ألوان تقويم ناعمة</string>
|
||||||
<string name="settings_soften_colors_summary">خفف ألوان التقويم والأحداث لتتناسب مع الثيم. قم بإيقافه لإظهار الألوان الخام من مصدر التقويم.</string>
|
<string name="settings_soften_colors_summary">خفف ألوان التقويم والأحداث لتتناسب مع الثيم. قم بإيقافه لإظهار الألوان الخام من مصدر التقويم.</string>
|
||||||
@@ -227,7 +227,7 @@
|
|||||||
<string name="settings_font_choose_file">اختر ملفًا…</string>
|
<string name="settings_font_choose_file">اختر ملفًا…</string>
|
||||||
<string name="settings_font_custom_selected">خط مخصص</string>
|
<string name="settings_font_custom_selected">خط مخصص</string>
|
||||||
<string name="settings_font_import_failed">تعذّر قراءة هذا الملف كخط</string>
|
<string name="settings_font_import_failed">تعذّر قراءة هذا الملف كخط</string>
|
||||||
<string name="settings_week_start">يبدأ الأسبوع في</string>
|
<string name="settings_week_start">الأسبوع يبدأ في</string>
|
||||||
<string name="settings_week_start_auto">تلقائي</string>
|
<string name="settings_week_start_auto">تلقائي</string>
|
||||||
<string name="settings_time_format_auto">تلقائي</string>
|
<string name="settings_time_format_auto">تلقائي</string>
|
||||||
<string name="settings_time_format">تنسيق الوقت</string>
|
<string name="settings_time_format">تنسيق الوقت</string>
|
||||||
@@ -243,4 +243,58 @@
|
|||||||
<string name="settings_past_events_show">إظهار</string>
|
<string name="settings_past_events_show">إظهار</string>
|
||||||
<string name="settings_past_events_hide">إخفاء</string>
|
<string name="settings_past_events_hide">إخفاء</string>
|
||||||
<string name="settings_agenda_header">جدول</string>
|
<string name="settings_agenda_header">جدول</string>
|
||||||
|
<string name="event_edit_timezone_device">المنطقة الزمنية للجهاز</string>
|
||||||
|
<string name="event_edit_timezone_search">البحث عن المناطق الزمنية</string>
|
||||||
|
<string name="event_edit_timezone_all">جميع المناطق الزمنية</string>
|
||||||
|
<string name="event_edit_timezone_none">لا منطقة زمنية تطابق \"%1$s\"</string>
|
||||||
|
<string name="event_edit_timezone_local_time">%1$s توقيتك</string>
|
||||||
|
<string name="event_edit_timezone_recent">الأحدث</string>
|
||||||
|
<string name="event_edit_recurrence_incomplete">أدخل رقمًا من ١ إلى ٩٩٩</string>
|
||||||
|
<plurals name="duration_minutes">
|
||||||
|
<item quantity="zero">(%d) لا دقائق</item>
|
||||||
|
<item quantity="one">(%d) دقيقة واحده</item>
|
||||||
|
<item quantity="two">%d دقيقتان</item>
|
||||||
|
<item quantity="few">%d دقائق</item>
|
||||||
|
<item quantity="many">%d دقيقة</item>
|
||||||
|
<item quantity="other">%d دقيقة</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_hours">
|
||||||
|
<item quantity="zero">(%d) لا ساعات</item>
|
||||||
|
<item quantity="one">(%d) ساعة واحده</item>
|
||||||
|
<item quantity="two">%d ساعتان</item>
|
||||||
|
<item quantity="few">%d ساعات</item>
|
||||||
|
<item quantity="many">%d ساعة</item>
|
||||||
|
<item quantity="other">%d ساعة</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_days">
|
||||||
|
<item quantity="zero">(%d) لا أيام</item>
|
||||||
|
<item quantity="one">(%d) يوم واحد</item>
|
||||||
|
<item quantity="two">%d يومان</item>
|
||||||
|
<item quantity="few">%d أيام</item>
|
||||||
|
<item quantity="many">%d يوم</item>
|
||||||
|
<item quantity="other">%d يوم</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_weeks">
|
||||||
|
<item quantity="zero">(%d) لا أسابيع</item>
|
||||||
|
<item quantity="one">(%d) أسبوع واحد</item>
|
||||||
|
<item quantity="two">%d أسبوعان</item>
|
||||||
|
<item quantity="few">%d أسابيع</item>
|
||||||
|
<item quantity="many">%d أسبوع</item>
|
||||||
|
<item quantity="other">%d أسبوع</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="agenda_span_starts">يبدأ %1$s</string>
|
||||||
|
<string name="agenda_span_ends">ينتهي %1$s</string>
|
||||||
|
<string name="today_jump_action">الانتقال إلى اليوم</string>
|
||||||
|
<string name="widget_refresh">تحديث</string>
|
||||||
|
<string name="settings_app_name">اسم التطبيق</string>
|
||||||
|
<string name="settings_dim_completed">تعتيم الأحداث المكتملة</string>
|
||||||
|
<string name="settings_dim_completed_summary">تعتيم الأحداث التي انتهت بالفعل في عرض الشهر والأسبوع</string>
|
||||||
|
<string name="settings_today_toolbar">زر اليوم في شريط الأدوات</string>
|
||||||
|
<string name="settings_today_toolbar_summary">إظهار زر الانتقال إلى اليوم في شريط الأدوات بدلاً من زر عائم</string>
|
||||||
|
<string name="settings_app_name_summary">اعرض Calendula كـ \"Calendar\" في مشغّل التطبيقات الخاص بك. فقط الاسم في المشغّل يتغير؛ وقد ينتقل الرمز إلى مكان جديد بعد التبديل.</string>
|
||||||
|
<string name="settings_past_events_dim">تعتيم</string>
|
||||||
|
<string name="settings_past_events">الأحداث السابقة</string>
|
||||||
|
<string name="settings_agenda_range">مدى الجدول</string>
|
||||||
|
<string name="agenda_range_custom">مخصص…</string>
|
||||||
|
<string name="agenda_range_custom_hint">أيام</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -354,7 +354,7 @@
|
|||||||
<string name="settings_translate">Pomóż w tłumaczeniu</string>
|
<string name="settings_translate">Pomóż w tłumaczeniu</string>
|
||||||
<string name="settings_translate_hint">Dodaj lub ulepsz tłumaczenie w Weblate</string>
|
<string name="settings_translate_hint">Dodaj lub ulepsz tłumaczenie w Weblate</string>
|
||||||
<string name="settings_appearance_subtitle">Motyw, domyślny widok, pierwszy dzień tygodnia</string>
|
<string name="settings_appearance_subtitle">Motyw, domyślny widok, pierwszy dzień tygodnia</string>
|
||||||
<string name="settings_views_subtitle">Kolejność przycisku szybkiego przełączania oraz menu</string>
|
<string name="settings_views_subtitle">Układ widoku miesiąca, przycisk szybkiego przełączania, kolejność menu</string>
|
||||||
<string name="settings_event_form_subtitle">Domyślne pola dla nowych wydarzeń</string>
|
<string name="settings_event_form_subtitle">Domyślne pola dla nowych wydarzeń</string>
|
||||||
<string name="settings_notifications_subtitle">Przypomnienia o wydarzeniach</string>
|
<string name="settings_notifications_subtitle">Przypomnienia o wydarzeniach</string>
|
||||||
<string name="settings_special_dates_subtitle">Urodziny i rocznice kontaktów</string>
|
<string name="settings_special_dates_subtitle">Urodziny i rocznice kontaktów</string>
|
||||||
@@ -515,4 +515,32 @@
|
|||||||
<string name="settings_agenda_show_today_hint">Zachowaj dzisiejszy dzień na górze agendy i widżetu, nawet gdy nie ma już na dziś żadnych zadań.</string>
|
<string name="settings_agenda_show_today_hint">Zachowaj dzisiejszy dzień na górze agendy i widżetu, nawet gdy nie ma już na dziś żadnych zadań.</string>
|
||||||
<string name="special_dates_calendar_birthday">Urodziny</string>
|
<string name="special_dates_calendar_birthday">Urodziny</string>
|
||||||
<string name="special_dates_calendar_anniversary">Rocznice</string>
|
<string name="special_dates_calendar_anniversary">Rocznice</string>
|
||||||
|
<string name="event_edit_timezone_device">Strefa czasowa urządzenia</string>
|
||||||
|
<string name="event_edit_timezone_device_summary">Podąża za Twoją lokalizacją</string>
|
||||||
|
<string name="event_edit_timezone_search">Znajdź strefę czasową</string>
|
||||||
|
<string name="event_edit_timezone_recent">Ostatnie</string>
|
||||||
|
<string name="event_edit_timezone_all">Wszystkie strefy czasowe</string>
|
||||||
|
<string name="event_edit_timezone_none">Żadna strefa czasowa nie pasuje do „%1$s”</string>
|
||||||
|
<string name="event_edit_timezone_local_time">%1$s Twojego czasu</string>
|
||||||
|
<string name="event_edit_recurrence_incomplete">Wpisz liczbę od 1 do 999</string>
|
||||||
|
<string name="agenda_span_starts">Początek o %1$s</string>
|
||||||
|
<string name="agenda_span_ends">Koniec o %1$s</string>
|
||||||
|
<string name="today_jump_action">Dzisiaj</string>
|
||||||
|
<string name="settings_today_toolbar">Przycisk „Dzisiaj” na pasku narzędzi</string>
|
||||||
|
<string name="settings_today_toolbar_summary">Pokaż przycisk skoku do dzisiaj na pasku narzędzi zamiast przycisku pływającego</string>
|
||||||
|
<string name="settings_app_name">Nazwa aplikacji</string>
|
||||||
|
<string name="settings_app_name_summary">Wyświetlaj Calendula jako „Kalendarz” w menu aplikacji. Zmieni się tylko nazwa w menu; po przełączeniu ikona może pojawić się w innym miejscu.</string>
|
||||||
|
<string name="settings_month_header">Widok miesiąca</string>
|
||||||
|
<string name="settings_month_view_style">Styl widoku miesiąca</string>
|
||||||
|
<string name="month_style_paged">Strony</string>
|
||||||
|
<string name="month_style_paged_summary">Jeden miesiąc wypełnia ekran. Przesuń w lewo lub w prawo, aby zmienić miesiąc.</string>
|
||||||
|
<string name="month_style_continuous">Przewijanie miesięcy</string>
|
||||||
|
<string name="month_style_continuous_summary">Każdy miesiąc znajduje się pod własnym nagłówkiem, oddzielony od następnego niewielkim odstępem.</string>
|
||||||
|
<string name="month_style_dense">Tygodnie bez przerw</string>
|
||||||
|
<string name="month_style_dense_summary">Tygodnie są ułożone jeden po drugim, każdy miesiąc płynnie łączy się z następnym, bez odstępu pomiędzy.</string>
|
||||||
|
<string name="month_style_split">Podzielony</string>
|
||||||
|
<string name="month_style_split_summary">Zwięzła siatka wyróżnia dni z wydarzeniami a lista dla dnia wybranego dotknięciem wyświetla się poniżej.</string>
|
||||||
|
<string name="month_split_no_events">Brak planów</string>
|
||||||
|
<string name="month_split_expand">Pokaż cały miesiąc</string>
|
||||||
|
<string name="month_split_collapse">Pokaż wydarzenia dnia</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -462,6 +462,7 @@
|
|||||||
<string name="settings_license_value">MIT</string>
|
<string name="settings_license_value">MIT</string>
|
||||||
<string name="settings_about_author">by Jean-Luc Makiola</string>
|
<string name="settings_about_author">by Jean-Luc Makiola</string>
|
||||||
<string name="settings_about_source">Source</string>
|
<string name="settings_about_source">Source</string>
|
||||||
|
<string name="settings_about_privacy">Privacy policy</string>
|
||||||
<string name="settings_about_support">Support development</string>
|
<string name="settings_about_support">Support development</string>
|
||||||
<string name="settings_about_version">Version %1$s</string>
|
<string name="settings_about_version">Version %1$s</string>
|
||||||
<string name="settings_about_logo_desc">Calendula app icon</string>
|
<string name="settings_about_logo_desc">Calendula app icon</string>
|
||||||
@@ -477,6 +478,13 @@
|
|||||||
<string name="calendars_visibility_a11y">Show \"%1$s\"</string>
|
<string name="calendars_visibility_a11y">Show \"%1$s\"</string>
|
||||||
<string name="calendars_visibility_notice_title">Some calendars are switched off</string>
|
<string name="calendars_visibility_notice_title">Some calendars are switched off</string>
|
||||||
<string name="calendars_visibility_notice_message">Calendula now shows the calendars that are switched on for this device, so what you see and what reminds you can no longer disagree. Some of yours are currently off — they were switched off here or in another calendar app. Turn any of them back on in Settings → Calendars.</string>
|
<string name="calendars_visibility_notice_message">Calendula now shows the calendars that are switched on for this device, so what you see and what reminds you can no longer disagree. Some of yours are currently off — they were switched off here or in another calendar app. Turn any of them back on in Settings → Calendars.</string>
|
||||||
|
<!-- Footer row under the event-form and .ics import calendar pickers. -->
|
||||||
|
<string name="calendar_picker_missing_title">Missing a calendar?</string>
|
||||||
|
<string name="calendar_picker_missing_summary">It may be switched off, read-only, or filled from your contacts — manage your calendars here.</string>
|
||||||
|
<string name="calendars_state_read_only">Read-only</string>
|
||||||
|
<string name="calendars_state_not_synced">Not synced to this device</string>
|
||||||
|
<string name="calendars_state_managed">Filled from your contacts</string>
|
||||||
|
<string name="calendars_managed_delete_locked">This calendar is filled from your contacts, so Calendula would create it again on the next sync. Turn special dates off under Settings → Special dates to delete it.</string>
|
||||||
<string name="calendars_synced_header">Synced calendars</string>
|
<string name="calendars_synced_header">Synced calendars</string>
|
||||||
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
|
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
|
||||||
<string name="calendars_manage_in_app">Manage in app</string>
|
<string name="calendars_manage_in_app">Manage in app</string>
|
||||||
@@ -572,8 +580,9 @@
|
|||||||
<string name="settings_qs_tile">Add Quick Settings tile</string>
|
<string name="settings_qs_tile">Add Quick Settings tile</string>
|
||||||
<string name="settings_qs_tile_hint">Add a “New event” tile to the Quick Settings panel.</string>
|
<string name="settings_qs_tile_hint">Add a “New event” tile to the Quick Settings panel.</string>
|
||||||
|
|
||||||
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
|
<string name="about_source_url" translatable="false">https://codeberg.org/jlmakiola/calendula</string>
|
||||||
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
|
<string name="about_license_url" translatable="false">https://codeberg.org/jlmakiola/calendula/src/branch/main/LICENSE</string>
|
||||||
|
<string name="about_privacy_url" translatable="false">https://jeanlucmakiola.de/calendula/privacy</string>
|
||||||
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
|
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
|
||||||
<string name="about_translate_url" translatable="false">https://weblate.dev.jeanlucmakiola.de/engage/calendula/</string>
|
<string name="about_translate_url" translatable="false">https://weblate.dev.jeanlucmakiola.de/engage/calendula/</string>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class CalendarRowStateTest {
|
||||||
|
|
||||||
|
private fun cal(
|
||||||
|
id: Long = 1L,
|
||||||
|
name: String = "Cal $id",
|
||||||
|
writable: Boolean = true,
|
||||||
|
syncsEvents: Boolean = true,
|
||||||
|
local: Boolean = false,
|
||||||
|
managed: Boolean = false,
|
||||||
|
) = CalendarSource(
|
||||||
|
id = id,
|
||||||
|
displayName = name,
|
||||||
|
accountName = "account",
|
||||||
|
accountType = if (local) "LOCAL" else "com.google",
|
||||||
|
color = 0,
|
||||||
|
isVisibleInSystem = true,
|
||||||
|
canModifyContents = writable,
|
||||||
|
isLocal = local,
|
||||||
|
syncsEvents = syncsEvents,
|
||||||
|
isManaged = managed,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a plain writable calendar carries no state labels`() {
|
||||||
|
assertThat(cal().stateLabels()).isEmpty()
|
||||||
|
assertThat(cal().hasVisibilitySwitch).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a read-only calendar is labelled`() {
|
||||||
|
assertThat(cal(writable = false).stateLabels())
|
||||||
|
.containsExactly(CalendarStateLabel.READ_ONLY)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a non-syncing account calendar is labelled and loses its switch`() {
|
||||||
|
val calendar = cal(syncsEvents = false)
|
||||||
|
assertThat(calendar.stateLabels()).containsExactly(CalendarStateLabel.NOT_SYNCED)
|
||||||
|
assertThat(calendar.hasVisibilitySwitch).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `both states can hold at once, read-only first`() {
|
||||||
|
assertThat(cal(writable = false, syncsEvents = false).stateLabels())
|
||||||
|
.containsExactly(CalendarStateLabel.READ_ONLY, CalendarStateLabel.NOT_SYNCED)
|
||||||
|
.inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a managed special-dates mirror is labelled although it is writable`() {
|
||||||
|
// Writable, visible, syncing — nothing else on the row would hint at why
|
||||||
|
// it can't be picked as an event target.
|
||||||
|
val calendar = cal(local = true, managed = true)
|
||||||
|
assertThat(calendar.stateLabels()).containsExactly(CalendarStateLabel.MANAGED)
|
||||||
|
assertThat(calendar.hasVisibilitySwitch).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a local calendar is never called not-synced`() {
|
||||||
|
// Nothing syncs a device-local calendar, so sync_events says nothing
|
||||||
|
// about it — and another app's local calendar can hold real events at 0.
|
||||||
|
val calendar = cal(syncsEvents = false, local = true)
|
||||||
|
assertThat(calendar.isNotSynced).isFalse()
|
||||||
|
assertThat(calendar.stateLabels()).isEmpty()
|
||||||
|
assertThat(calendar.hasVisibilitySwitch).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every named state keeps a calendar out of the pickers`() {
|
||||||
|
// The labels and the picker exclusion are the same set, stated twice —
|
||||||
|
// a labelled row the pickers still offered would make the footer's
|
||||||
|
// "manage your calendars to see why" a lie (#76).
|
||||||
|
assertThat(cal().isEventTarget).isTrue()
|
||||||
|
listOf(
|
||||||
|
cal(writable = false),
|
||||||
|
cal(syncsEvents = false),
|
||||||
|
cal(local = true, managed = true),
|
||||||
|
).forEach { calendar ->
|
||||||
|
assertThat(calendar.stateLabels()).isNotEmpty()
|
||||||
|
assertThat(calendar.isEventTarget).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a switched-off calendar is no target although it carries no label`() {
|
||||||
|
// The switch is right there on the row, so the state speaks for itself.
|
||||||
|
val calendar = cal().copy(isVisibleInSystem = false)
|
||||||
|
assertThat(calendar.stateLabels()).isEmpty()
|
||||||
|
assertThat(calendar.isEventTarget).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `manager order puts non-syncing calendars last and is otherwise stable`() {
|
||||||
|
val ordered = listOf(
|
||||||
|
cal(id = 1L, name = "Anna", syncsEvents = false),
|
||||||
|
cal(id = 2L, name = "Bert"),
|
||||||
|
cal(id = 3L, name = "Cleo", syncsEvents = false),
|
||||||
|
cal(id = 4L, name = "Dana"),
|
||||||
|
).orderedForManager()
|
||||||
|
|
||||||
|
assertThat(ordered.map { it.displayName })
|
||||||
|
.containsExactly("Bert", "Dana", "Anna", "Cleo")
|
||||||
|
.inOrder()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,9 +45,14 @@ class EventEditViewModelTest {
|
|||||||
private val beginMillis = 1_781_164_800_000L
|
private val beginMillis = 1_781_164_800_000L
|
||||||
private val endMillis = beginMillis + 3_600_000L
|
private val endMillis = beginMillis + 3_600_000L
|
||||||
|
|
||||||
private fun cal(id: Long, visible: Boolean = true): CalendarSource = CalendarSource(
|
private fun cal(
|
||||||
|
id: Long,
|
||||||
|
visible: Boolean = true,
|
||||||
|
syncsEvents: Boolean = true,
|
||||||
|
): CalendarSource = CalendarSource(
|
||||||
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
|
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
|
||||||
color = 0xFF112233.toInt(), isVisibleInSystem = visible, canModifyContents = true,
|
color = 0xFF112233.toInt(), isVisibleInSystem = visible, canModifyContents = true,
|
||||||
|
syncsEvents = syncsEvents,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail(
|
private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail(
|
||||||
@@ -108,6 +113,27 @@ class EventEditViewModelTest {
|
|||||||
job.cancel()
|
job.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a calendar whose account is not synced to this device is not a target`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
// Writable and switched on, but the account keeps its events off the
|
||||||
|
// device: nothing saved here ever reaches it, and the provider drops the
|
||||||
|
// rows when the subscription comes back (#76).
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
calendarsResult = listOf(cal(1L), cal(2L, syncsEvents = false))
|
||||||
|
eventDetailResult = { detail(calendarId = 1L) }
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
val job = activate(vm)
|
||||||
|
|
||||||
|
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(vm.state.value?.calendars?.map { it.id }).containsExactly(1L)
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `editing an event in a switched-off calendar keeps it in the picker`(
|
fun `editing an event in a switched-off calendar keeps it in the picker`(
|
||||||
@TempDir tempDir: Path,
|
@TempDir tempDir: Path,
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`
|
|||||||
CI and release are split so a change is built once on its PR and only does
|
CI and release are split so a change is built once on its PR and only does
|
||||||
release work when a merge actually cuts a release:
|
release work when a merge actually cuts a release:
|
||||||
|
|
||||||
- **`ci.yaml`** (on `pull_request`) — lint + unit tests + a debug assemble (and
|
- **`ci.yaml`** (`.forgejo/workflows/`, on `pull_request`, **Codeberg**) — lint +
|
||||||
|
unit tests + a debug assemble (and
|
||||||
a Trivy scan), once per PR. Docs/metadata-only PRs skip the Android build but
|
a Trivy scan), once per PR. Docs/metadata-only PRs skip the Android build but
|
||||||
still report a green `CI` check.
|
still report a green `CI` check.
|
||||||
- **`release.yaml`** (on push to `main`, plus `workflow_dispatch`) — a cheap
|
- **`release.yaml`** (on push to `main`, plus `workflow_dispatch`) — a cheap
|
||||||
@@ -91,9 +92,9 @@ release work when a merge actually cuts a release:
|
|||||||
|
|
||||||
Alongside F-Droid, each release is mirrored to the Codeberg repo
|
Alongside F-Droid, each release is mirrored to the Codeberg repo
|
||||||
(`jlmakiola/calendula`) as a plain download for users who don't want F-Droid.
|
(`jlmakiola/calendula`) as a plain download for users who don't want F-Droid.
|
||||||
Gitea already **push-mirrors** branches and tags to Codeberg, but releases
|
Codeberg **push-mirrors** branches and tags to Gitea, but releases aren't git
|
||||||
aren't git objects and don't sync, so the pipeline creates the release over the
|
objects and don't sync in either direction, so the pipeline creates the release
|
||||||
Codeberg API and attaches `calendula_v<version>.apk` + its `.sha256`. It's the
|
over the Codeberg API and attaches `calendula_v<version>.apk` + its `.sha256`. It's the
|
||||||
same APK the F-Droid repo serves (same **app key**), so it adds no trust
|
same APK the F-Droid repo serves (same **app key**), so it adds no trust
|
||||||
surface. The step is best-effort: a Codeberg outage never fails an
|
surface. The step is best-effort: a Codeberg outage never fails an
|
||||||
already-published F-Droid release, and it skips cleanly if `CODEBERG_RELEASE_TOKEN` is
|
already-published F-Droid release, and it skips cleanly if `CODEBERG_RELEASE_TOKEN` is
|
||||||
@@ -109,6 +110,39 @@ build, the version bump, and tag/release creation, and just re-signs the
|
|||||||
existing F-Droid index with the configured repo key and re-uploads. Use this
|
existing F-Droid index with the configured repo key and re-uploads. Use this
|
||||||
for key rotation or repo recovery without publishing a new app version.
|
for key rotation or repo recovery without publishing a new app version.
|
||||||
|
|
||||||
|
## Two forges, one repo
|
||||||
|
|
||||||
|
**Codeberg (`jlmakiola/calendula`) is canonical** — git, issues, PRs, tags and
|
||||||
|
releases. The self-hosted Gitea instance is build infrastructure: it holds the
|
||||||
|
signing key, publishes the F-Droid repo, and runs the release pipeline. Codeberg
|
||||||
|
push-mirrors `main` and tags to Gitea, and a bumped `versionName` arriving there
|
||||||
|
triggers `release.yaml` exactly as before.
|
||||||
|
|
||||||
|
Workflows are separated by **directory**, not by conditionals. Forgejo looks in
|
||||||
|
`.forgejo/workflows` → `.gitea/workflows` → `.github/workflows` and stops at the
|
||||||
|
first that exists; Gitea doesn't know `.forgejo/` at all:
|
||||||
|
|
||||||
|
| Directory | Runs on | Contains | Secrets |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `.forgejo/workflows/` | Codeberg | `ci.yaml`, `translations.yaml` | **none** |
|
||||||
|
| `.gitea/workflows/` | Gitea | `release.yaml`, `renovate.yml` | signing key, F-Droid, Play, bot tokens |
|
||||||
|
|
||||||
|
The line is drawn at **secrets, not at CI-vs-release**. That's what makes fork
|
||||||
|
PRs safe: everything a contributor can trigger lives in `.forgejo/` and can
|
||||||
|
reference no secret. Renovate stays on the Gitea runner *even though it opens
|
||||||
|
PRs on Codeberg* — it talks to Codeberg's API rather than moving its token onto
|
||||||
|
the contributor-facing runner.
|
||||||
|
|
||||||
|
Two consequences worth remembering:
|
||||||
|
|
||||||
|
- **`detect` reads tags from Codeberg**, not from the Gitea instance it runs on.
|
||||||
|
Push mirroring is `git push --mirror`, so a tag minted on Gitea is deleted by
|
||||||
|
the next sync until the Codeberg tag push propagates back. Asking Gitea inside
|
||||||
|
that window would re-cut a shipped release.
|
||||||
|
- **Any ref that exists only on Gitea gets deleted** by the mirror. That's
|
||||||
|
correct under Codeberg-canonical, but don't debug a "vanished" branch without
|
||||||
|
remembering it.
|
||||||
|
|
||||||
## Secrets (Gitea → repo Settings → Actions → Secrets)
|
## Secrets (Gitea → repo Settings → Actions → Secrets)
|
||||||
|
|
||||||
| Secret | Purpose |
|
| Secret | Purpose |
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ Categories:
|
|||||||
- Calendar & Agenda
|
- Calendar & Agenda
|
||||||
License: MIT
|
License: MIT
|
||||||
AuthorName: Jean-Luc Makiola
|
AuthorName: Jean-Luc Makiola
|
||||||
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
|
SourceCode: https://codeberg.org/jlmakiola/calendula
|
||||||
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
|
IssueTracker: https://codeberg.org/jlmakiola/calendula/issues
|
||||||
Changelog: https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/CHANGELOG.md
|
Changelog: https://codeberg.org/jlmakiola/calendula/src/branch/main/CHANGELOG.md
|
||||||
Donate: https://ko-fi.com/jeanlucmakiola
|
Donate: https://ko-fi.com/jeanlucmakiola
|
||||||
|
|
||||||
AutoName: Calendula
|
AutoName: Calendula
|
||||||
|
|||||||
@@ -6,6 +6,6 @@ Summary: A modern Material 3 Expressive calendar for Android.
|
|||||||
Categories:
|
Categories:
|
||||||
- Time
|
- Time
|
||||||
|
|
||||||
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
|
SourceCode: https://codeberg.org/jlmakiola/calendula
|
||||||
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
|
IssueTracker: https://codeberg.org/jlmakiola/calendula/issues
|
||||||
Donate: https://ko-fi.com/jeanlucmakiola
|
Donate: https://ko-fi.com/jeanlucmakiola
|
||||||
|
|||||||
@@ -7,6 +7,23 @@
|
|||||||
":semanticCommits",
|
":semanticCommits",
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// `config:recommended` brings in mergeConfidence:age-confidence-badges, whose
|
||||||
|
// Age column is a Mend badge. Mend's Merge Confidence index only covers Maven
|
||||||
|
// Central: org.jetbrains.kotlin, junit, truth, turbine et al resolve, but
|
||||||
|
// every androidx/compose artifact lives on Google's Maven repo and comes back
|
||||||
|
// as a grey UNKNOWN — i.e. most of this project. Renovate already knows the
|
||||||
|
// real answer, since it derives release timestamps itself for the
|
||||||
|
// minimumReleaseAge rules below (Google Maven serves `last-modified` on its
|
||||||
|
// POMs), so take the age from there and leave Mend to the Confidence column,
|
||||||
|
// which still carries signal for the Maven Central half.
|
||||||
|
prBodyDefinitions: {
|
||||||
|
Age: "{{#if releaseTimestamp}}{{{newVersionAgeInDays}}} d{{else}}unknown{{/if}}",
|
||||||
|
},
|
||||||
|
// Default heading links to the Merge Confidence docs; this column is ours now.
|
||||||
|
prBodyHeadingDefinitions: {
|
||||||
|
Age: "Age",
|
||||||
|
},
|
||||||
|
|
||||||
// No automerge: a dependency bump goes through the same review (and, for
|
// No automerge: a dependency bump goes through the same review (and, for
|
||||||
// anything touching the build, the same on-device check) as a feature
|
// anything touching the build, the same on-device check) as a feature
|
||||||
// before it can ride a release — see docs/RELEASING.md and the
|
// before it can ride a release — see docs/RELEASING.md and the
|
||||||
@@ -15,6 +32,22 @@
|
|||||||
|
|
||||||
// One reviewable surface; the dashboard issue lists everything pending.
|
// One reviewable surface; the dashboard issue lists everything pending.
|
||||||
dependencyDashboard: true,
|
dependencyDashboard: true,
|
||||||
|
|
||||||
|
// The cooling-off periods below are advisory, not a gate. "flexible" still
|
||||||
|
// prefers a version that has cleared its window, but when every candidate is
|
||||||
|
// too young it opens the PR at the newest one anyway, so merging early stays
|
||||||
|
// a judgement call. (The default, "strict", would suppress the PR entirely
|
||||||
|
// until a release aged in.) A still-young branch carries a yellow
|
||||||
|
// `renovate/stability-days` check so it's visible which side of the line
|
||||||
|
// it's on; with automerge off, nothing acts on that check by itself.
|
||||||
|
//
|
||||||
|
// NOT "none": that short-circuits the candidate loop in filter-checks.ts, and
|
||||||
|
// that loop is what calls postprocessRelease — the only thing that fetches a
|
||||||
|
// Maven artifact's Last-Modified header. Skipping it leaves releaseTimestamp
|
||||||
|
// unset, which empties the Age column and quietly makes minimumReleaseAge and
|
||||||
|
// the stability check no-ops, since both need that timestamp to compare.
|
||||||
|
internalChecksFilter: "flexible",
|
||||||
|
|
||||||
labels: ["dependencies"],
|
labels: ["dependencies"],
|
||||||
prConcurrentLimit: 5,
|
prConcurrentLimit: 5,
|
||||||
prHourlyLimit: 0,
|
prHourlyLimit: 0,
|
||||||
@@ -25,11 +58,28 @@
|
|||||||
|
|
||||||
// Gitea Actions workflows live under .gitea/workflows, not .github — extend
|
// Gitea Actions workflows live under .gitea/workflows, not .github — extend
|
||||||
// the github-actions manager (same syntax) to watch them too.
|
// the github-actions manager (same syntax) to watch them too.
|
||||||
|
// `fileMatch` is deprecated; the replacement takes the regex delimited, and
|
||||||
|
// Renovate's config migration was already rewriting this on every run.
|
||||||
"github-actions": {
|
"github-actions": {
|
||||||
fileMatch: ["^\\.gitea/workflows/[^/]+\\.ya?ml$"],
|
managerFilePatterns: ["/^\\.gitea/workflows/[^/]+\\.ya?ml$/"],
|
||||||
},
|
},
|
||||||
|
|
||||||
packageRules: [
|
packageRules: [
|
||||||
|
// Cooling-off period, scaled by blast radius: how long a release should
|
||||||
|
// have been out (and un-yanked, un-hotfixed) before it's considered
|
||||||
|
// settled. Advisory only — see `internalChecksFilter` above.
|
||||||
|
{
|
||||||
|
matchUpdateTypes: ["major"],
|
||||||
|
minimumReleaseAge: "30 days",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matchUpdateTypes: ["minor"],
|
||||||
|
minimumReleaseAge: "20 days",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matchUpdateTypes: ["patch", "digest", "pin", "rollback"],
|
||||||
|
minimumReleaseAge: "10 days",
|
||||||
|
},
|
||||||
// material3 is deliberately pinned to the 1.5 *alpha* line for the
|
// material3 is deliberately pinned to the 1.5 *alpha* line for the
|
||||||
// Expressive APIs (see gradle/libs.versions.toml). Follow the alpha train
|
// Expressive APIs (see gradle/libs.versions.toml). Follow the alpha train
|
||||||
// but keep it in its own PR, reviewed in isolation; revisit the pin when
|
// but keep it in its own PR, reviewed in isolation; revisit the pin when
|
||||||
@@ -52,5 +102,18 @@
|
|||||||
],
|
],
|
||||||
groupName: "test dependencies",
|
groupName: "test dependencies",
|
||||||
},
|
},
|
||||||
|
// Last word on the PR table. The merge-confidence preset sets prBodyColumns
|
||||||
|
// from inside a packageRule of its own, and only for the datasources Mend
|
||||||
|
// supports — so a plain top-level prBodyColumns would lose to it for maven
|
||||||
|
// deps, and the Gradle wrapper / Actions / container bumps would keep the
|
||||||
|
// default columns and show no age at all. A rule declared after it wins,
|
||||||
|
// and gives every PR the same table.
|
||||||
|
// "Pending" earns its place under a flexible filter: when the bump lands on
|
||||||
|
// a version that has cleared its window but a newer one hasn't, that newer
|
||||||
|
// version is named here rather than silently withheld.
|
||||||
|
{
|
||||||
|
matchPackageNames: ["*"],
|
||||||
|
prBodyColumns: ["Package", "Type", "Change", "Age", "Pending", "Confidence"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user