Compare commits
1 Commits
release/v2
...
e726db9606
| Author | SHA1 | Date | |
|---|---|---|---|
| e726db9606 |
@@ -26,6 +26,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
with:
|
with:
|
||||||
# Full history so the base..HEAD diff below has a merge-base.
|
# Full history so the base..HEAD diff below has a merge-base.
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Resolve version and whether it is a new release
|
- name: Resolve version and whether it is a new release
|
||||||
id: v
|
id: v
|
||||||
@@ -82,6 +84,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
|
|||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "floret-kit"]
|
||||||
|
path = floret-kit
|
||||||
|
url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git
|
||||||
44
CHANGELOG.md
44
CHANGELOG.md
@@ -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/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [2.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
|
## [2.11.0] — 2026-06-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -725,7 +685,3 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#6]: https://codeberg.org/jlmakiola/calendula/issues/6
|
[#6]: https://codeberg.org/jlmakiola/calendula/issues/6
|
||||||
[#7]: https://codeberg.org/jlmakiola/calendula/issues/7
|
[#7]: https://codeberg.org/jlmakiola/calendula/issues/7
|
||||||
[#8]: https://codeberg.org/jlmakiola/calendula/issues/8
|
[#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
|
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 21200
|
versionCode = 21100
|
||||||
versionName = "2.12.0"
|
versionName = "2.11.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -159,6 +159,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.glance.material3)
|
implementation(libs.androidx.glance.material3)
|
||||||
|
|
||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-time")
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
|||||||
@@ -59,35 +59,6 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</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). -->
|
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|||||||
@@ -35,9 +35,6 @@ import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
|||||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
@@ -159,33 +156,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||||
else -> null
|
else -> null
|
||||||
} ?: return 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" }
|
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? {
|
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 source = sourceViewOrNull()
|
||||||
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||||
return when {
|
return when {
|
||||||
@@ -227,10 +201,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
|
||||||
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
|
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
|
||||||
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
|
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -38,19 +38,6 @@ sealed interface WeekStartPref {
|
|||||||
*/
|
*/
|
||||||
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
|
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
|
* Resolve to a concrete 24-hour flag. AUTO defers to [systemIs24Hour] (the
|
||||||
* device's `DateFormat.is24HourFormat` value).
|
* device's `DateFormat.is24HourFormat` value).
|
||||||
@@ -129,31 +116,6 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
|
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
|
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
||||||
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
||||||
@@ -220,20 +182,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 —
|
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
||||||
* for users whose only calendar app this is, reminders are essential; the
|
* for users whose only calendar app this is, reminders are essential; the
|
||||||
@@ -442,11 +390,8 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
||||||
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
||||||
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
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 DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
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 REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||||
|
|||||||
@@ -40,14 +40,6 @@ data class EventInstance(
|
|||||||
val location: String?,
|
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(
|
data class EventDetail(
|
||||||
val instance: EventInstance,
|
val instance: EventInstance,
|
||||||
val description: String?,
|
val description: String?,
|
||||||
|
|||||||
@@ -196,9 +196,7 @@ fun CalendarHost(
|
|||||||
dismissCoveringOverlays()
|
dismissCoveringOverlays()
|
||||||
createDateIso = null
|
createDateIso = null
|
||||||
pendingDayIso = req.dateIso
|
pendingDayIso = req.dateIso
|
||||||
// No widget source (an external date tap) roots over the default
|
viewStack = viewBaseStack(defaultView, req.source).drillToDay()
|
||||||
// home view, so backing out of the day returns home then exits.
|
|
||||||
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
|
|
||||||
onWidgetNavConsumed()
|
onWidgetNavConsumed()
|
||||||
}
|
}
|
||||||
is WidgetNavRequest.OpenEvent -> {
|
is WidgetNavRequest.OpenEvent -> {
|
||||||
|
|||||||
@@ -13,13 +13,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
|||||||
* detail-key channel and leave the base view untouched.)
|
* detail-key channel and leave the base view untouched.)
|
||||||
*/
|
*/
|
||||||
sealed interface WidgetNavRequest {
|
sealed interface WidgetNavRequest {
|
||||||
/**
|
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over [source]. */
|
||||||
* Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over
|
data class OpenDate(val dateIso: String, val source: CalendarView) : WidgetNavRequest
|
||||||
* [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 one occurrence's detail (an agenda-widget event tap), over [source]. */
|
/** Open one occurrence's detail (an agenda-widget event tap), over [source]. */
|
||||||
data class OpenEvent(
|
data class OpenEvent(
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -54,9 +53,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
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.AgendaRangePicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
|
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
|
||||||
@@ -64,14 +61,12 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
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.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
@@ -101,7 +96,6 @@ fun AgendaScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
@@ -184,7 +178,6 @@ fun AgendaScreen(
|
|||||||
}
|
}
|
||||||
AgendaContent(
|
AgendaContent(
|
||||||
state = state,
|
state = state,
|
||||||
pastDisplay = pastDisplay,
|
|
||||||
onRetry = viewModel::goToToday,
|
onRetry = viewModel::goToToday,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -282,7 +275,6 @@ private fun AgendaRangeBanner(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun AgendaContent(
|
private fun AgendaContent(
|
||||||
state: AgendaUiState,
|
state: AgendaUiState,
|
||||||
pastDisplay: PastEventDisplay,
|
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -292,31 +284,11 @@ private fun AgendaContent(
|
|||||||
is AgendaUiState.Failure -> Box(modifier) {
|
is AgendaUiState.Failure -> Box(modifier) {
|
||||||
CalendarFailure(reason = state.reason, onRetry = onRetry)
|
CalendarFailure(reason = state.reason, onRetry = onRetry)
|
||||||
}
|
}
|
||||||
is AgendaUiState.Success -> {
|
is AgendaUiState.Success ->
|
||||||
val now by rememberCurrentMinute()
|
if (state.days.isEmpty()) {
|
||||||
// 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()) {
|
|
||||||
AgendaEmpty(modifier)
|
AgendaEmpty(modifier)
|
||||||
} else {
|
} else {
|
||||||
AgendaList(
|
AgendaList(state = state, onEventClick = onEventClick, modifier = modifier)
|
||||||
days = days,
|
|
||||||
today = state.today,
|
|
||||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
|
||||||
now = now,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,10 +296,7 @@ private fun AgendaContent(
|
|||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaList(
|
private fun AgendaList(
|
||||||
days: List<AgendaDay>,
|
state: AgendaUiState.Success,
|
||||||
today: LocalDate,
|
|
||||||
dimPast: Boolean,
|
|
||||||
now: Instant,
|
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -336,9 +305,9 @@ private fun AgendaList(
|
|||||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||||
) {
|
) {
|
||||||
days.forEach { day ->
|
state.days.forEach { day ->
|
||||||
stickyHeader(key = "header-${day.date}") {
|
stickyHeader(key = "header-${day.date}") {
|
||||||
AgendaDayHeader(date = day.date, today = today)
|
AgendaDayHeader(date = day.date, today = state.today)
|
||||||
}
|
}
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = day.events,
|
items = day.events,
|
||||||
@@ -347,7 +316,6 @@ private fun AgendaList(
|
|||||||
AgendaEventRow(
|
AgendaEventRow(
|
||||||
event = event,
|
event = event,
|
||||||
position = positionOf(index, day.events.size),
|
position = positionOf(index, day.events.size),
|
||||||
dimmed = dimPast && event.hasEnded(now),
|
|
||||||
modifier = calendarAnimateItem(),
|
modifier = calendarAnimateItem(),
|
||||||
onClick = { onEventClick(event) },
|
onClick = { onEventClick(event) },
|
||||||
)
|
)
|
||||||
@@ -380,14 +348,13 @@ private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
|||||||
private fun AgendaEventRow(
|
private fun AgendaEventRow(
|
||||||
event: EventInstance,
|
event: EventInstance,
|
||||||
position: Position,
|
position: Position,
|
||||||
dimmed: Boolean,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
modifier = modifier,
|
||||||
title = title,
|
title = title,
|
||||||
summary = agendaTimeSummary(event),
|
summary = agendaTimeSummary(event),
|
||||||
position = position,
|
position = position,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
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.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
@@ -54,18 +53,6 @@ class AgendaViewModel @Inject constructor(
|
|||||||
private val weekStartDay = settingsPrefs.weekStart
|
private val weekStartDay = settingsPrefs.weekStart
|
||||||
.map { it.resolveFirstDay(Locale.getDefault()) }
|
.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 zone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ private val LineThickness = 2.dp
|
|||||||
* the middle of a minute. Drives the "now" line in the day/week grids.
|
* the middle of a minute. Drives the "now" line in the day/week grids.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberCurrentMinute(): State<Instant> {
|
private fun rememberCurrentMinute(): State<Instant> {
|
||||||
val instant = remember { mutableStateOf(Clock.System.now()) }
|
val instant = remember { mutableStateOf(Clock.System.now()) }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -81,8 +81,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
@@ -481,17 +479,6 @@ private fun EventEditContent(
|
|||||||
?: MaterialTheme.colorScheme.primary
|
?: MaterialTheme.colorScheme.primary
|
||||||
val gap = 12.dp
|
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:
|
// Per-event colour applicability for the resolved calendar:
|
||||||
// - palette calendars (Google, …) and local calendars always support it;
|
// - palette calendars (Google, …) and local calendars always support it;
|
||||||
// - synced calendars with no palette only when the user opted in, and even
|
// - synced calendars with no palette only when the user opted in, and even
|
||||||
@@ -519,10 +506,6 @@ private fun EventEditContent(
|
|||||||
placeholder = stringResource(R.string.event_edit_title_hint),
|
placeholder = stringResource(R.string.event_edit_title_hint),
|
||||||
textStyle = MaterialTheme.typography.headlineMedium
|
textStyle = MaterialTheme.typography.headlineMedium
|
||||||
.copy(fontWeight = FontWeight.SemiBold),
|
.copy(fontWeight = FontWeight.SemiBold),
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 4.dp)
|
|
||||||
.focusRequester(titleFocusRequester),
|
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Box(
|
Box(
|
||||||
|
|||||||
@@ -29,12 +29,6 @@ data class EventEditUiState(
|
|||||||
val hiddenFields: List<EventFormField> = emptyList(),
|
val hiddenFields: List<EventFormField> = emptyList(),
|
||||||
/** True while editing an existing event (the calendar is then fixed). */
|
/** True while editing an existing event (the calendar is then fixed). */
|
||||||
val isEditing: Boolean = false,
|
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
|
* 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).
|
* then drops "only this event" (an exception row can't carry a rule).
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val lastUsed: Long?,
|
val lastUsed: Long?,
|
||||||
val defaultFields: Set<EventFormField>,
|
val defaultFields: Set<EventFormField>,
|
||||||
val allowColorOnUnsupported: Boolean,
|
val allowColorOnUnsupported: Boolean,
|
||||||
val autofocusTitle: Boolean,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,7 +160,6 @@ class EventEditViewModel @Inject constructor(
|
|||||||
prefs.lastUsedCalendarId,
|
prefs.lastUsedCalendarId,
|
||||||
settingsPrefs.defaultFormFields,
|
settingsPrefs.defaultFormFields,
|
||||||
settingsPrefs.allowColorOnUnsupportedCalendars,
|
settingsPrefs.allowColorOnUnsupportedCalendars,
|
||||||
settingsPrefs.autofocusEventTitle,
|
|
||||||
::ExternalInputs,
|
::ExternalInputs,
|
||||||
).flowOn(io),
|
).flowOn(io),
|
||||||
colorPalette,
|
colorPalette,
|
||||||
@@ -180,7 +178,6 @@ class EventEditViewModel @Inject constructor(
|
|||||||
visibleFields = visibleFields,
|
visibleFields = visibleFields,
|
||||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
||||||
isEditing = local.editTarget != null,
|
isEditing = local.editTarget != null,
|
||||||
autofocusTitle = external.autofocusTitle,
|
|
||||||
// A modified-occurrence exception can't carry its own rule, so
|
// A modified-occurrence exception can't carry its own rule, so
|
||||||
// the scope dialog drops "only this event" after a rule change.
|
// the scope dialog drops "only this event" after a rule change.
|
||||||
recurrenceChanged = local.editTarget != null &&
|
recurrenceChanged = local.editTarget != null &&
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
@@ -46,7 +44,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -63,14 +60,10 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
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.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
@@ -104,14 +97,6 @@ fun MonthScreen(
|
|||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val month by viewModel.month.collectAsStateWithLifecycle()
|
val month by viewModel.month.collectAsStateWithLifecycle()
|
||||||
val weekStart by viewModel.weekStart.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 scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
@@ -208,7 +193,6 @@ fun MonthScreen(
|
|||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
WeekdayHeader(weekStart = weekStart)
|
WeekdayHeader(weekStart = weekStart)
|
||||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
|
||||||
MonthContent(
|
MonthContent(
|
||||||
state = state,
|
state = state,
|
||||||
slideDir = slideDir,
|
slideDir = slideDir,
|
||||||
@@ -220,7 +204,6 @@ fun MonthScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -575,8 +558,6 @@ private fun MonthBar(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
val dimCutoff = LocalDimCutoff.current
|
|
||||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
|
||||||
val shape = RoundedCornerShape(
|
val shape = RoundedCornerShape(
|
||||||
topStart = if (continuesLeft) 0.dp else 4.dp,
|
topStart = if (continuesLeft) 0.dp else 4.dp,
|
||||||
bottomStart = 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,
|
bottomEnd = if (continuesRight) 0.dp else 4.dp,
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = modifier
|
||||||
.background(pastelize(event.color, dark), shape)
|
.background(pastelize(event.color, dark), shape)
|
||||||
.padding(horizontal = 4.dp)
|
.padding(horizontal = 4.dp)
|
||||||
.semantics { contentDescription = title },
|
.semantics { contentDescription = title },
|
||||||
|
|||||||
@@ -59,14 +59,6 @@ class MonthViewModel @Inject constructor(
|
|||||||
initialValue = DayOfWeek.MONDAY,
|
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
|
private val todayDate: LocalDate
|
||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ import androidx.compose.material.icons.filled.ExpandLess
|
|||||||
import androidx.compose.material.icons.filled.ExpandMore
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.Favorite
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
import androidx.compose.material.icons.filled.Gavel
|
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.Language
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
@@ -86,7 +85,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
@@ -476,7 +474,6 @@ private fun AppearanceScreen(
|
|||||||
var showDefaultView by remember { mutableStateOf(false) }
|
var showDefaultView by remember { mutableStateOf(false) }
|
||||||
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||||
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
||||||
var showPastEvents by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_appearance),
|
title = stringResource(R.string.settings_section_appearance),
|
||||||
@@ -535,7 +532,7 @@ private fun AppearanceScreen(
|
|||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_hour_lines),
|
title = stringResource(R.string.settings_hour_lines),
|
||||||
summary = stringResource(R.string.settings_hour_lines_summary),
|
summary = stringResource(R.string.settings_hour_lines_summary),
|
||||||
position = Position.Middle,
|
position = Position.Bottom,
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = state.showHourLines,
|
checked = state.showHourLines,
|
||||||
@@ -544,36 +541,16 @@ private fun AppearanceScreen(
|
|||||||
},
|
},
|
||||||
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
|
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))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// Agenda — the only group labelled, so its agenda-specific rows are easy
|
// Agenda
|
||||||
// to pick out among the otherwise unheadered Appearance settings.
|
|
||||||
SectionHeader(stringResource(R.string.settings_agenda_header))
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_agenda_range),
|
title = stringResource(R.string.settings_agenda_range),
|
||||||
summary = agendaRangeLabel(state.agendaScreenRange),
|
summary = agendaRangeLabel(state.agendaScreenRange),
|
||||||
position = Position.Top,
|
position = Position.Top,
|
||||||
onClick = { showAgendaScreenRange = true },
|
onClick = { showAgendaScreenRange = true },
|
||||||
)
|
)
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_past_events),
|
|
||||||
summary = pastEventDisplayLabel(state.pastEventDisplay),
|
|
||||||
position = Position.Middle,
|
|
||||||
onClick = { showPastEvents = true },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_agenda_widget_range),
|
title = stringResource(R.string.settings_agenda_widget_range),
|
||||||
summary = agendaRangeLabel(state.agendaWidgetRange),
|
summary = agendaRangeLabel(state.agendaWidgetRange),
|
||||||
@@ -642,16 +619,6 @@ private fun AppearanceScreen(
|
|||||||
onDismiss = { showTimeFormat = false },
|
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) {
|
if (showDefaultView) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_default_view),
|
title = stringResource(R.string.settings_default_view),
|
||||||
@@ -706,30 +673,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
|
// Per-event colour on calendars that publish no colour set (some
|
||||||
// CalDAV) — off by default, with the honest caveat that the colour may
|
// CalDAV) — off by default, with the honest caveat that the colour may
|
||||||
// not survive their next sync. Local and palette calendars ignore it.
|
// not survive their next sync. Local and palette calendars ignore it.
|
||||||
@@ -1202,26 +1145,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
|
@Composable
|
||||||
private fun languageLabel(tag: String?): String =
|
private fun languageLabel(tag: String?): String =
|
||||||
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
|
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
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.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
@@ -25,10 +24,6 @@ data class SettingsUiState(
|
|||||||
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
||||||
/** Whether the week/day timeline draws an hour separator line (v2.11). */
|
/** Whether the week/day timeline draws an hour separator line (v2.11). */
|
||||||
val showHourLines: Boolean = false,
|
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). */
|
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||||
/** How far ahead the agenda widget shows events (v2.11). */
|
/** How far ahead the agenda widget shows events (v2.11). */
|
||||||
@@ -39,8 +34,6 @@ data class SettingsUiState(
|
|||||||
val defaultView: CalendarView = CalendarView.Week,
|
val defaultView: CalendarView = CalendarView.Week,
|
||||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_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). */
|
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||||
val remindersEnabled: Boolean = true,
|
val remindersEnabled: Boolean = true,
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
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.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
@@ -21,7 +20,6 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
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.AGENDA_RANGE_KEY
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||||
@@ -98,24 +96,15 @@ class SettingsViewModel @Inject constructor(
|
|||||||
) { view, screenRange, widgetRange, timeFormat, showHourLines ->
|
) { view, screenRange, widgetRange, timeFormat, showHourLines ->
|
||||||
ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines)
|
ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines)
|
||||||
},
|
},
|
||||||
combine(
|
|
||||||
prefs.agendaShowRangeBar,
|
prefs.agendaShowRangeBar,
|
||||||
prefs.autofocusEventTitle,
|
) { base, defaults, overrides, views, showRangeBar ->
|
||||||
prefs.pastEventDisplay,
|
|
||||||
prefs.dimCompletedEvents,
|
|
||||||
::MiscSettings,
|
|
||||||
),
|
|
||||||
) { base, defaults, overrides, views, misc ->
|
|
||||||
base.copy(
|
base.copy(
|
||||||
defaultView = views.defaultView,
|
defaultView = views.defaultView,
|
||||||
agendaScreenRange = views.agendaScreenRange,
|
agendaScreenRange = views.agendaScreenRange,
|
||||||
agendaWidgetRange = views.agendaWidgetRange,
|
agendaWidgetRange = views.agendaWidgetRange,
|
||||||
timeFormat = views.timeFormat,
|
timeFormat = views.timeFormat,
|
||||||
showHourLines = views.showHourLines,
|
showHourLines = views.showHourLines,
|
||||||
agendaShowRangeBar = misc.showRangeBar,
|
agendaShowRangeBar = showRangeBar,
|
||||||
autofocusEventTitle = misc.autofocusEventTitle,
|
|
||||||
pastEventDisplay = misc.pastEventDisplay,
|
|
||||||
dimCompletedEvents = misc.dimCompletedEvents,
|
|
||||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
@@ -159,13 +148,6 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val showHourLines: Boolean,
|
val showHourLines: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class MiscSettings(
|
|
||||||
val showRangeBar: Boolean,
|
|
||||||
val autofocusEventTitle: Boolean,
|
|
||||||
val pastEventDisplay: PastEventDisplay,
|
|
||||||
val dimCompletedEvents: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun setThemeMode(mode: ThemeMode) {
|
fun setThemeMode(mode: ThemeMode) {
|
||||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||||
}
|
}
|
||||||
@@ -219,26 +201,6 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setShowHourLines(enabled) }
|
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) {
|
fun setDefaultView(view: CalendarView) {
|
||||||
viewModelScope.launch { prefs.setDefaultView(view) }
|
viewModelScope.launch { prefs.setDefaultView(view) }
|
||||||
}
|
}
|
||||||
@@ -251,10 +213,6 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setAutofocusEventTitle(enabled: Boolean) {
|
|
||||||
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setDefaultReminderMinutes(minutes: Int?) {
|
fun setDefaultReminderMinutes(minutes: Int?) {
|
||||||
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,9 +45,7 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
@@ -57,7 +55,6 @@ import androidx.compose.runtime.snapshotFlow
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -76,15 +73,11 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
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.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
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.NowLine
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
@@ -136,14 +129,6 @@ fun WeekScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val weekStart by viewModel.weekStartDate.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 scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
@@ -236,7 +221,6 @@ fun WeekScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
|
||||||
WeekContent(
|
WeekContent(
|
||||||
state = state,
|
state = state,
|
||||||
slideDir = slideDir,
|
slideDir = slideDir,
|
||||||
@@ -252,7 +236,6 @@ fun WeekScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -567,10 +550,8 @@ private fun AllDayBar(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
val dimCutoff = LocalDimCutoff.current
|
|
||||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = modifier
|
||||||
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
@@ -711,7 +692,6 @@ private fun DayColumnCard(
|
|||||||
EventBlock(
|
EventBlock(
|
||||||
block = block,
|
block = block,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
height = height,
|
|
||||||
onClick = { onEventClick(block.event) },
|
onClick = { onEventClick(block.event) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(x = laneWidth * block.lane, y = top)
|
.offset(x = laneWidth * block.lane, y = top)
|
||||||
@@ -732,7 +712,6 @@ private fun DayColumnCard(
|
|||||||
private fun EventBlock(
|
private fun EventBlock(
|
||||||
block: TimedBlock,
|
block: TimedBlock,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
height: Dp,
|
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -741,26 +720,9 @@ private fun EventBlock(
|
|||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||||
minToHm(block.endMin, use24Hour, locale)
|
minToHm(block.endMin, use24Hour, locale)
|
||||||
// Only full-width (non-overlapping) blocks that are tall enough show the time.
|
val showTime = block.endMin - block.startMin >= 45
|
||||||
// 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)
|
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = modifier
|
||||||
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||||
@@ -770,15 +732,18 @@ private fun EventBlock(
|
|||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
maxLines = titleMaxLines,
|
maxLines = if (showTime) 1 else 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = Color.Black.copy(alpha = 0.85f),
|
color = Color.Black.copy(alpha = 0.85f),
|
||||||
)
|
)
|
||||||
if (showTime) {
|
if (showTime) {
|
||||||
|
// Narrow columns can't fit "13:00–14:00" on one line, so let it
|
||||||
|
// wrap to a second line (after the dash) instead of clipping the
|
||||||
|
// end time.
|
||||||
Text(
|
Text(
|
||||||
text = timeLabel,
|
text = timeLabel,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = Color.Black.copy(alpha = 0.6f),
|
color = Color.Black.copy(alpha = 0.6f),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -50,14 +50,6 @@ class WeekViewModel @Inject constructor(
|
|||||||
private val zone = TimeZone.currentSystemDefault()
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
private val locale: Locale = Locale.getDefault()
|
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
|
private val todayDate: LocalDate
|
||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import android.Manifest
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import androidx.core.content.ContextCompat
|
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.is24Hour
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
@@ -25,7 +24,6 @@ import kotlinx.datetime.toInstant
|
|||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far either side of today the month widget pre-loads. The displayed month
|
* 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
|
* range is sliced from this in the composition (read reactively from
|
||||||
* Glance state), so a range change is pure recomposition rather than a
|
* Glance state), so a range change is pure recomposition rather than a
|
||||||
* flaky widget session restart — the same approach the month widget uses.
|
* 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>,
|
val days: List<AgendaDay>,
|
||||||
/** Resolved clock convention for event time labels (the time-format pref). */
|
/** Resolved clock convention for event time labels (the time-format pref). */
|
||||||
@@ -64,10 +60,6 @@ sealed interface AgendaWidgetData {
|
|||||||
val weekStart: DayOfWeek,
|
val weekStart: DayOfWeek,
|
||||||
/** Saved range pref — the fallback when an instance has no Glance state yet. */
|
/** Saved range pref — the fallback when an instance has no Glance state yet. */
|
||||||
val savedRange: AgendaRange,
|
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
|
) : AgendaWidgetData
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +108,6 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
|||||||
val ep = widgetEntryPoint()
|
val ep = widgetEntryPoint()
|
||||||
val prefs = ep.settingsPrefs()
|
val prefs = ep.settingsPrefs()
|
||||||
val savedRange = prefs.agendaWidgetRange.first()
|
val savedRange = prefs.agendaWidgetRange.first()
|
||||||
val savedPastDisplay = prefs.pastEventDisplay.first()
|
|
||||||
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||||
// Load the widest selectable window once; the displayed range is sliced in
|
// Load the widest selectable window once; the displayed range is sliced in
|
||||||
// the composition from Glance state, so changing the range is a plain
|
// the composition from Glance state, so changing the range is a plain
|
||||||
@@ -131,8 +122,6 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
|||||||
is24Hour = is24Hour,
|
is24Hour = is24Hour,
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
savedRange = savedRange,
|
savedRange = savedRange,
|
||||||
savedPastDisplay = savedPastDisplay,
|
|
||||||
now = Clock.System.now(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,15 +43,11 @@ import androidx.glance.text.Text
|
|||||||
import androidx.glance.text.TextStyle
|
import androidx.glance.text.TextStyle
|
||||||
import de.jeanlucmakiola.calendula.MainActivity
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
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.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
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.formatTimeOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
@@ -83,14 +79,6 @@ import java.util.Locale
|
|||||||
*/
|
*/
|
||||||
internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
|
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() {
|
class AgendaWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
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
|
// to the saved pref for a freshly placed widget), then the wide
|
||||||
// pre-loaded window is sliced to it — pure recomposition.
|
// pre-loaded window is sliced to it — pure recomposition.
|
||||||
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
||||||
val pastDisplay =
|
|
||||||
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
|
||||||
val rangeEnd = data.today.plus(
|
val rangeEnd = data.today.plus(
|
||||||
range.dayCount(data.today, data.weekStart) - 1,
|
range.dayCount(data.today, data.weekStart) - 1,
|
||||||
DateTimeUnit.DAY,
|
DateTimeUnit.DAY,
|
||||||
)
|
)
|
||||||
// Slice to the chosen range, then drop finished events (and any day
|
val visibleDays = data.days.filter { it.date <= rangeEnd }
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (visibleDays.isEmpty()) {
|
if (visibleDays.isEmpty()) {
|
||||||
WidgetMessage(R.string.agenda_empty_title)
|
WidgetMessage(R.string.agenda_empty_title)
|
||||||
} else {
|
} else {
|
||||||
@@ -170,13 +143,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
items(rows.size) { index ->
|
items(rows.size) { index ->
|
||||||
when (val row = rows[index]) {
|
when (val row = rows[index]) {
|
||||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
||||||
is AgendaRow.Event -> EventRow(
|
is AgendaRow.Event -> EventRow(row.event, dark, data.is24Hour)
|
||||||
event = row.event,
|
|
||||||
dark = dark,
|
|
||||||
is24Hour = data.is24Hour,
|
|
||||||
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
|
||||||
row.event.hasEnded(data.now),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,15 +224,9 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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 context = androidx.glance.LocalContext.current
|
||||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
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(
|
Row(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -288,14 +249,14 @@ private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dim
|
|||||||
.width(5.dp)
|
.width(5.dp)
|
||||||
.height(36.dp)
|
.height(36.dp)
|
||||||
.cornerRadius(3.dp)
|
.cornerRadius(3.dp)
|
||||||
.background(stripeColor),
|
.background(pastelize(event.color, dark)),
|
||||||
) {}
|
) {}
|
||||||
Spacer(GlanceModifier.width(10.dp))
|
Spacer(GlanceModifier.width(10.dp))
|
||||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = titleColor, fontSize = 14.sp),
|
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = eventTimeSummary(context, event, is24Hour),
|
text = eventTimeSummary(context, event, is24Hour),
|
||||||
|
|||||||
@@ -302,8 +302,6 @@
|
|||||||
</plurals>
|
</plurals>
|
||||||
<string name="settings_section_event_form">Termin-Formular</string>
|
<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_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">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_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>
|
<string name="settings_section_notifications">Benachrichtigungen</string>
|
||||||
|
|||||||
@@ -282,13 +282,6 @@
|
|||||||
<string name="settings_time_format_24h">24-hour (14:00)</string>
|
<string name="settings_time_format_24h">24-hour (14:00)</string>
|
||||||
<string name="settings_hour_lines">Hour lines</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_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">Agenda range</string>
|
||||||
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</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>
|
<string name="settings_agenda_widget_range">Agenda widget range</string>
|
||||||
@@ -310,8 +303,6 @@
|
|||||||
</plurals>
|
</plurals>
|
||||||
<string name="settings_section_event_form">New event form</string>
|
<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_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">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_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>
|
<string name="settings_section_notifications">Notifications</string>
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -90,14 +90,6 @@ class SettingsPrefsTest {
|
|||||||
assertThat(prefs.showHourLines.first()).isTrue()
|
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
|
@Test
|
||||||
fun `agenda ranges default to month and round-trip independently`(@TempDir tempDir: Path) = runTest {
|
fun `agenda ranges default to month and round-trip independently`(@TempDir tempDir: Path) = runTest {
|
||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
|||||||
@@ -43,11 +43,21 @@ Builds:
|
|||||||
versionCode: 20705
|
versionCode: 20705
|
||||||
commit: v2.7.5
|
commit: v2.7.5
|
||||||
subdir: app
|
subdir: app
|
||||||
|
submodules: true
|
||||||
gradle:
|
gradle:
|
||||||
- yes
|
- yes
|
||||||
# No NDK / no flavors. The release buildType applies a signingConfig only
|
# No NDK / no flavors. The release buildType applies a signingConfig only
|
||||||
# when key.properties exists; on the buildserver it does not, so this
|
# when key.properties exists; on the buildserver it does not, so this
|
||||||
# produces the unsigned APK F-Droid compares against our binary.
|
# 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
|
# SHA-256 of our app signing certificate (public; embedded in every published
|
||||||
# APK). Locks F-Droid to publish only binaries signed with our key.
|
# APK). Locks F-Droid to publish only binaries signed with our key.
|
||||||
|
|||||||
@@ -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]).
|
|
||||||
@@ -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]).
|
|
||||||
@@ -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
1
floret-kit
Submodule
Submodule floret-kit added at 554d536b2f
@@ -34,8 +34,15 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. The foojay toolchain resolver must not be present in any Gradle script.
|
# 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")
|
gradle_files=("$SETTINGS" "$APP")
|
||||||
[ -f build.gradle.kts ] && gradle_files+=(build.gradle.kts)
|
[ -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
|
if grep -qi 'foojay' "${gradle_files[@]}"; then
|
||||||
echo "ERROR: foojay toolchain resolver found in: $(grep -li foojay "${gradle_files[@]}" | tr '\n' ' ')" >&2
|
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
|
echo " F-Droid's source scanner rejects org.gradle.toolchains.foojay-resolver" >&2
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ dependencyResolutionManagement {
|
|||||||
|
|
||||||
rootProject.name = "Calendula"
|
rootProject.name = "Calendula"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
includeBuild("floret-kit")
|
||||||
|
|||||||
Reference in New Issue
Block a user