Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e6defd4c7 | |||
| 6e7ae3e60d | |||
| b0b30eef91 | |||
| 8b25c9be39 | |||
| 2943f3945d | |||
| b62f097392 |
@@ -166,8 +166,12 @@ unblock a later item. Order is a plan, not a contract — revisit after each lan
|
|||||||
3. ~~Settings redesign & restructure~~ *(shipped v2.3.0 — grew into the full
|
3. ~~Settings redesign & restructure~~ *(shipped v2.3.0 — grew into the full
|
||||||
grouped-list blueprint across Settings + calendars + drawer; see "v2.3"
|
grouped-list blueprint across Settings + calendars + drawer; see "v2.3"
|
||||||
above)*
|
above)*
|
||||||
4. **Per-event color** *(next)* — reuses the calendar color picker/palette; closes the create/edit theme
|
4. ~~Per-event color~~ *(shipped v2.4.0)* — palette calendars write
|
||||||
5. Duplicate event — detail action → prefilled create form; near-free on the tap-to-create prefill infra
|
`EVENT_COLOR_KEY` (sync-safe); local/opted-in calendars write a raw
|
||||||
|
`EVENT_COLOR`; off-by-default setting for no-palette synced calendars
|
||||||
|
Tier 1's create/edit + calendars arc is effectively closed. **Duplicate event**
|
||||||
|
was deprioritised (2026-06-17) as low-importance and dropped to the bottom of
|
||||||
|
the sequence; the next item is now **Jump-to-date** (formerly Tier 2).
|
||||||
|
|
||||||
(Tier 2+ numbering below shifts accordingly; ranking unchanged.)
|
(Tier 2+ numbering below shifts accordingly; ranking unchanged.)
|
||||||
|
|
||||||
@@ -221,12 +225,12 @@ Out of scope (no new settings *features* here) — this is a structure + style
|
|||||||
pass on the existing controls; new toggles ride in with their own features.
|
pass on the existing controls; new toggles ride in with their own features.
|
||||||
|
|
||||||
**Tier 2 — navigation & daily-driver completeness**
|
**Tier 2 — navigation & daily-driver completeness**
|
||||||
5. Jump-to-date — drawer date picker (un-cut from V1); cheap, fills the nav gap
|
5. ~~Jump-to-date — drawer date picker (un-cut from V1); cheap, fills the nav gap~~ *(done, v2.5.0)*
|
||||||
6. Agenda view — the missing 4th view; serves daily-driver users *and* becomes the data source for the widget
|
6. ~~Agenda view — the missing 4th view; serves daily-driver users *and* becomes the data source for the widget~~ *(done, v2.5.0)*
|
||||||
|
|
||||||
**Tier 3 — platform reach (depends on Tier 2)**
|
**Tier 3 — platform reach (depends on Tier 2)**
|
||||||
7. Home-screen widget — built on the agenda data source from #6
|
7. ~~Home-screen widget — built on the agenda data source from #6~~ *(done, v2.5.0 — agenda + month widgets)*
|
||||||
8. App shortcuts (launcher long-press → New event); cheap, optional quick-settings tile
|
8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; optional quick-settings tile still open
|
||||||
|
|
||||||
**Tier 4 — interop & bigger-ticket**
|
**Tier 4 — interop & bigger-ticket**
|
||||||
9. Share event as .ics + receive/open .ics into a prefilled create form
|
9. Share event as .ics + receive/open .ics into a prefilled create form
|
||||||
@@ -238,6 +242,10 @@ pass on the existing controls; new toggles ride in with their own features.
|
|||||||
- Locations & People — contact address picker (no-permission, one-shot) is the safe entry; OSM autocomplete needs INTERNET
|
- Locations & People — contact address picker (no-permission, one-shot) is the safe entry; OSM autocomplete needs INTERNET
|
||||||
- Move event to another calendar — sync-adapter minefield (copy+delete model)
|
- Move event to another calendar — sync-adapter minefield (copy+delete model)
|
||||||
|
|
||||||
|
**Bottom — deprioritised, not important**
|
||||||
|
- Duplicate event (detail action → prefilled create form) — moved here
|
||||||
|
2026-06-17; cheap but low value, pick up only if asked
|
||||||
|
|
||||||
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts,
|
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts,
|
||||||
full-text search, ICS file import. Pulled in opportunistically, not sequenced.
|
full-text search, ICS file import. Pulled in opportunistically, not sequenced.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Calendula — Current State
|
# Calendula — Current State
|
||||||
|
|
||||||
*Last updated: 2026-06-16*
|
*Last updated: 2026-06-17*
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
@@ -8,8 +8,10 @@
|
|||||||
v2.1.0 (month event grid, drawer view tabs, cursor fix) shipped 2026-06-15.
|
v2.1.0 (month event grid, drawer view tabs, cursor fix) shipped 2026-06-15.
|
||||||
**Phase:** post-2.1 backlog work. v2.2.0 (tap-to-create in day/week + local
|
**Phase:** post-2.1 backlog work. v2.2.0 (tap-to-create in day/week + local
|
||||||
calendar management) and v2.3.0 (Material 3 grouped-list redesign of Settings,
|
calendar management) and v2.3.0 (Material 3 grouped-list redesign of Settings,
|
||||||
the calendar manager and the navigation drawer) both shipped 2026-06-16. The
|
the calendar manager and the navigation drawer) both shipped 2026-06-16;
|
||||||
backlog is now organised by theme in `ROADMAP.md`.
|
v2.4.0 (per-event colors) and v2.5.0 (jump-to-date, Agenda view, home-screen
|
||||||
|
agenda + month widgets, and a "New event" launcher shortcut) shipped
|
||||||
|
2026-06-17. The backlog is now organised by theme in `ROADMAP.md`.
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
@@ -105,13 +107,24 @@ backlog is now organised by theme in `ROADMAP.md`.
|
|||||||
- Cards use `surfaceContainerHigh` for readable contrast against `surface`.
|
- Cards use `surfaceContainerHigh` for readable contrast against `surface`.
|
||||||
- Donate button on the About card deferred (target still TBD).
|
- Donate button on the About card deferred (target still TBD).
|
||||||
|
|
||||||
|
- [x] v2.4 per-event color (shipped 2026-06-17) — an optional "Color" field in
|
||||||
|
the event form. Read/render already resolved `EVENT_COLOR` with a calendar
|
||||||
|
fallback; this adds the write side and the picker. Palette-backed calendars
|
||||||
|
(Google, some CalDAV) pick from the account's `Colors` (`TYPE_EVENT`) and
|
||||||
|
write `EVENT_COLOR_KEY` so the color round-trips through sync; local
|
||||||
|
calendars write a raw `EVENT_COLOR` from the shared `CALENDAR_COLOR_PALETTE`
|
||||||
|
(extracted with the swatch row to `ui/common/ColorSwatchRow.kt`). Switching
|
||||||
|
calendars resets the choice (a key is account-scoped). A settings toggle
|
||||||
|
("Allow colors on unsupported calendars", off by default) extends the raw
|
||||||
|
path to synced calendars with no palette, with an honest "may not survive
|
||||||
|
sync" warning on the picker and in Settings. Color writes flow through
|
||||||
|
insert / dirty-checked update / occurrence-exception; mapper + form tests.
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
1. Monitor the F-Droid build/publish for the v2.3.0 tag
|
1. Monitor the F-Droid build/publish for the v2.4.0 tag
|
||||||
2. Decide the "Locations & People" and "remote calendar create/edit"
|
2. Decide the "Locations & People" and "remote calendar create/edit"
|
||||||
go/no-go calls (both hinge on the INTERNET permission) — see `ROADMAP.md`
|
go/no-go calls (both hinge on the INTERNET permission) — see `ROADMAP.md`
|
||||||
3. **Per-event color** is next — reuses the color picker + palette plumbing
|
3. **Duplicate event** and **jump-to-date** are the cheap follow-ups; then
|
||||||
from local calendar management; finishes the create/edit theme.
|
agenda view (strategic, backs a future widget). Full ranked sequence in
|
||||||
4. Then agenda view (strategic, backs a future widget); jump-to-date and
|
|
||||||
duplicate event remain cheap follow-ups. Full ranked sequence in
|
|
||||||
`ROADMAP.md` → "Near-term sequence".
|
`ROADMAP.md` → "Near-term sequence".
|
||||||
|
|||||||
36
CHANGELOG.md
36
CHANGELOG.md
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.5.0] — 2026-06-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Home-screen widgets (two of them): an "Upcoming" agenda widget — a scrolling
|
||||||
|
list of the next month of events grouped under day headers, with refresh and
|
||||||
|
"New event" buttons — and a month-grid widget showing the full month with
|
||||||
|
today highlighted, connected multi-day event bars, and prev/next/today
|
||||||
|
navigation. Both reuse the in-app grouping and layout so they match the app
|
||||||
|
exactly, respect your hidden-calendar choices, and refresh automatically when
|
||||||
|
the calendar changes or the day rolls over. Tapping a day opens that day;
|
||||||
|
tapping an event opens its details
|
||||||
|
- App shortcut: long-press the Calendula icon for a "New event" action that
|
||||||
|
jumps straight into the create-event form
|
||||||
|
- Agenda view — a fourth top-level view alongside Month/Week/Day: a
|
||||||
|
forward-looking list of upcoming events grouped under "Today"/"Tomorrow"/date
|
||||||
|
headers, reachable from the view switcher
|
||||||
|
- Jump to date — a "Jump to date" row in the navigation drawer opens a date
|
||||||
|
picker and moves the active view (Month/Week/Day/Agenda) to the chosen day
|
||||||
|
|
||||||
|
## [2.4.0] — 2026-06-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Per-event colors: give a single event its own color, instead of always
|
||||||
|
inheriting its calendar's. Add the new "Color" field from "More fields" in
|
||||||
|
the event form. On calendars that publish their own color set — such as
|
||||||
|
Google — you pick from that calendar's palette, so the color is stored
|
||||||
|
with the event and shows correctly on every synced device. On local
|
||||||
|
calendars you pick from Calendula's palette. "Reset" returns an event to
|
||||||
|
its calendar's color
|
||||||
|
- A new "Allow colors on unsupported calendars" setting (New event form,
|
||||||
|
off by default) extends per-event colors to calendars that publish no
|
||||||
|
color set of their own (some CalDAV). Such a color is kept on the device
|
||||||
|
and may be dropped or overwritten on that calendar's next sync — a
|
||||||
|
limitation of those calendars, called out plainly in the setting and on
|
||||||
|
the color picker
|
||||||
|
|
||||||
## [2.3.0] — 2026-06-16
|
## [2.3.0] — 2026-06-16
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
|
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
|
||||||
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
|
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
|
||||||
// default; keep them matching the latest released tag. See docs/RELEASING.md.
|
// default; keep them matching the latest released tag. See docs/RELEASING.md.
|
||||||
versionCode = 20300
|
versionCode = 20500
|
||||||
versionName = "2.3.0"
|
versionName = "2.5.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -113,6 +113,9 @@ dependencies {
|
|||||||
|
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
|
||||||
|
implementation(libs.androidx.glance.appwidget)
|
||||||
|
implementation(libs.androidx.glance.material3)
|
||||||
|
|
||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,11 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
||||||
|
<meta-data
|
||||||
|
android:name="android.app.shortcuts"
|
||||||
|
android:resource="@xml/shortcuts" />
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
||||||
@@ -54,6 +59,51 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.agenda.AgendaWidgetReceiver"
|
||||||
|
android:label="@string/widget_agenda_label"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/appwidget_info_agenda" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.month.MonthWidgetReceiver"
|
||||||
|
android:label="@string/widget_month_label"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/appwidget_info_month" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Keeps both widgets fresh: the calendar provider broadcasts
|
||||||
|
PROVIDER_CHANGED on any data change (our writes and external sync),
|
||||||
|
and the system broadcasts the date/time ones at midnight / clock
|
||||||
|
changes so "today" highlighting rolls over. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.WidgetUpdateReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.PROVIDER_CHANGED" />
|
||||||
|
<data
|
||||||
|
android:host="com.android.calendar"
|
||||||
|
android:scheme="content" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.DATE_CHANGED" />
|
||||||
|
<action android:name="android.intent.action.TIME_SET" />
|
||||||
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<!-- Persists the per-app language (M4) on API < 33, where the platform
|
<!-- Persists the per-app language (M4) on API < 33, where the platform
|
||||||
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||||
<service
|
<service
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||||
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
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -29,10 +31,15 @@ class MainActivity : ComponentActivity() {
|
|||||||
// tap into the running activity; CalendarHost consumes and clears it.
|
// tap into the running activity; CalendarHost consumes and clears it.
|
||||||
private var requestedDetailKey by mutableStateOf<LongArray?>(null)
|
private var requestedDetailKey by mutableStateOf<LongArray?>(null)
|
||||||
|
|
||||||
|
// A navigation a home-screen widget asked for (open a date / start a
|
||||||
|
// create). Consumed once by CalendarHost, same pattern as the detail key.
|
||||||
|
private var requestedNav by mutableStateOf<WidgetNavRequest?>(null)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
requestedDetailKey = intent.detailKeyOrNull()
|
requestedDetailKey = intent.detailKeyOrNull()
|
||||||
|
requestedNav = intent.navRequestOrNull()
|
||||||
setContent {
|
setContent {
|
||||||
// One activity-scoped SettingsViewModel drives both the theme here
|
// One activity-scoped SettingsViewModel drives both the theme here
|
||||||
// and the Settings screen, so a theme change applies app-wide at once.
|
// and the Settings screen, so a theme change applies app-wide at once.
|
||||||
@@ -51,6 +58,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
requestedDetailKey = requestedDetailKey,
|
requestedDetailKey = requestedDetailKey,
|
||||||
onDetailKeyConsumed = { requestedDetailKey = null },
|
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||||
|
widgetNavRequest = requestedNav,
|
||||||
|
onWidgetNavConsumed = { requestedNav = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,6 +68,18 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
||||||
|
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Intent.navRequestOrNull(): WidgetNavRequest? = when {
|
||||||
|
// Launcher long-press "New event" shortcut. Static shortcut intents
|
||||||
|
// can't carry typed extras, so the action alone signals create-on-today.
|
||||||
|
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
|
||||||
|
getBooleanExtra(EXTRA_CREATE, false) ->
|
||||||
|
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
|
||||||
|
getStringExtra(EXTRA_DATE_ISO) != null ->
|
||||||
|
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!)
|
||||||
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Intent.detailKeyOrNull(): LongArray? {
|
private fun Intent.detailKeyOrNull(): LongArray? {
|
||||||
@@ -75,6 +96,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
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"
|
||||||
|
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
|
||||||
|
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
|
||||||
|
|
||||||
|
// Fired by the launcher long-press "New event" shortcut (res/xml/
|
||||||
|
// shortcuts.xml hardcodes this string — keep the two in sync).
|
||||||
|
const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Intent opening the detail screen of one occurrence (reminder
|
* Intent opening the detail screen of one occurrence (reminder
|
||||||
@@ -93,5 +120,22 @@ class MainActivity : ComponentActivity() {
|
|||||||
putExtra(EXTRA_END_MILLIS, endMillis)
|
putExtra(EXTRA_END_MILLIS, endMillis)
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Open the day view anchored on [date] (home-screen widgets). */
|
||||||
|
fun openDateIntent(context: Context, date: LocalDate): Intent =
|
||||||
|
Intent(context, MainActivity::class.java).apply {
|
||||||
|
data = "calendula://date/$date".toUri()
|
||||||
|
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the create-event form prefilled for [date] (home-screen widgets). */
|
||||||
|
fun openCreateIntent(context: Context, date: LocalDate): Intent =
|
||||||
|
Intent(context, MainActivity::class.java).apply {
|
||||||
|
data = "calendula://create/$date".toUri()
|
||||||
|
putExtra(EXTRA_CREATE, true)
|
||||||
|
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import android.util.Log
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
@@ -37,6 +38,15 @@ interface CalendarDataSource {
|
|||||||
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
||||||
fun eventDetail(eventId: Long): EventDetail?
|
fun eventDetail(eventId: Long): EventDetail?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event-colour palette the calendar's account publishes
|
||||||
|
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
|
||||||
|
* account exposes no palette (most local calendars, some CalDAV) — the
|
||||||
|
* signal that a custom colour can only be written as a raw `EVENT_COLOR`,
|
||||||
|
* which a synced calendar may drop on its next sync.
|
||||||
|
*/
|
||||||
|
fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
||||||
* returns its `Calendars._ID`. Inserted through the sync-adapter URI so the
|
* returns its `Calendars._ID`. Inserted through the sync-adapter URI so the
|
||||||
@@ -215,6 +225,46 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun eventColorPalette(calendarId: Long): List<EventColorOption> {
|
||||||
|
val account = calendarAccount(calendarId) ?: return emptyList()
|
||||||
|
return resolver.query(
|
||||||
|
CalendarContract.Colors.CONTENT_URI,
|
||||||
|
arrayOf(CalendarContract.Colors.COLOR_KEY, CalendarContract.Colors.COLOR),
|
||||||
|
CalendarContract.Colors.ACCOUNT_NAME + " = ? AND " +
|
||||||
|
CalendarContract.Colors.ACCOUNT_TYPE + " = ? AND " +
|
||||||
|
CalendarContract.Colors.COLOR_TYPE + " = ?",
|
||||||
|
arrayOf(
|
||||||
|
account.name,
|
||||||
|
account.type,
|
||||||
|
CalendarContract.Colors.TYPE_EVENT.toString(),
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
||||||
|
}
|
||||||
|
?.filter { it.key.isNotEmpty() }
|
||||||
|
?.sortedBy { it.key }
|
||||||
|
?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
|
||||||
|
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
||||||
|
arrayOf(
|
||||||
|
CalendarContract.Calendars.ACCOUNT_NAME,
|
||||||
|
CalendarContract.Calendars.ACCOUNT_TYPE,
|
||||||
|
),
|
||||||
|
null, null, null,
|
||||||
|
)?.use { c ->
|
||||||
|
if (c.moveToFirst()) {
|
||||||
|
CalendarAccount(name = c.getString(0).orEmpty(), type = c.getString(1).orEmpty())
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class CalendarAccount(val name: String, val type: String)
|
||||||
|
|
||||||
override fun insertEvent(form: EventForm): Long {
|
override fun insertEvent(form: EventForm): Long {
|
||||||
val times = form.toWriteTimes(ZoneId.systemDefault())
|
val times = form.toWriteTimes(ZoneId.systemDefault())
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
@@ -240,6 +290,13 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||||
form.description.trim().takeIf { it.isNotEmpty() }
|
form.description.trim().takeIf { it.isNotEmpty() }
|
||||||
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||||
|
// A null colour just leaves both columns unset (the event inherits
|
||||||
|
// its calendar's colour), so only the key/raw cases are written.
|
||||||
|
when {
|
||||||
|
form.colorKey != null ->
|
||||||
|
put(CalendarContract.Events.EVENT_COLOR_KEY, form.colorKey)
|
||||||
|
form.color != null -> put(CalendarContract.Events.EVENT_COLOR, form.color)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||||
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
|
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
@@ -12,6 +13,12 @@ interface CalendarRepository {
|
|||||||
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
||||||
suspend fun eventDetail(eventId: Long): EventDetail
|
suspend fun eventDetail(eventId: Long): EventDetail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event-colour palette a calendar's account publishes; empty when it
|
||||||
|
* exposes none (see [CalendarDataSource.eventColorPalette]).
|
||||||
|
*/
|
||||||
|
suspend fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
||||||
|
|
||||||
/** Create a device-only (LOCAL) calendar the app owns; returns its id. */
|
/** Create a device-only (LOCAL) calendar the app owns; returns its id. */
|
||||||
suspend fun createLocalCalendar(displayName: String, color: Int, description: String?): Long
|
suspend fun createLocalCalendar(displayName: String, color: Int, description: String?): Long
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
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.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
@@ -70,6 +71,9 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||||
|
withContext(io) { dataSource.eventColorPalette(calendarId) }
|
||||||
|
|
||||||
override suspend fun createLocalCalendar(
|
override suspend fun createLocalCalendar(
|
||||||
displayName: String,
|
displayName: String,
|
||||||
color: Int,
|
color: Int,
|
||||||
|
|||||||
@@ -46,11 +46,16 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
// localized placeholder, and the edit form must prefill the true value.
|
// localized placeholder, and the edit form must prefill the true value.
|
||||||
val title = getString(EventDetailProjection.IDX_TITLE).orEmpty()
|
val title = getString(EventDetailProjection.IDX_TITLE).orEmpty()
|
||||||
|
|
||||||
val color = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
|
// The event's own colour (null = inherits the calendar's) is kept apart
|
||||||
getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
// from the resolved display colour: the edit form needs to tell the two
|
||||||
|
// cases apart, while the instance carries the calendar fallback for display.
|
||||||
|
val eventColor = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
|
||||||
|
null
|
||||||
} else {
|
} else {
|
||||||
getInt(EventDetailProjection.IDX_EVENT_COLOR)
|
getInt(EventDetailProjection.IDX_EVENT_COLOR)
|
||||||
}
|
}
|
||||||
|
val eventColorKey = getString(EventDetailProjection.IDX_EVENT_COLOR_KEY)
|
||||||
|
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
||||||
|
|
||||||
val eventId = getLong(EventDetailProjection.IDX_EVENT_ID)
|
val eventId = getLong(EventDetailProjection.IDX_EVENT_ID)
|
||||||
val instance = EventInstance(
|
val instance = EventInstance(
|
||||||
@@ -87,6 +92,8 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
accessLevel = mapAccessLevel(getInt(EventDetailProjection.IDX_ACCESS_LEVEL)),
|
accessLevel = mapAccessLevel(getInt(EventDetailProjection.IDX_ACCESS_LEVEL)),
|
||||||
eventTimezone = getString(EventDetailProjection.IDX_EVENT_TIMEZONE),
|
eventTimezone = getString(EventDetailProjection.IDX_EVENT_TIMEZONE),
|
||||||
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
|
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||||
|
eventColor = eventColor,
|
||||||
|
eventColorKey = eventColorKey,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ internal fun buildEventUpdateValues(
|
|||||||
if (updated.accessLevel != original.accessLevel) {
|
if (updated.accessLevel != original.accessLevel) {
|
||||||
put(CalendarContract.Events.ACCESS_LEVEL, updated.accessLevel.toProviderValue())
|
put(CalendarContract.Events.ACCESS_LEVEL, updated.accessLevel.toProviderValue())
|
||||||
}
|
}
|
||||||
|
if (updated.colorKey != original.colorKey || updated.color != original.color) {
|
||||||
|
putAll(eventColorColumns(updated.colorKey, updated.color))
|
||||||
|
}
|
||||||
|
|
||||||
val timesChanged = updated.start != original.start ||
|
val timesChanged = updated.start != original.start ||
|
||||||
updated.end != original.end ||
|
updated.end != original.end ||
|
||||||
@@ -134,6 +137,28 @@ internal fun buildOccurrenceExceptionValues(
|
|||||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||||
put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null })
|
put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null })
|
||||||
put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null })
|
put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null })
|
||||||
|
putAll(eventColorColumns(form.colorKey, form.color))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
||||||
|
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
||||||
|
* account's palette, so the colour round-trips through sync); a raw [color]
|
||||||
|
* writes `EVENT_COLOR` and clears any key; "no colour" clears both so the event
|
||||||
|
* falls back to its calendar's colour. The two are never written together —
|
||||||
|
* the provider rejects a raw colour on a calendar that publishes a palette,
|
||||||
|
* which is exactly why palette calendars only ever go through the key.
|
||||||
|
*/
|
||||||
|
internal fun eventColorColumns(colorKey: String?, color: Int?): Map<String, Any?> = when {
|
||||||
|
colorKey != null -> mapOf(CalendarContract.Events.EVENT_COLOR_KEY to colorKey)
|
||||||
|
color != null -> mapOf(
|
||||||
|
CalendarContract.Events.EVENT_COLOR_KEY to null,
|
||||||
|
CalendarContract.Events.EVENT_COLOR to color,
|
||||||
|
)
|
||||||
|
else -> mapOf(
|
||||||
|
CalendarContract.Events.EVENT_COLOR_KEY to null,
|
||||||
|
CalendarContract.Events.EVENT_COLOR to null,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ internal object EventDetailProjection {
|
|||||||
CalendarContract.Events.ACCESS_LEVEL,
|
CalendarContract.Events.ACCESS_LEVEL,
|
||||||
CalendarContract.Events.EVENT_TIMEZONE,
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||||
|
CalendarContract.Events.EVENT_COLOR_KEY,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_EVENT_ID = 0
|
const val IDX_EVENT_ID = 0
|
||||||
@@ -93,6 +94,7 @@ internal object EventDetailProjection {
|
|||||||
const val IDX_ACCESS_LEVEL = 14
|
const val IDX_ACCESS_LEVEL = 14
|
||||||
const val IDX_EVENT_TIMEZONE = 15
|
const val IDX_EVENT_TIMEZONE = 15
|
||||||
const val IDX_SELF_ATTENDEE_STATUS = 16
|
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||||
|
const val IDX_EVENT_COLOR_KEY = 17
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object AttendeeProjection {
|
internal object AttendeeProjection {
|
||||||
|
|||||||
@@ -99,6 +99,22 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[REMINDERS_ENABLED_KEY] = enabled }
|
store.edit { it[REMINDERS_ENABLED_KEY] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to offer a custom event colour even on calendars that publish no
|
||||||
|
* colour palette (most local calendars handle it fine; synced calendars
|
||||||
|
* without a palette — some CalDAV — may drop or overwrite a raw colour on
|
||||||
|
* their next sync). Defaults to OFF: such calendars hide the colour picker
|
||||||
|
* until the user opts in, accepting the limitation. Local calendars and
|
||||||
|
* palette-backed calendars (Google, …) are unaffected by this flag.
|
||||||
|
*/
|
||||||
|
val allowColorOnUnsupportedCalendars: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[ALLOW_COLOR_UNSUPPORTED_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) {
|
||||||
|
store.edit { it[ALLOW_COLOR_UNSUPPORTED_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the one-time reminder onboarding step (after the calendar
|
* Whether the one-time reminder onboarding step (after the calendar
|
||||||
* grant) has been shown — also true for users who tapped "not now".
|
* grant) has been shown — also true for users who tapped "not now".
|
||||||
@@ -125,6 +141,8 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||||
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 =
|
||||||
|
booleanPreferencesKey("allow_color_unsupported_calendars")
|
||||||
internal val DEFAULT_FORM_FIELDS =
|
internal val DEFAULT_FORM_FIELDS =
|
||||||
setOf(EventFormField.Location, EventFormField.Description)
|
setOf(EventFormField.Location, EventFormField.Description)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,17 @@ data class EventForm(
|
|||||||
* those are kept verbatim until the user picks something else.
|
* those are kept verbatim until the user picks something else.
|
||||||
*/
|
*/
|
||||||
val rrule: String? = null,
|
val rrule: String? = null,
|
||||||
|
/**
|
||||||
|
* The event's own colour, or null to inherit the calendar's colour.
|
||||||
|
* [colorKey] is a `Colors.COLOR_KEY` from the calendar account's published
|
||||||
|
* event palette (Google, some CalDAV) — written as `EVENT_COLOR_KEY` so it
|
||||||
|
* round-trips through sync. When it is null but [color] is set, [color] is
|
||||||
|
* a raw ARGB written as `EVENT_COLOR` (local calendars, or synced ones the
|
||||||
|
* user opted into despite no palette). [color] mirrors the key's swatch when
|
||||||
|
* [colorKey] is set, so the picker can highlight it.
|
||||||
|
*/
|
||||||
|
val colorKey: String? = null,
|
||||||
|
val color: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,6 +54,7 @@ enum class EventFormField {
|
|||||||
Recurrence,
|
Recurrence,
|
||||||
Availability,
|
Availability,
|
||||||
Visibility,
|
Visibility,
|
||||||
|
Color,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class EventFormProblem {
|
enum class EventFormProblem {
|
||||||
@@ -91,6 +103,11 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
|||||||
availability = availability,
|
availability = availability,
|
||||||
accessLevel = accessLevel,
|
accessLevel = accessLevel,
|
||||||
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||||
|
// The provider fills EVENT_COLOR from the key, so [color] is the
|
||||||
|
// swatch either way; a null colour means the event inherits its
|
||||||
|
// calendar's colour.
|
||||||
|
colorKey = eventColorKey,
|
||||||
|
color = eventColor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +147,7 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
|||||||
if (rrule != null) add(EventFormField.Recurrence)
|
if (rrule != null) add(EventFormField.Recurrence)
|
||||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||||
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
|
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
|
||||||
|
if (colorKey != null || color != null) add(EventFormField.Color)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
||||||
|
|||||||
@@ -58,8 +58,25 @@ data class EventDetail(
|
|||||||
val eventTimezone: String? = null,
|
val eventTimezone: String? = null,
|
||||||
/** This device user's own response (`Events.SELF_ATTENDEE_STATUS`). */
|
/** This device user's own response (`Events.SELF_ATTENDEE_STATUS`). */
|
||||||
val selfStatus: AttendeeStatus = AttendeeStatus.Unknown,
|
val selfStatus: AttendeeStatus = AttendeeStatus.Unknown,
|
||||||
|
/**
|
||||||
|
* The event's own raw colour (`Events.EVENT_COLOR`), null when the event
|
||||||
|
* inherits its calendar's colour. Unlike [EventInstance.color] (which
|
||||||
|
* already folds in the calendar fallback for display) this stays null so
|
||||||
|
* the edit form can tell "has own colour" from "inherits".
|
||||||
|
*/
|
||||||
|
val eventColor: Int? = null,
|
||||||
|
/** The event's `Events.EVENT_COLOR_KEY` (a calendar-palette key), or null. */
|
||||||
|
val eventColorKey: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One selectable event colour published by a calendar's account
|
||||||
|
* (`CalendarContract.Colors`, `TYPE_EVENT`): [key] is the account-scoped
|
||||||
|
* `COLOR_KEY` written as `EVENT_COLOR_KEY` (so the colour survives sync),
|
||||||
|
* [argb] is the swatch it renders as.
|
||||||
|
*/
|
||||||
|
data class EventColorOption(val key: String, val argb: Int)
|
||||||
|
|
||||||
data class Attendee(
|
data class Attendee(
|
||||||
val name: String,
|
val name: String,
|
||||||
val email: String?,
|
val email: String?,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
@@ -26,6 +27,9 @@ import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
|||||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds the active top-level view (spec M1) and swaps between the calendar
|
* Holds the active top-level view (spec M1) and swaps between the calendar
|
||||||
@@ -42,6 +46,8 @@ fun CalendarHost(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
requestedDetailKey: LongArray? = null,
|
requestedDetailKey: LongArray? = null,
|
||||||
onDetailKeyConsumed: () -> Unit = {},
|
onDetailKeyConsumed: () -> Unit = {},
|
||||||
|
widgetNavRequest: WidgetNavRequest? = null,
|
||||||
|
onWidgetNavConsumed: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
||||||
val onSelectView: (CalendarView) -> Unit = { view = it }
|
val onSelectView: (CalendarView) -> Unit = { view = it }
|
||||||
@@ -115,6 +121,28 @@ fun CalendarHost(
|
|||||||
var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||||
var heldEditKey by remember { mutableStateOf<LongArray?>(null) }
|
var heldEditKey by remember { mutableStateOf<LongArray?>(null) }
|
||||||
|
|
||||||
|
// A home-screen widget launch asks to open a date (→ day view) or start a
|
||||||
|
// create. Handled once and cleared, mirroring [requestedDetailKey].
|
||||||
|
LaunchedEffect(widgetNavRequest) {
|
||||||
|
when (val req = widgetNavRequest) {
|
||||||
|
is WidgetNavRequest.OpenDate -> {
|
||||||
|
pendingDayIso = req.dateIso
|
||||||
|
view = CalendarView.Day
|
||||||
|
onWidgetNavConsumed()
|
||||||
|
}
|
||||||
|
is WidgetNavRequest.Create -> {
|
||||||
|
val iso = req.dateIso ?: Clock.System.now()
|
||||||
|
.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
|
||||||
|
heldCreateIso = iso
|
||||||
|
createDateIso = iso
|
||||||
|
heldCreateMinutes = null
|
||||||
|
createStartMinutes = null
|
||||||
|
onWidgetNavConsumed()
|
||||||
|
}
|
||||||
|
null -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
@@ -141,6 +169,13 @@ fun CalendarHost(
|
|||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
)
|
)
|
||||||
|
CalendarView.Agenda -> AgendaScreen(
|
||||||
|
selectedView = view,
|
||||||
|
onSelectView = onSelectView,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
|
onCreateEvent = onCreateEvent,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer the live key; fall back to the held one only while sliding out.
|
// Prefer the live key; fall back to the held one only while sliding out.
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ fun RootScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
requestedDetailKey: LongArray? = null,
|
requestedDetailKey: LongArray? = null,
|
||||||
onDetailKeyConsumed: () -> Unit = {},
|
onDetailKeyConsumed: () -> Unit = {},
|
||||||
|
widgetNavRequest: WidgetNavRequest? = null,
|
||||||
|
onWidgetNavConsumed: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var hasPermission by remember {
|
var hasPermission by remember {
|
||||||
@@ -58,6 +60,8 @@ fun RootScreen(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
requestedDetailKey = requestedDetailKey,
|
requestedDetailKey = requestedDetailKey,
|
||||||
onDetailKeyConsumed = onDetailKeyConsumed,
|
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||||
|
widgetNavRequest = widgetNavRequest,
|
||||||
|
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||||
)
|
)
|
||||||
false -> ReminderOnboardingScreen(
|
false -> ReminderOnboardingScreen(
|
||||||
onFinished = reminderOnboarding::finish,
|
onFinished = reminderOnboarding::finish,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A navigation a home-screen widget asked the app to perform when launched.
|
||||||
|
* Parsed from the launch intent in MainActivity and consumed once by
|
||||||
|
* [CalendarHost] (event taps reuse the existing reminder detail-key channel, so
|
||||||
|
* they are not modelled here).
|
||||||
|
*/
|
||||||
|
sealed interface WidgetNavRequest {
|
||||||
|
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date). */
|
||||||
|
data class OpenDate(val dateIso: String) : WidgetNavRequest
|
||||||
|
|
||||||
|
/** Open the create-event form prefilled for [dateIso] (today when null). */
|
||||||
|
data class Create(val dateIso: String?) : WidgetNavRequest
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.EventAvailable
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
|
import androidx.compose.material3.rememberDrawerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AgendaScreen(
|
||||||
|
selectedView: CalendarView,
|
||||||
|
onSelectView: (CalendarView) -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: AgendaViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val isOnToday = when (val s = state) {
|
||||||
|
is AgendaUiState.Success -> s.anchor == s.today
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalNavigationDrawer(
|
||||||
|
drawerState = drawerState,
|
||||||
|
drawerContent = {
|
||||||
|
CalendarDrawer(
|
||||||
|
currentView = selectedView,
|
||||||
|
currentDate = anchor,
|
||||||
|
onSelectView = { view ->
|
||||||
|
onSelectView(view)
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
onJumpToDate = { target ->
|
||||||
|
viewModel.goToDate(target)
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
onSettings = {
|
||||||
|
onOpenSettings()
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
AgendaTopBar(
|
||||||
|
selectedView = selectedView,
|
||||||
|
onCycleView = { onSelectView(selectedView.next()) },
|
||||||
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
CalendarFabColumn(
|
||||||
|
todayVisible = !isOnToday,
|
||||||
|
todayText = stringResource(R.string.agenda_today_action),
|
||||||
|
onToday = viewModel::goToToday,
|
||||||
|
onCreate = { onCreateEvent(anchor, null) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
AgendaContent(
|
||||||
|
state = state,
|
||||||
|
onRetry = viewModel::goToToday,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaContent(
|
||||||
|
state: AgendaUiState,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
when (state) {
|
||||||
|
AgendaUiState.Loading -> Box(modifier)
|
||||||
|
is AgendaUiState.Failure -> Box(modifier) {
|
||||||
|
CalendarFailure(reason = state.reason, onRetry = onRetry)
|
||||||
|
}
|
||||||
|
is AgendaUiState.Success ->
|
||||||
|
if (state.days.isEmpty()) {
|
||||||
|
AgendaEmpty(modifier)
|
||||||
|
} else {
|
||||||
|
AgendaList(state = state, onEventClick = onEventClick, modifier = modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun AgendaList(
|
||||||
|
state: AgendaUiState.Success,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier,
|
||||||
|
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||||
|
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||||
|
) {
|
||||||
|
state.days.forEach { day ->
|
||||||
|
stickyHeader(key = "header-${day.date}") {
|
||||||
|
AgendaDayHeader(date = day.date, today = state.today)
|
||||||
|
}
|
||||||
|
itemsIndexed(
|
||||||
|
items = day.events,
|
||||||
|
key = { _, event -> event.instanceId },
|
||||||
|
) { index, event ->
|
||||||
|
AgendaEventRow(
|
||||||
|
event = event,
|
||||||
|
position = positionOf(index, day.events.size),
|
||||||
|
onClick = { onEventClick(event) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = agendaDayLabel(date, today),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = if (date == today) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaEventRow(
|
||||||
|
event: EventInstance,
|
||||||
|
position: Position,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
|
GroupedRow(
|
||||||
|
title = title,
|
||||||
|
summary = agendaTimeSummary(event),
|
||||||
|
position = position,
|
||||||
|
minHeight = 64.dp,
|
||||||
|
leading = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = 6.dp, height = 36.dp)
|
||||||
|
.clip(RoundedCornerShape(3.dp))
|
||||||
|
.background(pastelize(event.color, dark)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.padding(32.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.EventAvailable,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.agenda_empty_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.agenda_empty_subtitle),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun AgendaTopBar(
|
||||||
|
selectedView: CalendarView,
|
||||||
|
onCycleView: () -> Unit,
|
||||||
|
onOpenDrawer: () -> Unit,
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior,
|
||||||
|
) {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.view_agenda),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onOpenDrawer) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Menu,
|
||||||
|
contentDescription = stringResource(R.string.month_open_menu),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
ViewSwitcherPill(
|
||||||
|
current = selectedView,
|
||||||
|
onCycle = onCycleView,
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */
|
||||||
|
@Composable
|
||||||
|
private fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
|
||||||
|
val relative = when (date) {
|
||||||
|
today -> stringResource(R.string.agenda_header_today)
|
||||||
|
today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val formatted = formatAgendaDate(date)
|
||||||
|
return if (relative != null) "$relative · $formatted" else formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Time line under the title: "09:00 – 10:00 · Location", "All day", etc. */
|
||||||
|
@Composable
|
||||||
|
private fun agendaTimeSummary(event: EventInstance): String {
|
||||||
|
val time = if (event.isAllDay) {
|
||||||
|
stringResource(R.string.event_detail_all_day)
|
||||||
|
} else {
|
||||||
|
"${formatTime(event.start)} – ${formatTime(event.end)}"
|
||||||
|
}
|
||||||
|
val location = event.location?.takeIf { it.isNotBlank() }
|
||||||
|
return if (location != null) "$time · $location" else time
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTime(instant: Instant): String {
|
||||||
|
val t = instant.toLocalDateTime(zone).time
|
||||||
|
return "%02d:%02d".format(t.hour, t.minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatAgendaDate(date: LocalDate): String {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||||
|
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||||
|
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||||
|
return "$weekday, ${date.day}. $monthName ${date.year}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
|
||||||
|
/** One calendar day with at least one event, for the agenda list. */
|
||||||
|
data class AgendaDay(
|
||||||
|
val date: LocalDate,
|
||||||
|
/** Events on this day, all-day first then ascending by start time. */
|
||||||
|
val events: List<EventInstance>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group flat [instances] into forward-looking [AgendaDay]s (only days that
|
||||||
|
* actually carry events). An event that began before [anchor] (ongoing or
|
||||||
|
* multi-day) is clamped to the anchor day so it still surfaces on top. Within a
|
||||||
|
* day, all-day events sort first, then ascending by start time, then title.
|
||||||
|
*
|
||||||
|
* Shared by the Agenda screen and the agenda home-screen widget so both group
|
||||||
|
* and order identically.
|
||||||
|
*/
|
||||||
|
fun groupAgendaDays(
|
||||||
|
anchor: LocalDate,
|
||||||
|
instances: List<EventInstance>,
|
||||||
|
zone: TimeZone,
|
||||||
|
): List<AgendaDay> =
|
||||||
|
instances
|
||||||
|
.groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) }
|
||||||
|
.toSortedMap()
|
||||||
|
.map { (date, dayEvents) ->
|
||||||
|
AgendaDay(
|
||||||
|
date = date,
|
||||||
|
events = dayEvents.sortedWith(
|
||||||
|
compareByDescending<EventInstance> { it.isAllDay }
|
||||||
|
.thenBy { it.start }
|
||||||
|
.thenBy { it.title },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State for the Agenda view: a flat, forward-looking list of upcoming events
|
||||||
|
* grouped by day (only days that actually have events appear).
|
||||||
|
*/
|
||||||
|
sealed interface AgendaUiState {
|
||||||
|
data object Loading : AgendaUiState
|
||||||
|
data class Failure(val reason: FailureReason) : AgendaUiState
|
||||||
|
data class Success(
|
||||||
|
/** First day of the loaded window (today, or a jumped-to date). */
|
||||||
|
val anchor: LocalDate,
|
||||||
|
val today: LocalDate,
|
||||||
|
val days: List<AgendaDay>,
|
||||||
|
) : AgendaUiState
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** How far ahead the agenda loads events from its anchor day. */
|
||||||
|
internal const val AGENDA_WINDOW_DAYS = 60
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@HiltViewModel
|
||||||
|
class AgendaViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
|
private val todayDate: LocalDate
|
||||||
|
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
|
private val _anchor = MutableStateFlow(todayDate)
|
||||||
|
val anchor: StateFlow<LocalDate> = _anchor
|
||||||
|
|
||||||
|
val state: StateFlow<AgendaUiState> = _anchor
|
||||||
|
.flatMapLatest { anchor ->
|
||||||
|
val range = agendaRange(anchor, AGENDA_WINDOW_DAYS, zone)
|
||||||
|
combine(
|
||||||
|
repository.calendars(),
|
||||||
|
repository.instances(range),
|
||||||
|
) { calendars, instances ->
|
||||||
|
buildState(anchor, calendars, instances)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = AgendaUiState.Loading,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun goToToday() {
|
||||||
|
_anchor.value = todayDate
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jump the agenda window to start on a specific date (drawer jump-to-date). */
|
||||||
|
fun goToDate(date: LocalDate) {
|
||||||
|
_anchor.value = date
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildState(
|
||||||
|
anchor: LocalDate,
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
instances: List<EventInstance>,
|
||||||
|
): AgendaUiState {
|
||||||
|
if (calendars.isEmpty()) {
|
||||||
|
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
|
}
|
||||||
|
val days = groupAgendaDays(anchor, instances, zone)
|
||||||
|
return AgendaUiState.Success(anchor = anchor, today = todayDate, days = days)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */
|
||||||
|
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> {
|
||||||
|
val from = anchor.atStartOfDayIn(zone)
|
||||||
|
val to = anchor.plus(days, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
|
||||||
|
return from..to
|
||||||
|
}
|
||||||
@@ -6,14 +6,10 @@ import android.content.Intent
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -31,7 +27,6 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|||||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
@@ -73,8 +68,10 @@ 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.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
@@ -325,7 +322,12 @@ private fun CalendarEditor(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
ColorPalette(selected = color, onSelect = { color = it }, dark = dark)
|
ColorSwatchRow(
|
||||||
|
colors = CALENDAR_COLOR_PALETTE,
|
||||||
|
selected = color,
|
||||||
|
onSelect = { color = it },
|
||||||
|
dark = dark,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
EditorCard(
|
EditorCard(
|
||||||
icon = Icons.AutoMirrored.Filled.Notes,
|
icon = Icons.AutoMirrored.Filled.Notes,
|
||||||
@@ -402,42 +404,6 @@ private fun EditorCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
|
||||||
private fun ColorPalette(selected: Int, onSelect: (Int) -> Unit, dark: Boolean) {
|
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
CALENDAR_COLOR_PALETTE.forEach { argb ->
|
|
||||||
val isSelected = argb == selected
|
|
||||||
// Show the pastel the calendar will actually render as, not the raw hue.
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(vertical = 4.dp)
|
|
||||||
.size(40.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(pastelize(argb, dark))
|
|
||||||
.then(
|
|
||||||
if (isSelected) {
|
|
||||||
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.clickable { onSelect(argb) },
|
|
||||||
) {
|
|
||||||
if (isSelected) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color.Black.copy(alpha = 0.7f),
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AccountHeader(account: String, accountType: String) {
|
private fun AccountHeader(account: String, accountType: String) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -532,15 +498,3 @@ private fun curatedSourcePackage(accountType: String): String? = when {
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Google-Calendar-style palette; values are ARGB ints for `Calendars.CALENDAR_COLOR`. */
|
|
||||||
internal val CALENDAR_COLOR_PALETTE: List<Int> = listOf(
|
|
||||||
0xFFD50000, // red
|
|
||||||
0xFFE67C00, // orange
|
|
||||||
0xFFF6BF26, // amber
|
|
||||||
0xFF33B679, // green
|
|
||||||
0xFF0B8043, // dark green
|
|
||||||
0xFF039BE5, // blue
|
|
||||||
0xFF3F51B5, // indigo
|
|
||||||
0xFF8E24AA, // purple
|
|
||||||
0xFF616161, // graphite
|
|
||||||
).map { it.toInt() }
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.material3.DatePicker
|
||||||
|
import androidx.compose.material3.DatePickerDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberDatePickerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/** One UTC day in milliseconds — the unit the M3 [DatePicker] speaks. */
|
||||||
|
const val MILLIS_PER_DAY: Long = 86_400_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's standard Material 3 date picker, opened on [initial] and reporting
|
||||||
|
* the chosen day through [onConfirm]. Shared by the event form (start/end date,
|
||||||
|
* RRULE until) and the drawer's jump-to-date action.
|
||||||
|
*
|
||||||
|
* DatePicker speaks UTC-midnight millis; epoch-day arithmetic keeps the
|
||||||
|
* conversion zone-proof in both directions.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CalendarDatePickerDialog(
|
||||||
|
initial: LocalDate,
|
||||||
|
onConfirm: (LocalDate) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state = rememberDatePickerState(
|
||||||
|
initialSelectedDateMillis = initial.toEpochDays() * MILLIS_PER_DAY,
|
||||||
|
)
|
||||||
|
DatePickerDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
state.selectedDateMillis?.let { millis ->
|
||||||
|
onConfirm(LocalDate.fromEpochDays((millis / MILLIS_PER_DAY).toInt()))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { Text(stringResource(R.string.dialog_ok)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
DatePicker(state = state)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,12 +17,17 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.DateRange
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalDrawerSheet
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -32,23 +37,31 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigation drawer shared by every top-level calendar screen.
|
* Navigation drawer shared by every top-level calendar screen.
|
||||||
*
|
*
|
||||||
* Uses the app's grouped-card design system (see [GroupedRow]): a branded
|
* Uses the app's grouped-card design system (see [GroupedRow]): a branded
|
||||||
* header, the View switcher as a grouped card (the active view highlighted),
|
* header, the View switcher as a grouped card (the active view highlighted),
|
||||||
* the per-calendar visibility filter (M3) inline, and a pinned Settings row.
|
* a jump-to-date action, the per-calendar visibility filter (M3) inline, and a
|
||||||
* The "View" section mirrors the top-bar switcher pill — tapping a view here
|
* pinned Settings row. The "View" section mirrors the top-bar switcher pill —
|
||||||
* selects it (and closes the drawer) rather than cycling. The host screen owns
|
* tapping a view here selects it (and closes the drawer) rather than cycling.
|
||||||
* the drawer state.
|
* The host screen owns the drawer state.
|
||||||
|
*
|
||||||
|
* [currentDate] seeds the jump-to-date picker (the visible day/week-start/month
|
||||||
|
* anchor); [onJumpToDate] navigates the active view to the chosen day.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarDrawer(
|
fun CalendarDrawer(
|
||||||
currentView: CalendarView,
|
currentView: CalendarView,
|
||||||
|
currentDate: LocalDate,
|
||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
var showDatePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
ModalDrawerSheet {
|
ModalDrawerSheet {
|
||||||
// The whole sidebar scrolls as one — header, views, the calendar filter
|
// The whole sidebar scrolls as one — header, views, the calendar filter
|
||||||
// and Settings all flow in a single scroll container.
|
// and Settings all flow in a single scroll container.
|
||||||
@@ -71,6 +84,15 @@ fun CalendarDrawer(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.drawer_jump_to_date),
|
||||||
|
position = Position.Alone,
|
||||||
|
minHeight = 56.dp,
|
||||||
|
leading = { Icon(Icons.Filled.DateRange, contentDescription = null) },
|
||||||
|
onClick = { showDatePicker = true },
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
DrawerSectionHeader(stringResource(R.string.filter_title))
|
DrawerSectionHeader(stringResource(R.string.filter_title))
|
||||||
@@ -87,6 +109,17 @@ fun CalendarDrawer(
|
|||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showDatePicker) {
|
||||||
|
CalendarDatePickerDialog(
|
||||||
|
initial = currentDate,
|
||||||
|
onConfirm = {
|
||||||
|
showDatePicker = false
|
||||||
|
onJumpToDate(it)
|
||||||
|
},
|
||||||
|
onDismiss = { showDatePicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Branded header: the app-icon chip beside the app name. */
|
/** Branded header: the app-icon chip beside the app name. */
|
||||||
|
|||||||
@@ -5,17 +5,16 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.filled.CalendarViewDay
|
import androidx.compose.material.icons.filled.CalendarViewDay
|
||||||
import androidx.compose.material.icons.filled.CalendarViewMonth
|
import androidx.compose.material.icons.filled.CalendarViewMonth
|
||||||
import androidx.compose.material.icons.filled.CalendarViewWeek
|
import androidx.compose.material.icons.filled.CalendarViewWeek
|
||||||
|
import androidx.compose.material.icons.filled.ViewAgenda
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
/**
|
/** The top-level calendar views the user can switch between (spec M1). */
|
||||||
* The top-level calendar views the user can switch between (spec M1).
|
|
||||||
* Day is declared but not yet implemented (v0.5) — see [IMPLEMENTED_VIEWS].
|
|
||||||
*/
|
|
||||||
enum class CalendarView {
|
enum class CalendarView {
|
||||||
Month,
|
Month,
|
||||||
Week,
|
Week,
|
||||||
Day,
|
Day,
|
||||||
|
Agenda,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Switcher label, shared by the top-bar pill and the drawer's View section. */
|
/** Switcher label, shared by the top-bar pill and the drawer's View section. */
|
||||||
@@ -25,6 +24,7 @@ val CalendarView.labelRes: Int
|
|||||||
CalendarView.Month -> R.string.view_month
|
CalendarView.Month -> R.string.view_month
|
||||||
CalendarView.Week -> R.string.view_week
|
CalendarView.Week -> R.string.view_week
|
||||||
CalendarView.Day -> R.string.view_day
|
CalendarView.Day -> R.string.view_day
|
||||||
|
CalendarView.Agenda -> R.string.view_agenda
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Leading icon for the view in the drawer's View section. */
|
/** Leading icon for the view in the drawer's View section. */
|
||||||
@@ -33,6 +33,7 @@ val CalendarView.icon: ImageVector
|
|||||||
CalendarView.Month -> Icons.Filled.CalendarViewMonth
|
CalendarView.Month -> Icons.Filled.CalendarViewMonth
|
||||||
CalendarView.Week -> Icons.Filled.CalendarViewWeek
|
CalendarView.Week -> Icons.Filled.CalendarViewWeek
|
||||||
CalendarView.Day -> Icons.Filled.CalendarViewDay
|
CalendarView.Day -> Icons.Filled.CalendarViewDay
|
||||||
|
CalendarView.Agenda -> Icons.Filled.ViewAgenda
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,7 +41,7 @@ val CalendarView.icon: ImageVector
|
|||||||
* through these in order.
|
* through these in order.
|
||||||
*/
|
*/
|
||||||
val IMPLEMENTED_VIEWS: List<CalendarView> =
|
val IMPLEMENTED_VIEWS: List<CalendarView> =
|
||||||
listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day)
|
listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day, CalendarView.Agenda)
|
||||||
|
|
||||||
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
||||||
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapping row of round colour swatches; the one matching [selected] is
|
||||||
|
* ringed and checked. Shared by the calendar editor and the event-colour
|
||||||
|
* picker so both pick a colour the same way. Swatches render through
|
||||||
|
* [pastelize] — the softened colour the app actually paints, not the raw hue.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ColorSwatchRow(
|
||||||
|
colors: List<Int>,
|
||||||
|
selected: Int?,
|
||||||
|
onSelect: (Int) -> Unit,
|
||||||
|
dark: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
FlowRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
colors.forEach { argb ->
|
||||||
|
val isSelected = argb == selected
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(pastelize(argb, dark))
|
||||||
|
.then(
|
||||||
|
if (isSelected) {
|
||||||
|
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.clickable { onSelect(argb) },
|
||||||
|
) {
|
||||||
|
if (isSelected) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.Black.copy(alpha = 0.7f),
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` / `EVENT_COLOR`. */
|
||||||
|
val CALENDAR_COLOR_PALETTE: List<Int> = listOf(
|
||||||
|
0xFFD50000, // red
|
||||||
|
0xFFE67C00, // orange
|
||||||
|
0xFFF6BF26, // amber
|
||||||
|
0xFF33B679, // green
|
||||||
|
0xFF0B8043, // dark green
|
||||||
|
0xFF039BE5, // blue
|
||||||
|
0xFF3F51B5, // indigo
|
||||||
|
0xFF8E24AA, // purple
|
||||||
|
0xFF616161, // graphite
|
||||||
|
).map { it.toInt() }
|
||||||
@@ -151,6 +151,11 @@ fun DayScreen(
|
|||||||
}
|
}
|
||||||
viewModel.goToToday()
|
viewModel.goToToday()
|
||||||
}
|
}
|
||||||
|
// Drawer jump-to-date: slide from the side the target lies on.
|
||||||
|
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||||
|
slideDir = if (target < date) -1 else 1
|
||||||
|
viewModel.goToDate(target)
|
||||||
|
}
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -159,10 +164,15 @@ fun DayScreen(
|
|||||||
drawerContent = {
|
drawerContent = {
|
||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
|
currentDate = date,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
},
|
},
|
||||||
|
onJumpToDate = { target ->
|
||||||
|
jumpToDate(target)
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
onSettings = {
|
onSettings = {
|
||||||
onOpenSettings()
|
onOpenSettings()
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import androidx.compose.material.icons.filled.Close
|
|||||||
import androidx.compose.material.icons.filled.EventAvailable
|
import androidx.compose.material.icons.filled.EventAvailable
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
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.Place
|
import androidx.compose.material.icons.filled.Place
|
||||||
import androidx.compose.material.icons.filled.Public
|
import androidx.compose.material.icons.filled.Public
|
||||||
import androidx.compose.material.icons.filled.Repeat
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
@@ -50,8 +51,6 @@ import androidx.compose.material.icons.filled.VisibilityOff
|
|||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.AssistChip
|
import androidx.compose.material3.AssistChip
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.DatePicker
|
|
||||||
import androidx.compose.material3.DatePickerDialog
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -72,7 +71,6 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.material3.TimePicker
|
import androidx.compose.material3.TimePicker
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberDatePickerState
|
|
||||||
import androidx.compose.material3.rememberTimePickerState
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -102,6 +100,7 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
||||||
@@ -110,6 +109,10 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
|||||||
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||||
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||||
import de.jeanlucmakiola.calendula.domain.toRRule
|
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
@@ -414,6 +417,7 @@ private fun EventEditContent(
|
|||||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showRecurrencePicker by rememberSaveable { mutableStateOf(false) }
|
var showRecurrencePicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
|
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showColorPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
|
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
|
||||||
@@ -423,6 +427,16 @@ private fun EventEditContent(
|
|||||||
?: MaterialTheme.colorScheme.primary
|
?: MaterialTheme.colorScheme.primary
|
||||||
val gap = 12.dp
|
val gap = 12.dp
|
||||||
|
|
||||||
|
// Per-event colour applicability for the resolved calendar:
|
||||||
|
// - palette calendars (Google, …) and local calendars always support it;
|
||||||
|
// - synced calendars with no palette only when the user opted in, and even
|
||||||
|
// then the colour may not survive the calendar's next sync (the warning).
|
||||||
|
val isLocalCalendar = selectedCalendar?.isLocal == true
|
||||||
|
val colorSupported = state.colorPalette.isNotEmpty() || isLocalCalendar ||
|
||||||
|
state.allowColorOnUnsupportedCalendars
|
||||||
|
val colorSyncRisk = state.colorPalette.isEmpty() && !isLocalCalendar &&
|
||||||
|
state.allowColorOnUnsupportedCalendars
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
// Shrink the scroll viewport by the keyboard instead of letting
|
// Shrink the scroll viewport by the keyboard instead of letting
|
||||||
@@ -692,6 +706,67 @@ private fun EventEditContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OptionalFormSection(visible = EventFormField.Color in state.visibleFields) {
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
// The swatch the event will paint with: its own colour, else the
|
||||||
|
// calendar's. The Palette icon takes that colour as a preview.
|
||||||
|
val swatch = form.color ?: selectedCalendar?.color
|
||||||
|
EditCard(
|
||||||
|
icon = Icons.Default.Palette,
|
||||||
|
iconContentDescription = stringResource(R.string.event_edit_color),
|
||||||
|
iconTint = if (colorSupported && swatch != null) {
|
||||||
|
pastelize(swatch, dark)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
onClick = { showColorPicker = true }.takeIf { colorSupported },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
when {
|
||||||
|
!colorSupported -> R.string.event_edit_color_unsupported
|
||||||
|
form.color != null -> R.string.event_edit_color_custom
|
||||||
|
else -> R.string.event_edit_color_default
|
||||||
|
},
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
if (colorSupported) {
|
||||||
|
R.string.event_edit_color
|
||||||
|
} else {
|
||||||
|
R.string.event_edit_color_unsupported_hint
|
||||||
|
},
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
if (colorSyncRisk) {
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_edit_color_sync_warning),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (colorSupported) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowDropDown,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
|
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -710,12 +785,12 @@ private fun EventEditContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
when (picker) {
|
when (picker) {
|
||||||
PickerTarget.StartDate -> DatePickerAlert(
|
PickerTarget.StartDate -> CalendarDatePickerDialog(
|
||||||
initial = form.start.date,
|
initial = form.start.date,
|
||||||
onConfirm = { viewModel.setStartDate(it); picker = null },
|
onConfirm = { viewModel.setStartDate(it); picker = null },
|
||||||
onDismiss = { picker = null },
|
onDismiss = { picker = null },
|
||||||
)
|
)
|
||||||
PickerTarget.EndDate -> DatePickerAlert(
|
PickerTarget.EndDate -> CalendarDatePickerDialog(
|
||||||
initial = form.end.date,
|
initial = form.end.date,
|
||||||
onConfirm = { viewModel.setEndDate(it); picker = null },
|
onConfirm = { viewModel.setEndDate(it); picker = null },
|
||||||
onDismiss = { picker = null },
|
onDismiss = { picker = null },
|
||||||
@@ -779,6 +854,28 @@ private fun EventEditContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showColorPicker) {
|
||||||
|
ColorPickerDialog(
|
||||||
|
palette = state.colorPalette,
|
||||||
|
selected = form.color,
|
||||||
|
hasExplicitColor = form.color != null,
|
||||||
|
syncWarning = colorSyncRisk,
|
||||||
|
onPickKey = { key, argb ->
|
||||||
|
viewModel.setColorKey(key, argb)
|
||||||
|
showColorPicker = false
|
||||||
|
},
|
||||||
|
onPickRaw = { argb ->
|
||||||
|
viewModel.setColorRaw(argb)
|
||||||
|
showColorPicker = false
|
||||||
|
},
|
||||||
|
onClear = {
|
||||||
|
viewModel.clearColor()
|
||||||
|
showColorPicker = false
|
||||||
|
},
|
||||||
|
onDismiss = { showColorPicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showFieldPicker) {
|
if (showFieldPicker) {
|
||||||
FieldPickerDialog(
|
FieldPickerDialog(
|
||||||
hiddenFields = state.hiddenFields,
|
hiddenFields = state.hiddenFields,
|
||||||
@@ -1080,7 +1177,7 @@ private fun RecurrencePickerDialog(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (showUntilPicker) {
|
if (showUntilPicker) {
|
||||||
DatePickerAlert(
|
CalendarDatePickerDialog(
|
||||||
initial = untilDate ?: LocalDate.fromEpochDays(
|
initial = untilDate ?: LocalDate.fromEpochDays(
|
||||||
(Clock.System.now().toEpochMilliseconds() / MILLIS_PER_DAY).toInt(),
|
(Clock.System.now().toEpochMilliseconds() / MILLIS_PER_DAY).toInt(),
|
||||||
),
|
),
|
||||||
@@ -1294,6 +1391,7 @@ private fun fieldLabel(field: EventFormField): Int = when (field) {
|
|||||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||||
EventFormField.Availability -> R.string.event_edit_availability
|
EventFormField.Availability -> R.string.event_edit_availability
|
||||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||||
|
EventFormField.Color -> R.string.event_edit_color
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
|
private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
|
||||||
@@ -1303,6 +1401,7 @@ private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
|
|||||||
EventFormField.Recurrence -> Icons.Default.Repeat
|
EventFormField.Recurrence -> Icons.Default.Repeat
|
||||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||||
EventFormField.Visibility -> Icons.Default.Lock
|
EventFormField.Visibility -> Icons.Default.Lock
|
||||||
|
EventFormField.Color -> Icons.Default.Palette
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1336,6 +1435,72 @@ private fun VisibilityPickerDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event-colour picker: just the swatches. A non-empty [palette] (the calendar
|
||||||
|
* account's published colours) picks by key so the colour round-trips through
|
||||||
|
* sync; otherwise the app's own palette writes a raw colour, with a
|
||||||
|
* [syncWarning] when that calendar may not keep it. The "Reset" button (shown
|
||||||
|
* only once a colour is set) drops back to the calendar's own colour.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ColorPickerDialog(
|
||||||
|
palette: List<EventColorOption>,
|
||||||
|
selected: Int?,
|
||||||
|
hasExplicitColor: Boolean,
|
||||||
|
syncWarning: Boolean,
|
||||||
|
onPickKey: (String, Int) -> Unit,
|
||||||
|
onPickRaw: (Int) -> Unit,
|
||||||
|
onClear: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.event_edit_color)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
if (palette.isNotEmpty()) {
|
||||||
|
ColorSwatchRow(
|
||||||
|
colors = palette.map { it.argb },
|
||||||
|
selected = selected,
|
||||||
|
onSelect = { argb ->
|
||||||
|
palette.firstOrNull { it.argb == argb }
|
||||||
|
?.let { onPickKey(it.key, it.argb) }
|
||||||
|
},
|
||||||
|
dark = dark,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ColorSwatchRow(
|
||||||
|
colors = CALENDAR_COLOR_PALETTE,
|
||||||
|
selected = selected,
|
||||||
|
onSelect = onPickRaw,
|
||||||
|
dark = dark,
|
||||||
|
)
|
||||||
|
if (syncWarning) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_edit_color_sync_warning),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
|
dismissButton = if (hasExplicitColor) {
|
||||||
|
{
|
||||||
|
TextButton(onClick = onClear) {
|
||||||
|
Text(stringResource(R.string.event_edit_color_reset))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun accessLevelIcon(level: AccessLevel): ImageVector = when (level) {
|
private fun accessLevelIcon(level: AccessLevel): ImageVector = when (level) {
|
||||||
AccessLevel.Default -> Icons.Default.Tune
|
AccessLevel.Default -> Icons.Default.Tune
|
||||||
AccessLevel.Public -> Icons.Default.Public
|
AccessLevel.Public -> Icons.Default.Public
|
||||||
@@ -1522,37 +1687,6 @@ private fun ScheduleRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun DatePickerAlert(
|
|
||||||
initial: LocalDate,
|
|
||||||
onConfirm: (LocalDate) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
// DatePicker speaks UTC-midnight millis; epoch-day arithmetic keeps the
|
|
||||||
// conversion zone-proof in both directions.
|
|
||||||
val state = rememberDatePickerState(
|
|
||||||
initialSelectedDateMillis = initial.toEpochDays() * MILLIS_PER_DAY,
|
|
||||||
)
|
|
||||||
DatePickerDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
state.selectedDateMillis?.let { millis ->
|
|
||||||
onConfirm(LocalDate.fromEpochDays((millis / MILLIS_PER_DAY).toInt()))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) { Text(stringResource(R.string.dialog_ok)) }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
DatePicker(state = state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun TimePickerAlert(
|
private fun TimePickerAlert(
|
||||||
@@ -1609,5 +1743,3 @@ private fun CalendarPickerDialog(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val MILLIS_PER_DAY = 86_400_000L
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.edit
|
package de.jeanlucmakiola.calendula.ui.edit
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||||
@@ -33,6 +34,18 @@ data class EventEditUiState(
|
|||||||
* 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).
|
||||||
*/
|
*/
|
||||||
val recurrenceChanged: Boolean = false,
|
val recurrenceChanged: Boolean = false,
|
||||||
|
/**
|
||||||
|
* The event-colour palette the resolved target calendar publishes; empty
|
||||||
|
* when it exposes none. Non-empty → the colour picker offers these swatches
|
||||||
|
* (written as a key, sync-safe); empty → see [colorMode].
|
||||||
|
*/
|
||||||
|
val colorPalette: List<EventColorOption> = emptyList(),
|
||||||
|
/**
|
||||||
|
* Whether the user has opted into custom colours on calendars that publish
|
||||||
|
* no palette (a synced one may then drop the colour on sync). Mirrors the
|
||||||
|
* settings flag; ignored for local and palette-backed calendars.
|
||||||
|
*/
|
||||||
|
val allowColorOnUnsupportedCalendars: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** One-shot state of a save request, mirroring DeleteUiState on the detail screen. */
|
/** One-shot state of a save request, mirroring DeleteUiState on the detail screen. */
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import de.jeanlucmakiola.calendula.domain.AccessLevel
|
|||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EditSnapshot
|
import de.jeanlucmakiola.calendula.domain.EditSnapshot
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
@@ -19,12 +20,17 @@ import de.jeanlucmakiola.calendula.domain.populatedFields
|
|||||||
import de.jeanlucmakiola.calendula.domain.problems
|
import de.jeanlucmakiola.calendula.domain.problems
|
||||||
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
@@ -98,19 +104,44 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val writable: List<CalendarSource>,
|
val writable: List<CalendarSource>,
|
||||||
val lastUsed: Long?,
|
val lastUsed: Long?,
|
||||||
val defaultFields: Set<EventFormField>,
|
val defaultFields: Set<EventFormField>,
|
||||||
|
val allowColorOnUnsupported: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Writable calendars — the only valid event targets. */
|
||||||
|
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
|
||||||
|
.map { calendars -> calendars.filter { it.canModifyContents } }
|
||||||
|
.catch { emit(emptyList()) }
|
||||||
|
|
||||||
|
/** The target calendar id, resolved exactly as the form shows it. */
|
||||||
|
private val resolvedCalendarId: Flow<Long?> = combine(
|
||||||
|
_form.map { it?.calendarId },
|
||||||
|
writableCalendars,
|
||||||
|
prefs.lastUsedCalendarId,
|
||||||
|
) { picked, writable, lastUsed ->
|
||||||
|
picked
|
||||||
|
?: lastUsed?.takeIf { id -> writable.any { it.id == id } }
|
||||||
|
?: writable.firstOrNull()?.id
|
||||||
|
}.distinctUntilChanged()
|
||||||
|
|
||||||
|
/** The resolved calendar's published event palette, refetched when it changes. */
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
private val colorPalette: Flow<List<EventColorOption>> = resolvedCalendarId
|
||||||
|
.flatMapLatest { id ->
|
||||||
|
flow { emit(id?.let { repository.eventColorPalette(it) }.orEmpty()) }
|
||||||
|
}
|
||||||
|
.flowOn(io)
|
||||||
|
|
||||||
val state: StateFlow<EventEditUiState?> = combine(
|
val state: StateFlow<EventEditUiState?> = combine(
|
||||||
combine(_form, _saveState, _showProblems, _revealed, _editTarget, ::LocalInputs),
|
combine(_form, _saveState, _showProblems, _revealed, _editTarget, ::LocalInputs),
|
||||||
combine(
|
combine(
|
||||||
repository.calendars()
|
writableCalendars,
|
||||||
.map { calendars -> calendars.filter { it.canModifyContents } }
|
|
||||||
.catch { emit(emptyList()) },
|
|
||||||
prefs.lastUsedCalendarId,
|
prefs.lastUsedCalendarId,
|
||||||
settingsPrefs.defaultFormFields,
|
settingsPrefs.defaultFormFields,
|
||||||
|
settingsPrefs.allowColorOnUnsupportedCalendars,
|
||||||
::ExternalInputs,
|
::ExternalInputs,
|
||||||
).flowOn(io),
|
).flowOn(io),
|
||||||
) { local, external ->
|
colorPalette,
|
||||||
|
) { local, external, palette ->
|
||||||
val form = local.form ?: return@combine null
|
val form = local.form ?: return@combine null
|
||||||
val resolvedId = form.calendarId
|
val resolvedId = form.calendarId
|
||||||
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
||||||
@@ -129,6 +160,8 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// 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 &&
|
||||||
resolved.rrule != local.editTarget.original.rrule,
|
resolved.rrule != local.editTarget.original.rrule,
|
||||||
|
colorPalette = palette,
|
||||||
|
allowColorOnUnsupportedCalendars = external.allowColorOnUnsupported,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.stateIn(
|
.stateIn(
|
||||||
@@ -207,10 +240,25 @@ class EventEditViewModel @Inject constructor(
|
|||||||
fun setLocation(value: String) = update { it.copy(location = value) }
|
fun setLocation(value: String) = update { it.copy(location = value) }
|
||||||
fun setDescription(value: String) = update { it.copy(description = value) }
|
fun setDescription(value: String) = update { it.copy(description = value) }
|
||||||
fun setAllDay(value: Boolean) = update { it.copy(isAllDay = value) }
|
fun setAllDay(value: Boolean) = update { it.copy(isAllDay = value) }
|
||||||
fun setCalendar(id: Long) = update { it.copy(calendarId = id) }
|
|
||||||
|
/**
|
||||||
|
* Switching calendars drops any chosen colour: a palette key is
|
||||||
|
* account-scoped, and a raw colour may be invalid on the new calendar.
|
||||||
|
* The event falls back to the new calendar's colour until re-picked.
|
||||||
|
*/
|
||||||
|
fun setCalendar(id: Long) = update { it.copy(calendarId = id, colorKey = null, color = null) }
|
||||||
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
||||||
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
||||||
|
|
||||||
|
/** Pick a palette colour (round-trips via its key); [argb] is its swatch. */
|
||||||
|
fun setColorKey(key: String, argb: Int) = update { it.copy(colorKey = key, color = argb) }
|
||||||
|
|
||||||
|
/** Pick a raw colour (local / opted-in calendars), clearing any palette key. */
|
||||||
|
fun setColorRaw(argb: Int) = update { it.copy(colorKey = null, color = argb) }
|
||||||
|
|
||||||
|
/** Clear the colour so the event inherits its calendar's. */
|
||||||
|
fun clearColor() = update { it.copy(colorKey = null, color = null) }
|
||||||
|
|
||||||
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
|
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
|
||||||
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
|
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,11 @@ fun MonthScreen(
|
|||||||
}
|
}
|
||||||
viewModel.goToToday()
|
viewModel.goToToday()
|
||||||
}
|
}
|
||||||
|
// Drawer jump-to-date: slide from the side the target month lies on.
|
||||||
|
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||||
|
slideDir = if (YearMonth(target.year, target.month) < month) -1 else 1
|
||||||
|
viewModel.goToDate(target)
|
||||||
|
}
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -132,10 +137,15 @@ fun MonthScreen(
|
|||||||
drawerContent = {
|
drawerContent = {
|
||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
|
currentDate = LocalDate(month.year, month.month, 1),
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
},
|
},
|
||||||
|
onJumpToDate = { target ->
|
||||||
|
jumpToDate(target)
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
onSettings = {
|
onSettings = {
|
||||||
onOpenSettings()
|
onOpenSettings()
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ class MonthViewModel @Inject constructor(
|
|||||||
_month.value = YearMonth(todayDate.year, todayDate.month)
|
_month.value = YearMonth(todayDate.year, todayDate.month)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Jump to the month containing [date] (drawer jump-to-date). */
|
||||||
|
fun goToDate(date: LocalDate) {
|
||||||
|
_month.value = YearMonth(date.year, date.month)
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildState(
|
private fun buildState(
|
||||||
ym: YearMonth,
|
ym: YearMonth,
|
||||||
weekStart: DayOfWeek,
|
weekStart: DayOfWeek,
|
||||||
@@ -108,53 +113,57 @@ class MonthViewModel @Inject constructor(
|
|||||||
return MonthUiState.Success(
|
return MonthUiState.Success(
|
||||||
month = ym,
|
month = ym,
|
||||||
today = todayDate,
|
today = todayDate,
|
||||||
weeks = layoutMonth(ym, weekStart, instances),
|
weeks = layoutMonthWeeks(ym, weekStart, instances, zone),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Split the grid into week rows and resolve each row's events. An event is a
|
* Split the month grid into week rows and resolve each row's events. An event is
|
||||||
* spanning bar when it's all-day or touches more than one of the row's days;
|
* a spanning bar when it's all-day or touches more than one of the row's days;
|
||||||
* everything else is a single-day timed pill. Bars get lanes from the shared
|
* everything else is a single-day timed pill. Bars get lanes from the shared
|
||||||
* [layoutAllDay] so a multi-day event stays on one row across the week.
|
* [layoutAllDay] so a multi-day event stays on one row across the week.
|
||||||
*/
|
*
|
||||||
private fun layoutMonth(
|
* Shared by the Month screen and the month home-screen widget so both lay out
|
||||||
ym: YearMonth,
|
* spans, lanes and per-day counts identically.
|
||||||
weekStart: DayOfWeek,
|
*/
|
||||||
instances: List<EventInstance>,
|
internal fun layoutMonthWeeks(
|
||||||
): List<MonthWeek> {
|
ym: YearMonth,
|
||||||
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
|
weekStart: DayOfWeek,
|
||||||
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
|
instances: List<EventInstance>,
|
||||||
val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
zone: TimeZone,
|
||||||
val daysInMonth =
|
): List<MonthWeek> {
|
||||||
java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
|
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
|
||||||
val weekCount = (leadOffset + daysInMonth + 6) / 7
|
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
|
||||||
|
val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||||
|
val daysInMonth =
|
||||||
|
java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
|
||||||
|
val weekCount = (leadOffset + daysInMonth + 6) / 7
|
||||||
|
|
||||||
return (0 until weekCount).map { row ->
|
return (0 until weekCount).map { row ->
|
||||||
val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
|
val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
|
||||||
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
|
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
|
||||||
val (bars, singles) = weekEvents.partition { ev ->
|
val (bars, singles) = weekEvents.partition { ev ->
|
||||||
ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
|
ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
|
||||||
}
|
}
|
||||||
val spans = layoutAllDay(bars, days, zone).map { s ->
|
val spans = layoutAllDay(bars, days, zone).map { s ->
|
||||||
MonthSpan(
|
MonthSpan(
|
||||||
event = s.event,
|
event = s.event,
|
||||||
startCol = s.startCol,
|
startCol = s.startCol,
|
||||||
endCol = s.endCol,
|
endCol = s.endCol,
|
||||||
lane = s.lane,
|
lane = s.lane,
|
||||||
continuesLeft = s.event.coversDay(days.first().minus(1, DateTimeUnit.DAY), zone),
|
continuesLeft = s.event.coversDay(days.first().minus(1, DateTimeUnit.DAY), zone),
|
||||||
continuesRight = s.event.coversDay(days.last().plus(1, DateTimeUnit.DAY), zone),
|
continuesRight = s.event.coversDay(days.last().plus(1, DateTimeUnit.DAY), zone),
|
||||||
)
|
|
||||||
}
|
|
||||||
MonthWeek(
|
|
||||||
days = days,
|
|
||||||
spans = spans,
|
|
||||||
timedByDay = days.associateWith { d ->
|
|
||||||
singles.filter { it.coversDay(d, zone) }.sortedBy { it.start }
|
|
||||||
},
|
|
||||||
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
MonthWeek(
|
||||||
|
days = days,
|
||||||
|
spans = spans,
|
||||||
|
timedByDay = days.associateWith { d ->
|
||||||
|
singles.filter { it.coversDay(d, zone) }.sortedBy { it.start }
|
||||||
|
},
|
||||||
|
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -431,6 +431,27 @@ private fun EventFormScreen(
|
|||||||
onClick = { viewModel.setFormFieldDefault(field, !checked) },
|
onClick = { viewModel.setFormFieldDefault(field, !checked) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-event colour on calendars that publish no colour set (some
|
||||||
|
// CalDAV) — off by default, with the honest caveat that the colour may
|
||||||
|
// not survive their next sync. Local and palette calendars ignore it.
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_color_unsupported),
|
||||||
|
summary = stringResource(R.string.settings_color_unsupported_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.allowColorOnUnsupportedCalendars,
|
||||||
|
onCheckedChange = { viewModel.setAllowColorOnUnsupportedCalendars(it) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
viewModel.setAllowColorOnUnsupportedCalendars(
|
||||||
|
!state.allowColorOnUnsupportedCalendars,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,6 +576,7 @@ private fun formFieldLabel(field: EventFormField): Int = when (field) {
|
|||||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||||
EventFormField.Availability -> R.string.event_edit_availability
|
EventFormField.Availability -> R.string.event_edit_availability
|
||||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||||
|
EventFormField.Color -> R.string.event_edit_color
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -20,4 +20,9 @@ data class SettingsUiState(
|
|||||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||||
/** Whether Calendula posts reminder notifications (v1.4). */
|
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||||
val remindersEnabled: Boolean = true,
|
val remindersEnabled: Boolean = true,
|
||||||
|
/**
|
||||||
|
* Whether the event-colour picker is offered on calendars that publish no
|
||||||
|
* colour palette (the colour may then not survive their next sync).
|
||||||
|
*/
|
||||||
|
val allowColorOnUnsupportedCalendars: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,20 +24,27 @@ class SettingsViewModel @Inject constructor(
|
|||||||
|
|
||||||
val state: StateFlow<SettingsUiState> =
|
val state: StateFlow<SettingsUiState> =
|
||||||
combine(
|
combine(
|
||||||
prefs.themeMode,
|
// combine() only types up to five flows, so the sixth pref folds
|
||||||
prefs.dynamicColor,
|
// into the assembled state in an outer combine.
|
||||||
prefs.weekStart,
|
combine(
|
||||||
prefs.defaultFormFields,
|
prefs.themeMode,
|
||||||
prefs.remindersEnabled,
|
prefs.dynamicColor,
|
||||||
) { theme, dynamic, weekStart, formFields, reminders ->
|
prefs.weekStart,
|
||||||
SettingsUiState(
|
prefs.defaultFormFields,
|
||||||
themeMode = theme,
|
prefs.remindersEnabled,
|
||||||
dynamicColor = dynamic && dynamicColorAvailable,
|
) { theme, dynamic, weekStart, formFields, reminders ->
|
||||||
dynamicColorAvailable = dynamicColorAvailable,
|
SettingsUiState(
|
||||||
weekStart = weekStart,
|
themeMode = theme,
|
||||||
defaultFormFields = formFields,
|
dynamicColor = dynamic && dynamicColorAvailable,
|
||||||
remindersEnabled = reminders,
|
dynamicColorAvailable = dynamicColorAvailable,
|
||||||
)
|
weekStart = weekStart,
|
||||||
|
defaultFormFields = formFields,
|
||||||
|
remindersEnabled = reminders,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
prefs.allowColorOnUnsupportedCalendars,
|
||||||
|
) { base, allowColor ->
|
||||||
|
base.copy(allowColorOnUnsupportedCalendars = allowColor)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
@@ -63,4 +70,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
fun setRemindersEnabled(enabled: Boolean) {
|
fun setRemindersEnabled(enabled: Boolean) {
|
||||||
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setAllowColorOnUnsupportedCalendars(enabled) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,11 @@ fun WeekScreen(
|
|||||||
}
|
}
|
||||||
viewModel.goToToday()
|
viewModel.goToToday()
|
||||||
}
|
}
|
||||||
|
// Drawer jump-to-date: slide from the side the target week lies on.
|
||||||
|
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||||
|
slideDir = if (target < weekStart) -1 else 1
|
||||||
|
viewModel.goToDate(target)
|
||||||
|
}
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -164,10 +169,15 @@ fun WeekScreen(
|
|||||||
drawerContent = {
|
drawerContent = {
|
||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
|
currentDate = weekStart,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
},
|
},
|
||||||
|
onJumpToDate = { target ->
|
||||||
|
jumpToDate(target)
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
onSettings = {
|
onSettings = {
|
||||||
onOpenSettings()
|
onOpenSettings()
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ class WeekViewModel @Inject constructor(
|
|||||||
_anchor.value = todayDate
|
_anchor.value = todayDate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Jump to the week containing [date] (drawer jump-to-date). */
|
||||||
|
fun goToDate(date: LocalDate) {
|
||||||
|
_anchor.value = date
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildState(
|
private fun buildState(
|
||||||
start: LocalDate,
|
start: LocalDate,
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.minus
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/** How far ahead the agenda widget loads (a month of upcoming events). */
|
||||||
|
private const val AGENDA_WIDGET_DAYS = 30
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far either side of today the month widget pre-loads. The displayed month
|
||||||
|
* is chosen reactively in the composition, so one wide read covers ~13 months of
|
||||||
|
* prev/next navigation without re-querying on every arrow tap.
|
||||||
|
*/
|
||||||
|
private const val MONTH_WIDGET_RANGE_DAYS = 400
|
||||||
|
|
||||||
|
internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
|
internal fun today(zone: TimeZone): LocalDate =
|
||||||
|
Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
|
internal fun Context.hasCalendarPermission(): Boolean =
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
/** Snapshot rendered by the agenda widget. */
|
||||||
|
sealed interface AgendaWidgetData {
|
||||||
|
/** Calendar permission not granted — the widget can't read events. */
|
||||||
|
data object NeedsPermission : AgendaWidgetData
|
||||||
|
data class Ready(val today: LocalDate, val days: List<AgendaDay>) : AgendaWidgetData
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source data for the month widget: a wide window of instances plus the
|
||||||
|
* week-start preference and today. The widget computes each displayed month's
|
||||||
|
* grid from this in-memory list (via `layoutMonthWeeks`) as the user pages,
|
||||||
|
* so month navigation is pure recomposition — no reload, no flaky widget
|
||||||
|
* session restart.
|
||||||
|
*/
|
||||||
|
sealed interface MonthWidgetSource {
|
||||||
|
data object NeedsPermission : MonthWidgetSource
|
||||||
|
data class Ready(
|
||||||
|
val today: LocalDate,
|
||||||
|
val weekStart: DayOfWeek,
|
||||||
|
val instances: List<EventInstance>,
|
||||||
|
) : MonthWidgetSource
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-lived cache of the wide month window. Month navigation re-runs
|
||||||
|
* `provideGlance` (via `updateAll`), and re-querying ~13 months of instances on
|
||||||
|
* every arrow tap is what made paging feel sluggish — so we load once and reuse
|
||||||
|
* the same snapshot for every nearby month. Invalidated by
|
||||||
|
* [invalidateMonthWidgetCache] when calendar data changes (the freshness
|
||||||
|
* receiver), and automatically when the day rolls over (the `today` guard).
|
||||||
|
*/
|
||||||
|
internal object MonthWidgetCache {
|
||||||
|
@Volatile
|
||||||
|
var data: MonthWidgetSource.Ready? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun invalidateMonthWidgetCache() {
|
||||||
|
MonthWidgetCache.data = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot read of the upcoming agenda for the widget. Reuses the app's
|
||||||
|
* [agendaRange] window and [groupAgendaDays] grouping, and the repository's
|
||||||
|
* [first]-emitted snapshot already has hidden calendars filtered out.
|
||||||
|
*/
|
||||||
|
internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
||||||
|
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
||||||
|
val zone = systemZone()
|
||||||
|
val anchor = today(zone)
|
||||||
|
val repo = widgetEntryPoint().calendarRepository()
|
||||||
|
val instances = repo.instances(agendaRange(anchor, AGENDA_WIDGET_DAYS, zone)).first()
|
||||||
|
return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-shot wide read backing the month widget's grid for any nearby month. */
|
||||||
|
internal suspend fun Context.loadMonthWidgetSource(): MonthWidgetSource {
|
||||||
|
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
|
||||||
|
val zone = systemZone()
|
||||||
|
val anchor = today(zone)
|
||||||
|
// Reuse the cached window unless the day changed (then it's stale for "today").
|
||||||
|
MonthWidgetCache.data?.let { if (it.today == anchor) return it }
|
||||||
|
val ep = widgetEntryPoint()
|
||||||
|
val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||||
|
val from = anchor.minus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
||||||
|
val to = anchor.plus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
|
||||||
|
val instances = ep.calendarRepository().instances(from..to).first()
|
||||||
|
return MonthWidgetSource.Ready(today = anchor, weekStart = weekStart, instances = instances)
|
||||||
|
.also { MonthWidgetCache.data = it }
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hilt bridge for the Glance widgets. A [androidx.glance.appwidget.GlanceAppWidget]
|
||||||
|
* is instantiated by the framework, not by Hilt, so it can't take constructor
|
||||||
|
* injection. We instead reach the singleton graph through this entry point and
|
||||||
|
* read the same [CalendarRepository] / [SettingsPrefs] the app uses — so widget
|
||||||
|
* data (hidden-calendar filtering, week-start preference, …) matches the app
|
||||||
|
* one-to-one.
|
||||||
|
*/
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface WidgetEntryPoint {
|
||||||
|
fun calendarRepository(): CalendarRepository
|
||||||
|
fun settingsPrefs(): SettingsPrefs
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Context.widgetEntryPoint(): WidgetEntryPoint =
|
||||||
|
EntryPointAccessors.fromApplication(applicationContext, WidgetEntryPoint::class.java)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.glance.GlanceTheme
|
||||||
|
import androidx.glance.material3.ColorProviders
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brand fallback for devices without Material You dynamic colour (API < 31).
|
||||||
|
* Reuses the exact same hand-tuned schemes as the in-app theme
|
||||||
|
* ([CalendulaLightFallback] / [CalendulaDarkFallback]) so a widget on an older
|
||||||
|
* device matches the app surface-for-surface.
|
||||||
|
*/
|
||||||
|
private val CalendulaGlanceColors = ColorProviders(
|
||||||
|
light = CalendulaLightFallback,
|
||||||
|
dark = CalendulaDarkFallback,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Glance equivalent of `CalendulaTheme`. On API 31+ it follows the system's
|
||||||
|
* Material You palette (so the widget matches the home screen / the app's
|
||||||
|
* dynamic colour); below that it falls back to the brand scheme. Either way the
|
||||||
|
* widget draws only from M3 colour-role tokens (`GlanceTheme.colors.*`) — never
|
||||||
|
* a hardcoded colour — so it tracks light/dark automatically.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CalendulaGlanceTheme(content: @Composable () -> Unit) {
|
||||||
|
val colors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
GlanceTheme.colors
|
||||||
|
} else {
|
||||||
|
CalendulaGlanceColors
|
||||||
|
}
|
||||||
|
GlanceTheme(colors = colors, content = content)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.glance.appwidget.updateAll
|
||||||
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||||
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redraws both home-screen widgets when their data goes stale. Triggered by:
|
||||||
|
* - `PROVIDER_CHANGED` from the calendar provider — fires on any data change,
|
||||||
|
* so it covers both the app's own writes and external sync.
|
||||||
|
* - `DATE_CHANGED` / `TIME_SET` / `TIMEZONE_CHANGED` — so "today" highlighting
|
||||||
|
* and the upcoming window roll over at midnight / on a clock change.
|
||||||
|
*
|
||||||
|
* Both widgets also carry an `updatePeriodMillis` backstop in their provider
|
||||||
|
* XML, and the month widget's refresh button forces an immediate redraw.
|
||||||
|
*/
|
||||||
|
class WidgetUpdateReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val pending = goAsync()
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
// Calendar data may have changed (sync / our own write) — drop the cached
|
||||||
|
// month window so the widgets reload fresh. Month paging does NOT call
|
||||||
|
// this, so arrow taps stay instant.
|
||||||
|
invalidateMonthWidgetCache()
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
AgendaWidget().updateAll(appContext)
|
||||||
|
MonthWidget().updateAll(appContext)
|
||||||
|
} finally {
|
||||||
|
pending.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.agenda
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.glance.ColorFilter
|
||||||
|
import androidx.glance.GlanceId
|
||||||
|
import androidx.glance.GlanceModifier
|
||||||
|
import androidx.glance.GlanceTheme
|
||||||
|
import androidx.glance.Image
|
||||||
|
import androidx.glance.ImageProvider
|
||||||
|
import androidx.glance.action.ActionParameters
|
||||||
|
import androidx.glance.action.clickable
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.action.ActionCallback
|
||||||
|
import androidx.glance.appwidget.action.actionRunCallback
|
||||||
|
import androidx.glance.appwidget.action.actionStartActivity
|
||||||
|
import androidx.glance.appwidget.cornerRadius
|
||||||
|
import androidx.glance.appwidget.lazy.LazyColumn
|
||||||
|
import androidx.glance.appwidget.lazy.items
|
||||||
|
import androidx.glance.appwidget.provideContent
|
||||||
|
import androidx.glance.appwidget.updateAll
|
||||||
|
import androidx.glance.background
|
||||||
|
import androidx.glance.layout.Alignment
|
||||||
|
import androidx.glance.layout.Box
|
||||||
|
import androidx.glance.layout.Column
|
||||||
|
import androidx.glance.layout.Row
|
||||||
|
import androidx.glance.layout.Spacer
|
||||||
|
import androidx.glance.layout.fillMaxSize
|
||||||
|
import androidx.glance.layout.fillMaxWidth
|
||||||
|
import androidx.glance.layout.height
|
||||||
|
import androidx.glance.layout.padding
|
||||||
|
import androidx.glance.layout.size
|
||||||
|
import androidx.glance.layout.width
|
||||||
|
import androidx.glance.text.FontWeight
|
||||||
|
import androidx.glance.text.Text
|
||||||
|
import androidx.glance.text.TextStyle
|
||||||
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
|
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
||||||
|
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||||
|
import de.jeanlucmakiola.calendula.widget.today
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
||||||
|
* of events grouped under day headers (the Google "Schedule" widget model).
|
||||||
|
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
||||||
|
*/
|
||||||
|
class AgendaWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
|
val data = context.loadAgendaWidgetData()
|
||||||
|
val dark = (context.resources.configuration.uiMode and
|
||||||
|
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
|
provideContent {
|
||||||
|
CalendulaGlanceTheme {
|
||||||
|
AgendaWidgetBody(data = data, dark = dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-reads the calendar and redraws the widget (header refresh button). */
|
||||||
|
class RefreshAgendaAction : ActionCallback {
|
||||||
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
|
AgendaWidget().updateAll(context.applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flat row model so the [LazyColumn] can mix day headers and events. */
|
||||||
|
private sealed interface AgendaRow {
|
||||||
|
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
|
||||||
|
data class Event(val event: EventInstance) : AgendaRow
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||||
|
Column(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(GlanceTheme.colors.surface)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
AgendaHeader()
|
||||||
|
Spacer(GlanceModifier.height(4.dp))
|
||||||
|
when (data) {
|
||||||
|
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
|
||||||
|
is AgendaWidgetData.Ready ->
|
||||||
|
if (data.days.isEmpty()) {
|
||||||
|
WidgetMessage(R.string.agenda_empty_title)
|
||||||
|
} else {
|
||||||
|
val rows = buildList {
|
||||||
|
data.days.forEach { day ->
|
||||||
|
add(AgendaRow.Header(day.date, data.today))
|
||||||
|
day.events.forEach { add(AgendaRow.Event(it)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
|
items(rows.size) { index ->
|
||||||
|
when (val row = rows[index]) {
|
||||||
|
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
||||||
|
is AgendaRow.Event -> EventRow(row.event, dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaHeader() {
|
||||||
|
val context = androidx.glance.LocalContext.current
|
||||||
|
Row(
|
||||||
|
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = context.getString(R.string.widget_agenda_title),
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.primary,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
),
|
||||||
|
modifier = GlanceModifier.defaultWeight(),
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
resId = R.drawable.ic_widget_refresh,
|
||||||
|
contentDescription = context.getString(R.string.widget_refresh),
|
||||||
|
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
resId = R.drawable.ic_widget_add,
|
||||||
|
contentDescription = context.getString(R.string.widget_new_event),
|
||||||
|
onClick = GlanceModifier.clickable(
|
||||||
|
actionStartActivity(
|
||||||
|
MainActivity.openCreateIntent(context, today(systemZone())),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IconButton(resId: Int, contentDescription: String, onClick: GlanceModifier) {
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier.size(40.dp).then(onClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
provider = ImageProvider(resId),
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||||
|
modifier = GlanceModifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
||||||
|
val context = androidx.glance.LocalContext.current
|
||||||
|
Text(
|
||||||
|
text = agendaDayLabel(context, date, today),
|
||||||
|
style = TextStyle(
|
||||||
|
color = if (date == today) GlanceTheme.colors.primary
|
||||||
|
else GlanceTheme.colors.onSurfaceVariant,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
),
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp)
|
||||||
|
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EventRow(event: EventInstance, dark: Boolean) {
|
||||||
|
val context = androidx.glance.LocalContext.current
|
||||||
|
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
|
Row(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||||
|
.clickable(
|
||||||
|
actionStartActivity(
|
||||||
|
MainActivity.eventDetailIntent(
|
||||||
|
context = context,
|
||||||
|
eventId = event.eventId,
|
||||||
|
beginMillis = event.start.toEpochMilliseconds(),
|
||||||
|
endMillis = event.end.toEpochMilliseconds(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.width(5.dp)
|
||||||
|
.height(36.dp)
|
||||||
|
.cornerRadius(3.dp)
|
||||||
|
.background(pastelize(event.color, dark)),
|
||||||
|
) {}
|
||||||
|
Spacer(GlanceModifier.width(10.dp))
|
||||||
|
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = eventTimeSummary(context, event),
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WidgetMessage(resId: Int) {
|
||||||
|
val context = androidx.glance.LocalContext.current
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = context.getString(resId),
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun zone(): TimeZone = systemZone()
|
||||||
|
|
||||||
|
/** "Today · Wed, 17 Jun" — relative word for today/tomorrow, else the date. */
|
||||||
|
private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
|
||||||
|
val relative = when (date) {
|
||||||
|
today -> context.getString(R.string.agenda_header_today)
|
||||||
|
today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||||
|
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||||
|
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||||
|
val formatted = "$weekday, ${date.day} $monthName"
|
||||||
|
return if (relative != null) "$relative · $formatted" else formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun eventTimeSummary(context: Context, event: EventInstance): String {
|
||||||
|
val time = if (event.isAllDay) {
|
||||||
|
context.getString(R.string.event_detail_all_day)
|
||||||
|
} else {
|
||||||
|
"${formatTime(event.start)} – ${formatTime(event.end)}"
|
||||||
|
}
|
||||||
|
val location = event.location?.takeIf { it.isNotBlank() }
|
||||||
|
return if (location != null) "$time · $location" else time
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTime(instant: Instant): String {
|
||||||
|
val t = instant.toLocalDateTime(zone()).time
|
||||||
|
return "%02d:%02d".format(t.hour, t.minute)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.agenda
|
||||||
|
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host-facing receiver for the agenda widget. Declared in the manifest with the
|
||||||
|
* `appwidget_info_agenda` provider metadata; delegates all rendering to
|
||||||
|
* [AgendaWidget].
|
||||||
|
*/
|
||||||
|
class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
|
override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.month
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.glance.ColorFilter
|
||||||
|
import androidx.glance.GlanceId
|
||||||
|
import androidx.glance.GlanceModifier
|
||||||
|
import androidx.glance.GlanceTheme
|
||||||
|
import androidx.glance.Image
|
||||||
|
import androidx.glance.ImageProvider
|
||||||
|
import androidx.glance.LocalContext
|
||||||
|
import androidx.glance.LocalSize
|
||||||
|
import androidx.glance.action.ActionParameters
|
||||||
|
import androidx.glance.action.actionParametersOf
|
||||||
|
import androidx.glance.action.clickable
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.SizeMode
|
||||||
|
import androidx.glance.appwidget.action.ActionCallback
|
||||||
|
import androidx.glance.appwidget.action.actionRunCallback
|
||||||
|
import androidx.glance.appwidget.action.actionStartActivity
|
||||||
|
import androidx.glance.appwidget.cornerRadius
|
||||||
|
import androidx.glance.appwidget.provideContent
|
||||||
|
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||||
|
import androidx.glance.appwidget.updateAll
|
||||||
|
import androidx.glance.background
|
||||||
|
import androidx.glance.currentState
|
||||||
|
import androidx.glance.layout.Alignment
|
||||||
|
import androidx.glance.layout.Box
|
||||||
|
import androidx.glance.layout.Column
|
||||||
|
import androidx.glance.layout.Row
|
||||||
|
import androidx.glance.layout.Spacer
|
||||||
|
import androidx.glance.layout.fillMaxSize
|
||||||
|
import androidx.glance.layout.fillMaxWidth
|
||||||
|
import androidx.glance.layout.height
|
||||||
|
import androidx.glance.layout.padding
|
||||||
|
import androidx.glance.layout.size
|
||||||
|
import androidx.glance.layout.width
|
||||||
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
|
import androidx.glance.text.FontWeight
|
||||||
|
import androidx.glance.text.Text
|
||||||
|
import androidx.glance.text.TextAlign
|
||||||
|
import androidx.glance.text.TextStyle
|
||||||
|
import androidx.glance.unit.ColorProvider
|
||||||
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||||
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
|
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||||
|
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
|
||||||
|
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||||
|
import de.jeanlucmakiola.calendula.widget.today
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.Month
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.YearMonth
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */
|
||||||
|
private val MONTH_INDEX_KEY = intPreferencesKey("month_index")
|
||||||
|
|
||||||
|
/** Event rows (lanes) shown per week before the rest collapse into "+N". */
|
||||||
|
private const val MAX_LANES = 3
|
||||||
|
private val LANE_HEIGHT = 14.dp
|
||||||
|
private val DAY_NUMBER_HEIGHT = 18.dp
|
||||||
|
private val GRID_HPADDING = 8.dp
|
||||||
|
|
||||||
|
/** Dark ink that reads on the pastelized event fills, like the in-app MonthBar. */
|
||||||
|
private val EventInk = ColorProvider(Color(0xDE000000))
|
||||||
|
|
||||||
|
private fun currentMonthIndex(zone: TimeZone): Int {
|
||||||
|
val t = today(zone)
|
||||||
|
return t.year * 12 + t.month.ordinal
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun yearMonthOf(index: Int): YearMonth =
|
||||||
|
YearMonth(index / 12, Month(index % 12 + 1))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Month-grid widget: a 6×7 calendar with today highlighted, connected multi-day
|
||||||
|
* event bars and titled single-day pills (the in-app lane layout via
|
||||||
|
* [layoutMonthWeeks]), and prev/next/today navigation.
|
||||||
|
*
|
||||||
|
* Columns are sized explicitly from [LocalSize] (hence [SizeMode.Exact]) so a
|
||||||
|
* multi-day span renders as a single Box spanning its columns — connected, no
|
||||||
|
* inter-cell seam, with rounded end caps. The displayed month lives in Glance
|
||||||
|
* state and is read reactively in the composition ([currentState]) so the arrows
|
||||||
|
* move it via plain recomposition, not a (here-unreliable) widget session reload.
|
||||||
|
*/
|
||||||
|
class MonthWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
|
override val sizeMode = SizeMode.Exact
|
||||||
|
|
||||||
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
|
val source = context.loadMonthWidgetSource()
|
||||||
|
val dark = (context.resources.configuration.uiMode and
|
||||||
|
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
|
provideContent {
|
||||||
|
CalendulaGlanceTheme {
|
||||||
|
MonthWidgetBody(source = source, dark = dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step the displayed month by the `delta` action parameter (±1). */
|
||||||
|
class ShiftMonthAction : ActionCallback {
|
||||||
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
|
val delta = parameters[deltaKey] ?: 0
|
||||||
|
updateAppWidgetState(context, glanceId) { prefs ->
|
||||||
|
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
|
||||||
|
prefs[MONTH_INDEX_KEY] = cur + delta
|
||||||
|
}
|
||||||
|
MonthWidget().updateAll(context.applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val deltaKey = ActionParameters.Key<Int>("delta")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jump the displayed month back to the current month. */
|
||||||
|
class ResetMonthAction : ActionCallback {
|
||||||
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
|
updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) }
|
||||||
|
MonthWidget().updateAll(context.applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) {
|
||||||
|
Column(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(GlanceTheme.colors.surface)
|
||||||
|
.padding(horizontal = GRID_HPADDING, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
when (source) {
|
||||||
|
MonthWidgetSource.NeedsPermission -> {
|
||||||
|
MonthHeader(label = "Calendula")
|
||||||
|
PermissionMessage()
|
||||||
|
}
|
||||||
|
is MonthWidgetSource.Ready -> {
|
||||||
|
val zone = systemZone()
|
||||||
|
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
|
||||||
|
val ym = yearMonthOf(index)
|
||||||
|
// Column width from the live widget size, minus our H padding.
|
||||||
|
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
||||||
|
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
||||||
|
|
||||||
|
MonthHeader(label = monthLabel(ym))
|
||||||
|
Spacer(GlanceModifier.height(2.dp))
|
||||||
|
WeekdayHeader(weekStart = source.weekStart, colW = colW)
|
||||||
|
weeks.forEach { week ->
|
||||||
|
WeekRow(
|
||||||
|
week = week,
|
||||||
|
currentMonth = ym.month,
|
||||||
|
today = source.today,
|
||||||
|
dark = dark,
|
||||||
|
colW = colW,
|
||||||
|
modifier = GlanceModifier.defaultWeight(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MonthHeader(label: String) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
Row(
|
||||||
|
modifier = GlanceModifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
HeaderIcon(
|
||||||
|
resId = R.drawable.ic_widget_chevron_left,
|
||||||
|
contentDescription = context.getString(R.string.widget_prev_month),
|
||||||
|
onClick = GlanceModifier.clickable(
|
||||||
|
actionRunCallback<ShiftMonthAction>(
|
||||||
|
actionParametersOf(ShiftMonthAction.deltaKey to -1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.primary,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
),
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.defaultWeight()
|
||||||
|
.clickable(actionRunCallback<ResetMonthAction>()),
|
||||||
|
)
|
||||||
|
HeaderIcon(
|
||||||
|
resId = R.drawable.ic_widget_today,
|
||||||
|
contentDescription = context.getString(R.string.widget_today),
|
||||||
|
onClick = GlanceModifier.clickable(
|
||||||
|
actionStartActivity(MainActivity.openDateIntent(context, today(systemZone()))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
HeaderIcon(
|
||||||
|
resId = R.drawable.ic_widget_chevron_right,
|
||||||
|
contentDescription = context.getString(R.string.widget_next_month),
|
||||||
|
onClick = GlanceModifier.clickable(
|
||||||
|
actionRunCallback<ShiftMonthAction>(
|
||||||
|
actionParametersOf(ShiftMonthAction.deltaKey to 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HeaderIcon(resId: Int, contentDescription: String, onClick: GlanceModifier) {
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier.size(40.dp).then(onClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
provider = ImageProvider(resId),
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||||
|
modifier = GlanceModifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) {
|
||||||
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
|
weekdayNarrowNames(weekStart).forEach { name ->
|
||||||
|
Text(
|
||||||
|
text = name,
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.onSurfaceVariant,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
),
|
||||||
|
modifier = GlanceModifier.width(colW),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Narrow weekday initials starting at [weekStart], in the device locale.
|
||||||
|
* Computed outside the composable so the locale read stays observable-safe. */
|
||||||
|
private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
return (0 until 7).map { i ->
|
||||||
|
java.time.DayOfWeek.of((weekStart.ordinal + i) % 7 + 1)
|
||||||
|
.getDisplayName(JavaTextStyle.NARROW, locale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeekRow(
|
||||||
|
week: MonthWeek,
|
||||||
|
currentMonth: Month,
|
||||||
|
today: LocalDate,
|
||||||
|
dark: Boolean,
|
||||||
|
colW: Dp,
|
||||||
|
modifier: GlanceModifier,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
|
// Day numbers.
|
||||||
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
|
week.days.forEach { date ->
|
||||||
|
DayNumber(
|
||||||
|
date = date,
|
||||||
|
isToday = date == today,
|
||||||
|
inMonth = date.month == currentMonth,
|
||||||
|
colW = colW,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(GlanceModifier.height(2.dp))
|
||||||
|
// One lane row per event row. A multi-day span is a single Box spanning
|
||||||
|
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
||||||
|
repeat(MAX_LANES) { lane ->
|
||||||
|
LaneRow(week = week, lane = lane, dark = dark, colW = colW)
|
||||||
|
Spacer(GlanceModifier.height(1.dp))
|
||||||
|
}
|
||||||
|
OverflowRow(week = week, colW = colW)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier.width(colW).height(DAY_NUMBER_HEIGHT),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.size(DAY_NUMBER_HEIGHT)
|
||||||
|
.then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = date.day.toString(),
|
||||||
|
style = TextStyle(
|
||||||
|
color = when {
|
||||||
|
isToday -> GlanceTheme.colors.onPrimary
|
||||||
|
inMonth -> GlanceTheme.colors.onSurface
|
||||||
|
else -> GlanceTheme.colors.onSurfaceVariant
|
||||||
|
},
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
||||||
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
|
var col = 0
|
||||||
|
while (col < 7) {
|
||||||
|
val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||||
|
if (span != null) {
|
||||||
|
val cols = span.endCol - col + 1
|
||||||
|
SpanBar(event = span.event, dark = dark, width = colW * cols)
|
||||||
|
col = span.endCol + 1
|
||||||
|
} else {
|
||||||
|
val timed = timedEventAt(week, lane, col, week.days[col])
|
||||||
|
if (timed != null) {
|
||||||
|
SpanBar(event = timed, dark = dark, width = colW)
|
||||||
|
} else {
|
||||||
|
Box(GlanceModifier.width(colW).height(LANE_HEIGHT)) {}
|
||||||
|
}
|
||||||
|
col += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single connected, rounded event bar [width] wide with its clipped title. */
|
||||||
|
@Composable
|
||||||
|
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
Box(modifier = GlanceModifier.width(width).height(LANE_HEIGHT).padding(horizontal = 1.dp)) {
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.cornerRadius(4.dp)
|
||||||
|
.background(pastelize(event.color, dark)),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = EventInk, fontSize = 9.sp),
|
||||||
|
modifier = GlanceModifier.padding(horizontal = 3.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OverflowRow(week: MonthWeek, colW: Dp) {
|
||||||
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
|
week.days.forEachIndexed { col, date ->
|
||||||
|
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||||
|
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
||||||
|
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
|
||||||
|
val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier.width(colW).height(LANE_HEIGHT),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
if (hidden > 0) {
|
||||||
|
Text(
|
||||||
|
text = "+$hidden",
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
|
||||||
|
modifier = GlanceModifier.padding(start = 3.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The timed single-day event that fills lane [lane] on day [col], if any. */
|
||||||
|
private fun timedEventAt(week: MonthWeek, lane: Int, col: Int, date: LocalDate): EventInstance? {
|
||||||
|
val occupied = week.spans
|
||||||
|
.filter { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||||
|
.map { it.lane }
|
||||||
|
.toSet()
|
||||||
|
val freeSlots = (0 until MAX_LANES).filter { it !in occupied }
|
||||||
|
val timed = week.timedByDay[date].orEmpty()
|
||||||
|
return timed.getOrNull(freeSlots.indexOf(lane))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PermissionMessage() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = context.getString(R.string.widget_needs_permission),
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.onSurfaceVariant,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun monthLabel(month: YearMonth): String {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
val name = java.time.Month.of(month.month.ordinal + 1)
|
||||||
|
.getDisplayName(JavaTextStyle.FULL, locale)
|
||||||
|
return "$name ${month.year}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.month
|
||||||
|
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host-facing receiver for the month widget. Declared in the manifest with the
|
||||||
|
* `appwidget_info_month` provider metadata; delegates rendering to [MonthWidget].
|
||||||
|
*/
|
||||||
|
class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
|
override val glanceAppWidget: GlanceAppWidget = MonthWidget()
|
||||||
|
}
|
||||||
26
app/src/main/res/drawable/ic_shortcut_new_event.xml
Normal file
26
app/src/main/res/drawable/ic_shortcut_new_event.xml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Launcher long-press shortcut icon ("New event"): a brand-coloured circle
|
||||||
|
with a white calendar+plus glyph, as one scalable vector. Self-contained so
|
||||||
|
it stays visible on any launcher surface (shortcut icons aren't tinted, so a
|
||||||
|
bare white glyph would vanish on a light sheet). -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="48dp"
|
||||||
|
android:height="48dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#3B5364"
|
||||||
|
android:pathData="M0,12 a12,12 0 1,0 24,0 a12,12 0 1,0 -24,0 Z" />
|
||||||
|
<group
|
||||||
|
android:scaleX="0.58"
|
||||||
|
android:scaleY="0.58"
|
||||||
|
android:translateX="5"
|
||||||
|
android:translateY="5">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM19,19H5V8h14V19z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M11.5,10.5h1v2h2v1h-2v2h-1v-2h-2v-1h2z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
6
app/src/main/res/drawable/ic_widget_add.xml
Normal file
6
app/src/main/res/drawable/ic_widget_add.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
|
||||||
|
</vector>
|
||||||
6
app/src/main/res/drawable/ic_widget_chevron_left.xml
Normal file
6
app/src/main/res/drawable/ic_widget_chevron_left.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M15.41,7.41L14,6l-6,6 6,6 1.41,-1.41L10.83,12z"/>
|
||||||
|
</vector>
|
||||||
6
app/src/main/res/drawable/ic_widget_chevron_right.xml
Normal file
6
app/src/main/res/drawable/ic_widget_chevron_right.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M10,6L8.59,7.41 13.17,12l-4.58,4.59L10,18l6,-6z"/>
|
||||||
|
</vector>
|
||||||
6
app/src/main/res/drawable/ic_widget_refresh.xml
Normal file
6
app/src/main/res/drawable/ic_widget_refresh.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
|
||||||
|
</vector>
|
||||||
6
app/src/main/res/drawable/ic_widget_today.xml
Normal file
6
app/src/main/res/drawable/ic_widget_today.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2zM19,19H5V8h14v11zM7,10h5v5H7z"/>
|
||||||
|
</vector>
|
||||||
6
app/src/main/res/drawable/preview_stripe.xml
Normal file
6
app/src/main/res/drawable/preview_stripe.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#4F7CAC" />
|
||||||
|
<corners android:radius="3dp" />
|
||||||
|
</shape>
|
||||||
5
app/src/main/res/drawable/preview_today_circle.xml
Normal file
5
app/src/main/res/drawable/preview_today_circle.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="@color/widget_preview_primary" />
|
||||||
|
</shape>
|
||||||
6
app/src/main/res/drawable/preview_widget_bg.xml
Normal file
6
app/src/main/res/drawable/preview_widget_bg.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="@color/widget_preview_surface" />
|
||||||
|
<corners android:radius="20dp" />
|
||||||
|
</shape>
|
||||||
124
app/src/main/res/layout/widget_preview_agenda.xml
Normal file
124
app/src/main/res/layout/widget_preview_agenda.xml
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Static mock shown in the widget picker (android:previewLayout). Not the
|
||||||
|
runtime layout — the live widget is rendered by Glance. Colours are the
|
||||||
|
brand light scheme so the picker preview reads on its light sheet. -->
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@drawable/preview_widget_bg"
|
||||||
|
android:padding="14dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/widget_agenda_title"
|
||||||
|
android:textColor="@color/widget_preview_primary"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/agenda_header_today"
|
||||||
|
android:textColor="@color/widget_preview_primary"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="5dp"
|
||||||
|
android:layout_height="34dp"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:background="@drawable/preview_stripe"
|
||||||
|
android:backgroundTint="#4F7CAC" />
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="10dp"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Standup"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="09:00 – 09:30"
|
||||||
|
android:textColor="@color/widget_preview_variant"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="5dp"
|
||||||
|
android:layout_height="34dp"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:background="@drawable/preview_stripe"
|
||||||
|
android:backgroundTint="#6FA37A" />
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="10dp"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Design review"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="11:30 – 12:30"
|
||||||
|
android:textColor="@color/widget_preview_variant"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="5dp"
|
||||||
|
android:layout_height="34dp"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:background="@drawable/preview_stripe"
|
||||||
|
android:backgroundTint="#C58A56" />
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="10dp"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="1:1 with Mara"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="14:00 – 14:30"
|
||||||
|
android:textColor="@color/widget_preview_variant"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
114
app/src/main/res/layout/widget_preview_month.xml
Normal file
114
app/src/main/res/layout/widget_preview_month.xml
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Static mock shown in the widget picker (android:previewLayout). The live
|
||||||
|
widget is rendered by Glance with real events; this only needs to read as
|
||||||
|
"a month grid" in the picker. -->
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@drawable/preview_widget_bg"
|
||||||
|
android:padding="12dp">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="‹"
|
||||||
|
android:textColor="@color/widget_preview_variant"
|
||||||
|
android:textSize="16sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="June 2026"
|
||||||
|
android:textColor="@color/widget_preview_primary"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="›"
|
||||||
|
android:textColor="@color/widget_preview_variant"
|
||||||
|
android:textSize="16sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Weekday header -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="M" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="T" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="W" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="T" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="F" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="S" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="S" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Week 1: 1..7 (event bar on the 3rd) -->
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="1" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="2" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="3" android:textColor="#4F7CAC" android:textStyle="bold" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="4" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="5" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="6" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="7" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Week 2: 8..14 (bars on 11,12,13) -->
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="8" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="9" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="10" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="11" android:textColor="#6FA37A" android:textStyle="bold" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="12" android:textColor="#6FA37A" android:textStyle="bold" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="13" android:textColor="#6FA37A" android:textStyle="bold" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="14" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Week 3: 15..21 (17 = today) -->
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="15" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="16" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:orientation="horizontal">
|
||||||
|
<TextView android:layout_width="22dp" android:layout_height="22dp" android:gravity="center"
|
||||||
|
android:text="17" android:textColor="@color/widget_preview_on_primary" android:textStyle="bold" android:textSize="12sp"
|
||||||
|
android:background="@drawable/preview_today_circle" />
|
||||||
|
</LinearLayout>
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="18" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="19" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="20" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="21" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Week 4: 22..28 (bar on 24) -->
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="22" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="23" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="24" android:textColor="#C58A56" android:textStyle="bold" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="25" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="26" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="27" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="28" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Week 5: 29, 30, then trailing July days greyed -->
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="29" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="30" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="1" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="2" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="3" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="4" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
|
||||||
|
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="5" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
@@ -82,6 +82,15 @@
|
|||||||
<string name="event_edit_availability">Verfügbarkeit</string>
|
<string name="event_edit_availability">Verfügbarkeit</string>
|
||||||
<string name="event_edit_visibility">Sichtbarkeit</string>
|
<string name="event_edit_visibility">Sichtbarkeit</string>
|
||||||
|
|
||||||
|
<!-- Termin-Formular — eigene Terminfarbe -->
|
||||||
|
<string name="event_edit_color">Farbe</string>
|
||||||
|
<string name="event_edit_color_default">Kalenderfarbe</string>
|
||||||
|
<string name="event_edit_color_custom">Eigene Farbe</string>
|
||||||
|
<string name="event_edit_color_reset">Zurücksetzen</string>
|
||||||
|
<string name="event_edit_color_unsupported">Für diesen Kalender nicht verfügbar</string>
|
||||||
|
<string name="event_edit_color_unsupported_hint">Dieser Kalender stellt keine Farbpalette bereit. Du kannst eigene Farben für solche Kalender in den Einstellungen aktivieren.</string>
|
||||||
|
<string name="event_edit_color_sync_warning">Dieser Kalender verwirft oder überschreibt die Farbe unter Umständen bei der nächsten Synchronisierung.</string>
|
||||||
|
|
||||||
<!-- Termin-Formular — Speicher-Konflikt (v2.0) -->
|
<!-- Termin-Formular — Speicher-Konflikt (v2.0) -->
|
||||||
<string name="event_edit_conflict_title">Termin wurde extern geändert</string>
|
<string name="event_edit_conflict_title">Termin wurde extern geändert</string>
|
||||||
<string name="event_edit_conflict_body">Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren?</string>
|
<string name="event_edit_conflict_body">Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren?</string>
|
||||||
@@ -187,8 +196,34 @@
|
|||||||
<string name="view_month">Monat</string>
|
<string name="view_month">Monat</string>
|
||||||
<string name="view_week">Woche</string>
|
<string name="view_week">Woche</string>
|
||||||
<string name="view_day">Tag</string>
|
<string name="view_day">Tag</string>
|
||||||
|
<string name="view_agenda">Agenda</string>
|
||||||
<string name="view_section">Ansicht</string>
|
<string name="view_section">Ansicht</string>
|
||||||
|
|
||||||
|
<!-- Zu Datum springen (Navigationsleiste) -->
|
||||||
|
<string name="drawer_jump_to_date">Zu Datum springen</string>
|
||||||
|
|
||||||
|
<!-- Agenda-Ansicht -->
|
||||||
|
<string name="agenda_today_action">Heute</string>
|
||||||
|
<string name="agenda_header_today">Heute</string>
|
||||||
|
<string name="agenda_header_tomorrow">Morgen</string>
|
||||||
|
<string name="agenda_empty_title">Nichts geplant</string>
|
||||||
|
<string name="agenda_empty_subtitle">Anstehende Termine erscheinen hier.</string>
|
||||||
|
|
||||||
|
<!-- Startbildschirm-Widgets -->
|
||||||
|
<string name="widget_agenda_title">Anstehend</string>
|
||||||
|
<string name="widget_agenda_label">Calendula Agenda</string>
|
||||||
|
<string name="widget_month_label">Calendula Monat</string>
|
||||||
|
<string name="widget_refresh">Aktualisieren</string>
|
||||||
|
<string name="widget_new_event">Neuer Termin</string>
|
||||||
|
<string name="widget_needs_permission">Öffne Calendula, um Kalenderzugriff zu erlauben</string>
|
||||||
|
<string name="widget_prev_month">Vorheriger Monat</string>
|
||||||
|
<string name="widget_next_month">Nächster Monat</string>
|
||||||
|
<string name="widget_today">Heute</string>
|
||||||
|
|
||||||
|
<!-- Verknüpfungen (Long-Press auf das App-Symbol) -->
|
||||||
|
<string name="shortcut_new_event_short">Neuer Termin</string>
|
||||||
|
<string name="shortcut_new_event_long">Neuen Termin erstellen</string>
|
||||||
|
|
||||||
<!-- Kalender-Filter (M3) -->
|
<!-- Kalender-Filter (M3) -->
|
||||||
<string name="filter_title">Kalender</string>
|
<string name="filter_title">Kalender</string>
|
||||||
|
|
||||||
@@ -208,6 +243,8 @@
|
|||||||
<string name="settings_week_start_sunday">Sonntag</string>
|
<string name="settings_week_start_sunday">Sonntag</string>
|
||||||
<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_color_unsupported">Farben auf nicht unterstützten Kalendern erlauben</string>
|
||||||
|
<string name="settings_color_unsupported_hint">Manche Kalender (z. B. bestimmte CalDAV) stellen keine Farbpalette bereit; eine eigene Terminfarbe wird dort bei der nächsten Synchronisierung unter Umständen verworfen oder überschrieben. Das ist eine Einschränkung dieser Kalender und kann von Calendula nicht behoben werden.</string>
|
||||||
<string name="settings_section_notifications">Benachrichtigungen</string>
|
<string name="settings_section_notifications">Benachrichtigungen</string>
|
||||||
<string name="settings_reminders">Termin-Erinnerungen</string>
|
<string name="settings_reminders">Termin-Erinnerungen</string>
|
||||||
<string name="settings_reminders_hint">Erinnerungen doppelt? Eine andere Kalender-App zeigt sie ebenfalls — schalte sie in einer der beiden ab.</string>
|
<string name="settings_reminders_hint">Erinnerungen doppelt? Eine andere Kalender-App zeigt sie ebenfalls — schalte sie in einer der beiden ab.</string>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="widget_preview_surface">@android:color/system_neutral1_900</color>
|
||||||
|
<color name="widget_preview_on_surface">@android:color/system_neutral1_50</color>
|
||||||
|
<color name="widget_preview_variant">@android:color/system_neutral2_200</color>
|
||||||
|
<color name="widget_preview_primary">@android:color/system_accent1_200</color>
|
||||||
|
<color name="widget_preview_on_primary">@android:color/system_accent1_800</color>
|
||||||
|
</resources>
|
||||||
8
app/src/main/res/values-night/widget_preview_colors.xml
Normal file
8
app/src/main/res/values-night/widget_preview_colors.xml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="widget_preview_surface">#101316</color>
|
||||||
|
<color name="widget_preview_on_surface">#E1E3E6</color>
|
||||||
|
<color name="widget_preview_variant">#A8ADB2</color>
|
||||||
|
<color name="widget_preview_primary">#A3CBE2</color>
|
||||||
|
<color name="widget_preview_on_primary">#003348</color>
|
||||||
|
</resources>
|
||||||
8
app/src/main/res/values-v31/widget_preview_colors.xml
Normal file
8
app/src/main/res/values-v31/widget_preview_colors.xml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="widget_preview_surface">@android:color/system_neutral1_50</color>
|
||||||
|
<color name="widget_preview_on_surface">@android:color/system_neutral1_900</color>
|
||||||
|
<color name="widget_preview_variant">@android:color/system_neutral2_700</color>
|
||||||
|
<color name="widget_preview_primary">@android:color/system_accent1_600</color>
|
||||||
|
<color name="widget_preview_on_primary">@android:color/system_accent1_0</color>
|
||||||
|
</resources>
|
||||||
@@ -83,6 +83,15 @@
|
|||||||
<string name="event_edit_availability">Availability</string>
|
<string name="event_edit_availability">Availability</string>
|
||||||
<string name="event_edit_visibility">Visibility</string>
|
<string name="event_edit_visibility">Visibility</string>
|
||||||
|
|
||||||
|
<!-- Event form — per-event color -->
|
||||||
|
<string name="event_edit_color">Color</string>
|
||||||
|
<string name="event_edit_color_default">Calendar color</string>
|
||||||
|
<string name="event_edit_color_custom">Custom color</string>
|
||||||
|
<string name="event_edit_color_reset">Reset</string>
|
||||||
|
<string name="event_edit_color_unsupported">Not available for this calendar</string>
|
||||||
|
<string name="event_edit_color_unsupported_hint">This calendar publishes no color set. You can allow custom colors for such calendars in Settings.</string>
|
||||||
|
<string name="event_edit_color_sync_warning">This calendar may drop or overwrite the color on its next sync.</string>
|
||||||
|
|
||||||
<!-- Event form — save conflict (v2.0) -->
|
<!-- Event form — save conflict (v2.0) -->
|
||||||
<string name="event_edit_conflict_title">Event changed elsewhere</string>
|
<string name="event_edit_conflict_title">Event changed elsewhere</string>
|
||||||
<string name="event_edit_conflict_body">While you were editing, this event was changed — by sync or another app. What should happen to your changes?</string>
|
<string name="event_edit_conflict_body">While you were editing, this event was changed — by sync or another app. What should happen to your changes?</string>
|
||||||
@@ -188,8 +197,30 @@
|
|||||||
<string name="view_month">Month</string>
|
<string name="view_month">Month</string>
|
||||||
<string name="view_week">Week</string>
|
<string name="view_week">Week</string>
|
||||||
<string name="view_day">Day</string>
|
<string name="view_day">Day</string>
|
||||||
|
<string name="view_agenda">Agenda</string>
|
||||||
<string name="view_section">View</string>
|
<string name="view_section">View</string>
|
||||||
|
|
||||||
|
<!-- Jump to date (drawer) -->
|
||||||
|
<string name="drawer_jump_to_date">Jump to date</string>
|
||||||
|
|
||||||
|
<!-- Agenda view -->
|
||||||
|
<string name="agenda_today_action">Today</string>
|
||||||
|
<string name="agenda_header_today">Today</string>
|
||||||
|
<string name="agenda_header_tomorrow">Tomorrow</string>
|
||||||
|
<string name="agenda_empty_title">Nothing scheduled</string>
|
||||||
|
<string name="agenda_empty_subtitle">Upcoming events will show up here.</string>
|
||||||
|
|
||||||
|
<!-- Home-screen widgets -->
|
||||||
|
<string name="widget_agenda_title">Upcoming</string>
|
||||||
|
<string name="widget_agenda_label">Calendula agenda</string>
|
||||||
|
<string name="widget_month_label">Calendula month</string>
|
||||||
|
<string name="widget_refresh">Refresh</string>
|
||||||
|
<string name="widget_new_event">New event</string>
|
||||||
|
<string name="widget_needs_permission">Open Calendula to grant calendar access</string>
|
||||||
|
<string name="widget_prev_month">Previous month</string>
|
||||||
|
<string name="widget_next_month">Next month</string>
|
||||||
|
<string name="widget_today">Today</string>
|
||||||
|
|
||||||
<!-- Calendar filter (M3) -->
|
<!-- Calendar filter (M3) -->
|
||||||
<string name="filter_title">Calendars</string>
|
<string name="filter_title">Calendars</string>
|
||||||
|
|
||||||
@@ -209,6 +240,8 @@
|
|||||||
<string name="settings_week_start_sunday">Sunday</string>
|
<string name="settings_week_start_sunday">Sunday</string>
|
||||||
<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_color_unsupported">Allow colors on unsupported calendars</string>
|
||||||
|
<string name="settings_color_unsupported_hint">Some calendars (e.g. certain CalDAV) publish no color set; a custom event color may be dropped or overwritten on their next sync. That\'s a limitation of those calendars, not something Calendula can fix.</string>
|
||||||
<string name="settings_section_notifications">Notifications</string>
|
<string name="settings_section_notifications">Notifications</string>
|
||||||
<string name="settings_reminders">Event reminders</string>
|
<string name="settings_reminders">Event reminders</string>
|
||||||
<string name="settings_reminders_hint">Seeing reminders twice? Another calendar app is posting them too — turn them off in one of the two.</string>
|
<string name="settings_reminders_hint">Seeing reminders twice? Another calendar app is posting them too — turn them off in one of the two.</string>
|
||||||
@@ -249,6 +282,10 @@
|
|||||||
<string name="calendars_delete_confirm_title">Delete calendar?</string>
|
<string name="calendars_delete_confirm_title">Delete calendar?</string>
|
||||||
<string name="calendars_delete_confirm_message">\"%1$s\" and all of its events will be permanently removed from this device.</string>
|
<string name="calendars_delete_confirm_message">\"%1$s\" and all of its events will be permanently removed from this device.</string>
|
||||||
<string name="calendars_write_error">Couldn\'t save the change.</string>
|
<string name="calendars_write_error">Couldn\'t save the change.</string>
|
||||||
|
<!-- Launcher long-press shortcuts -->
|
||||||
|
<string name="shortcut_new_event_short">New event</string>
|
||||||
|
<string name="shortcut_new_event_long">Create a new event</string>
|
||||||
|
|
||||||
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
|
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
|
||||||
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
|
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
11
app/src/main/res/values/widget_preview_colors.xml
Normal file
11
app/src/main/res/values/widget_preview_colors.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Colours for the static widget-picker previews. Base = brand light fallback
|
||||||
|
(API < 31). values-v31 maps them to the Material You dynamic palette so the
|
||||||
|
preview matches the live Glance widget; -night holds the dark variants. -->
|
||||||
|
<resources>
|
||||||
|
<color name="widget_preview_surface">#FBFCFE</color>
|
||||||
|
<color name="widget_preview_on_surface">#191C1F</color>
|
||||||
|
<color name="widget_preview_variant">#6E7479</color>
|
||||||
|
<color name="widget_preview_primary">#3B5364</color>
|
||||||
|
<color name="widget_preview_on_primary">#FFFFFF</color>
|
||||||
|
</resources>
|
||||||
14
app/src/main/res/xml/appwidget_info_agenda.xml
Normal file
14
app/src/main/res/xml/appwidget_info_agenda.xml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:minWidth="180dp"
|
||||||
|
android:minHeight="150dp"
|
||||||
|
android:targetCellWidth="3"
|
||||||
|
android:targetCellHeight="3"
|
||||||
|
android:minResizeWidth="110dp"
|
||||||
|
android:minResizeHeight="110dp"
|
||||||
|
android:resizeMode="horizontal|vertical"
|
||||||
|
android:widgetCategory="home_screen"
|
||||||
|
android:description="@string/widget_agenda_label"
|
||||||
|
android:updatePeriodMillis="1800000"
|
||||||
|
android:previewLayout="@layout/widget_preview_agenda"
|
||||||
|
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||||
14
app/src/main/res/xml/appwidget_info_month.xml
Normal file
14
app/src/main/res/xml/appwidget_info_month.xml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:minWidth="250dp"
|
||||||
|
android:minHeight="180dp"
|
||||||
|
android:targetCellWidth="4"
|
||||||
|
android:targetCellHeight="4"
|
||||||
|
android:minResizeWidth="180dp"
|
||||||
|
android:minResizeHeight="150dp"
|
||||||
|
android:resizeMode="horizontal|vertical"
|
||||||
|
android:widgetCategory="home_screen"
|
||||||
|
android:description="@string/widget_month_label"
|
||||||
|
android:updatePeriodMillis="1800000"
|
||||||
|
android:previewLayout="@layout/widget_preview_month"
|
||||||
|
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||||
17
app/src/main/res/xml/shortcuts.xml
Normal file
17
app/src/main/res/xml/shortcuts.xml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Launcher long-press shortcuts. The intent fires a custom action that
|
||||||
|
MainActivity (singleTop) consumes to open the create-event form on today;
|
||||||
|
see MainActivity.ACTION_NEW_EVENT. -->
|
||||||
|
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<shortcut
|
||||||
|
android:shortcutId="new_event"
|
||||||
|
android:enabled="true"
|
||||||
|
android:icon="@drawable/ic_shortcut_new_event"
|
||||||
|
android:shortcutShortLabel="@string/shortcut_new_event_short"
|
||||||
|
android:shortcutLongLabel="@string/shortcut_new_event_long">
|
||||||
|
<intent
|
||||||
|
android:action="de.jeanlucmakiola.calendula.action.NEW_EVENT"
|
||||||
|
android:targetPackage="de.jeanlucmakiola.calendula"
|
||||||
|
android:targetClass="de.jeanlucmakiola.calendula.MainActivity" />
|
||||||
|
</shortcut>
|
||||||
|
</shortcuts>
|
||||||
@@ -7,6 +7,7 @@ import app.cash.turbine.test
|
|||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -400,4 +401,20 @@ class CalendarRepositoryImplTest {
|
|||||||
assertThat(expected.message).contains("999")
|
assertThat(expected.message).contains("999")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `eventColorPalette delegates to the data source for the given calendar`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
eventColorPaletteResult = { id ->
|
||||||
|
if (id == 7L) listOf(EventColorOption("5", 0xFF33B679.toInt())) else emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
assertThat(repo.eventColorPalette(7L))
|
||||||
|
.containsExactly(EventColorOption("5", 0xFF33B679.toInt()))
|
||||||
|
assertThat(repo.eventColorPalette(8L)).isEmpty()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class EventDetailMapperTest {
|
|||||||
organizer: String? = "x@y",
|
organizer: String? = "x@y",
|
||||||
rrule: String? = null,
|
rrule: String? = null,
|
||||||
eventColor: Any? = null,
|
eventColor: Any? = null,
|
||||||
|
eventColorKey: String? = null,
|
||||||
calendarColor: Int = 0xFFAABBCC.toInt(),
|
calendarColor: Int = 0xFFAABBCC.toInt(),
|
||||||
dtstart: Long = 1_000_000_000L,
|
dtstart: Long = 1_000_000_000L,
|
||||||
dtend: Long = 1_000_003_600L,
|
dtend: Long = 1_000_003_600L,
|
||||||
@@ -49,6 +50,7 @@ class EventDetailMapperTest {
|
|||||||
EventDetailProjection.IDX_ACCESS_LEVEL to accessLevel,
|
EventDetailProjection.IDX_ACCESS_LEVEL to accessLevel,
|
||||||
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
|
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
|
||||||
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
|
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
|
||||||
|
EventDetailProjection.IDX_EVENT_COLOR_KEY to eventColorKey,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun attendeeReader(
|
private fun attendeeReader(
|
||||||
@@ -99,6 +101,22 @@ class EventDetailMapperTest {
|
|||||||
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())
|
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())
|
||||||
.toDetail()
|
.toDetail()
|
||||||
assertThat(detail!!.instance.color).isEqualTo(0xFF112233.toInt())
|
assertThat(detail!!.instance.color).isEqualTo(0xFF112233.toInt())
|
||||||
|
// No own colour: the edit form must see this as "inherits".
|
||||||
|
assertThat(detail.eventColor).isNull()
|
||||||
|
assertThat(detail.eventColorKey).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `own event color and key are surfaced apart from the resolved color`() {
|
||||||
|
val detail = detailReader(
|
||||||
|
eventColor = 0xFF33B679.toInt(),
|
||||||
|
eventColorKey = "5",
|
||||||
|
calendarColor = 0xFF112233.toInt(),
|
||||||
|
).toDetail()
|
||||||
|
// Resolved display colour is the event's own, not the calendar fallback.
|
||||||
|
assertThat(detail!!.instance.color).isEqualTo(0xFF33B679.toInt())
|
||||||
|
assertThat(detail.eventColor).isEqualTo(0xFF33B679.toInt())
|
||||||
|
assertThat(detail.eventColorKey).isEqualTo("5")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -218,4 +218,83 @@ class EventWriteMapperTest {
|
|||||||
assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null)
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null)
|
||||||
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
|
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- per-event colour ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `palette colour writes only the key, never a raw colour`() {
|
||||||
|
assertThat(eventColorColumns(colorKey = "5", color = 0xFF33B679.toInt()))
|
||||||
|
.containsExactly(CalendarContract.Events.EVENT_COLOR_KEY, "5")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `raw colour writes the colour and clears any key`() {
|
||||||
|
assertThat(eventColorColumns(colorKey = null, color = 0xFF8E24AA.toInt()))
|
||||||
|
.containsExactly(
|
||||||
|
CalendarContract.Events.EVENT_COLOR_KEY, null,
|
||||||
|
CalendarContract.Events.EVENT_COLOR, 0xFF8E24AA.toInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no colour clears both columns so the event inherits its calendar`() {
|
||||||
|
assertThat(eventColorColumns(colorKey = null, color = null))
|
||||||
|
.containsExactly(
|
||||||
|
CalendarContract.Events.EVENT_COLOR_KEY, null,
|
||||||
|
CalendarContract.Events.EVENT_COLOR, null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setting a palette colour on update writes just the key`() {
|
||||||
|
val original = form()
|
||||||
|
val values = update(original, original.copy(colorKey = "3", color = 0xFFF6BF26.toInt()))
|
||||||
|
assertThat(values).containsExactly(CalendarContract.Events.EVENT_COLOR_KEY, "3")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setting a raw colour on update writes the colour and a null key`() {
|
||||||
|
val original = form()
|
||||||
|
val values = update(original, original.copy(color = 0xFF039BE5.toInt()))
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, 0xFF039BE5.toInt())
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clearing a colour on update writes explicit nulls`() {
|
||||||
|
val original = form().copy(color = 0xFFD50000.toInt())
|
||||||
|
val values = update(original, original.copy(color = null))
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, null)
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unchanged colour writes no colour columns`() {
|
||||||
|
val original = form().copy(colorKey = "7", color = 0xFF3F51B5.toInt())
|
||||||
|
val values = update(original, original.copy(title = "Renamed"))
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY)
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `occurrence exception carries the palette key`() {
|
||||||
|
val values = buildOccurrenceExceptionValues(
|
||||||
|
form = form().copy(colorKey = "2", color = 0xFFE67C00.toInt()),
|
||||||
|
originalInstanceMillis = 0L,
|
||||||
|
zone = berlin,
|
||||||
|
)
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, "2")
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `occurrence exception with no colour clears both columns`() {
|
||||||
|
val values = buildOccurrenceExceptionValues(
|
||||||
|
form = form(),
|
||||||
|
originalInstanceMillis = 0L,
|
||||||
|
zone = berlin,
|
||||||
|
)
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, null)
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
@@ -14,6 +15,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
var calendarsResult: List<CalendarSource> = emptyList()
|
var calendarsResult: List<CalendarSource> = emptyList()
|
||||||
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
|
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
|
||||||
var eventDetailResult: (Long) -> EventDetail? = { null }
|
var eventDetailResult: (Long) -> EventDetail? = { null }
|
||||||
|
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
|
||||||
/** Set to make the next write call throw. */
|
/** Set to make the next write call throw. */
|
||||||
var writeError: Exception? = null
|
var writeError: Exception? = null
|
||||||
/** Id returned by the next [insertEvent]. */
|
/** Id returned by the next [insertEvent]. */
|
||||||
@@ -45,6 +47,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||||
instancesResult(beginMillis, endMillis)
|
instancesResult(beginMillis, endMillis)
|
||||||
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
|
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
|
||||||
|
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||||
|
eventColorPaletteResult(calendarId)
|
||||||
|
|
||||||
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
||||||
writeError?.let { throw it }
|
writeError?.let { throw it }
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ class EventFormTest {
|
|||||||
rowStart: Long = 0L,
|
rowStart: Long = 0L,
|
||||||
rowEnd: Long = 0L,
|
rowEnd: Long = 0L,
|
||||||
attendees: List<Attendee> = emptyList(),
|
attendees: List<Attendee> = emptyList(),
|
||||||
|
eventColor: Int? = null,
|
||||||
|
eventColorKey: String? = null,
|
||||||
): EventDetail = EventDetail(
|
): EventDetail = EventDetail(
|
||||||
instance = EventInstance(
|
instance = EventInstance(
|
||||||
instanceId = 1L,
|
instanceId = 1L,
|
||||||
@@ -134,6 +136,8 @@ class EventFormTest {
|
|||||||
reminders = reminders,
|
reminders = reminders,
|
||||||
availability = availability,
|
availability = availability,
|
||||||
accessLevel = accessLevel,
|
accessLevel = accessLevel,
|
||||||
|
eventColor = eventColor,
|
||||||
|
eventColorKey = eventColorKey,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -227,6 +231,7 @@ class EventFormTest {
|
|||||||
rrule = "FREQ=DAILY",
|
rrule = "FREQ=DAILY",
|
||||||
availability = Availability.Free,
|
availability = Availability.Free,
|
||||||
accessLevel = AccessLevel.Private,
|
accessLevel = AccessLevel.Private,
|
||||||
|
color = 0xFFD50000.toInt(),
|
||||||
)
|
)
|
||||||
assertThat(full.populatedFields()).containsExactly(
|
assertThat(full.populatedFields()).containsExactly(
|
||||||
EventFormField.Location,
|
EventFormField.Location,
|
||||||
@@ -235,6 +240,33 @@ class EventFormTest {
|
|||||||
EventFormField.Recurrence,
|
EventFormField.Recurrence,
|
||||||
EventFormField.Availability,
|
EventFormField.Availability,
|
||||||
EventFormField.Visibility,
|
EventFormField.Visibility,
|
||||||
|
EventFormField.Color,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toEditForm carries a palette colour as key plus swatch`() {
|
||||||
|
val prefilled = detail(eventColor = 0xFF33B679.toInt(), eventColorKey = "5")
|
||||||
|
.toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
|
||||||
|
assertThat(prefilled.colorKey).isEqualTo("5")
|
||||||
|
assertThat(prefilled.color).isEqualTo(0xFF33B679.toInt())
|
||||||
|
assertThat(prefilled.populatedFields()).contains(EventFormField.Color)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toEditForm carries a raw colour with no key`() {
|
||||||
|
val prefilled = detail(eventColor = 0xFF8E24AA.toInt())
|
||||||
|
.toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
|
||||||
|
assertThat(prefilled.colorKey).isNull()
|
||||||
|
assertThat(prefilled.color).isEqualTo(0xFF8E24AA.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toEditForm leaves an inheriting event without a colour`() {
|
||||||
|
val prefilled = detail()
|
||||||
|
.toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
|
||||||
|
assertThat(prefilled.colorKey).isNull()
|
||||||
|
assertThat(prefilled.color).isNull()
|
||||||
|
assertThat(prefilled.populatedFields()).doesNotContain(EventFormField.Color)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ turbine = "1.2.0"
|
|||||||
hiltNavigationCompose = "1.3.0"
|
hiltNavigationCompose = "1.3.0"
|
||||||
lifecycleCompose = "2.10.0"
|
lifecycleCompose = "2.10.0"
|
||||||
androidxTestRules = "1.7.0"
|
androidxTestRules = "1.7.0"
|
||||||
|
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
|
||||||
|
glance = "1.1.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
# AndroidX core
|
# AndroidX core
|
||||||
@@ -79,6 +81,10 @@ androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navig
|
|||||||
# Lifecycle compose (for collectAsStateWithLifecycle)
|
# Lifecycle compose (for collectAsStateWithLifecycle)
|
||||||
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" }
|
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" }
|
||||||
|
|
||||||
|
# Glance — Jetpack home-screen widgets (Compose-like RemoteViews)
|
||||||
|
androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" }
|
||||||
|
androidx-glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glance" }
|
||||||
|
|
||||||
# Android tests - GrantPermissionRule
|
# Android tests - GrantPermissionRule
|
||||||
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }
|
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user