ADD - map app

This commit is contained in:
Eichenholz
2026-08-05 11:44:17 +02:00
parent 257db3b7e6
commit a410cc72c9
31 changed files with 5003 additions and 149 deletions
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
View File
@@ -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>
+19
View File
@@ -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

+32 -1
View File
@@ -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%;
+38 -19
View File
@@ -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>
+14 -9
View File
@@ -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" />
+8
View File
@@ -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',
}
+7 -2
View File
@@ -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,
+15
View File
@@ -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,
})
+107
View File
@@ -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([])
})
})
+73 -16
View File
@@ -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,
+13
View File
@@ -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',
+1
View File
@@ -5,6 +5,7 @@ export type PhoneAppId =
| 'camera'
| 'clock'
| 'mail'
| 'map'
| 'notes'
| 'photos'
| 'app-store'
+6
View File
@@ -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
+1
View File
@@ -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 },
+1 -17
View File
@@ -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'),
})
}
}
+451
View File
@@ -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>
+126 -37
View File
@@ -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'">
+7
View File
@@ -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 = {
+5
View File
@@ -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",
+1
View File
@@ -31,6 +31,7 @@ server_scripts {
files {
'source/html/index.html',
'source/html/assets/**',
'source/html/img/**',
}
ui_page 'source/html/index.html'
+17
View File
@@ -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)
+2 -2
View File
@@ -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>
+6 -4
View File
@@ -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 } }
+36
View File
@@ -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)