mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
ADD - map app
This commit is contained in:
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 175 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 4.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 921 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1011 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
+109
-42
@@ -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<CSSProperties>(() => ({
|
||||
'--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<AppMessage>): 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(() => {
|
||||
<template>
|
||||
<Transition name="phone-lift" appear>
|
||||
<main
|
||||
v-if="phone.isOpen || notifications.current"
|
||||
v-if="
|
||||
phone.isOpen ||
|
||||
notifications.current ||
|
||||
notifications.devicePreviews.length
|
||||
"
|
||||
class="phone-stage"
|
||||
:class="{
|
||||
'phone-stage--dev': isDevelopment,
|
||||
@@ -219,45 +267,64 @@ onBeforeUnmount(() => {
|
||||
}"
|
||||
:style="phoneResolutionStyle"
|
||||
>
|
||||
<div class="phone-resolution-wrapper">
|
||||
<section class="phone-device" :aria-label="phone.t('Common.phone')">
|
||||
<div
|
||||
class="phone-screen"
|
||||
:class="{ 'phone-screen--app': isAppRoute }"
|
||||
>
|
||||
<k-app
|
||||
theme="ios"
|
||||
:dark="phone.isDarkMode"
|
||||
safe-areas
|
||||
class="phone-app"
|
||||
:class="{
|
||||
dark: phone.isDarkMode,
|
||||
'phone-app--light': !phone.isDarkMode,
|
||||
'phone-app--unlocking': isUnlocking,
|
||||
}"
|
||||
<div class="phone-device-row">
|
||||
<NotificationPhonePreview
|
||||
v-for="notification in notifications.devicePreviews"
|
||||
:key="notification.device?.imei"
|
||||
:notification="notification"
|
||||
:zoom="
|
||||
phoneBaseZoom *
|
||||
((notification.device?.preferences.settings.phoneScale ?? 100) /
|
||||
100)
|
||||
"
|
||||
@close="notifications.dismiss(notification.id)"
|
||||
/>
|
||||
<div
|
||||
v-if="phone.isOpen || notifications.current"
|
||||
class="phone-resolution-wrapper phone-resolution-wrapper--primary"
|
||||
>
|
||||
<section class="phone-device" :aria-label="phone.t('Common.phone')">
|
||||
<div
|
||||
class="phone-screen"
|
||||
:class="{ 'phone-screen--app': isAppRoute }"
|
||||
>
|
||||
<PhoneStatusBar v-if="!isLocked" />
|
||||
<SpringboardView />
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition name="app-window">
|
||||
<component :is="Component" v-if="isAppRoute" />
|
||||
<k-app
|
||||
theme="ios"
|
||||
:dark="phone.isDarkMode"
|
||||
safe-areas
|
||||
class="phone-app"
|
||||
:class="{
|
||||
dark: phone.isDarkMode,
|
||||
'phone-app--light': !phone.isDarkMode,
|
||||
'phone-app--unlocking': isUnlocking,
|
||||
}"
|
||||
>
|
||||
<PhoneStatusBar v-if="!isLocked" />
|
||||
<SpringboardView />
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition name="app-window">
|
||||
<component :is="Component" v-if="isAppRoute" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
<PhoneHomeIndicator v-if="!isLocked" />
|
||||
<Transition name="lock-screen">
|
||||
<PhoneLockScreen v-if="isLocked" @unlock="unlockPhone" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
<PhoneHomeIndicator v-if="!isLocked" />
|
||||
<Transition name="lock-screen">
|
||||
<PhoneLockScreen v-if="isLocked" @unlock="unlockPhone" />
|
||||
</Transition>
|
||||
<PhoneNotifications />
|
||||
</k-app>
|
||||
</div>
|
||||
<img
|
||||
class="phone-device__frame"
|
||||
:src="phoneFrameImage"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable="false"
|
||||
/>
|
||||
</section>
|
||||
<PhoneNotifications
|
||||
:notification="notifications.current"
|
||||
@close="notifications.dismissCurrent()"
|
||||
/>
|
||||
</k-app>
|
||||
</div>
|
||||
<img
|
||||
class="phone-device__frame"
|
||||
:src="phoneFrameImage"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable="false"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</Transition>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<linearGradient id="background" x1="18" y1="8" x2="110" y2="120" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#58c7ff"/>
|
||||
<stop offset="1" stop-color="#1478df"/>
|
||||
</linearGradient>
|
||||
<clipPath id="icon">
|
||||
<rect width="128" height="128" rx="28"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#icon)">
|
||||
<rect width="128" height="128" fill="url(#background)"/>
|
||||
<path d="M-6 94 34 60 56 72 91 36 134 52v82H-6Z" fill="#69c674"/>
|
||||
<path d="m-8 44 34 18 29-20 27 22 51-35" fill="none" stroke="#f7f3db" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="m-8 44 34 18 29-20 27 22 51-35" fill="none" stroke="#f0c24b" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M91 22c-14 0-25 11-25 25 0 19 25 45 25 45s25-26 25-45c0-14-11-25-25-25Z" fill="#ef4d46" stroke="#fff" stroke-width="4"/>
|
||||
<circle cx="91" cy="47" r="8" fill="#fff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1011 B |
@@ -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%;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { kBadge } from 'konsta/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useMailStore } from '@/stores/mail'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { PhoneAppDefinition } from '@/types/apps'
|
||||
|
||||
@@ -18,8 +20,16 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const mail = useMailStore()
|
||||
const router = useRouter()
|
||||
const iconFailed = ref(false)
|
||||
const unreadCount = computed(() =>
|
||||
props.app.id === 'mail' ? mail.counts.unread : 0,
|
||||
)
|
||||
const notificationBadgeColors = {
|
||||
bg: 'bg-red-500',
|
||||
text: 'text-white',
|
||||
}
|
||||
|
||||
function launch(event: MouseEvent): void {
|
||||
const button = event.currentTarget as HTMLElement
|
||||
@@ -56,24 +66,33 @@ function launch(event: MouseEvent): void {
|
||||
:aria-label="phone.t(app.labelKey)"
|
||||
@click="launch"
|
||||
>
|
||||
<span
|
||||
class="app-icon"
|
||||
:class="[app.iconClass, { 'app-icon--image': !iconFailed }]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
v-if="!iconFailed"
|
||||
:src="app.iconImage"
|
||||
alt=""
|
||||
draggable="false"
|
||||
@error="iconFailed = true"
|
||||
/>
|
||||
<component
|
||||
:is="app.icon"
|
||||
v-else
|
||||
:size="compact ? 18 : 28"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span class="app-icon-anchor" aria-hidden="true">
|
||||
<span
|
||||
class="app-icon"
|
||||
:class="[app.iconClass, { 'app-icon--image': !iconFailed }]"
|
||||
>
|
||||
<img
|
||||
v-if="!iconFailed"
|
||||
:src="app.iconImage"
|
||||
alt=""
|
||||
draggable="false"
|
||||
@error="iconFailed = true"
|
||||
/>
|
||||
<component
|
||||
:is="app.icon"
|
||||
v-else
|
||||
:size="compact ? 18 : 28"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</span>
|
||||
<k-badge
|
||||
v-if="unreadCount"
|
||||
class="app-icon-badge"
|
||||
:small="compact"
|
||||
:colors="notificationBadgeColors"
|
||||
>
|
||||
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
||||
</k-badge>
|
||||
</span>
|
||||
<span v-if="showLabel" class="app-icon-label">{{
|
||||
phone.t(app.labelKey)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { kApp } from 'konsta/vue'
|
||||
import { computed, type CSSProperties } from 'vue'
|
||||
|
||||
import PhoneNotifications from '@/components/PhoneNotifications.vue'
|
||||
import PhoneStatusBar from '@/components/PhoneStatusBar.vue'
|
||||
import { PHONE_FRAME_IMAGES } from '@/config/appearance'
|
||||
import type { PhoneNotification } from '@/stores/notifications'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
const props = defineProps<{
|
||||
notification: PhoneNotification
|
||||
zoom: number
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
const phone = usePhoneStore()
|
||||
const device = computed(() => props.notification.device!)
|
||||
const preferences = computed(() => device.value.preferences)
|
||||
const isDarkMode = computed(() => {
|
||||
const appearance = preferences.value.settings.appearanceMode
|
||||
if (appearance === 'dark') return true
|
||||
if (appearance === 'light') return false
|
||||
return phone.systemDarkMode
|
||||
})
|
||||
const frameImage = computed(
|
||||
() => PHONE_FRAME_IMAGES[preferences.value.settings.frame],
|
||||
)
|
||||
const wrapperStyle = computed<CSSProperties>(() => ({ zoom: props.zoom }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="phone-resolution-wrapper phone-resolution-wrapper--notification"
|
||||
:style="wrapperStyle"
|
||||
>
|
||||
<section
|
||||
class="phone-device phone-device--notification"
|
||||
:aria-label="device.name"
|
||||
>
|
||||
<div class="phone-screen">
|
||||
<k-app
|
||||
theme="ios"
|
||||
:dark="isDarkMode"
|
||||
safe-areas
|
||||
class="phone-app"
|
||||
:class="{
|
||||
dark: isDarkMode,
|
||||
'phone-app--light': !isDarkMode,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="notification-phone-background"
|
||||
:class="`wallpaper--${preferences.settings.wallpaper}`"
|
||||
></div>
|
||||
<PhoneStatusBar />
|
||||
<PhoneNotifications
|
||||
:notification="notification"
|
||||
@close="emit('close')"
|
||||
/>
|
||||
</k-app>
|
||||
</div>
|
||||
<img
|
||||
class="phone-device__frame"
|
||||
:src="frameImage"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable="false"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<k-notification
|
||||
:opened="!!notifications.current"
|
||||
:title="notifications.current?.title"
|
||||
:subtitle="notifications.current?.subtitle"
|
||||
:text="notifications.current?.text"
|
||||
:opened="!!notification"
|
||||
:title="notification?.title"
|
||||
:subtitle="notification?.subtitle"
|
||||
:text="notification?.text"
|
||||
:title-right-text="phone.t('Notifications.now')"
|
||||
button="close"
|
||||
class="phone-notification"
|
||||
@close="notifications.dismissCurrent()"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<template v-if="icon" #icon>
|
||||
<img :src="icon" alt="" class="phone-notification__icon" />
|
||||
|
||||
@@ -12,3 +12,11 @@ export const PHONE_FRAME_IMAGES: Record<PhoneFrameId, string> = {
|
||||
lavender: lavenderFrame,
|
||||
white: whiteFrame,
|
||||
}
|
||||
|
||||
export const PHONE_FRAME_COLORS: Record<PhoneFrameId, string> = {
|
||||
black: '#3a3a3c',
|
||||
blue: '#7294c2',
|
||||
green: '#889b6e',
|
||||
lavender: '#aaa1c8',
|
||||
white: '#f2f2f2',
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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<string, () => void>()
|
||||
export const useNotificationsStore = defineStore('notifications', () => {
|
||||
const phone = usePhoneStore()
|
||||
const queue = ref<PhoneNotification[]>([])
|
||||
const deviceQueue = ref<PhoneNotification[]>([])
|
||||
const current = computed(() => queue.value[0] ?? null)
|
||||
const devicePreviews = computed(() => {
|
||||
const devices = new Set<string>()
|
||||
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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -5,6 +5,7 @@ export type PhoneAppId =
|
||||
| 'camera'
|
||||
| 'clock'
|
||||
| 'mail'
|
||||
| 'map'
|
||||
| 'notes'
|
||||
| 'photos'
|
||||
| 'app-store'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<AuthMode>('login')
|
||||
const authEmail = ref('')
|
||||
const authPassword = ref('')
|
||||
@@ -335,18 +331,6 @@ function onMailEvent(event: MessageEvent<MailEvent>): 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'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
<script setup lang="ts">
|
||||
import { kButton, kPage } from 'konsta/vue'
|
||||
import { LocateFixed, Map, MapPinned, Route, Satellite } from 'lucide-vue-next'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
clampDefaultMapPoint,
|
||||
defaultCayoStyle,
|
||||
defaultMainlandStyle,
|
||||
defaultMapCoordinates,
|
||||
defaultMapWorldToPercent,
|
||||
type MapPoint,
|
||||
} from '@/features/map/defaultMapGeometry'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
type MapStyle = 'default' | 'satellite' | 'atlas' | 'roads'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const mapStyle = ref<MapStyle>('default')
|
||||
const zoom = ref(1.1)
|
||||
const pan = ref<MapPoint>({ x: 0, y: 0 })
|
||||
const currentLocation = ref<MapPoint | null>(null)
|
||||
const mapAspect = ref(
|
||||
defaultMapCoordinates.width / defaultMapCoordinates.height,
|
||||
)
|
||||
const imageError = ref(false)
|
||||
const locating = ref(false)
|
||||
const viewportRef = ref<HTMLElement | null>(null)
|
||||
const canvasRef = ref<HTMLElement | null>(null)
|
||||
const locationRef = ref<HTMLElement | null>(null)
|
||||
const isPointerDown = ref(false)
|
||||
const isPanning = ref(false)
|
||||
let pointerMoveFrame: number | undefined
|
||||
let wheelZoomFrame: number | undefined
|
||||
let pendingWheelDirection: -1 | 0 | 1 = 0
|
||||
let pendingWheelPoint: MapPoint | undefined
|
||||
|
||||
const pointerStart = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
}
|
||||
|
||||
const mapBounds = {
|
||||
minX: -4096,
|
||||
maxX: 4096,
|
||||
minY: -4096,
|
||||
maxY: 4096,
|
||||
}
|
||||
const mapOrigin = { x: -336.8, y: -1412.2 }
|
||||
const mapScale = { x: -2340 / -1548.1, y: 291 / 189.3 }
|
||||
const zoomFactor = 1.35
|
||||
const minZoom = 0.7
|
||||
const maxZoom = 12.3
|
||||
|
||||
const mapStyles = [
|
||||
{ id: 'default' as const, icon: MapPinned },
|
||||
{ id: 'satellite' as const, icon: Satellite },
|
||||
{ id: 'atlas' as const, icon: Map },
|
||||
{ id: 'roads' as const, icon: Route },
|
||||
]
|
||||
const activeMapStyle = computed(
|
||||
() => mapStyles.find((style) => style.id === mapStyle.value) ?? mapStyles[0],
|
||||
)
|
||||
|
||||
const mapImageUrl = computed(() => {
|
||||
const filename =
|
||||
mapStyle.value === 'default'
|
||||
? 'gtav-map.svg'
|
||||
: mapStyle.value === 'roads'
|
||||
? 'map_roads_4096.webp'
|
||||
: mapStyle.value === 'atlas'
|
||||
? 'map_atlas_4096.webp'
|
||||
: 'map_satellite_4096.webp'
|
||||
return `${import.meta.env.BASE_URL}img/maps/${filename}`
|
||||
})
|
||||
const cayoMapImageUrl = `${import.meta.env.BASE_URL}img/maps/cayo-perico.svg`
|
||||
|
||||
const canvasStyle = computed(() => ({
|
||||
aspectRatio: String(mapAspect.value),
|
||||
transform: `translate(-50%, -50%) translate(${pan.value.x}px, ${pan.value.y}px) scale(${zoom.value})`,
|
||||
width: 'max(120%, 120vh)',
|
||||
}))
|
||||
|
||||
const mapToWorld = (coords: MapPoint): MapPoint => ({
|
||||
x: coords.x / mapScale.x + mapOrigin.x,
|
||||
y: coords.y / mapScale.y + mapOrigin.y,
|
||||
})
|
||||
|
||||
const worldToPercent = (coords: MapPoint): MapPoint => {
|
||||
if (mapStyle.value === 'default') {
|
||||
return defaultMapWorldToPercent(coords)
|
||||
}
|
||||
|
||||
const world = mapToWorld(coords)
|
||||
return {
|
||||
x: Math.min(
|
||||
Math.max(
|
||||
(world.x - mapBounds.minX) / (mapBounds.maxX - mapBounds.minX),
|
||||
0,
|
||||
),
|
||||
1,
|
||||
),
|
||||
y: Math.min(
|
||||
Math.max(
|
||||
(mapBounds.maxY - world.y) / (mapBounds.maxY - mapBounds.minY),
|
||||
0,
|
||||
),
|
||||
1,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const locationStyle = computed(() => {
|
||||
if (!currentLocation.value) return undefined
|
||||
const percent = worldToPercent(currentLocation.value)
|
||||
return {
|
||||
left: `${percent.x * 100}%`,
|
||||
top: `${percent.y * 100}%`,
|
||||
transform: `translate(-50%, -50%) scale(${1 / zoom.value})`,
|
||||
}
|
||||
})
|
||||
|
||||
function setMapStyle(style: MapStyle): void {
|
||||
mapStyle.value = style
|
||||
imageError.value = false
|
||||
}
|
||||
|
||||
function cycleMapStyle(): void {
|
||||
const currentIndex = mapStyles.findIndex(
|
||||
(style) => style.id === mapStyle.value,
|
||||
)
|
||||
setMapStyle(mapStyles[(currentIndex + 1) % mapStyles.length].id)
|
||||
}
|
||||
|
||||
function changeZoom(direction: -1 | 1, focalPoint?: MapPoint): void {
|
||||
const targetZoom =
|
||||
direction > 0 ? zoom.value * zoomFactor : zoom.value / zoomFactor
|
||||
const nextZoom = Math.min(
|
||||
Math.max(Math.round(targetZoom * 1000) / 1000, minZoom),
|
||||
maxZoom,
|
||||
)
|
||||
if (nextZoom === zoom.value) return
|
||||
|
||||
if (focalPoint && canvasRef.value) {
|
||||
const rect = canvasRef.value.getBoundingClientRect()
|
||||
const offsetX = focalPoint.x - rect.left - rect.width / 2
|
||||
const offsetY = focalPoint.y - rect.top - rect.height / 2
|
||||
const scale = nextZoom / zoom.value
|
||||
pan.value = {
|
||||
x: pan.value.x - offsetX * (scale - 1),
|
||||
y: pan.value.y - offsetY * (scale - 1),
|
||||
}
|
||||
}
|
||||
|
||||
zoom.value = nextZoom
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent): void {
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return
|
||||
isPointerDown.value = true
|
||||
isPanning.value = false
|
||||
pointerStart.x = event.clientX
|
||||
pointerStart.y = event.clientY
|
||||
pointerStart.panX = pan.value.x
|
||||
pointerStart.panY = pan.value.y
|
||||
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent): void {
|
||||
if (!isPointerDown.value) return
|
||||
const deltaX = event.clientX - pointerStart.x
|
||||
const deltaY = event.clientY - pointerStart.y
|
||||
if (Math.abs(deltaX) > 4 || Math.abs(deltaY) > 4) {
|
||||
isPanning.value = true
|
||||
}
|
||||
if (!isPanning.value) return
|
||||
if (pointerMoveFrame) cancelAnimationFrame(pointerMoveFrame)
|
||||
pointerMoveFrame = requestAnimationFrame(() => {
|
||||
pointerMoveFrame = undefined
|
||||
pan.value = {
|
||||
x: pointerStart.panX + deltaX,
|
||||
y: pointerStart.panY + deltaY,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onPointerUp(): void {
|
||||
isPointerDown.value = false
|
||||
isPanning.value = false
|
||||
}
|
||||
|
||||
function onWheel(event: WheelEvent): void {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
pendingWheelDirection = event.deltaY > 0 ? -1 : 1
|
||||
pendingWheelPoint = { x: event.clientX, y: event.clientY }
|
||||
if (wheelZoomFrame) return
|
||||
wheelZoomFrame = requestAnimationFrame(() => {
|
||||
wheelZoomFrame = undefined
|
||||
if (pendingWheelDirection !== 0) {
|
||||
changeZoom(pendingWheelDirection, pendingWheelPoint)
|
||||
}
|
||||
pendingWheelDirection = 0
|
||||
pendingWheelPoint = undefined
|
||||
})
|
||||
}
|
||||
|
||||
function onImageLoad(event: Event): void {
|
||||
const image = event.target as HTMLImageElement
|
||||
mapAspect.value =
|
||||
mapStyle.value === 'default'
|
||||
? defaultMapCoordinates.width / defaultMapCoordinates.height
|
||||
: image.naturalWidth / image.naturalHeight
|
||||
imageError.value = false
|
||||
}
|
||||
|
||||
async function loadCurrentLocation(center: boolean): Promise<void> {
|
||||
locating.value = true
|
||||
const response = await nuiCall<{ coords?: MapPoint }>('map:getPlayerCoords')
|
||||
locating.value = false
|
||||
if (!response.success || !response.data?.coords) return
|
||||
|
||||
currentLocation.value = clampDefaultMapPoint(response.data.coords)
|
||||
if (!center) return
|
||||
|
||||
zoom.value = 3
|
||||
pan.value = { x: 0, y: 0 }
|
||||
await nextTick()
|
||||
const viewportElement = viewportRef.value
|
||||
const viewport = viewportElement?.getBoundingClientRect()
|
||||
const location = locationRef.value?.getBoundingClientRect()
|
||||
if (!viewportElement || !viewport || !location) return
|
||||
const renderedScaleX = viewport.width / viewportElement.clientWidth
|
||||
const renderedScaleY = viewport.height / viewportElement.clientHeight
|
||||
pan.value = {
|
||||
x:
|
||||
(viewport.left +
|
||||
viewport.width / 2 -
|
||||
(location.left + location.width / 2)) /
|
||||
renderedScaleX,
|
||||
y:
|
||||
(viewport.top +
|
||||
viewport.height / 2 -
|
||||
(location.top + location.height / 2)) /
|
||||
renderedScaleY,
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadCurrentLocation(false)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pointerMoveFrame) cancelAnimationFrame(pointerMoveFrame)
|
||||
if (wheelZoomFrame) cancelAnimationFrame(wheelZoomFrame)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<k-page class="map-app">
|
||||
<div
|
||||
ref="viewportRef"
|
||||
class="map-viewport"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp"
|
||||
@wheel="onWheel"
|
||||
>
|
||||
<div ref="canvasRef" class="map-canvas" :style="canvasStyle">
|
||||
<img
|
||||
:src="mapImageUrl"
|
||||
alt=""
|
||||
class="map-image"
|
||||
:class="{ 'map-image-default': mapStyle === 'default' }"
|
||||
:style="mapStyle === 'default' ? defaultMainlandStyle : undefined"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
@dragstart.prevent
|
||||
@load="onImageLoad"
|
||||
@error="imageError = true"
|
||||
/>
|
||||
<img
|
||||
v-if="mapStyle === 'default'"
|
||||
:src="cayoMapImageUrl"
|
||||
alt=""
|
||||
class="map-default-layer"
|
||||
:style="defaultCayoStyle"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
@dragstart.prevent
|
||||
@error="imageError = true"
|
||||
/>
|
||||
<div
|
||||
v-if="currentLocation && locationStyle"
|
||||
ref="locationRef"
|
||||
class="current-location"
|
||||
:style="locationStyle"
|
||||
>
|
||||
<span class="current-location__pulse"></span>
|
||||
<span class="current-location__dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="imageError" class="map-error">
|
||||
{{ phone.t('Apps.map.imageError') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav class="map-controls" :aria-label="phone.t('Apps.map.controls')">
|
||||
<k-button
|
||||
rounded
|
||||
tonal
|
||||
class="map-control"
|
||||
:aria-label="`${phone.t('Apps.map.switchStyle')}: ${phone.t(`Apps.map.styles.${mapStyle}`)}`"
|
||||
@click="cycleMapStyle"
|
||||
>
|
||||
<component :is="activeMapStyle.icon" aria-hidden="true" />
|
||||
</k-button>
|
||||
<k-button
|
||||
rounded
|
||||
class="map-control map-control--location"
|
||||
:disabled="locating"
|
||||
:aria-label="phone.t('Apps.map.currentLocation')"
|
||||
@click="loadCurrentLocation(true)"
|
||||
>
|
||||
<LocateFixed aria-hidden="true" />
|
||||
</k-button>
|
||||
</nav>
|
||||
</k-page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.map-app {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.map-viewport {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.map-canvas {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
height: auto;
|
||||
transform-origin: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.map-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: saturate(1.05) contrast(1.02);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.map-image-default,
|
||||
.map-default-layer {
|
||||
position: absolute;
|
||||
display: block;
|
||||
object-fit: fill;
|
||||
}
|
||||
|
||||
.map-default-layer {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.current-location {
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.current-location__pulse,
|
||||
.current-location__dot {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.current-location__pulse {
|
||||
background: rgb(0 122 255 / 25%);
|
||||
border: 1px solid rgb(255 255 255 / 80%);
|
||||
}
|
||||
|
||||
.current-location__dot {
|
||||
inset: 5px;
|
||||
background: #007aff;
|
||||
border: 1.5px solid #fff;
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 35%);
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
right: 12px;
|
||||
bottom: 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.map-control {
|
||||
width: 46px;
|
||||
min-width: 46px;
|
||||
height: 46px;
|
||||
padding: 0;
|
||||
color: #1c1c1e;
|
||||
background: rgb(242 242 247 / 90%);
|
||||
box-shadow: 0 5px 18px rgb(0 0 0 / 24%);
|
||||
}
|
||||
|
||||
.map-control svg {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
}
|
||||
|
||||
.map-control--location {
|
||||
color: #fff;
|
||||
background: #007aff;
|
||||
}
|
||||
|
||||
.map-error {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 70%;
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 12px;
|
||||
background: rgb(0 0 0 / 70%);
|
||||
}
|
||||
</style>
|
||||
@@ -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<SettingsView>('root')
|
||||
const selectedNotificationAppId = ref<PhoneAppId>('calculator')
|
||||
const settingsPage = ref<ComponentPublicInstance | null>(null)
|
||||
const framePickerButton = ref<ComponentPublicInstance | null>(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<void> {
|
||||
const target = framePickerTarget.value
|
||||
const screen = target?.closest<HTMLElement>('.phone-screen')
|
||||
const wrapper = target?.closest<HTMLElement>('.phone-resolution-wrapper')
|
||||
const popover = document.querySelector<HTMLElement>(
|
||||
'.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<HTMLElement>('[role="group"]')
|
||||
if (pickerGrid) {
|
||||
pickerGrid.style.gap = `${20 * framePickerScale}px`
|
||||
pickerGrid.style.padding = `${20 * framePickerScale}px`
|
||||
pickerGrid.querySelectorAll<HTMLElement>('button').forEach((button) => {
|
||||
button.style.width = `${40 * framePickerScale}px`
|
||||
button.style.height = `${40 * framePickerScale}px`
|
||||
})
|
||||
}
|
||||
|
||||
framePickerOpened.value = true
|
||||
await nextTick()
|
||||
await new Promise<void>((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(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<k-page ref="settingsPage" class="!pt-[44px] !pb-[24px]">
|
||||
<k-page
|
||||
ref="settingsPage"
|
||||
:class="['!pb-[24px]', { '!pt-[44px]': activeView !== 'root' }]"
|
||||
>
|
||||
<template v-if="activeView === 'root'">
|
||||
<k-navbar
|
||||
large
|
||||
@@ -391,10 +466,8 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<template #media>
|
||||
<span
|
||||
:class="[
|
||||
'flex h-7 w-7 shrink-0 items-center justify-center rounded-[7px] text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.35),0_1px_2px_rgba(0,0,0,0.25)]',
|
||||
row.iconClass,
|
||||
]"
|
||||
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-[7px] text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.35),0_1px_2px_rgba(0,0,0,0.25)]"
|
||||
:style="{ backgroundColor: row.iconColor }"
|
||||
>
|
||||
<component :is="row.icon" :size="17" :stroke-width="2.25" />
|
||||
</span>
|
||||
@@ -421,10 +494,8 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<template #media>
|
||||
<span
|
||||
:class="[
|
||||
'flex h-7 w-7 shrink-0 items-center justify-center rounded-[7px] text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.35),0_1px_2px_rgba(0,0,0,0.25)]',
|
||||
row.iconClass,
|
||||
]"
|
||||
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-[7px] text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.35),0_1px_2px_rgba(0,0,0,0.25)]"
|
||||
:style="{ backgroundColor: row.iconColor }"
|
||||
>
|
||||
<component :is="row.icon" :size="17" :stroke-width="2.25" />
|
||||
</span>
|
||||
@@ -444,10 +515,8 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<template #media>
|
||||
<span
|
||||
:class="[
|
||||
'flex h-7 w-7 shrink-0 items-center justify-center rounded-[7px] text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.35),0_1px_2px_rgba(0,0,0,0.25)]',
|
||||
row.iconClass,
|
||||
]"
|
||||
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-[7px] text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.35),0_1px_2px_rgba(0,0,0,0.25)]"
|
||||
:style="{ backgroundColor: row.iconColor }"
|
||||
>
|
||||
<component :is="row.icon" :size="17" :stroke-width="2.25" />
|
||||
</span>
|
||||
@@ -895,29 +964,49 @@ onBeforeUnmount(() => {
|
||||
<k-block-title>{{ phone.t('Apps.settings.phoneFrame') }}</k-block-title>
|
||||
<k-list strong inset>
|
||||
<k-list-item
|
||||
v-for="frame in PHONE_FRAME_IDS"
|
||||
:key="frame"
|
||||
ref="framePickerButton"
|
||||
link
|
||||
:chevron="false"
|
||||
:title="phone.t(`Apps.settings.frames.${frame}`)"
|
||||
@click="selectFrame(frame)"
|
||||
:title="phone.t('Apps.settings.phoneFrame')"
|
||||
@click="openFramePicker"
|
||||
>
|
||||
<template #media>
|
||||
<img
|
||||
class="w-6 h-12 object-fill"
|
||||
:src="PHONE_FRAME_IMAGES[frame]"
|
||||
alt=""
|
||||
draggable="false"
|
||||
/>
|
||||
</template>
|
||||
<template #after>
|
||||
<Check
|
||||
v-if="phone.preferences.settings.frame === frame"
|
||||
class="w-5 h-5 text-primary"
|
||||
<span
|
||||
class="h-7 w-7 rounded-full border border-black/15 shadow-sm"
|
||||
:style="{ backgroundColor: selectedFrameColor }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
|
||||
<Teleport to="body">
|
||||
<k-popover
|
||||
:opened="framePickerOpened"
|
||||
:target="framePickerTarget"
|
||||
:class="[
|
||||
'settings-frame-popover',
|
||||
{ dark: phone.isDarkMode },
|
||||
]"
|
||||
@backdropclick="framePickerOpened = false"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-3 gap-5 p-5"
|
||||
role="group"
|
||||
:aria-label="phone.t('Apps.settings.phoneFrame')"
|
||||
>
|
||||
<button
|
||||
v-for="frame in PHONE_FRAME_IDS"
|
||||
:key="frame"
|
||||
type="button"
|
||||
class="h-10 w-10 rounded-full border border-black/15 shadow-sm"
|
||||
:style="{ backgroundColor: PHONE_FRAME_COLORS[frame] }"
|
||||
:aria-label="phone.t(`Apps.settings.frames.${frame}`)"
|
||||
:aria-pressed="phone.preferences.settings.frame === frame"
|
||||
@click="selectFrame(frame)"
|
||||
/>
|
||||
</div>
|
||||
</k-popover>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeView === 'wallpaper'">
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -31,6 +31,7 @@ server_scripts {
|
||||
files {
|
||||
'source/html/index.html',
|
||||
'source/html/assets/**',
|
||||
'source/html/img/**',
|
||||
}
|
||||
|
||||
ui_page 'source/html/index.html'
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sky Phone</title>
|
||||
<script type="module" crossorigin src="./assets/sky-index-BIZ3BtrM.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/sky-index-CsEJ7-yo.css">
|
||||
<script type="module" crossorigin src="./assets/sky-index-_kcEMkLZ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/sky-index--1RlStC1.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user