diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 61412d4..18493bc 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -2910,6 +2910,7 @@ const defaultLocales: LocaleTree = { title: 'Title', titlePlaceholder: 'What is planned?', date: 'Date', + allDay: 'All-day', starts: 'Starts', ends: 'Ends', reminder: 'Reminder', diff --git a/frontend/src/views/apps/CalendarApp.vue b/frontend/src/views/apps/CalendarApp.vue index b82982b..80b14f6 100644 --- a/frontend/src/views/apps/CalendarApp.vue +++ b/frontend/src/views/apps/CalendarApp.vue @@ -18,7 +18,7 @@ import { UserRound, X, } from 'lucide-vue-next' -import { kFab } from 'konsta/vue' +import { kGlass, kSearchbar, kToggle } from 'konsta/vue' import { computed, nextTick, onMounted, reactive, ref } from 'vue' import { useAccountStore } from '@/stores/account' @@ -59,6 +59,11 @@ const searchQuery = ref('') const reminderOpen = ref(false) const saving = ref(false) const formError = ref('') +const datePickerOpen = ref(false) +const pickerDate = ref(selectedDate.value) +const pickerMonth = ref( + new Date(today.getFullYear(), today.getMonth(), 1), +) const timePickerField = ref(null) const pickerHour = ref('09') const pickerMinute = ref('00') @@ -66,7 +71,9 @@ const hourColumn = ref(null) const minuteColumn = ref(null) const calendarScroll = ref(null) const yearOverviewScroll = ref(null) +const searchToolbar = ref(null) const draft = reactive({ + allDay: false, date: selectedDate.value, endTime: '10:00', note: '', @@ -81,10 +88,8 @@ const hourOptions = Array.from({ length: 24 }, (_, index) => const minuteOptions = Array.from({ length: 12 }, (_, index) => String(index * 5).padStart(2, '0'), ) -const cornerButtonColors = { - bgIos: 'bg-ios-light-glass/75 dark:bg-ios-dark-glass/75', - activeBgIos: 'active:bg-white/90 dark:active:bg-white/20', - textIos: 'text-black/80 dark:text-white/80', +const calendarToggleColors = { + checkedBgIos: 'bg-[#ff3b30]', } const viewModes: Array<{ icon: typeof LayoutGrid @@ -119,6 +124,20 @@ const timePickerTitle = computed(() => ? phone.t('Apps.calendar.starts') : phone.t('Apps.calendar.ends'), ) +const draftDateLabel = computed(() => + new Intl.DateTimeFormat(phone.lang, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }).format(new Date(`${draft.date}T12:00:00`)), +) +const pickerMonthLabel = computed(() => + new Intl.DateTimeFormat(phone.lang, { + month: 'long', + year: 'numeric', + }).format(pickerMonth.value), +) +const pickerMonthCells = computed(() => monthCells(pickerMonth.value)) const weekdayLabels = computed(() => { const monday = new Date(2026, 0, 5) return Array.from({ length: 7 }, (_, index) => @@ -328,19 +347,40 @@ function scrollToMonth(date: Date, behavior: ScrollBehavior = 'smooth'): void { ?.scrollIntoView({ behavior, block: 'start' }) } -function toggleSearch(): void { +async function toggleSearch(): Promise { searchOpen.value = !searchOpen.value - if (!searchOpen.value) searchQuery.value = '' + viewMenuOpen.value = false + if (!searchOpen.value) { + searchQuery.value = '' + return + } + await nextTick() + searchToolbar.value?.querySelector('input')?.focus() +} + +function isAllDayEvent(event: CalendarEvent): boolean { + return timeValue(event.startsAt) === '00:00' && timeValue(event.endsAt) === '23:59' +} + +function eventTimeLabel(event: CalendarEvent): string { + if (isAllDayEvent(event)) return phone.t('Apps.calendar.allDay') + return `${formatTime(event.startsAt)} – ${formatTime(event.endsAt)}` +} + +function updateSearch(event: Event): void { + searchQuery.value = (event.target as HTMLInputElement).value } function resetDraft(event?: CalendarEvent): void { editingEvent.value = event ?? null + draft.allDay = event ? isAllDayEvent(event) : false draft.title = event?.title ?? '' draft.note = event?.note ?? '' draft.date = event ? dateKey(event.startsAt) : selectedDate.value draft.startTime = event ? timeValue(event.startsAt) : '09:00' draft.endTime = event ? timeValue(event.endsAt) : '10:00' draft.reminderMinutes = event?.reminderMinutes ?? 60 + datePickerOpen.value = false reminderOpen.value = false formError.value = '' } @@ -375,15 +415,51 @@ function shareEvent(): void { } function closeForm(): void { + datePickerOpen.value = false timePickerField.value = null screen.value = editingEvent.value ? 'detail' : 'main' } +function toggleAllDay(): void { + draft.allDay = !draft.allDay + timePickerField.value = null + if ( + !draft.allDay && + draft.startTime === '00:00' && + draft.endTime === '23:59' + ) { + draft.startTime = '09:00' + draft.endTime = '10:00' + } +} + +function openDatePicker(): void { + const date = new Date(`${draft.date}T12:00:00`) + pickerDate.value = draft.date + pickerMonth.value = new Date(date.getFullYear(), date.getMonth(), 1) + timePickerField.value = null + datePickerOpen.value = true +} + +function movePickerMonth(amount: number): void { + pickerMonth.value = new Date( + pickerMonth.value.getFullYear(), + pickerMonth.value.getMonth() + amount, + 1, + ) +} + +function confirmDatePicker(): void { + draft.date = pickerDate.value + datePickerOpen.value = false +} + async function openTimePicker(field: TimeField): Promise { const [hour, minute] = draft[field].split(':') timePickerField.value = field pickerHour.value = hour ?? '09' pickerMinute.value = minute ?? '00' + datePickerOpen.value = false await nextTick() hourColumn.value ?.querySelector(`[data-value="${pickerHour.value}"]`) @@ -412,8 +488,14 @@ function selectPickerValue( } async function saveEvent(): Promise { - const startsAt = combineDateAndTime(draft.date, draft.startTime) - const endsAt = combineDateAndTime(draft.date, draft.endTime) + const startsAt = combineDateAndTime( + draft.date, + draft.allDay ? '00:00' : draft.startTime, + ) + const endsAt = combineDateAndTime( + draft.date, + draft.allDay ? '23:59' : draft.endTime, + ) if (!draft.title.trim() || endsAt <= startsAt) { formError.value = phone.t('Apps.calendar.errors.invalid_event') return @@ -467,91 +549,91 @@ onMounted(async () => { class="calendar__toolbar" :class="{ 'calendar__toolbar--year': yearOverviewOpen }" > - - - -
- +
+ + + + +
+
+ - - - - - - - - -
+ + {{ visibleYear }} + +
+ + + + + + + + + +
- -
- + +
+ +
+
- - - -

{{ phone.t('Apps.calendar.signInTitle') }}

@@ -729,10 +811,7 @@ onMounted(async () => { {{ event.title }} - - {{ formatTime(event.startsAt) }} – - {{ formatTime(event.endsAt) }} - + {{ eventTimeLabel(event) }} @@ -741,13 +820,14 @@ onMounted(async () => {
- + > + {{ phone.t('Apps.calendar.today') }} +
@@ -779,10 +859,7 @@ onMounted(async () => {
{{ phone.t('Apps.calendar.date') }} {{ formatLongDate(selectedEvent.startsAt) }} - - {{ formatTime(selectedEvent.startsAt) }} – - {{ formatTime(selectedEvent.endsAt) }} - + {{ eventTimeLabel(selectedEvent) }}
@@ -810,9 +887,14 @@ onMounted(async () => {
- + {{ editingEvent @@ -820,9 +902,15 @@ onMounted(async () => { : phone.t('Apps.calendar.newEvent') }} - +
@@ -839,11 +927,26 @@ onMounted(async () => {
- +
+ {{ phone.t('Apps.calendar.allDay') }} + +
+
+ + + +
{ --muted: #6e6e73; } -.calendar button:not(.k-fab), +.calendar button, .calendar input, .calendar textarea { border: 0; @@ -1004,7 +1177,7 @@ onMounted(async () => { font: inherit; } -.calendar button:not(.k-fab) { +.calendar button { cursor: pointer; } @@ -1026,6 +1199,92 @@ onMounted(async () => { gap: 8px; } +.calendar__toolbar-default, +.calendar__search-toolbar { + width: 100%; + display: flex; + align-items: center; +} + +.calendar__toolbar-default { + justify-content: space-between; + gap: 10px; +} + +.calendar__search-toolbar { + gap: 8px; +} + +.calendar__searchbar { + min-width: 0; + flex: 1; +} + +.calendar__searchbar :deep(> div:first-child) { + height: 44px; + border-radius: 9999px; + overflow: hidden; + background: var(--color-ios-dark-glass); + box-shadow: var(--shadow-ios-dark-glass); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); +} + +.calendar--light .calendar__searchbar :deep(> div:first-child) { + background: var(--color-ios-light-glass); + box-shadow: var(--shadow-ios-light-glass); +} + +.calendar__searchbar :deep(input) { + color: var(--label); + font-size: 14px; +} + +.calendar__control { + width: 44px; + height: 44px; + padding: 0; + border-radius: 9999px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + gap: 3px; + background: var(--color-ios-dark-glass); + color: var(--label); + box-shadow: var(--shadow-ios-dark-glass); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); +} + +.calendar--light .calendar__control { + background: var(--color-ios-light-glass); + box-shadow: var(--shadow-ios-light-glass); +} + +.calendar__control--year { + width: auto; + min-width: 86px; + padding: 0 11px 0 7px; + color: var(--accent); + font-size: 14px; + font-weight: 700; +} + +.calendar__control--today { + width: auto; + min-width: 74px; + padding: 0 14px; + color: var(--accent); + font-size: 13px; + font-weight: 750; +} + +.calendar__control--close { + flex: none; + color: var(--accent); +} + .calendar__view-menu { position: absolute; z-index: 12; @@ -1035,7 +1294,7 @@ onMounted(async () => { padding: 8px; border: 1px solid var(--line); border-radius: 18px; - background: var(--glass); + background: var(--elevated); box-shadow: 0 16px 44px rgb(0 0 0 / 38%); backdrop-filter: blur(28px) saturate(180%); -webkit-backdrop-filter: blur(28px) saturate(180%); @@ -1060,27 +1319,6 @@ onMounted(async () => { color: var(--accent); } -.calendar__search { - height: 46px; - margin: 0 14px 7px; - padding: 0 10px; - border-radius: 13px; - display: flex; - align-items: center; - gap: 7px; - background: var(--elevated); - color: var(--muted); -} - -.calendar__search input { - min-width: 0; - flex: 1; - outline: 0; - background: transparent; - color: var(--label); - font-size: 15px; -} - .calendar__content { height: calc(100% - 68px); padding: 4px 0 100px; @@ -1090,10 +1328,6 @@ onMounted(async () => { scrollbar-width: none; } -.calendar__search + .calendar__content { - height: calc(100% - 121px); -} - .calendar__year-overview { height: calc(100% - 68px); padding: 4px 14px 100px; @@ -1103,10 +1337,6 @@ onMounted(async () => { scrollbar-width: none; } -.calendar__search + .calendar__year-overview { - height: calc(100% - 121px); -} - .calendar__year-overview::-webkit-scrollbar { display: none; } @@ -1129,8 +1359,8 @@ onMounted(async () => { .calendar__overview-months { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 24px 11px; + grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); + gap: 28px 18px; } .calendar__overview-month { @@ -1161,7 +1391,7 @@ onMounted(async () => { .calendar__overview-month button, .calendar__overview-month > div > span { min-width: 0; - height: 17px; + height: 22px; } .calendar__overview-month button { @@ -1171,7 +1401,8 @@ onMounted(async () => { place-items: center; background: transparent; color: var(--label); - font-size: 10px; + font-size: 11px; + font-variant-numeric: tabular-nums; font-weight: 540; } @@ -1629,6 +1860,16 @@ onMounted(async () => { opacity: 0.4; } +.calendar__sheet-action { + width: 68px; + height: 38px; + padding: 0 12px; + border-radius: 9999px; + color: var(--accent); + font-size: 13px; + font-weight: 700; +} + .calendar__sheet-header > strong { font-size: 15px; } @@ -1786,19 +2027,6 @@ onMounted(async () => { line-height: 1.4; } -.calendar__group--fields label { - min-height: 58px; - padding: 0 15px; - display: flex; - align-items: center; - border-bottom: 1px solid var(--line); - font-size: 13px; -} - -.calendar__group--fields label:last-child { - border-bottom: 0; -} - .calendar__date-time-row { width: 100%; min-height: 58px; @@ -1813,6 +2041,18 @@ onMounted(async () => { text-align: left; } +.calendar__all-day-row { + min-height: 58px; + padding: 0 15px; + border-bottom: 1px solid var(--line); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--label); + font-size: 13px; +} + .calendar__date-time-row span { min-width: 0; } @@ -1827,22 +2067,6 @@ onMounted(async () => { color: var(--muted); } -.calendar__group--fields label span { - flex: 1; -} - -.calendar__group--fields label input { - width: auto; - min-width: 112px; - color-scheme: dark; - font-size: 13px; - text-align: right; -} - -.calendar--light .calendar__group--fields label input { - color-scheme: light; -} - .calendar__form-row { width: 100%; min-height: 58px; @@ -1959,6 +2183,89 @@ onMounted(async () => { justify-self: end; } +.calendar__date-picker-month { + height: 58px; + display: grid; + grid-template-columns: 40px 1fr 40px; + align-items: center; + gap: 10px; +} + +.calendar__date-picker-month > strong { + color: var(--label); + font-size: 18px; + text-align: center; + text-transform: capitalize; +} + +.calendar__date-picker-month > button { + width: 38px; + height: 38px; + padding: 0; + border-radius: 9999px; + display: grid; + place-items: center; + background: var(--color-ios-dark-glass); + color: var(--accent); + box-shadow: var(--shadow-ios-dark-glass); +} + +.calendar--light .calendar__date-picker-month > button { + background: var(--color-ios-light-glass); + box-shadow: var(--shadow-ios-light-glass); +} + +.calendar__date-picker-weekdays, +.calendar__date-picker-grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + align-items: center; +} + +.calendar__date-picker-weekdays { + height: 28px; + color: var(--muted); + font-size: 11px; + font-weight: 700; + text-align: center; + text-transform: uppercase; +} + +.calendar__date-picker-grid { + gap: 4px 0; +} + +.calendar__date-picker-grid button, +.calendar__date-picker-grid > span { + width: 38px; + height: 38px; + justify-self: center; +} + +.calendar__date-picker-grid button { + padding: 0; + border-radius: 9999px; + display: grid; + place-items: center; + background: transparent; + color: var(--label); + font-size: 14px; + font-variant-numeric: tabular-nums; + font-weight: 650; +} + +.calendar__date-picker-grid button.today { + box-shadow: inset 0 0 0 1px var(--accent); + color: var(--accent); +} + +.calendar__date-picker-grid button.selected { + background: var(--accent); + color: #fff; + box-shadow: none; + font-weight: 800; +} + .calendar__time-preview { height: 64px; display: flex; @@ -2072,7 +2379,7 @@ onMounted(async () => { .calendar-search-enter-from, .calendar-search-leave-to { opacity: 0; - transform: translateY(-4px); + transform: translateY(-58px); } .calendar-picker-enter-active, diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 90cf18f..c18345a 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -1102,7 +1102,7 @@ Locales["en"] = { searchPlaceholder = "Search events", previousMonth = "Previous month", nextMonth = "Next month", eyebrow = "Your time. Clearly planned.", schedule = "Schedule", event = "Event", appointment = "Appointment", newEvent = "New event", editEvent = "Edit event", deleteEvent = "Delete event", details = "Event details", - title = "Title", titlePlaceholder = "What is planned?", date = "Date", starts = "Starts", ends = "Ends", + title = "Title", titlePlaceholder = "What is planned?", date = "Date", allDay = "All-day", starts = "Starts", ends = "Ends", reminder = "Reminder", reminderNotification = "Upcoming: {title}", note = "Note", notePlaceholder = "Add details, an address or anything to remember...", noEvents = "Nothing planned", noEventsBody = "This day is still free. Add an event whenever you are ready.",