mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
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:
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user