diff --git a/frontend/public/img/maps/cayo-perico.svg b/frontend/public/img/maps/cayo-perico.svg new file mode 100644 index 0000000..6826fa2 --- /dev/null +++ b/frontend/public/img/maps/cayo-perico.svg @@ -0,0 +1,308 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/img/maps/gtav-map.svg b/frontend/public/img/maps/gtav-map.svg new file mode 100644 index 0000000..8d3906b --- /dev/null +++ b/frontend/public/img/maps/gtav-map.svg @@ -0,0 +1,3448 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/img/maps/map_atlas_4096.webp b/frontend/public/img/maps/map_atlas_4096.webp new file mode 100644 index 0000000..696b0be Binary files /dev/null and b/frontend/public/img/maps/map_atlas_4096.webp differ diff --git a/frontend/public/img/maps/map_roads_4096.webp b/frontend/public/img/maps/map_roads_4096.webp new file mode 100644 index 0000000..2211bf0 Binary files /dev/null and b/frontend/public/img/maps/map_roads_4096.webp differ diff --git a/frontend/public/img/maps/map_satellite_4096.webp b/frontend/public/img/maps/map_satellite_4096.webp new file mode 100644 index 0000000..219b57c Binary files /dev/null and b/frontend/public/img/maps/map_satellite_4096.webp differ diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e7d9b75..6d711d9 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -13,6 +13,7 @@ import { useRoute, useRouter } from 'vue-router' import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue' import PhoneLockScreen from '@/components/PhoneLockScreen.vue' import PhoneNotifications from '@/components/PhoneNotifications.vue' +import NotificationPhonePreview from '@/components/NotificationPhonePreview.vue' import PhoneStatusBar from '@/components/PhoneStatusBar.vue' import { PHONE_FRAME_IMAGES } from '@/config/appearance' import { useClockStore } from '@/stores/clock' @@ -25,13 +26,25 @@ import { type PhoneNotificationInput, } from '@/stores/notifications' import { usePhoneStore, type PhoneOpenPayload } from '@/stores/phone' +import type { PhoneNotificationDevicePayload } from '@/types/device' +import type { MailCounts } from '@/types/mail' import { nuiCall } from '@/utils/nui' import { formatTimer } from '@/utils/clock' +import { parsePhonePreferences } from '@/utils/preferences' import SpringboardView from '@/views/SpringboardView.vue' type AppMessage = { type?: string - data?: PhoneNotificationInput | PhoneOpenPayload + data?: MailEventData | PhoneNotificationInput | PhoneOpenPayload +} + +type MailEventData = { + counts?: MailCounts + device?: PhoneNotificationDevicePayload + sender?: string + subject?: string + text?: string + title?: string } const REFERENCE_VIEWPORT_WIDTH = 1920 @@ -53,12 +66,12 @@ const isLocked = ref(false) const isUnlocking = ref(false) const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)') const viewportScale = ref(getViewportScale()) +const phoneBaseZoom = computed(() => viewportScale.value * PHONE_BASE_SCALE) const phoneResolutionStyle = computed(() => ({ '--phone-edge-gap': `${24 * viewportScale.value}px`, + '--phone-stack-gap': `${16 * viewportScale.value}px`, '--phone-zoom': - viewportScale.value * - PHONE_BASE_SCALE * - (phone.preferences.settings.phoneScale / 100), + phoneBaseZoom.value * (phone.preferences.settings.phoneScale / 100), })) const phoneFrameImage = computed( () => PHONE_FRAME_IMAGES[phone.preferences.settings.frame], @@ -92,6 +105,37 @@ function onMessage(event: MessageEvent): void { phone.close() } else if (event.data?.type === 'notification:show' && event.data.data) { notifications.show(event.data.data as PhoneNotificationInput) + } else if (event.data?.type === 'mail:changed' && event.data.data) { + const data = event.data.data as MailEventData + if (data.counts) mail.setCounts(data.counts) + } else if (event.data?.type === 'mail:new' && event.data.data) { + const data = event.data.data as MailEventData + if ( + data.counts && + (!data.device || data.device.imei === phone.device?.imei) + ) { + mail.setCounts(data.counts) + } + + const notification: PhoneNotificationInput = { + appId: 'mail', + subtitle: data.subject, + text: + data.text ?? + phone.t('Apps.mail.newMessage', { sender: data.sender ?? '' }), + title: data.title ?? phone.t('Apps.mail.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) } } @@ -211,7 +255,11 @@ onBeforeUnmount(() => { - - - - + + + + - - - - - + + + + + + + + + + + - - - - - - - - - - + + + + + + diff --git a/frontend/src/assets/img/app-icons/map.svg b/frontend/src/assets/img/app-icons/map.svg new file mode 100644 index 0000000..1f32700 --- /dev/null +++ b/frontend/src/assets/img/app-icons/map.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 99dcf7a..7223812 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -42,7 +42,7 @@ button { place-items: center; padding: 0; } -.phone-stage--peek .phone-device { +.phone-stage--peek .phone-resolution-wrapper--primary .phone-device { transform: translateY(calc(100% - 190px)); transform-origin: right bottom; } @@ -67,6 +67,23 @@ button { height: 844px; zoom: var(--phone-zoom, 1); } +.phone-device-row { + display: flex; + align-items: flex-end; + justify-content: flex-end; + gap: var(--phone-stack-gap, 16px); +} +.phone-resolution-wrapper--notification { + flex: 0 0 auto; +} +.phone-device--notification { + transform: translateY(calc(100% - 190px)); + transform-origin: right bottom; +} +.notification-phone-background { + position: absolute; + inset: 0; +} .phone-device { position: relative; width: 390px; @@ -115,6 +132,7 @@ button { color-scheme: light; } .phone-notification { + z-index: 85 !important; top: 68px !important; right: 8px !important; left: 8px !important; @@ -399,6 +417,11 @@ button { cursor: pointer; text-shadow: 0 1px 4px #000b; } +.app-icon-anchor { + position: relative; + display: inline-grid; + overflow: visible; +} .app-icon { width: 56px; height: 56px; @@ -411,6 +434,14 @@ button { 0 4px 13px #0004, inset 0 1px 1px #ffffff55; } +.app-icon-badge { + position: absolute; + z-index: 2; + top: -5px; + right: -5px; + text-shadow: none; + box-shadow: 0 1px 3px #0008; +} .app-icon > img { width: 100%; height: 100%; diff --git a/frontend/src/components/AppIcon.vue b/frontend/src/components/AppIcon.vue index 3cb2ec8..781dc26 100644 --- a/frontend/src/components/AppIcon.vue +++ b/frontend/src/components/AppIcon.vue @@ -1,7 +1,9 @@ + + + + + + + + + + + + + + + diff --git a/frontend/src/components/PhoneNotifications.vue b/frontend/src/components/PhoneNotifications.vue index 3a6aa78..4aac0d7 100644 --- a/frontend/src/components/PhoneNotifications.vue +++ b/frontend/src/components/PhoneNotifications.vue @@ -3,28 +3,33 @@ import { kNotification } from 'konsta/vue' import { computed } from 'vue' import { getPhoneApp } from '@/config/apps' -import { useNotificationsStore } from '@/stores/notifications' +import type { PhoneNotification } from '@/stores/notifications' import { usePhoneStore } from '@/stores/phone' -const notifications = useNotificationsStore() +const props = defineProps<{ + notification: PhoneNotification | null +}>() +const emit = defineEmits<{ + close: [] +}>() const phone = usePhoneStore() const icon = computed(() => - notifications.current - ? getPhoneApp(notifications.current.appId)?.iconImage + props.notification + ? getPhoneApp(props.notification.appId)?.iconImage : undefined, ) diff --git a/frontend/src/config/appearance.ts b/frontend/src/config/appearance.ts index 9ebeed0..6ac3132 100644 --- a/frontend/src/config/appearance.ts +++ b/frontend/src/config/appearance.ts @@ -12,3 +12,11 @@ export const PHONE_FRAME_IMAGES: Record = { lavender: lavenderFrame, white: whiteFrame, } + +export const PHONE_FRAME_COLORS: Record = { + black: '#3a3a3c', + blue: '#7294c2', + green: '#889b6e', + lavender: '#aaa1c8', + white: '#f2f2f2', +} diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index 483e629..d13b555 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -8,8 +8,13 @@ describe('app registry', () => { expect(PHONE_APPS.every((app) => app.route === `/apps/${app.id}`)).toBe( true, ) - expect(PHONE_APPS.every((app) => app.iconImage.endsWith('.webp'))).toBe( - true, + expect( + PHONE_APPS.filter((app) => app.id !== 'map').every((app) => + app.iconImage.endsWith('.webp'), + ), + ).toBe(true) + expect(PHONE_APPS.find((app) => app.id === 'map')?.iconImage).toMatch( + /^(data:image\/svg\+xml|.+\.svg$)/, ) expect(PHONE_APPS.find((app) => app.id === 'mail')).toMatchObject({ gridOrder: 3, diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index b539ffc..669599d 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -4,6 +4,7 @@ import { Clock3, Images, Mail, + MapPinned, NotebookPen, Settings, ShoppingBag, @@ -15,12 +16,26 @@ 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 mailIcon from '@/assets/img/app-icons/mail.webp' +import mapIcon from '@/assets/img/app-icons/map.svg' import notesIcon from '@/assets/img/app-icons/notes.webp' import photosIcon from '@/assets/img/app-icons/gallery.webp' import settingsIcon from '@/assets/img/app-icons/settings.webp' import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps' export const PHONE_APPS: PhoneAppDefinition[] = [ + { + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/MapApp.vue')), + ), + dockOrder: null, + gridOrder: 8, + icon: markRaw(MapPinned), + iconClass: '', + iconImage: mapIcon, + id: 'map', + labelKey: 'Apps.map.name', + route: '/apps/map', + }, { component: markRaw( defineAsyncComponent(() => import('@/views/apps/MailApp.vue')), diff --git a/frontend/src/features/map/defaultMapGeometry.ts b/frontend/src/features/map/defaultMapGeometry.ts new file mode 100644 index 0000000..4ef4b95 --- /dev/null +++ b/frontend/src/features/map/defaultMapGeometry.ts @@ -0,0 +1,79 @@ +export type MapPoint = { x: number; y: number } + +export const defaultMainlandCoordinates = { + minX: -4015.07959, + minY: -4146.092, + width: 8837.37159, + height: 12432.11, + yFlipOffset: 4139.926, +} + +const cayoMapCoordinates = { + centerX: 4704.5, + centerY: -5139.09, + width: 1990.65, + height: 1994.4, +} + +const cayoTerritoryBounds = { + maxX: 7542.07, + minY: -7170.07, +} + +export const defaultMapCoordinates = { + minX: defaultMainlandCoordinates.minX, + minY: defaultMainlandCoordinates.minY, + width: cayoTerritoryBounds.maxX - defaultMainlandCoordinates.minX, + height: + defaultMainlandCoordinates.yFlipOffset - + cayoTerritoryBounds.minY - + defaultMainlandCoordinates.minY, + yFlipOffset: defaultMainlandCoordinates.yFlipOffset, +} + +const toDefaultMapLayerStyle = (bounds: { + minX: number + minY: number + width: number + height: number +}) => ({ + left: `${((bounds.minX - defaultMapCoordinates.minX) / defaultMapCoordinates.width) * 100}%`, + top: `${((bounds.minY - defaultMapCoordinates.minY) / defaultMapCoordinates.height) * 100}%`, + width: `${(bounds.width / defaultMapCoordinates.width) * 100}%`, + height: `${(bounds.height / defaultMapCoordinates.height) * 100}%`, +}) + +export const defaultMainlandStyle = toDefaultMapLayerStyle( + defaultMainlandCoordinates, +) +export const defaultCayoStyle = toDefaultMapLayerStyle({ + minX: cayoMapCoordinates.centerX - cayoMapCoordinates.width / 2, + minY: + defaultMapCoordinates.yFlipOffset - + (cayoMapCoordinates.centerY + cayoMapCoordinates.height / 2), + width: cayoMapCoordinates.width, + height: cayoMapCoordinates.height, +}) + +export const clampDefaultMapPoint = (point: MapPoint): MapPoint => ({ + x: Math.max( + defaultMapCoordinates.minX, + Math.min(defaultMapCoordinates.minX + defaultMapCoordinates.width, point.x), + ), + y: Math.max( + defaultMapCoordinates.yFlipOffset - + defaultMapCoordinates.minY - + defaultMapCoordinates.height, + Math.min( + defaultMapCoordinates.yFlipOffset - defaultMapCoordinates.minY, + point.y, + ), + ), +}) + +export const defaultMapWorldToPercent = (point: MapPoint): MapPoint => ({ + x: (point.x - defaultMapCoordinates.minX) / defaultMapCoordinates.width, + y: + (defaultMapCoordinates.yFlipOffset - point.y - defaultMapCoordinates.minY) / + defaultMapCoordinates.height, +}) diff --git a/frontend/src/stores/notifications.test.ts b/frontend/src/stores/notifications.test.ts new file mode 100644 index 0000000..d0f3b82 --- /dev/null +++ b/frontend/src/stores/notifications.test.ts @@ -0,0 +1,107 @@ +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + useNotificationsStore, + type PhoneNotificationDevice, +} from '@/stores/notifications' +import { + DEFAULT_PHONE_PREFERENCES, + type PhonePreferencesV1, +} from '@/utils/preferences' + +vi.mock('@/utils/tones', () => ({ + playPhoneTone: vi.fn(() => vi.fn()), +})) +vi.mock('@/utils/nui', () => ({ + nuiCall: vi.fn(), +})) + +function device( + imei: string, + configure?: (preferences: PhonePreferencesV1) => void, +): PhoneNotificationDevice { + const preferences = structuredClone(DEFAULT_PHONE_PREFERENCES) + configure?.(preferences) + return { imei, name: `Phone ${imei}`, preferences } +} + +describe('notifications store', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('window', { + matchMedia: vi.fn(() => ({ matches: false })), + }) + setActivePinia(createPinia()) + }) + + afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('shows one simultaneous preview per notifying phone', () => { + const notifications = useNotificationsStore() + + notifications.show({ + appId: 'mail', + device: device('111'), + text: 'First phone', + title: 'Mail', + }) + notifications.show({ + appId: 'mail', + device: device('222'), + text: 'Second phone', + title: 'Mail', + }) + + expect( + notifications.devicePreviews.map( + (notification) => notification.device?.imei, + ), + ).toEqual(['111', '222']) + }) + + it('queues mail independently for each phone', () => { + const notifications = useNotificationsStore() + const target = device('111') + const firstId = notifications.show({ + appId: 'mail', + device: target, + text: 'First message', + title: 'Mail', + }) + notifications.show({ + appId: 'mail', + device: target, + text: 'Second message', + title: 'Mail', + }) + + expect(notifications.devicePreviews).toHaveLength(1) + expect(notifications.devicePreviews[0].text).toBe('First message') + + notifications.dismiss(firstId!) + + expect(notifications.devicePreviews[0].text).toBe('Second message') + }) + + it('uses the target phone notification preferences', () => { + const notifications = useNotificationsStore() + const muted = device('111', (preferences) => { + preferences.settings.notifications.mail.enabled = false + }) + + const id = notifications.show({ + appId: 'mail', + device: muted, + text: 'Hidden message', + title: 'Mail', + }) + + expect(id).toBeNull() + expect(notifications.devicePreviews).toEqual([]) + }) +}) diff --git a/frontend/src/stores/notifications.ts b/frontend/src/stores/notifications.ts index 5a3126a..e63a7bd 100644 --- a/frontend/src/stores/notifications.ts +++ b/frontend/src/stores/notifications.ts @@ -3,11 +3,19 @@ import { defineStore } from 'pinia' import { usePhoneStore } from '@/stores/phone' import type { PhoneAppId } from '@/types/apps' +import type { PhonePreferencesV1 } from '@/utils/preferences' import { playPhoneTone, type PhoneToneId } from '@/utils/tones' +export type PhoneNotificationDevice = { + imei: string + name: string + preferences: PhonePreferencesV1 +} + export type PhoneNotificationInput = { appId: PhoneAppId critical?: boolean + device?: PhoneNotificationDevice persistent?: boolean sound?: PhoneToneId subtitle?: string @@ -25,21 +33,34 @@ const stopToneHandles = new Map void>() export const useNotificationsStore = defineStore('notifications', () => { const phone = usePhoneStore() const queue = ref([]) + const deviceQueue = ref([]) const current = computed(() => queue.value[0] ?? null) + const devicePreviews = computed(() => { + const devices = new Set() + return deviceQueue.value.filter((notification) => { + const imei = notification.device?.imei + if (!imei || devices.has(imei)) return false + devices.add(imei) + return true + }) + }) const isPeeking = computed(() => !!current.value && !phone.isOpen) const requiresAttention = computed( - () => !!current.value?.persistent && !phone.isOpen, + () => + !phone.isOpen && + (!!current.value?.persistent || + devicePreviews.value.some((notification) => notification.persistent)), ) function activate(notification: PhoneNotification): void { + const preferences = notification.device?.preferences ?? phone.preferences const appPreferences = - phone.preferences.settings.notifications[notification.appId] + preferences.settings.notifications[notification.appId] if (appPreferences.sounds || notification.critical) { - const sound = - notification.sound ?? phone.preferences.settings.notificationSound + const sound = notification.sound ?? preferences.settings.notificationSound const volume = notification.critical - ? phone.preferences.settings.ringtoneVolume - : phone.preferences.settings.notificationVolume + ? preferences.settings.ringtoneVolume + : preferences.settings.notificationVolume stopToneHandles.set( notification.id, playPhoneTone(sound, volume, !!notification.persistent), @@ -51,23 +72,44 @@ export const useNotificationsStore = defineStore('notifications', () => { notification.id, setTimeout( () => dismiss(notification.id), - phone.preferences.settings.notificationDurationSeconds * 1000, + preferences.settings.notificationDurationSeconds * 1000, ), ) } } function dismiss(id: string): void { - const index = queue.value.findIndex((notification) => notification.id === id) - if (index < 0) return - const wasCurrent = index === 0 + const index = queue.value.findIndex( + (notification) => notification.id === id, + ) + const deviceIndex = deviceQueue.value.findIndex( + (notification) => notification.id === id, + ) + if (index < 0 && deviceIndex < 0) return const timeout = timeoutHandles.get(id) if (timeout) clearTimeout(timeout) timeoutHandles.delete(id) stopToneHandles.get(id)?.() stopToneHandles.delete(id) - queue.value.splice(index, 1) - if (wasCurrent && current.value) activate(current.value) + + if (index >= 0) { + const wasCurrent = index === 0 + queue.value.splice(index, 1) + if (wasCurrent && current.value) activate(current.value) + return + } + + const imei = deviceQueue.value[deviceIndex].device?.imei + const wasDeviceCurrent = !deviceQueue.value + .slice(0, deviceIndex) + .some((notification) => notification.device?.imei === imei) + deviceQueue.value.splice(deviceIndex, 1) + if (wasDeviceCurrent) { + const next = deviceQueue.value.find( + (notification) => notification.device?.imei === imei, + ) + if (next) activate(next) + } } function dismissCurrent(): void { @@ -75,7 +117,8 @@ export const useNotificationsStore = defineStore('notifications', () => { } function show(input: PhoneNotificationInput): string | null { - const appPreferences = phone.preferences.settings.notifications[input.appId] + const preferences = input.device?.preferences ?? phone.preferences + const appPreferences = preferences.settings.notifications[input.appId] if (!appPreferences) { console.error(`[Phone notifications] Unknown app: ${input.appId}`) return null @@ -84,19 +127,33 @@ export const useNotificationsStore = defineStore('notifications', () => { return null } if (input.critical) { - for (const notification of [...queue.value]) dismiss(notification.id) + const pending = input.device + ? deviceQueue.value.filter( + (notification) => notification.device?.imei === input.device?.imei, + ) + : queue.value + for (const notification of [...pending]) dismiss(notification.id) } const notification: PhoneNotification = { ...input, id: `notification-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, } - queue.value.push(notification) - if (queue.value.length === 1) activate(notification) + if (notification.device) { + const isFirstForDevice = !deviceQueue.value.some( + (pending) => pending.device?.imei === notification.device?.imei, + ) + deviceQueue.value.push(notification) + if (isFirstForDevice) activate(notification) + } else { + queue.value.push(notification) + if (queue.value.length === 1) activate(notification) + } return notification.id } return { current, + devicePreviews, dismiss, dismissCurrent, isPeeking, diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index aeebac4..a4443a3 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -67,6 +67,19 @@ const defaultLocales: LocaleTree = { }, }, calculator: { name: 'Calculator' }, + map: { + name: 'Map', + controls: 'Map controls', + currentLocation: 'Current Location', + imageError: 'The map image could not be loaded.', + switchStyle: 'Switch Map Type', + styles: { + default: 'Default Map', + satellite: 'Satellite Map', + atlas: 'Atlas Map', + roads: 'Road Map', + }, + }, camera: { name: 'Camera', shutter: 'Take photo', diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index a199781..c628693 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -5,6 +5,7 @@ export type PhoneAppId = | 'camera' | 'clock' | 'mail' + | 'map' | 'notes' | 'photos' | 'app-store' diff --git a/frontend/src/types/device.ts b/frontend/src/types/device.ts index d9f8113..ba5246b 100644 --- a/frontend/src/types/device.ts +++ b/frontend/src/types/device.ts @@ -11,6 +11,12 @@ export type PhoneDevice = { name: string } +export type PhoneNotificationDevicePayload = { + imei: string + name: string + settings?: string | null +} + export type AccountDevice = { created_at: string current: boolean diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index bc0cf68..216d0a1 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -52,6 +52,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< camera: { enabled: true, sounds: true }, clock: { enabled: true, sounds: true }, mail: { enabled: true, sounds: true }, + map: { enabled: true, sounds: true }, notes: { enabled: true, sounds: true }, photos: { enabled: true, sounds: true }, settings: { enabled: true, sounds: true }, diff --git a/frontend/src/views/apps/MailApp.vue b/frontend/src/views/apps/MailApp.vue index fd72342..ac825d9 100644 --- a/frontend/src/views/apps/MailApp.vue +++ b/frontend/src/views/apps/MailApp.vue @@ -31,7 +31,6 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { IFRUIT_AUTH_INPUT_COLORS } from '@/config/ifruit' import { useMailStore } from '@/stores/mail' -import { useNotificationsStore } from '@/stores/notifications' import { usePhoneStore } from '@/stores/phone' import type { MailComposeDraft, @@ -51,15 +50,12 @@ type MailScreen = 'folders' | 'list' | 'message' | 'compose' type MailEvent = { data?: { counts?: MailCounts - sender?: string - subject?: string } - type?: 'mail:changed' | 'mail:new' + type?: 'mail:changed' } const phone = usePhoneStore() const mail = useMailStore() -const notifications = useNotificationsStore() const authMode = ref('login') const authEmail = ref('') const authPassword = ref('') @@ -335,18 +331,6 @@ function onMailEvent(event: MessageEvent): void { if (screen.value === 'list') { void mail.loadFolder(mail.folder, mail.search) } - return - } - - if (event.data.type === 'mail:new' && event.data.data?.sender) { - notifications.show({ - appId: 'mail', - subtitle: event.data.data.subject || phone.t('Apps.mail.untitled'), - text: phone.t('Apps.mail.newMessage', { - sender: event.data.data.sender, - }), - title: phone.t('Apps.mail.name'), - }) } } diff --git a/frontend/src/views/apps/MapApp.vue b/frontend/src/views/apps/MapApp.vue new file mode 100644 index 0000000..f2a2fff --- /dev/null +++ b/frontend/src/views/apps/MapApp.vue @@ -0,0 +1,451 @@ + + + + + + + + + + + + + + + + {{ phone.t('Apps.map.imageError') }} + + + + + + + + + + + + + + + diff --git a/frontend/src/views/apps/SettingsApp.vue b/frontend/src/views/apps/SettingsApp.vue index d063097..2001d10 100644 --- a/frontend/src/views/apps/SettingsApp.vue +++ b/frontend/src/views/apps/SettingsApp.vue @@ -13,6 +13,7 @@ import { kNavbar, kNavbarBackLink, kPage, + kPopover, kPreloader, kRange, kSearchbar, @@ -41,7 +42,7 @@ import { type ComponentPublicInstance, } from 'vue' -import { PHONE_FRAME_IMAGES } from '@/config/appearance' +import { PHONE_FRAME_COLORS } from '@/config/appearance' import { PHONE_APPS } from '@/config/apps' import { IFRUIT_AUTH_INPUT_COLORS } from '@/config/ifruit' import { usePhoneStore } from '@/stores/phone' @@ -83,6 +84,11 @@ const query = ref('') const activeView = ref('root') const selectedNotificationAppId = ref('calculator') const settingsPage = ref(null) +const framePickerButton = ref(null) +const framePickerOpened = ref(false) +const framePickerTarget = computed( + () => framePickerButton.value?.$el as HTMLElement | undefined, +) const accountMode = ref<'login' | 'register'>('login') const accountEmail = ref('') const accountPassword = ref('') @@ -99,18 +105,21 @@ const factoryResetDashOffset = computed( () => FACTORY_RESET_CIRCUMFERENCE * (1 - factoryResetProgress.value / 100), ) +const selectedFrameColor = computed( + () => PHONE_FRAME_COLORS[phone.preferences.settings.frame], +) let factoryResetAnimationFrame: number | undefined const toggleRows = [ { key: 'airplaneMode' as const, icon: Plane, - iconClass: 'bg-linear-to-br from-orange-400 to-orange-500', + iconColor: '#ff9500', }, { key: 'streamerMode' as const, icon: EyeOff, - iconClass: 'bg-linear-to-br from-purple-400 to-purple-500', + iconColor: '#af52de', }, ] const serviceRows = [ @@ -118,13 +127,13 @@ const serviceRows = [ key: 'notifications', view: 'notifications' as const, icon: BellRing, - iconClass: 'bg-linear-to-br from-red-400 to-red-500', + iconColor: '#ff3b30', }, { key: 'sounds', view: 'sounds' as const, icon: Volume2, - iconClass: 'bg-linear-to-br from-rose-400 to-rose-500', + iconColor: '#ff2d55', }, ] const preferenceRows = [ @@ -132,19 +141,19 @@ const preferenceRows = [ key: 'general', view: 'general' as const, icon: Settings, - iconClass: 'bg-linear-to-br from-slate-400 to-slate-500', + iconColor: '#8e8e93', }, { key: 'appearance', view: 'appearance' as const, icon: Sun, - iconClass: 'bg-linear-to-br from-blue-400 to-blue-500', + iconColor: '#007aff', }, { key: 'wallpaper', view: 'wallpaper' as const, icon: Monitor, - iconClass: 'bg-linear-to-br from-sky-400 to-sky-500', + iconColor: '#32ade6', }, ] @@ -204,6 +213,7 @@ function openNotificationApp(app: PhoneAppDefinition): void { } function goBack(): void { + framePickerOpened.value = false activeView.value = activeView.value === 'notification-detail' ? 'notifications' : 'root' scrollPageToTop() @@ -240,6 +250,68 @@ function selectAppearanceMode(mode: AppearanceMode): void { function selectFrame(frame: PhoneFrameId): void { phone.setPreference('frame', frame) + framePickerOpened.value = false +} + +async function openFramePicker(): Promise { + const target = framePickerTarget.value + const screen = target?.closest('.phone-screen') + const wrapper = target?.closest('.phone-resolution-wrapper') + const popover = document.querySelector( + '.settings-frame-popover', + ) + if (!target || !screen || !wrapper || !popover) { + console.error('Unable to position the Settings frame color picker') + return + } + + const framePickerScale = + wrapper.getBoundingClientRect().width / wrapper.offsetWidth + popover.style.width = `${240 * framePickerScale}px` + popover.style.marginLeft = '0px' + popover.style.marginTop = '0px' + + const pickerGrid = popover.querySelector('[role="group"]') + if (pickerGrid) { + pickerGrid.style.gap = `${20 * framePickerScale}px` + pickerGrid.style.padding = `${20 * framePickerScale}px` + pickerGrid.querySelectorAll('button').forEach((button) => { + button.style.width = `${40 * framePickerScale}px` + button.style.height = `${40 * framePickerScale}px` + }) + } + + framePickerOpened.value = true + await nextTick() + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + + const screenRect = screen.getBoundingClientRect() + const targetRect = target.getBoundingClientRect() + const popoverRect = popover.getBoundingClientRect() + const inset = 8 * framePickerScale + const gap = 8 * framePickerScale + const minimumLeft = screenRect.left + inset + const maximumLeft = screenRect.right - popoverRect.width - inset + const desiredLeft = + targetRect.left + targetRect.width / 2 - popoverRect.width / 2 + const aboveTop = targetRect.top - popoverRect.height - gap + const desiredTop = + aboveTop >= screenRect.top + inset ? aboveTop : targetRect.bottom + gap + + const clampedLeft = Math.max( + minimumLeft, + Math.min(desiredLeft, maximumLeft), + ) + const clampedTop = Math.max( + screenRect.top + inset, + Math.min( + desiredTop, + screenRect.bottom - popoverRect.height - inset, + ), + ) + + popover.style.marginLeft = `${clampedLeft - popoverRect.left}px` + popover.style.marginTop = `${clampedTop - popoverRect.top}px` } function selectRingtone(ringtone: RingtoneId): void { @@ -350,7 +422,10 @@ onBeforeUnmount(() => { - + { > @@ -421,10 +494,8 @@ onBeforeUnmount(() => { > @@ -444,10 +515,8 @@ onBeforeUnmount(() => { > @@ -895,29 +964,49 @@ onBeforeUnmount(() => { {{ phone.t('Apps.settings.phoneFrame') }} - - - - + + + + + + + + diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 5f0deda..89424ff 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -94,6 +94,13 @@ function counts() { app.post('/api/:endpoint', (request, response) => { console.log(`[NUI] ${request.params.endpoint}`, request.body) const endpoint = request.params.endpoint + if (endpoint === 'map:getPlayerCoords') { + response.json({ + success: true, + data: { coords: { x: -75.2, y: -818.9, z: 326.2 } }, + }) + return + } if (endpoint === 'account:login' || endpoint === 'account:register') { authenticated = true linkedAccount = { diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 1c91993..5294536 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -82,6 +82,11 @@ Locales["en"] = { default = "The mail request failed.", }, }, + map = { + name = "Map", controls = "Map controls", currentLocation = "Current Location", + imageError = "The map image could not be loaded.", switchStyle = "Switch Map Type", + styles = { default = "Default Map", satellite = "Satellite Map", atlas = "Atlas Map", roads = "Road Map" }, + }, notes = { name = "Notes", note = "Note", back = "Back", actions = "Note actions", newNote = "New Note", searchPlaceholder = "Search Notes", diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index 50cdb9c..dbf6ecd 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -31,6 +31,7 @@ server_scripts { files { 'source/html/index.html', 'source/html/assets/**', + 'source/html/img/**', } ui_page 'source/html/index.html' diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 14da1cf..495ed9b 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -102,6 +102,20 @@ RegisterNUICallback("notification:focus", function(data, cb) cb({ success = true }) end) +RegisterNUICallback("map:getPlayerCoords", function(_, cb) + local coords = GetEntityCoords(PlayerPedId()) + cb({ + success = true, + data = { + coords = { + x = coords.x, + y = coords.y, + z = coords.z + } + } + }) +end) + for _, callback_name in ipairs(server_callbacks) do RegisterNUICallback(callback_name, function(data, cb) local result = Sky.Cb.Trigger("sky_phone:" .. callback_name, data) @@ -152,6 +166,9 @@ RegisterNetEvent("sky_phone:mail:changed", function(data) end) RegisterNetEvent("sky_phone:mail:new", function(data) + local mail_locale = get_locale().Nui.Apps.mail + data.title = mail_locale.name + data.text = mail_locale.newMessage:gsub("{sender}", tostring(data.sender)) SendNUIMessage({ type = "mail:new", data = data }) end) diff --git a/sky_phone/source/html/index.html b/sky_phone/source/html/index.html index 38e3d02..ca7fb47 100644 --- a/sky_phone/source/html/index.html +++ b/sky_phone/source/html/index.html @@ -4,8 +4,8 @@ Sky Phone - - + + diff --git a/sky_phone/source/server/mail.lua b/sky_phone/source/server/mail.lua index f9235bc..5b8975c 100644 --- a/sky_phone/source/server/mail.lua +++ b/sky_phone/source/server/mail.lua @@ -135,9 +135,9 @@ local function get_counts(account_id) } end -local function broadcast_mailbox_changed(account_id) +local function broadcast_mailbox_changed(account_id, counts) notify_account(account_id, "sky_phone:mail:changed", { - counts = get_counts(account_id), + counts = counts or get_counts(account_id), }) end @@ -450,11 +450,13 @@ Sky.Cb.Register("sky_phone:mail:send", function(source, data) broadcast_mailbox_changed(session.id) for _, account in ipairs(recipient_accounts) do - notify_account(account.id, "sky_phone:mail:new", { + local counts = get_counts(account.id) + SkyPhone.NotifyAccountDevices(account.id, "sky_phone:mail:new", { + counts = counts, sender = session.email, subject = subject, }) - broadcast_mailbox_changed(account.id) + broadcast_mailbox_changed(account.id, counts) end return { success = true, data = { id = message_id } } diff --git a/sky_phone/source/server/phone.lua b/sky_phone/source/server/phone.lua index 8f056e5..a2a2a35 100644 --- a/sky_phone/source/server/phone.lua +++ b/sky_phone/source/server/phone.lua @@ -464,6 +464,42 @@ function SkyPhone.NotifyAccount(account_id, event_name, data) end end +function SkyPhone.NotifyAccountDevices(account_id, event_name, data) + local rows = Sky.Query([[ + SELECT d.`imei`, d.`device_name`, settings.`payload` AS `settings` + FROM `sky_phone_devices` d + LEFT JOIN `sky_phone_device_data` settings + ON settings.`device_imei` = d.`imei` AND settings.`namespace` = 'settings' + WHERE d.`account_id` = ? + ]], { account_id }) + local devices = {} + for _, row in ipairs(rows) do + devices[row.imei] = row + end + + for _, player_source in ipairs(Sky.FW.GetPlayers()) do + local source = tonumber(player_source) or player_source + local notified_devices = {} + for _, item in ipairs(Sky.FW.GetInventorySlotsWithItem(source, Config.Phone.Item)) do + local imei = item.metadata and item.metadata.imei + local device = imei and devices[imei] + if device and not notified_devices[imei] then + local payload = {} + for key, value in pairs(data) do + payload[key] = value + end + payload.device = { + imei = device.imei, + name = device.device_name, + settings = device.settings, + } + notified_devices[imei] = true + TriggerClientEvent(event_name, source, payload) + end + end + end +end + function SkyPhone.RefreshAccount(account_id) for source in pairs(sessions) do local account = SkyPhone.RequireAccount(source)
+ {{ phone.t('Apps.map.imageError') }} +