ADD - introduce Health phone app

Add the Health app with server-authoritative health, wellness, medical ID, and emergency-contact data flows.

Register the app in the phone UI, add localized configuration and NUI coverage, persist its schema through the resource migration and install SQL, and include design references and the WebP app icon.
This commit is contained in:
Type
2026-08-14 23:23:46 +02:00
parent 25c8adb737
commit 56ca2c5ba9
23 changed files with 2052 additions and 3 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

+7
View File
@@ -32,6 +32,13 @@ describe('app registry', () => {
labelKey: 'Apps.weather.name',
route: '/apps/weather',
})
expect(PHONE_APPS.find((app) => app.id === 'health')).toMatchObject({
category: 'utilities',
gridOrder: 11,
labelKey: 'Apps.health.name',
route: '/apps/health',
})
expect(isPhoneAppId('health')).toBe(true)
expect(PHONE_APPS.find((app) => app.id === 'banking')).toMatchObject({
gridOrder: 5,
labelKey: 'Apps.banking.name',
+18
View File
@@ -35,6 +35,7 @@ import {
UsersRound,
Building2,
Newspaper,
HeartPulse,
} from 'lucide-vue-next'
import { defineAsyncComponent, markRaw, shallowReactive } from 'vue'
@@ -61,6 +62,7 @@ import towerStackIcon from '@/assets/img/app-icons/tower-stack.webp'
import skyFlappyIcon from '@/assets/img/app-icons/sky-flappy.webp'
import neonDropIcon from '@/assets/img/app-icons/neon-drop.webp'
import weatherIcon from '@/assets/img/app-icons/weather.webp'
import healthIcon from '@/assets/img/app-icons/health.webp'
import bankingIcon from '@/assets/img/app-icons/banking.webp'
import billingIcon from '@/assets/img/app-icons/billing.svg'
import garageIcon from '@/assets/img/app-icons/garage.webp'
@@ -87,6 +89,20 @@ import type {
} from '@/types/apps'
export const PHONE_APPS = shallowReactive<PhoneAppDefinition[]>([
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/HealthApp.vue')),
),
dockOrder: null,
gridOrder: 11,
icon: markRaw(HeartPulse),
iconClass: 'app-icon--health',
iconImage: healthIcon,
id: 'health',
labelKey: 'Apps.health.name',
route: '/apps/health',
},
{
category: 'social',
component: markRaw(
@@ -643,6 +659,7 @@ export const DEFAULT_INSTALLED_PHONE_APP_IDS: ReadonlySet<BuiltinPhoneAppId> =
'settings',
'map',
'calendar',
'health',
])
export const NON_REMOVABLE_PHONE_APP_IDS: ReadonlySet<LaunchablePhoneAppId> =
@@ -654,6 +671,7 @@ export const NON_REMOVABLE_PHONE_APP_IDS: ReadonlySet<LaunchablePhoneAppId> =
'phone',
'messages',
'mail',
'health',
])
export const PHONE_APP_IDS = PHONE_APPS.map((app) => app.id)
+5 -2
View File
@@ -35,7 +35,7 @@ describe('app store', () => {
vi.useRealTimers()
})
it('installs only the fourteen standard built-in apps by default', () => {
it('installs the fifteen system apps by default', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
@@ -55,6 +55,7 @@ describe('app store', () => {
'settings',
'map',
'calendar',
'health',
])
for (const appId of DEFAULT_INSTALLED_PHONE_APP_IDS) {
expect(apps.isInstalled(appId)).toBe(true)
@@ -62,6 +63,7 @@ describe('app store', () => {
expect(apps.isInstalled('banking')).toBe(false)
expect(apps.isInstalled('feather')).toBe(false)
expect(apps.isInstalled('snake')).toBe(false)
expect(apps.isInstalled('health')).toBe(true)
})
it('removes old automatic apps unless the player installed them', () => {
@@ -157,7 +159,7 @@ describe('app store', () => {
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('installs an app after showing a three second loading state', () => {
it('installs a downloadable app after a three second loading state', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
@@ -303,6 +305,7 @@ describe('app store', () => {
'phone',
'messages',
'mail',
'health',
])
for (const appId of NON_REMOVABLE_PHONE_APP_IDS) {
apps.removeHomeApp(appId)
+64
View File
@@ -0,0 +1,64 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useHealthStore } from '@/stores/health'
import type { HealthOverview } from '@/types/health'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
const overview: HealthOverview = {
dailyStepGoal: 8000,
days: [],
emergencyNumber: '911',
medicalId: {
allergies: '',
bloodType: '',
conditions: '',
emergencyName: '',
emergencyPhone: '',
emergencyRelation: '',
medication: '',
playerName: 'Alex Morgan',
},
previousWeekSteps: 0,
snapshot: { healthPercent: 100 },
}
describe('health store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(nuiCall).mockReset()
})
it('loads the server-authoritative health overview', async () => {
vi.mocked(nuiCall).mockResolvedValue({ data: overview, success: true })
const health = useHealthStore()
await expect(health.load()).resolves.toBe(true)
expect(nuiCall).toHaveBeenCalledWith('health:overview')
expect(health.overview?.dailyStepGoal).toBe(8000)
})
it('persists medical ID edits through NUI', async () => {
const health = useHealthStore()
health.overview = structuredClone(overview)
vi.mocked(nuiCall).mockResolvedValue({
data: { ...overview.medicalId, bloodType: 'O+' },
success: true,
})
await expect(
health.saveMedicalId({
allergies: overview.medicalId.allergies,
bloodType: 'O+',
conditions: overview.medicalId.conditions,
emergencyName: overview.medicalId.emergencyName,
emergencyPhone: overview.medicalId.emergencyPhone,
emergencyRelation: overview.medicalId.emergencyRelation,
medication: overview.medicalId.medication,
}),
).resolves.toBe(true)
expect(health.overview.medicalId.bloodType).toBe('O+')
})
})
+54
View File
@@ -0,0 +1,54 @@
import { defineStore } from 'pinia'
import type {
HealthMedicalId,
HealthMedicalIdInput,
HealthOverview,
} from '@/types/health'
import { nuiCall } from '@/utils/nui'
export const useHealthStore = defineStore('health', {
state: () => ({
error: '',
isLoading: false,
isSaving: false,
overview: null as HealthOverview | null,
requestGeneration: 0,
}),
actions: {
async load(): Promise<boolean> {
const generation = ++this.requestGeneration
this.isLoading = true
const response = await nuiCall<HealthOverview>('health:overview').finally(
() => {
if (generation === this.requestGeneration) this.isLoading = false
},
)
if (generation !== this.requestGeneration) return response.success
if (response.success && response.data) {
this.overview = response.data
this.error = ''
return true
}
this.error = response.error ?? 'request_failed'
return false
},
async saveMedicalId(data: HealthMedicalIdInput): Promise<boolean> {
if (this.isSaving) return false
this.isSaving = true
const response = await nuiCall<HealthMedicalId>(
'health:save-profile',
data,
).finally(() => {
this.isSaving = false
})
if (response.success && response.data && this.overview) {
this.overview.medicalId = response.data
this.error = ''
return true
}
this.error = response.error ?? 'request_failed'
return false
},
},
})
+65
View File
@@ -349,6 +349,70 @@ const companiesFallbackLocales = {
},
}
const healthFallbackLocales = {
name: 'Health',
navigation: 'Health navigation',
tabs: { today: 'Today', trends: 'Trends', medicalId: 'Medical ID' },
loading: 'Loading health data...',
errorTitle: 'Health is unavailable',
errorBody: 'Your activity data could not be loaded.',
tryAgain: 'Try Again',
goal: 'of {count}',
steps: 'Steps',
distance: 'Distance',
active: 'Active',
energy: 'Energy',
kilometers: '{count} km',
minutes: '{count} min',
kilocalories: '{count} kcal',
thisWeek: 'This week',
snapshot: 'Health snapshot',
condition: 'Condition',
recovery: 'Recovery',
currentHealth: 'Current health',
conditions: { good: 'Good', fair: 'Fair', low: 'Low' },
trends: {
title: 'Trends',
week: 'Week',
month: 'Month',
total: '{count} steps',
more: '{count}% more than last week',
less: '{count}% less than last week',
same: 'Same as last week',
dailyAverage: 'Daily average',
activeTime: 'Active time',
dailyActivity: 'Daily activity',
goal: 'Goal',
},
medicalId: {
title: 'Medical ID',
edit: 'Edit',
done: 'Done',
resident: 'Los Santos resident',
emergencyInformation: 'Emergency information',
bloodType: 'Blood type',
allergies: 'Allergies',
conditions: 'Conditions',
medication: 'Medication',
noneRecorded: 'None recorded',
emergencyContact: 'Emergency contact',
contactName: 'Contact name',
relation: 'Relation',
phoneNumber: 'Phone number',
emergencyCall: 'Emergency call',
callContact: 'Call emergency contact',
privacy:
'Information is stored with your character and can be shown to emergency services.',
saveFailed: 'Medical ID could not be saved.',
},
errors: {
invalid_phone_number: 'Enter a valid in-game phone number.',
invalid_request: 'Check the Medical ID fields.',
rate_limited: 'Please wait before trying again.',
request_failed: 'Health could not complete the request.',
},
}
const defaultLocales: LocaleTree = {
Apps: {
easyShare: {
@@ -549,6 +613,7 @@ const defaultLocales: LocaleTree = {
},
},
companies: companiesFallbackLocales,
health: healthFallbackLocales,
crewlink: {
name: 'CrewLink',
connecting: 'Connecting your crew...',
+1
View File
@@ -9,6 +9,7 @@ export type BuiltinPhoneAppId =
| 'clock'
| 'calendar'
| 'weather'
| 'health'
| 'banking'
| 'billing'
| 'garage'
+33
View File
@@ -0,0 +1,33 @@
export type HealthActivityDay = {
activeSeconds: number
date: string
distanceMeters: number
energyKcal: number
steps: number
}
export type HealthMedicalId = {
allergies: string
bloodType: string
conditions: string
emergencyName: string
emergencyPhone: string
emergencyRelation: string
medication: string
playerName: string
}
export type HealthSnapshot = {
healthPercent: number
}
export type HealthOverview = {
dailyStepGoal: number
days: HealthActivityDay[]
emergencyNumber: string
medicalId: HealthMedicalId
previousWeekSteps: number
snapshot: HealthSnapshot
}
export type HealthMedicalIdInput = Omit<HealthMedicalId, 'playerName'>
+14
View File
@@ -402,6 +402,20 @@ describe('home layout', () => {
expect(restored.hidden).not.toContain('phone')
})
it('reveals an existing shortcut when restoring a hidden app', () => {
const hidden = {
...defaults,
grid: [...defaults.grid],
hidden: [...defaults.hidden, 'health' as const],
}
hidden.grid[5] = 'health'
const restored = restoreHomeApp(hidden, 'health')
expect(restored.grid[5]).toBe('health')
expect(restored.hidden).not.toContain('health')
})
it('adds persistent empty pages up to the home screen limit', () => {
let layout = defaults
for (let page = 1; page < MAX_HOME_GRID_PAGES; page += 1) {
+5 -1
View File
@@ -601,7 +601,11 @@ export function restoreHomeApp(
layout.grid.some((item) => itemContainsApp(item, appId)) ||
layout.dock.some((item) => itemContainsApp(item, appId))
) {
return layout
if (!layout.hidden.includes(appId)) return layout
return {
...layout,
hidden: layout.hidden.filter((id) => id !== appId),
}
}
const grid = layout.grid.map(cloneItem)
placeInFirstEmptySlot(grid, appId)
+1
View File
@@ -101,6 +101,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
flare: { enabled: true, sounds: true },
'app-store': { enabled: true, sounds: true },
calculator: { enabled: true, sounds: true },
health: { enabled: true, sounds: true },
snake: { enabled: true, sounds: true },
memory: { enabled: true, sounds: true },
'number-merge': { enabled: true, sounds: true },
@@ -0,0 +1,29 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./HealthApp.vue', import.meta.url), 'utf8')
describe('HealthApp Sky UI contract', () => {
it('uses the shared page, navbar, scroll owner, and pill navigation', () => {
expect(source).toContain('<SkyAppPage')
expect(source).toContain('<SkyNavbar')
expect(source).toContain('<SkyScrollArea')
expect(source).toContain('with-tabbar')
expect(source).toContain('<SkyPillNavigation')
expect(source).not.toContain(
'padding: 0 var(--sky-page-gutter) var(--sky-page-space)',
)
})
it('keeps every user-facing label in the locale tree', () => {
expect(source).toContain("phone.t('Apps.health.name')")
expect(source).toContain("phone.t('Apps.health.tabs.today')")
expect(source).toContain("phone.t('Apps.health.medicalId.title')")
})
it('uses the real Health NUI data flow', () => {
expect(source).toContain('void health.load()')
expect(source).toContain("nuiCall('calls:dial'")
expect(source).toContain('health.saveMedicalId')
})
})
File diff suppressed because it is too large Load Diff