Compare commits

..

12 Commits

Author SHA1 Message Date
8f87dc3564 ci(fdroid): fix invalid YAML in the submodule-aware checkout steps
The earlier submodule wiring left a mis-indented `with:` / `submodules:
recursive` block under `uses: actions/checkout@v4` (and, in ci.yaml, a second
stray `with:`), which made both workflows fail to parse
("mapping values are not allowed here"). Merge submodules into the single
correctly-indented `with:` block so CI actually checks out floret-kit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 21:07:06 +02:00
f9bf9db141 refactor(settings): draw the About card from floret-kit SettingsRecipes
Calendula's bespoke About card was the floret-kit components.AboutCard recipe in
disguise — logo + name/author, a row of equal-width outlined links, and a
full-width tonal highlight link. Replace the hand-rolled layout with the kit
recipe, supplying Calendula's logo (AppLogo, app-local) and its source
(ic_gitea, via vectorResource) / licence / support links as AboutLinks. Drop the
now-unused button/icon imports.

Calendula's language picker keeps its app-local form (it uses a custom header
the generic LanguagePickerRow recipe doesn't carry).

Compile + unit tests + lintDebug green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 21:05:01 +02:00
78fe7676a2 refactor(onboarding): draw the onboarding shell from floret-kit components
OnboardingScaffold, OnboardingSpace and BenefitRow were identical to the kit's
components forms, so delete the app-local copies and repoint the permission and
reminder onboarding screens at floret-kit. BrandHero — Calendula's own launcher
mark with the permission-denied lock badge — stays app-local in its own file
(each sibling app keeps its own hero).

Compile + unit tests + lintDebug green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 21:01:01 +02:00
3c323ec4c4 refactor(ui): draw the grouped-list core + pickers from floret-kit components
With the kit grown to carry Calendula's richer variants (previous floret-kit
commit), move the last app-local UI-core onto the shared components module and
bump the submodule:

- Delete the app-local GroupedList.kt (Position/positionOf/GroupedRow/
  CollapsingScaffold) and Picker.kt's FullScreenPicker/OptionPicker — all now
  come from floret-kit. Repoint every screen's imports.
