mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
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:
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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+')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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...',
|
||||
|
||||
@@ -9,6 +9,7 @@ export type BuiltinPhoneAppId =
|
||||
| 'clock'
|
||||
| 'calendar'
|
||||
| 'weather'
|
||||
| 'health'
|
||||
| 'banking'
|
||||
| 'billing'
|
||||
| 'garage'
|
||||
|
||||
@@ -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'>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user