ADD - build Housing phone app

This commit is contained in:
Type
2026-08-09 22:12:38 +02:00
parent 6702cd8933
commit 2e4843cfbe
14 changed files with 1490 additions and 26 deletions
+38 -8
View File
@@ -201,6 +201,7 @@ const passcodeVisible = ref(false)
const pendingUnlockRoute = ref<string | null>(null)
const unlockedServicesLoaded = ref(false)
const controlCenterOpened = ref(false)
const activitySuspended = ref(false)
const simPicker = ref<SimPickerPayload | null>(null)
const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)')
const viewportScale = ref(getViewportScale())
@@ -215,6 +216,10 @@ const phoneResolutionStyle = computed<CSSProperties>(() => ({
'--phone-zoom':
phoneBaseZoom.value * (phone.preferences.settings.phoneScale / 100),
}))
const phoneStageStyle = computed<CSSProperties>(() => ({
...phoneResolutionStyle.value,
visibility: activitySuspended.value ? 'hidden' : 'visible',
}))
const phoneDisplayStyle = computed<CSSProperties>(() => ({
'--phone-display-dim': String(
((100 - phone.preferences.settings.screenBrightness) / 100) * 0.8,
@@ -226,6 +231,7 @@ const phoneFrameImage = computed(
let clockTicker: ReturnType<typeof setInterval> | 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<void> {
@@ -288,7 +305,12 @@ function onMessage(event: MessageEvent<AppMessage>): 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<AppMessage>): 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"
>
<div class="phone-device-row">
<NotificationPhonePreview
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="House">
<defs>
<linearGradient id="house-bg" x1="18" y1="12" x2="108" y2="118" gradientUnits="userSpaceOnUse">
<stop stop-color="#ffb84d"/>
<stop offset="1" stop-color="#f06b35"/>
</linearGradient>
<filter id="house-shadow" x="-20%" y="-20%" width="140%" height="150%">
<feDropShadow dx="0" dy="5" stdDeviation="5" flood-color="#8f351c" flood-opacity=".28"/>
</filter>
</defs>
<rect width="128" height="128" rx="29" fill="url(#house-bg)"/>
<circle cx="101" cy="25" r="23" fill="#fff" opacity=".13"/>
<path d="M27 61.5 64 31l37 30.5v36a9 9 0 0 1-9 9H36a9 9 0 0 1-9-9z" fill="#fff" filter="url(#house-shadow)"/>
<path d="M19 64.5 64 27l45 37.5" fill="none" stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="11"/>
<rect x="53" y="73" width="22" height="33.5" rx="5" fill="#f4823c"/>
<circle cx="69" cy="90" r="2.4" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 993 B

+22 -16
View File
@@ -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;
@@ -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('/')
}
</script>
@@ -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"
>
<span aria-hidden="true"></span>
+5
View File
@@ -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',
+16
View File
@@ -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(
+97
View File
@@ -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')
})
})
+75
View File
@@ -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<boolean> {
this.isLoading = true
const response = await nuiCall<HousingOverview>('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<boolean> {
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<string, unknown> = {},
): Promise<boolean> {
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
},
},
})
+82
View File
@@ -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',
+1
View File
@@ -11,6 +11,7 @@ export type PhoneAppId =
| 'weather'
| 'banking'
| 'garage'
| 'house'
| 'mail'
| 'map'
| 'notes'
+47
View File
@@ -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'
+1
View File
@@ -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 },
File diff suppressed because it is too large Load Diff
+67
View File
@@ -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