feat(calendar): move views into the drawer and add a create button

The calendar header was crowded: four icon toggles, a separate row of
arrows and a Today button, and a small create button squeezed in next
to them. Navigation now follows Google Calendar's layout while keeping
the app's own look:

- the header shows the month; tapping it drops down a month you can
  swipe through and pick a day from
- a calendar icon with today's date jumps to today
- Schedule, Day, 3 days, Week and Month are listed at the top of the
  drawer, styled like the mail drawer's folders, with Refresh below
- a round create button sits in the corner like the mail compose
  button; with tasks on it offers Event or Task
- a new 3 days view shows three columns of the time grid

The arrows stay in the header when free scrolling is off, since the
views can't be swiped then.
This commit is contained in:
Linus Rath
2026-10-10 22:41:46 +02:00
parent 76180b332d
commit c1e0cb36c9
34 changed files with 762 additions and 165 deletions
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+153
View File
@@ -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 && (
<Animated.View style={[styles.scrim, { opacity: progress }]}>
<Pressable
style={StyleSheet.absoluteFill}
onPress={() => setOpen(false)}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
/>
</Animated.View>
)}
<View style={styles.anchor} pointerEvents="box-none">
{open && onNewTask && (
<Animated.View style={[styles.option, { opacity: progress, transform: [{ translateY: lift }] }]}>
<Text style={styles.optionLabel}>{t('calendar.fab.task', 'Task')}</Text>
<Pressable
style={styles.miniFab}
onPress={() => pick(onNewTask)}
accessibilityRole="button"
accessibilityLabel={t('calendar.events.new_task', 'New task')}
>
<CircleCheck size={20} color={c.text} />
</Pressable>
</Animated.View>
)}
<View style={styles.mainRow}>
{open && (
<Animated.Text style={[styles.optionLabel, { opacity: progress }]}>
{t('calendar.fab.event', 'Event')}
</Animated.Text>
)}
<Pressable
style={({ pressed }) => [styles.fab, pressed && styles.fabPressed]}
onPress={onMainPress}
accessibilityRole="button"
accessibilityLabel={t('calendar.events.new_event', 'New event')}
>
{open ? (
<CalendarPlus size={24} color={c.background} />
) : (
<Animated.View style={{ transform: [{ rotate }] }}>
<Plus size={26} color={c.background} />
</Animated.View>
)}
</Pressable>
</View>
</View>
</>
);
}
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 },
});
}
@@ -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({
>
<X size={20} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>{t('calendar.my_calendars', 'Calendars')}</Text>
<Text style={styles.headerTitle}>{t('calendar.title', 'Calendar')}</Text>
</View>
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
{views && views.length > 0 && (
<View style={styles.viewsSection}>
{views.map(({ key, label, Icon }) => {
const active = key === activeView;
return (
<Pressable
key={key}
onPress={() => onSelectView?.(key)}
style={({ pressed }) => [
styles.viewRow,
active && styles.viewRowActive,
pressed && !active && styles.rowPressed,
]}
accessibilityRole="button"
accessibilityState={{ selected: active }}
>
<Icon size={16} color={active ? c.text : c.textSecondary} />
<Text style={[styles.viewText, active && styles.viewTextActive]}>{label}</Text>
</Pressable>
);
})}
{onRefresh && (
<Pressable
onPress={onRefresh}
style={({ pressed }) => [styles.viewRow, styles.refreshRow, pressed && styles.rowPressed]}
accessibilityRole="button"
>
<RefreshCw size={16} color={c.textSecondary} />
<Text style={styles.viewText}>{t('common.refresh', 'Refresh')}</Text>
</Pressable>
)}
</View>
)}
{calendars.length === 0 && (
<Text style={styles.empty}>{t('calendar.drawer.no_calendars', 'No calendars yet.')}</Text>
)}
@@ -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: {
+125
View File
@@ -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<NativeScrollEvent>) => {
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<Date>) => (
<View style={{ width, height: PANEL_HEIGHT }}>
<MonthView
currentDate={item}
selectedDate={selectedDate}
events={[]}
eventsByDay={eventsByDay}
calendars={calendars}
weekStartsOn={weekStartsOn}
onSelectDate={onSelectDate}
/>
</View>
),
[width, selectedDate, eventsByDay, calendars, weekStartsOn, onSelectDate],
);
const getItemLayout = React.useCallback(
(_: ArrayLike<Date> | null | undefined, i: number) => ({ length: width, offset: width * i, index: i }),
[width],
);
return (
<View style={styles.panel}>
<FlatList
horizontal
pagingEnabled
data={months}
keyExtractor={monthKey}
renderItem={renderItem}
getItemLayout={getItemLayout}
initialScrollIndex={MONTHS_EACH_SIDE}
onMomentumScrollEnd={handleMomentumEnd}
showsHorizontalScrollIndicator={false}
initialNumToRender={1}
windowSize={3}
style={{ height: PANEL_HEIGHT }}
/>
</View>
);
}
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,
},
});
}
+5 -2
View File
@@ -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<CalendarEvent>, calendarId: string) => Promise<void> | void;
onUpdate?: (id: string, changes: Partial<CalendarEvent>) => Promise<void> | 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) {
@@ -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);
+8 -2
View File
@@ -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,
+180 -158
View File
@@ -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<DrawerViewOption[]>(
() => [
{ 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<Date | null>(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 (
<SafeAreaView style={styles.container} edges={['top']}>
@@ -936,96 +973,83 @@ export default function CalendarScreen() {
>
<Menu size={20} color={c.text} />
</Pressable>
<View style={styles.headerLeft}>
{/* Four view buttons leave less room: shrink a long title instead of wrapping it. */}
<Text style={styles.headerTitle} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.75}>
{headerTitle(
viewMode,
visibleDate ?? focus.date,
calendarFirstDayOfWeek,
locale,
viewMode === 'week' && freeScroll ? visibleDate : null,
)}
<Pressable
onPress={toggleMonthPanel}
style={styles.titleBtn}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={t('calendar.mini_calendar_change', 'Change month')}
accessibilityState={{ expanded: monthPanelOpen }}
>
<Text style={styles.headerTitle} numberOfLines={1}>
{headerTitle(titleDate, locale)}
</Text>
<Text style={styles.headerSubtitle}>
{isSelectedToday
? t('calendar.views.today', 'Today')
: format(selectedDate, 'EEE, MMM d', { locale })}
</Text>
</View>
<View style={monthPanelOpen && styles.chevronOpen}>
<ChevronDown size={18} color={c.textSecondary} />
</View>
</Pressable>
<View style={styles.headerActions}>
{/* Without free scrolling the views don't swipe: keep arrows. */}
{!freeScroll && (
<>
<Pressable
onPress={goPrev}
style={styles.headerBtn}
hitSlop={4}
accessibilityRole="button"
accessibilityLabel={t('calendar.nav_prev', 'Previous')}
>
<ChevronLeft size={22} color={c.text} />
</Pressable>
<Pressable
onPress={goNext}
style={styles.headerBtn}
hitSlop={4}
accessibilityRole="button"
accessibilityLabel={t('calendar.nav_next', 'Next')}
>
<ChevronRight size={22} color={c.text} />
</Pressable>
</>
)}
<Pressable
style={styles.headerBtn}
onPress={() => {
setMonthPanelOpen(false);
goToday();
}}
hitSlop={4}
accessibilityRole="button"
accessibilityLabel={t('calendar.events.go_to_today', 'Go to today')}
>
<CalendarIcon size={26} color={c.text} strokeWidth={1.75} />
<Text style={styles.todayIconText}>{format(displayNow(), 'd')}</Text>
</Pressable>
{enableCalendarTasks && (
<Pressable
style={styles.headerBtn}
onPress={() => setTasksVisible(true)}
hitSlop={6}
hitSlop={4}
accessibilityRole="button"
accessibilityLabel={t('calendar.views.tasks', 'Tasks')}
>
<ListChecks size={20} color={c.text} />
<ListChecks size={22} color={c.text} />
</Pressable>
)}
<View style={styles.viewToggle}>
{(['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 (
<Pressable
key={mode}
style={[styles.viewToggleBtn, active && styles.viewToggleBtnActive]}
onPress={() => changeViewMode(mode)}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={{ selected: active }}
>
<Icon size={16} color={active ? c.primary : c.textMuted} />
</Pressable>
);
})}
</View>
<Pressable
style={styles.fab}
onPress={() => openCreate(selectedDate)}
accessibilityRole="button"
accessibilityLabel={t('calendar.events.new_event', 'New event')}
>
<Plus size={18} color={c.primaryForeground} />
</Pressable>
</View>
</View>
<View style={styles.nav}>
<Pressable
onPress={goPrev}
style={styles.navBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('calendar.nav_prev', 'Previous')}
>
<ChevronLeft size={20} color={c.text} />
</Pressable>
<Button variant="outline" size="sm" onPress={goToday}>
{t('calendar.views.today', 'Today')}
</Button>
<Pressable
onPress={goNext}
style={styles.navBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('calendar.nav_next', 'Next')}
>
<ChevronRight size={20} color={c.text} />
</Pressable>
</View>
{monthPanelOpen && (
<MonthDropdown
month={titleDate}
selectedDate={selectedDate}
eventsByDay={eventsByDay}
calendars={calendars}
weekStartsOn={calendarFirstDayOfWeek}
onMonthChange={setPanelMonth}
onSelectDate={handlePanelSelect}
/>
)}
{error && (
<View style={styles.errorBanner}>
@@ -1071,10 +1095,10 @@ export default function CalendarScreen() {
onLongPressDate={openCreate}
/>
)}
{(viewMode === 'day' || (viewMode === 'week' && freeScroll)) && (
{(viewMode === 'day' || viewMode === '3day' || (viewMode === 'week' && freeScroll)) && (
<TimeGridScrollView
key={windowKey}
mode={viewMode === 'day' ? 'day' : 'week'}
mode={viewMode}
focus={focus}
window={scrollWindow}
onExtendStart={freeScroll && scrollWindow.canExtendStart ? extendWindowStart : undefined}
@@ -1151,6 +1175,11 @@ export default function CalendarScreen() {
)}
</View>
<CalendarFab
onNewEvent={() => openCreate(selectedDate)}
onNewTask={enableCalendarTasks ? openNewTask : undefined}
/>
<EventDetailSheet
event={detailEvent}
calendars={calendars}
@@ -1195,6 +1224,17 @@ export default function CalendarScreen() {
<CalendarSidebarDrawer
visible={sidebarVisible}
views={viewOptions}
activeView={viewMode}
onSelectView={(key) => {
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: {