From b3db702a2e36ae7b3e34ce8f99c702942654a122 Mon Sep 17 00:00:00 2001 From: Type <79042381+TypeFor@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:52:52 +0200 Subject: [PATCH] ADD - implement weather app camera views Add the Weather phone experience with live widgets, configuration, and localized status details.\n\nProvide server-authorized, rate-limited remote weather camera sessions with configurable camera locations and safe client cleanup. Include test-server support and weather store coverage. --- frontend/package.json | 2 +- frontend/src/App.vue | 92 ++++++--- frontend/src/assets/main.css | 182 +++++++++++++++++- frontend/src/components/SpringboardWidget.vue | 13 +- frontend/src/components/WidgetConfigSheet.vue | 6 +- frontend/src/services/widgetServices.ts | 72 +++++-- frontend/src/stores/phone.ts | 22 +++ frontend/src/stores/weather.test.ts | 21 ++ frontend/src/stores/weather.ts | 40 +++- frontend/src/types/weather.ts | 5 + frontend/src/utils/nui.ts | 2 +- frontend/src/views/apps/WeatherApp.vue | 84 +++++++- frontend/testserver/index.cjs | 29 ++- sky_phone/config/locales/en.lua | 12 ++ sky_phone/config/weather_cameras.lua | 32 +++ sky_phone/fxmanifest.lua | 3 + sky_phone/source/client/weather_camera.lua | 103 ++++++++++ sky_phone/source/server/weather.lua | 62 ++++++ 18 files changed, 721 insertions(+), 61 deletions(-) create mode 100644 sky_phone/config/weather_cameras.lua create mode 100644 sky_phone/source/client/weather_camera.lua create mode 100644 sky_phone/source/server/weather.lua diff --git a/frontend/package.json b/frontend/package.json index 69b2228..7c86e8f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "version": "0.1.0", "type": "module", "scripts": { - "dev": "concurrently -k -n FRONTEND,BACKEND -c cyan,green \"vite --port 5174 --strictPort\" \"node testserver/index.cjs 3002\"", + "dev": "concurrently -k -n FRONTEND,BACKEND -c cyan,green \"vite\" \"node testserver/index.cjs\"", "build": "pnpm typecheck && pnpm build-only", "build-only": "vite build && node build.cjs", "preview": "vite preview", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b7c966a..8dab943 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -269,7 +269,7 @@ const phoneFrameImage = computed( let clockTicker: ReturnType | undefined let unlockTimer: number | undefined let passcodeLockTimer: number | undefined -let unlockedServicesFrame: number | undefined +let unlockedServicesIdle: number | undefined function getViewportScale(): number { const heightScale = window.innerHeight / REFERENCE_VIEWPORT_HEIGHT @@ -296,27 +296,62 @@ function hydratePhone(payload: PhoneOpenPayload): void { widgets.hydrate(payload.device?.data.widgets?.payload) } -function loadUnlockedPhoneData(): void { - if (unlockedServicesLoaded.value) return - unlockedServicesLoaded.value = true +function cancelUnlockedPhoneDataLoad(): void { + if (unlockedServicesIdle === undefined) return + if (typeof window.cancelIdleCallback === 'function') { + window.cancelIdleCallback(unlockedServicesIdle) + } else { + window.clearTimeout(unlockedServicesIdle) + } + unlockedServicesIdle = undefined +} - // Let the lock screen/home screen paint before background app requests compete - // with the first visible NUI frame. - unlockedServicesFrame = window.requestAnimationFrame(() => { - unlockedServicesFrame = undefined +async function bootstrapUnlockedPhoneData(): Promise { + const tasks: Array<() => Promise | void> = [ + () => calls.bootstrap(), + () => messages.loadConversations(), + () => billing.loadOverview(), + () => mail.bootstrap(account.email), + () => { + if (account.email) return marketplace.loadCounts() + marketplace.setCounts({ active: 0, unread: 0 }) + }, + () => (account.email ? darkchat.bootstrap() : undefined), + ] + + for (const task of tasks) { if (!phone.isOpen || isLocked.value) { unlockedServicesLoaded.value = false return } + try { + await task() + } catch (error) { + console.error('[sky_phone] Failed to bootstrap unlocked phone data.', error) + } + } +} - void mail.bootstrap(account.email) - if (account.email) void marketplace.loadCounts() - else marketplace.setCounts({ active: 0, unread: 0 }) - void calls.bootstrap() - void messages.loadConversations() - void billing.loadOverview() - if (account.email) void darkchat.bootstrap() - }) +function loadUnlockedPhoneData(): void { + if (unlockedServicesLoaded.value) return + unlockedServicesLoaded.value = true + + const startBootstrap = () => { + unlockedServicesIdle = undefined + if (!phone.isOpen || isLocked.value) { + unlockedServicesLoaded.value = false + return + } + void bootstrapUnlockedPhoneData() + } + + if (typeof window.requestIdleCallback === 'function') { + unlockedServicesIdle = window.requestIdleCallback(startBootstrap, { + timeout: 1500, + }) + } else { + unlockedServicesIdle = window.setTimeout(startBootstrap, 0) + } } async function hydrateDevelopmentPhone(): Promise { @@ -727,6 +762,16 @@ function updateViewportScale(): void { viewportScale.value = getViewportScale() } +function completeUnlock(): void { + if (!isUnlocking.value) return + if (unlockTimer !== undefined) { + window.clearTimeout(unlockTimer) + unlockTimer = undefined + } + isUnlocking.value = false + loadUnlockedPhoneData() +} + function finishUnlock(): void { if (!isLocked.value) return isUnlocking.value = true @@ -735,7 +780,8 @@ function finishUnlock(): void { passcodeError.value = '' unlockTimer = window.setTimeout(() => { - isUnlocking.value = false + unlockTimer = undefined + completeUnlock() }, 720) if (pendingUnlockRoute.value) { @@ -743,7 +789,6 @@ function finishUnlock(): void { pendingUnlockRoute.value = null window.setTimeout(() => void router.push(routePath), 0) } - loadUnlockedPhoneData() } function unlockPhone(): void { @@ -907,10 +952,7 @@ watch( (isOpen) => { if (unlockTimer !== undefined) window.clearTimeout(unlockTimer) if (!isOpen) { - if (unlockedServicesFrame !== undefined) { - window.cancelAnimationFrame(unlockedServicesFrame) - unlockedServicesFrame = undefined - } + cancelUnlockedPhoneDataLoad() activitySuspended.value = false weather.stop() controlCenterOpened.value = false @@ -956,9 +998,7 @@ watch( ) onBeforeUnmount(() => { - if (unlockedServicesFrame !== undefined) { - window.cancelAnimationFrame(unlockedServicesFrame) - } + cancelUnlockedPhoneDataLoad() weather.stop() if (clockTicker) clearInterval(clockTicker) if (unlockTimer !== undefined) window.clearTimeout(unlockTimer) @@ -1063,7 +1103,7 @@ onBeforeUnmount(() => { :opened="controlCenterOpened" @close="controlCenterOpened = false" /> - + + ['clock', 'date'].includes(props.instance.kind), +) +const usesBank = computed(() => + ['transactions', 'wallet'].includes(props.instance.kind), +) +const usesContacts = computed(() => props.instance.kind === 'contacts') +const clock = useClockService(usesClock) const weather = useWeatherService() const music = useMusicService() -const bank = useBankService() -const contactsService = useContactsService() +const bank = useBankService(usesBank) +const contactsService = useContactsService(usesContacts) const isDragging = ref(false) const dragOffset = ref({ x: 0, y: 0 }) const suppressClick = ref(false) diff --git a/frontend/src/components/WidgetConfigSheet.vue b/frontend/src/components/WidgetConfigSheet.vue index d10c2ea..c237b9d 100644 --- a/frontend/src/components/WidgetConfigSheet.vue +++ b/frontend/src/components/WidgetConfigSheet.vue @@ -11,7 +11,7 @@ import { kSheet, kToggle, } from 'konsta/vue' -import { ref, watch } from 'vue' +import { computed, ref, watch } from 'vue' import SpringboardWidget from '@/components/SpringboardWidget.vue' import { WIDGET_REGISTRY_BY_KIND } from '@/config/widgets' @@ -32,7 +32,9 @@ const emit = defineEmits<{ save: [size: WidgetSize, settings: WidgetSettings] }>() const phone = usePhoneStore() -const contactsService = useContactsService() +const contactsService = useContactsService( + computed(() => props.opened && props.instance?.kind === 'contacts'), +) const size = ref('small') const showDate = ref(true) const balanceSource = ref<'bank' | 'cash'>('bank') diff --git a/frontend/src/services/widgetServices.ts b/frontend/src/services/widgetServices.ts index 2e9c232..ce02709 100644 --- a/frontend/src/services/widgetServices.ts +++ b/frontend/src/services/widgetServices.ts @@ -1,4 +1,12 @@ -import { computed, onBeforeUnmount, onMounted, ref } from 'vue' +import { + computed, + onBeforeUnmount, + onMounted, + ref, + toValue, + watch, + type MaybeRefOrGetter, +} from 'vue' import { useBankingStore } from '@/stores/banking' import { useCallsStore } from '@/stores/calls' @@ -9,23 +17,43 @@ import { useWeatherStore } from '@/stores/weather' const now = ref(new Date()) let clockConsumers = 0 let clockInterval: number | undefined +let contactsRequest: Promise | undefined -export function useClockService() { +export function useClockService(enabled: MaybeRefOrGetter = true) { const phone = usePhoneStore() - onMounted(() => { + let active = false + let mounted = false + + function syncClock(): void { + const shouldBeActive = mounted && toValue(enabled) + if (shouldBeActive === active) return + + active = shouldBeActive + if (!active) { + clockConsumers -= 1 + if (clockConsumers === 0 && clockInterval !== undefined) { + window.clearInterval(clockInterval) + clockInterval = undefined + } + return + } + clockConsumers += 1 if (clockInterval === undefined) { clockInterval = window.setInterval(() => { now.value = new Date() }, 1000) } + } + + watch(() => toValue(enabled), syncClock) + onMounted(() => { + mounted = true + syncClock() }) onBeforeUnmount(() => { - clockConsumers -= 1 - if (clockConsumers === 0 && clockInterval !== undefined) { - window.clearInterval(clockInterval) - clockInterval = undefined - } + mounted = false + syncClock() }) return { date: computed(() => @@ -92,7 +120,7 @@ export function useMusicService() { } } -export function useBankService() { +export function useBankService(enabled: MaybeRefOrGetter = true) { const banking = useBankingStore() const overview = computed( () => @@ -130,13 +158,18 @@ export function useBankService() { ], }, ) - onMounted(() => { - if (!banking.overview && !banking.isLoading) void banking.load() - }) + function loadIfNeeded(): void { + if (toValue(enabled) && !banking.overview && !banking.isLoading) { + void banking.load() + } + } + + watch(() => toValue(enabled), loadIfNeeded) + onMounted(loadIfNeeded) return { overview } } -export function useContactsService() { +export function useContactsService(enabled: MaybeRefOrGetter = true) { const calls = useCallsStore() const contacts = computed(() => calls.contacts.length @@ -148,8 +181,15 @@ export function useContactsService() { { id: 'mock-liam', name: 'Liam', phone_number: '555-0177' }, ], ) - onMounted(() => { - if (!calls.contacts.length) void calls.loadContacts() - }) + function loadIfNeeded(): void { + if (!toValue(enabled) || calls.contacts.length || contactsRequest) return + + contactsRequest = calls.loadContacts().finally(() => { + contactsRequest = undefined + }) + } + + watch(() => toValue(enabled), loadIfNeeded) + onMounted(loadIfNeeded) return { contacts } } diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index e9288b9..7ab4eae 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -2338,6 +2338,28 @@ const defaultLocales: LocaleTree = { unavailable: 'Weather is unavailable', stale: 'Showing the last available weather update.', tryAgain: 'Try Again', + webcam: { + title: 'Weather Cameras', + live: 'LIVE', + view: 'View Live Camera', + subtitle: 'A live look at current conditions', + unavailable: 'No weather cameras are available.', + locations: { + legion_square: 'Legion Square', + sandy_shores: 'Sandy Shores', + cayo_airstrip: 'Cayo Perico Airstrip', + }, + errors: { + camera_disabled: 'Weather cameras are currently disabled.', + camera_not_found: 'This weather camera is no longer available.', + camera_requires_safe_position: + 'Stand still and exit your vehicle before opening a camera.', + camera_active: 'A weather camera is already open.', + rate_limited: 'Too many camera requests. Try again shortly.', + request_failed: 'The weather camera could not be opened.', + not_authenticated: 'Unlock the phone before opening a camera.', + }, + }, regions: { los_santos: 'Los Santos', blaine_county: 'Blaine County', diff --git a/frontend/src/stores/weather.test.ts b/frontend/src/stores/weather.test.ts index a58d2f8..b89b26a 100644 --- a/frontend/src/stores/weather.test.ts +++ b/frontend/src/stores/weather.test.ts @@ -60,4 +60,25 @@ describe('weather store', () => { expect(weather.forecast).toBe(previous) expect(weather.error).toBe('offline') }) + + it('loads the server-approved camera list and opens a camera by id', async () => { + vi.mocked(nuiCall) + .mockResolvedValueOnce({ + data: { cameras: [{ id: 'legion_square', region: 'los_santos' }] }, + success: true, + }) + .mockResolvedValueOnce({ success: true }) + const weather = useWeatherStore() + + await weather.loadCameras() + const opened = await weather.openCamera('legion_square') + + expect(weather.cameras).toEqual([ + { id: 'legion_square', region: 'los_santos' }, + ]) + expect(nuiCall).toHaveBeenLastCalledWith('weather:camera-open', { + id: 'legion_square', + }) + expect(opened).toBe(true) + }) }) diff --git a/frontend/src/stores/weather.ts b/frontend/src/stores/weather.ts index 103c378..e94dbe6 100644 --- a/frontend/src/stores/weather.ts +++ b/frontend/src/stores/weather.ts @@ -1,6 +1,10 @@ import { defineStore } from 'pinia' -import type { RawWeatherSnapshot, WeatherForecast } from '@/types/weather' +import type { + RawWeatherSnapshot, + WeatherCamera, + WeatherForecast, +} from '@/types/weather' import { nuiCall } from '@/utils/nui' import { buildWeatherForecast } from '@/utils/weather' @@ -9,12 +13,45 @@ const REFRESH_INTERVAL = 30_000 export const useWeatherStore = defineStore('weather', { state: () => ({ error: null as string | null, + cameraError: null as string | null, + cameras: [] as WeatherCamera[], + camerasLoaded: false, forecast: null as WeatherForecast | null, intervalId: undefined as number | undefined, isLoading: false, + isOpeningCamera: false, lastFetchedAt: 0, }), actions: { + async loadCameras(force = false): Promise { + if (this.camerasLoaded && !force) return + const response = await nuiCall<{ cameras: WeatherCamera[] }>( + 'weather:cameras', + ) + this.camerasLoaded = true + if (!response.success || !Array.isArray(response.data?.cameras)) { + this.cameraError = response.error ?? 'request_failed' + return + } + this.cameras = response.data.cameras.filter( + (camera) => + typeof camera?.id === 'string' && + ['los_santos', 'blaine_county', 'cayo_perico'].includes( + camera.region, + ), + ) + this.cameraError = null + }, + async openCamera(id: string): Promise { + if (this.isOpeningCamera) return false + this.isOpeningCamera = true + const response = await nuiCall('weather:camera-open', { id }) + this.isOpeningCamera = false + this.cameraError = response.success + ? null + : (response.error ?? 'request_failed') + return response.success + }, async refresh(force = false): Promise { if (this.isLoading) return if (!force && Date.now() - this.lastFetchedAt < REFRESH_INTERVAL) return @@ -31,6 +68,7 @@ export const useWeatherStore = defineStore('weather', { }, start(): void { void this.refresh(true) + void this.loadCameras() if (this.intervalId !== undefined) return this.intervalId = window.setInterval(() => void this.refresh(true), REFRESH_INTERVAL) }, diff --git a/frontend/src/types/weather.ts b/frontend/src/types/weather.ts index 14918a5..568ac93 100644 --- a/frontend/src/types/weather.ts +++ b/frontend/src/types/weather.ts @@ -10,6 +10,11 @@ export type WeatherConditionId = export type WeatherRegionId = 'los_santos' | 'blaine_county' | 'cayo_perico' +export type WeatherCamera = { + id: string + region: WeatherRegionId +} + export type WeatherClock = { day: number hour: number diff --git a/frontend/src/utils/nui.ts b/frontend/src/utils/nui.ts index 670160f..d97bf82 100644 --- a/frontend/src/utils/nui.ts +++ b/frontend/src/utils/nui.ts @@ -14,7 +14,7 @@ export async function nuiCall( 'apiPort', ) const baseUrl = import.meta.env.DEV - ? `http://localhost:${developmentPort ?? '3002'}/api` + ? `http://localhost:${developmentPort ?? '3001'}/api` : `https://${resourceName}` const requestData = import.meta.env.DEV ? { diff --git a/frontend/src/views/apps/WeatherApp.vue b/frontend/src/views/apps/WeatherApp.vue index 81f46b1..af217fd 100644 --- a/frontend/src/views/apps/WeatherApp.vue +++ b/frontend/src/views/apps/WeatherApp.vue @@ -1,5 +1,5 @@