From 2e4843cfbeeb1a00e5f6b480d98b9b2cbff92b5b Mon Sep 17 00:00:00 2001 From: Type <79042381+TypeFor@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:12:38 +0200 Subject: [PATCH] ADD - build Housing phone app --- frontend/src/App.vue | 46 +- frontend/src/assets/img/app-icons/house.svg | 17 + frontend/src/assets/main.css | 38 +- .../src/components/PhoneHomeIndicator.vue | 7 +- frontend/src/config/apps.test.ts | 5 + frontend/src/config/apps.ts | 16 + frontend/src/stores/housing.test.ts | 97 ++ frontend/src/stores/housing.ts | 75 ++ frontend/src/stores/phone.ts | 82 ++ frontend/src/types/apps.ts | 1 + frontend/src/types/housing.ts | 47 + frontend/src/utils/preferences.ts | 1 + frontend/src/views/apps/HouseApp.vue | 1017 +++++++++++++++++ frontend/testserver/index.cjs | 67 ++ 14 files changed, 1490 insertions(+), 26 deletions(-) create mode 100644 frontend/src/assets/img/app-icons/house.svg create mode 100644 frontend/src/stores/housing.test.ts create mode 100644 frontend/src/stores/housing.ts create mode 100644 frontend/src/types/housing.ts create mode 100644 frontend/src/views/apps/HouseApp.vue diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ec2dd24..efac080 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -201,6 +201,7 @@ const passcodeVisible = ref(false) const pendingUnlockRoute = ref(null) const unlockedServicesLoaded = ref(false) const controlCenterOpened = ref(false) +const activitySuspended = ref(false) const simPicker = ref(null) const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)') const viewportScale = ref(getViewportScale()) @@ -215,6 +216,10 @@ const phoneResolutionStyle = computed(() => ({ '--phone-zoom': phoneBaseZoom.value * (phone.preferences.settings.phoneScale / 100), })) +const phoneStageStyle = computed(() => ({ + ...phoneResolutionStyle.value, + visibility: activitySuspended.value ? 'hidden' : 'visible', +})) const phoneDisplayStyle = computed(() => ({ '--phone-display-dim': String( ((100 - phone.preferences.settings.screenBrightness) / 100) * 0.8, @@ -226,6 +231,7 @@ const phoneFrameImage = computed( let clockTicker: ReturnType | undefined let unlockTimer: number | undefined let passcodeLockTimer: number | undefined +let unlockedServicesFrame: number | undefined function getViewportScale(): number { const heightScale = window.innerHeight / REFERENCE_VIEWPORT_HEIGHT @@ -248,12 +254,23 @@ function hydratePhone(payload: PhoneOpenPayload): void { function loadUnlockedPhoneData(): void { if (unlockedServicesLoaded.value) return unlockedServicesLoaded.value = true - void mail.bootstrap(account.email) - if (account.email) void marketplace.loadCounts() - else marketplace.setCounts({ active: 0, unread: 0 }) - void calls.bootstrap() - void messages.loadConversations() - if (account.email) void darkchat.bootstrap() + + // Let the lock screen/home screen paint before background app requests compete + // with the first visible NUI frame. + unlockedServicesFrame = window.requestAnimationFrame(() => { + unlockedServicesFrame = undefined + if (!phone.isOpen || isLocked.value) { + unlockedServicesLoaded.value = false + return + } + + void mail.bootstrap(account.email) + if (account.email) void marketplace.loadCounts() + else marketplace.setCounts({ active: 0, unread: 0 }) + void calls.bootstrap() + void messages.loadConversations() + if (account.email) void darkchat.bootstrap() + }) } async function hydrateDevelopmentPhone(): Promise { @@ -288,7 +305,12 @@ function onMessage(event: MessageEvent): void { } else if (event.data?.type === 'device:updated') { hydratePhone(event.data.data as PhoneOpenPayload) } else if (event.data?.type === 'app:close') { + activitySuspended.value = false phone.close() + } else if (event.data?.type === 'app:suspend') { + activitySuspended.value = true + } else if (event.data?.type === 'app:resume') { + activitySuspended.value = false } 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) { @@ -560,7 +582,7 @@ function onMessage(event: MessageEvent): void { } function onKeydown(event: KeyboardEvent): void { - if (event.key !== 'Escape' || !phone.isOpen) return + if (event.key !== 'Escape' || !phone.isOpen || activitySuspended.value) return if (controlCenterOpened.value) { controlCenterOpened.value = false return @@ -746,6 +768,11 @@ watch( (isOpen) => { if (unlockTimer !== undefined) window.clearTimeout(unlockTimer) if (!isOpen) { + if (unlockedServicesFrame !== undefined) { + window.cancelAnimationFrame(unlockedServicesFrame) + unlockedServicesFrame = undefined + } + activitySuspended.value = false weather.stop() controlCenterOpened.value = false isLocked.value = false @@ -790,6 +817,9 @@ watch( ) onBeforeUnmount(() => { + if (unlockedServicesFrame !== undefined) { + window.cancelAnimationFrame(unlockedServicesFrame) + } weather.stop() if (clockTicker) clearInterval(clockTicker) if (unlockTimer !== undefined) window.clearTimeout(unlockTimer) @@ -824,7 +854,7 @@ onBeforeUnmount(() => { 'phone-stage--landscape': phone.cameraLandscape, 'phone-stage--peek': notifications.isPeeking, }" - :style="phoneResolutionStyle" + :style="phoneStageStyle" >
+ + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 8c4dcfc..3e7c51e 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -2847,6 +2847,8 @@ button { } .banking-balance { + position: relative; + overflow: hidden; padding: 22px 20px 20px; border-radius: 27px; } @@ -2895,11 +2897,13 @@ button { } .banking-action { + position: relative; display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 64px; + overflow: hidden; padding: 11px 12px; border-radius: 19px; color: rgb(237 243 255 / 82%); @@ -3047,8 +3051,9 @@ button { display: flex; justify-content: flex-end; gap: 12px; - color: rgb(218 229 251 / 50%); - font-size: 8px; + color: rgb(218 229 251 / 72%); + font-size: 11px; + line-height: 1.35; } .banking-chart-legend span { @@ -3151,8 +3156,8 @@ button { .banking-sheet__content > p { margin: 0 0 15px; color: rgb(213 225 248 / 52%); - font-size: 10px; - line-height: 1.45; + font-size: 12px; + line-height: 1.5; } .banking-form-list { @@ -3167,7 +3172,9 @@ button { display: block; margin: 0 4px 6px; color: rgb(213 225 248 / 52%); - font-size: 9px; + font-size: 11px; + font-weight: 600; + line-height: 1.35; } .banking-contact-picker > div { @@ -3179,13 +3186,15 @@ button { .banking-contact-picker > p { margin: 0 4px; color: rgb(213 225 248 / 42%); - font-size: 9px; + font-size: 11px; + line-height: 1.4; } .banking-sheet__content .banking-form-error { margin: 10px 3px 0; color: #ff8b98; - font-size: 9px; + font-size: 11px; + line-height: 1.4; } .banking-sheet__content > button:last-child { @@ -3325,21 +3334,18 @@ button { text-transform: uppercase; } .weather-hourly { - display: flex; - gap: 5px; - overflow-x: auto; - padding: 11px 9px 13px; - scrollbar-width: none; - scroll-snap-type: x proximity; + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 2px; + padding: 11px 6px 13px; } .weather-hour { display: flex; align-items: center; - flex: 0 0 52px; flex-direction: column; + min-width: 0; min-height: 104px; - padding: 3px 2px; - scroll-snap-align: start; + padding: 3px 1px; } .weather-hour span { height: 21px; diff --git a/frontend/src/components/PhoneHomeIndicator.vue b/frontend/src/components/PhoneHomeIndicator.vue index 823d2ae..626162a 100644 --- a/frontend/src/components/PhoneHomeIndicator.vue +++ b/frontend/src/components/PhoneHomeIndicator.vue @@ -14,8 +14,12 @@ const canGoHome = computed( function goHome(): void { if (!canGoHome.value) return + if (route.name !== 'home') { + void router.push('/') + return + } + phone.setCurrentPage(HOME_PAGE) - if (route.name !== 'home') void router.push('/') } @@ -25,7 +29,6 @@ function goHome(): void { :class="{ 'phone-home-indicator--interactive': canGoHome }" type="button" :aria-label="phone.t('Common.home')" - @pointerdown.stop="goHome" @click.stop="goHome" > diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index 01be3d3..88c34a9 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -47,6 +47,11 @@ describe('app registry', () => { labelKey: 'Apps.skyride.name', route: '/apps/skyride', }) + expect(PHONE_APPS.find((app) => app.id === 'house')).toMatchObject({ + category: 'utilities', + labelKey: 'Apps.house.name', + route: '/apps/house', + }) expect(PHONE_APPS.find((app) => app.id === 'calendar')).toMatchObject({ gridOrder: 21, labelKey: 'Apps.calendar.name', diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 16fb02a..1d3a57c 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -27,6 +27,7 @@ import { Tag, MapPinHouse, Flame, + House, } from 'lucide-vue-next' import { defineAsyncComponent, markRaw } from 'vue' @@ -54,6 +55,7 @@ import neonDropIcon from '@/assets/img/app-icons/neon-drop.webp' import weatherIcon from '@/assets/img/app-icons/weather.webp' import bankingIcon from '@/assets/img/app-icons/banking.webp' import garageIcon from '@/assets/img/app-icons/garage.webp' +import houseIcon from '@/assets/img/app-icons/house.svg' import citymarktIcon from '@/assets/img/app-icons/citymarkt.webp' import localPagesIcon from '@/assets/img/app-icons/local-pages.webp' import flareIcon from '@/assets/img/app-icons/flare.svg' @@ -207,6 +209,20 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ labelKey: 'Apps.garage.name', route: '/apps/garage', }, + { + category: 'utilities', + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/HouseApp.vue')), + ), + dockOrder: null, + gridOrder: 25, + icon: markRaw(House), + iconClass: 'app-icon--house', + iconImage: houseIcon, + id: 'house', + labelKey: 'Apps.house.name', + route: '/apps/house', + }, { category: 'utilities', component: markRaw( diff --git a/frontend/src/stores/housing.test.ts b/frontend/src/stores/housing.test.ts new file mode 100644 index 0000000..52b2a72 --- /dev/null +++ b/frontend/src/stores/housing.test.ts @@ -0,0 +1,97 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useHousingStore } from '@/stores/housing' +import type { HousingOverview } from '@/types/housing' +import { nuiCall } from '@/utils/nui' + +vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() })) + +const mockNuiCall = vi.mocked(nuiCall) +const overview: HousingOverview = { + available: true, + provider: 'esx_property', + properties: [ + { + access: 'owner', + capabilities: { + cctv: true, + garageStatus: true, + keys: true, + lock: true, + waypoint: true, + }, + cctv: { enabled: true }, + entrance: { x: 1, y: 2, z: 3 }, + garage: { enabled: true, storedVehicles: 2 }, + id: 'esx_property:1', + keys: [], + locked: true, + name: 'Alta Street 3', + providerId: '1', + }, + ], +} + +describe('housing store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + mockNuiCall.mockReset() + }) + + it('loads a provider-backed housing overview', async () => { + mockNuiCall.mockResolvedValueOnce({ data: overview, success: true }) + const housing = useHousingStore() + + expect(await housing.load()).toBe(true) + expect(housing.overview).toEqual(overview) + expect(mockNuiCall).toHaveBeenCalledWith('housing:overview') + }) + + it('represents a missing provider as an available response with offline data', async () => { + const offline = { available: false, properties: [], provider: null } + mockNuiCall.mockResolvedValueOnce({ data: offline, success: true }) + const housing = useHousingStore() + + expect(await housing.load()).toBe(true) + expect(housing.overview).toEqual(offline) + }) + + it('loads only provider-authorized key candidates', async () => { + mockNuiCall.mockResolvedValueOnce({ + data: { candidates: [{ id: 42, name: 'Alex Morgan' }] }, + success: true, + }) + const housing = useHousingStore() + + expect(await housing.loadKeyCandidates('esx_property:1')).toBe(true) + expect(housing.candidates).toEqual([{ id: 42, name: 'Alex Morgan' }]) + }) + + it('refreshes the overview after a successful state mutation', async () => { + mockNuiCall + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ data: overview, success: true }) + const housing = useHousingStore() + + expect(await housing.command('toggle_lock', 'esx_property:1')).toBe(true) + expect(mockNuiCall).toHaveBeenNthCalledWith(1, 'housing:command', { + action: 'toggle_lock', + propertyId: 'esx_property:1', + }) + expect(mockNuiCall).toHaveBeenNthCalledWith(2, 'housing:overview') + }) + + it('keeps the overview and exposes a rejected action', async () => { + mockNuiCall.mockResolvedValueOnce({ + error: 'property_access_denied', + success: false, + }) + const housing = useHousingStore() + housing.overview = overview + + expect(await housing.command('toggle_lock', 'esx_property:1')).toBe(false) + expect(housing.overview).toEqual(overview) + expect(housing.error).toBe('property_access_denied') + }) +}) diff --git a/frontend/src/stores/housing.ts b/frontend/src/stores/housing.ts new file mode 100644 index 0000000..218ef2c --- /dev/null +++ b/frontend/src/stores/housing.ts @@ -0,0 +1,75 @@ +import { defineStore } from 'pinia' + +import type { + HousingCommand, + HousingKeyCandidate, + HousingOverview, +} from '@/types/housing' +import { nuiCall } from '@/utils/nui' + +export const useHousingStore = defineStore('housing', { + state: () => ({ + candidates: [] as HousingKeyCandidate[], + error: '', + isLoading: false, + isLoadingCandidates: false, + overview: null as HousingOverview | null, + pendingAction: '', + }), + actions: { + async load(): Promise { + this.isLoading = true + const response = await nuiCall('housing:overview') + this.isLoading = false + if (response.success && response.data) { + this.overview = response.data + this.error = '' + return true + } + this.error = response.error ?? 'request_failed' + return false + }, + async loadKeyCandidates(propertyId: string): Promise { + this.isLoadingCandidates = true + this.candidates = [] + const response = await nuiCall<{ candidates: HousingKeyCandidate[] }>( + 'housing:key-candidates', + { propertyId }, + ) + this.isLoadingCandidates = false + if (response.success && response.data) { + this.candidates = response.data.candidates + this.error = '' + return true + } + this.error = response.error ?? 'request_failed' + return false + }, + async command( + action: HousingCommand, + propertyId: string, + payload: Record = {}, + ): Promise { + this.pendingAction = `${action}:${propertyId}` + const response = await nuiCall('housing:command', { + action, + propertyId, + ...payload, + }) + this.pendingAction = '' + if (!response.success) { + this.error = response.error ?? 'request_failed' + return false + } + this.error = '' + if ( + action === 'toggle_lock' || + action === 'grant_key' || + action === 'revoke_key' + ) { + await this.load() + } + return true + }, + }, +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index cfd0c6e..bbb767d 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -1233,6 +1233,88 @@ const defaultLocales: LocaleTree = { default: 'SkyRide could not complete the request.', }, }, + house: { + name: 'House', + subtitle: 'Homes and access', + myHomes: 'My Homes', + properties: 'Properties', + owned: 'Owned', + shared: 'Shared with you', + locked: 'Locked', + unlocked: 'Unlocked', + owner: 'Owner', + keyholder: 'Key holder', + openDetails: 'Open property details', + tryAgain: 'Try Again', + refresh: 'Refresh', + offline: 'House is offline', + offlineBody: + 'Start a supported housing resource or review Config.Housing.', + unavailable: 'Homes unavailable', + empty: 'No Homes', + emptyBody: 'Owned homes and homes shared with you will appear here.', + provider: 'Housing data · {system}', + access: 'Access', + status: 'Status', + actions: 'Controls', + setWaypoint: 'Directions', + viewCamera: 'View Camera', + garage: 'Garage', + garageEnabled: 'Enabled', + garageDisabled: 'Unavailable', + storedVehicles: '{count} stored', + camera: 'Camera', + cameraAvailable: 'Ready', + cameraUnavailable: 'Unavailable', + keys: 'Keys', + manageKeys: 'Manage Keys', + addKey: 'Add Key', + revokeKey: 'Revoke', + noKeys: 'No shared keys', + noKeysBody: 'Only you currently have access to this home.', + chooseResident: 'Give a Key', + chooseResidentBody: 'Choose a person currently inside this property.', + noCandidates: 'Nobody is waiting inside', + noCandidatesBody: + 'A person must be inside the property before you can give them a key.', + revokeTitle: 'Revoke Key?', + revokeBody: '{name} will lose access to this property.', + offlineKey: 'Offline', + onlineKey: 'Online', + cancel: 'Cancel', + confirm: 'Confirm', + lockSuccess: 'Door status updated.', + waypointSuccess: 'Route set on your map.', + keyGranted: 'Key granted.', + keyRevoked: 'Key revoked.', + cameraStarting: 'Opening camera…', + errors: { + provider_unavailable: 'The housing provider is unavailable.', + provider_error: 'The housing provider could not be reached.', + housing_unavailable: 'Housing data is unavailable for this character.', + property_not_found: 'This property no longer exists.', + property_access_denied: 'You no longer have access to this property.', + owner_required: 'Only the property owner can do that.', + invalid_property: 'Select a valid property.', + invalid_action: 'This housing action is unavailable.', + capability_unavailable: + 'This property provider does not support that control.', + invalid_target: 'Select a valid resident.', + target_not_in_property: 'That person is no longer inside the property.', + key_not_found: 'That key no longer exists.', + key_already_exists: 'That person already has access to this property.', + keyholder_offline: + 'Offline keys must be revoked through the property menu.', + cctv_unavailable: 'The camera is unavailable for this property.', + provider_rejected: 'The housing provider rejected the request.', + action_failed: 'The property change could not be saved.', + rate_limited: 'Please wait before using another housing control.', + device_not_open: 'Open the phone again to manage your homes.', + device_locked: 'Unlock the phone to manage your homes.', + request_failed: 'The housing request failed.', + default: 'The housing request failed.', + }, + }, calculator: { name: 'Calculator' }, snake: { name: 'Snake', diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index 5987788..732dcf9 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -11,6 +11,7 @@ export type PhoneAppId = | 'weather' | 'banking' | 'garage' + | 'house' | 'mail' | 'map' | 'notes' diff --git a/frontend/src/types/housing.ts b/frontend/src/types/housing.ts new file mode 100644 index 0000000..7c2dbfa --- /dev/null +++ b/frontend/src/types/housing.ts @@ -0,0 +1,47 @@ +export type HousingAccess = 'owner' | 'keyholder' + +export type HousingCapabilities = { + cctv: boolean + garageStatus: boolean + keys: boolean + lock: boolean + waypoint: boolean +} + +export type HousingKey = { + identifier: string + name: string + online: boolean + revocable?: boolean +} + +export type HousingKeyCandidate = { + id: number + name: string +} + +export type HousingProperty = { + access: HousingAccess + capabilities: HousingCapabilities + cctv: { enabled: boolean } + entrance: { x: number; y: number; z: number } + garage: { enabled: boolean; storedVehicles: number } | null + id: string + keys?: HousingKey[] + locked: boolean + name: string + providerId: string +} + +export type HousingOverview = { + available: boolean + properties: HousingProperty[] + provider: string | null +} + +export type HousingCommand = + | 'grant_key' + | 'open_cctv' + | 'revoke_key' + | 'set_waypoint' + | 'toggle_lock' diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index d4f5a57..86a2c67 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -90,6 +90,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< banking: { enabled: true, sounds: true }, garage: { enabled: true, sounds: true }, skyride: { enabled: true, sounds: true }, + house: { enabled: true, sounds: true }, mail: { enabled: true, sounds: true }, map: { enabled: true, sounds: true }, notes: { enabled: true, sounds: true }, diff --git a/frontend/src/views/apps/HouseApp.vue b/frontend/src/views/apps/HouseApp.vue new file mode 100644 index 0000000..0a8665e --- /dev/null +++ b/frontend/src/views/apps/HouseApp.vue @@ -0,0 +1,1017 @@ + + + + + diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 335adf1..e2a1648 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -617,6 +617,48 @@ function skyRideUpdate(fields) { ) } +const mockHousingOverview = { + available: true, + provider: 'esx_property', + properties: [ + { + id: 'esx_property:1', + providerId: '1', + name: 'Alta Street Apartment', + access: 'owner', + locked: true, + entrance: { x: -268.9, y: -962.3, z: 31.2 }, + capabilities: { + lock: true, + keys: true, + waypoint: true, + cctv: true, + garageStatus: true, + }, + cctv: { enabled: true }, + garage: { enabled: true, storedVehicles: 2 }, + keys: [{ identifier: 'char2:mock', name: 'Jamie Rivera', online: true }], + }, + { + id: 'esx_property:2', + providerId: '2', + name: 'Vespucci Beach House', + access: 'keyholder', + locked: false, + entrance: { x: -1150.1, y: -1520.8, z: 10.6 }, + capabilities: { + lock: true, + keys: false, + waypoint: true, + cctv: false, + garageStatus: false, + }, + cctv: { enabled: false }, + garage: null, + }, + ], +} + let contactSequence = 2 const contacts = [ { @@ -2859,6 +2901,31 @@ app.post('/api/:endpoint', (request, response) => { }) return } + if (endpoint === 'housing:overview') { + response.json({ success: true, data: mockHousingOverview }) + return + } + if (endpoint === 'housing:key-candidates') { + response.json({ + success: true, + data: { candidates: [{ id: 27, name: 'Alex Morgan' }] }, + }) + return + } + if (endpoint === 'housing:command') { + const property = mockHousingOverview.properties.find( + (item) => item.id === request.body.propertyId, + ) + if (!property) { + response.json({ success: false, error: 'property_not_found' }) + return + } + if (request.body.action === 'toggle_lock') { + property.locked = !property.locked + } + response.json({ success: true, data: { accepted: true } }) + return + } if (endpoint === 'garage:valet-state') { response.json({ success: true, data: mockGarageValet }) return