- The features that kept these local are preserved through the kit's new
  parameters: GroupedRow dimmed/container; CollapsingScaffold actions/snackbar;
  and predictiveBack, passed true at the settings hub/sub-screens, the calendars
  manager, and all full-screen pickers (the kit defaults it off, so it's opt-in).
- FullScreenPicker's keyboard fix (SOFT_INPUT_ADJUST_NOTHING) and OptionPicker's
  header slot now live in the kit too.
- Add a localized `back` string (the kit scaffold's back-button label).

Bumps floret-kit 86f4480 -> d9e4e87. Compile + unit tests + lintDebug +
reproducible-release guard all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:57:59 +02:00
c472394d26 refactor(color): draw pastelize from floret-kit components
Calendula's pastelize (raw provider colour -> theme-fitting pastel) was
byte-identical to the kit's components.pastelize, so delete the app-local copy
and repoint its ~13 call sites (views, widgets, swatch row, calendar chip) at
the shared one. CalendarColorChip — the calendar-glyph avatar — stays local and
now tints from the kit pastelize.

Compile + unit tests + lintDebug green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:32:34 +02:00
11a54cd519 refactor(ui): draw InlineTextField/OptionCard/DialogControls from floret-kit components
Adopt the floret-kit components module for the three widgets whose kit form is
identical to Calendula's, deleting the app-local copies and repointing imports.

One behavioural guard: the kit's InlineTextField defaults capitalization to
Sentences (the family default), where Calendula's defaulted to None. The three
Calendula call sites that relied on the old default now pass
KeyboardCapitalization.None explicitly — notably the guest **email** field,
which must not auto-capitalize — so behaviour is unchanged.

The richer app-local primitives (GroupedRow with dimmed/container,
CollapsingScaffold with actions/snackbar/predictive-back) stay local for now:
they're supersets of the kit's versions, so consuming the kit forms would drop
features. Promoting those into the kit is a follow-up.

Compile + unit tests + lintDebug green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:29:57 +02:00
94c9fc845e refactor(theme,motion): draw identity (theme + content transitions) from floret-kit
Adopt floret-kit's identity module, shared with Agendula:

- CalendulaTheme becomes a thin wrapper over the kit's FloretExpressiveTheme
  (dynamic colour, system light/dark, standard motion scheme). Calendula keeps
  its identity locally: the seed-derived CalendulaLightFallback/DarkFallback and
  CalendulaTypography. No call-site changes — CalendulaTheme keeps its name.
- The generic content transitions move to identity: calendarExpandEnter ->
  expandEnter, calendarCollapseExit -> collapseExit, calendarItemEnter ->
  itemEnter, calendarAnimateItem -> animateItemMotion, calendarFadeThrough ->
  fadeThrough, plus Modifier.predictiveBack and rememberReduceMotion (same
  names). Call sites across the screens repointed to floret.identity.
- CalendarTransitions.kt keeps only what's genuinely calendar-specific: the
  directional month/week/day slide (calendarSlideTransition) and its specs
  (rememberCalendarSlideSpec / rememberCalendarFadeSpec).

Compile + unit tests + lintDebug green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:22:35 +02:00
7a04eff471 refactor(reminders): draw the reminder-override model from floret-kit
Replace Calendula's app-local reminder-override plumbing with floret-kit's
core-reminders, shared with Agendula:

- CalendarReminderOverride (Inherit/None/Minutes) -> kit ReminderOverride, with
  the kit's applyReminderOverride() map extension.
- The hand-rolled parse/serialize of the per-calendar override string -> kit
  ReminderOverrideCodec, configured with Calendula's on-disk dialect
  (id=minutes entries joined by ;, "none" sentinel). Format is unchanged, so
  stored prefs round-trip identically (SettingsPrefsTest still green).
- ReminderUnit enum + the custom-amount decomposition (decomposeReminder) ->
  kit ReminderUnit + decomposeReminderMinutes/ReminderAmount.

App-specific glue stays put: resolveDefaultReminder (timed vs all-day),
reminderLeadTimeLabel / reminderUnitLabel (app string resources), and the
global-default picker helpers.

Compile + unit tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:59:06 +02:00
763bb915f4 refactor(crash): draw crash capture + report UI from floret-kit core-crash
Replace the app-local crash machinery (CrashReporter, CrashReportDialog,
CrashReportSubmit) with floret-kit's core-crash. The kit version is the same
privacy-respecting, on-device, no-network design (clipboard + prefilled issue
URL via ACTION_VIEW), parameterised by a CrashConfig: app label + the
issue-tracker URLs, supplied once at install() in CalendulaApp.

- CrashReportActivity stays app-local (thin standalone surface) but now imports
  the kit's CrashReporter/CrashReportDialog/submitCrashReport.
- buildCrashReport moved to the kit and gained an appLabel parameter; the
  privacy-allowlist test is repointed to the kit API (still pins Calendula's
  exact six-line header — no identifiers, no calendar content).
- Calendula keeps its own crash strings (incl. German + "calendar content"
  wording) as resource overrides, converted to the kit's %1$s app-label /
  two-arg body-template arity so the shared code formats them correctly.

Compile + unit tests + lintDebug green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:51:11 +02:00
926600ed3d refactor(locale): draw per-app language from floret-kit core-locale
Replace the app-local AppLanguage with the shared
de.jeanlucmakiola.floret.locale.AppLanguage. The kit version is identical
except supportedTags() takes the locales_config XML res id as a parameter
(the kit can't reference the app's R), so the call site now passes
R.xml.locales_config. Wire the core-locale module into the app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:43:43 +02:00
71a08af514 chore(floret-kit): bump submodule to kit main (all six modules)
Phase 2 of the shared-kit plan: point Calendula's floret-kit submodule at
kit main (86f4480), which carries the full module set the sibling app
(Agendula) already consumes — identity, components, core-crash, core-locale,
core-reminders — on top of core-time.

No app wiring yet; only core-time is depended on so far, so this is a pure
pointer bump. Composite build still configures cleanly (the new Android
library modules configure but aren't built until consumed) and the
reproducible-release guard holds (kit main is a descendant of the previous
foojay-free pin; no toolchain resolver reintroduced).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:42:03 +02:00
e726db9606 Draw core-time from floret-kit (TimeBridge) via submodule + composite build
Phase 1 of the shared-kit plan: Calendula becomes the second core-time consumer.
floret-kit is embedded as a git submodule and wired with includeBuild, so the
app depends on de.jeanlucmakiola.floret:core-time and Gradle substitutes the
local source module (built from source).

- Remove the app's TimeBridge (Instant <-> epoch millis) + its test; the helpers
  now live in core-time. Repoint the 7 call sites to de.jeanlucmakiola.floret.time.
- Calendula's richer time formatting (TimeFormat, 24h CompositionLocal) stays
  app-local by design.

Reproducible-build handling (Calendula publishes via official F-Droid repro):
- Pin the submodule to a foojay-free kit commit (the resolver would let a build
  fetch a JDK -> non-reproducible; the kit never used a toolchain block).
- Harden scripts/check_reproducible_release.sh to scan the included build's
  Gradle scripts for the toolchain-resolver invariant.
- Draft fdroiddata recipe: submodules: true (else F-Droid checks out an empty
  floret-kit/ and the from-source build fails).
- CI build checkouts fetch submodules recursively.

Clean composite build + unit tests + lintDebug + repro guard all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:47:40 +02:00
74 changed files with 425 additions and 2260 deletions

View File

@@ -29,6 +29,7 @@ jobs:
with:
# Full history so the base..HEAD diff below has a merge-base.
fetch-depth: 0
submodules: recursive
# Cheap, always-on guard: the release build must stay reproducible for the
# official F-Droid repo (no AGP VCS-info embedding). Runs regardless of

View File

@@ -33,6 +33,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Resolve version and whether it is a new release
id: v
@@ -82,6 +84,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Java
uses: actions/setup-java@v4

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "floret-kit"]
path = floret-kit
url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git

View File

@@ -5,46 +5,6 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.12.0] — 2026-06-28
### Added
- See past events your way. Two new settings change how events that have already
ended are shown. **Past events** (Settings → Appearance → Agenda) lets the
agenda — both in the app and the home-screen "Upcoming" widget — keep them as
usual, dim them, or hide them from the list entirely. **Dim completed events**
(Settings → Appearance) separately fades finished events in the month and week
views. Both are off by default, an event only counts as finished once it has
actually ended (events still in progress are never dimmed), and the lists
update on their own as the day goes on. Thanks to @ptab for the suggestion
([#12]).
### Changed
- Readable titles on overlapping events. In the week and day views, events that
overlap split a day into slim columns where the title used to be clipped to a
character or two. The title now wraps across as many lines as the block can
fit, and the time is hidden on those narrow blocks so the name can fill the
space — so you can tell events apart without opening each one. Thanks to @ptab
for the suggestion ([#13]).
## [2.11.2] — 2026-06-28
### Added
- Jump straight to typing a title. When you start a new event, Calendula now puts
the cursor in the title field and opens the keyboard right away, so you can type
the name without an extra tap. It's on by default and only affects creating an
event — editing never grabs focus — and a new **Focus title on new event**
switch (Settings → New event form) turns it off. Thanks to @abrossimow for the
suggestion ([#10]).
## [2.11.1] — 2026-06-28
### Fixed
- Calendula can now be set as your default calendar app. It registers the
calendar-app intent filters the system uses, so it appears in the chooser when
you tap a date in a launcher or clock — and opening one takes you straight to
that day. Android has no way for an app to make itself the default, so you pick
it once from the system picker. Thanks to @abrossimow for the report ([#9]).
## [2.11.0] — 2026-06-27
### Added
@@ -725,7 +685,3 @@ automatically, with zero telemetry and no internet permission.
[#6]: https://codeberg.org/jlmakiola/calendula/issues/6
[#7]: https://codeberg.org/jlmakiola/calendula/issues/7
[#8]: https://codeberg.org/jlmakiola/calendula/issues/8
[#9]: https://codeberg.org/jlmakiola/calendula/issues/9
[#10]: https://codeberg.org/jlmakiola/calendula/issues/10
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13

View File

@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 21200
versionName = "2.12.0"
versionCode = 21100
versionName = "2.11.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -159,6 +159,12 @@ dependencies {
implementation(libs.androidx.glance.material3)
implementation(libs.kotlinx.datetime)
implementation("de.jeanlucmakiola.floret:core-time")
implementation("de.jeanlucmakiola.floret:core-locale")
implementation("de.jeanlucmakiola.floret:core-crash")
implementation("de.jeanlucmakiola.floret:core-reminders")
implementation("de.jeanlucmakiola.floret:identity")
implementation("de.jeanlucmakiola.floret:components")
implementation(libs.kotlinx.coroutines.core)
debugImplementation(libs.androidx.ui.tooling)

View File

@@ -59,35 +59,6 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Be selectable as the system calendar app. Android has no API for
an app to make itself the default, so registering the filters
launchers and the OS use is what lets the user pick Calendula
from the system chooser when a date action fires (issue #9).
APP_CALENDAR is the "open the calendar app" action; the VIEW
filters below catch a tapped date. -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.APP_CALENDAR" />
</intent-filter>
<!-- A launcher/clock date tap fires ACTION_VIEW on the provider's
time Uri (content://com.android.calendar/time/<epochMillis>);
some surfaces use the time/epoch mime type. We open the day view
on that date (MainActivity.calendarTimeDateOrNull). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data
android:scheme="content"
android:host="com.android.calendar"
android:pathPrefix="/time" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="time/epoch" />
</intent-filter>
<!-- Open a .ics file (file manager / email attachment / browser). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />

View File

@@ -5,7 +5,8 @@ import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -22,8 +23,18 @@ class CalendulaApp : Application() {
override fun onCreate() {
super.onCreate()
// Install first thing so startup crashes are captured too (privacy-
// respecting, on-device; the user submits the report by hand).
CrashReporter.install(this)
// respecting, on-device; the user submits the report by hand). The
// capture/loop-detection/report machinery lives in floret-kit's
// core-crash; only the app label + issue-tracker URLs are app-specific.
CrashReporter.install(
this,
CrashConfig(
appLabel = getString(R.string.app_name),
newIssueUrl = getString(R.string.report_issue_url),
chooseIssueUrl = getString(R.string.report_issue_choose_url),
issueTitle = getString(R.string.crash_report_issue_title),
),
)
reconcileAutoBackup()
}

View File

@@ -21,7 +21,6 @@ import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.ui.RootScreen
@@ -30,14 +29,12 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.submitCrashReport
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@@ -159,33 +156,10 @@ class MainActivity : AppCompatActivity() {
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
else -> null
} ?: return null
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
// it's a navigation, not a file to import, so [navRequestOrNull] owns it.
if (uri.host == CALENDAR_PROVIDER_HOST) return null
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
}
/**
* The date a launcher/clock date tap points at, parsed from the AOSP calendar
* "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/
* <epochMillis>`. Null for any other intent. The matching manifest filter is
* what lets users pick Calendula from the system calendar chooser (issue #9).
*/
private fun Intent.calendarTimeDateOrNull(): LocalDate? {
if (action != Intent.ACTION_VIEW) return null
val uri = data ?: return null
if (uri.host != CALENDAR_PROVIDER_HOST) return null
val segments = uri.pathSegments
if (segments.firstOrNull() != "time") return null
val millis = segments.getOrNull(1)?.toLongOrNull() ?: return null
return Instant.fromEpochMilliseconds(millis)
.toLocalDateTime(TimeZone.currentSystemDefault()).date
}
private fun Intent.navRequestOrNull(): WidgetNavRequest? {
// An external date tap (launcher/clock) has no widget source, so it opens
// the day view rooted over the default home view (OpenDate source = null).
calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) }
val source = sourceViewOrNull()
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
return when {
@@ -227,10 +201,6 @@ class MainActivity : AppCompatActivity() {
}
companion object {
// The calendar provider's authority/host. A date tap arrives as
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"

View File

@@ -1,5 +1,7 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.time.toEpochMillis
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import android.Manifest
import android.content.ContentResolver
import android.content.ContentUris

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.time.toEpochMillis
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import android.provider.CalendarContract
import android.util.Log
import de.jeanlucmakiola.calendula.domain.AccessLevel

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import android.util.Log
import de.jeanlucmakiola.calendula.domain.EventInstance

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis

View File

@@ -1,7 +0,0 @@
package de.jeanlucmakiola.calendula.data.calendar
import kotlin.time.Instant
fun Long.toKotlinInstantFromEpochMillis(): Instant = Instant.fromEpochMilliseconds(this)
fun Instant.toEpochMillis(): Long = toEpochMilliseconds()

View File

@@ -1,188 +0,0 @@
package de.jeanlucmakiola.calendula.data.crash
import android.content.Context
import android.content.pm.PackageInfo
import android.os.Build
import androidx.core.content.pm.PackageInfoCompat
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
/**
* Privacy-respecting crash capture (prod-readiness item 10). On an uncaught
* exception it writes a self-contained report to the app's private storage and
* then chains to the platform's default handler, so the process still dies
* normally (and the OS shows its own "stopped" dialog). Nothing is uploaded —
* the app holds no `INTERNET` permission. The user submits the report later,
* by hand, as a Gitea issue (see the ui/crash surfaces).
*
* The report is built from a fixed [CrashContext] allowlist — app/Android/device
* version, locale, time, and the stack trace — and **nothing else**: no device
* identifiers, no account names, no calendar/event content, no logcat. The user
* is always shown the full text before it leaves the device.
*/
object CrashReporter {
/**
* Install the handler. Called first thing in `CalendulaApp.onCreate()` so it
* also catches crashes during startup. The handler swallows nothing — it
* persists, then delegates to the previously-registered handler.
*/
fun install(context: Context) {
val appContext = context.applicationContext
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
// Capturing must never mask the original crash, so guard every step.
runCatching {
val now = System.currentTimeMillis()
writeReport(appContext, buildCrashReport(CrashContext.from(appContext), throwable, now))
recordCrashTime(appContext, now)
}
previous?.uncaughtException(thread, throwable)
}
}
/** The persisted report from the last crash, or null if there is none. */
fun pendingReport(context: Context): String? {
val file = reportFile(context)
return if (file.exists()) runCatching { file.readText() }.getOrNull()?.takeIf { it.isNotBlank() } else null
}
/**
* Whether to surface the report unprompted (on the next launch): a report
* exists and the user hasn't already waved this one away. Settings reaches
* the report via [pendingReport] regardless, so "Not now" only stops the
* auto-prompt — it doesn't discard the report.
*/
fun shouldPrompt(context: Context): Boolean =
reportFile(context).exists() && !dismissedFile(context).exists()
/** Stop auto-prompting for the current report without discarding it. */
fun dismissPrompt(context: Context) {
runCatching { dismissedFile(context).apply { parentFile?.mkdirs() }.writeText("") }
}
/** Drop the persisted report once the user has reported it (or from Settings). */
fun clearReport(context: Context) {
runCatching { reportFile(context).delete() }
runCatching { dismissedFile(context).delete() }
}
/**
* Whether the app appears to be in a startup crash-loop: at least
* [LOOP_THRESHOLD] crashes inside [LOOP_WINDOW_MS]. In that case the main UI
* can't be trusted to start, so the caller routes straight to the standalone
* report screen instead of re-entering the crashing graph.
*/
fun isCrashLoop(context: Context): Boolean {
val times = readCrashTimes(context)
if (times.size < LOOP_THRESHOLD) return false
val recent = times.sortedDescending()
return recent[0] - recent[LOOP_THRESHOLD - 1] <= LOOP_WINDOW_MS
}
/**
* Mark the app as having started successfully, resetting the loop counter so
* an ordinary single crash much later never trips loop detection. The
* pending report itself is kept — only the timing trail is cleared.
*/
fun markHealthy(context: Context) {
runCatching { timesFile(context).delete() }
}
// --- persistence -------------------------------------------------------
private fun writeReport(context: Context, report: String) {
val file = reportFile(context).apply { parentFile?.mkdirs() }
file.writeText(report.take(MAX_REPORT_CHARS))
// A fresh crash should prompt again, even if the previous one was waved away.
runCatching { dismissedFile(context).delete() }
}
private fun recordCrashTime(context: Context, nowMillis: Long) {
val kept = (readCrashTimes(context) + nowMillis).takeLast(MAX_TIMES)
timesFile(context).apply { parentFile?.mkdirs() }
.writeText(kept.joinToString("\n"))
}
private fun readCrashTimes(context: Context): List<Long> {
val file = timesFile(context)
if (!file.exists()) return emptyList()
return runCatching { file.readLines().mapNotNull { it.trim().toLongOrNull() } }.getOrDefault(emptyList())
}
private fun crashDir(context: Context) = File(context.filesDir, CRASH_DIR)
private fun reportFile(context: Context) = File(crashDir(context), REPORT_FILE)
private fun timesFile(context: Context) = File(crashDir(context), TIMES_FILE)
private fun dismissedFile(context: Context) = File(crashDir(context), DISMISSED_FILE)
private const val CRASH_DIR = "crash"
private const val REPORT_FILE = "last_crash.txt"
private const val TIMES_FILE = "crash_times.txt"
private const val DISMISSED_FILE = "dismissed"
private const val MAX_TIMES = 5
private const val MAX_REPORT_CHARS = 64 * 1024
private const val LOOP_THRESHOLD = 2
private const val LOOP_WINDOW_MS = 10_000L
}
/**
* The allowlist of non-personal facts that go into a crash report. Built from
* [Build] and the app's own [PackageInfo]; deliberately holds no identifiers.
*/
data class CrashContext(
val appVersionName: String,
val appVersionCode: Long,
val sdkInt: Int,
val androidRelease: String,
val manufacturer: String,
val model: String,
val locale: String,
) {
companion object {
fun from(context: Context): CrashContext {
val pkg = runCatching {
context.packageManager.getPackageInfo(context.packageName, 0)
}.getOrNull()
return CrashContext(
appVersionName = pkg?.versionName ?: "?",
appVersionCode = pkg?.let { PackageInfoCompat.getLongVersionCode(it) } ?: 0L,
sdkInt = Build.VERSION.SDK_INT,
androidRelease = Build.VERSION.RELEASE ?: "?",
manufacturer = Build.MANUFACTURER ?: "?",
model = Build.MODEL ?: "?",
locale = Locale.getDefault().toLanguageTag(),
)
}
}
}
/**
* Render a crash report from the [ctx] allowlist, the [throwable]'s full stack
* trace, and the crash [nowMillis]. Pure (no Android, no I/O) so it is unit
* tested. The leading marker doubles as the file's sanity check in
* [CrashReporter.pendingReport].
*/
fun buildCrashReport(ctx: CrashContext, throwable: Throwable, nowMillis: Long): String {
val trace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
val time = runCatching {
Instant.ofEpochMilli(nowMillis).atZone(ZoneId.systemDefault()).format(TIME_FORMAT)
}.getOrDefault(nowMillis.toString())
return buildString {
appendLine("Calendula crash report")
appendLine("App version: ${ctx.appVersionName} (${ctx.appVersionCode})")
appendLine("Android: ${ctx.androidRelease} (API ${ctx.sdkInt})")
appendLine("Device: ${ctx.manufacturer} ${ctx.model}")
appendLine("Locale: ${ctx.locale}")
appendLine("Time: $time")
appendLine()
appendLine("Stack trace:")
append(trace)
}
}
private val TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")

View File

@@ -12,6 +12,9 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.datetime.DayOfWeek
@@ -38,19 +41,6 @@ sealed interface WeekStartPref {
*/
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
/**
* How the Agenda screen treats events that have already finished today: [SHOW]
* leaves them as-is (the historical behaviour), [DIM] fades them, [HIDE] drops
* them from the list entirely.
*/
enum class PastEventDisplay { SHOW, DIM, HIDE }
/** Parse a stored [PastEventDisplay.name]; unknown/null falls back to [default]. */
fun parsePastEventDisplay(
stored: String?,
default: PastEventDisplay = PastEventDisplay.SHOW,
): PastEventDisplay = PastEventDisplay.entries.firstOrNull { it.name == stored } ?: default
/**
* Resolve to a concrete 24-hour flag. AUTO defers to [systemIs24Hour] (the
* device's `DateFormat.is24HourFormat` value).
@@ -129,31 +119,6 @@ class SettingsPrefs @Inject constructor(
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
}
/**
* How the Agenda screen treats events that already ended today. Defaults to
* [PastEventDisplay.SHOW] — the historical behaviour; users opt into dimming
* or hiding.
*/
val pastEventDisplay: Flow<PastEventDisplay> = store.data.map { prefs ->
prefs[PAST_EVENT_DISPLAY_KEY].toEnum(PastEventDisplay.SHOW)
}
suspend fun setPastEventDisplay(mode: PastEventDisplay) {
store.edit { it[PAST_EVENT_DISPLAY_KEY] = mode.name }
}
/**
* Whether the month/week grids fade events that have already finished.
* Defaults to OFF — independent of the Agenda's [pastEventDisplay].
*/
val dimCompletedEvents: Flow<Boolean> = store.data.map { prefs ->
prefs[DIM_COMPLETED_EVENTS_KEY] ?: false
}
suspend fun setDimCompletedEvents(enabled: Boolean) {
store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled }
}
/**
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
* [AgendaRange.Month] — a month of upcoming events. Independent of the
@@ -220,20 +185,6 @@ class SettingsPrefs @Inject constructor(
}
}
/**
* Whether opening the new-event form focuses the title field and raises the
* keyboard straight away (issue #10). Default ON — a new event almost always
* gets a title, so this saves a tap; users who set the time/calendar first
* can turn it off. Only the create form auto-focuses — editing never does.
*/
val autofocusEventTitle: Flow<Boolean> = store.data.map { prefs ->
prefs[AUTOFOCUS_EVENT_TITLE_KEY] ?: true
}
suspend fun setAutofocusEventTitle(enabled: Boolean) {
store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled }
}
/**
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
* for users whose only calendar app this is, reminders are essential; the
@@ -348,14 +299,14 @@ class SettingsPrefs @Inject constructor(
* [defaultAllDayReminderMinutes].)
*/
val perCalendarReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
reminderOverrideCodec.parse(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
}
suspend fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
suspend fun setCalendarReminderOverride(calendarId: Long, override: ReminderOverride) {
store.edit { prefs ->
val current = parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
current.applyOverride(calendarId, override)
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
val current = reminderOverrideCodec.parse(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
current.applyReminderOverride(calendarId, override)
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = reminderOverrideCodec.serialize(current)
}
}
@@ -365,18 +316,18 @@ class SettingsPrefs @Inject constructor(
* inherit the global all-day default; present null = no reminder).
*/
val perCalendarAllDayReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
reminderOverrideCodec.parse(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
}
suspend fun setCalendarAllDayReminderOverride(
calendarId: Long,
override: CalendarReminderOverride,
override: ReminderOverride,
) {
store.edit { prefs ->
val current =
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
current.applyOverride(calendarId, override)
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
reminderOverrideCodec.parse(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
current.applyReminderOverride(calendarId, override)
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = reminderOverrideCodec.serialize(current)
}
}
@@ -442,11 +393,8 @@ class SettingsPrefs @Inject constructor(
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
@@ -489,16 +437,6 @@ data class BackupStatus(
val consecutiveFailures: Int,
)
/** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */
sealed interface CalendarReminderOverride {
/** No override — the calendar uses the global default. */
data object Inherit : CalendarReminderOverride
/** Explicit "no reminder" for this calendar, regardless of the global default. */
data object None : CalendarReminderOverride
/** A specific lead time in minutes before the event start. */
data class Minutes(val minutes: Int) : CalendarReminderOverride
}
/**
* The lead time to prefill on a new event: the matching per-calendar override
* if [calendarId] has one for this event kind, otherwise the global default for
@@ -523,18 +461,6 @@ fun resolveDefaultReminder(
}
}
/** Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the key). */
private fun MutableMap<Long, Int?>.applyOverride(
calendarId: Long,
override: CalendarReminderOverride,
) {
when (override) {
CalendarReminderOverride.Inherit -> remove(calendarId)
CalendarReminderOverride.None -> put(calendarId, null)
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes)
}
}
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
private const val WEEK_START_AUTO = "AUTO"
@@ -555,26 +481,18 @@ private fun parseWeekStart(stored: String?): WeekStartPref = when (stored) {
}
private const val NONE = "none"
private const val ENTRY_SEP = ";"
private const val KEY_VALUE_SEP = "="
private fun String?.toReminderMinutes(): Int? = when (this) {
null, "", NONE -> null
else -> toIntOrNull()
}
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> {
if (stored.isNullOrBlank()) return emptyMap()
return stored.split(ENTRY_SEP).mapNotNull { entry ->
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
val value = if (parts[1] == NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null
id to value
}.toMap()
}
private fun serializeReminderOverrides(map: Map<Long, Int?>): String =
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" }
/**
* Calendula's dialect of the shared per-target reminder-override codec: entries
* `id=minutes` joined by `;`, with `none` for an explicit no-reminder. These
* separators are the on-disk format and must not change without a migration.
*/
private val reminderOverrideCodec = ReminderOverrideCodec(entrySep = ";", keyValueSep = "=", noneToken = NONE)
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default

View File

@@ -40,14 +40,6 @@ data class EventInstance(
val location: String?,
)
/**
* Whether this event has finished relative to [now] — its end is at or before
* the current instant. An in-progress event (already started but not yet ended)
* is *not* considered ended. All-day events end at the exclusive next-midnight,
* so they only count as ended once their day is fully over.
*/
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
data class EventDetail(
val instance: EventInstance,
val description: String?,

View File

@@ -24,7 +24,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
import de.jeanlucmakiola.calendula.ui.common.calendarFadeThrough
import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.drillToDay
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
@@ -196,9 +196,7 @@ fun CalendarHost(
dismissCoveringOverlays()
createDateIso = null
pendingDayIso = req.dateIso
// No widget source (an external date tap) roots over the default
// home view, so backing out of the day returns home then exits.
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
viewStack = viewBaseStack(defaultView, req.source).drillToDay()
onWidgetNavConsumed()
}
is WidgetNavRequest.OpenEvent -> {
@@ -246,7 +244,7 @@ fun CalendarHost(
// Switching between the peer views (month/week/day/agenda) is lateral
// navigation, so it fades through rather than sliding — paging *within* a
// view keeps the directional slide. AnimatedContent keyed on the view type.
val viewSwitch = calendarFadeThrough()
val viewSwitch = fadeThrough()
AnimatedContent(
targetState = view,
transitionSpec = { viewSwitch },

View File

@@ -13,13 +13,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
* detail-key channel and leave the base view untouched.)
*/
sealed interface WidgetNavRequest {
/**
* Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over
* [source]. A null [source] means the request came from outside the app (a
* launcher/clock date tap, issue #9) rather than a widget, so it roots over
* the default home view instead of a widget's view.
*/
data class OpenDate(val dateIso: String, val source: CalendarView?) : WidgetNavRequest
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over [source]. */
data class OpenDate(val dateIso: String, val source: CalendarView) : WidgetNavRequest
/** Open one occurrence's detail (an agenda-widget event tap), over [source]. */
data class OpenEvent(

View File

@@ -45,7 +45,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
@@ -54,24 +53,20 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.positionOf
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
@@ -101,7 +96,6 @@ fun AgendaScreen(
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -184,7 +178,6 @@ fun AgendaScreen(
}
AgendaContent(
state = state,
pastDisplay = pastDisplay,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
modifier = Modifier
@@ -282,7 +275,6 @@ private fun AgendaRangeBanner(
@Composable
private fun AgendaContent(
state: AgendaUiState,
pastDisplay: PastEventDisplay,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
@@ -292,31 +284,11 @@ private fun AgendaContent(
is AgendaUiState.Failure -> Box(modifier) {
CalendarFailure(reason = state.reason, onRetry = onRetry)
}
is AgendaUiState.Success -> {
val now by rememberCurrentMinute()
// Hiding drops finished events — and any day they leave empty; dimming
// keeps them but fades the row. Recomputed each minute so events fall
// away (or fade) as they end while the screen stays open.
val days = if (pastDisplay == PastEventDisplay.HIDE) {
state.days.mapNotNull { day ->
val remaining = day.events.filterNot { it.hasEnded(now) }
if (remaining.isEmpty()) null else day.copy(events = remaining)
}
} else {
state.days
}
if (days.isEmpty()) {
is AgendaUiState.Success ->
if (state.days.isEmpty()) {
AgendaEmpty(modifier)
} else {
AgendaList(
days = days,
today = state.today,
dimPast = pastDisplay == PastEventDisplay.DIM,
now = now,
onEventClick = onEventClick,
modifier = modifier,
)
}
AgendaList(state = state, onEventClick = onEventClick, modifier = modifier)
}
}
}
@@ -324,10 +296,7 @@ private fun AgendaContent(
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun AgendaList(
days: List<AgendaDay>,
today: LocalDate,
dimPast: Boolean,
now: Instant,
state: AgendaUiState.Success,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -336,9 +305,9 @@ private fun AgendaList(
// Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
) {
days.forEach { day ->
state.days.forEach { day ->
stickyHeader(key = "header-${day.date}") {
AgendaDayHeader(date = day.date, today = today)
AgendaDayHeader(date = day.date, today = state.today)
}
itemsIndexed(
items = day.events,
@@ -347,8 +316,7 @@ private fun AgendaList(
AgendaEventRow(
event = event,
position = positionOf(index, day.events.size),
dimmed = dimPast && event.hasEnded(now),
modifier = calendarAnimateItem(),
modifier = animateItemMotion(),
onClick = { onEventClick(event) },
)
}
@@ -380,14 +348,13 @@ private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
private fun AgendaEventRow(
event: EventInstance,
position: Position,
dimmed: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow(
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
modifier = modifier,
title = title,
summary = agendaTimeSummary(event),
position = position,

View File

@@ -5,7 +5,6 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.CalendarSource
@@ -54,18 +53,6 @@ class AgendaViewModel @Inject constructor(
private val weekStartDay = settingsPrefs.weekStart
.map { it.resolveFirstDay(Locale.getDefault()) }
/**
* How to treat events that already ended today (show / dim / hide). A display
* concern only, so it rides alongside the data state rather than re-querying;
* the screen combines it with a per-minute "now" to fade or drop past rows.
*/
val pastEventDisplay: StateFlow<PastEventDisplay> = settingsPrefs.pastEventDisplay
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = PastEventDisplay.SHOW,
)
private val zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate

View File

@@ -96,17 +96,17 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.pastelize
import java.time.LocalDate
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
@@ -253,6 +253,7 @@ private fun CalendarsList(
title = stringResource(R.string.calendars_title),
onBack = onBack,
snackbarHost = { SnackbarHost(snackbarHostState) },
predictiveBack = true,
) {
// What the per-calendar / per-account switches below actually do.
HintText(stringResource(R.string.calendars_disable_hint))
@@ -664,8 +665,8 @@ private fun CalendarGroup(
)
AnimatedVisibility(
visible = expanded,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
enter = expandEnter(),
exit = collapseExit(),
) {
Column(content = body)
}

View File

@@ -13,22 +13,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/**
* Soften a raw calendar color toward a pastel that fits the active theme.
* - Keeps the hue (so users still recognise their calendars)
* - Caps saturation so harsh provider colors stop screaming
* - Pins value/brightness to a band that reads on both light and dark surfaces
*/
fun pastelize(rawArgb: Int, dark: Boolean): Color {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(rawArgb, hsv)
hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f)
hsv[2] = if (dark) 0.82f else 0.72f
return Color(android.graphics.Color.HSVToColor(hsv))
}
import de.jeanlucmakiola.floret.components.pastelize
/**
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted

View File

@@ -37,6 +37,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import kotlinx.datetime.LocalDate
/**

View File

@@ -1,56 +1,26 @@
package de.jeanlucmakiola.calendula.ui.common
import android.provider.Settings
import androidx.activity.BackEventCompat
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.coroutines.cancellation.CancellationException
/**
* Whether the user has asked the system to remove animations (Settings →
* Accessibility → "Remove animations", which sets the global animator duration
* scale to 0). Compose animations do *not* honour this platform flag on their
* own, so the shared motion helpers in this file check it and fall back to a
* quick cross-fade — opacity only, no spatial movement — to respect the
* vestibular intent of the setting while keeping state changes legible.
*
* Read once at composition; the scale changes rarely and only takes full effect
* after a process restart anyway.
* Calendar-specific motion. The family's generic content transitions — section
* expand/collapse, item reveal, list relayout, cross-fade, predictive-back, and
* the reduce-motion check — now live in floret-kit's identity module
* (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`,
* `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays
* here is only what's specific to paging the calendar grid: the directional
* month/week/day slide and the specs that feed it.
*/
@Composable
fun rememberReduceMotion(): Boolean {
val resolver = LocalContext.current.contentResolver
return remember(resolver) {
Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
}
}
/**
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
@@ -98,128 +68,3 @@ fun calendarSlideTransition(
return slideInHorizontally(spec) { w -> dir * w }
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
}
/**
* Cross-fade [ContentTransform] for swapping whole screens or content blocks
* where there is no meaningful spatial direction (e.g. onboarding gates). Pure
* opacity, so it doubles as its own reduced-motion form.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarFadeThrough(): ContentTransform {
val fade = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
return fadeIn(fade).togetherWith(fadeOut(fade))
}
/**
* Enter transition for a vertically-revealed section (expandable rows, inline
* fields): height grows from the top while fading in. Under reduced motion the
* height growth is dropped, leaving a quick fade.
*
* Pair with [calendarCollapseExit]. This is the promoted form of the pattern
* originally inlined in the event edit form, so every expandable surface in the
* app reveals the same way.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarExpandEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) {
fade
} else {
expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
}
}
/** Exit counterpart to [calendarExpandEnter]: shrink + fade, or fade only under reduced motion. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarCollapseExit(reduceMotion: Boolean = rememberReduceMotion()): ExitTransition {
val fade = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) {
fade
} else {
shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
}
}
/**
* Enter transition for content revealed by an `AnimatedContent`/`AnimatedVisibility`
* (e.g. search results once a query resolves): a gentle rise + fade. Reduced
* motion keeps the fade only.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarItemEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) {
fade
} else {
fade + slideInVertically(MaterialTheme.motionScheme.fastSpatialSpec()) { h -> h / 6 }
}
}
/**
* Shared [LazyItemScope.animateItem] wiring so list rows fade/relocate with the
* app's motion scheme instead of Compose's default spring. Returns a bare
* [Modifier] under reduced motion so rows snap into place. Requires the list to
* supply stable item keys.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun LazyItemScope.calendarAnimateItem(reduceMotion: Boolean = rememberReduceMotion()): Modifier =
if (reduceMotion) {
Modifier
} else {
Modifier.animateItem(
fadeInSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
placementSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
fadeOutSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
)
}
/**
* Standard Android predictive-back transform for a full-screen overlay: as the
* back gesture is dragged, the surface scales toward ~90%, shifts toward the
* swiped edge and rounds its corners, previewing what's behind. Completing the
* gesture invokes [onBack]; cancelling springs it back.
*
* A drop-in replacement for a screen's own `BackHandler(onBack)` — register it
* once and apply the returned [Modifier] to that screen's root so the preview
* respects the same back semantics. Under reduced motion the visual preview is
* skipped (the back still works); on API < 34 the system delivers no progress,
* so it degrades to a plain back.
*/
@Composable
fun Modifier.predictiveBack(
onBack: () -> Unit,
enabled: Boolean = true,
reduceMotion: Boolean = rememberReduceMotion(),
): Modifier {
val progress = remember { Animatable(0f) }
var fromLeftEdge by remember { mutableStateOf(true) }
PredictiveBackHandler(enabled = enabled) { events ->
try {
events.collect { event ->
fromLeftEdge = event.swipeEdge == BackEventCompat.EDGE_LEFT
progress.snapTo(FastOutSlowInEasing.transform(event.progress))
}
onBack()
progress.snapTo(0f)
} catch (_: CancellationException) {
progress.animateTo(0f)
}
}
if (reduceMotion) return this
return this.graphicsLayer {
val p = progress.value
val scale = 1f - 0.1f * p
scaleX = scale
scaleY = scale
translationX = (if (fromLeftEdge) 1f else -1f) * 24.dp.toPx() * p
shape = RoundedCornerShape(32.dp.toPx() * p)
clip = p > 0f
}
}

View File

@@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.components.pastelize
/**
* A wrapping row of round colour swatches; the one matching [selected] is

View File

@@ -1,94 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
/**
* Tonal 3-digit number input shared by the custom reminder/recurrence steps and
* the reminder pickers — the app's [InlineTextField] over a tonal surface, so it
* matches the card/grouped-row design language (not Material's outlined field).
*/
@Composable
fun DialogAmountField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
) {
// surfaceContainerHighest — the picker/dialog sits on surfaceContainerHigh,
// so anything lower vanishes.
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
) {
InlineTextField(
value = value,
onValueChange = { text ->
if (text.length <= 3 && text.all(Char::isDigit)) onValueChange(text)
},
placeholder = placeholder,
textStyle = MaterialTheme.typography.titleMedium,
keyboardType = KeyboardType.Number,
modifier = Modifier
.width(72.dp)
.padding(horizontal = 14.dp, vertical = 12.dp),
)
}
}
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps and pickers. */
@Composable
fun DialogUnitDropdown(
label: String,
entries: List<String>,
onPick: (Int) -> Unit,
) {
var open by remember { mutableStateOf(false) }
Box {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
onClick = { open = true },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 14.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
) {
Text(text = label, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.width(4.dp))
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null)
}
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
entries.forEachIndexed { index, entry ->
DropdownMenuItem(
text = { Text(entry) },
onClick = {
onPick(index)
open = false
},
)
}
}
}
}

View File

@@ -1,218 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/**
* Position of a row within a grouped list, after the Android-15 settings
* pattern: a run of rows shares one rounded container, with full corners at the
* group's outer edges and small corners between, separated by small gaps.
*/
enum class Position { Top, Middle, Bottom, Alone }
/** Maps an index within a group of [count] rows to its [Position]. */
fun positionOf(index: Int, count: Int): Position = when {
count <= 1 -> Position.Alone
index == 0 -> Position.Top
index == count - 1 -> Position.Bottom
else -> Position.Middle
}
/**
* The app's standard full-screen list scaffold: a collapsing [LargeTopAppBar]
* whose title shrinks into the bar (next to the back button) as the content
* scrolls. Content is a scrollable column that feeds the toolbar via nested
* scroll. Used by Settings and the calendar manager so they share one shell.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CollapsingScaffold(
title: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
snackbarHost: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
val scrollBehavior =
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
Scaffold(
modifier = modifier
.predictiveBack(onBack = onBack)
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
LargeTopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.settings_back),
)
}
},
actions = actions,
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
)
},
snackbarHost = snackbarHost,
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
// Mark the scaffold's system-bar insets as consumed so the
// imePadding below adds only the keyboard height beyond them
// (max, not sum) — otherwise the nav-bar inset double-counts and
// leaves an empty strip above the keyboard.
.consumeWindowInsets(innerPadding)
.fillMaxSize()
// Paint the surface across the full area before imePadding carves
// into it, so any sliver above the keyboard reads as surface — not
// the dialog window's black — during the IME animation.
.background(MaterialTheme.colorScheme.surface)
// Shrink the scroll viewport by the keyboard inset so a focused
// field (e.g. the custom-reminder amount) can scroll into view.
.imePadding()
.verticalScroll(rememberScrollState())
.padding(top = 8.dp, bottom = 24.dp),
content = content,
)
}
}
/**
* One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose
* corner radii come from its [position] (so a run of rows reads as a single
* rounded card). Corners round further on press. A null [onClick] makes the
* row non-interactive (e.g. read-only entries). [dimmed] fades the headline and
* summary to the M3 disabled emphasis while leaving the [trailing] control at
* full opacity — for rows that are present but switched off.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GroupedRow(
title: String,
position: Position,
modifier: Modifier = Modifier,
summary: String? = null,
selected: Boolean = false,
dimmed: Boolean = false,
container: Color? = null,
minHeight: Dp = 72.dp,
leading: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
val shape = when (position) {
Position.Alone -> RoundedCornerShape(full)
Position.Top -> RoundedCornerShape(
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
)
Position.Middle -> RoundedCornerShape(small)
Position.Bottom -> RoundedCornerShape(
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
)
}
val gap = when (position) {
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
Position.Bottom, Position.Alone -> Modifier
}
val itemColors = if (selected) {
ListItemDefaults.colors(
containerColor = Color.Transparent,
headlineColor = MaterialTheme.colorScheme.onSecondaryContainer,
leadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
} else if (dimmed) {
// M3 disabled emphasis (0.38α) on the text/leading; the trailing control
// stays full-opacity so the toggle reads as live even on a faded row.
val muted = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
ListItemDefaults.colors(
containerColor = Color.Transparent,
headlineColor = muted,
leadingIconColor = muted,
supportingColor = muted,
)
} else {
ListItemDefaults.colors(containerColor = Color.Transparent)
}
val item: @Composable () -> Unit = {
ListItem(
headlineContent = { Text(title) },
supportingContent = summary?.let { text -> { Text(text) } },
leadingContent = leading,
trailingContent = trailing,
colors = itemColors,
modifier = Modifier.heightIn(min = minHeight),
)
}
val base = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.then(gap)
val containerColor = when {
selected -> MaterialTheme.colorScheme.secondaryContainer
container != null -> container
else -> MaterialTheme.colorScheme.surfaceContainerHigh
}
if (onClick != null) {
Surface(
onClick = onClick,
color = containerColor,
shape = shape,
interactionSource = interaction,
modifier = base,
) { item() }
} else {
Surface(color = containerColor, shape = shape, modifier = base) { item() }
}
}

View File

@@ -1,83 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
/**
* The app's borderless text input: no underline, no outline, just the tonal
* card behind it. This is the standard input across the app — we deliberately
* don't use Material's outlined/filled text fields, so anything that takes text
* (the event form, the calendar manager, dialogs) uses this inside a tonal
* [androidx.compose.material3.Surface].
*/
@Composable
fun InlineTextField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
singleLine: Boolean = true,
minLines: Int = 1,
keyboardType: KeyboardType = KeyboardType.Text,
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
imeAction: ImeAction = ImeAction.Default,
/** Invoked when the IME action key (e.g. Done) is pressed. */
onImeAction: (() -> Unit)? = null,
) {
val resolvedStyle = textStyle.copy(
color = if (textStyle.color.isSpecified) {
textStyle.color
} else {
MaterialTheme.colorScheme.onSurface
},
)
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = resolvedStyle,
singleLine = singleLine,
minLines = minLines,
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
capitalization = capitalization,
imeAction = imeAction,
),
keyboardActions = onImeAction?.let { action ->
KeyboardActions(onAny = { action() })
} ?: KeyboardActions.Default,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Box {
if (value.isEmpty()) {
// Clearly fainter than typed text, so a hint never reads as
// prefilled content.
Text(
text = placeholder,
style = resolvedStyle,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
}
innerTextField()
}
},
modifier = modifier,
)
}

View File

@@ -34,7 +34,7 @@ private val LineThickness = 2.dp
* the middle of a minute. Drives the "now" line in the day/week grids.
*/
@Composable
fun rememberCurrentMinute(): State<Instant> {
private fun rememberCurrentMinute(): State<Instant> {
val instant = remember { mutableStateOf(Clock.System.now()) }
LaunchedEffect(Unit) {
while (true) {

View File

@@ -1,94 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
/**
* The app's standard pick in a selection dialog: a full-width tonal card,
* optionally with a leading icon and a supporting line; the selected option
* is highlighted. Stack with 8dp gaps inside an AlertDialog — this is the
* only sanctioned selection-modal style (no radio rows, no bare text lists).
*/
@Composable
fun OptionCard(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
icon: ImageVector? = null,
/** Icon tint override, e.g. a calendar colour; unspecified follows selection. */
iconTint: Color = Color.Unspecified,
supportingText: String? = null,
selected: Boolean = false,
/** Label colour override, e.g. primary for an emphasised "Custom" entry. */
labelColor: Color = Color.Unspecified,
) {
val contentColor = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurface
}
Surface(
onClick = onClick,
color = if (selected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
},
shape = RoundedCornerShape(12.dp),
modifier = modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = when {
iconTint.isSpecified -> iconTint
selected -> MaterialTheme.colorScheme.onSecondaryContainer
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(12.dp))
}
Column {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = if (labelColor.isSpecified) labelColor else contentColor,
)
if (supportingText != null) {
Text(
text = supportingText,
style = MaterialTheme.typography.bodySmall,
color = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
}
}

View File

@@ -1,16 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.compositionLocalOf
import kotlin.time.Instant
/**
* The cut-off instant before which a calendar event counts as "completed" and is
* drawn dimmed in the month/week grids — i.e. the current wall-clock minute when
* the "dim completed events" setting is on, or `null` when it is off (nothing
* dims). Provided per grid screen so only the event chips that read it recompose
* as the minute ticks, mirroring [LocalShowHourLines] / [LocalUse24HourFormat].
*/
val LocalDimCutoff = compositionLocalOf<Instant?> { null }
/** Opacity applied to a completed/past event chip when it is dimmed. */
const val EventDimAlpha = 0.4f

View File

@@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -21,126 +20,60 @@ import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import android.view.WindowManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
/**
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
* reuses the app's [CollapsingScaffold] (collapsing title + back button), so a
* picker is visually identical to a Settings sub-page and uses the full width.
* [content] places the connected grouped rows; selecting one calls [onDismiss].
*/
@Composable
fun FullScreenPicker(
title: String,
onDismiss: () -> Unit,
content: @Composable ColumnScope.() -> Unit,
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false,
),
) {
// The dialog window pans by default when the keyboard opens, which —
// combined with the content's own imePadding — leaves a fixed black gap
// above the keyboard. Switch it to ADJUST_NOTHING so the window stays
// full-screen and imePadding alone lifts the focused field.
val view = LocalView.current
SideEffect {
(view.parent as? DialogWindowProvider)?.window
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
}
CollapsingScaffold(title = title, onBack = onDismiss, content = content)
}
}
/**
* General single-select picker, full-screen: each option is a connected grouped
* row and the current one carries a check. Drop-in for the former dialog
* (theme, week start, language, …).
*/
@Composable
fun <T> OptionPicker(
title: String,
options: List<T>,
selected: T,
label: @Composable (T) -> String,
onSelect: (T) -> Unit,
onDismiss: () -> Unit,
header: (@Composable ColumnScope.() -> Unit)? = null,
) {
FullScreenPicker(title = title, onDismiss = onDismiss) {
header?.invoke(this)
options.forEachIndexed { index, option ->
val isSelected = option == selected
GroupedRow(
title = label(option),
position = positionOf(index, options.size),
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = {
onSelect(option)
onDismiss()
},
)
}
}
}
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.ReminderUnit
import de.jeanlucmakiola.floret.reminders.decomposeReminderMinutes
/**
* Reminder-default picker, full-screen: the grouped list (with an optional "Use
* default reminder" row and a "None" row), the [presets] as lead-time rows, and
* a "Custom" row that expands an inline number field plus a segmented unit
* selector. Returns the choice as a [CalendarReminderOverride].
* selector. Returns the choice as a [ReminderOverride].
*/
@Composable
fun ReminderDefaultPicker(
title: String,
presets: List<Int>,
selected: CalendarReminderOverride,
selected: ReminderOverride,
allowInherit: Boolean,
onSelect: (CalendarReminderOverride) -> Unit,
onSelect: (ReminderOverride) -> Unit,
onDismiss: () -> Unit,
) {
val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes
val selectedMinutes = (selected as? ReminderOverride.Minutes)?.minutes
val customSelected = selectedMinutes != null && selectedMinutes !in presets
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected })
val seed = decomposeReminderMinutes(selectedMinutes?.takeIf { customSelected })
var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable { mutableStateOf(seed.first) }
var unit by rememberSaveable { mutableStateOf(seed.second) }
var amountText by rememberSaveable { mutableStateOf(seed.amount?.toString() ?: "") }
var unit by rememberSaveable { mutableStateOf(seed.unit) }
val options = buildList {
if (allowInherit) add(CalendarReminderOverride.Inherit)
add(CalendarReminderOverride.None)
presets.forEach { add(CalendarReminderOverride.Minutes(it)) }
if (allowInherit) add(ReminderOverride.Inherit)
add(ReminderOverride.None)
presets.forEach { add(ReminderOverride.Minutes(it)) }
}
val rowCount = options.size + 1 // + the custom row
FullScreenPicker(title = title, onDismiss = onDismiss) {
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
options.forEachIndexed { index, option ->
val isSelected = option == selected
GroupedRow(
@@ -180,8 +113,8 @@ fun ReminderDefaultPicker(
)
AnimatedVisibility(
visible = customExpanded,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
enter = expandEnter(),
exit = collapseExit(),
) {
CustomReminderEditor(
amountText = amountText,
@@ -189,7 +122,7 @@ fun ReminderDefaultPicker(
unit = unit,
onUnitChange = { unit = it },
onConfirm = { minutes ->
onSelect(CalendarReminderOverride.Minutes(minutes))
onSelect(ReminderOverride.Minutes(minutes))
onDismiss()
},
)
@@ -327,7 +260,7 @@ fun AgendaRangePicker(
)
}
FullScreenPicker(title = title, onDismiss = onDismiss) {
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
PickerDescription(description)
// Calendar-aligned windows.
@@ -358,8 +291,8 @@ fun AgendaRangePicker(
)
AnimatedVisibility(
visible = customExpanded,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
enter = expandEnter(),
exit = collapseExit(),
) {
CustomDaysEditor(
amountText = amountText,
@@ -429,17 +362,8 @@ fun agendaRangeLabel(range: AgendaRange): String = when (range) {
}
@Composable
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
}
/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */
private fun decomposeReminder(minutes: Int?): Pair<String, ReminderUnit> = when {
minutes == null -> "" to ReminderUnit.Minutes
minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks
minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days
minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours
else -> minutes.toString() to ReminderUnit.Minutes
private fun reminderOverrideLabel(override: ReminderOverride): String = when (override) {
ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
ReminderOverride.None -> stringResource(R.string.reminder_none)
is ReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
}

View File

@@ -5,18 +5,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.reminders.ReminderUnit
/** Common reminder lead times offered as quick picks in the form and settings. */
val REMINDER_PRESETS = listOf(0, 10, 30, 60, 1_440)
/** The unit of a custom reminder lead time; [minutesFactor] converts to minutes. */
enum class ReminderUnit(val minutesFactor: Int) {
Minutes(1),
Hours(60),
Days(1_440),
Weeks(10_080),
}
@StringRes
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
ReminderUnit.Minutes -> R.string.reminder_unit_minutes

View File

@@ -8,8 +8,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.submitCrashReport
/**
* A deliberately minimal, standalone surface for a captured crash report.

View File

@@ -1,75 +0,0 @@
package de.jeanlucmakiola.calendula.ui.crash
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/**
* Asks the user to send a captured crash report as an issue. The full report is
* shown verbatim in a scrollable panel — the user sees exactly what will leave
* the device before choosing to share it (the privacy backstop). [onSend] hands
* off to [submitCrashReport]; [onDismiss] declines.
*/
@Composable
fun CrashReportDialog(
report: String,
onSend: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.BugReport, contentDescription = null) },
title = { Text(stringResource(R.string.crash_dialog_title)) },
text = {
Column {
Text(
text = stringResource(R.string.crash_dialog_message),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(12.dp))
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = report,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.heightIn(max = 220.dp)
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
}
},
confirmButton = {
TextButton(onClick = onSend) { Text(stringResource(R.string.crash_dialog_report)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.crash_dialog_dismiss)) }
},
)
}

View File

@@ -1,61 +0,0 @@
package de.jeanlucmakiola.calendula.ui.crash
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.core.net.toUri
import de.jeanlucmakiola.calendula.R
/**
* Hand the captured crash report off to the user's chosen channel: the report
* is copied to the clipboard (the reliable path for a full stack trace) and the
* project's Gitea "new issue" page is opened with the body prefilled. Nothing is
* sent automatically — the app has no network access; the user reviews and
* submits the issue themselves.
*/
fun submitCrashReport(context: Context, report: String) {
copyReportToClipboard(context, report)
val opened = runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, buildIssueUri(context, report)))
}.isSuccess
val message = if (opened) R.string.crash_report_copied else R.string.crash_report_open_failed
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
/** Open the issue tracker's template chooser for a manual (non-crash) report. */
fun openIssueTracker(context: Context) {
val uri = context.getString(R.string.report_issue_choose_url).toUri()
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, uri)) }
}
private fun copyReportToClipboard(context: Context, report: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
val label = context.getString(R.string.crash_report_clip_label)
clipboard.setPrimaryClip(ClipData.newPlainText(label, report))
}
/**
* The Gitea `issues/new` URL with `title` and `body` prefilled. A full report
* can blow past URL-length limits, so an over-long one is left out of the link
* (with a "paste from clipboard" placeholder) — the clipboard copy is the
* source of truth in that case.
*/
private fun buildIssueUri(context: Context, report: String) =
context.getString(R.string.report_issue_url).toUri().buildUpon()
.appendQueryParameter("title", context.getString(R.string.crash_report_issue_title))
.appendQueryParameter("body", buildIssueBody(context, report))
.build()
private fun buildIssueBody(context: Context, report: String): String {
val block = if (report.length > MAX_URL_REPORT_CHARS) {
context.getString(R.string.crash_report_body_paste)
} else {
"```\n$report\n```"
}
return context.getString(R.string.crash_report_body_template, block)
}
/** Keep the prefilled body comfortably under common URL-length ceilings. */
private const val MAX_URL_REPORT_CHARS = 6_000

View File

@@ -76,9 +76,9 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat

View File

@@ -91,13 +91,13 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.OptionCard
import de.jeanlucmakiola.floret.components.OptionCard
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import kotlinx.coroutines.launch

View File

@@ -81,8 +81,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.isSpecified
@@ -95,6 +93,7 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -116,30 +115,30 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
import de.jeanlucmakiola.calendula.domain.toRRule
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.OptionCard
import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.calendula.ui.common.positionOf
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.OptionCard
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
import de.jeanlucmakiola.calendula.ui.common.ReminderUnit
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.floret.reminders.ReminderUnit
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
@@ -418,8 +417,8 @@ private fun OptionalFormSection(
) {
AnimatedVisibility(
visible = visible,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
enter = expandEnter(),
exit = collapseExit(),
) {
Column(modifier = Modifier.fillMaxWidth(), content = content)
}
@@ -481,17 +480,6 @@ private fun EventEditContent(
?: MaterialTheme.colorScheme.primary
val gap = 12.dp
// Issue #10: on a fresh create form (setting on, nothing typed yet) focus the
// title and raise the keyboard so the user can type straight away. Keyed on
// Unit so it runs once per open — not on every keystroke — and guarded so
// editing or an imported/prefilled form never grabs focus.
val titleFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
if (state.autofocusTitle && !state.isEditing && form.title.isBlank()) {
runCatching { titleFocusRequester.requestFocus() }
}
}
// Per-event colour applicability for the resolved calendar:
// - palette calendars (Google, …) and local calendars always support it;
// - synced calendars with no palette only when the user opted in, and even
@@ -519,10 +507,6 @@ private fun EventEditContent(
placeholder = stringResource(R.string.event_edit_title_hint),
textStyle = MaterialTheme.typography.headlineMedium
.copy(fontWeight = FontWeight.SemiBold),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.focusRequester(titleFocusRequester),
)
Spacer(Modifier.height(10.dp))
Box(
@@ -1586,6 +1570,7 @@ private fun AddGuestInlineCard(
value = text,
onValueChange = { text = it },
placeholder = stringResource(R.string.event_edit_add_guest_hint),
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Done,
onImeAction = { commit() },
@@ -1872,6 +1857,7 @@ private fun InlineField(
singleLine = singleLine,
minLines = minLines,
keyboardType = keyboardType,
capitalization = KeyboardCapitalization.None,
)
}

View File

@@ -29,12 +29,6 @@ data class EventEditUiState(
val hiddenFields: List<EventFormField> = emptyList(),
/** True while editing an existing event (the calendar is then fixed). */
val isEditing: Boolean = false,
/**
* Whether to focus the title and raise the keyboard when this is a fresh
* create form (issue #10). Mirrors the settings flag; the screen only acts
* on it for a new event with an empty title, never when editing/importing.
*/
val autofocusTitle: Boolean = true,
/**
* True while an edit changed the recurrence rule — the save-scope dialog
* then drops "only this event" (an exception row can't carry a rule).

View File

@@ -119,7 +119,6 @@ class EventEditViewModel @Inject constructor(
val lastUsed: Long?,
val defaultFields: Set<EventFormField>,
val allowColorOnUnsupported: Boolean,
val autofocusTitle: Boolean,
)
/**
@@ -161,7 +160,6 @@ class EventEditViewModel @Inject constructor(
prefs.lastUsedCalendarId,
settingsPrefs.defaultFormFields,
settingsPrefs.allowColorOnUnsupportedCalendars,
settingsPrefs.autofocusEventTitle,
::ExternalInputs,
).flowOn(io),
colorPalette,
@@ -180,7 +178,6 @@ class EventEditViewModel @Inject constructor(
visibleFields = visibleFields,
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
isEditing = local.editTarget != null,
autofocusTitle = external.autofocusTitle,
// A modified-occurrence exception can't carry its own rule, so
// the scope dialog drops "only this event" after a rule change.
recurrenceChanged = local.editTarget != null &&

View File

@@ -21,8 +21,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.positionOf
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.positionOf
/**
* Calendar-visibility filter (M3), rendered inline in the navigation drawer.

View File

@@ -38,8 +38,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.OptionCard
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.floret.components.OptionCard
/**
* Handles an opened/received `.ics` file. A single event is handed straight to

View File

@@ -36,8 +36,6 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
@@ -46,7 +44,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
@@ -63,22 +60,18 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.floret.components.pastelize
import kotlinx.coroutines.launch
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
@@ -104,14 +97,6 @@ fun MonthScreen(
val state by viewModel.state.collectAsStateWithLifecycle()
val month by viewModel.month.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
// The instant before which an event counts as completed, or null when dimming
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
// screen while the setting is off (it stays null regardless of the tick).
val nowState = rememberCurrentMinute()
val dimCutoff by remember(dimCompleted) {
derivedStateOf { if (dimCompleted) nowState.value else null }
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -208,7 +193,6 @@ fun MonthScreen(
.fillMaxSize(),
) {
WeekdayHeader(weekStart = weekStart)
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
MonthContent(
state = state,
slideDir = slideDir,
@@ -220,7 +204,6 @@ fun MonthScreen(
}
}
}
}
}
@Composable
@@ -575,8 +558,6 @@ private fun MonthBar(
modifier: Modifier = Modifier,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
val shape = RoundedCornerShape(
topStart = if (continuesLeft) 0.dp else 4.dp,
bottomStart = if (continuesLeft) 0.dp else 4.dp,
@@ -584,7 +565,7 @@ private fun MonthBar(
bottomEnd = if (continuesRight) 0.dp else 4.dp,
)
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
modifier = modifier
.background(pastelize(event.color, dark), shape)
.padding(horizontal = 4.dp)
.semantics { contentDescription = title },

View File

@@ -59,14 +59,6 @@ class MonthViewModel @Inject constructor(
initialValue = DayOfWeek.MONDAY,
)
/** Whether to fade events that have already finished (display concern only). */
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date

View File

@@ -0,0 +1,66 @@
package de.jeanlucmakiola.calendula.ui.permission
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/**
* The app's adaptive launcher mark, reconstructed as a large branded squircle —
* the hero of the onboarding screens (floret-kit's OnboardingScaffold supplies
* the shell; the mark stays app-local so each sibling keeps its own identity).
* A lock badge overlays the corner when permission has been [denied].
*/
@Composable
internal fun BrandHero(denied: Boolean) {
Box(contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(128.dp)
.clip(RoundedCornerShape(34.dp))
.background(colorResource(R.color.ic_launcher_background)),
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = stringResource(R.string.app_name),
modifier = Modifier.fillMaxSize(),
)
}
if (denied) {
// A small lock badge sits over the corner to signal "blocked".
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.offset(x = 10.dp, y = 10.dp)
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.errorContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(24.dp),
)
}
}
}
}

View File

@@ -1,163 +0,0 @@
package de.jeanlucmakiola.calendula.ui.permission
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/** MD3 8dp spacing scale shared by the onboarding screens. */
internal object OnboardingSpace {
val xs = 8.dp
val sm = 16.dp
val md = 24.dp
val lg = 32.dp
val xl = 48.dp
}
/**
* Shared onboarding shell (calendar grant, reminder step): a scrollable,
* centred hero + body with the call(s) to action pinned to the bottom (clear
* of the navigation bar). The content slot is centred horizontally; benefit
* rows fill the width so their own content left-aligns.
*/
@Composable
internal fun OnboardingScaffold(
hero: @Composable () -> Unit,
actions: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
body: @Composable ColumnScope.() -> Unit,
) {
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.surface,
bottomBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
content = actions,
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = OnboardingSpace.md),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(OnboardingSpace.xl))
hero()
Spacer(Modifier.height(OnboardingSpace.lg))
body()
Spacer(Modifier.height(OnboardingSpace.md))
}
}
}
/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */
@Composable
internal fun BrandHero(denied: Boolean) {
Box(contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(128.dp)
.clip(RoundedCornerShape(34.dp))
.background(colorResource(R.color.ic_launcher_background)),
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = stringResource(R.string.app_name),
modifier = Modifier.fillMaxSize(),
)
}
if (denied) {
// A small lock badge sits over the corner to signal "blocked".
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.offset(x = 10.dp, y = 10.dp)
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.errorContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(24.dp),
)
}
}
}
}
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
@Composable
internal fun BenefitRow(icon: ImageVector, title: String, body: String) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(22.dp),
)
}
Spacer(Modifier.width(OnboardingSpace.sm))
Column(modifier = Modifier.weight(1f)) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View File

@@ -1,5 +1,9 @@
package de.jeanlucmakiola.calendula.ui.permission
import de.jeanlucmakiola.floret.components.BenefitRow
import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
import android.Manifest
import android.content.Intent
import android.net.Uri

View File

@@ -1,5 +1,9 @@
package de.jeanlucmakiola.calendula.ui.permission
import de.jeanlucmakiola.floret.components.BenefitRow
import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult

View File

@@ -42,22 +42,23 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.positionOf
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf
import java.time.Instant as JavaInstant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -101,6 +102,7 @@ fun SearchScreen(
value = query,
onValueChange = viewModel::setQuery,
placeholder = stringResource(R.string.search_hint),
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Search,
onImeAction = { keyboard?.hide() },
modifier = Modifier
@@ -175,7 +177,7 @@ private fun SearchResults(
SearchResultRow(
event = event,
position = positionOf(index, events.size),
modifier = calendarAnimateItem(),
modifier = animateItemMotion(),
onClick = { onEventClick(event) },
)
}

View File

@@ -1,78 +0,0 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.content.Context
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import de.jeanlucmakiola.calendula.R
import org.xmlpull.v1.XmlPullParser
import java.util.Locale
private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
/**
* Per-app language via AppCompatDelegate, driven by res/xml/locales_config.xml.
*
* That file is the single source of truth for which languages we ship: dropping
* in a values-<tag> translation and adding a matching `<locale>` entry makes the
* language show up here and in the system per-app-language settings, with no
* other code change. The system-default choice is represented as `null`.
*
* On API 33+ this delegates to the platform per-app-languages API; below that
* the appcompat backport persists the choice itself (manifest `autoStoreLocales`
* service), so we don't mirror it in DataStore. Setting a locale recreates the
* activity, which re-reads the current value for the picker.
*/
object AppLanguage {
/**
* The BCP-47 tags the app ships translations for, in declaration order, as
* listed in locales_config.xml. Returns whatever could be parsed; a missing
* or malformed config yields an empty list (the picker then offers only the
* system-default entry rather than crashing).
*/
fun supportedTags(context: Context): List<String> {
val tags = mutableListOf<String>()
val parser = context.resources.getXml(R.xml.locales_config)
try {
var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
if (event == XmlPullParser.START_TAG && parser.name == "locale") {
parser.getAttributeValue(ANDROID_NS, "name")?.let(tags::add)
}
event = parser.next()
}
} catch (_: Exception) {
// Fall back to whatever was parsed before the failure.
} finally {
parser.close()
}
return tags
}
/** The applied app language as a BCP-47 tag, or `null` when following the system. */
fun currentTag(): String? {
val locales = AppCompatDelegate.getApplicationLocales()
return if (locales.isEmpty) null else locales[0]?.toLanguageTag()
}
/** Apply a BCP-47 tag, or `null` to follow the system languages. */
fun apply(tag: String?) {
val locales = if (tag == null) {
LocaleListCompat.getEmptyLocaleList()
} else {
LocaleListCompat.forLanguageTags(tag)
}
AppCompatDelegate.setApplicationLocales(locales)
}
/**
* The autonym for a tag — the language's own name in its own script, e.g.
* "Deutsch", "English", "Français" — so users find their language regardless
* of the current UI language. Capitalised per the language's own rules.
*/
fun displayName(tag: String): String {
val locale = Locale.forLanguageTag(tag)
return locale.getDisplayName(locale)
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }
}
}

View File

@@ -24,7 +24,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -45,16 +44,14 @@ import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Gavel
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.FilledTonalButton
import de.jeanlucmakiola.floret.locale.AppLanguage
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -72,6 +69,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@@ -84,32 +82,33 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.qs.NewEventTileService
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.openIssueTracker
import de.jeanlucmakiola.floret.crash.submitCrashReport
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.floret.components.AboutCard
import de.jeanlucmakiola.floret.components.AboutLink
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.labelRes
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.calendula.ui.common.positionOf
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.currentLocale
@@ -193,7 +192,7 @@ private fun SettingsHub(
onOpenSection: (SettingsSection) -> Unit,
onManageCalendars: () -> Unit,
) {
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) {
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack, predictiveBack = true) {
Box(Modifier.padding(horizontal = 16.dp)) { AboutCard() }
Spacer(Modifier.height(16.dp))
@@ -300,7 +299,7 @@ private fun LanguageRow(position: Position) {
var showDialog by remember { mutableStateOf(false) }
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context) }
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context, R.xml.locales_config) }
GroupedRow(
title = stringResource(R.string.settings_language),
@@ -313,6 +312,7 @@ private fun LanguageRow(position: Position) {
if (showDialog) {
OptionPicker(
title = stringResource(R.string.settings_language),
predictiveBack = true,
options = options,
selected = current,
label = { languageLabel(it) },
@@ -339,83 +339,30 @@ private fun LanguageRow(position: Position) {
@Composable
private fun AboutCard() {
val context = LocalContext.current
val sourceUrl = stringResource(R.string.about_source_url)
val licenseUrl = stringResource(R.string.about_license_url)
val supportUrl = stringResource(R.string.about_support_url)
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(24.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
AppLogo()
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
// The card layout lives in floret-kit (components.AboutCard); Calendula
// supplies its own logo, author and the source / licence / support links.
AboutCard(
logo = { AppLogo() },
appName = stringResource(R.string.app_name),
author = stringResource(R.string.settings_about_author),
primaryLinks = listOf(
AboutLink(
icon = ImageVector.vectorResource(R.drawable.ic_gitea),
label = stringResource(R.string.settings_about_source),
url = stringResource(R.string.about_source_url),
),
AboutLink(
icon = Icons.Default.Gavel,
label = stringResource(R.string.settings_license),
url = stringResource(R.string.about_license_url),
),
),
highlightLink = AboutLink(
icon = Icons.Default.Favorite,
label = stringResource(R.string.settings_about_support),
url = stringResource(R.string.about_support_url),
),
)
Text(
text = stringResource(R.string.settings_about_author),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = { openUrl(context, sourceUrl) },
contentPadding = PaddingValues(horizontal = 12.dp),
modifier = Modifier.weight(1f),
) {
Icon(
painter = painterResource(R.drawable.ic_gitea),
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.settings_about_source))
}
OutlinedButton(
onClick = { openUrl(context, licenseUrl) },
contentPadding = PaddingValues(horizontal = 12.dp),
modifier = Modifier.weight(1f),
) {
Icon(
Icons.Default.Gavel,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.settings_license))
}
}
Spacer(Modifier.height(8.dp))
FilledTonalButton(
onClick = { openUrl(context, supportUrl) },
modifier = Modifier.fillMaxWidth(),
) {
Icon(
Icons.Default.Favorite,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.settings_about_support))
}
}
}
}
/** Plain centred version mark at the foot of the settings list (no card). */
@@ -476,11 +423,11 @@ private fun AppearanceScreen(
var showDefaultView by remember { mutableStateOf(false) }
var showAgendaScreenRange by remember { mutableStateOf(false) }
var showAgendaWidgetRange by remember { mutableStateOf(false) }
var showPastEvents by remember { mutableStateOf(false) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_appearance),
onBack = onBack,
predictiveBack = true,
) {
// Theme & colour
GroupedRow(
@@ -535,7 +482,7 @@ private fun AppearanceScreen(
GroupedRow(
title = stringResource(R.string.settings_hour_lines),
summary = stringResource(R.string.settings_hour_lines_summary),
position = Position.Middle,
position = Position.Bottom,
trailing = {
Switch(
checked = state.showHourLines,
@@ -544,36 +491,16 @@ private fun AppearanceScreen(
},
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
)
GroupedRow(
title = stringResource(R.string.settings_dim_completed),
summary = stringResource(R.string.settings_dim_completed_summary),
position = Position.Bottom,
trailing = {
Switch(
checked = state.dimCompletedEvents,
onCheckedChange = viewModel::setDimCompletedEvents,
)
},
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
)
Spacer(Modifier.height(16.dp))
// Agenda — the only group labelled, so its agenda-specific rows are easy
// to pick out among the otherwise unheadered Appearance settings.
SectionHeader(stringResource(R.string.settings_agenda_header))
// Agenda
GroupedRow(
title = stringResource(R.string.settings_agenda_range),
summary = agendaRangeLabel(state.agendaScreenRange),
position = Position.Top,
onClick = { showAgendaScreenRange = true },
)
GroupedRow(
title = stringResource(R.string.settings_past_events),
summary = pastEventDisplayLabel(state.pastEventDisplay),
position = Position.Middle,
onClick = { showPastEvents = true },
)
GroupedRow(
title = stringResource(R.string.settings_agenda_widget_range),
summary = agendaRangeLabel(state.agendaWidgetRange),
@@ -597,6 +524,7 @@ private fun AppearanceScreen(
if (showTheme) {
OptionPicker(
title = stringResource(R.string.settings_theme),
predictiveBack = true,
options = ThemeMode.entries,
selected = state.themeMode,
label = { themeLabel(it) },
@@ -607,6 +535,7 @@ private fun AppearanceScreen(
if (showWeekStart) {
OptionPicker(
title = stringResource(R.string.settings_week_start),
predictiveBack = true,
options = WEEK_START_OPTIONS,
selected = state.weekStart,
label = { weekStartLabel(it) },
@@ -635,6 +564,7 @@ private fun AppearanceScreen(
if (showTimeFormat) {
OptionPicker(
title = stringResource(R.string.settings_time_format),
predictiveBack = true,
options = TimeFormatPref.entries,
selected = state.timeFormat,
label = { timeFormatLabel(it) },
@@ -642,19 +572,10 @@ private fun AppearanceScreen(
onDismiss = { showTimeFormat = false },
)
}
if (showPastEvents) {
OptionPicker(
title = stringResource(R.string.settings_past_events),
options = PastEventDisplay.entries,
selected = state.pastEventDisplay,
label = { pastEventDisplayLabel(it) },
onSelect = viewModel::setPastEventDisplay,
onDismiss = { showPastEvents = false },
)
}
if (showDefaultView) {
OptionPicker(
title = stringResource(R.string.settings_default_view),
predictiveBack = true,
options = IMPLEMENTED_VIEWS,
selected = state.defaultView,
label = { stringResource(it.labelRes) },
@@ -673,6 +594,7 @@ private fun EventFormScreen(
CollapsingScaffold(
title = stringResource(R.string.settings_section_event_form),
onBack = onBack,
predictiveBack = true,
) {
Text(
text = stringResource(R.string.settings_form_fields_hint),
@@ -706,30 +628,6 @@ private fun EventFormScreen(
)
}
// Auto-focus the title on a new event (issue #10) — on by default, since
// most events get a title; raising the keyboard saves a tap. Off lets you
// set the time/calendar first without the keyboard in the way.
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_autofocus_title),
summary = stringResource(R.string.settings_autofocus_title_hint),
position = Position.Alone,
leading = {
Icon(
imageVector = Icons.Default.Keyboard,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailing = {
Switch(
checked = state.autofocusEventTitle,
onCheckedChange = { viewModel.setAutofocusEventTitle(it) },
)
},
onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) },
)
// Per-event colour on calendars that publish no colour set (some
// CalDAV) — off by default, with the honest caveat that the colour may
// not survive their next sync. Local and palette calendars ignore it.
@@ -807,6 +705,7 @@ private fun NotificationsScreen(
CollapsingScaffold(
title = stringResource(R.string.settings_section_notifications),
onBack = onBack,
predictiveBack = true,
) {
GroupedRow(
title = stringResource(R.string.settings_reminders),
@@ -897,8 +796,8 @@ private fun NotificationsScreen(
)
AnimatedVisibility(
visible = calendarSectionExpanded,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
enter = expandEnter(),
exit = collapseExit(),
) {
Column {
state.writableCalendars.forEach { calendar ->
@@ -927,8 +826,8 @@ private fun NotificationsScreen(
)
AnimatedVisibility(
visible = expanded,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
enter = expandEnter(),
exit = collapseExit(),
) {
Column {
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
@@ -956,6 +855,7 @@ private fun NotificationsScreen(
if (showSnooze) {
OptionPicker(
title = stringResource(R.string.settings_snooze_duration),
predictiveBack = true,
options = SNOOZE_PRESETS,
selected = state.snoozeMinutes,
label = { snoozeDurationLabel(it) },
@@ -1029,12 +929,12 @@ private fun NotificationsScreen(
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
/** A global default (null = none) as a picker choice for selection highlighting. */
private fun Int?.toReminderChoice(): CalendarReminderOverride =
if (this == null) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this)
private fun Int?.toReminderChoice(): ReminderOverride =
if (this == null) ReminderOverride.None else ReminderOverride.Minutes(this)
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
private fun CalendarReminderOverride.toMinutesOrNull(): Int? =
(this as? CalendarReminderOverride.Minutes)?.minutes
private fun ReminderOverride.toMinutesOrNull(): Int? =
(this as? ReminderOverride.Minutes)?.minutes
/**
* Whether Calendula is exempt from battery optimisation, re-read on every
@@ -1109,10 +1009,10 @@ private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
}
/** The stored override for [calendarId], as a picker choice (absent → inherit). */
private fun Map<Long, Int?>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
this[calendarId] == null -> CalendarReminderOverride.None
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId)!!)
private fun Map<Long, Int?>.choiceFor(calendarId: Long): ReminderOverride = when {
!containsKey(calendarId) -> ReminderOverride.Inherit
this[calendarId] == null -> ReminderOverride.None
else -> ReminderOverride.Minutes(this.getValue(calendarId)!!)
}
/** Label for a global-default choice: null → "None", else the lead time. */
@@ -1123,13 +1023,13 @@ private fun reminderChoiceLabel(minutes: Int?): String =
/** Row summary for a calendar: its override, or the inherited global default. */
@Composable
private fun calendarOverrideSummary(
choice: CalendarReminderOverride,
choice: ReminderOverride,
globalDefault: Int?,
): String = when (choice) {
CalendarReminderOverride.Inherit ->
ReminderOverride.Inherit ->
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
ReminderOverride.None -> stringResource(R.string.reminder_none)
is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
}
// ---------------------------------------------------------------------------
@@ -1202,26 +1102,6 @@ private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource(
},
)
/** A small primary-coloured group label, matching the Calendars settings screen. */
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
)
}
@Composable
private fun pastEventDisplayLabel(mode: PastEventDisplay): String = stringResource(
when (mode) {
PastEventDisplay.SHOW -> R.string.settings_past_events_show
PastEventDisplay.DIM -> R.string.settings_past_events_dim
PastEventDisplay.HIDE -> R.string.settings_past_events_hide
},
)
@Composable
private fun languageLabel(tag: String?): String =
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)

View File

@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.ui.settings
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
@@ -25,10 +24,6 @@ data class SettingsUiState(
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
/** Whether the week/day timeline draws an hour separator line (v2.11). */
val showHourLines: Boolean = false,
/** How the Agenda screen treats events that already ended today. */
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
/** Whether the month/week grids fade events that have already finished. */
val dimCompletedEvents: Boolean = false,
/** How far ahead the in-app Agenda screen shows events (v2.11). */
val agendaScreenRange: AgendaRange = AgendaRange.Month,
/** How far ahead the agenda widget shows events (v2.11). */
@@ -39,8 +34,6 @@ data class SettingsUiState(
val defaultView: CalendarView = CalendarView.Week,
/** Optional event-form fields shown by default (rest behind "more fields"). */
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
val autofocusEventTitle: Boolean = true,
/** Whether Calendula posts reminder notifications (v1.4). */
val remindersEnabled: Boolean = true,
/**

View File

@@ -10,10 +10,9 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.CalendarSource
@@ -21,7 +20,6 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
@@ -98,24 +96,15 @@ class SettingsViewModel @Inject constructor(
) { view, screenRange, widgetRange, timeFormat, showHourLines ->
ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines)
},
combine(
prefs.agendaShowRangeBar,
prefs.autofocusEventTitle,
prefs.pastEventDisplay,
prefs.dimCompletedEvents,
::MiscSettings,
),
) { base, defaults, overrides, views, misc ->
) { base, defaults, overrides, views, showRangeBar ->
base.copy(
defaultView = views.defaultView,
agendaScreenRange = views.agendaScreenRange,
agendaWidgetRange = views.agendaWidgetRange,
timeFormat = views.timeFormat,
showHourLines = views.showHourLines,
agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle,
pastEventDisplay = misc.pastEventDisplay,
dimCompletedEvents = misc.dimCompletedEvents,
agendaShowRangeBar = showRangeBar,
allowColorOnUnsupportedCalendars = defaults.allowColor,
defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder,
@@ -159,13 +148,6 @@ class SettingsViewModel @Inject constructor(
val showHourLines: Boolean,
)
private data class MiscSettings(
val showRangeBar: Boolean,
val autofocusEventTitle: Boolean,
val pastEventDisplay: PastEventDisplay,
val dimCompletedEvents: Boolean,
)
fun setThemeMode(mode: ThemeMode) {
viewModelScope.launch { prefs.setThemeMode(mode) }
}
@@ -219,26 +201,6 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setShowHourLines(enabled) }
}
fun setPastEventDisplay(mode: PastEventDisplay) {
viewModelScope.launch {
prefs.setPastEventDisplay(mode)
// The agenda widget honours this setting too, so push it into each
// instance's Glance state and recompose — same reliable-update path as
// setAgendaWidgetRange (updateAll alone won't re-run the data preamble).
widgetRefreshMutex.withLock {
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
updateAppWidgetState(appContext, id) { it[AGENDA_PAST_DISPLAY_KEY] = mode.name }
}
AgendaWidget().updateAll(appContext)
}
}
}
fun setDimCompletedEvents(enabled: Boolean) {
viewModelScope.launch { prefs.setDimCompletedEvents(enabled) }
}
fun setDefaultView(view: CalendarView) {
viewModelScope.launch { prefs.setDefaultView(view) }
}
@@ -251,10 +213,6 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
}
fun setAutofocusEventTitle(enabled: Boolean) {
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
}
fun setDefaultReminderMinutes(minutes: Int?) {
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
}
@@ -271,11 +229,11 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
}
fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
fun setCalendarReminderOverride(calendarId: Long, override: ReminderOverride) {
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
}
fun setCalendarAllDayReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
fun setCalendarAllDayReminderOverride(calendarId: Long, override: ReminderOverride) {
viewModelScope.launch { prefs.setCalendarAllDayReminderOverride(calendarId, override) }
}

View File

@@ -1,49 +1,31 @@
package de.jeanlucmakiola.calendula.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MotionScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import de.jeanlucmakiola.floret.identity.FloretExpressiveTheme
/**
* App theme. Honors:
* - System light/dark.
* - Dynamic Color on API 31+, else falls back to the hand-tuned scheme
* derived from [CalendulaSeed].
* Calendula's theme: the family's [FloretExpressiveTheme] machinery — dynamic
* colour on API 31+, system light/dark, and the **standard** motion scheme
* (spring choreography without the overshoot) — fed with Calendula's own
* identity: its seed-derived fallback schemes ([CalendulaLightFallback] /
* [CalendulaDarkFallback]) and [CalendulaTypography].
*
* The Settings screen (later) can override useDynamicColor and themePreference,
* but the V1 foundation just follows the system.
* The mechanics live in floret-kit's identity module; the look stays here, so a
* sibling app reuses the same theming without inheriting Calendula's palette.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun CalendulaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val ctx = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
}
darkTheme -> CalendulaDarkFallback
else -> CalendulaLightFallback
}
// MaterialExpressiveTheme routes all component + custom motion through
// MaterialTheme.motionScheme (switches, chips, pickers, calendar slide,
// FAB, field reveal). The STANDARD scheme is a deliberate choice over
// expressive(): same spring choreography, but without the overshoot —
// the bouncy variant felt overdone in review (2026-06-11).
MaterialExpressiveTheme(
colorScheme = colorScheme,
FloretExpressiveTheme(
lightScheme = CalendulaLightFallback,
darkScheme = CalendulaDarkFallback,
darkTheme = darkTheme,
dynamicColor = dynamicColor,
typography = CalendulaTypography,
motionScheme = MotionScheme.standard(),
content = content,
)
}

View File

@@ -45,9 +45,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
@@ -57,7 +55,6 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
@@ -76,19 +73,15 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
@@ -97,7 +90,7 @@ import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.floret.components.pastelize
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
@@ -136,14 +129,6 @@ fun WeekScreen(
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle()
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
// The instant before which an event counts as completed, or null when dimming
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
// screen while the setting is off (it stays null regardless of the tick).
val nowState = rememberCurrentMinute()
val dimCutoff by remember(dimCompleted) {
derivedStateOf { if (dimCompleted) nowState.value else null }
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -236,7 +221,6 @@ fun WeekScreen(
)
},
) { innerPadding ->
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
WeekContent(
state = state,
slideDir = slideDir,
@@ -252,7 +236,6 @@ fun WeekScreen(
)
}
}
}
}
@Composable
@@ -567,10 +550,8 @@ private fun AllDayBar(
modifier: Modifier = Modifier,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
modifier = modifier
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp)
@@ -711,7 +692,6 @@ private fun DayColumnCard(
EventBlock(
block = block,
dark = dark,
height = height,
onClick = { onEventClick(block.event) },
modifier = Modifier
.offset(x = laneWidth * block.lane, y = top)
@@ -732,7 +712,6 @@ private fun DayColumnCard(
private fun EventBlock(
block: TimedBlock,
dark: Boolean,
height: Dp,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -741,26 +720,9 @@ private fun EventBlock(
val locale = currentLocale()
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" +
minToHm(block.endMin, use24Hour, locale)
// Only full-width (non-overlapping) blocks that are tall enough show the time.
// On narrow overlapping columns we drop it so the title can wrap to fill the
// whole block, mirroring Google Calendar.
val showTime = block.endMin - block.startMin >= 45 && block.laneCount == 1
val density = LocalDensity.current
val titleLineHeight = with(density) {
MaterialTheme.typography.labelMedium.lineHeight.toDp()
}
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
// Wrap the title across as many lines as the block can fit (minus the 2.dp
// top/bottom padding and the reserved time line) instead of clipping it to a
// single character on slim, overlapping blocks.
val contentHeight = height - 4.dp - if (showTime) timeLineHeight else 0.dp
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
val showTime = block.endMin - block.startMin >= 45
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
modifier = modifier
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 2.dp)
@@ -770,15 +732,18 @@ private fun EventBlock(
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = titleMaxLines,
maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.85f),
)
if (showTime) {
// Narrow columns can't fit "13:0014:00" on one line, so let it
// wrap to a second line (after the dash) instead of clipping the
// end time.
Text(
text = timeLabel,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.6f),
)

View File

@@ -50,14 +50,6 @@ class WeekViewModel @Inject constructor(
private val zone = TimeZone.currentSystemDefault()
private val locale: Locale = Locale.getDefault()
/** Whether to fade events that have already finished (display concern only). */
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date

View File

@@ -4,7 +4,6 @@ import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.EventInstance
@@ -25,7 +24,6 @@ import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import java.util.Locale
import kotlin.time.Clock
import kotlin.time.Instant
/**
* How far either side of today the month widget pre-loads. The displayed month
@@ -54,8 +52,6 @@ sealed interface AgendaWidgetData {
* range is sliced from this in the composition (read reactively from
* Glance state), so a range change is pure recomposition rather than a
* flaky widget session restart — the same approach the month widget uses.
* Kept unfiltered: hiding/dimming finished events also happens reactively
* in the composition, against [now], for the same reliable-update reason.
*/
val days: List<AgendaDay>,
/** Resolved clock convention for event time labels (the time-format pref). */
@@ -64,10 +60,6 @@ sealed interface AgendaWidgetData {
val weekStart: DayOfWeek,
/** Saved range pref — the fallback when an instance has no Glance state yet. */
val savedRange: AgendaRange,
/** Saved past-event display pref — the fallback before Glance state is set. */
val savedPastDisplay: PastEventDisplay,
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
val now: Instant,
) : AgendaWidgetData
}
@@ -116,7 +108,6 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
val ep = widgetEntryPoint()
val prefs = ep.settingsPrefs()
val savedRange = prefs.agendaWidgetRange.first()
val savedPastDisplay = prefs.pastEventDisplay.first()
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
// Load the widest selectable window once; the displayed range is sliced in
// the composition from Glance state, so changing the range is a plain
@@ -131,8 +122,6 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
is24Hour = is24Hour,
weekStart = weekStart,
savedRange = savedRange,
savedPastDisplay = savedPastDisplay,
now = Clock.System.now(),
)
}

View File

@@ -43,17 +43,13 @@ import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
@@ -83,14 +79,6 @@ import java.util.Locale
*/
internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
/**
* Per-instance Glance state key holding the past-event display mode (as
* [PastEventDisplay.name]). Read reactively in the composition for the same
* reason as [AGENDA_RANGE_KEY] — so toggling the setting reflects on the live
* widget without depending on the `provideGlance` preamble re-running.
*/
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
class AgendaWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
@@ -137,26 +125,11 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
// to the saved pref for a freshly placed widget), then the wide
// pre-loaded window is sliced to it — pure recomposition.
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
val pastDisplay =
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
val rangeEnd = data.today.plus(
range.dayCount(data.today, data.weekStart) - 1,
DateTimeUnit.DAY,
)
// Slice to the chosen range, then drop finished events (and any day
// they leave empty) when the mode is Hide — so "Upcoming" really is.
val visibleDays = data.days
.filter { it.date <= rangeEnd }
.let { days ->
if (pastDisplay == PastEventDisplay.HIDE) {
days.mapNotNull { day ->
val remaining = day.events.filterNot { it.hasEnded(data.now) }
if (remaining.isEmpty()) null else day.copy(events = remaining)
}
} else {
days
}
}
val visibleDays = data.days.filter { it.date <= rangeEnd }
if (visibleDays.isEmpty()) {
WidgetMessage(R.string.agenda_empty_title)
} else {
@@ -170,13 +143,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
items(rows.size) { index ->
when (val row = rows[index]) {
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
is AgendaRow.Event -> EventRow(
event = row.event,
dark = dark,
is24Hour = data.is24Hour,
dimmed = pastDisplay == PastEventDisplay.DIM &&
row.event.hasEnded(data.now),
)
is AgendaRow.Event -> EventRow(row.event, dark, data.is24Hour)
}
}
}
@@ -257,15 +224,9 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
}
@Composable
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) {
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean) {
val context = androidx.glance.LocalContext.current
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
// dropping both text lines to the lower-emphasis on-surface-variant tone.
val stripeColor = pastelize(event.color, dark).let {
if (dimmed) it.copy(alpha = EventDimAlpha) else it
}
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
Row(
modifier = GlanceModifier
.fillMaxWidth()
@@ -288,14 +249,14 @@ private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dim
.width(5.dp)
.height(36.dp)
.cornerRadius(3.dp)
.background(stripeColor),
.background(pastelize(event.color, dark)),
) {}
Spacer(GlanceModifier.width(10.dp))
Column(modifier = GlanceModifier.defaultWeight()) {
Text(
text = title,
maxLines = 1,
style = TextStyle(color = titleColor, fontSize = 14.sp),
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
)
Text(
text = eventTimeSummary(context, event, is24Hour),

View File

@@ -50,7 +50,7 @@ import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme

View File

@@ -266,6 +266,7 @@
<!-- Einstellungen (M4) -->
<string name="settings_title">Einstellungen</string>
<string name="settings_back">Zurück</string>
<string name="back">Zurück</string>
<string name="settings_section_appearance">Darstellung</string>
<string name="settings_theme">Design</string>
<string name="settings_theme_system">System</string>
@@ -302,8 +303,6 @@
</plurals>
<string name="settings_section_event_form">Termin-Formular</string>
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
<string name="settings_autofocus_title">Titel bei neuem Termin fokussieren</string>
<string name="settings_autofocus_title_hint">Beim Anlegen eines neuen Termins den Cursor direkt ins Titelfeld setzen und die Tastatur öffnen.</string>
<string name="settings_color_unsupported">Farben auf nicht unterstützten Kalendern erlauben</string>
<string name="settings_color_unsupported_hint">Manche Kalender (z. B. bestimmte CalDAV) stellen keine Farbpalette bereit; eine eigene Terminfarbe wird dort bei der nächsten Synchronisierung unter Umständen verworfen oder überschrieben. Das ist eine Einschränkung dieser Kalender und kann von Calendula nicht behoben werden.</string>
<string name="settings_section_notifications">Benachrichtigungen</string>
@@ -401,14 +400,14 @@
</plurals>
<!-- Absturzberichte: vom Nutzer selbst als Gitea-Issue einreichbar -->
<string name="crash_dialog_title">Calendula ist abgestürzt</string>
<string name="crash_dialog_message">Calendula wurde beim letzten Mal unerwartet beendet. Du kannst bei der Behebung helfen, indem du diesen Bericht als Issue sendest. Er bleibt auf deinem Gerät, bis du ihn teilst, und enthält keine persönlichen Daten oder Kalenderinhalte — nur die technischen Angaben unten.</string>
<string name="crash_dialog_title">%1$s ist abgestürzt</string>
<string name="crash_dialog_message">%1$s wurde beim letzten Mal unerwartet beendet. Du kannst bei der Behebung helfen, indem du diesen Bericht als Issue sendest. Er bleibt auf deinem Gerät, bis du ihn teilst, und enthält keine persönlichen Daten oder Kalenderinhalte — nur die technischen Angaben unten.</string>
<string name="crash_dialog_report">Melden</string>
<string name="crash_dialog_dismiss">Nicht jetzt</string>
<string name="crash_report_issue_title">Absturzbericht</string>
<string name="crash_report_clip_label">Calendula-Absturzbericht</string>
<string name="crash_report_clip_label">%1$s-Absturzbericht</string>
<string name="crash_report_copied">Bericht in die Zwischenablage kopiert</string>
<string name="crash_report_open_failed">Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage.</string>
<string name="crash_report_body_template">Danke, dass du einen Absturz in Calendula meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%1$s\n</string>
<string name="crash_report_body_template">Danke, dass du einen Absturz in %1$s meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%2$s\n</string>
<string name="crash_report_body_paste">_(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_</string>
</resources>

View File

@@ -266,6 +266,8 @@
<!-- Settings (M4) -->
<string name="settings_title">Settings</string>
<string name="settings_back">Back</string>
<!-- Overrides floret-kit components' CollapsingScaffold back-button label. -->
<string name="back">Back</string>
<string name="settings_section_appearance">Appearance</string>
<string name="settings_theme">Theme</string>
<string name="settings_theme_system">System</string>
@@ -282,13 +284,6 @@
<string name="settings_time_format_24h">24-hour (14:00)</string>
<string name="settings_hour_lines">Hour lines</string>
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
<string name="settings_dim_completed">Dim completed events</string>
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
<string name="settings_past_events">Past events</string>
<string name="settings_past_events_show">Show</string>
<string name="settings_past_events_dim">Dim</string>
<string name="settings_past_events_hide">Hide</string>
<string name="settings_agenda_header">Agenda</string>
<string name="settings_agenda_range">Agenda range</string>
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
<string name="settings_agenda_widget_range">Agenda widget range</string>
@@ -310,8 +305,6 @@
</plurals>
<string name="settings_section_event_form">New event form</string>
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
<string name="settings_autofocus_title">Focus title on new event</string>
<string name="settings_autofocus_title_hint">When you start a new event, place the cursor in the title field and open the keyboard right away.</string>
<string name="settings_color_unsupported">Allow colors on unsupported calendars</string>
<string name="settings_color_unsupported_hint">Some calendars (e.g. certain CalDAV) publish no color set; a custom event color may be dropped or overwritten on their next sync. That\'s a limitation of those calendars, not something Calendula can fix.</string>
<string name="settings_section_notifications">Notifications</string>
@@ -451,15 +444,15 @@
<!-- Crash reporting: a captured report the user can submit, by hand, as a
Gitea issue (the app sends nothing automatically). -->
<string name="crash_dialog_title">Calendula crashed</string>
<string name="crash_dialog_message">Calendula closed unexpectedly last time. You can help fix it by sending this report as an issue. It stays on your device until you choose to share it, and includes no personal data or calendar content — only the technical details below.</string>
<string name="crash_dialog_title">%1$s crashed</string>
<string name="crash_dialog_message">%1$s closed unexpectedly last time. You can help fix it by sending this report as an issue. It stays on your device until you choose to share it, and includes no personal data or calendar content — only the technical details below.</string>
<string name="crash_dialog_report">Report</string>
<string name="crash_dialog_dismiss">Not now</string>
<string name="crash_report_issue_title">Crash report</string>
<string name="crash_report_clip_label">Calendula crash report</string>
<string name="crash_report_clip_label">%1$s crash report</string>
<string name="crash_report_copied">Report copied to your clipboard</string>
<string name="crash_report_open_failed">Couldn\'t open the issue tracker. The report is on your clipboard.</string>
<string name="crash_report_body_template">Thanks for reporting a crash in Calendula. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%1$s\n</string>
<string name="crash_report_body_template">Thanks for reporting a crash in %1$s. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%2$s\n</string>
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new</string>
<string name="report_issue_choose_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new/choose</string>

View File

@@ -1,26 +0,0 @@
package de.jeanlucmakiola.calendula.data.calendar
import com.google.common.truth.Truth.assertThat
import kotlin.time.Instant
import org.junit.jupiter.api.Test
class TimeBridgeTest {
@Test
fun `epoch millis round-trips through Instant`() {
val original = 1_717_840_800_000L // 2024-06-08T10:00:00Z
val instant = original.toKotlinInstantFromEpochMillis()
assertThat(instant.toEpochMillis()).isEqualTo(original)
}
@Test
fun `zero millis maps to Instant epoch`() {
assertThat(0L.toKotlinInstantFromEpochMillis()).isEqualTo(Instant.fromEpochMilliseconds(0L))
}
@Test
fun `negative epoch millis is supported`() {
val original = -1_000_000L
assertThat(original.toKotlinInstantFromEpochMillis().toEpochMillis()).isEqualTo(original)
}
}

View File

@@ -1,8 +1,17 @@
package de.jeanlucmakiola.calendula.data.crash
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.floret.crash.CrashContext
import de.jeanlucmakiola.floret.crash.buildCrashReport
import org.junit.jupiter.api.Test
/**
* Guards the privacy contract of the report Calendula ships: the report builder
* itself now lives in floret-kit's core-crash, but the allowlist — exactly the
* app/Android/device/locale/time header lines plus the stack trace, and nothing
* else — is what Calendula relies on, so it stays pinned here with Calendula's
* own app label.
*/
class CrashReportBuilderTest {
private val context = CrashContext(
@@ -17,7 +26,7 @@ class CrashReportBuilderTest {
@Test
fun `report carries the allowlisted facts and the stack trace`() {
val report = buildCrashReport(context, IllegalStateException("boom"), nowMillis = 0L)
val report = buildCrashReport(context, IllegalStateException("boom"), nowMillis = 0L, appLabel = "Calendula")
assertThat(report).startsWith("Calendula crash report")
assertThat(report).contains("App version: 2.7.0 (20700)")
@@ -33,7 +42,7 @@ class CrashReportBuilderTest {
@Test
fun `nested causes are included`() {
val cause = NullPointerException("inner")
val report = buildCrashReport(context, RuntimeException("outer", cause), nowMillis = 0L)
val report = buildCrashReport(context, RuntimeException("outer", cause), nowMillis = 0L, appLabel = "Calendula")
assertThat(report).contains("outer")
assertThat(report).contains("Caused by")
@@ -42,7 +51,7 @@ class CrashReportBuilderTest {
@Test
fun `report holds only the allowlisted lines before the stack trace`() {
val report = buildCrashReport(context, Exception("x"), nowMillis = 0L)
val report = buildCrashReport(context, Exception("x"), nowMillis = 0L, appLabel = "Calendula")
val header = report.substringBefore("Stack trace:").trim().lines()
// No identifiers, accounts, or extra fields ever creep into the header:

View File

@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.DayOfWeek
@@ -90,14 +91,6 @@ class SettingsPrefsTest {
assertThat(prefs.showHourLines.first()).isTrue()
}
@Test
fun `autofocus event title defaults on and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.autofocusEventTitle.first()).isTrue()
prefs.setAutofocusEventTitle(false)
assertThat(prefs.autofocusEventTitle.first()).isFalse()
}
@Test
fun `agenda ranges default to month and round-trip independently`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
@@ -214,14 +207,14 @@ class SettingsPrefsTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.perCalendarReminderOverride.first()).isEmpty()
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15))
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.None)
prefs.setCalendarReminderOverride(7L, ReminderOverride.Minutes(15))
prefs.setCalendarReminderOverride(9L, ReminderOverride.None)
prefs.perCalendarReminderOverride.first().let { map ->
assertThat(map).containsExactly(7L, 15, 9L, null)
}
// Inherit drops the override entirely (absent != null value).
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.Inherit)
prefs.setCalendarReminderOverride(9L, ReminderOverride.Inherit)
prefs.perCalendarReminderOverride.first().let { map ->
assertThat(map).containsExactly(7L, 15)
assertThat(map.containsKey(9L)).isFalse()
@@ -241,12 +234,12 @@ class SettingsPrefsTest {
@Test
fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15))
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(1_440))
prefs.setCalendarReminderOverride(7L, ReminderOverride.Minutes(15))
prefs.setCalendarAllDayReminderOverride(7L, ReminderOverride.Minutes(1_440))
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15)
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, 1_440)
// Clearing the all-day override leaves the timed one untouched.
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Inherit)
prefs.setCalendarAllDayReminderOverride(7L, ReminderOverride.Inherit)
assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty()
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15)
}

View File

@@ -43,11 +43,21 @@ Builds:
versionCode: 20705
commit: v2.7.5
subdir: app
submodules: true
gradle:
- yes
# No NDK / no flavors. The release buildType applies a signingConfig only
# when key.properties exists; on the buildserver it does not, so this
# produces the unsigned APK F-Droid compares against our binary.
#
# submodules: true — REQUIRED from v2.11.0 onward, where the build pulls
# shared code from the `floret-kit` git submodule via a Gradle composite
# build (`includeBuild("floret-kit")`). Without it F-Droid checks out an
# empty floret-kit/ and the from-source build fails, stalling publishing.
# Inert for v2.7.5 (no submodule yet); kept here so AutoUpdateMode copies it
# onto every auto-generated future build entry. The kit is plain-Kotlin and
# carries no foojay toolchain resolver, so it clears the same reproducibility
# bar (enforced by scripts/check_reproducible_release.sh).
# SHA-256 of our app signing certificate (public; embedded in every published
# APK). Locks F-Droid to publish only binaries signed with our key.

View File

@@ -1,6 +0,0 @@
### Fixed
- Calendula can now be set as your default calendar app. It registers the
calendar-app intent filters the system uses, so it appears in the chooser when
you tap a date in a launcher or clock — and opening one takes you straight to
that day. Android has no way for an app to make itself the default, so you pick
it once from the system picker. Thanks to @abrossimow for the report ([#9]).

View File

@@ -1,7 +0,0 @@
### Added
- Jump straight to typing a title. When you start a new event, Calendula now puts
the cursor in the title field and opens the keyboard right away, so you can type
the name without an extra tap. It's on by default and only affects creating an
event — editing never grabs focus — and a new **Focus title on new event**
switch (Settings → New event form) turns it off. Thanks to @abrossimow for the
suggestion ([#10]).

View File

@@ -1,19 +0,0 @@
### Added
- See past events your way. Two new settings change how events that have already
ended are shown. **Past events** (Settings → Appearance → Agenda) lets the
agenda — both in the app and the home-screen "Upcoming" widget — keep them as
usual, dim them, or hide them from the list entirely. **Dim completed events**
(Settings → Appearance) separately fades finished events in the month and week
views. Both are off by default, an event only counts as finished once it has
actually ended (events still in progress are never dimmed), and the lists
update on their own as the day goes on. Thanks to @ptab for the suggestion
([#12]).
### Changed
- Readable titles on overlapping events. In the week and day views, events that
overlap split a day into slim columns where the title used to be clipped to a
character or two. The title now wraps across as many lines as the block can
fit, and the time is hidden on those narrow blocks so the name can fill the
space — so you can tell events apart without opening each one. Thanks to @ptab
for the suggestion ([#13]).

1
floret-kit Submodule

Submodule floret-kit added at d9e4e877cc

View File

@@ -34,8 +34,15 @@ else
fi
# 2. The foojay toolchain resolver must not be present in any Gradle script.
# This includes the floret-kit submodule: it's an included build (composite
# build via `includeBuild`), so F-Droid evaluates its Gradle scripts too when
# building from source — the same offline-scanner bar applies to it.
gradle_files=("$SETTINGS" "$APP")
[ -f build.gradle.kts ] && gradle_files+=(build.gradle.kts)
if [ -d floret-kit ]; then
while IFS= read -r f; do gradle_files+=("$f"); done \
< <(find floret-kit -name '*.gradle.kts' -not -path '*/build/*')
fi
if grep -qi 'foojay' "${gradle_files[@]}"; then
echo "ERROR: foojay toolchain resolver found in: $(grep -li foojay "${gradle_files[@]}" | tr '\n' ' ')" >&2
echo " F-Droid's source scanner rejects org.gradle.toolchains.foojay-resolver" >&2

View File

@@ -22,3 +22,4 @@ dependencyResolutionManagement {
rootProject.name = "Calendula"
include(":app")
includeBuild("floret-kit")