From 56ca2c5ba9a5543e71b4f5e52fc184f501cc17f1 Mon Sep 17 00:00:00 2001 From: Type <79042381+TypeFor@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:23:46 +0200 Subject: [PATCH] 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. --- frontend/src/assets/img/app-icons/health.webp | Bin 0 -> 60634 bytes frontend/src/config/apps.test.ts | 7 + frontend/src/config/apps.ts | 18 + frontend/src/stores/app-store.test.ts | 7 +- frontend/src/stores/health.test.ts | 64 + frontend/src/stores/health.ts | 54 + frontend/src/stores/phone.ts | 65 + frontend/src/types/apps.ts | 1 + frontend/src/types/health.ts | 33 + frontend/src/utils/homeLayout.test.ts | 14 + frontend/src/utils/homeLayout.ts | 6 +- frontend/src/utils/preferences.ts | 1 + .../src/views/apps/HealthApp.contract.test.ts | 29 + frontend/src/views/apps/HealthApp.vue | 1179 +++++++++++++++++ frontend/testserver/index.cjs | 53 + frontend/testserver/smoke.cjs | 24 + sky_phone/config/config.lua | 10 + sky_phone/config/locales/en.lua | 45 + sky_phone/fxmanifest.lua | 2 + sky_phone/source/client/health.lua | 111 ++ sky_phone/source/server/db_migrate.lua | 37 + sky_phone/source/server/health.lua | 268 ++++ sky_phone/sql/install.sql | 27 + 23 files changed, 2052 insertions(+), 3 deletions(-) create mode 100644 frontend/src/assets/img/app-icons/health.webp create mode 100644 frontend/src/stores/health.test.ts create mode 100644 frontend/src/stores/health.ts create mode 100644 frontend/src/types/health.ts create mode 100644 frontend/src/views/apps/HealthApp.contract.test.ts create mode 100644 frontend/src/views/apps/HealthApp.vue create mode 100644 sky_phone/source/client/health.lua create mode 100644 sky_phone/source/server/health.lua diff --git a/frontend/src/assets/img/app-icons/health.webp b/frontend/src/assets/img/app-icons/health.webp new file mode 100644 index 0000000000000000000000000000000000000000..be26df8afb9efbdf14bc40f0db8841d1f5f28d20 GIT binary patch literal 60634 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 z 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
of3l7uB28$An0J`D{8{2}etp zVrrKals2vkvckPSTzGp|ChC`J8NWXvwJk0s9L{Kqr#&k6oPUenWrzYR%+ue( zkb7T@SI&r{3ZB)~N(JoQt(b8RriP)jjnhgdsH>rzUTCcW-qWLvO>)pDOd8h=|)NNdeTu6C+pXotA;z=x2aXe0>;$q-&z;Q;*bXtoNw*N`mhex2IzHN1*fRx@& zayid=S(8wgq;N>{MG)jsq_=Vi^=bpzD zFj!2E1cyG&#bPm*rYR1ojN>vmrfK{njWKkxY}OPqq^8%q1lJUc`Q1MW?Z~`r9d_cx z?i4QB3gUJMe;)5SOm$zb>Ko9##QGa!hqMAQcU6)5&{PjrhM#=|h1@t=6^gD{CrHt{ z44D>0JUV?=cK{W&(S-FZERAwLipkh_000000O5)}Z6gCoVnxYU|AkY452Ce!hAs+a z#K?YPGQO4)AlFDnl&2`2x~Ql>WO_TDS)jJ4A+~2RwP7USxORQijNj)J{bYKPRqgmk zQLuTATp~jsv3l^B#1K%l4@_<(=fE5h(?JmKT0G)j7q11&i1-sUL~_BV|IE~i${IfB z)j9$dQnz^~7G?5EgdriGNv0?&6mrU(i^ml5O4io!{bpuC#=UF-xjm^mDCt;WBtsLR z7h}7Sv_c;vb$}xI`|8tXvmXgrhwfGKh9yh)j>C~qwr_G`bGTP%A_GD-A!jCqE@Q)B zXrKyE1fjLW7SHC{n!7)7ek8ZDH}uLQ=5FpN0000000001YC50p^{-Q+jB0#j;y@W@ zo+~U!TK%0>A|UN)Weh%mREEz_sX-c6*&T>WVC%jVe_d4UQO8mtv3X_B=tvB)bDG%s zEw?RH1%cCvkxskK=-?eUEyTkyc}2kBM=YEeN(10%^jjhVx7_xcn1p-}3Iu}*6+mXn zV?H4=Ixn5>yNG*y-*&D~VUHgavW3oiZKK{PF8TMpEvCAxJWT{5OYRFImWSE&lXU$9 zmEU5vbMlJ&G*jMSm9dHX%&(B3rYf;(MBj$*V$C4|=PD`I7aq@FRy;6!kg592xLT3A8EKhOXG008$`00n#i0000000000 b000Mf^8f$<000007zb1U0002HqJRJZj}k3+ literal 0 HcmV?d00001 diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index 09fd166..b7fe0a7 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -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', diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index dd63476..69b1cf3 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -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([ + { + 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 = 'settings', 'map', 'calendar', + 'health', ]) export const NON_REMOVABLE_PHONE_APP_IDS: ReadonlySet = @@ -654,6 +671,7 @@ export const NON_REMOVABLE_PHONE_APP_IDS: ReadonlySet = 'phone', 'messages', 'mail', + 'health', ]) export const PHONE_APP_IDS = PHONE_APPS.map((app) => app.id) diff --git a/frontend/src/stores/app-store.test.ts b/frontend/src/stores/app-store.test.ts index 5ac2891..9b14196 100644 --- a/frontend/src/stores/app-store.test.ts +++ b/frontend/src/stores/app-store.test.ts @@ -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) diff --git a/frontend/src/stores/health.test.ts b/frontend/src/stores/health.test.ts new file mode 100644 index 0000000..729a192 --- /dev/null +++ b/frontend/src/stores/health.test.ts @@ -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+') + }) +}) diff --git a/frontend/src/stores/health.ts b/frontend/src/stores/health.ts new file mode 100644 index 0000000..bad35b9 --- /dev/null +++ b/frontend/src/stores/health.ts @@ -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 { + const generation = ++this.requestGeneration + this.isLoading = true + const response = await nuiCall('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 { + if (this.isSaving) return false + this.isSaving = true + const response = await nuiCall( + '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 + }, + }, +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 34baa40..231868b 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -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...', diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index 24829dd..51d903e 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -9,6 +9,7 @@ export type BuiltinPhoneAppId = | 'clock' | 'calendar' | 'weather' + | 'health' | 'banking' | 'billing' | 'garage' diff --git a/frontend/src/types/health.ts b/frontend/src/types/health.ts new file mode 100644 index 0000000..d28fa9b --- /dev/null +++ b/frontend/src/types/health.ts @@ -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 diff --git a/frontend/src/utils/homeLayout.test.ts b/frontend/src/utils/homeLayout.test.ts index a6a8d88..402bb1b 100644 --- a/frontend/src/utils/homeLayout.test.ts +++ b/frontend/src/utils/homeLayout.test.ts @@ -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) { diff --git a/frontend/src/utils/homeLayout.ts b/frontend/src/utils/homeLayout.ts index 0826cfd..91ed923 100644 --- a/frontend/src/utils/homeLayout.ts +++ b/frontend/src/utils/homeLayout.ts @@ -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) diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 7b00633..d2a3335 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -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 }, diff --git a/frontend/src/views/apps/HealthApp.contract.test.ts b/frontend/src/views/apps/HealthApp.contract.test.ts new file mode 100644 index 0000000..3950b7f --- /dev/null +++ b/frontend/src/views/apps/HealthApp.contract.test.ts @@ -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(' { + 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') + }) +}) diff --git a/frontend/src/views/apps/HealthApp.vue b/frontend/src/views/apps/HealthApp.vue new file mode 100644 index 0000000..34aa4d1 --- /dev/null +++ b/frontend/src/views/apps/HealthApp.vue @@ -0,0 +1,1179 @@ + + + + + diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 1041411..a3c7411 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -40,6 +40,41 @@ function unixTime(offsetSeconds = 0) { return Math.floor(Date.now() / 1000) + offsetSeconds } +function healthDate(dayOffset) { + const value = new Date() + value.setDate(value.getDate() + dayOffset) + return value.toISOString().slice(0, 10) +} + +const healthStepSamples = [4289, 6312, 3421, 7024, 8618, 4302, 6420] +let healthMedicalId = { + allergies: '', + bloodType: 'O+', + conditions: '', + emergencyName: 'Jamie Morgan', + emergencyPhone: '5550102211', + emergencyRelation: 'Sibling', + medication: '', + playerName: 'Alex Morgan', +} + +function healthOverview() { + return { + dailyStepGoal: 8000, + days: healthStepSamples.map((steps, index) => ({ + activeSeconds: Math.round(steps * 0.36), + date: healthDate(index - 6), + distanceMeters: Math.round(steps * 0.75), + energyKcal: Math.round(steps * 0.045), + steps, + })), + emergencyNumber: '911', + medicalId: healthMedicalId, + previousWeekSteps: 36980, + snapshot: { healthPercent: 96 }, + } +} + function memoWaveform(phase = 0) { return Array.from({ length: 48 }, (_, index) => Number( @@ -7532,6 +7567,24 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: true, data: bankingOverview() }) return } + if (endpoint === 'health:overview') { + response.json({ success: true, data: healthOverview() }) + return + } + if (endpoint === 'health:save-profile') { + healthMedicalId = { + ...healthMedicalId, + allergies: String(request.body.allergies ?? ''), + bloodType: String(request.body.bloodType ?? ''), + conditions: String(request.body.conditions ?? ''), + emergencyName: String(request.body.emergencyName ?? ''), + emergencyPhone: String(request.body.emergencyPhone ?? ''), + emergencyRelation: String(request.body.emergencyRelation ?? ''), + medication: String(request.body.medication ?? ''), + } + response.json({ success: true, data: healthMedicalId }) + return + } if (endpoint === 'billing:overview') { response.json({ success: true, diff --git a/frontend/testserver/smoke.cjs b/frontend/testserver/smoke.cjs index 79206a1..9f47480 100644 --- a/frontend/testserver/smoke.cjs +++ b/frontend/testserver/smoke.cjs @@ -7,6 +7,7 @@ const browserDataRequests = [ ['development:bootstrap', {}], ['account:devices', {}], ['banking:overview', {}], + ['health:overview', {}], ['billing:overview', {}], ['billing:list', { filter: 'all', limit: 20, offset: 0 }], ['calendar:list', { endsAt: 4_102_444_800, startsAt: 0 }], @@ -321,6 +322,29 @@ async function verifyStatefulActions(baseUrl) { ) assert.equal(bankingAfter.bank, bankingBefore.bank - 125) + const health = await expectSuccess(baseUrl, 'health:overview', {}, true) + assert.equal(health.dailyStepGoal, 8000) + assert.equal(health.days.length, 7) + assert.equal(health.days.at(-1).steps, 6420) + assert.equal(health.snapshot.healthPercent, 96) + + const medicalId = await expectSuccess( + baseUrl, + 'health:save-profile', + { + allergies: 'Penicillin', + bloodType: 'A+', + conditions: 'Asthma', + emergencyName: 'Jamie Morgan', + emergencyPhone: '5550102211', + emergencyRelation: 'Sibling', + medication: 'Inhaler', + }, + true, + ) + assert.equal(medicalId.bloodType, 'A+') + assert.equal(medicalId.allergies, 'Penicillin') + let radio = await expectSuccess( baseUrl, 'radio:connect', diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index a865cf5..845fce6 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -270,6 +270,16 @@ Config.Banking = { HistoryLimit = 50, } +Config.Health = { + DailyStepGoal = 8000, + SampleIntervalMs = 500, + ReportIntervalSeconds = 30, + ReportsPerMinute = 4, + MaximumSpeedMetersPerSecond = 12.0, + EmergencyNumber = "911", + ProfileTextMaxLength = 500, +} + Config.Billing = { Enabled = true, Currency = "$", diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 002bfa1..db87d87 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -184,6 +184,51 @@ Locales["en"] = { }, }, Apps = { + health = { + 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.", + }, + }, customApps = { loading = "Opening app...", unavailableTitle = "App unavailable", diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index c1d31a8..efe1958 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -37,6 +37,7 @@ client_scripts { 'source/client/skyride.lua', 'source/client/housing.lua', 'source/client/crewlink.lua', + 'source/client/health.lua', 'source/bridge/client/radio.lua', 'source/client/payphones.lua', 'source/client/custom_apps.lua', @@ -87,6 +88,7 @@ server_scripts { 'source/server/notes.lua', 'source/server/mail.lua', 'source/server/banking.lua', + 'source/server/health.lua', 'source/server/billing.lua', 'source/server/garage.lua', 'source/server/housing.lua', diff --git a/sky_phone/source/client/health.lua b/sky_phone/source/client/health.lua new file mode 100644 index 0000000..94a8d22 --- /dev/null +++ b/sky_phone/source/client/health.lua @@ -0,0 +1,111 @@ +local pending_steps = 0 +local pending_distance = 0.0 +local pending_active_seconds = 0.0 +local step_progress = 0.0 +local last_coords = nil +local last_report_at = GetGameTimer() + +local function health_snapshot() + local ped = PlayerPedId() + if not DoesEntityExist(ped) then + return { healthPercent = 0 } + end + + local health = GetEntityHealth(ped) + local maximum = math.max(1, GetEntityMaxHealth(ped)) + local base = maximum > 100 and 100 or 0 + local percentage = math.floor(math.max(0, math.min(100, (health - base) / (maximum - base) * 100)) + 0.5) + return { healthPercent = percentage } +end + +local function flush_activity() + last_report_at = GetGameTimer() + local distance_meters = math.floor(pending_distance + 0.5) + local active_seconds = math.floor(pending_active_seconds + 0.5) + if pending_steps == 0 and distance_meters == 0 and active_seconds == 0 then + return + end + + TriggerServerEvent("sky_phone:health:record-activity", { + steps = pending_steps, + distanceMeters = distance_meters, + activeSeconds = active_seconds, + }) + pending_steps = 0 + pending_distance = 0.0 + pending_active_seconds = 0.0 +end + +CreateThread(function() + while true do + Wait(Config.Health.SampleIntervalMs) + + local ped = PlayerPedId() + if DoesEntityExist(ped) then + local coords = GetEntityCoords(ped) + local is_moving_on_foot = IsPedOnFoot(ped) + and not IsPedDeadOrDying(ped, true) + and not IsPedFalling(ped) + and not IsPedRagdoll(ped) + and (IsPedWalking(ped) or IsPedRunning(ped) or IsPedSprinting(ped)) + + if last_coords and is_moving_on_foot then + local delta_x = coords.x - last_coords.x + local delta_y = coords.y - last_coords.y + local distance = math.sqrt(delta_x * delta_x + delta_y * delta_y) + local maximum_sample_distance = Config.Health.MaximumSpeedMetersPerSecond + * Config.Health.SampleIntervalMs / 1000.0 + if distance >= 0.04 and distance <= maximum_sample_distance then + local stride_length = 0.75 + if IsPedSprinting(ped) then + stride_length = 1.15 + elseif IsPedRunning(ped) then + stride_length = 1.0 + end + + pending_distance = pending_distance + distance + pending_active_seconds = pending_active_seconds + Config.Health.SampleIntervalMs / 1000.0 + step_progress = step_progress + distance + while step_progress >= stride_length do + step_progress = step_progress - stride_length + pending_steps = pending_steps + 1 + end + end + end + last_coords = coords + else + last_coords = nil + end + + if GetGameTimer() - last_report_at >= Config.Health.ReportIntervalSeconds * 1000 then + flush_activity() + end + end +end) + +AddEventHandler("playerSpawned", function() + last_coords = nil +end) + +AddEventHandler("onResourceStop", function(resource_name) + if resource_name == GetCurrentResourceName() then + flush_activity() + end +end) + +RegisterNUICallback("health:overview", function(data, cb) + local result = Bridge.Callbacks.Trigger("sky_phone:health:overview", data or {}) + if result and result.success and result.data then + result.data.snapshot = health_snapshot() + end + cb(result or { success = false, error = "request_failed" }) +end) + +RegisterNUICallback("health:save-profile", function(data, cb) + local result = Bridge.Callbacks.Trigger("sky_phone:health:save-profile", data or {}) + cb(result or { success = false, error = "request_failed" }) +end) + +RegisterNetEvent("sky_phone:health:changed", function() + SendNUIMessage({ type = "health:changed" }) +end) diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 65081d4..2305a49 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -532,6 +532,43 @@ local schema = { }, tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", }, + { + name = "sky_phone_health_daily", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { name = "owner_identifier", type = "VARCHAR(80) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "activity_date", type = "DATE NOT NULL" }, + { name = "steps", type = "INT UNSIGNED NOT NULL DEFAULT 0" }, + { name = "distance_meters", type = "INT UNSIGNED NOT NULL DEFAULT 0" }, + { name = "active_seconds", type = "INT UNSIGNED NOT NULL DEFAULT 0" }, + { name = "energy_kcal", type = "INT UNSIGNED NOT NULL DEFAULT 0" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_health_daily", columns = "(`owner_identifier`, `activity_date`)" }, + }, + indexes = { + { name = "idx_sky_phone_health_history", columns = "(`owner_identifier`, `activity_date`)" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_health_profiles", + columns = { + { name = "owner_identifier", type = "VARCHAR(80) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "blood_type", type = "VARCHAR(3) NOT NULL DEFAULT ''", characterSet = "ascii", collation = "ascii_general_ci" }, + { name = "allergies", type = "VARCHAR(500) NOT NULL DEFAULT ''" }, + { name = "conditions", type = "VARCHAR(500) NOT NULL DEFAULT ''" }, + { name = "medication", type = "VARCHAR(500) NOT NULL DEFAULT ''" }, + { name = "emergency_name", type = "VARCHAR(80) NOT NULL DEFAULT ''" }, + { name = "emergency_relation", type = "VARCHAR(40) NOT NULL DEFAULT ''" }, + { name = "emergency_phone", type = "VARCHAR(24) NOT NULL DEFAULT ''", characterSet = "ascii", collation = "ascii_bin" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "owner_identifier", + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, { name = "sky_phone_billing_invoices", columns = { diff --git a/sky_phone/source/server/health.lua b/sky_phone/source/server/health.lua new file mode 100644 index 0000000..f670267 --- /dev/null +++ b/sky_phone/source/server/health.lua @@ -0,0 +1,268 @@ +Bridge.Database.AfterMigration("sky_phone", function() + +local activity_reports = {} +local allowed_blood_types = { + [""] = true, + ["A+"] = true, + ["A-"] = true, + ["B+"] = true, + ["B-"] = true, + ["AB+"] = true, + ["AB-"] = true, + ["O+"] = true, + ["O-"] = true, +} + +local function character_identifier(source, require_session) + if require_session then + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return nil, error_response + end + end + + local identifier = Bridge.Framework.GetIdentifier(source) + if type(identifier) ~= "string" or identifier == "" then + return nil, { success = false, error = "health_unavailable" } + end + return identifier +end + +local function player_name(source) + local firstname = Bridge.Framework.GetFirstname(source) + local lastname = Bridge.Framework.GetLastname(source) + local name = ((firstname or "") .. " " .. (lastname or "")):match("^%s*(.-)%s*$") + if name == "" then + return GetPlayerName(source) or ("Player %s"):format(source) + end + return name +end + +local function clean_text(value, maximum_length) + if type(value) ~= "string" or value:find("[%z\1-\31]") then + return nil + end + local cleaned = value:match("^%s*(.-)%s*$") + if #cleaned > maximum_length then + return nil + end + return cleaned +end + +local function empty_profile(source) + return { + playerName = player_name(source), + bloodType = "", + allergies = "", + conditions = "", + medication = "", + emergencyName = "", + emergencyRelation = "", + emergencyPhone = "", + } +end + +local function profile_for(source, identifier) + local rows = Bridge.Database.Query([[ + SELECT `blood_type`, `allergies`, `conditions`, `medication`, + `emergency_name`, `emergency_relation`, `emergency_phone` + FROM `sky_phone_health_profiles` + WHERE `owner_identifier` = ? + LIMIT 1 + ]], { identifier }) + local row = rows[1] + if not row then + return empty_profile(source) + end + return { + playerName = player_name(source), + bloodType = row.blood_type or "", + allergies = row.allergies or "", + conditions = row.conditions or "", + medication = row.medication or "", + emergencyName = row.emergency_name or "", + emergencyRelation = row.emergency_relation or "", + emergencyPhone = row.emergency_phone or "", + } +end + +local function day_key(timestamp) + return os.date("%Y-%m-%d", timestamp) +end + +local function activity_history(identifier) + local rows = Bridge.Database.Query([[ + SELECT DATE_FORMAT(`activity_date`, '%Y-%m-%d') AS `activity_date`, + `steps`, `distance_meters`, `active_seconds`, `energy_kcal` + FROM `sky_phone_health_daily` + WHERE `owner_identifier` = ? + AND `activity_date` >= DATE_SUB(CURDATE(), INTERVAL 13 DAY) + ORDER BY `activity_date` ASC + ]], { identifier }) + local by_date = {} + for _, row in ipairs(rows) do + by_date[row.activity_date] = row + end + + local now = os.time() + local days = {} + local previous_week_steps = 0 + for days_ago = 13, 0, -1 do + local date = day_key(now - days_ago * 86400) + local row = by_date[date] + local item = { + date = date, + steps = math.max(0, tonumber(row and row.steps) or 0), + distanceMeters = math.max(0, tonumber(row and row.distance_meters) or 0), + activeSeconds = math.max(0, tonumber(row and row.active_seconds) or 0), + energyKcal = math.max(0, tonumber(row and row.energy_kcal) or 0), + } + if days_ago >= 7 then + previous_week_steps = previous_week_steps + item.steps + else + days[#days + 1] = item + end + end + return days, previous_week_steps +end + +local function overview(source, identifier) + local days, previous_week_steps = activity_history(identifier) + return { + dailyStepGoal = Config.Health.DailyStepGoal, + days = days, + previousWeekSteps = previous_week_steps, + medicalId = profile_for(source, identifier), + emergencyNumber = Config.Health.EmergencyNumber, + } +end + +Bridge.Callbacks.Register("sky_phone:health:overview", function(source) + if not SkyPhone.AllowOperation(source, "health_overview", 60, 60) then + return { success = false, error = "rate_limited" } + end + local identifier, error_response = character_identifier(source, true) + if not identifier then + return error_response + end + return { success = true, data = overview(source, identifier) } +end) + +Bridge.Callbacks.Register("sky_phone:health:save-profile", function(source, data) + if not SkyPhone.AllowOperation(source, "health_profile_save", 12, 60) then + return { success = false, error = "rate_limited" } + end + local identifier, error_response = character_identifier(source, true) + if not identifier then + return error_response + end + if type(data) ~= "table" then + return { success = false, error = "invalid_request" } + end + + local blood_type = clean_text(data.bloodType, 3) + local allergies = clean_text(data.allergies, Config.Health.ProfileTextMaxLength) + local conditions = clean_text(data.conditions, Config.Health.ProfileTextMaxLength) + local medication = clean_text(data.medication, Config.Health.ProfileTextMaxLength) + local emergency_name = clean_text(data.emergencyName, 80) + local emergency_relation = clean_text(data.emergencyRelation, 40) + local emergency_phone = clean_text(data.emergencyPhone, 24) + if not blood_type or not allowed_blood_types[blood_type] + or not allergies or not conditions or not medication + or not emergency_name or not emergency_relation or not emergency_phone + then + return { success = false, error = "invalid_request" } + end + if emergency_phone ~= "" then + emergency_phone = SkyPhoneSimNumber.Normalize( + emergency_phone, + Config.Sim.NumberLength, + Config.Sim.NumberPrefix + ) + if not emergency_phone then + return { success = false, error = "invalid_phone_number" } + end + end + + Bridge.Database.Query([[ + INSERT INTO `sky_phone_health_profiles` + (`owner_identifier`, `blood_type`, `allergies`, `conditions`, `medication`, + `emergency_name`, `emergency_relation`, `emergency_phone`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + `blood_type` = VALUES(`blood_type`), + `allergies` = VALUES(`allergies`), + `conditions` = VALUES(`conditions`), + `medication` = VALUES(`medication`), + `emergency_name` = VALUES(`emergency_name`), + `emergency_relation` = VALUES(`emergency_relation`), + `emergency_phone` = VALUES(`emergency_phone`) + ]], { + identifier, + blood_type, + allergies, + conditions, + medication, + emergency_name, + emergency_relation, + emergency_phone, + }) + return { success = true, data = profile_for(source, identifier) } +end) + +RegisterNetEvent("sky_phone:health:record-activity", function(data) + local player_source = source + if type(data) ~= "table" + or type(data.steps) ~= "number" or data.steps ~= math.floor(data.steps) + or type(data.distanceMeters) ~= "number" or data.distanceMeters ~= math.floor(data.distanceMeters) + or type(data.activeSeconds) ~= "number" or data.activeSeconds ~= math.floor(data.activeSeconds) + or data.steps < 0 or data.distanceMeters < 0 or data.activeSeconds < 0 + then + Bridge.Debug("warn", "[sky_phone] Rejected malformed health activity from source %s.", tostring(player_source)) + return + end + if not SkyPhone.AllowOperation(player_source, "health_activity", Config.Health.ReportsPerMinute, 60) then + return + end + + local identifier = character_identifier(player_source, false) + if not identifier then + return + end + local now = os.time() + local previous = activity_reports[player_source] + local elapsed = previous and math.max(1, now - previous) or Config.Health.ReportIntervalSeconds + 5 + activity_reports[player_source] = now + local maximum_distance = math.ceil(elapsed * Config.Health.MaximumSpeedMetersPerSecond) + local maximum_steps = math.ceil(data.distanceMeters / 0.35) + 4 + if data.distanceMeters > maximum_distance + or data.activeSeconds > elapsed + 5 + or data.distanceMeters > data.activeSeconds * Config.Health.MaximumSpeedMetersPerSecond + 5 + or data.steps > maximum_steps + then + Bridge.Debug("warn", "[sky_phone] Rejected implausible health activity from source %s.", tostring(player_source)) + return + end + if data.steps == 0 and data.distanceMeters == 0 and data.activeSeconds == 0 then + return + end + + local energy_kcal = math.floor(data.distanceMeters * 0.06 + 0.5) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_health_daily` + (`owner_identifier`, `activity_date`, `steps`, `distance_meters`, `active_seconds`, `energy_kcal`) + VALUES (?, CURDATE(), ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + `steps` = `steps` + VALUES(`steps`), + `distance_meters` = `distance_meters` + VALUES(`distance_meters`), + `active_seconds` = `active_seconds` + VALUES(`active_seconds`), + `energy_kcal` = `energy_kcal` + VALUES(`energy_kcal`) + ]], { identifier, data.steps, data.distanceMeters, data.activeSeconds, energy_kcal }) + TriggerClientEvent("sky_phone:health:changed", player_source) +end) + +AddEventHandler("playerDropped", function() + activity_reports[source] = nil +end) + +end) diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index c39f40f..96ca343 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -267,6 +267,33 @@ CREATE TABLE IF NOT EXISTS `sky_phone_bank_transactions` ( KEY `idx_sky_phone_bank_reference` (`reference`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `sky_phone_health_daily` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `activity_date` DATE NOT NULL, + `steps` INT UNSIGNED NOT NULL DEFAULT 0, + `distance_meters` INT UNSIGNED NOT NULL DEFAULT 0, + `active_seconds` INT UNSIGNED NOT NULL DEFAULT 0, + `energy_kcal` INT UNSIGNED NOT NULL DEFAULT 0, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_health_daily` (`owner_identifier`, `activity_date`), + KEY `idx_sky_phone_health_history` (`owner_identifier`, `activity_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_health_profiles` ( + `owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `blood_type` VARCHAR(3) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL DEFAULT '', + `allergies` VARCHAR(500) NOT NULL DEFAULT '', + `conditions` VARCHAR(500) NOT NULL DEFAULT '', + `medication` VARCHAR(500) NOT NULL DEFAULT '', + `emergency_name` VARCHAR(80) NOT NULL DEFAULT '', + `emergency_relation` VARCHAR(40) NOT NULL DEFAULT '', + `emergency_phone` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`owner_identifier`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `sky_phone_billing_invoices` ( `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, `recipient_identifier` VARCHAR(80) NOT NULL,