diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ee2e055..9daa55c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -46,7 +46,13 @@ import SpringboardView from '@/views/SpringboardView.vue' type AppMessage = { type?: string - data?: MailEventData | MarketplaceEventData | PhoneCall | PhoneNotificationInput | PhoneOpenPayload + data?: + | CalendarReminderData + | MailEventData + | MarketplaceEventData + | PhoneCall + | PhoneNotificationInput + | PhoneOpenPayload } type SimPickerPayload = { @@ -73,6 +79,15 @@ type MarketplaceEventData = { title?: string } +type CalendarReminderData = { + device?: PhoneNotificationDevicePayload + eventId?: string + eventTitle?: string + startsAt?: number + text?: string + title?: string +} + const REFERENCE_VIEWPORT_WIDTH = 1920 const REFERENCE_VIEWPORT_HEIGHT = 1080 const PHONE_BASE_SCALE = 0.69 @@ -195,6 +210,30 @@ function onMessage(event: MessageEvent): void { } notifications.show(notification) void marketplace.loadCounts() + } else if (event.data?.type === 'calendar:reminder' && event.data.data) { + const data = event.data.data as CalendarReminderData + const startsAt = Number(data.startsAt) || Date.now() + const notification: PhoneNotificationInput = { + appId: 'calendar', + subtitle: new Intl.DateTimeFormat(phone.lang, { + hour: '2-digit', + minute: '2-digit', + }).format(startsAt), + text: + data.text ?? + phone.t('Apps.calendar.reminderNotification', { + title: data.eventTitle ?? '', + }), + title: data.title ?? phone.t('Apps.calendar.name'), + } + if (data.device && (!phone.isOpen || data.device.imei !== phone.device?.imei)) { + notification.device = { + imei: data.device.imei, + name: data.device.name, + preferences: parsePhonePreferences(data.device.settings ?? null), + } + } + notifications.show(notification) } else if (event.data?.type === 'contacts:changed') { void calls.loadContacts() } else if (event.data?.type === 'calls:changed') { diff --git a/frontend/src/assets/img/app-icons/calendar.svg b/frontend/src/assets/img/app-icons/calendar.svg new file mode 100644 index 0000000..6c34aa8 --- /dev/null +++ b/frontend/src/assets/img/app-icons/calendar.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index 5b5b963..a5fd6bd 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -10,9 +10,11 @@ describe('app registry', () => { (app) => app.route === null || app.route === `/apps/${app.id}`, ), ).toBe(true) - expect(PHONE_APPS.every((app) => app.iconImage.endsWith('.webp'))).toBe( - true, - ) + expect( + PHONE_APPS.every( + (app) => typeof app.iconImage === 'string' && app.iconImage.length > 0, + ), + ).toBe(true) expect(PHONE_APPS.find((app) => app.id === 'phone')).toMatchObject({ dockOrder: 0, labelKey: 'Apps.phone.name', @@ -28,6 +30,11 @@ describe('app registry', () => { labelKey: 'Apps.weather.name', route: '/apps/weather', }) + expect(PHONE_APPS.find((app) => app.id === 'calendar')).toMatchObject({ + gridOrder: 20, + labelKey: 'Apps.calendar.name', + route: '/apps/calendar', + }) expect(PHONE_APPS.find((app) => app.id === 'snake')).toMatchObject({ dockOrder: null, gridOrder: 11, diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index b1af4d2..9377063 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -3,6 +3,7 @@ import { Bomb, Blocks, Camera, + CalendarDays, Clock3, Gamepad2, Grid2X2, @@ -26,6 +27,7 @@ import appStoreIcon from '@/assets/img/app-icons/apps.webp' import calculatorIcon from '@/assets/img/app-icons/calculator.webp' import cameraIcon from '@/assets/img/app-icons/camera.webp' import clockIcon from '@/assets/img/app-icons/clock.webp' +import calendarIcon from '@/assets/img/app-icons/calendar.svg' import mailIcon from '@/assets/img/app-icons/mail.webp' import mapIcon from '@/assets/img/app-icons/map.webp' import notesIcon from '@/assets/img/app-icons/notes.webp' @@ -49,6 +51,19 @@ import type { } from '@/types/apps' export const PHONE_APPS: PhoneAppDefinition[] = [ + { + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/CalendarApp.vue')), + ), + dockOrder: null, + gridOrder: 20, + icon: markRaw(CalendarDays), + iconClass: 'app-icon--calendar', + iconImage: calendarIcon, + id: 'calendar', + labelKey: 'Apps.calendar.name', + route: '/apps/calendar', + }, { component: markRaw( defineAsyncComponent(() => import('@/views/apps/LocalPagesApp.vue')), diff --git a/frontend/src/stores/calendar.test.ts b/frontend/src/stores/calendar.test.ts new file mode 100644 index 0000000..f46de96 --- /dev/null +++ b/frontend/src/stores/calendar.test.ts @@ -0,0 +1,61 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useCalendarStore } from '@/stores/calendar' +import { nuiCall } from '@/utils/nui' + +vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() })) +const mockNuiCall = vi.mocked(nuiCall) + +describe('calendar store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + mockNuiCall.mockReset() + }) + + it('loads a bounded calendar range', async () => { + mockNuiCall.mockResolvedValueOnce({ data: [], success: true }) + const calendar = useCalendarStore() + + expect(await calendar.load(1_000_000, 2_000_000)).toBe(true) + expect(mockNuiCall).toHaveBeenCalledWith('calendar:list', { + endsAt: 2000, + startsAt: 1000, + }) + }) + + it('sends server timestamps and revisions when updating', async () => { + mockNuiCall.mockResolvedValueOnce({ success: true }) + const calendar = useCalendarStore() + + await calendar.update( + { + endsAt: 0, + id: 'event-id', + note: '', + remindedAt: null, + reminderMinutes: null, + revision: 4, + startsAt: 0, + title: '', + }, + { + endsAt: 2_000_000, + note: 'Bring documents', + reminderMinutes: 60, + startsAt: 1_000_000, + title: 'Meeting', + }, + ) + + expect(mockNuiCall).toHaveBeenCalledWith('calendar:update', { + endsAt: 2000, + id: 'event-id', + note: 'Bring documents', + reminderMinutes: 60, + revision: 4, + startsAt: 1000, + title: 'Meeting', + }) + }) +}) diff --git a/frontend/src/stores/calendar.ts b/frontend/src/stores/calendar.ts new file mode 100644 index 0000000..682b058 --- /dev/null +++ b/frontend/src/stores/calendar.ts @@ -0,0 +1,63 @@ +import { defineStore } from 'pinia' + +import type { CalendarEvent, CalendarEventDraft } from '@/types/calendar' +import { nuiCall } from '@/utils/nui' + +function toServerDraft(draft: CalendarEventDraft): Record { + return { + endsAt: Math.floor(draft.endsAt / 1000), + note: draft.note, + reminderMinutes: draft.reminderMinutes, + startsAt: Math.floor(draft.startsAt / 1000), + title: draft.title, + } +} + +export const useCalendarStore = defineStore('calendar', { + state: () => ({ + error: '' as string, + events: [] as CalendarEvent[], + loading: false, + }), + actions: { + async create(draft: CalendarEventDraft): Promise { + const response = await nuiCall<{ id: string }>( + 'calendar:create', + toServerDraft(draft), + ) + this.error = response.success ? '' : (response.error ?? 'default') + return response.success + }, + async deleteEvent(id: string): Promise { + const response = await nuiCall('calendar:delete', { id }) + this.error = response.success ? '' : (response.error ?? 'default') + if (response.success) { + this.events = this.events.filter((event) => event.id !== id) + } + return response.success + }, + async load(startsAt: number, endsAt: number): Promise { + this.loading = true + const response = await nuiCall('calendar:list', { + endsAt: Math.floor(endsAt / 1000), + startsAt: Math.floor(startsAt / 1000), + }) + this.loading = false + this.error = response.success ? '' : (response.error ?? 'default') + if (response.success) this.events = response.data ?? [] + return response.success + }, + async update( + event: CalendarEvent, + draft: CalendarEventDraft, + ): Promise { + const response = await nuiCall('calendar:update', { + ...toServerDraft(draft), + id: event.id, + revision: event.revision, + }) + this.error = response.success ? '' : (response.error ?? 'default') + return response.success + }, + }, +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 5d74bad..753f768 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -522,6 +522,47 @@ const defaultLocales: LocaleTree = { upload_timeout: 'The media upload timed out.', }, }, + calendar: { + name: 'Calendar', + 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', + reminder: 'Reminder', + 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.', + signInTitle: 'Your iFruit calendar', + signInBody: 'Sign in to iFruit in Settings to sync appointments and receive reminders.', + reminderNotification: 'Upcoming: {title}', + reminders: { + none: 'No reminder', + atStart: 'At start time', + tenMinutes: '10 minutes before', + thirtyMinutes: '30 minutes before', + oneHour: '1 hour before', + oneDay: '1 day before', + }, + errors: { + invalid_event: 'Enter a title and make sure the end is after the start.', + invalid_range: 'This calendar period is invalid.', + conflict: 'This event changed on another phone. Open it again.', + rate_limited: 'Too many changes. Try again shortly.', + not_authenticated: 'Sign in to your iFruit account first.', + request_failed: 'Calendar is temporarily unavailable.', + default: 'The calendar request failed.', + }, + }, clock: { name: 'Clock', lap: 'Lap', diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index 4058056..fe27ca5 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -5,6 +5,7 @@ export type PhoneAppId = | 'calculator' | 'camera' | 'clock' + | 'calendar' | 'weather' | 'mail' | 'map' diff --git a/frontend/src/types/calendar.ts b/frontend/src/types/calendar.ts new file mode 100644 index 0000000..957b259 --- /dev/null +++ b/frontend/src/types/calendar.ts @@ -0,0 +1,18 @@ +export type CalendarEvent = { + endsAt: number + id: string + note: string + remindedAt: number | null + reminderMinutes: number | null + revision: number + startsAt: number + title: string +} + +export type CalendarEventDraft = { + endsAt: number + note: string + reminderMinutes: number | null + startsAt: number + title: string +} diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 8194514..b68589f 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -61,6 +61,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< 'local-pages': { enabled: true, sounds: true }, camera: { enabled: true, sounds: true }, clock: { enabled: true, sounds: true }, + calendar: { enabled: true, sounds: true }, weather: { enabled: true, sounds: true }, mail: { enabled: true, sounds: true }, map: { enabled: true, sounds: true }, diff --git a/frontend/src/views/apps/CalendarApp.vue b/frontend/src/views/apps/CalendarApp.vue new file mode 100644 index 0000000..7688abc --- /dev/null +++ b/frontend/src/views/apps/CalendarApp.vue @@ -0,0 +1,383 @@ + + +