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
+10
View File
@@ -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 = "$",
+45
View File
@@ -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",
+2
View File
@@ -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',
+111
View File
@@ -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)
+37
View File
@@ -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 = {
+268
View File
@@ -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)
+27
View File
@@ -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,