diff --git a/locales/rn/ar.json b/locales/rn/ar.json index 96ea46ea..3959d574 100644 --- a/locales/rn/ar.json +++ b/locales/rn/ar.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "اضغط + لإنشاء حدث" }, + "fab": { + "event": "حدث", + "task": "مهمة" + }, "form": { "description_placeholder": "ملاحظات، جدول أعمال…", "location_placeholder": "المكان أو العنوان", @@ -127,6 +131,10 @@ "not_found": "لم تعد هذه المهمة متاحة.", "save_error": "فشل حفظ المهمة", "update_error": "فشل تحديث المهمة" + }, + "views": { + "schedule": "الجدول", + "three_days": "3 أيام" } }, "common": { diff --git a/locales/rn/ca.json b/locales/rn/ca.json index 1f24ae9c..56fee5a3 100644 --- a/locales/rn/ca.json +++ b/locales/rn/ca.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "Toqueu + per crear-ne un" }, + "fab": { + "event": "Esdeveniment", + "task": "Tasca" + }, "form": { "description_placeholder": "Notes, ordre del dia…", "location_placeholder": "Lloc o adreça", @@ -127,6 +131,10 @@ "not_found": "Aquesta tasca ja no està disponible.", "save_error": "No s'ha pogut desar la tasca", "update_error": "No s'ha pogut actualitzar la tasca" + }, + "views": { + "schedule": "Agenda", + "three_days": "3 dies" } }, "common": { diff --git a/locales/rn/cs.json b/locales/rn/cs.json index e33381f9..58583cbe 100644 --- a/locales/rn/cs.json +++ b/locales/rn/cs.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "Událost vytvoříte klepnutím na +" }, + "fab": { + "event": "Událost", + "task": "Úkol" + }, "form": { "description_placeholder": "Poznámky, program…", "location_placeholder": "Místo nebo adresa", @@ -127,6 +131,10 @@ "not_found": "Tento úkol už není k dispozici.", "save_error": "Úkol se nepodařilo uložit", "update_error": "Úkol se nepodařilo aktualizovat" + }, + "views": { + "schedule": "Program", + "three_days": "3 dny" } }, "common": { diff --git a/locales/rn/da.json b/locales/rn/da.json index 3e8ea243..662eb608 100644 --- a/locales/rn/da.json +++ b/locales/rn/da.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "Tryk på + for at oprette en" }, + "fab": { + "event": "Begivenhed", + "task": "Opgave" + }, "form": { "description_placeholder": "Noter, dagsorden…", "location_placeholder": "Sted eller adresse", @@ -127,6 +131,10 @@ "not_found": "Denne opgave er ikke længere tilgængelig.", "save_error": "Kunne ikke gemme opgaven", "update_error": "Kunne ikke opdatere opgaven" + }, + "views": { + "schedule": "Tidsplan", + "three_days": "3 dage" } }, "common": { diff --git a/locales/rn/de.json b/locales/rn/de.json index e738a8d6..58fcba37 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "Tippen Sie auf +, um einen zu erstellen" }, + "fab": { + "event": "Termin", + "task": "Aufgabe" + }, "form": { "description_placeholder": "Notizen, Tagesordnung…", "location_placeholder": "Ort oder Adresse", @@ -127,6 +131,10 @@ "not_found": "Diese Aufgabe ist nicht mehr verfügbar.", "save_error": "Aufgabe konnte nicht gespeichert werden", "update_error": "Aufgabe konnte nicht aktualisiert werden" + }, + "views": { + "schedule": "Terminübersicht", + "three_days": "3 Tage" } }, "common": { diff --git a/locales/rn/en.json b/locales/rn/en.json index eedb5336..eebec322 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "Tap + to create one" }, + "fab": { + "event": "Event", + "task": "Task" + }, "form": { "description_placeholder": "Notes, agenda…", "location_placeholder": "Place or address", @@ -127,6 +131,10 @@ "not_found": "This task is no longer available.", "save_error": "Failed to save task", "update_error": "Failed to update task" + }, + "views": { + "schedule": "Schedule", + "three_days": "3 days" } }, "common": { diff --git a/locales/rn/es.json b/locales/rn/es.json index ef29641e..5668fe0a 100644 --- a/locales/rn/es.json +++ b/locales/rn/es.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "Toca + para crear uno" }, + "fab": { + "event": "Evento", + "task": "Tarea" + }, "form": { "description_placeholder": "Notas, orden del día…", "location_placeholder": "Lugar o dirección", @@ -127,6 +131,10 @@ "not_found": "Esta tarea ya no está disponible.", "save_error": "No se pudo guardar la tarea", "update_error": "No se pudo actualizar la tarea" + }, + "views": { + "schedule": "Agenda", + "three_days": "3 días" } }, "common": { diff --git a/locales/rn/fa.json b/locales/rn/fa.json index 23d47354..ce458222 100644 --- a/locales/rn/fa.json +++ b/locales/rn/fa.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "برای ایجاد، روی + ضربه بزنید" }, + "fab": { + "event": "رویداد", + "task": "کار" + }, "form": { "description_placeholder": "یادداشت‌ها، دستور جلسه…", "location_placeholder": "مکان یا نشانی", @@ -127,6 +131,10 @@ "not_found": "این وظیفه دیگر در دسترس نیست.", "save_error": "ذخیره وظیفه ناموفق بود", "update_error": "به‌روزرسانی وظیفه ناموفق بود" + }, + "views": { + "schedule": "برنامه", + "three_days": "۳ روز" } }, "common": { diff --git a/locales/rn/fr.json b/locales/rn/fr.json index 226ca38e..67e54653 100644 --- a/locales/rn/fr.json +++ b/locales/rn/fr.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "Appuyez sur + pour en créer un" }, + "fab": { + "event": "Événement", + "task": "Tâche" + }, "form": { "description_placeholder": "Notes, ordre du jour…", "location_placeholder": "Lieu ou adresse", @@ -127,6 +131,10 @@ "not_found": "Cette tâche n'est plus disponible.", "save_error": "Échec de l'enregistrement de la tâche", "update_error": "Échec de la mise à jour de la tâche" + }, + "views": { + "schedule": "Planning", + "three_days": "3 jours" } }, "common": { diff --git a/locales/rn/he.json b/locales/rn/he.json index 74fbf9ad..44a820e2 100644 --- a/locales/rn/he.json +++ b/locales/rn/he.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "הקש על + כדי ליצור" }, + "fab": { + "event": "אירוע", + "task": "משימה" + }, "form": { "description_placeholder": "הערות, סדר יום…", "location_placeholder": "מקום או כתובת", @@ -127,6 +131,10 @@ "not_found": "המשימה הזו אינה זמינה עוד.", "save_error": "שמירת המשימה נכשלה", "update_error": "עדכון המשימה נכשל" + }, + "views": { + "schedule": "לוח זמנים", + "three_days": "3 ימים" } }, "common": { diff --git a/locales/rn/hu.json b/locales/rn/hu.json index cdf2a9ec..1b04ed44 100644 --- a/locales/rn/hu.json +++ b/locales/rn/hu.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "Esemény létrehozásához koppints a + gombra" }, + "fab": { + "event": "Esemény", + "task": "Feladat" + }, "form": { "description_placeholder": "Jegyzetek, napirend…", "location_placeholder": "Hely vagy cím", @@ -127,6 +131,10 @@ "not_found": "Ez a feladat már nem érhető el.", "save_error": "Nem sikerült menteni a feladatot", "update_error": "Nem sikerült frissíteni a feladatot" + }, + "views": { + "schedule": "Ütemezés", + "three_days": "3 nap" } }, "common": { diff --git a/locales/rn/it.json b/locales/rn/it.json index ac84ea94..785a68ee 100644 --- a/locales/rn/it.json +++ b/locales/rn/it.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "Tocca + per crearne uno" }, + "fab": { + "event": "Evento", + "task": "Attività" + }, "form": { "description_placeholder": "Note, ordine del giorno…", "location_placeholder": "Luogo o indirizzo", @@ -127,6 +131,10 @@ "not_found": "Questa attività non è più disponibile.", "save_error": "Impossibile salvare l'attività", "update_error": "Impossibile aggiornare l'attività" + }, + "views": { + "schedule": "Programma", + "three_days": "3 giorni" } }, "common": { diff --git a/locales/rn/ja.json b/locales/rn/ja.json index a3409565..6e6b7ac5 100644 --- a/locales/rn/ja.json +++ b/locales/rn/ja.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "+ をタップして作成" }, + "fab": { + "event": "予定", + "task": "タスク" + }, "form": { "description_placeholder": "メモ、議題…", "location_placeholder": "場所または住所", @@ -127,6 +131,10 @@ "not_found": "このタスクは利用できなくなりました。", "save_error": "タスクの保存に失敗しました", "update_error": "タスクの更新に失敗しました" + }, + "views": { + "schedule": "スケジュール", + "three_days": "3 日" } }, "common": { diff --git a/locales/rn/ko.json b/locales/rn/ko.json index c4664b73..6125577b 100644 --- a/locales/rn/ko.json +++ b/locales/rn/ko.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "+를 눌러 만들어 보세요" }, + "fab": { + "event": "일정", + "task": "할 일" + }, "form": { "description_placeholder": "메모, 안건…", "location_placeholder": "장소 또는 주소", @@ -127,6 +131,10 @@ "not_found": "이 할 일을 더 이상 찾을 수 없어요.", "save_error": "할 일을 저장하지 못했어요", "update_error": "할 일을 업데이트하지 못했어요" + }, + "views": { + "schedule": "일정", + "three_days": "3일" } }, "common": { diff --git a/locales/rn/lv.json b/locales/rn/lv.json index ef5fb809..1f256434 100644 --- a/locales/rn/lv.json +++ b/locales/rn/lv.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "Pieskarieties +, lai izveidotu notikumu" }, + "fab": { + "event": "Notikums", + "task": "Uzdevums" + }, "form": { "description_placeholder": "Piezīmes, darba kārtība…", "location_placeholder": "Vieta vai adrese", @@ -127,6 +131,10 @@ "not_found": "Šis uzdevums vairs nav pieejams.", "save_error": "Neizdevās saglabāt uzdevumu", "update_error": "Neizdevās atjaunināt uzdevumu" + }, + "views": { + "schedule": "Grafiks", + "three_days": "3 dienas" } }, "common": { diff --git a/locales/rn/mn.json b/locales/rn/mn.json index 73ba4774..162422e6 100644 --- a/locales/rn/mn.json +++ b/locales/rn/mn.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "Үүсгэхийн тулд + товшино уу" }, + "fab": { + "event": "Үйл явдал", + "task": "Даалгавар" + }, "form": { "description_placeholder": "Тэмдэглэл, хэлэлцэх асуудал…", "location_placeholder": "Газар эсвэл хаяг", @@ -127,6 +131,10 @@ "not_found": "Энэ даалгавар байхгүй болсон.", "save_error": "Даалгаврыг хадгалж чадсангүй", "update_error": "Даалгаврыг шинэчилж чадсангүй" + }, + "views": { + "schedule": "Хуваарь", + "three_days": "3 өдөр" } }, "common": { diff --git a/locales/rn/nb.json b/locales/rn/nb.json index 16443226..a3754623 100644 --- a/locales/rn/nb.json +++ b/locales/rn/nb.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "Trykk på + for å opprette en" }, + "fab": { + "event": "Hendelse", + "task": "Oppgave" + }, "form": { "description_placeholder": "Notater, agenda…", "location_placeholder": "Sted eller adresse", @@ -127,6 +131,10 @@ "not_found": "Denne oppgaven er ikke lenger tilgjengelig.", "save_error": "Kunne ikke lagre oppgaven", "update_error": "Kunne ikke oppdatere oppgaven" + }, + "views": { + "schedule": "Timeplan", + "three_days": "3 dager" } }, "common": { diff --git a/locales/rn/nl.json b/locales/rn/nl.json index efbca3fd..03d47732 100644 --- a/locales/rn/nl.json +++ b/locales/rn/nl.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "Tik op + om er een te maken" }, + "fab": { + "event": "Afspraak", + "task": "Taak" + }, "form": { "description_placeholder": "Notities, agendapunten…", "location_placeholder": "Plaats of adres", @@ -127,6 +131,10 @@ "not_found": "Deze taak is niet meer beschikbaar.", "save_error": "Taak opslaan mislukt", "update_error": "Taak bijwerken mislukt" + }, + "views": { + "schedule": "Planning", + "three_days": "3 dagen" } }, "common": { diff --git a/locales/rn/pl.json b/locales/rn/pl.json index 42bf12a8..58e8dbf0 100644 --- a/locales/rn/pl.json +++ b/locales/rn/pl.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "Stuknij +, aby utworzyć wydarzenie" }, + "fab": { + "event": "Wydarzenie", + "task": "Zadanie" + }, "form": { "description_placeholder": "Notatki, agenda…", "location_placeholder": "Miejsce lub adres", @@ -127,6 +131,10 @@ "not_found": "To zadanie nie jest już dostępne.", "save_error": "Nie udało się zapisać zadania", "update_error": "Nie udało się zaktualizować zadania" + }, + "views": { + "schedule": "Harmonogram", + "three_days": "3 dni" } }, "common": { diff --git a/locales/rn/pt.json b/locales/rn/pt.json index 84f7918a..ac3bd7bd 100644 --- a/locales/rn/pt.json +++ b/locales/rn/pt.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "Toque em + para criar um" }, + "fab": { + "event": "Evento", + "task": "Tarefa" + }, "form": { "description_placeholder": "Notas, pauta…", "location_placeholder": "Local ou endereço", @@ -127,6 +131,10 @@ "not_found": "Esta tarefa não está mais disponível.", "save_error": "Falha ao salvar a tarefa", "update_error": "Falha ao atualizar a tarefa" + }, + "views": { + "schedule": "Programação", + "three_days": "3 dias" } }, "common": { diff --git a/locales/rn/ro.json b/locales/rn/ro.json index 1cab3248..064a8c8d 100644 --- a/locales/rn/ro.json +++ b/locales/rn/ro.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "Atingeți + pentru a crea unul" }, + "fab": { + "event": "Eveniment", + "task": "Sarcină" + }, "form": { "description_placeholder": "Note, agendă…", "location_placeholder": "Loc sau adresă", @@ -127,6 +131,10 @@ "not_found": "Această sarcină nu mai este disponibilă.", "save_error": "Sarcina nu a putut fi salvată", "update_error": "Sarcina nu a putut fi actualizată" + }, + "views": { + "schedule": "Program", + "three_days": "3 zile" } }, "common": { diff --git a/locales/rn/ru.json b/locales/rn/ru.json index 9e88464e..c2bea6d7 100644 --- a/locales/rn/ru.json +++ b/locales/rn/ru.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "Нажмите +, чтобы создать" }, + "fab": { + "event": "Мероприятие", + "task": "Задача" + }, "form": { "description_placeholder": "Заметки, повестка…", "location_placeholder": "Место или адрес", @@ -127,6 +131,10 @@ "not_found": "Эта задача больше недоступна.", "save_error": "Не удалось сохранить задачу", "update_error": "Не удалось обновить задачу" + }, + "views": { + "schedule": "Расписание", + "three_days": "3 дня" } }, "common": { diff --git a/locales/rn/sk.json b/locales/rn/sk.json index b06666e8..089de996 100644 --- a/locales/rn/sk.json +++ b/locales/rn/sk.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "Udalosť vytvoríte klepnutím na +" }, + "fab": { + "event": "Udalosť", + "task": "Úloha" + }, "form": { "description_placeholder": "Poznámky, program…", "location_placeholder": "Miesto alebo adresa", @@ -127,6 +131,10 @@ "not_found": "Táto úloha už nie je k dispozícii.", "save_error": "Úlohu sa nepodarilo uložiť", "update_error": "Úlohu sa nepodarilo aktualizovať" + }, + "views": { + "schedule": "Program", + "three_days": "3 dni" } }, "common": { diff --git a/locales/rn/tr.json b/locales/rn/tr.json index e14810c6..4fcba8b5 100644 --- a/locales/rn/tr.json +++ b/locales/rn/tr.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "Etkinlik oluşturmak için + simgesine dokunun" }, + "fab": { + "event": "Etkinlik", + "task": "Görev" + }, "form": { "description_placeholder": "Notlar, gündem…", "location_placeholder": "Yer veya adres", @@ -127,6 +131,10 @@ "not_found": "Bu görev artık kullanılamıyor.", "save_error": "Görev kaydedilemedi", "update_error": "Görev güncellenemedi" + }, + "views": { + "schedule": "Program", + "three_days": "3 gün" } }, "common": { diff --git a/locales/rn/uk.json b/locales/rn/uk.json index 0e0d71a3..cfe4df86 100644 --- a/locales/rn/uk.json +++ b/locales/rn/uk.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "Натисніть +, щоб створити" }, + "fab": { + "event": "Подія", + "task": "Завдання" + }, "form": { "description_placeholder": "Нотатки, порядок денний…", "location_placeholder": "Місце або адреса", @@ -127,6 +131,10 @@ "not_found": "Це завдання більше недоступне.", "save_error": "Не вдалося зберегти завдання", "update_error": "Не вдалося оновити завдання" + }, + "views": { + "schedule": "Розклад", + "three_days": "3 дні" } }, "common": { diff --git a/locales/rn/zh-TW.json b/locales/rn/zh-TW.json index a93623de..05981e80 100644 --- a/locales/rn/zh-TW.json +++ b/locales/rn/zh-TW.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "點選 + 即可建立" }, + "fab": { + "event": "活動", + "task": "工作" + }, "form": { "description_placeholder": "備註、議程…", "location_placeholder": "地點或地址", @@ -127,6 +131,10 @@ "not_found": "此任務已不存在。", "save_error": "儲存任務失敗", "update_error": "更新任務失敗" + }, + "views": { + "schedule": "行程", + "three_days": "3 天" } }, "common": { diff --git a/locales/rn/zh.json b/locales/rn/zh.json index 5cdb8d6a..3f275e9a 100644 --- a/locales/rn/zh.json +++ b/locales/rn/zh.json @@ -27,6 +27,10 @@ "export_ics_short": ".ics", "tap_to_create": "点按 + 创建" }, + "fab": { + "event": "活动", + "task": "任务" + }, "form": { "description_placeholder": "备注、议程…", "location_placeholder": "地点或地址", @@ -127,6 +131,10 @@ "not_found": "此任务已不存在。", "save_error": "保存任务失败", "update_error": "更新任务失败" + }, + "views": { + "schedule": "日程", + "three_days": "3 天" } }, "common": { diff --git a/src/components/calendar/CalendarFab.tsx b/src/components/calendar/CalendarFab.tsx new file mode 100644 index 00000000..c6783d5b --- /dev/null +++ b/src/components/calendar/CalendarFab.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { Animated, Easing, Pressable, StyleSheet, Text, View } from 'react-native'; +import { CalendarPlus, CircleCheck, Plus } from 'lucide-react-native'; +import { componentSizes, radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { useAnimDuration } from '../../theme/dynamic'; +import { useLocaleStore } from '../../stores/locale-store'; + +interface CalendarFabProps { + onNewEvent: () => void; + /** With tasks turned on, the button first offers "Event" and "Task". */ + onNewTask?: () => void; +} + +/** + * The floating create button in the corner of the calendar, styled like the + * mail list's compose button. Without tasks it creates an event at once; + * with tasks it opens a small menu over a scrim. + */ +export function CalendarFab({ onNewEvent, onNewTask }: CalendarFabProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const [open, setOpen] = React.useState(false); + const progress = React.useRef(new Animated.Value(0)).current; + const duration = useAnimDuration(160); + + React.useEffect(() => { + Animated.timing(progress, { + toValue: open ? 1 : 0, + duration, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }).start(); + }, [open, progress, duration]); + + const pick = (action: () => void) => { + setOpen(false); + action(); + }; + + const onMainPress = () => { + if (!onNewTask) { + onNewEvent(); + return; + } + if (open) pick(onNewEvent); + else setOpen(true); + }; + + const rotate = progress.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '90deg'] }); + const lift = progress.interpolate({ inputRange: [0, 1], outputRange: [12, 0] }); + + return ( + <> + {open && ( + + setOpen(false)} + accessibilityRole="button" + accessibilityLabel={t('common.close', 'Close')} + /> + + )} + + {open && onNewTask && ( + + {t('calendar.fab.task', 'Task')} + pick(onNewTask)} + accessibilityRole="button" + accessibilityLabel={t('calendar.events.new_task', 'New task')} + > + + + + )} + + {open && ( + + {t('calendar.fab.event', 'Event')} + + )} + [styles.fab, pressed && styles.fabPressed]} + onPress={onMainPress} + accessibilityRole="button" + accessibilityLabel={t('calendar.events.new_event', 'New event')} + > + {open ? ( + + ) : ( + + + + )} + + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + scrim: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.55)', + zIndex: 20, + }, + anchor: { + position: 'absolute', + right: spacing.lg, + bottom: spacing.lg, + alignItems: 'flex-end', + gap: spacing.lg, + zIndex: 21, + }, + option: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, marginRight: 8 }, + mainRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md }, + optionLabel: { ...typography.baseMedium, color: '#ffffff' }, + miniFab: { + width: 40, + height: 40, + borderRadius: radius.full, + backgroundColor: c.surface, + alignItems: 'center', + justifyContent: 'center', + elevation: 3, + shadowColor: '#000', + shadowOpacity: 0.2, + shadowRadius: 4, + shadowOffset: { width: 0, height: 2 }, + }, + // Same as the compose button on the mail list (EmailListScreen). + fab: { + width: componentSizes.fab, + height: componentSizes.fab, + borderRadius: radius.full, + backgroundColor: c.text, + alignItems: 'center', + justifyContent: 'center', + elevation: 6, + shadowColor: '#000', + shadowOpacity: 0.25, + shadowRadius: 6, + shadowOffset: { width: 0, height: 3 }, + }, + fabPressed: { opacity: 0.85 }, + }); +} diff --git a/src/components/calendar/CalendarSidebarDrawer.tsx b/src/components/calendar/CalendarSidebarDrawer.tsx index c332b42f..0f43d235 100644 --- a/src/components/calendar/CalendarSidebarDrawer.tsx +++ b/src/components/calendar/CalendarSidebarDrawer.tsx @@ -12,7 +12,8 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { - Check, X, Upload, Rss, Shuffle, Star, Plus, Pencil, Share2, Eraser, Trash2, + Check, X, Upload, Rss, Shuffle, Star, Plus, Pencil, Share2, Eraser, Trash2, RefreshCw, + type LucideIcon, } from 'lucide-react-native'; import type { Calendar } from '../../api/types'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; @@ -23,8 +24,19 @@ import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar'; import { isWritableCalendar } from '../../lib/calendar-editability'; import { useLocaleStore } from '../../stores/locale-store'; +/** A calendar view listed at the top of the drawer. */ +export interface DrawerViewOption { + key: string; + label: string; + Icon: LucideIcon; +} + interface CalendarSidebarDrawerProps { visible: boolean; + views?: DrawerViewOption[]; + activeView?: string; + onSelectView?: (key: string) => void; + onRefresh?: () => void; calendars: Calendar[]; hiddenCalendarIds: string[]; onToggle: (id: string) => void; @@ -48,6 +60,10 @@ interface CalendarSidebarDrawerProps { export function CalendarSidebarDrawer({ visible, + views, + activeView, + onSelectView, + onRefresh, calendars, hiddenCalendarIds, onToggle, @@ -161,10 +177,44 @@ export function CalendarSidebarDrawer({ > - {t('calendar.my_calendars', 'Calendars')} + {t('calendar.title', 'Calendar')} + {views && views.length > 0 && ( + + {views.map(({ key, label, Icon }) => { + const active = key === activeView; + return ( + onSelectView?.(key)} + style={({ pressed }) => [ + styles.viewRow, + active && styles.viewRowActive, + pressed && !active && styles.rowPressed, + ]} + accessibilityRole="button" + accessibilityState={{ selected: active }} + > + + {label} + + ); + })} + {onRefresh && ( + [styles.viewRow, styles.refreshRow, pressed && styles.rowPressed]} + accessibilityRole="button" + > + + {t('common.refresh', 'Refresh')} + + )} + + )} + {calendars.length === 0 && ( {t('calendar.drawer.no_calendars', 'No calendars yet.')} )} @@ -431,6 +481,27 @@ function makeStyles(c: ThemePalette) { headerTitle: { ...typography.h3, color: c.text }, scroll: { flex: 1 }, + + viewsSection: { + paddingTop: spacing.sm, + paddingBottom: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: c.border, + }, + // Same rows as the mail drawer's folders (SidebarDrawer). + viewRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingHorizontal: spacing.lg, + minHeight: 44, + borderLeftWidth: 2, + borderLeftColor: 'transparent', + }, + viewRowActive: { backgroundColor: c.accent, borderLeftColor: c.primary }, + refreshRow: { marginTop: spacing.xs }, + viewText: { ...typography.body, color: c.text }, + viewTextActive: { ...typography.bodySemibold }, scrollContent: { paddingBottom: spacing.lg }, empty: { diff --git a/src/components/calendar/MonthDropdown.tsx b/src/components/calendar/MonthDropdown.tsx new file mode 100644 index 00000000..fda002cf --- /dev/null +++ b/src/components/calendar/MonthDropdown.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { + FlatList, + StyleSheet, + View, + useWindowDimensions, + type ListRenderItemInfo, + type NativeScrollEvent, + type NativeSyntheticEvent, +} from 'react-native'; +import { addMonths, startOfMonth } from 'date-fns'; +import type { Calendar } from '../../api/types'; +import type { ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import type { EventDayIndex } from '../../lib/calendar-utils'; +import { MONTH_ROW_HEIGHT, MonthView } from './MonthView'; + +type WeekStart = 0 | 1 | 6; + +/** Months either side of the one the panel opens on. */ +const MONTHS_EACH_SIDE = 24; +/** Six week rows plus the weekday initials. */ +const PANEL_HEIGHT = MONTH_ROW_HEIGHT * 6 + 22; + +interface MonthDropdownProps { + /** The month the panel opens on. */ + month: Date; + selectedDate: Date; + eventsByDay: EventDayIndex; + calendars: Calendar[]; + weekStartsOn: WeekStart; + /** Reports the month swiped to, so the title can follow it. */ + onMonthChange: (month: Date) => void; + onSelectDate: (date: Date) => void; +} + +/** + * The month that drops down under the calendar title: swipe sideways + * between months, tap a day to go there. + */ +export function MonthDropdown({ + month, + selectedDate, + eventsByDay, + calendars, + weekStartsOn, + onMonthChange, + onSelectDate, +}: MonthDropdownProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const { width } = useWindowDimensions(); + // The months are fixed while the panel is open; it remounts on each open. + const [months] = React.useState(() => { + const first = addMonths(startOfMonth(month), -MONTHS_EACH_SIDE); + return Array.from({ length: MONTHS_EACH_SIDE * 2 + 1 }, (_, i) => addMonths(first, i)); + }); + const lastIndexRef = React.useRef(MONTHS_EACH_SIDE); + + const handleMomentumEnd = React.useCallback( + (e: NativeSyntheticEvent) => { + const index = Math.round(e.nativeEvent.contentOffset.x / width); + if (index === lastIndexRef.current || !months[index]) return; + lastIndexRef.current = index; + onMonthChange(months[index]); + }, + [width, months, onMonthChange], + ); + + const renderItem = React.useCallback( + ({ item }: ListRenderItemInfo) => ( + + + + ), + [width, selectedDate, eventsByDay, calendars, weekStartsOn, onSelectDate], + ); + + const getItemLayout = React.useCallback( + (_: ArrayLike | null | undefined, i: number) => ({ length: width, offset: width * i, index: i }), + [width], + ); + + return ( + + + + ); +} + +function monthKey(month: Date): string { + return `${month.getFullYear()}-${month.getMonth()}`; +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + panel: { + backgroundColor: c.background, + borderBottomWidth: 1, + borderBottomColor: c.border, + paddingTop: 4, + }, + }); +} diff --git a/src/components/calendar/TasksSheet.tsx b/src/components/calendar/TasksSheet.tsx index 55d96dd7..ad8864a2 100644 --- a/src/components/calendar/TasksSheet.tsx +++ b/src/components/calendar/TasksSheet.tsx @@ -52,6 +52,8 @@ interface TasksSheetProps { timeFormat?: TimeFormat; // Task to open in the editor when the sheet appears (from a calendar chip). initialTaskId?: string | null; + // Open straight on an empty editor (the calendar's "new task" button). + startNew?: boolean; onClose: () => void; onCreate: (task: Partial, calendarId: string) => Promise | void; onUpdate?: (id: string, changes: Partial) => Promise | void; @@ -94,6 +96,7 @@ export function TasksSheet({ calendars, timeFormat, initialTaskId, + startNew = false, onClose, onCreate, onUpdate, @@ -132,10 +135,10 @@ export function TasksSheet({ setExpanded(true); } else { setEditor(emptyTaskEditor(defaultCalendarId)); - setExpanded(false); + setExpanded(startNew); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [visible, initialTaskId]); + }, [visible, initialTaskId, startNew]); React.useEffect(() => { if (visible && !editor.calendarId && defaultCalendarId) { diff --git a/src/components/calendar/TimeGridScrollView.tsx b/src/components/calendar/TimeGridScrollView.tsx index 8ef195be..ef3d3972 100644 --- a/src/components/calendar/TimeGridScrollView.tsx +++ b/src/components/calendar/TimeGridScrollView.tsx @@ -37,6 +37,7 @@ import { isInactiveEvent } from '../../lib/calendar-participants'; import { eventBlockColors } from '../../lib/event-colors'; import { buildAllDaySegments, + columnsPerScreen, headerColumnRange, hourAtOffset, timeGridFocusColumn, @@ -119,7 +120,7 @@ function TimeGridScrollViewInner({ [eventsByDay, events], ); - const perScreen = mode === 'week' ? 7 : 1; + const perScreen = columnsPerScreen(mode); const { width: screenWidth } = useWindowDimensions(); const [rootWidth, setRootWidth] = React.useState(screenWidth); const listWidth = Math.max(perScreen, rootWidth - GUTTER_WIDTH); diff --git a/src/lib/calendar-time-grid.ts b/src/lib/calendar-time-grid.ts index 5a1800df..3fff4fb1 100644 --- a/src/lib/calendar-time-grid.ts +++ b/src/lib/calendar-time-grid.ts @@ -16,7 +16,12 @@ import type { DayRange, WeekStartsOn } from './calendar-scroll-window'; * window, scrolling sideways and snapping to whole days. */ -export type TimeGridMode = 'week' | 'day'; +export type TimeGridMode = 'week' | '3day' | 'day'; + +/** Day columns on one screen. */ +export function columnsPerScreen(mode: TimeGridMode): number { + return mode === 'week' ? 7 : mode === '3day' ? 3 : 1; +} /** Every day of the window, in order. */ export function windowDays(window: DayRange): Date[] { @@ -28,7 +33,8 @@ export function windowDays(window: DayRange): Date[] { /** * The column navigation aligns with the start of the viewport: the focused - * week's first day in the week view, the focused day in the day view. + * week's first day in the week view, the focused day in the day and + * three-day views. */ export function timeGridFocusColumn( window: DayRange, diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 3b7bb051..51408188 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -13,20 +13,21 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { + Calendar as CalendarIcon, + CalendarDays, + ChevronDown, ChevronLeft, ChevronRight, - Plus, - Calendar1, - CalendarDays, - LayoutGrid, - List as ListIcon, + Columns3, + Columns4, + Grid3x3, ListChecks, Menu, + Rows2, + Square, } from 'lucide-react-native'; import { format, - startOfWeek, - endOfWeek, addDays, subDays, addMonths, @@ -39,7 +40,6 @@ import { useCalendarLocale } from '../lib/calendar-locale'; import { displayNow, isDisplayToday } from '../lib/calendar-timezone'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; -import { Button } from '../components'; import { useCalendarStore } from '../stores/calendar-store'; import { useSettingsStore } from '../stores/settings-store'; import { MonthView } from '../components/calendar/MonthView'; @@ -54,7 +54,9 @@ import { RecurrenceScopeDialog, type RecurrenceEditScope, } from '../components/calendar/RecurrenceScopeDialog'; -import { CalendarSidebarDrawer } from '../components/calendar/CalendarSidebarDrawer'; +import { CalendarSidebarDrawer, type DrawerViewOption } from '../components/calendar/CalendarSidebarDrawer'; +import { CalendarFab } from '../components/calendar/CalendarFab'; +import { MonthDropdown } from '../components/calendar/MonthDropdown'; import { TasksSheet } from '../components/calendar/TasksSheet'; import { ICalImportSheet } from '../components/calendar/ICalImportSheet'; import { ICalSubscriptionSheet } from '../components/calendar/ICalSubscriptionSheet'; @@ -71,6 +73,7 @@ import { scrollWindowLoadRange, windowStateForJump, type CalendarFocus, + type ScrollViewMode, type ScrollWindowOptions, type ScrollWindowState, } from '../lib/calendar-scroll-window'; @@ -107,7 +110,18 @@ import { shareEventICS } from '../lib/calendar-ics-export'; import * as Clipboard from 'expo-clipboard'; import type { Calendar, CalendarEvent, RecurrenceRule } from '../api/types'; -type ViewMode = 'month' | 'week' | 'day' | 'agenda'; +type ViewMode = 'month' | 'week' | '3day' | 'day' | 'agenda'; + +// The three-day grid scrolls through the same window of days as the day view. +function windowMode(mode: ViewMode): ScrollViewMode { + return mode === '3day' ? 'day' : mode; +} + +// A window without free scrolling: one period, three days for the three-day grid. +function fixedWindowFor(mode: ViewMode, anchor: Date): ScrollWindowState { + const state = fixedScrollWindowState(windowMode(mode), anchor); + return mode === '3day' ? { ...state, after: 2 } : state; +} type PendingAction = | { kind: 'edit'; @@ -129,28 +143,10 @@ type PendingAction = // the next arrow press or edge usually finds them there already. const RANGE_MARGIN_DAYS = 14; -type WeekStart = 0 | 1 | 6; - -// `firstDay`: the scrolled week grid reports the first column in view; the -// title then spans the seven days from there instead of the focused week. -function headerTitle( - viewMode: ViewMode, - currentDate: Date, - weekStartsOn: WeekStart, - locale: Locale, - firstDay?: Date | null, -): string { - if (viewMode === 'month') return format(currentDate, 'MMMM yyyy', { locale }); - if (viewMode === 'day') return format(currentDate, 'EEE, MMM d, yyyy', { locale }); - if (viewMode === 'week') { - const start = firstDay ?? startOfWeek(currentDate, { weekStartsOn }); - const end = firstDay ? addDays(firstDay, 6) : endOfWeek(currentDate, { weekStartsOn }); - if (start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear()) { - return `${format(start, 'MMM d', { locale })} – ${format(end, 'd, yyyy', { locale })}`; - } - return `${format(start, 'MMM d', { locale })} – ${format(end, 'MMM d, yyyy', { locale })}`; - } - return format(currentDate, 'MMMM yyyy', { locale }); +// The month on screen; the year only when it isn't this year. +function headerTitle(date: Date, locale: Locale): string { + const sameYear = date.getFullYear() === displayNow().getFullYear(); + return format(date, sameYear ? 'LLLL' : 'LLLL yyyy', { locale }); } export default function CalendarScreen() { @@ -201,13 +197,14 @@ export default function CalendarScreen() { // The sideways-scrolling week and day grids assume a left-to-right strip; // right-to-left layouts keep them paged. const freeScroll = - calendarFreeScroll && ((viewMode !== 'week' && viewMode !== 'day') || !I18nManager.isRTL); + calendarFreeScroll + && ((viewMode !== 'week' && viewMode !== '3day' && viewMode !== 'day') || !I18nManager.isRTL); const focusKey = dayKey(focus.date); const activeWindowState = React.useMemo( () => freeScroll - ? normalizeScrollWindowState(windowState, viewMode, parseDayKey(focusKey)) - : fixedScrollWindowState(viewMode, parseDayKey(focusKey)), + ? normalizeScrollWindowState(windowState, windowMode(viewMode), parseDayKey(focusKey)) + : fixedWindowFor(viewMode, parseDayKey(focusKey)), [freeScroll, windowState, viewMode, focusKey], ); React.useEffect(() => { @@ -218,7 +215,7 @@ export default function CalendarScreen() { [activeWindowState, windowOptions], ); // Changes whenever a fresh window starts; the views remount on it. - const windowKey = `${activeWindowState.mode}:${activeWindowState.anchorKey}`; + const windowKey = `${viewMode}:${activeWindowState.anchorKey}`; const loadRange = React.useMemo(() => { const { after, before } = scrollWindowLoadRange(scrollWindow, RANGE_MARGIN_DAYS); return { after: after.toISOString(), before: before.toISOString() }; @@ -467,7 +464,7 @@ export default function CalendarScreen() { setSelectedDate(date); setVisibleDate(null); setFocus((prev) => ({ date, nonce: prev.nonce + 1 })); - setWindowState((prev) => windowStateForJump(prev, viewMode, date, windowOptions)); + setWindowState((prev) => windowStateForJump(prev, windowMode(viewMode), date, windowOptions)); }, [viewMode, windowOptions], ); @@ -479,7 +476,7 @@ export default function CalendarScreen() { (side: 'before' | 'after') => { setLoadingEdge(side === 'before' ? 'start' : 'end'); setWindowState((prev) => - growScrollWindow(normalizeScrollWindowState(prev, viewMode, focus.date), side), + growScrollWindow(normalizeScrollWindowState(prev, windowMode(viewMode), focus.date), side), ); }, [viewMode, focus.date], @@ -493,6 +490,7 @@ export default function CalendarScreen() { const base = visibleDate ?? focus.date; jumpTo( viewMode === 'week' ? subWeeks(base, 1) + : viewMode === '3day' ? subDays(base, 3) : viewMode === 'day' ? subDays(base, 1) : subMonths(base, 1), ); @@ -502,6 +500,7 @@ export default function CalendarScreen() { const base = visibleDate ?? focus.date; jumpTo( viewMode === 'week' ? addWeeks(base, 1) + : viewMode === '3day' ? addDays(base, 3) : viewMode === 'day' ? addDays(base, 1) : addMonths(base, 1), ); @@ -519,11 +518,48 @@ export default function CalendarScreen() { setViewMode(mode); setVisibleDate(null); setFocus((prev) => ({ date, nonce: prev.nonce + 1 })); - setWindowState(freshScrollWindowState(mode, date)); + setWindowState(freshScrollWindowState(windowMode(mode), date)); }, [viewMode, visibleDate, focus.date], ); + // The views, listed at the top of the drawer. + const viewOptions = React.useMemo( + () => [ + { key: 'agenda', label: t('calendar.views.schedule', 'Schedule'), Icon: Rows2 }, + { key: 'day', label: t('calendar.views.day', 'Day'), Icon: Square }, + { key: '3day', label: t('calendar.views.three_days', '3 days'), Icon: Columns3 }, + { key: 'week', label: t('calendar.views.week', 'Week'), Icon: Columns4 }, + { key: 'month', label: t('calendar.views.month', 'Month'), Icon: Grid3x3 }, + ], + [t], + ); + + // The month that drops down under the title; the title follows the month + // swiped to while it is open. + const [monthPanelOpen, setMonthPanelOpen] = React.useState(false); + const [panelMonth, setPanelMonth] = React.useState(null); + const toggleMonthPanel = React.useCallback(() => { + setPanelMonth(null); + setMonthPanelOpen((open) => !open); + }, []); + const handlePanelSelect = React.useCallback( + (date: Date) => { + setMonthPanelOpen(false); + setPanelMonth(null); + jumpTo(date); + }, + [jumpTo], + ); + + // New tasks open the tasks sheet on an empty editor. + const [newTask, setNewTask] = React.useState(false); + const openNewTask = React.useCallback(() => { + setTasksInitialId(null); + setNewTask(true); + setTasksVisible(true); + }, []); + // Picking a day only moves the selection; the view stays where it is. const handleSelectDate = React.useCallback((date: Date) => { setSelectedDate(date); @@ -923,6 +959,7 @@ export default function CalendarScreen() { ); const isSelectedToday = isDisplayToday(selectedDate); + const titleDate = (monthPanelOpen && panelMonth) || visibleDate || focus.date; return ( @@ -936,96 +973,83 @@ export default function CalendarScreen() { > - - {/* Four view buttons leave less room: shrink a long title instead of wrapping it. */} - - {headerTitle( - viewMode, - visibleDate ?? focus.date, - calendarFirstDayOfWeek, - locale, - viewMode === 'week' && freeScroll ? visibleDate : null, - )} + + + {headerTitle(titleDate, locale)} - - {isSelectedToday - ? t('calendar.views.today', 'Today') - : format(selectedDate, 'EEE, MMM d', { locale })} - - + + + + + {/* Without free scrolling the views don't swipe: keep arrows. */} + {!freeScroll && ( + <> + + + + + + + + )} + { + setMonthPanelOpen(false); + goToday(); + }} + hitSlop={4} + accessibilityRole="button" + accessibilityLabel={t('calendar.events.go_to_today', 'Go to today')} + > + + {format(displayNow(), 'd')} + {enableCalendarTasks && ( setTasksVisible(true)} - hitSlop={6} + hitSlop={4} accessibilityRole="button" accessibilityLabel={t('calendar.views.tasks', 'Tasks')} > - + )} - - {(['month', 'week', 'day', 'agenda'] as ViewMode[]).map((mode) => { - const Icon = - mode === 'month' ? LayoutGrid - : mode === 'week' ? CalendarDays - : mode === 'day' ? Calendar1 - : ListIcon; - const active = viewMode === mode; - const label = - mode === 'month' ? t('calendar.views.month', 'Month') - : mode === 'week' ? t('calendar.views.week', 'Week') - : mode === 'day' ? t('calendar.views.day', 'Day') - : t('calendar.views.agenda', 'Agenda'); - return ( - changeViewMode(mode)} - accessibilityRole="button" - accessibilityLabel={label} - accessibilityState={{ selected: active }} - > - - - ); - })} - - openCreate(selectedDate)} - accessibilityRole="button" - accessibilityLabel={t('calendar.events.new_event', 'New event')} - > - - - - - - - - - - - + {monthPanelOpen && ( + + )} {error && ( @@ -1071,10 +1095,10 @@ export default function CalendarScreen() { onLongPressDate={openCreate} /> )} - {(viewMode === 'day' || (viewMode === 'week' && freeScroll)) && ( + {(viewMode === 'day' || viewMode === '3day' || (viewMode === 'week' && freeScroll)) && ( + openCreate(selectedDate)} + onNewTask={enableCalendarTasks ? openNewTask : undefined} + /> + { + setSidebarVisible(false); + setMonthPanelOpen(false); + changeViewMode(key as ViewMode); + }} + onRefresh={() => { + setSidebarVisible(false); + void onRefresh(); + }} calendars={eventCalendars} hiddenCalendarIds={hiddenCalendarIds} onToggle={toggleCalendarVisibility} @@ -1222,7 +1262,8 @@ export default function CalendarScreen() { calendars={taskSheetCalendars} timeFormat={calendarTimeFormat} initialTaskId={tasksInitialId} - onClose={() => { setTasksVisible(false); setTasksInitialId(null); }} + startNew={newTask} + onClose={() => { setTasksVisible(false); setTasksInitialId(null); setNewTask(false); }} onCreate={createTask} onUpdate={updateTask} onToggle={handleToggleTask} @@ -1330,57 +1371,38 @@ function makeStyles(c: ThemePalette) { header: { flexDirection: 'row', alignItems: 'center', - paddingHorizontal: spacing.md, + paddingHorizontal: spacing.sm, paddingVertical: spacing.sm, gap: spacing.sm, }, headerBtn: { - width: 36, - height: 36, + width: 40, + height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.full, }, - headerLeft: { flex: 1 }, - headerTitle: { ...typography.h3, color: c.text }, - headerSubtitle: { ...typography.caption, color: c.textMuted, marginTop: 2 }, - headerActions: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, - viewToggle: { - flexDirection: 'row', - backgroundColor: c.surface, - borderRadius: radius.md, - padding: 2, - }, - viewToggleBtn: { - width: 30, - height: 32, - alignItems: 'center', - justifyContent: 'center', - borderRadius: radius.sm, - }, - viewToggleBtnActive: { backgroundColor: c.background }, - fab: { - width: 36, - height: 36, - borderRadius: radius.full, - backgroundColor: c.primary, - alignItems: 'center', - justifyContent: 'center', - }, - - nav: { + titleBtn: { + flex: 1, flexDirection: 'row', alignItems: 'center', - justifyContent: 'center', - paddingVertical: spacing.xs, - gap: spacing.lg, + gap: 2, + minHeight: 40, }, - navBtn: { - width: 36, - height: 36, - alignItems: 'center', - justifyContent: 'center', - borderRadius: radius.full, + headerTitle: { ...typography.h3, color: c.text, flexShrink: 1 }, + chevronOpen: { transform: [{ rotate: '180deg' }] }, + headerActions: { flexDirection: 'row', alignItems: 'center', gap: 2 }, + // Today's date inside the calendar icon, below its rings. + todayIconText: { + position: 'absolute', + top: 15, + left: 0, + right: 0, + textAlign: 'center', + fontSize: 10, + lineHeight: 12, + fontWeight: '700', + color: c.text, }, errorBanner: {