# About GOFA (/docs/about) GOFA provides hosted health experiences that products can integrate through server-to-server APIs and Flutter SDKs. ## Mission and technology [#mission-and-technology] GOFA's mission is to make health and fitness accessible and engaging through AI and computer vision. Its motion-tracking experiences provide feedback on movement, exercise form, and posture, alongside guided lessons, structured programs, and challenges. Developers can bring these experiences into web and mobile products through GOFA APIs and Flutter SDKs. See [GOFA](https://gofa.co) for more about the company. ## Developer platform [#developer-platform] The **Vital Scan API** creates a hosted assessment session, gives your product a URL for the user, and lets your backend retrieve the resulting metrics through the session API. The [Avatar API](/docs/avatar) generates a four-view avatar sheet from a face image. Existing client and user workflows are covered in [Integration authentication](/docs/integration-authentication). The [Flutter SDK guides](/docs/sdk) cover GOFA Challenge, GOFA Lessons, and GOFA WebView integrations for mobile applications. The [assessment](/docs/assessments), [activity](/docs/activities), and [AI & wellness](/docs/ai) guides cover the existing product APIs and embedded experiences. Each guide explains its own authentication and client-access requirements. ## Vital Scan integration boundary [#vital-scan-integration-boundary] * Your backend owns API-key storage and calls GOFA over HTTPS. * GOFA hosts the assessment experience and controls the temporary session URL. * Your product owns the user identifier, return-page handling, and any records it needs to keep. The documentation is intentionally English-only and focuses on the public API and SDK integration contracts. Internal administration tools are outside the scope of this documentation. To make a first request, start with [Quick Start](/docs/quick-start). # Choose an authentication flow (/docs/authentication-overview) GOFA uses different credentials for member sign-in, client administration, and Commercial API requests. Choose the flow for the operation you need; the credentials are not interchangeable. ## Find your guide [#find-your-guide] * **Flutter Lessons or WebView member sign-in:** Start with [SDK authentication](/docs/sdk/authentication), then the [backend member sign-in API](/docs/integration-authentication/member-sign-in). Keep GOFA client credentials on your backend and supply a Firebase custom token to the SDK through `customTokenProvider`. * **Client administration from a backend:** Use [Existing client integrations](/docs/integration-authentication/client-token). Contact GOFA for supported credential and migration details. * **Commercial APIs from a backend:** Use [Commercial API authentication](/docs/authentication) for hosted services such as Vital Scan. Send the Commercial API key from your backend only. * **Existing GOFA password or Firebase sign-in:** Use [User authentication](/docs/integration-authentication/user-auth). A Firebase custom token signs the user in; the resulting ID token identifies the user to protected routes. GOFA enables member sign-in separately for each environment. Complete UAT and confirm existing members keep their records before rolling out an app migration. # Commercial API authentication (/docs/authentication) For a Flutter SDK, ClientToken, or Firebase user integration, start with [Choose an authentication flow](/docs/authentication-overview). This page applies to the GOFA Commercial API key. The GOFA Commercial API is server-to-server. Keep your API key in a trusted backend environment and use it only when your server makes a request. ## Server-side only [#server-side-only] Do not put your API key in browser code, client-side bundles, mobile apps, WebViews, URLs, or public repositories. Your backend should be the only place that reads the key and sends requests to GOFA. ## Send your API key [#send-your-api-key] Send the key in the `Authorization` header as a bearer token: ```http Authorization: Bearer ``` For example, a production request includes the same header: ```bash curl --request GET \ 'https://www.gofa.app/api/v1/vital-scan/sessions/' \ -H 'Authorization: Bearer ' ``` Replace the placeholders with values held by your server-side configuration. The same header is used for the Vital Scan and Cognitive Assessment create and read endpoints. Creation also requires an `Idempotency-Key` header; see each product's request example. The API accepts the exact `Authorization: Bearer ` form. Do not send a user token or another integration credential as a substitute. ## Key handling [#key-handling] * Read the key from your server's secret manager or deployment environment. * Never put it in browser code, client-side bundles, mobile apps, WebViews, URLs, logs, analytics events, or public repositories. * Do not forward the key to the hosted Vital Scan page. The `vitalScanUrl` returned by the create endpoint is a separate, temporary session URL. ## Get or replace a key [#get-or-replace-a-key] Sign in to the GOFA customer portal as an active administrator of your own workspace, then open **API → Keys → Create API key**. Choose a recognizable label and an appropriate expiry. Copy the secret directly into your backend's secret manager: it is shown only once and cannot be retrieved later. Product activation and key creation require a customer-owned administrator session. A GOFA platform administrator viewing your workspace, or access through a linked workspace, is not a substitute. If the actions are unavailable, ask your workspace administrator or GOFA Support to check your access. Creating a key does not activate Vital Scan. Complete the service activation in [Quick Start](/docs/quick-start#prepare-your-workspace) before making requests. For a planned replacement without an interruption: 1. Create a second key while the existing key is still active. 2. Store and deploy the new key to all backend consumers. Verify it with a GET for an existing session in the same workspace; do not create a paid session just to test the key. The original creating key is not required for GET. 3. Once all consumers use the new key, revoke the old key in the portal. **Rotate is immediate:** it creates a replacement and revokes the old key in the same operation. There is no overlap or grace period. Use it when immediate invalidation is intended, and store the replacement secret when it is shown. If a key is exposed, revoke or rotate it promptly; do not leave a compromised key active for a planned overlap. If a secret is lost, create or rotate a replacement rather than trying to retrieve it. If a request returns `401 invalid_api_key`, check that the key is present, active, and sent in the bearer header. For entitlement or product errors, see [Errors & limits](/docs/errors-and-limits). ## Existing client integrations [#existing-client-integrations] Some existing GOFA integrations use Firebase credentials or a client token on the separate `/api/auth` and `/api/client` routes. Those credentials are not interchangeable with a Commercial API key. See [Integration authentication](/docs/integration-authentication) and contact GOFA for details of an existing integration. # Avatar API (/docs/avatar) This document describes how to call the GOFA Avatar API from your backend or an authenticated user-facing application. The Avatar API accepts a face image and returns: * a temporary URL for the generated avatar sheet * the sheet dimensions * four panel crop rectangles so the frontend can render the front, right three-quarter, right profile, and back views from the same sheet image * optionally, four separated panel images as base64 strings *** ## Endpoint [#endpoint] **POST** `/api/avatar` Call this endpoint on your assigned GOFA client domain. *** ## Authentication [#authentication] Choose the credential issued for your integration. Send only one authentication mechanism per request. ### Commercial API key (backend) [#commercial-api-key-backend] Send your Avatar-enabled Commercial API key directly to `POST /api/avatar`: ```http Authorization: Bearer ``` Use `https://www.gofa.app` for production Commercial API requests. The key identifies your workspace; do not send a `clientId` to switch workspaces. Keep the key on your backend. See [API key authentication](/docs/authentication). ### ClientToken (existing backend integrations) [#clienttoken-existing-backend-integrations] Contact GOFA for the supported setup and credential guidance for an existing backend integration. See [Existing client integrations](/docs/integration-authentication/client-token). ### Firebase ID token (signed-in user) [#firebase-id-token-signed-in-user] An existing GOFA/Firebase user integration can send: ```http Authorization: Bearer ``` The signed-in user must have a direct claim for the client represented by the domain. A linked-client association alone does not grant Avatar access. See [User authentication](/docs/integration-authentication/user-auth). *** ## Request Format [#request-format] Send the request as `multipart/form-data`. ### Required fields [#required-fields] * `templateGender`: `female` or `male` * `outfitMode`: `template` or `user` Send the input image using exactly one of these fields: * `faceFile`: user face image file * `faceImageBase64`: user face image as a base64 string or data URL If `faceImageBase64` is a raw base64 string without a `data:image/...;base64,` prefix, also send: * `faceImageMediaType`: `image/jpeg`, `image/png`, or `image/webp` Do not send `faceFile` and `faceImageBase64` in the same request. ### Optional fields [#optional-fields] * `outputFormat`: `sheet_url` or `panel_base64` If omitted, `outputFormat` defaults to `sheet_url`. ### Supported image types [#supported-image-types] * `image/jpeg` * `image/png` * `image/webp` ### Size limits [#size-limits] * Maximum real input image size: `1 MiB` * Maximum request body size: `2 MiB` For base64 input, GOFA decodes the base64 string and applies the `1 MiB` limit to the decoded image bytes. Keep the complete multipart body within `2 MiB`. When a `Content-Length` header is supplied, it must be a positive number no greater than 2,097,152 bytes. The endpoint does not require you to manually set that header. Let your HTTP client generate the multipart boundary and `Content-Type`. *** ## Request Example [#request-example] ### Backend example with a Commercial API key [#backend-example-with-a-commercial-api-key] ```bash curl --request POST 'https://www.gofa.app/api/avatar' \ --header "Authorization: Bearer $GOFA_API_KEY" \ --form 'faceFile=@face.jpg;type=image/jpeg' \ --form 'templateGender=female' \ --form 'outfitMode=template' \ --form 'outputFormat=sheet_url' ``` ### Frontend example with Firebase ID token [#frontend-example-with-firebase-id-token] ```typescript const idToken = await auth.currentUser?.getIdToken(); const formData = new FormData(); formData.append("faceFile", file); formData.append("templateGender", "female"); formData.append("outfitMode", "template"); formData.append("outputFormat", "sheet_url"); const response = await fetch("/api/avatar", { method: "POST", headers: { Authorization: `Bearer ${idToken}`, }, body: formData, }); if (!response.ok) throw new Error(`Avatar request failed (${response.status})`); const data = await response.json(); ``` ### Backend example with base64 input [#backend-example-with-base64-input] ```typescript const formData = new FormData(); formData.append("faceImageBase64", "data:image/png;base64,iVBORw0KGgo..."); formData.append("templateGender", "female"); formData.append("outfitMode", "template"); formData.append("outputFormat", "panel_base64"); const response = await fetch("https://www.gofa.app/api/avatar", { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, }, body: formData, }); if (!response.ok) throw new Error(`Avatar request failed (${response.status})`); const data = await response.json(); ``` *** ## Successful Response [#successful-response] **200 OK** Dimensions and coordinates below are illustrative. Always use the returned values. The signed `sheetUrl` expires one hour after it is created. ```json { "sheetUrl": "https://...", "sheet": { "width": 1024, "height": 1024 }, "panels": { "front": { "left": 20, "top": 20, "width": 472, "height": 472 }, "rightThreeQuarter": { "left": 532, "top": 20, "width": 472, "height": 472 }, "rightProfile": { "left": 20, "top": 532, "width": 472, "height": 472 }, "back": { "left": 532, "top": 532, "width": 472, "height": 472 } }, "panelImages": { "front": { "base64": "data:image/jpeg;base64,...", "mediaType": "image/jpeg", "width": 472, "height": 472 }, "rightThreeQuarter": { "base64": "data:image/jpeg;base64,...", "mediaType": "image/jpeg", "width": 472, "height": 472 }, "rightProfile": { "base64": "data:image/jpeg;base64,...", "mediaType": "image/jpeg", "width": 472, "height": 472 }, "back": { "base64": "data:image/jpeg;base64,...", "mediaType": "image/jpeg", "width": 472, "height": 472 } } } ``` The `panelImages` object is returned only when `outputFormat` is `panel_base64`. ### Response fields [#response-fields] * `sheetUrl`: temporary URL of the generated avatar sheet image * `sheet.width`: width of the generated sheet image * `sheet.height`: height of the generated sheet image * `panels.front`: crop rectangle for the front view * `panels.rightThreeQuarter`: crop rectangle for the right three-quarter view * `panels.rightProfile`: crop rectangle for the right profile view * `panels.back`: crop rectangle for the back view * `panelImages.front.base64`: optional base64 image for the front view * `panelImages.rightThreeQuarter.base64`: optional base64 image for the right three-quarter view * `panelImages.rightProfile.base64`: optional base64 image for the right profile view * `panelImages.back.base64`: optional base64 image for the back view *** ## Error responses [#error-responses] | Status | Meaning | | ------ | -------------------------------------------------------------------------------- | | 400 | Invalid fields, image data, multipart body, or supplied Content-Length | | 401 | Missing or invalid authentication | | 402 | Insufficient available credits or allowance | | 403 | Credential lacks required client access, product entitlement, or Free membership | | 413 | Input image exceeds 1 MiB, or supplied Content-Length exceeds 2 MiB | | 415 | Unsupported image type | | 500 | Generation or server processing failed, including generation timeout | | 503 | Commercial allowance or commit state temporarily unavailable | Commercial API failures use a structured `error` object with `message`, `type`, `param`, and `code`. Existing ClientToken/Firebase requests use a string `error` plus a `code` for handler errors. Authentication failures may be returned before the handler. Check the HTTP status before reading success fields. For example, a Commercial API validation error may look like: ```json { "error": { "message": "Invalid form data", "type": "invalid_request_error", "param": null, "code": "invalid_form_data" } } ``` Generation may take time. A timeout does not prove that a request was never processed. Do not automatically resubmit generation after a connection loss or `commit_outcome_unknown`; check the outcome with GOFA first to avoid duplicate generation or usage. ## Usage and allowance [#usage-and-allowance] A successful generation is one usage unit for `avatar.generate`. Commercial API access requires an Avatar-enabled key and product entitlement, a valid Free membership, and remaining allowance for the current cycle. Exhausted allowance returns `402 insufficient_credits`. Existing ClientToken and signed-in-user integrations follow their client's configured Avatar usage and billing rules. Do not infer their available balance or price from the Commercial API allowance. Confirm your workspace configuration with GOFA before production use. *** ## Frontend Rendering Strategy [#frontend-rendering-strategy] By default, the response does **not** return four separate image URLs. Instead: * the backend returns one generated sheet image URL * the backend also returns crop rectangles for each panel * the frontend displays the same image multiple times using different crop areas This keeps storage usage low and avoids creating additional temporary panel files. If you need separated panel image data, set: ```txt outputFormat=panel_base64 ``` GOFA will still store only one generated sheet image, but the response will include four derived panel images as base64 strings. *** ## Frontend Example: Render the Avatar Sheet [#frontend-example-render-the-avatar-sheet] ```tsx Generated avatar sheet ``` *** ## Frontend Example: Render a Panel Using Canvas [#frontend-example-render-a-panel-using-canvas] ```tsx function renderPanel( canvas: HTMLCanvasElement, image: HTMLImageElement, rect: { left: number; top: number; width: number; height: number } ) { const context = canvas.getContext("2d"); if (!context) { return; } canvas.width = rect.width; canvas.height = rect.height; context.clearRect(0, 0, rect.width, rect.height); context.drawImage( image, rect.left, rect.top, rect.width, rect.height, 0, 0, rect.width, rect.height, ); } ``` Example usage: ```tsx const image = new Image(); image.onload = () => { renderPanel(frontCanvas, image, data.panels.front); renderPanel(rightThreeQuarterCanvas, image, data.panels.rightThreeQuarter); renderPanel(rightProfileCanvas, image, data.panels.rightProfile); renderPanel(backCanvas, image, data.panels.back); }; image.src = data.sheetUrl; ``` *** ## Notes [#notes] * For frontend integrations, call this API with a valid Firebase ID token for the signed-in user. * For backend integrations, use your issued Commercial API key. Contact GOFA for an existing client integration. * Keep API keys on your backend server only. Do not expose API keys in browser or mobile app code. * Always use `multipart/form-data`. * Send either `faceFile` or `faceImageBase64`, not both. * Keep the real input image within the `1 MiB` limit. * Base64 input is accepted to support clients that cannot send binary file fields. The decoded image still must be within `1 MiB`. * The frontend should treat `sheetUrl` as a temporary resource and should not assume it is permanent. * The panel coordinates are based on the generated sheet and should be used together with the returned `sheetUrl`. * If `outputFormat` is `panel_base64`, the returned panel base64 strings are derived from the generated sheet. GOFA still stores only the generated sheet image. *** # Cognitive Assessment API (/docs/cognitive-assessment) The Cognitive Assessment API lets your backend create a hosted HK-MoCA 5-minute assessment for an external user. GOFA hosts the audio flow and returns only the formal scores, total, percentile interpretation, and confirmed demographics. ## Workflow [#workflow] 1. From your server, create a session with a bearer API key. 2. Open the returned `launchUrl` in a top-level browser or microphone-capable WebView. 3. Let the user complete the hosted flow. The same URL can be reopened while the session is active and retries do not consume more credits. 4. Poll the session until it is completed or expired. Treat return navigation as a hint and use this endpoint as the authoritative status and result source. The launch URL is a hosted boundary; inline iframe embedding is not supported. ## Authentication and workspace setup [#authentication-and-workspace-setup] Keep the API key on your server. Do not put it in browser code, mobile apps, URLs, or the hosted page. Activate **Cognitive Assessment** in **API → API Services** and add the exact HTTPS return origin in the shared **Allowed return origins** setting before sending `returnUrl`. ## Create a hosted session [#create-a-hosted-session] ```http POST /api/v1/cognitive-assessment/sessions ``` The request body is strict: Send a unique `Idempotency-Key` header for each intended assessment. Keep the same key and request body if the POST must be retried. | Field | Required | Description | | ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `userId` | Yes | Your opaque external user identifier. It is returned as supplied and may contain ASCII letters, numbers, `.`, `_`, `:`, `@`, `+`, or `-`. | | `returnUrl` | No | An approved HTTPS URL. GOFA returns to this URL with only the session ID and `status` query parameters. | | `locale` | No | `en`, `zh`, `zh-Hant`, `zh-Hans`, or `zh-TW`. | | `age` | No | Optional prefill from 0 to 130. The participant can correct it in the hosted flow. | | `educationYears` | No | Optional prefill from -1 to 100. `-1` means unknown; the participant can correct it. | ```bash curl --request POST 'https://www.gofa.app/api/v1/cognitive-assessment/sessions' \ --header 'Authorization: Bearer ' \ --header 'Idempotency-Key: 22dd42e8-5064-4f2a-a6af-d76cb61f365e' \ --header 'Content-Type: application/json' \ --data '{ "userId": "customer-member-123", "locale": "en", "age": 72, "educationYears": 6, "returnUrl": "https://your-app.example/cognitive/complete" }' ``` The response is `201 Created`: ```json { "data": { "cognitiveAssessmentSessionId": "", "userId": "customer-member-123", "status": "created", "createdAt": "2026-09-20T09:00:00.000Z", "updatedAt": "2026-09-20T09:00:00.000Z", "firstOpenedAt": null, "startedAt": null, "completedAt": null, "failedAt": null, "expiresAt": "2026-09-20T11:00:00.000Z", "resultExpiresAt": null, "returnUrl": "https://your-app.example/cognitive/complete", "locale": "en", "demographics": { "age": 72, "educationYears": 6 }, "result": null, "launchUrl": "https://www.gofa.app/cognitive-assessment-audio/runs/#sessionToken=" } } ``` The first successful POST returns `201 Created` and consumes the configured Cognitive Assessment price in credits. Replaying the same key and request for the same workspace and product returns the original ID with `200 OK` and `Idempotency-Replayed: true`, without another charge. The key must be 16–128 ASCII letters, numbers, `.`, `_`, `:`, or `-`; a UUID is suitable. A missing or invalid key returns `400 IDEMPOTENCY_KEY_INVALID`; reusing a key with a changed request returns `409 IDEMPOTENCY_CONFLICT`. A replacement API key belonging to the same workspace can replay the request. A temporary launch credential is scoped to the client and session, expires with the two-hour unfinished session window, and must never be logged or forwarded to another user. The original two-hour `expiresAt` does not move on replay. After expiry or a terminal state, replay still returns the original ID and `launchUrl: null`. ## Read status and formal result [#read-status-and-formal-result] ```http GET /api/v1/cognitive-assessment/sessions/{cognitiveAssessmentSessionId} ``` GET does not consume credits. It still requires an active bearer API key from the owning workspace, remains subject to the workspace requests-per-minute limit, and is limited to one poll every five seconds per session. Any active key owned by the same workspace can read the result, even if another key created the session. Wait for `Retry-After` after a `429`. `expiresAt` ends hosted input; it is not a result-read deadline. New Commercial responses return `resultExpiresAt: null`, with no automatic 30-day result-read cutoff. The completed `result` contains the formal per-domain scores, raw and adjusted total, education bonus, percentile label/risk interpretation, and the confirmed age and education values. It does not contain recordings, audio URLs, model costs, raw diagnostics, or technical capture data. ## Return navigation and session removal [#return-navigation-and-session-removal] After completion or an early exit, GOFA navigates to the approved return origin with only `cognitive_assessment_session_id` and `status` query parameters. Do not treat these values as the result; fetch the session from your server. This public API has no session deletion operation. `DELETE` on the single session path returns `405 Method Not Allowed`. An unfinished session expiring does not refund its creation charge. # Errors & limits (/docs/errors-and-limits) The API uses JSON responses and includes a GOFA-generated `x-request-id` on authenticated requests. Keep that ID with your application logs and include it when contacting GOFA Support; do not send API keys or health data to Support. ## Error envelope [#error-envelope] Errors from the common API access boundary have this shape: ```json { "error": { "code": "", "message": "", "requestId": "" } } ``` Some handler errors contain only `error.message` and `error.requestId`; do not assume every error has a code. The common access boundary and new session creation errors use the coded responses listed below. ## Public error codes [#public-error-codes] | Status | Code | Meaning | | ------ | --------------------------------- | ----------------------------------------------------------------------------------------------- | | `400` | `invalid_request` | The JSON body, `userId`, `locale`, or `returnUrl` is invalid. | | `400` | `IDEMPOTENCY_KEY_INVALID` | The required session-creation key is missing or malformed. | | `400` | `return_origin_not_approved` | The `returnUrl` origin is not approved for the workspace. | | `401` | `invalid_api_key` | The bearer key is missing, invalid, expired, or revoked. | | `402` | `INSUFFICIENT_CREDITS` | The initialized shared account lacks the full product price for a new session. | | `402` | `insufficient_credits` | Legacy allowance is insufficient for a new session. Legacy responses may include cycle details. | | `409` | `IDEMPOTENCY_CONFLICT` | An idempotency key was used with a different request for the same workspace and product. | | `403` | `product_not_available` | Vital Scan is not available for the workspace. | | `403` | `product_not_entitled` | The authenticated key is not entitled to Vital Scan. | | `403` | `entitlement_inactive` | The workspace's commercial entitlement is not active. | | `403` | `entitlement_expired` | The workspace's commercial entitlement has expired. | | `403` | `free_membership_required` | The workspace does not have the required Free membership. | | `429` | `rate_limit_exceeded` | The configured requests-per-minute limit was exceeded. | | `503` | `entitlement_unavailable` | Entitlement data could not be read or validated temporarily. | | `503` | `rate_limit_unavailable` | Request-protection configuration or its distributed check is temporarily unavailable. | | `503` | `free_membership_unavailable` | Free membership data is temporarily unavailable. | | `503` | `free_allowance_unavailable` | Free allowance data is temporarily unavailable. | | `503` | `vital_scan_creation_unavailable` | GOFA could not confirm whether session creation committed. | | `503` | `commit_outcome_unknown` | The session-creation transaction outcome is unknown. | | `503` | `usage_event_unavailable` | Successful commercial usage recording is temporarily unavailable. | | `500` | `vital_scan_creation_failed` | An unexpected error prevented session creation. | The `product_not_available` and `invalid_request` checks are specific to session creation. Authentication, workspace access, and rate-limit checks also apply to GET. The public single-session paths have POST and GET operations; DELETE returns `405 Method Not Allowed`. ## Retry behavior [#retry-behavior] * Fix the request or credentials before retrying `400`, `401`, or `403`. * For `402`, wait until allowance is available or ask GOFA to update the workspace; repeating the request cannot create a session without enough credits. * For `409 IDEMPOTENCY_CONFLICT`, choose a new key only if you intend a distinct assessment. A retry of the original assessment must use the original body. * For `429`, honor `Retry-After` and use exponential backoff. A GET that polls the same session too soon returns `429` with the number of seconds to wait. * GET requests may be retried after a network failure or temporary `5xx`, with bounded exponential backoff. Honor `Retry-After` if present and stop at a deadline rather than polling indefinitely. * After an uncertain POST (timeout, lost response, or temporary `5xx`), retry with the **same** `Idempotency-Key` and identical request body. If the first transaction committed, the replay returns `200 OK` with the original session and `Idempotency-Replayed: true`, without a second charge. If it did not commit, a successful retry returns `201 Created`. Replaying never extends the original two-hour hosted window. Use bounded backoff; retain the key, request time, and GOFA request ID for support. Do not send keys, hosted URLs, or health data to Support. ## Rate-limit headers [#rate-limit-headers] Authenticated responses may include: * `ratelimit-limit` — the workspace-configured requests-per-minute limit. * `ratelimit-remaining` — the remaining requests in the current window. * `ratelimit-reset` — the reset time as a Unix timestamp in seconds. * `Retry-After` — how long to wait when a request is rejected for a retryable limit condition. The exact requests-per-minute value is configured per workspace; do not hardcode a plan value in your client. GET is not billable, but still consumes rate-limit capacity. In addition, a given session may be polled no more than once every five seconds. These are separate limits. For example, five-second polling is 12 requests per minute, so it exceeds a workspace configured for 10 requests per minute even with only one session. Share one request budget across all keys, session polls, and creates in the workspace. See the bounded, single-session example in [Quick Start](/docs/quick-start#2-poll-the-session). ## Charging [#charging] A first successful POST to either hosted assessment product (`201 Created`) consumes that product's configured price in credits. An idempotent `200 OK` replay and GET consume no credits. A rejected request does not intentionally create a billable session. Letting a session expire or leaving the hosted flow does not refund the creation charge. # Create your workspace (/docs/getting-access) Create your organization's workspace on the [GOFA registration page](https://www.gofa.app/b2b/register). Complete registration in the website, then configure the integration from your workspace. ## Register on the website [#register-on-the-website] 1. Enter your **Organization name** and review the suggested **Client ID**. You can edit the identifier before submitting; it uses lowercase letters, numbers, and hyphens. 2. Enter the **Owner name**, **Work email**, and a password with at least eight characters. 3. Choose your organization's brand color, review the terms, and accept them to continue. 4. Select **Create workspace**. Keep the page open while registration completes and the website takes you to your workspace. If the organization already has a workspace, use **Sign in** instead of creating another one. Follow any validation or verification instructions shown by the website. ## Prepare your integration [#prepare-your-integration] After registration, create any required Commercial API keys in your API workspace. Contact GOFA for access to an existing client integration. Keep your client ID and environment details with your server configuration. Confirm that the product you need is enabled before sending requests: * Use [API key authentication](/docs/authentication) for Commercial APIs such as Vital Scan and Avatar. * Use [Integration authentication](/docs/integration-authentication) for an existing integration, and contact GOFA for its credential details. * Follow the relevant [Flutter SDK guide](/docs/sdk) for mobile SDK setup. Your organization's workspace registration is separate from signing an end user into an existing application. For that flow, see [User authentication](/docs/integration-authentication/user-auth). # GOFA Documentation (/docs) Welcome to the GOFA developer documentation. Use these guides to connect your backend to GOFA's hosted Vital Scan or Cognitive Assessment experiences, generate avatars, integrate existing client and user workflows, or add GOFA Flutter SDKs to your mobile application. ## What you can integrate [#what-you-can-integrate] Vital Scan provides a server-to-server workflow for creating and managing hosted sessions: * Your backend creates a session with a GOFA API key. * Your product opens the returned hosted URL for the user. * Your backend polls the session and reads the completed metrics. See [Vital Scan API](/docs/vital-scan) for the complete contract. Cognitive Assessment provides the same server-to-server hosted-session pattern for the audio V2 screening flow. See [Cognitive Assessment API](/docs/cognitive-assessment) for its one-point session charge, completion result, and return-origin setup. ## Start here [#start-here] 1. [Create your workspace](/docs/getting-access) — register your organization on the GOFA website. 2. [Quick Start](/docs/quick-start) — create and poll your first session. 3. [Choose an authentication flow](/docs/authentication-overview) — identify the credential required for your integration. 4. [Errors & limits](/docs/errors-and-limits) — handle retries, rate limits, and billing outcomes. ## Available APIs [#available-apis] ### Vital Scan API [#vital-scan-api] Create a hosted Vital Scan session, send the user to GOFA, and retrieve the result through the session API. See the [Vital Scan API reference](/docs/vital-scan) for request, response, lifecycle, and error details. ### Cognitive Assessment API [#cognitive-assessment-api] Create a hosted Cognitive Assessment session, let the participant complete the audio screening on GOFA, and retrieve the formal score through the session API. See the [Cognitive Assessment API reference](/docs/cognitive-assessment) for request, response, lifecycle, and result details. ### Avatar API [#avatar-api] Generate an avatar sheet with four view panels from a face image. See [Avatar API](/docs/avatar) for supported credentials, multipart requests, image rendering, and usage requirements. ### Existing client and user integrations [#existing-client-and-user-integrations] Use [Client integration authentication](/docs/integration-authentication) for existing client and user authentication. Contact GOFA for the supported credential setup for an existing integration. ## More integration guides [#more-integration-guides] * [Assessments](/docs/assessments) — movement, balance, cognitive, and musculoskeletal assessment workflows and embedded experiences. * [Activities](/docs/activities) — challenges, lessons, plans, play records, and content. * [AI & wellness](/docs/ai) — AI-assisted movement, nutrition, training, hydration, and wellness conversations. Choose a product guide first: its credentials, client access, and embedding messages are specific to that integration. ## Flutter SDKs [#flutter-sdks] Add motion-tracking challenges, fitness lessons, or GOFA WebView experiences to your Flutter application. The [Flutter SDK guides](/docs/sdk) cover package installation, setup, reference information, and release notes. For GOFA member sign-in, start with [SDK authentication](/docs/sdk/authentication) and contact GOFA before changing an existing app. ## Need help? [#need-help] If your workspace or approved return origin is not configured, contact the GOFA team before sending production requests. # Documentation migration (/docs/migration) GOFA documentation is moving to a new site built with Fumadocs. Use the navigation and search to find the guide for your integration. Page addresses have changed; old documentation links are not maintained as compatibility routes. ## Vital Scan API [#vital-scan-api] Use [Quick Start](/docs/quick-start), [Authentication](/docs/authentication), and the [Vital Scan API reference](/docs/vital-scan) for the server-to-server hosted assessment workflow. Keep API keys on your backend. ## Avatar and integration authentication [#avatar-and-integration-authentication] Use [Avatar API](/docs/avatar) for image generation and [Integration authentication](/docs/integration-authentication) for existing client-token and user flows. Commercial API keys, ClientTokens, and Firebase user tokens are separate credentials; use the one required by your endpoint. ## Flutter SDKs [#flutter-sdks] The [Flutter SDK documentation](/docs/sdk) includes GOFA Challenge, GOFA Lessons, and GOFA WebView. SDK installation, initialization, data models, callbacks, and version notes are organized under their respective packages. Use the authentication and environment instructions for your chosen integration. SDK setup and the Vital Scan HTTP API are separate contracts; do not substitute one integration's credentials or initialization flow for another's. ## Product APIs and embedded experiences [#product-apis-and-embedded-experiences] Browse [Assessments](/docs/assessments), [Activities](/docs/activities), and [AI & wellness](/docs/ai) for the product guides. For a new organization, begin with [Create your workspace](/docs/getting-access). ## Existing integrations [#existing-integrations] Moving this documentation does not upgrade an installed SDK or change an API request. Consult the version notes for your package before changing an existing integration. If you cannot find the product or access information you need, contact GOFA with the package name, installed version, and integration context. # Quick Start (/docs/quick-start) This guide will help you create your first hosted Vital Scan session. ## Prerequisites [#prerequisites] Before you begin, you need: * A GOFA customer workspace and an active administrator account in that workspace. * The Vital Scan API enabled in your workspace. * An API key. Review [Authentication](/docs/authentication) before making your first request. ## Prepare your workspace [#prepare-your-workspace] If you do not have a workspace, follow [Getting access](/docs/getting-access). Existing customers should use their existing workspace, not register a duplicate. 1. Sign in to the customer portal as your workspace's own administrator. 2. Open **API → API Services**, select **Vital Scan**, and choose **Activate service**. Eligibility to activate a product is not the same as activation. If Vital Scan is unavailable, ask GOFA to check the workspace's product access. 3. If you want return navigation, configure the shared **Allowed return origins** setting, for example `https://your-app.example`. Enter only the HTTPS origin, without a path, query, or fragment. Otherwise omit `returnUrl` entirely. 4. Open **API → Keys → Create API key** and store the one-time secret in your backend. See [key handling and replacement](/docs/authentication#get-or-replace-a-key). 5. Check **API → Usage** for allowance and readiness before creating a session. If commercial status cannot be loaded, do not interpret that error as a confirmed zero balance; ask GOFA to check the workspace. New workspaces start with a shared monthly allowance, but GOFA must also initialize the global assessment product price before creation is ready. ## What you will do [#what-you-will-do] 1. Authenticate with your API key. 2. Create a hosted Vital Scan session. 3. Use the returned URL to open the hosted Vital Scan experience. 4. Poll the session until it reaches a terminal status. ## 1. Create a session [#1-create-a-session] Make this request from your backend: ```bash curl --request POST \ 'https://www.gofa.app/api/v1/vital-scan/sessions' \ --header 'Authorization: Bearer ' \ --header 'Idempotency-Key: 7dfdc983-1c55-49a3-99b2-238e78ef934a' \ --header 'Content-Type: application/json' \ --data '{ "userId": "customer-member-123", "locale": "en" }' ``` The first `201 Created` response contains a `vitalScanResultId`, `launchUrl`, and the same URL as the compatibility alias `vitalScanUrl`. The following abbreviated response shows only the fields used in this walkthrough; see [Vital Scan API](/docs/vital-scan) for the complete response DTO: ```json { "data": { "vitalScanResultId": "", "userId": "customer-member-123", "status": "created", "launchUrl": "https://www.gofa.app/vital-scan/#scanToken=", "vitalScanUrl": "https://www.gofa.app/vital-scan/#scanToken=" } } ``` Open `launchUrl` for the intended user in a top-level browser window or WebView. Do not log or reuse the URL for another user. Inline iframe embedding is not supported. Store `vitalScanResultId` and the idempotency key against your own assessment request on the backend, and deliver the hosted URL only to that authenticated user. Prevent duplicate create jobs. If the response is lost, retry with the same key and body. A replay returns the original session with `200 OK` and `Idempotency-Replayed: true` without another charge; changing the body under the same key returns `409`. A replay never extends the original two-hour hosted window. See [retry behavior](/docs/errors-and-limits#retry-behavior). Before launching in a mobile app, follow the [browser and camera checklist](/docs/vital-scan#browser-camera-and-webview-checklist). ## 2. Poll the session [#2-poll-the-session] After the user finishes, read the same session from your backend: ```bash curl --request GET \ 'https://www.gofa.app/api/v1/vital-scan/sessions/' \ --header 'Authorization: Bearer ' ``` The response remains `200 OK` while the session is `created` or `in_progress` and has `metrics: null`. Continue until the status is `completed`, `failed`, or `expired`. Do not poll the same session more than once every five seconds; if the API returns `429`, wait for its `Retry-After` header. Five seconds is a per-session minimum, not a recommended workspace polling rate. A Free workspace configured for 10 requests/minute cannot sustain five-second polling (12 requests/minute). Read the workspace's actual limit; other sessions and API operations share that budget. The following server-side JavaScript example handles one session, waits at least 20 seconds between attempts, honors limit headers, backs off on transient GET failures, and stops after 10 minutes or five consecutive transient failures. It never creates a replacement session. The deadline only stops this worker; it does not expire or delete the GOFA session. You can resume polling later using the same ID; hosted input still closes at the original `expiresAt`. ```js async function pollVitalScan(sessionId, apiKey) { const deadline = Date.now() + 10 * 60_000; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); let intervalMs = 20_000; let waitMs = intervalMs; let failures = 0; while (Date.now() + waitMs < deadline) { await sleep(waitMs); let response; try { response = await fetch( `https://www.gofa.app/api/v1/vital-scan/sessions/${encodeURIComponent(sessionId)}`, { headers: { Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(Math.min(15_000, deadline - Date.now())), }, ); } catch { if (++failures >= 5) throw new Error('GET retries exhausted; retain the session ID'); waitMs = Math.max(intervalMs, Math.min(60_000, 10_000 * 2 ** failures)); continue; } const limit = Number(response.headers.get('ratelimit-limit')); if (limit > 0) intervalMs = Math.max(20_000, Math.ceil(60_000 / limit) + 1_000); const retryAfter = response.headers.get('Retry-After'); const retryMs = retryAfter === null ? 0 : /^\d+$/.test(retryAfter) ? Number(retryAfter) * 1_000 : Math.max(0, Date.parse(retryAfter) - Date.now()) || 0; const resetMs = response.headers.get('ratelimit-remaining') === '0' ? Math.max(0, Number(response.headers.get('ratelimit-reset')) * 1_000 - Date.now()) + 1_000 : 0; waitMs = Math.max(intervalMs, retryMs, resetMs); if (response.status === 429 || response.status >= 500) { if (++failures >= 5) throw new Error('GET retries exhausted; retain the session ID'); waitMs = Math.max(waitMs, Math.min(60_000, 10_000 * 2 ** failures)); continue; } if (!response.ok) { throw new Error(`GET failed: ${response.status}; requestId=${response.headers.get('x-request-id') ?? 'unavailable'}`); } const { data } = await response.json(); failures = 0; if (['completed', 'failed', 'expired'].includes(data.status)) return data; if (!['created', 'in_progress'].includes(data.status)) { throw new Error('Unexpected session status; retain the session ID'); } } throw new Error('Polling deadline reached; retain the session ID to resume later'); } ``` Use this function only in a trusted backend with its API key from server-side configuration. In production, coordinate requests through a workspace-wide queue or limiter, allow only one poller per session, and add jitter to retries. Multiple independent copies of this example do not enforce an aggregate workspace budget. A return navigation can prompt your backend to fetch the result, but its query parameters are not proof of completion: verify the status through GET and check that the session belongs to your application's signed-in user. For the full request fields, response schema, return navigation, session removal, and billing behavior, continue to [Vital Scan API](/docs/vital-scan). # Vital Scan API (/docs/vital-scan) The Vital Scan API lets your backend create a hosted assessment for a user. GOFA hosts the assessment experience and returns the result through the session API. ## Workflow [#workflow] 1. From your server, create a session with a bearer API key. 2. Pass the returned `launchUrl` (also returned as `vitalScanUrl`) to your web application or mobile WebView, or open it in a top-level browser window. 3. Let the user complete the hosted assessment. 4. Poll the session by its `vitalScanResultId` until it reaches a terminal status. The returned URL is the integration boundary for the hosted experience. Inline ` ``` The production page currently posts to `window.parent` with a wildcard target origin. Your receiver must still check the actual `event.origin` and the expected message shape before accepting a payload. ## Events [#events] | `type` | `data` | When it is sent | | --------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------- | | `body-alignment-initialized` | `bodyAlignmentResultId`, `result` | The capture page has initialized or loaded its record. | | `body-alignment-result-saved` | `bodyAlignmentResultId`, `result` | The initial result has been saved or loaded in the result view. | | `body-alignment-report-generated` | `bodyAlignmentResultId`, `result` | Report generation has completed and the result includes the generated report when available. | Example envelope: ```json { "type": "body-alignment-result-saved", "data": { "bodyAlignmentResultId": "result-id", "result": { "id": "result-id", "clientId": "your-client-id", "createdAt": "2026-01-01T00:00:00.000Z" } } } ``` Example receiver: ```typescript const iframe = document.getElementById("bodyAlignmentIframe") as HTMLIFrameElement | null; const expectedOrigin = "https://your-client.gofa.app"; window.addEventListener("message", (event) => { const message = event.data; if ( event.origin !== expectedOrigin || event.source !== iframe?.contentWindow || !message || ![ "body-alignment-initialized", "body-alignment-result-saved", "body-alignment-report-generated", ].includes(message.type) ) { return; } const { bodyAlignmentResultId, result } = message.data ?? {}; if (typeof bodyAlignmentResultId !== "string" || !result) return; // Update the host application using the validated result. }); ``` Health data can be present in `result`. Keep it in memory only as long as needed, avoid logging the full payload, and do not treat a message as proof of authorization. Fetch the result through the authenticated detail endpoint when the host needs an authoritative record. # Body alignment API (/docs/assessments/body-alignment) The body alignment API creates an empty assessment record and returns the relative route for the capture experience. The experience records front and side pose measurements and can generate a report in its result view. ## Create an assessment [#create-an-assessment] **POST** `/api/body-alignment` The request must use an authenticated GOFA integration for the client derived from the request host. Access remains subject to the client's account configuration. See [User authentication](/docs/integration-authentication/user-auth) and [Client tokens](/docs/integration-authentication/client-token) for the credential handoff used by your integration. The JSON body accepts these fields: | Field | Type | Description | | ----------------- | ----------------- | ------------------------------------------------------------------------------------- | | `userId` | string, optional | Client user to associate with the result. The user must already exist for the client. | | `skipCalibration` | boolean, optional | Stores the request to skip calibration in the new record. | | `locale` | string, optional | Supported GOFA locale. Invalid locale values return `400`. | Example: ```typescript const response = await fetch("https://your-client.gofa.app/api/body-alignment", { method: "POST", headers: { Authorization: `Bearer ${firebaseIdToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ userId: "client-user-id", locale: "en", skipCalibration: false, }), }); if (!response.ok) throw new Error(`Create failed (${response.status})`); const assessment = await response.json(); ``` The successful response has status `201`: ```json { "bodyAlignmentResultId": "result-id", "redirectUrl": "/assessment/body-alignment/result-id", "createdAt": "2026-01-01T00:00:00.000Z" } ``` `redirectUrl` is relative to the client host. Open it as an iframe or navigate to it in a trusted application. The result view is available at `/assessment/body-alignment/{bodyAlignmentResultId}/result`. ## Retrieve a result [#retrieve-a-result] **GET** `/api/body-alignment/{bodyAlignmentResultId}` This route requires the same authenticated client boundary. A successful response is shaped as: ```json { "data": { "id": "result-id", "clientId": "your-client-id", "userId": "client-user-id", "createdAt": "2026-01-01T00:00:00.000Z", "front": { "head": { "tilt": { "value": 1.2, "score": 0.8 } } }, "side": { "shoulder": { "shift": { "value": 0.4, "score": 0.9 } } } } } ``` The stored result uses `front` and `side` maps. Each body part may contain a `tilt` and/or `shift` measurement with numeric `value` and `score` fields. Supported body parts are `head`, `shoulder`, `hip`, and `knee`. Empty maps and optional fields are normal while an assessment is in progress. ## Generated report [#generated-report] The result view can generate and persist the report as part of the normal assessment flow. Use the authenticated create and detail endpoints described above for your integration. For the parent-window events emitted by the result view, see [Body alignment iframe events](/docs/assessments/body-alignment-postmessage). # Cognitive assessment API (/docs/assessments/cognitive) The cognitive assessment API creates an empty screening result and returns the URLs for each user-facing step. The flow covers basic information, memory recall, verbal fluency, orientation, delayed recall, and a results view. ## Create a screening flow [#create-a-screening-flow] **POST** `/api/cognitive-assessment` The route derives the client from the request host, requires the `cognitiveAssessment` module, and uses GOFA's authenticated integration boundary. See [User authentication](/docs/integration-authentication/user-auth) or [Client tokens](/docs/integration-authentication/client-token) for the credential handoff used by your integration. The JSON body accepts: | Field | Type | Description | | ------------ | -------------------------- | ---------------------------------------------------------------------------------------------- | | `userId` | string, optional | Existing client user to associate with the result. The route verifies the user for the client. | | `email` | string, optional | Email snapshot. Invalid values return `400`. | | `photoURL` | string, optional | Valid URL snapshot. Invalid values return `400`. | | `userInfo` | object, optional | Validated assessment user information snapshot. Invalid snapshots are ignored. | | `returnUrl` | string, optional | Relative path beginning with `/`; protocol-relative paths such as `//host` are rejected. | | `resultMode` | `direct` or `qr`, optional | Selects the supported entry mode. It may also be supplied as a query parameter. | | `origin` | string, optional | Trimmed source label retained with the result. | Example: ```typescript const response = await fetch("https://your-client.gofa.app/api/cognitive-assessment", { method: "POST", headers: { Authorization: `Bearer ${firebaseIdToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ userId: "client-user-id", userInfo: { name: "Example User", age: 72 }, returnUrl: "/assessments/complete", resultMode: "direct", }), }); if (!response.ok) throw new Error(`Create failed (${response.status})`); const flow = await response.json(); ``` The successful response has status `200`: ```json { "cognitiveAssessmentResultId": "result-id", "basicInfoUrl": "/cognitive-assessment/result-id/basic-info?returnUrl=%2Fassessments%2Fcomplete&resultMode=direct", "memoryRecallUrl": "/cognitive-assessment/result-id/memory-recall?returnUrl=%2Fassessments%2Fcomplete&resultMode=direct", "verbalFluencyUrl": "/cognitive-assessment/result-id/verbal-fluency?returnUrl=%2Fassessments%2Fcomplete&resultMode=direct", "orientationUrl": "/cognitive-assessment/result-id/orientation?returnUrl=%2Fassessments%2Fcomplete&resultMode=direct", "delayedRecallUrl": "/cognitive-assessment/result-id/delayed-recall?returnUrl=%2Fassessments%2Fcomplete&resultMode=direct", "resultsUrl": "/cognitive-assessment/result-id/results?returnUrl=%2Fassessments%2Fcomplete&resultMode=direct", "createdAt": "2026-01-01T00:00:00.000Z", "returnUrl": "/assessments/complete", "resultMode": "direct" } ``` When `userId` is omitted, the deployed route creates or reuses a client-scoped anonymous Firebase identity and may return `firebaseCustomToken` for the flow. Treat that value as a credential: keep it out of logs and URLs, and pass it only to the intended Firebase sign-in handoff. ## Result routes [#result-routes] Use the returned `resultsUrl` for the user-facing results page. The API routes for an integration are: | Method | Route | Purpose | | ------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/api/cognitive-assessment-results` | List results for the authenticated client or user. Supports `userId` and `limit`; an explicit `clientId` is reserved for an authorized GOFA super-admin context. | | `GET` | `/api/cognitive-assessment-results/{id}` | Retrieve one result and its step/result URLs. | | `PATCH` | `/api/cognitive-assessment-results/{id}` | Save an allowed partial update for one result. | List responses use `{ "data": [...], "count": number }`. A single-result response uses `{ "data": result, ...urls }`. The result is always checked against the request's client boundary. The update route is strict: unknown keys and the immutable `id`, `clientId`, and `createdAt` fields are rejected. Send assessment progress fields from the flow; treat user association, linked-client, assessor, and other ownership metadata as server-managed. A typical progress update is: ```json { "status": "completed", "scores": { "rawTotal": 24, "adjustedTotal": 25 } } ``` When `status` is `completed`, the route records `completedAt`. Keep the nested assessment data and score fields produced by the flow together; a partial update can replace a nested object according to the deployed update schema. ## Score model [#score-model] The persisted result can include these domain scores: | Domain | Maximum | | --------------------- | ------: | | Memory | 5 | | Verbal fluency | 9 | | Orientation | 6 | | Delayed memory | 10 | | Raw or adjusted total | 30 | The result may also include a quick-screen recommendation, percentile risk band, domain details, `interpretation`, and `riskLevel`. These are assessment outputs; do not infer a diagnosis or substitute them for clinical judgment. The public flow covers creating a screening, collecting its user-facing steps, and reading or saving its result. B2B player history, kiosk administration, bulk management, and other operator workflows are intentionally excluded from this reference. # Fall risk PDF report (/docs/assessments/fall-risk-pdf-report) The report route generates a PDF for an existing fall risk result. ## Download a report [#download-a-report] **GET** `/api/fall-risk-results/{fallRiskResultId}/report` Authenticate the request with the credential issued for the client. The optional query parameters are: | Parameter | Description | | ----------- | ---------------------------------------------------------------------------------------------------- | | `name` | Display name used when rendering the report. | | `gender` | Display value used by the report. | | `birthDate` | Birth date used by the report. | | `uid` | Optional owner identifier used to hydrate user details. If supplied, it must match the result owner. | | `locale` | Report locale. The deployed default is `zh-Hant`; pass a supported locale such as `en` when needed. | `uid` is optional in the deployed handler. It does not let a caller look up a different user. The result access check still applies. Example binary download: ```typescript const response = await fetch( "https://your-client.gofa.app/api/fall-risk-results/result-id/report?locale=en", { headers: { Authorization: `Bearer ${firebaseIdToken}` }, }, ); if (!response.ok) throw new Error(`Report failed (${response.status})`); const pdfBytes = await response.arrayBuffer(); await Bun.write("fall-risk-report.pdf", pdfBytes); ``` The success response has `Content-Type: application/pdf`, an attachment `Content-Disposition` filename, and no-store cache headers. Do not log the response bytes or expose a report URL as a public bearer link. Possible failures include `401`/`403` for authentication or access, `404` when the result is not available to the caller, `402` for a product membership gate in the applicable SilverCare handoff, and `500` when report generation fails. The PDF is a generated presentation of the stored result. Treat its health information as sensitive and apply the same retention and access controls as the API result. # Fall risk quiz iframe events (/docs/assessments/fall-risk-quiz-postmessage) Embed the `quizUrl` returned by [Create an assessment](/docs/assessments/fall-risk). Add `noAutoNav=true` when the parent application owns navigation: ```html ``` ## Message envelope [#message-envelope] Messages have this shape: ```json { "source": "gofa-quiz", "type": "RESULTS_SET", "payload": { "ageGroup": "70to79" } } ``` | `type` | Payload | Meaning | | ------------------ | -------------------------------------------------------------------------- | --------------------------------- | | `STATE_TRANSITION` | Current `state` and step state | The quiz moved between steps. | | `QUIZ_STARTED` | Step and state | The participant started the quiz. | | `QUIZ_ENDED` | Final step and state | The quiz completed. | | `RESULTS_SET` | Quiz result state, including answers and derived `ageGroup` when available | Results were set in the flow. | The current page sends `source: "gofa-quiz"` and uses a wildcard target origin. Validate the sender in the parent: ```typescript const iframe = document.getElementById("fallRiskQuizIframe") as HTMLIFrameElement | null; const expectedOrigin = "https://your-client.gofa.app"; window.addEventListener("message", (event) => { const { source, type, payload } = event.data ?? {}; if ( event.origin !== expectedOrigin || event.source !== iframe?.contentWindow || source !== "gofa-quiz" ) return; if (type === "QUIZ_ENDED") { // noAutoNav=true leaves the next navigation to the host application. } if (type === "RESULTS_SET") { // Treat payload as health data and persist only through your controlled path. } }); ``` Without `noAutoNav=true`, the page automatically navigates to the result view after completion. With it, handle the next step after `QUIZ_ENDED` yourself. Always validate `event.origin`, `event.source`, `source`, and the payload before using a message. Do not log full quiz answers. # Fall risk results API (/docs/assessments/fall-risk-results) The fall risk result routes read the client-scoped record created by [Create an assessment](/docs/assessments/fall-risk). The result can be partial while the five tests are being completed; derived risk fields are calculated as the available assessment data changes. ## List results [#list-results] **GET** `/api/fall-risk-results` The request must be authenticated and the client's `fallRisk` module must be enabled. Query parameters: | Parameter | Description | | ---------- | ------------------------------------------------------------------------------------------------------- | | `userId` | Limit results to one client user. | | `limit` | Limit the number of returned records. | | `clientId` | Reserved for an authorized GOFA super-admin context; ordinary integrations use the host-derived client. | The response is private and non-cacheable: ```json { "data": [ { "id": "result-id", "clientId": "your-client-id", "userId": "client-user-id", "createdAt": "2026-01-01T00:00:00.000Z", "assessmentStatuses": { "quiz": "completed" }, "missingAssessments": ["tandemWalk"] } ], "count": 1 } ``` ## Retrieve a result [#retrieve-a-result] **GET** `/api/fall-risk-results/{fallRiskResultId}` This route uses the fall risk runtime authentication policy and verifies access to the result's client and user. Add `?recalculate=true` when a caller with the required access needs the server to refresh derived calculations. A normal response is `{ "data": result }`; the result may also include `availableAssessments`, `missingAssessments`, and generated assessment URLs. The implementation calculates from whichever assessments are available. A record can therefore include: * `quizResults` — fall history and risk-factor answers. * `sitToStand` — repetition count, five-repetition time, and shoulder tilts. * `singleLegStance` — held duration (up to 10 seconds), shoulder tilts, and optional sway series. * `tandemStance` — held duration (up to 30 seconds), shoulder tilts, and optional sway series. * `tandemWalk` — current round data, successful steps, duration, and swap observations. * derived `riskLevel`, `frailtyLevel`, `sarcopeniaRisk`, probability calculations, and calculation metadata when enough inputs are available. The current tandem walk model prefers three `rounds`, each with a completion flag, completed step count, failure/retry flags, and duration. Older persisted records can still contain deprecated fields. Read the fields present in the response instead of assuming every assessment was completed. ## Save a result update [#save-a-result-update] **PATCH** `/api/fall-risk-results/{fallRiskResultId}` Use this route to save an allowed partial update from the assessment flow. The request is strict: unknown fields, ownership changes, and attempts to replace immutable metadata are rejected. The server also checks that the mutation is safe for this assessment record. ```json { "sitToStand": { "count": 5, "fiveRepsTimeTaken": 14.8, "shoulderTilts": [1.2, 1.5, 1.1] }, "assessmentStatuses": { "sitToStand": "completed" } } ``` A successful update returns `{ "success": true }`. Keep user identity, client ownership, timestamps, payment metadata, and derived calculations under the server's control. Older source notes described result endpoints as available without auth for testing. The deployed result list, detail, report, and update handlers require the GOFA authentication boundary. Do not use an unauthenticated test claim in an integration. Risk bands are product outputs for the assessment workflow. They do not by themselves establish a diagnosis or replace clinical judgment. # Fall risk single-leg stance iframe events (/docs/assessments/fall-risk-single-leg-stance-postmessage) Embed `/fall-risk/{fallRiskResultId}/single-leg-stance`, usually using the `singleLegStanceTestUrl` returned by the fall risk create route: ```html ``` ## Events and metrics [#events-and-metrics] Messages use `source: "gofa-single-leg-stance"`. | `type` | Payload | Meaning | | ---------------------- | ------------------------------------ | --------------------------------------------- | | `STATE_TRANSITION` | State and current metrics | The flow changed state. | | `ASSESSMENT_STARTED` | Current metrics | The timed assessment began. | | `ASSESSMENT_ENDED` | Final `metrics` | The timed assessment ended. | | `RESULTS_SET` | `elapsedSeconds` and `shoulderTilts` | Final metrics were set. | | `TIMER_COMPLETE` | None | The 10-second timer completed. | | `TIMER_ERROR` | Error payload | The timer reported an error. | | `EARLY_EXIT` | None | The participant exited early. | | `SHOULDER_TILT_UPDATE` | Runtime tilt update | A live shoulder-tilt measurement was emitted. | `elapsedSeconds` is capped at 10 in the current result model. Intermediate metrics may be incomplete; use `RESULTS_SET` or retrieve the authenticated result after completion. ```typescript const iframe = document.getElementById("singleLegStanceIframe") as HTMLIFrameElement | null; const expectedOrigin = "https://your-client.gofa.app"; window.addEventListener("message", (event) => { const { source, type, payload } = event.data ?? {}; if ( event.origin !== expectedOrigin || event.source !== iframe?.contentWindow || source !== "gofa-single-leg-stance" ) return; if (type === "ASSESSMENT_ENDED" || type === "RESULTS_SET") { handleCompletedTest(payload?.metrics ?? payload); } else if (type === "EARLY_EXIT") { handleEarlyExit(); } }); ``` The page posts with a wildcard target origin for compatibility. The receiver must check `event.origin`, `event.source`, `source`, and the payload before using any camera-derived metric. # Fall risk sit-to-stand iframe events (/docs/assessments/fall-risk-sit-to-stand-postmessage) Embed `/fall-risk/{fallRiskResultId}/sit-to-stand`, usually using the `sitToStandTestUrl` returned by the fall risk create route: ```html ``` ## Events [#events] All messages use `source: "gofa-sit-to-stand"` and a `type`/`payload` envelope. | `type` | Payload | Meaning | | -------------------- | ------------------------------------------------------------- | ------------------------------ | | `STATE_TRANSITION` | State and current metrics | The flow changed state. | | `ASSESSMENT_STARTED` | Current metrics | The timed assessment began. | | `ASSESSMENT_ENDED` | Final metrics | The timed assessment ended. | | `REP_COUNT_UPDATED` | `count`, and the current `averageShoulderTilt` when available | A repetition was counted. | | `RESULTS_SET` | `count`, `fiveRepsTimeTaken`, and `shoulderTilts` | Final metrics were set. | | `TIMER_COMPLETE` | None | The 30-second timer completed. | | `TIMER_ERROR` | Error payload | The timer reported an error. | | `EARLY_EXIT` | None | The participant exited early. | The final stored result uses `count`, nullable `fiveRepsTimeTaken`, and a `shoulderTilts` array. Progress events can contain a smaller or intermediate metrics object. ```typescript const iframe = document.getElementById("sitToStandIframe") as HTMLIFrameElement | null; const expectedOrigin = "https://your-client.gofa.app"; window.addEventListener("message", (event) => { const { source, type, payload } = event.data ?? {}; if ( event.origin !== expectedOrigin || event.source !== iframe?.contentWindow || source !== "gofa-sit-to-stand" ) return; switch (type) { case "REP_COUNT_UPDATED": updateRepCount(payload?.count); break; case "RESULTS_SET": // Fetch the authenticated API result if the host needs an authoritative copy. handleCompletedTest(payload); break; case "EARLY_EXIT": handleEarlyExit(); break; } }); ``` The page currently posts with a wildcard target origin, so the parent must enforce its own origin and iframe-window checks. Keep camera-derived metrics private and avoid logging complete payloads. # Fall risk tandem stance iframe events (/docs/assessments/fall-risk-tandem-stance-postmessage) Embed `/fall-risk/{fallRiskResultId}/tandem-stance`, usually using the `tandemStanceTestUrl` returned by the fall risk create route: ```html ``` ## Events and metrics [#events-and-metrics] Messages use `source: "gofa-tandem-stance"`. | `type` | Payload | Meaning | | ------------------------ | ------------------------------------ | --------------------------------------------- | | `STATE_TRANSITION` | State and current metrics | The flow changed state. | | `ASSESSMENT_STARTED` | Current metrics | The timed assessment began. | | `ASSESSMENT_ENDED` | Final `metrics` | The timed assessment ended. | | `RESULTS_SET` | `elapsedSeconds` and `shoulderTilts` | Final metrics were set. | | `TIMER_COMPLETE` | None | The 30-second timer completed. | | `TIMER_ERROR` | Error payload | The timer reported an error. | | `EARLY_EXIT` | None | The participant exited early. | | `SHOULDER_TILT_MEASURED` | Runtime tilt update | A live shoulder-tilt measurement was emitted. | `elapsedSeconds` is capped at 30 in the current result model. Intermediate metrics may be incomplete; use `RESULTS_SET` or retrieve the authenticated result after completion. ```typescript const iframe = document.getElementById("tandemStanceIframe") as HTMLIFrameElement | null; const expectedOrigin = "https://your-client.gofa.app"; window.addEventListener("message", (event) => { const { source, type, payload } = event.data ?? {}; if ( event.origin !== expectedOrigin || event.source !== iframe?.contentWindow || source !== "gofa-tandem-stance" ) return; if (type === "ASSESSMENT_ENDED" || type === "RESULTS_SET") { handleCompletedTest(payload?.metrics ?? payload); } else if (type === "EARLY_EXIT") { handleEarlyExit(); } }); ``` The page posts with a wildcard target origin for compatibility. The receiver must check `event.origin`, `event.source`, `source`, and the payload before using any camera-derived metric. # Fall risk tandem walk iframe events (/docs/assessments/fall-risk-tandem-walk-postmessage) Embed `/fall-risk/{fallRiskResultId}/tandem-walk`, usually using the `tandemWalkTestUrl` returned by the fall risk create route: ```html ``` ## Events [#events] Messages use `source: "gofa-tandem-walk"` and a `type`/`payload` envelope. | `type` | Payload | Meaning | | ---------------------- | ------------------------------ | --------------------------------------------- | | `STATE_TRANSITION` | State and current metrics | The flow changed state. | | `ASSESSMENT_STARTED` | Current metrics | The timed assessment began. | | `ASSESSMENT_ENDED` | Final `metrics` | The assessment ended. | | `RESULTS_SET` | Final flow metrics | Final metrics were set. | | `TIMER_COMPLETE` | None | The flow timer completed. | | `TIMER_ERROR` | Error payload | The timer reported an error. | | `EARLY_EXIT` | None | The participant exited early. | | `SHOULDER_TILT_UPDATE` | Runtime tilt update | A live shoulder-tilt measurement was emitted. | | `TANDEM_WALK_FAILED` | Failure details when available | The flow recorded a failed walking attempt. | The persisted API model prefers `tandemWalk.rounds`, with up to three rounds, completed-step counts, retry/failure flags, durations, `isPassed`, and aggregate step/duration fields. Event payloads can contain intermediate runtime metrics; do not assume deprecated `stepCount` or `walkingPath` fields are present. Use the authenticated result endpoint for the canonical stored shape. ```typescript const iframe = document.getElementById("tandemWalkIframe") as HTMLIFrameElement | null; const expectedOrigin = "https://your-client.gofa.app"; window.addEventListener("message", (event) => { const { source, type, payload } = event.data ?? {}; if ( event.origin !== expectedOrigin || event.source !== iframe?.contentWindow || source !== "gofa-tandem-walk" ) return; switch (type) { case "RESULTS_SET": handleCompletedTest(payload); break; case "TANDEM_WALK_FAILED": handleWalkFailure(payload); break; case "EARLY_EXIT": handleEarlyExit(); break; } }); ``` The page posts with a wildcard target origin for compatibility. The receiver must check `event.origin`, `event.source`, `source`, and the payload before using camera-derived data. Do not log walking traces or complete result payloads. # Fall risk API (/docs/assessments/fall-risk) The fall risk API creates an empty result and returns the five relative URLs used by the assessment flow: quiz, sit-to-stand, tandem stance, single-leg stance, and tandem walk. ## Create an assessment [#create-an-assessment] **POST** `/api/fall-risk` The route derives the client from the request host, requires the `fallRisk` module, and authenticates the request through GOFA's existing integration boundary. See [User authentication](/docs/integration-authentication/user-auth) or [Client tokens](/docs/integration-authentication/client-token) for the credential handoff used by your integration. The JSON body accepts: | Field | Type | Description | | ----------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `userId` | string, optional | Existing client user to associate with the result. The route verifies the user for the client. | | `email` | string, optional | Email snapshot. Invalid values return `400`. | | `photoURL` | string, optional | Valid URL snapshot. Invalid values return `400`. | | `userInfo` | object, optional | Validated snapshot with fields such as `name`, `age`, `gender`, `heightInCm`, and `weightInKg`. Invalid snapshots are ignored. | | `skipDeviceSetup` | boolean, optional | Stores whether device setup should be skipped. | | `returnUrl` | string, optional | A relative path or valid absolute URL used for the flow handoff. Use a destination controlled by your application. | | `origin` | string, optional | Trimmed source label retained with the result. | Example: ```typescript const response = await fetch("https://your-client.gofa.app/api/fall-risk", { method: "POST", headers: { Authorization: `Bearer ${firebaseIdToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ userId: "client-user-id", userInfo: { name: "Example User", age: 72, gender: "female" }, returnUrl: "/assessments/complete", skipDeviceSetup: false, }), }); if (!response.ok) throw new Error(`Create failed (${response.status})`); const flow = await response.json(); ``` The successful response has status `200`: ```json { "fallRiskResultId": "result-id", "quizUrl": "/fall-risk/result-id/quiz?returnUrl=%2Fassessments%2Fcomplete", "sitToStandTestUrl": "/fall-risk/result-id/sit-to-stand?returnUrl=%2Fassessments%2Fcomplete", "tandemStanceTestUrl": "/fall-risk/result-id/tandem-stance?returnUrl=%2Fassessments%2Fcomplete", "singleLegStanceTestUrl": "/fall-risk/result-id/single-leg-stance?returnUrl=%2Fassessments%2Fcomplete", "tandemWalkTestUrl": "/fall-risk/result-id/tandem-walk?returnUrl=%2Fassessments%2Fcomplete", "createdAt": "2026-01-01T00:00:00.000Z", "returnUrl": "/assessments/complete" } ``` The returned paths are `/fall-risk/...` paths. They are the launch routes for the deployed API contract; do not substitute an internal `fall-risk-v2` path. Add the `noAutoNav=true` query parameter to an individual page when the host application will control the next step and navigation. If the returned URL already has a query string such as `returnUrl`, add the parameter with a URL builder so the existing parameters are preserved. The [iframe event guides](#iframe-events) below describe the callbacks for each step. When `userId` is omitted, the route uses the authenticated token UID when one is available; otherwise the new record has no explicit user association. Do not put the request credential in logs or URLs. Some SilverCare handoffs can return `402` with a membership-required response when the client, origin, and account meet the product's membership gate. Keep the host application responsible for displaying that plan handoff. ## Iframe events [#iframe-events] Each test page sends a `source`, `type`, and `payload` envelope to its parent window. See the individual guides for the current event names and payloads: * [Quiz events](/docs/assessments/fall-risk-quiz-postmessage) * [Sit-to-stand events](/docs/assessments/fall-risk-sit-to-stand-postmessage) * [Single-leg stance events](/docs/assessments/fall-risk-single-leg-stance-postmessage) * [Tandem stance events](/docs/assessments/fall-risk-tandem-stance-postmessage) * [Tandem walk events](/docs/assessments/fall-risk-tandem-walk-postmessage) Always validate both `event.origin` and `event.source` before handling a message. The current pages post with a wildcard target origin for compatibility; the receiver remains responsible for narrowing the sender. # Assessment APIs (/docs/assessments) GOFA assessment APIs create a client-scoped assessment record and return the relative URLs needed to run the user-facing flow. The assessment pages are designed to be opened in an iframe or a trusted application handoff. The current assessment API routes use the client derived from the request host, an enabled product module, and GOFA's existing authenticated integration boundary. Start with [User authentication](/docs/integration-authentication/user-auth) for a signed-in user or [Client tokens](/docs/integration-authentication/client-token) for an existing backend integration. Contact GOFA for its credential setup. Never put a credential in an assessment URL. ## Assessment families [#assessment-families] * [Body alignment](/docs/assessments/body-alignment) — posture capture, result retrieval, and iframe events. * [Cognitive assessment](/docs/assessments/cognitive) — create a screening flow, collect its steps, and update a result. * [Fall risk](/docs/assessments/fall-risk) — create the five-step balance and mobility flow. * [Fall risk results](/docs/assessments/fall-risk-results) — list, retrieve, and safely update results. * [Fall risk PDF report](/docs/assessments/fall-risk-pdf-report) — download an authenticated report. * [MSK assessment](/docs/assessments/msk) — create shoulder, low-back, or knee assessments. The postMessage pages describe the callback envelopes emitted by the embedded assessment flows. Validate the sender origin before accepting any health data. ## Scope and availability [#scope-and-availability] These pages document the implementation available in the production source reference used for this migration. A client's enabled module and credential permissions still determine whether a route is available. Confirm Commercial API publication, partner entitlements, and host/origin values with GOFA before shipping a new integration. # MSK shoulder arm raise (/docs/assessments/msk-shoulder-arm-raise) The shoulder MSK flow includes an arm raise movement inside the physical assessment. It runs in the `/msk/assessment/{assessmentResultId}` flow created with `assessmentCode: "shoulder"`. ## Movement sequence [#movement-sequence] 1. Stand in the ready position with both arms relaxed and below roughly 20°. 2. Follow the three-second countdown. 3. Raise the arms in view of the camera. The movement is counted when the arm reaches at least 60° and is held for about three seconds. 4. Follow the on-screen instruction to return or repeat when the movement is not detected. The flow can ask the participant to restart when the torso turns, elbows bend, or the movement is too fast. If the arm remains below the detection threshold, the page can show a prompt and continue after its retry handling. These checks are part of the current UI guidance; the saved shoulder result is the authoritative API record. The resulting measurements are stored in the shoulder result's `assessmentData.assessmentResults` object under the deployed `SHOULDER_ASSESSMENT_RESULTS_V1` version. Read the version and measure fields from [MSK assessment API](/docs/assessments/msk) rather than deriving a score from the movement callback alone. The arm raise is a balance and range-of-motion task. Provide a clear area and follow the product's participant safety guidance. A callback or saved metric is not a clinical diagnosis. # MSK assessment API (/docs/assessments/msk) The MSK API creates a client-scoped musculoskeletal assessment record and returns the three user-facing step URLs. The supported assessment codes are `shoulder`, `low-back`, and `knee`. For the existing webview credential handoff, see [MSK authentication](/docs/sdk/webview/msk-authentication). General integrations can also use [User authentication](/docs/integration-authentication/user-auth) or [Client tokens](/docs/integration-authentication/client-token), subject to the client's enabled `mskAssessment` module. ## Create an assessment [#create-an-assessment] **POST** `/api/msk/assessments/{code}` Replace `{code}` with `shoulder`, `low-back`, or `knee`. The JSON body may include `userId`, `email`, and `photoURL`. When the authenticated token contains those claims, the deployed route uses the token values in preference to request-body values. A supplied user must already exist for the client. ```typescript const response = await fetch( "https://your-client.gofa.app/api/msk/assessments/shoulder", { method: "POST", headers: { Authorization: `Bearer ${firebaseIdToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ userId: "client-user-id", email: "person@example.com", }), }, ); if (!response.ok) throw new Error(`Create failed (${response.status})`); const flow = await response.json(); ``` The successful response has status `200`: ```json { "assessmentResultId": "result-id", "assessmentSurveyUrl": "/msk/assessment/result-id/assessment-survey", "asessmentUrl": "/msk/assessment/result-id/assessment-results", "painReviewUrl": "/msk/assessment/result-id/pain-review", "createdAt": "2026-01-01T00:00:00.000Z" } ``` The response key is spelled `asessmentUrl` in the deployed implementation. Preserve that spelling when consuming the response; it is part of the currently observed contract. The route rejects an unknown assessment code with `400`. ## Read and save results [#read-and-save-results] | Method | Route | Purpose | | ------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/api/msk/assessment-results` | List results for the authenticated client or user. Supports `limit`; an explicit `clientId` is reserved for an authorized GOFA super-admin context. | | `GET` | `/api/msk/assessment-result/{id}` | Retrieve one result after access and client checks. | | `PATCH` | `/api/msk/assessment-result/{id}` | Save an allowed partial assessment update. | List responses use `{ "data": [...], "count": number }`. Detail responses use `{ "data": result }`. Both routes are private and client-scoped. The result is discriminated by `assessmentCode` and stores data beneath `assessmentData`: | Code | Stored result version and measures | | ---------- | --------------------------------------------------------------------------------------------------------------------------------- | | `shoulder` | `SHOULDER_ASSESSMENT_RESULTS_V1`, including abduction, flexion, extension, external rotation, internal rotation, and compression. | | `low-back` | `LOW_BACK_ASSESSMENT_RESULTS_V1`, including seated forward flexion. | | `knee` | `KNEE_ASSESSMENT_RESULTS_V1`, including squat. | Each record can also contain `assessmentSurvey`, `painReview`, and assessment risk output. The risk keys used by the deployed model are FS/SIS for shoulder, LB/LDC/LDH for low-back, and OA/PFPS for knee. Use the version fields in the response to handle future schema changes. The update route selects the schema for the record's assessment code, rejects unknown fields, and protects identity fields such as `id`, `clientId`, `userId`, and `createdAt`. A successful update returns `{ "success": true }`. ## Iframe callbacks [#iframe-callbacks] The MSK webview sends callbacks to its parent with a `type` and a data object that can include `assessmentResultId`, `assessmentCode`, `mskAssessResult`, and `mskAssessResultFlattened`. Current callback types include: `START_MSK_ASSESSMENT`, `FINISH_ASSESSMENT_SURVEY`, `FINISH_PHYSICAL_ASSESSMENT`, `FINISH_PAIN_REVIEW`, `GENERATED_REPORT`, `EARLY_QUIT_ASSESSMENT`, `QUIT_MSK_MODULE`, and the compatibility event `QUIT_MODULE`. `GENERATED_REPORT` may include a base64 `data:application/pdf` value. Validate the sender origin and iframe window, and never log the full result or report string. This page documents the user-facing assessment flow and its result contract. Internal authoring, B2B management, linked-client administration, and operator controls are outside the public integration reference. # AI Move API (/docs/ai/ai-move) AI Move provides authenticated, client-scoped exercise and session APIs for user-facing GOFA applications. The request hostname selects the client; the server does not take `clientId` from the request body. ## Authentication [#authentication] Send a Firebase ID token in the `Authorization` header: ```http Authorization: Bearer ``` The AI Move module must be available for the client. A request is restricted to the authenticated user's data. AI Training can use the same session routes for its child exercise sessions when the request identifies `sourceModule` as `aiTraining`. ## Exercises [#exercises] ### GET `/api/ai-move/exercises` [#get-apiai-moveexercises] Returns the exercises visible to the current client. The default catalog contains enabled exercises selected for that client. The GOFA library and client-owned exercises are filtered by the client's catalog and availability. The optional `includeTrainingOnly=true` query parameter requests enabled AI-Training-only exercises in addition to the normal catalog. It uses the AI Training module gate and is intended for an authorized application runtime rather than a public catalog browser. The single-exercise route can also load an unselected library exercise for this training-only request. ```json title="Response" { "exercises": [ { "exerciseId": "squat-basic", "clientId": "gofa", "name": "Basic Squat", "type": "rep", "targetValue": 10, "category": "strength", "difficulty": "beginner", "enabled": true, "sortOrder": 10 } ] } ``` ### GET `/api/ai-move/exercises/[exerciseId]` [#get-apiai-moveexercisesexerciseid] Returns one visible exercise and its algorithm data. Use `includeTrainingOnly=true` with the same runtime restrictions described above when the exercise is being loaded for AI Training. ```json title="Response" { "exercise": { "exerciseId": "squat-basic", "clientId": "gofa", "name": "Basic Squat", "nameZh": "基礎深蹲", "description": "Lower into a squat and return to standing.", "category": "strength", "difficulty": "beginner", "imageUrl": "/assets/ai-move/squat-basic.webp", "type": "rep", "targetValue": 10, "estimatedCalories": 8, "algorithm": { "mode": "rule" }, "enabled": true, "sortOrder": 10 } } ``` The response is `404` when the exercise is absent or is not visible to the requesting client. ## Sessions [#sessions] ### POST `/api/ai-move/sessions` [#post-apiai-movesessions] Creates an exercise session for the authenticated user. The module gate is selected from `sourceModule` when that field is present; otherwise AI Move is used. **Request body:** | Field | Type | Required | Description | | ----------------------- | -------------------------------------- | -------- | ---------------------------------------------- | | `exerciseId` | string | Yes | Exercise to perform. | | `exerciseName` | string | No | Display name captured by the caller. | | `exerciseNameZh` | string | No | Traditional Chinese display name. | | `exerciseType` | `"rep"` \| `"hold"` \| `"timer"` | Yes | Exercise mode. | | `targetReps` | integer | No | Minimum `1`; used for repetition exercises. | | `targetDurationSeconds` | number | No | Minimum `1`; used for hold or timer exercises. | | `sourceModule` | `"aiMove"` \| `"aiTraining"` | No | Runtime that owns the session. | | `planId` | string | No | AI Training plan context. | | `routineTemplateId` | string | No | Routine template context. | | `routineInstanceId` | string | No | Routine instance context. | | `routineRunId` | string | No | Parent routine run context. | | `sequenceIndex` | integer | No | Child step index; minimum `0`. | | `blockType` | `"warmup"` \| `"main"` \| `"cooldown"` | No | Routine block context. | ```json title="Request" { "exerciseId": "squat-basic", "exerciseType": "rep", "targetReps": 10 } ``` The endpoint returns the created session directly with status `201`: ```json title="Response (201)" { "sessionId": "abc123", "clientId": "gofa", "userId": "user-001", "exerciseId": "squat-basic", "exerciseType": "rep", "targetReps": 10, "repsCompleted": 0, "durationSeconds": 0, "caloriesBurned": 0, "poseAccuracy": 0, "formScore": null, "holdTimeSeconds": 0, "retryCountRemaining": null, "retryCountUsed": 0, "failureReason": null, "formViolations": [], "completionOutcome": null, "status": "created", "createdAt": "2026-03-17T10:00:00Z", "completedAt": null } ``` ### GET `/api/ai-move/sessions` [#get-apiai-movesessions] Lists sessions owned by the authenticated user. | Query parameter | Type | Default | Description | | --------------- | ------- | ------- | -------------------------------------------------------------- | | `limit` | integer | `20` | Maximum `100` results. | | `startAfter` | string | — | Session ID used as a pagination cursor. | | `from` | string | — | Optional lower date/time filter accepted by the session store. | | `to` | string | — | Optional upper date/time filter accepted by the session store. | ```json title="Response" { "sessions": [ { "sessionId": "abc123", "exerciseId": "squat-basic", "exerciseType": "rep", "status": "completed", "repsCompleted": 10, "durationSeconds": 45, "caloriesBurned": 8, "poseAccuracy": 0.92, "formScore": 0.88, "createdAt": "2026-03-17T10:00:00Z", "completedAt": "2026-03-17T10:01:00Z" } ] } ``` ### GET `/api/ai-move/sessions/[sessionId]` [#get-apiai-movesessionssessionid] Fetches detailed data for a session owned by the authenticated user. A session created by AI Training is accessible while either the AI Move or AI Training module is available for the client. ```json title="Response" { "session": { "sessionId": "abc123", "clientId": "gofa", "userId": "user-001", "exerciseId": "squat-basic", "exerciseType": "rep", "targetReps": 10, "repsCompleted": 10, "durationSeconds": 45, "caloriesBurned": 8, "poseAccuracy": 0.92, "formScore": 0.88, "holdTimeSeconds": 0, "retryCountUsed": 0, "formViolations": [], "completionOutcome": "success", "status": "completed", "createdAt": "2026-03-17T10:00:00Z", "completedAt": "2026-03-17T10:01:00Z" } } ``` The route returns `403` when the user does not own the session and `404` when the session is not found. ### PATCH `/api/ai-move/sessions/[sessionId]` [#patch-apiai-movesessionssessionid] Updates an owned session with exercise results. Every field is optional; the server validates supplied values. | Field | Type | Description | | -------------------------- | ------------------------------------------------------------------------------ | --------------------------------------- | | `status` | `"created"` \| `"in-progress"` \| `"completed"` \| `"failed"` \| `"abandoned"` | Session status. | | `completionOutcome` | `"success"` \| `"failed"` | Final outcome. | | `repsCompleted` | integer | Minimum `0`. | | `durationSeconds` | number | Minimum `0`. | | `caloriesBurned` | number | Minimum `0`. | | `poseAccuracy` | number | Between `0` and `1`. | | `formScore` | number | Between `0` and `1`. | | `holdTimeSeconds` | number | Minimum `0`. | | `holdBreakEvents` | array | Hold interruption records, up to `100`. | | `retryCountRemaining` | integer | Minimum `0`. | | `retryCountUsed` | integer | Minimum `0`. | | `failureReason` | string | Reason for failure. | | `formViolations` | array | Form violation records. | | `postAnalysis` | object | Post-session analysis result. | | `telemetrySummary` | object | Session telemetry summary. | | `telemetryBasePoseSummary` | object | Base-pose telemetry summary. | A form violation record contains `key`, `label`, optional `labelZh`, `weight`, `totalChecks`, `violationCount`, and `violationRate`. ```json title="Request" { "status": "completed", "completionOutcome": "success", "repsCompleted": 10, "durationSeconds": 45, "caloriesBurned": 8, "poseAccuracy": 0.92, "formScore": 0.88 } ``` ```json title="Response" { "success": true } ``` When a session is marked `completed`, the user's aggregate exercise statistics are updated. The route returns `403` for a session owned by another user and `404` when the session does not exist. ## Scope boundary [#scope-boundary] The published user runtime does not expose AI Move catalog-management or operator record queries as this public integration contract. B2B authoring, physical playtest, diagnostic capture, and other admin routes are intentionally omitted from this page. # AI Nutrition API (/docs/ai/ai-nutrition) AI Nutrition provides meal analysis, food logging, nutrition planning, and a nutrition assistant for authenticated users. Requests are client-scoped by the request hostname and require the AI Nutrition module and the client's food-AI feature entitlement. ## Authentication [#authentication] Send a Firebase ID token in the `Authorization` header: ```http Authorization: Bearer ``` The endpoints operate on the authenticated user's records. Do not send a `clientId` or another user's ID to select data. ## Nutrition plans [#nutrition-plans] ### GET `/api/ai-nutrition/plans` [#get-apiai-nutritionplans] Returns the authenticated user's most recent nutrition plans (up to 10). ```json title="Response" { "plans": [ { "planId": "plan-001", "clientId": "gofa", "userId": "user-001", "goal": "LOSE", "dailyCalories": 2000, "macros": { "protein": 150, "carbs": 200, "fats": 65 }, "targetWeight": "70", "bmi": "22.5", "explanation": "Based on your profile, a moderate calorie deficit...", "foodSuggestions": [ { "name": "Grilled chicken breast", "reason": "High protein, low fat" }, { "name": "Brown rice", "reason": "Complex carbs for sustained energy" } ], "isActive": true, "createdAt": "2026-03-17T10:00:00Z" } ] } ``` ### POST `/api/ai-nutrition/generate-plan` [#post-apiai-nutritiongenerate-plan] Generates and stores a personalized daily plan. A new plan replaces the previous active plan for the user. | Field | Type | Required | Description | | ------------------- | ------------------------------------ | -------- | ----------------------------------- | | `goal` | `"LOSE"` \| `"MAINTAIN"` \| `"GAIN"` | Yes | Nutrition goal. | | `targetWeight` | string | No | Target weight supplied by the user. | | `exerciseFrequency` | string | No | How often the user exercises. | | `dietaryStaple` | string | No | Primary dietary staple. | | `bodyFrame` | string | No | Body-frame description. | | `healthFocus` | string | No | Health focus area. | ```json title="Request" { "goal": "LOSE", "targetWeight": "70", "exerciseFrequency": "3-4 times per week", "dietaryStaple": "rice", "healthFocus": "heart health" } ``` The successful response contains the generated plan fields directly: ```json title="Response" { "planId": "plan-002", "dailyCalories": 1800, "macros": { "protein": 135, "carbs": 180, "fats": 60 }, "bmi": "24.1", "explanation": "Your personalized plan focuses on...", "foodSuggestions": [ { "name": "Salmon", "reason": "Rich in omega-3 for heart health" }, { "name": "Oats", "reason": "High fiber to support weight management" } ] } ``` ## Meal analysis [#meal-analysis] ### POST `/api/ai-nutrition/analyze-meal` [#post-apiai-nutritionanalyze-meal] Analyzes a food image and stores the resulting nutrition analysis. | Field | Type | Required | Description | | ------------- | ------------------------------------ | -------- | ------------------------------------------------------- | | `imageBase64` | string | Yes | Raw base64 data or a `data:image/...;base64,...` value. | | `language` | `"en"` \| `"zh-Hant"` \| `"zh-Hans"` | No | User-visible response language; defaults to `"en"`. | JPEG, PNG, and WebP images are accepted. The decoded image must be no larger than 350 KB for this endpoint. Photos are screened for personal identity information before analysis; a face, identifiable person, identity document, personal screen, or clearly readable personal identifier can be rejected. ```json title="Request" { "imageBase64": "", "language": "en" } ``` ```json title="Response" { "analysisId": "analysis-001", "text": "This appears to be a grilled chicken salad with mixed greens, providing a good balance of protein and fiber.", "foodData": { "name": "Grilled Chicken Salad", "dishName": "Grilled Chicken Salad", "cuisineType": "Western", "visibleIngredients": ["chicken", "mixed greens"], "portionLabel": "regular", "portionConfidence": "medium", "estimationBasis": "Estimated from dish identification and visible portion cues.", "disclaimer": "Nutrition is estimated from the identified dish and visual portion cues.", "calories": 350, "protein": 30, "carbs": 15, "fats": 18, "sugar": 4, "mealGrade": "B", "localizedText": { "en": { "name": "Grilled Chicken Salad", "aiInsights": "A balanced, protein-forward meal." } } }, "createdAt": "2026-03-17T12:30:00.000Z" } ``` The numeric nutrition values are estimates. `mealGrade` is `A`, `B`, or `C` under the service's healthy-diet grading rules. `healthScore` may appear on older records and remains accepted for compatibility, but new clients should use `mealGrade`. Privacy rejection returns `422`: ```json title="Privacy rejection response (422)" { "error": "This photo may contain personal identity information. Please retake the photo so it shows only the food.", "code": "PERSONAL_IDENTITY_DETECTED" } ``` Invalid image media types return `415`; invalid or empty image data returns `400`; an image over the endpoint limit returns `413`. ## Food logs [#food-logs] ### POST `/api/ai-nutrition/food-logs` [#post-apiai-nutritionfood-logs] Creates a food log for the authenticated user. The server assigns the current Hong Kong calendar date; the request does not choose a different date. | Field | Type | Required | Description | | -------------------- | ----------------------------------------------------------------------- | -------- | ------------------------------------------------- | | `name` | string | Yes | Food name. | | `dishName` | string | No | More specific dish name. | | `cuisineType` | string | No | Cuisine or regional context. | | `visibleIngredients` | string\[] | No | Ingredients identified by the caller or scan. | | `portionLabel` | `"small"` \| `"regular"` \| `"large"` \| `"extra_large"` \| `"unknown"` | No | Portion estimate. | | `portionConfidence` | `"low"` \| `"medium"` \| `"high"` | No | Portion confidence. | | `estimationBasis` | string | No | Basis for estimated values. | | `disclaimer` | string | No | Nutrition estimate disclaimer. | | `calories` | number | Yes | Calories, minimum `0`. | | `protein` | number | Yes | Protein in grams, minimum `0`. | | `carbs` | number | Yes | Carbohydrates in grams, minimum `0`. | | `fats` | number | Yes | Fat in grams, minimum `0`. | | `sugar` | number | No | Sugar in grams, minimum `0`. | | `mealGrade` | `"A"` \| `"B"` \| `"C"` | No | Healthy-diet grade. | | `healthScore` | number | No | Deprecated `0–100` compatibility field. | | `localizedText` | object | No | Localized food strings keyed by supported locale. | | `imageUrl` | string | No | Existing image URL. | | `imagePath` | string | No | Existing image storage path. | | `source` | `"scan"` \| `"manual"` \| `"chat"` | No | Log source; defaults to `"manual"`. | | `analysisId` | string | No | Link to a meal analysis. | ```json title="Request" { "name": "Grilled Chicken Salad", "calories": 350, "protein": 30, "carbs": 15, "fats": 18, "source": "scan", "analysisId": "analysis-001" } ``` ```json title="Response (201)" { "logId": "log-001", "clientId": "gofa", "userId": "user-001", "name": "Grilled Chicken Salad", "calories": 350, "protein": 30, "carbs": 15, "fats": 18, "source": "scan", "analysisId": "analysis-001", "date": "2026-03-17", "createdAt": "2026-03-17T12:35:00.000Z" } ``` ### GET `/api/ai-nutrition/food-logs` [#get-apiai-nutritionfood-logs] Gets logs for a date or recent history for the authenticated user. | Query parameter | Type | Description | | --------------- | ------------ | --------------------------------------------------------------------------------------------- | | `date` | `YYYY-MM-DD` | Return logs for that date. Defaults to today in Hong Kong time. | | `limit` | integer | When supplied without `date`, return recent history, clamped to `1–100`; the default is `30`. | ```json title="Response for a date" { "date": "2026-03-17", "limit": null, "logs": [ { "logId": "log-001", "clientId": "gofa", "userId": "user-001", "name": "Grilled Chicken Salad", "calories": 350, "protein": 30, "carbs": 15, "fats": 18, "source": "scan", "date": "2026-03-17", "createdAt": "2026-03-17T12:35:00.000Z" } ] } ``` When `limit` is supplied without `date`, the response has `date: null`, the effective clamped `limit`, and the most recent logs. If a log references an analysis, its available image URL is also resolved in the response. ### GET `/api/ai-nutrition/food-logs/[logId]` [#get-apiai-nutritionfood-logslogid] Gets one food log owned by the authenticated user. If `analysisId` is present and the analysis is available, the response also includes the linked analysis. ```json title="Response" { "log": { "logId": "log-001", "clientId": "gofa", "userId": "user-001", "name": "Grilled Chicken Salad", "calories": 350, "protein": 30, "carbs": 15, "fats": 18, "source": "scan", "analysisId": "analysis-001", "imageUrl": null, "date": "2026-03-17", "createdAt": "2026-03-17T12:35:00.000Z" }, "analysis": { "analysisId": "analysis-001", "clientId": "gofa", "userId": "user-001", "imageUrl": null, "foodData": { "name": "Grilled Chicken Salad", "calories": 350, "protein": 30, "carbs": 15, "fats": 18 }, "aiInsights": "A balanced, protein-forward meal.", "logged": true, "createdAt": "2026-03-17T12:30:00.000Z" } } ``` ### DELETE `/api/ai-nutrition/food-logs/[logId]` [#delete-apiai-nutritionfood-logslogid] Deletes a food log owned by the authenticated user. ```json title="Response" { "success": true } ``` The route returns `403` for a log owned by another user and `404` when the log does not exist. ## Nutrition chat [#nutrition-chat] ### POST `/api/ai-nutrition/chat` [#post-apiai-nutritionchat] Sends a message to the authenticated user's nutrition assistant. The response is JSON and the conversation is persisted server-side. | Field | Type | Required | Description | | ---------------- | ------------------------------------ | -------- | -------------------------------------- | | `message` | string | Yes | User message, at least one character. | | `imageBase64` | string \| null | No | Optional JPEG, PNG, or WebP image. | | `conversationId` | string | No | Existing conversation to continue. | | `language` | `"en"` \| `"zh-Hant"` \| `"zh-Hans"` | No | Response language; defaults to `"en"`. | ```json title="Request" { "message": "I just had a banana and a glass of milk for breakfast", "conversationId": "conv-001", "language": "en" } ``` ```json title="Response" { "conversationId": "conv-001", "response": "A banana with milk is a useful quick breakfast. The banana provides potassium and fiber, while the milk adds protein and calcium.", "suggestedFoodLog": { "name": "Banana and Milk", "calories": 250, "protein": 10, "carbs": 40, "fats": 5, "sugar": 28, "mealGrade": "B" } } ``` `suggestedFoodLog` is nullable. When present, the client can show it for confirmation and submit the accepted fields to `POST /api/ai-nutrition/food-logs`. The assistant response and food strings follow the requested language. Images attached to chat are screened for personal identity information. A rejected image returns `422` with the same `PERSONAL_IDENTITY_DETECTED` code shown in the meal-analysis section. Invalid image data or unsupported media types return the corresponding `400` or `415` validation response. ## Scope boundary [#scope-boundary] This page documents user-facing plans, meal analysis, food logs, and nutrition chat. Admin record queries, module settings, media configuration, and other B2B/operator endpoints are outside the public integration contract. # AI Training API (/docs/ai/ai-training) AI Training provides plan-based routine APIs for authenticated end users. It owns the user's plan, routine queue, and routine-run summary. AI Move remains the exercise execution surface for individual exercise steps. ## Authentication [#authentication] Send a Firebase ID token in the `Authorization` header for end-user routes: ```http Authorization: Bearer ``` The AI Training module must be available for the client. Client identity comes from the request hostname, and each user can access only their own plan, queue, and runs. ## External app handoff [#external-app-handoff] An application that already has a valid Firebase session can exchange its ID token for a Firebase custom token through [`POST /api/auth/exchange-token`](/docs/integration-authentication/user-auth#exchange-an-existing-firebase-session): ```http Authorization: Bearer ``` The request has no body and returns: ```json { "customToken": "" } ``` Pass that value only as the `customToken` query parameter when opening the AI Training play page: ```text https://{client}.gofa.app/ai-training/routine-run/{routineRunId}/play?customToken={token}&locale=zh-Hant ``` The GOFA web application consumes the custom token to establish a browser session. It is not a bearer token for API requests. Keep both token types out of logs, URLs that may be retained, and browser source code after the handoff. ## End-user runtime [#end-user-runtime] ### GET `/api/ai-training/my-plan` [#get-apiai-trainingmy-plan] Returns the latest plan for the authenticated user. A user without a plan receives `{"plan": null}`. ```json title="Response" { "plan": { "planId": "plan_001", "clientId": "gofa", "userId": "user_001", "status": "active", "goal": "balance", "intensity": "moderate", "frequency": 3, "preferredSessionDurationMinutes": 20, "equipmentAvailability": [], "generationSource": "rules", "assessmentSnapshot": { "latestRiskTier": "moderate", "availableInputs": ["fallRisk", "preferences"], "missingInputs": ["mskAssessment", "cognitiveAssessment"] } } } ``` ### POST `/api/ai-training/my-plan/generate` [#post-apiai-trainingmy-plangenerate] Generates a starter plan from routine templates visible to the client and returns the plan and created routine instances with status `201`. All request fields are optional. Unknown fields are rejected. | Field | Type | Description | | --------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | | `goal` | `"mobility"` \| `"strength"` \| `"balance"` \| `"endurance"` \| `"fall-prevention"` \| `"general-fitness"` | Preferred training goal. | | `intensity` | `"low"` \| `"moderate"` \| `"high"` | Preferred training intensity. | | `frequency` | integer `1–14` | Desired sessions per cycle. | | `preferredSessionDurationMinutes` | integer `5–180` | Preferred session duration. | | `equipmentAvailability` | string\[] | Available equipment labels. | | `allowPreferencesOnlyFallback` | boolean | Allow generation from preferences when assessment inputs are unavailable. | | `allowDuplicateRoutineTemplates` | boolean | Opt in to repeated routine templates in generated instances. | ```json title="Request" { "goal": "balance", "intensity": "moderate", "frequency": 3, "preferredSessionDurationMinutes": 20, "equipmentAvailability": ["chair"] } ``` ```json title="Response (201)" { "plan": { "planId": "plan_001", "status": "active", "goal": "balance", "intensity": "moderate", "frequency": 3 }, "routineInstances": [ { "routineInstanceId": "instance_001", "planId": "plan_001", "routineTemplateId": "template_001", "routineTemplateName": "Balance Builder A", "queueIndex": 0, "status": "queued", "blocks": [] } ] } ``` The returned routine instances can contain exercise blocks and normalized image/video URLs used by the play experience. ### GET `/api/ai-training/my-routine-queue` [#get-apiai-trainingmy-routine-queue] Returns the queued or in-progress routine instances for the authenticated user inside a `routineInstances` property. The optional `allowDuplicateRoutineTemplates` query parameter accepts `true` or `1`. It defaults to `false`, so duplicate routine-template instances are hidden from the queue response unless the caller opts in. ```json title="Response" { "routineInstances": [ { "routineInstanceId": "instance_001", "routineTemplateId": "template_001", "status": "queued", "queueIndex": 0, "deferCount": 0, "maxDefers": 2, "blocks": [] } ] } ``` ### POST `/api/ai-training/routine-runs` [#post-apiai-trainingroutine-runs] Creates a routine run for one queued routine instance. | Field | Type | Required | Description | | ------------------- | --------------------------------------------------------------------------------------- | -------- | --------------------------------- | | `routineInstanceId` | string | Yes | Queued routine instance to start. | | `startedFrom` | `"dashboard"` \| `"next-plan-card"` \| `"history"` \| `"manual-selection"` \| `"other"` | Yes | User-facing entry point label. | | `status` | routine-run status | No | Optional initial status. | ```json title="Request" { "routineInstanceId": "instance_001", "startedFrom": "dashboard" } ``` ```json title="Response (201)" { "routineRun": { "routineRunId": "run_001", "routineInstanceId": "instance_001", "planId": "plan_001", "clientId": "gofa", "userId": "user_001", "status": "created", "startedFrom": "dashboard", "summary": { "completedExercises": 0, "totalExercises": 4, "completionRate": 0, "totalDurationSeconds": 0, "totalCaloriesBurned": 0 }, "exerciseSteps": [ { "sequenceIndex": 0, "stepId": "step_001", "exerciseId": "march-basic", "status": "pending" } ] } } ``` Routine-run statuses are `created`, `in-progress`, `completed`, `partial`, `failed`, and `abandoned`. ### GET `/api/ai-training/routine-runs` [#get-apiai-trainingroutine-runs] Lists the authenticated user's routine runs: ```json { "routineRuns": [] } ``` ### GET `/api/ai-training/routine-runs/[routineRunId]` [#get-apiai-trainingroutine-runsroutinerunid] Returns one routine run owned by the authenticated user: ```json { "routineRun": { "routineRunId": "run_001", "routineInstanceId": "instance_001", "status": "in-progress", "startedFrom": "dashboard", "summary": { "completedExercises": 1, "totalExercises": 4, "completionRate": 0.25, "totalDurationSeconds": 120, "totalCaloriesBurned": 12 }, "exerciseSteps": [] } } ``` ### PATCH `/api/ai-training/routine-runs/[routineRunId]` [#patch-apiai-trainingroutine-runsroutinerunid] Updates a routine run owned by the authenticated user. The body is validated against the routine-run update schema; it can contain supported run status, timestamps, execution context, summary, and exercise-step updates. The successful response is: ```json { "success": true } ``` ### POST `/api/ai-training/routine-runs/[routineRunId]/sync` [#post-apiai-trainingroutine-runsroutinerunidsync] Synchronizes a routine-run summary from an AI Move child session or a guided step. All fields are optional, but unknown fields are rejected. | Field | Type | Description | | ------------------ | ---------------------------------------------------- | -------------------------------------------------- | | `aiMoveSessionId` | string | Child AI Move session to sync. | | `sequenceIndex` | integer | Step index, minimum `0`. | | `guidedComplete` | boolean | Mark a guided step complete. | | `executionMode` | `"guided"` \| `"post-analysis"` \| `"live-tracking"` | How the step was executed. | | `completionBasis` | `"elapsed"` \| `"user-confirmed"` \| `"algorithm"` | Why completion was recorded. | | `durationSeconds` | number | Step duration, minimum `0`. | | `skipped` | boolean | Mark a step skipped. | | `skipReason` | `"guest-skipped-after-stall"` | Requires `skipped: true`. | | `abandonRemaining` | boolean | Abandon remaining steps when supported by the run. | ```json title="Response" { "success": true, "routineRun": { "routineRunId": "run_001", "status": "in-progress" } } ``` ### POST `/api/ai-training/routine-instances/[routineInstanceId]/defer` [#post-apiai-trainingroutine-instancesroutineinstanceiddefer] Defers a queued routine instance for the authenticated user, subject to the instance's configured defer limit. ```json { "success": true } ``` ### GET `/api/ai-training/progress-report` [#get-apiai-trainingprogress-report] Returns a lightweight adherence summary for the authenticated user. ```json { "adherence": 0.67, "totalRuns": 6, "completedRuns": 4, "completionRate": 0.67, "series": [ { "label": "Mar 27", "completedRuns": 1, "totalRuns": 2, "completionRate": 0.5 } ] } ``` ## Relationship with AI Move [#relationship-with-ai-move] AI Training starts an AI Move session for each exercise step and can pass plan, routine, run, and sequence context to the AI Move session route. The routine sync route then summarizes the child session without changing the ownership boundary of the individual AI Move session. This page covers the user runtime and the Firebase custom-token handoff. Admin assignment, reporting, routine-template authoring, catalog management, and physical-playtest routes are outside this external reference. # GOFA AI services (/docs/ai) GOFA AI services are authenticated, client-scoped features for user-facing applications. The routes in this section use a Firebase ID token and derive the client from the request host. The relevant module must also be enabled for the client. Use the product guide that matches the feature you are integrating: * [AI Move](/docs/ai/ai-move) — exercise catalog and per-user exercise sessions. * [AI Nutrition](/docs/ai/ai-nutrition) — meal analysis, food logs, plans, and nutrition chat. * [AI Training](/docs/ai/ai-training) — plans, routine queues, and routine runs. * [Water Intake](/docs/ai/water-intake) — hydration logs, goals, and history. * [Wellness Chat](/docs/ai/wellness-chat) — a streaming 1CARE wellness assistant. ## Authentication [#authentication] Sign the user in with Firebase and send the resulting ID token on each API request: ```http Authorization: Bearer ``` See [User authentication](/docs/integration-authentication/user-auth) for the Firebase sign-in and token handoff flows. These routes are separate from the Commercial API key documented in [Authentication](/docs/authentication), and from the backend `ClientToken` used by client directory routes. ## Client and module boundaries [#client-and-module-boundaries] The server derives `clientId` from the request hostname and checks the feature module before serving data. A user token only accesses data belonging to the authenticated user and its client. Do not put Firebase tokens in URLs or browser source code. This public section covers the user runtime and approved user-facing handoffs. Admin record queries, B2B authoring/catalog management, provider proxy routes, and other operator-only surfaces are outside this API reference. # Water Intake API (/docs/ai/water-intake) Water Intake provides client-scoped hydration logging and daily goal tracking for authenticated users. The request hostname selects the client, and the Water Intake module must be available for that client. ## Authentication [#authentication] Send a Firebase ID token in the `Authorization` header: ```http Authorization: Bearer ``` Logs and settings are restricted to the authenticated user. Do not put the Firebase token in a URL. ## Logs [#logs] ### POST `/api/water-intake/logs` [#post-apiwater-intakelogs] Creates a water intake log. | Field | Type | Required | Description | | ---------- | ------------------- | -------- | ---------------------------------------------- | | `amountMl` | integer `1–5000` | Yes | Amount in millilitres. | | `date` | `YYYY-MM-DD` string | No | Log date; defaults to today in Hong Kong time. | ```json title="Request" { "amountMl": 250, "date": "2026-03-17" } ``` ```json title="Response (201)" { "logId": "log-001", "clientId": "gofa", "userId": "user-001", "amountMl": 250, "date": "2026-03-17", "createdAt": "2026-03-17T10:30:00.000Z" } ``` ### GET `/api/water-intake/logs` [#get-apiwater-intakelogs] Returns the user's logs for one date with a daily total and current goal. | Query parameter | Type | Default | Description | | --------------- | ------------------- | ----------------------- | ----------------- | | `date` | `YYYY-MM-DD` string | Today in Hong Kong time | Date to retrieve. | ```json title="Response" { "date": "2026-03-17", "totalMl": 1250, "goalMl": 2000, "logs": [ { "id": "log-001", "amountMl": 250, "timestamp": "2026-03-17T10:30:00.000Z" }, { "id": "log-002", "amountMl": 500, "timestamp": "2026-03-17T08:15:00.000Z" }, { "id": "log-003", "amountMl": 500, "timestamp": "2026-03-17T07:00:00.000Z" } ] } ``` ### DELETE `/api/water-intake/logs/[logId]` [#delete-apiwater-intakelogslogid] Deletes a water intake log owned by the authenticated user. ```json { "success": true } ``` The route returns `403` for a log owned by another user and `404` when the log does not exist. ## Date-range history [#date-range-history] ### GET `/api/water-intake/history` [#get-apiwater-intakehistory] Returns user-scoped logs and daily totals for an inclusive date range. Both parameters are required, use `YYYY-MM-DD`, and `from` must be on or before `to`. ```http GET /api/water-intake/history?from=2026-03-01&to=2026-03-07 Authorization: Bearer ``` ```json title="Response" { "from": "2026-03-01", "to": "2026-03-07", "average": 1250, "dailyTotals": [ { "date": "2026-03-01", "totalMl": 1000 }, { "date": "2026-03-02", "totalMl": 1500 } ], "logs": [ { "id": "log-001", "amountMl": 250, "date": "2026-03-01", "timestamp": "2026-03-01T10:30:00.000Z" } ] } ``` `average` is the rounded average across every day in the requested range, including days with no logs. ## Settings [#settings] ### GET `/api/water-intake/settings` [#get-apiwater-intakesettings] Returns the user's daily hydration goal: ```json { "dailyGoalMl": 2000 } ``` ### PATCH `/api/water-intake/settings` [#patch-apiwater-intakesettings] Updates the user's daily hydration goal. The only accepted field is `dailyGoalMl`, an integer from `500` through `10000`. ```json title="Request" { "dailyGoalMl": 2500 } ``` ```json title="Response" { "dailyGoalMl": 2500 } ``` ## Scope boundary [#scope-boundary] This page documents user-scoped hydration routes. Admin record queries and other operator surfaces are outside this external integration reference. # Wellness Chat API (/docs/ai/wellness-chat) The Wellness Chat API powers the 1CARE wellness assistant. It supports authenticated, streaming conversations about general wellness topics such as recovery, sleep, movement, nutrition, and recent assessment context. ## Authentication [#authentication] Send a Firebase ID token in the `Authorization` header: ```http Authorization: Bearer ``` The `aiChat` module must be available for the request hostname's client. The assistant runs in the authenticated user's context and must not be used to access another user's data. ## Start or continue a chat [#start-or-continue-a-chat] ### POST `/api/wellness-chat` [#post-apiwellness-chat] The request must include an `id` and either a latest `message` or a non-empty `messages` array. The last message must have the `user` role. | Field | Type | Required | Description | | ---------- | ------------- | -------- | -------------------------------------------------------------------------- | | `id` | string | Yes | Session identifier. Use `draft-session` to create a new persisted session. | | `message` | UI message | No | Latest user message for a new or continuing request. | | `messages` | UI message\[] | No | Alternative input; the final item is used as the latest user message. | An AI SDK UI message contains a `role` and `parts`. A text part has `{"type":"text","text":"..."}`. ```json title="Request" { "id": "draft-session", "message": { "id": "msg-001", "role": "user", "parts": [ { "type": "text", "text": "What are three simple ways to improve my sleep this week?" } ] } } ``` When `id` is `draft-session`, the server creates a wellness session for the authenticated user and returns its generated ID in the stream metadata. When continuing a session, the ID must belong to the authenticated user and have the `wellness` assistant type; otherwise the route returns `404`. The response is an AI SDK UI message stream. Consume the stream with the AI SDK client rather than treating it as a JSON object. The stream can include assistant reasoning and tool parts. The server may use these user-scoped tools when relevant: * `get_current_time` * `get_user_available_data` * `get_user_field_value` * `get_body_metrics_history` The assistant provides general wellness information. It does not diagnose medical conditions or prescribe treatment. Users describing urgent symptoms should be directed to qualified medical help. ## Request errors [#request-errors] The route returns `400` when no session ID is supplied, no message is supplied, or the latest message is not from the user. Missing or invalid authentication returns `401`; an unavailable client module returns `403`. The published production route exposes chat creation and continuation through this POST endpoint. It does not publish separate chat-history GET, PATCH, or DELETE routes, so clients should not build against the legacy history paths. # Existing client integrations (/docs/integration-authentication/client-token) Some existing GOFA backend integrations use a client-specific credential for server-side API access. **Contact GOFA for the supported setup, credential handling, and migration guidance for your integration.** Do not place backend credentials or tokens in a mobile app or browser. For member sign-in with a GOFA Flutter SDK, see [SDK authentication](/docs/sdk/authentication). For new Commercial API integrations, see [Commercial API authentication](/docs/authentication). # Client users (/docs/integration-authentication/client-user) Client-user directory access is available only to authorized integrations. **Contact GOFA for the supported API contract, permissions, and migration guidance for your existing integration.** For member sign-in through a Flutter SDK, see [SDK authentication](/docs/sdk/authentication). # Client integration authentication (/docs/integration-authentication) The [Commercial API authentication guide](/docs/authentication) covers the server-side `Authorization: Bearer ` credential used by the Commercial API. This section covers the separate client-integration routes under `/api/client` and `/api/auth`. See [Choose an authentication flow](/docs/authentication-overview) if you are not sure which credential you need, or [SDK authentication](/docs/sdk/authentication) for Flutter Lessons and WebView member sign-in. Use one credential type per request: * For member sign-in, use the [backend member sign-in API](/docs/integration-authentication/member-sign-in) with your enrolled client ID/secret or a scoped ClientToken. * Existing backend integrations should [contact GOFA](/docs/integration-authentication/client-token) for credential and migration guidance. * User-facing integrations can use the [Firebase user-authentication flow](/docs/integration-authentication/user-auth). * Authorized integrations can read and update records through the [client-user endpoints](/docs/integration-authentication/client-user). Complete account registration on the GOFA website before using user authentication. Confirm client provisioning, module entitlement, and environment details with GOFA before building an integration. # Backend member sign-in (/docs/integration-authentication/member-sign-in) Use this API from your authenticated backend. GOFA must enable member sign-in for your tenant and enroll the supplied client secret for the `external_user` grant. An arbitrary existing API credential does not automatically have this permission. ## Environments [#environments] | Environment | Base URL | | ----------- | -------------------------- | | UAT | `https://www.uat.gofa.app` | | Production | `https://www.gofa.app` | Use the matching environment's client ID and secret. Send requests directly to the canonical HTTPS host so a redirect does not alter the POST or its credentials. ## Direct exchange (recommended) [#direct-exchange-recommended] ```http POST /api/auth/custom-token Content-Type: application/json ``` ```json { "grantType": "external_user", "clientId": "your-client-id", "clientSecret": "", "externalUserId": "" } ``` Derive `externalUserId` from your verified member session. Never trust a member ID submitted by the app without checking that session. Keep the existing ID unchanged. Do not send an Authorization or ClientToken header with the direct credential pair. The success response contains `customToken` and `user`. Read `customToken` and return only that field to the same authenticated app session with `Cache-Control: no-store`. Never return the client secret or ClientToken. ```json {"customToken": ""} ``` ## Optional ClientToken exchange [#optional-clienttoken-exchange] If your backend prefers a temporary token, first obtain one with an explicit member-sign-in scope: ```http POST /api/client/token Content-Type: application/json ``` ```json { "clientId": "your-client-id", "clientSecret": "", "scope": "external_user" } ``` The response includes `token`, `apiKeyId`, `scope` and `expiresAt`. This ClientToken lasts **15 minutes** and authorizes member sign-in only. It cannot access tenant admin/data APIs. Keep it on the backend and renew it before expiry. ```http POST /api/auth/custom-token Content-Type: application/json ClientToken: ``` ```json { "grantType": "external_user", "externalUserId": "" } ``` The token determines the tenant. Do not also send `clientId`, `clientSecret` or an Authorization header. GOFA rechecks the originating secret's active status and permission on every exchange; expiry, revocation or removal of that permission also prevents further exchanges with already-issued ClientTokens. ## Identity, lifetime and errors [#identity-lifetime-and-errors] GOFA creates the UID `clientId|externalUserId` and preserves existing member records. New members are provisioned only when enabled for the tenant. Disabled members cannot sign in. Use the matching Firebase project through the SDK's UAT or production configuration. Custom tokens expire after one hour; request them when needed and redeem them immediately. Firebase session renewal is separate from the backend ClientToken. See [SDK authentication](/docs/sdk/authentication#token-lifetime-and-logout). | Status | Meaning / handling | | ------ | ---------------------------------------------------------------------------- | | 400 | Invalid or mixed request fields; correct the request. | | 401 | Invalid, expired, revoked or unauthorized credential/token. | | 403 | Tenant sign-in or member access is disabled, or provisioning is unavailable. | | 429 | Rate limit reached; honor `Retry-After` and use bounded backoff. | | 503 | Provisioning/authentication unavailable; retry with bounded backoff. | Keep credentials in a server secret store, exclude tokens/credentials from logs, and use HTTPS. During rotation, GOFA can enroll two active server secrets; deploy the replacement on your backend, verify it, then revoke the previous secret. Credential rotation does not terminate existing Firebase sessions. Current limits are 60 validations per key per minute and 30 per key/source per minute for sign-in and token exchange separately. New-user provisioning is limited to 10 per key and 5 per key/source per minute. Coordinate expected concurrent sign-in volume with GOFA before rollout. # User authentication (/docs/integration-authentication/user-auth) This page describes the Firebase flows used by existing user-facing integrations. A Firebase **custom token** is for Firebase SDK sign-in. After sign-in, obtain a Firebase **ID token** and send that ID token to a protected GOFA user route. For GOFA Lessons or WebView in a Flutter app, read [SDK authentication](/docs/sdk/authentication) and the [backend member sign-in API](/docs/integration-authentication/member-sign-in). The `external_user` grant uses an enrolled backend client credential or a scoped ClientToken; the password flow below authenticates the user's own password. ## Password sign-in through GOFA [#password-sign-in-through-gofa] **POST** `/api/auth/custom-token` Send the following JSON from the user-facing integration: ```http Content-Type: application/json ``` ```json { "clientId": "your-client-id", "email": "user@example.com", "password": "user-password" } ``` The optional `grantType` field may be set to `"password"`. The request is strict: unknown fields are rejected. The endpoint verifies the password with Firebase, resolves the user in the client directory, and requires an active Firebase account with `player.status: "active"`. ### Success response [#success-response] ```json { "customToken": "", "user": { "uid": "", "email": "user@example.com", "displayName": "Example User", "photoURL": null, "emailVerified": false, "disabled": false } } ``` Use the returned token immediately with the Firebase client SDK: ```ts const credential = await signInWithCustomToken(auth, data.customToken); const idToken = await credential.user.getIdToken(); ``` The custom token is not a direct GOFA API credential. Use the resulting Firebase ID token as `Authorization: Bearer ` where a user token is accepted. The endpoint does not expose a custom-token TTL; obtain fresh Firebase tokens through the SDK rather than caching the exchange result. ### Error responses [#error-responses] | Status | Body | | ------ | ------------------------------------------------------------------- | | `400` | `{ "code": "invalid_request", "error": "Invalid request" }` | | `401` | `{ "code": "invalid_credentials", "error": "Invalid credentials" }` | | `403` | `{ "code": "user_disabled", "error": "User access is disabled" }` | | `404` | `{ "code": "user_not_found", "error": "Client user not found" }` | | `500` | `{ "code": "internal_error", "error": "Internal server error" }` | The server may return a more specific message for the same error code, so clients should branch on the HTTP status and code rather than a human-readable message. ## Direct Firebase sign-in and claims [#direct-firebase-sign-in-and-claims] An integration that signs in directly with Firebase (for example with an OAuth provider) can request GOFA claims after it has an ID token. **POST** `/api/auth/set-claims` ```http Content-Type: application/json ``` ```json { "clientId": "your-client-id", "idToken": "" } ``` The endpoint verifies the ID token and uses the UID inside that token to find the client user. It does not accept a separate target UID. For a normal client, the signed-in user must have a corresponding client-user record. ### Success response [#success-response-1] ```json { "success": true, "claims": { "your-client-id": { "player": { "status": "active" } } }, "user": { "uid": "", "email": "user@example.com", "displayName": "Example User", "photoURL": null, "emailVerified": false, "disabled": false } } ``` The returned claims are also written to Firebase Auth. Refresh the ID token through the Firebase SDK before calling a route that depends on newly written claims. Typical errors are `400` for a missing `clientId` or `idToken`, `401` for an invalid ID token, `404` when the client user cannot be found, and `500` for a server error. Some internal session types can receive `403`; they are not a public user-authentication grant. ## Exchange an existing Firebase session [#exchange-an-existing-firebase-session] **POST** `/api/auth/exchange-token` This route is for an integration that already has a valid Firebase session and needs a Firebase custom token for a GOFA web-module handoff. ```http Authorization: Bearer ``` No request body is required. The client ID is derived from the request hostname. The server verifies the ID token, finds that UID under the hostname's client, and returns: ```json { "customToken": "" } ``` The response is `401` for a missing or invalid bearer token, `404` when the client user is not found, and `500` for a server error. Approved cross-origin callers must also satisfy the route's CORS allowlist. For a supported GOFA web-module handoff, pass the custom token in the module's documented `customToken` query parameter. The web app can consume that token to establish a browser session. Do not use this custom token as a bearer token for the client API. ## Website registration boundary [#website-registration-boundary] Website registration is handled by the GOFA website flow. This guide does not define a public `register` endpoint or a client-user creation workflow. # SDK authentication (/docs/sdk/authentication) Your backend authenticates the member and requests a Firebase custom token from GOFA. Your Flutter app supplies that token through `customTokenProvider`. Keep the GOFA client secret and any ClientToken exclusively on your backend. GOFA enables member sign-in and supplies credentials separately for each environment. ## Integration flow [#integration-flow] 1. Your app signs the member in using your existing authentication system. 2. Your backend verifies the member's session and identifies the same stable member account used by the integration. 3. Your backend requests a GOFA Firebase custom token using its client ID and client secret. See the [backend member sign-in API](/docs/integration-authentication/member-sign-in). Return only `{"customToken":"..."}` to that authenticated app session, with `Cache-Control: no-store`. 4. The GOFA SDK uses the custom token to sign the member in and open the GOFA experience. Your server credential is never sent to the app. The optional scoped ClientToken exchange is documented on the backend API page. It is unnecessary for the direct client ID/secret request. ## Flutter packages [#flutter-packages] The published integration versions are `gofa_pose 2.2.19`, `gofa_lessons 1.2.12`, `gofa_webview 2.1.5`, `gofa_core 1.1.6` and `gofa_firebase 1.0.19`. Resolve packages through your authorized OnePub account and retain the resulting lockfile. These examples target native Flutter Android/iOS applications. For `GofaWebView`, use the HTTPS tenant host matching the configured client ID and environment: `https://.uat.gofa.app` for UAT or `https://.gofa.app` for production, retaining the experience's page path. Member-token views reject a different tenant or environment. The SDK also supports the matching canonical GOFA host (`www.uat.gofa.app` or `www.gofa.app`); it does not send member tokens to arbitrary hosts. ## Token provider [#token-provider] Adapt the URL and member-session getter to your own backend. The session below is your app's member session. The app does not choose a member ID in this request. ```dart import 'dart:convert'; import 'dart:io'; Future Function() memberTokenProvider({ required Uri backendTokenUrl, required Future Function() getMemberSession, }) => () async { if (backendTokenUrl.scheme != 'https') { throw StateError('HTTPS backend required'); } final session = await getMemberSession(); if (session.isEmpty) throw StateError('Sign-in required'); final http = HttpClient() ..connectionTimeout = const Duration(seconds: 15); try { final req = await http.postUrl(backendTokenUrl); req.headers.contentType = ContentType.json; req.headers.set('Authorization', 'Bearer $session'); req.write('{}'); final res = await req.close().timeout(const Duration(seconds: 30)); final text = await utf8.decoder.bind(res).join() .timeout(const Duration(seconds: 30)); if (res.statusCode != 200) throw StateError('GOFA sign-in unavailable'); final result = jsonDecode(text); if (result is! Map || result['customToken'] is! String || (result['customToken'] as String).isEmpty) { throw StateError('Missing sign-in token'); } return result['customToken'] as String; } finally { http.close(force: true); } }; ``` Request a fresh token for each callback; do not store the custom token for reuse. Map backend session-expiry errors into your app's own sign-in experience. A network retry must recheck the current member session and request a fresh token. ## WebView and Lessons [#webview-and-lessons] Provide your existing backend URL, session getter and stable member ID below. The backend remains responsible for deriving and verifying that same ID. ```dart import 'package:gofa_lessons/gofa_lessons.dart'; import 'package:gofa_webview/gofa_webview.dart'; final provider = memberTokenProvider( backendTokenUrl: yourBackendTokenUrl, getMemberSession: getCurrentMemberSession, ); const environment = Environment.UAT; // Environment.PRD for production const clientId = 'your-uat-client-id'; final memberId = existingStableMemberId; final webview = WebViewSdkManager(environment: environment); await webview.init( environment: environment, clientId: clientId, clientUserId: memberId, customTokenProvider: provider, ); if (!webview.isInitialized) throw StateError('WebView not initialized'); final lessons = LessonSdkManager(isDemoApp: false); await lessons.init( environment: environment, params: { 'clientId': clientId, 'clientUserId': memberId, // Retain your other module-specific parameters. }, customTokenProvider: provider, ); if (!lessons.isInitialized) throw StateError('Lessons not initialized'); ``` ## Challenge uses the same member sign-in [#challenge-uses-the-same-member-sign-in] Challenge (the `gofa_pose` package), Lessons and WebView use the same `customTokenProvider`, environment, client ID and member ID. Any module may initialize first; Challenge also works on its own. The SDK selects and shares the Firebase app and member session internally. ```dart import 'package:gofa_pose/gofa_pose.dart'; final pose = GofaPoseSdkManager( // Keep your existing App Check, Crashlytics and device options. ); await pose.init( environment: environment, clientId: clientId, clientUserId: memberId, customTokenProvider: provider, ); if (!pose.isInitialized) throw StateError('Challenge not initialized'); ``` No manual Firebase-app handoff or fallback-account setting is needed in this member flow. Use the same provider across modules. A different tenant, member or environment is rejected instead of silently replacing the active session. After logout, module initialization cannot automatically sign the member back in; follow the explicit login renewal below. Verify camera access and result persistence in joint UAT. ## Preserve the existing member identity [#preserve-the-existing-member-identity] Use the exact same external member ID already assigned to each member, including case. GOFA constructs the Firebase UID as `clientId|externalUserId`. Do not send that full prefixed UID as `externalUserId`, generate a new ID on each login, or substitute an email address. Confirm existing records remain visible in UAT. ## Token lifetime and logout [#token-lifetime-and-logout] Firebase custom tokens expire after one hour and should be redeemed immediately. That is not a one-hour app-session limit. Firebase issues an ID token lasting about one hour and uses its refresh token to renew the signed-in session. See [Firebase custom tokens](https://firebase.google.com/docs/auth/admin/create-custom-tokens) and [session management](https://firebase.google.com/docs/auth/admin/manage-sessions). ### Local logout sample [#local-logout-sample] Use `AuthManager.signOut()` from gofa\_firebase 1.0.19 or later, with gofa\_webview 2.1.5 or later. This immediately blocks new SDK sign-ins, rejects pending custom-token results, waits for in-flight authentication, signs out of Firebase and clears the manager's session state. Automatic SDK listeners cannot sign the member back in. The SDK also blocks its managed WebViews and waits for their separate GOFA web sessions to sign out. The host app still blocks protected screens and controls navigation. The named host functions below are integration hooks to implement in your app/backend, not GOFA APIs. ```dart import 'package:get_it/get_it.dart'; import 'package:gofa_firebase/gofa_firebase.dart'; Future logoutMember(String previousSession) async { // Host: synchronously block screens AND token-provider requests. blockGofaScreensAndTokenRequests(); try { await Future.wait([ Future.sync(() => GetIt.I().signOut()) .whenComplete(clearLocalMemberSession), // Also runs if GOFA cleanup fails. Future.sync(() => invalidateMemberBackendSession(previousSession)), ], eagerError: false); // Attempt every step, even if another step fails. } finally { removeGofaScreensFromNavigation(); // Host UI; cleanup has settled. } } ``` Pass the current backend session captured before clearing local state. The host gate must stop token-provider requests until a new verified login. Each cleanup step is attempted independently; an error still propagates after the steps finish. The host must handle failures, keep protected screens blocked and retry backend invalidation when offline. GOFA handles cleanup for SDK-managed WebViews, including GOFA sessions left by views closed earlier. It uses GOFA's web logout bridge and waits for confirmation; it does not wipe unrelated browser cookies or storage. A timeout or cleanup failure is an error, not a successful logout. Keep protected screens blocked and retry after resolving the failure. Initialize WebViewSdkManager as shown above, even before opening a view, so it registers cleanup for the matching GOFA web origins after an app restart. Keep SDK views mounted behind the logout gate until signOut settles. The sample clears the local member session only after GOFA cleanup settles, even if cleanup fails; handle that failure and keep protected access blocked. Your app owns clearing its local member session, calling its backend to invalidate that session, and removing GOFA screens from navigation. WebViews created outside the GOFA SDK remain your responsibility. Create fresh GOFA screens after a new verified login; do not reuse a previous member's view. Confirm logout and account switching on supported Android and iOS devices in joint UAT. After logout succeeds and a new member login is verified, explicitly call `AuthManager.renewClientInformationAndSignIn` with `clientId`, the same stable `clientUserId` and the new session's `customTokenProvider`, or initialize a fresh SDK lifecycle. Failed cleanup blocks sign-in until `signOut()` is retried and succeeds; do not replace the AuthManager to bypass this protection. A plain automatic `signInWithUserId()` does not resume after logout. ### Server-side session invalidation [#server-side-session-invalidation] Local logout affects this device/session. Invalidating your own backend session prevents new custom-token requests through that session, but does not invalidate an already established GOFA Firebase session on another device. GOFA can separately revoke Firebase refresh tokens for the member. This is an administrative operation; no partner-facing session-revocation endpoint is included in this migration. It affects the member's Firebase sessions across devices. Existing ID tokens can remain usable until expiry (about one hour) unless the receiving service also checks revocation. Immediate all-device invalidation requires a separately agreed, tested revocation-aware design. Rotating a client secret blocks future credential use; it does not itself revoke Firebase member sessions. ## UAT before production [#uat-before-production] Verify the partner backend, exact member IDs, existing/new members, token refresh, logout and account switching, WebView, Lessons, Pose, supported Android/iOS devices and application rollout. GOFA's mock-backend tests do not replace acceptance of your backend and released app. # GOFA Demo App (/docs/sdk/demo-app) We offer a dedicated demo app to showcase the features and capabilities of the GOFA Flutter SDK. ## What the Demo App Offers [#what-the-demo-app-offers] * **Live demonstration** of AI-powered motion tracking and fitness analysis * **Interactive lessons** and challenge experiences * **Real-time feedback** on exercise form and posture * **Preview of integration** with Flutter and mobile platforms using the GOFA Flutter SDK ## Accessing the Demo App [#accessing-the-demo-app] To try the demo app or arrange a distribution for your development or testing team, please contact the GOFA team directly. The demo app is regularly updated to reflect the latest features and improvements in the GOFA Flutter SDK. # Flutter SDKs (/docs/sdk) Choose the guide for the experience you want to add to your Flutter application. GOFA distributes these packages through OnePub; follow the installation guide for your package and use the organization access provided by GOFA. ## Start with sign-in [#start-with-sign-in] If your app signs members in to GOFA, read [SDK authentication](/docs/sdk/authentication) before choosing a package. It explains the planned server-mediated sign-in approach. Contact GOFA for an existing app's setup and migration guidance. ## Choose a package [#choose-a-package] | Package | Integration | Start here | | -------------- | ------------------------------------------------------------- | ------------------------------------------------- | | `gofa_pose` | Motion tracking, exercise challenges, and repetition counting | [GOFA Challenge](/docs/sdk/challenge) | | `gofa_lessons` | Fitness lessons, training plans, and playback events | [GOFA Lessons](/docs/sdk/lessons) | | `gofa_webview` | GOFA web experiences inside a Flutter application | [GOFA WebView](/docs/sdk/webview/getting-started) | ## Resources [#resources] * [About the SDK](/docs/sdk/about) * [Demo application](/docs/sdk/demo-app) * [SDK release notes](/docs/sdk/release-notes) * [Software license](/docs/sdk/about/software-license) Package-specific requirements, initialization parameters, and version notes are listed in each guide. Contact GOFA if you need access to a package or an integration environment. # Release Notes (/docs/sdk/release-notes) ## 6 October 2026: coordinated SDK and WebView logout [#6-october-2026-coordinated-sdk-and-webview-logout] Current integration baseline: gofa\_firebase **1.0.19**, gofa\_core **1.1.6**, gofa\_webview **2.1.5**, gofa\_lessons **1.2.12**, gofa\_pose **2.2.19** and gofa\_rehab **0.1.6**. * `AuthManager.signOut()` also blocks SDK-managed WebViews and waits for confirmation that their GOFA web sessions have signed out. * Cleanup includes sessions left by closed SDK views. Unrelated browser data is not cleared. Failed cleanup is reported and protected views stay blocked. * Host applications still handle their navigation, local member session and backend-session invalidation. Host-created WebViews require host integration. * Requires the updated GOFA web logout bridge. See the [authentication and logout examples](/docs/sdk/authentication). ## 6 October 2026: unified Challenge, Lessons and WebView sign-in [#6-october-2026-unified-challenge-lessons-and-webview-sign-in] Previous integration baseline: gofa\_firebase **1.0.18**, gofa\_core **1.1.5**, gofa\_webview **2.1.4**, gofa\_lessons **1.2.11**, gofa\_pose **2.2.18** and gofa\_rehab **0.1.5**. * Challenge (`gofa_pose`), Lessons and WebView use the same `customTokenProvider` and share the member session internally. * Challenge can initialize first or run alone. No manual Firebase-app handoff or fallback-account configuration is needed in member mode. * Initialization rejects mixed member, tenant or environment settings and cannot automatically resume a logged-out session. * Use the current [authentication and logout examples](/docs/sdk/authentication). The entries below describe earlier releases and are superseded by this baseline for new member-sign-in integrations. ## 6 October 2026: member session logout and Pose continuity [#6-october-2026-member-session-logout-and-pose-continuity] Published package baseline: gofa\_firebase **1.0.17**, gofa\_core **1.1.4**, gofa\_webview **2.1.3**, gofa\_lessons **1.2.10**, gofa\_pose **2.2.17** and gofa\_rehab **0.1.4**. * `AuthManager.signOut()` blocks re-login from pending requests and SDK listeners. * WebView does not return a cached custom token after coordinated logout. * Pose accepts the authenticated Firebase app and preserves the current member. * High-level dependency minimums include the new session handling. * See [authentication and logout](/docs/sdk/authentication). For package version compatibility and migration support, contact GOFA. Read [SDK authentication](/docs/sdk/authentication) for member sign-in through your backend. ## 2026-10-06 [#2026-10-06] * `gofa_pose` 2.2.16 preserves an authenticated member during initialization and accepts the existing Firebase app. Backend-token integrations set `isLoginDefaultFirebaseAuthAccount: false`. * Use `gofa_lessons` 1.2.9, `gofa_webview` 2.1.2, `gofa_core` 1.1.3 and `gofa_firebase` 1.0.16 for the corrected concurrent sign-in dependency chain. * The [backend member sign-in API](/docs/integration-authentication/member-sign-in) documents the direct client ID/secret flow and optional scoped ClientToken. ## 2026-09-29 [#2026-09-29] ### Server-provided member sign-in [#server-provided-member-sign-in] The published packages support receiving a fresh Firebase custom token from the integrator's authenticated backend for member sign-in: * `gofa_firebase` 1.0.15 * `gofa_core` 1.1.2 * `gofa_lessons` 1.2.8 * `gofa_webview` 2.1.1 * `gofa_pose` 2.2.14 * `gofa_rehab` 0.1.2 The GOFA server endpoint for this flow is being rolled out separately. Contact GOFA to plan the integration and migration of an existing app. ## 2026-04-13 [#2026-04-13] ### GOFA SDK 3.13.2+113 [#gofa-sdk-3132113] This release updated `gofa_firebase` to `1.0.13` and `gofa_webview` to `1.2.3`. Contact GOFA for version-specific authentication and identity guidance for an existing app. # Lesson plays API (/docs/activities/lesson-plays) A lesson play is a user's runtime record for one lesson. The endpoints are authenticated and client-scoped by the request host. Use `ClientToken: ` for a backend integration or `Authorization: Bearer ` for a signed-in user. See [Activities authentication](/docs/activities). ## Start a lesson play [#start-a-lesson-play] **POST** `/api/lesson-plays` The client ID is derived from the request host. The JSON body requires `lessonId`: ```json title="Request" { "lessonId": "lesson-123", "userId": "user-456", "email": "user@example.com", "displayName": "Example User", "photoURL": "https://cdn.example.com/user.jpg", "skipWarmUp": false, "skipCoolDown": false, "skipCalibration": false, "locale": "en", "playbackSpeed": 1, "performanceTier": "medium", "returnUrl": "https://partner.example.com/lesson-finished", "planPlayId": "plan-play-123" } ``` Optional fields are `userId`, `email`, `displayName`, `photoURL`, `skipWarmUp`, `skipCoolDown`, `skipCalibration`, `locale`, `playbackSpeed`, `performanceTier`, `returnUrl`, and `planPlayId`. Supported locale values are `en`, `zh`, `zh-Hant`, `zh-Hans`, and `zh-TW`. `performanceTier` accepts `high`, `medium`, or `low`. The response contains the created ID and the route used to launch it: ```json { "lessonPlayId": "generated-lesson-play-id", "redirectUrl": "/move/lesson/play/generated-lesson-play-id", "createdAt": "2026-09-08T04:00:00.000Z" } ``` When a Firebase ID token is used, a supplied `userId` must equal the token's UID. A client token is the backend integration credential for creating a play on behalf of a client. `returnUrl` must be a valid URL when supplied. ## List lesson plays [#list-lesson-plays] **GET** `/api/lesson-plays` Use `clientId` to select a client when the integration is authorized to do so; when it is omitted, the request host supplies the client. The endpoint accepts these query parameters: | Query parameter | Description | | --------------------- | -------------------------------------------------------------- | | `page` | 1-based page number; defaults to `1`. | | `pageSize` or `limit` | Positive page size; defaults to `20`. | | `sortBy` | Sort field. | | `sortOrder` | `asc` or `desc`. | | `lessonId` | Filter by lesson. | | `userId` | Filter by user; a Firebase token is restricted to its own UID. | | `period` | `all`, `week`, or `month`. | | `includeSummary` | Include aggregate summary fields when `true`. | | `startAfter` | The opaque `nextPageToken` returned by the previous page. | The response is paginated: ```json { "data": [], "page": 1, "pageSize": 20, "totalCount": 0, "totalPages": 0, "hasMore": false, "nextPageToken": null } ``` When `includeSummary=true`, `summary` may include `totalLessons`, `completedLessons`, `openOnlyAttempts`, `totalMinutes`, `totalCalories`, `averageQualityScore`, and `averagePoseSimilarity`. ## Read and update one lesson play [#read-and-update-one-lesson-play] **GET** `/api/lesson-plays/{lessonPlayId}` returns the record under `data`. When available, the response also includes display titles for its lesson and sessions. **PATCH** `/api/lesson-plays/{lessonPlayId}` currently supports only `returnUrl`, including `null` to clear it: ```json { "returnUrl": "https://partner.example.com/lesson-finished" } ``` The response is: ```json { "success": true, "lessonPlayId": "generated-lesson-play-id", "updatedFields": ["returnUrl"] } ``` These operations return 404 for an unknown play and 401 when the Firebase token does not belong to the play's user. ## Complete a lesson play [#complete-a-lesson-play] **POST** `/api/lesson-plays/{lessonPlayId}/complete` The completion request requires `activityTime` as a finite number greater than or equal to zero. `sessions` is optional and contains the recorded session objects: ```json { "activityTime": 183, "sessions": [] } ``` The endpoint requires a Firebase ID token whose UID is the play's user. A successful request returns `success`, the `lessonPlayId`, the updated `activityTime`, and any calculated `offlineClass` result. ## Runtime record [#runtime-record] The `data` object can contain the play ID, client and user IDs, `lessonId`, an optional `planPlayId`, timestamps, completion state, activity metrics, `sessions`, locale, skip flags, playback settings, and `returnUrl`. The exact metric and session fields depend on the lesson configuration and recorded runtime data; clients should preserve unknown response fields. To embed the runtime player and exchange parent-frame events, see [Lesson play postMessage integration](/docs/activities/lesson-plays/postmessage-integration). # Lesson play postMessage integration (/docs/activities/lesson-plays/postmessage-integration) The lesson player is available at `/move/lesson/play/{lessonPlayId}`. Embed that route in an iframe after creating a lesson play: ```html ``` The host name is client-derived, so use the client host assigned to your integration. `allow` permissions are required when the lesson needs camera or microphone access. ## URL flags [#url-flags] | Flag | Effect | | ----------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `plain=true` | Uses the plain lesson player without AI pose tracking. | | `noAutoNav=true` | Prevents the default result-page navigation after completion and prevents the default back navigation on exit. | | `locale=en` | Selects English; values beginning with `en` use English and other values use Traditional Chinese. | | `performanceTier=high\|medium\|low` | Selects the requested playback/performance tier. | If a lesson play has a `returnUrl`, that URL is used before the default navigation choice. `noAutoNav` is useful when the parent application handles the result event itself. ## Commands from the parent [#commands-from-the-parent] The parent can send these command messages to the iframe: `skipSession`, `skipCalibration`, `pauseVideo`, `playVideo`, `startCountdown`, `pausePlay`, and `unpausePlay`. Send to the exact frame origin. Derive the origin from the configured frame URL or from an allowlist owned by the integrating application: ```js const frame = document.querySelector('#gofa-lesson-player'); const expectedOrigin = new URL(frame.src, window.location.href).origin; frame.contentWindow?.postMessage( { type: 'pauseVideo' }, expectedOrigin, ); ``` ## Events from the iframe [#events-from-the-iframe] The player sends completion and exit events to its parent. The current wire shape is flat; the lesson play ID is a top-level field: ```json { "type": "FINISH_LESSON_PLAY", "lessonPlayId": "lesson-play-123" } ``` ```json { "type": "EXIT_LESSON_PLAY", "lessonPlayId": "lesson-play-123" } ``` In `plain=true` mode, it can also send: ```json { "type": "SESSION_COMPLETE", "completionPct": 100, "durationMinutes": 3, "scoringMode": "no-tracking" } ``` The parent must validate both the sender origin and the sender window before acting on a message. The checks below are part of the integration boundary: ```js const frame = document.querySelector('#gofa-lesson-player'); const expectedOrigin = new URL(frame.src, window.location.href).origin; window.addEventListener('message', (event) => { if (event.origin !== expectedOrigin || event.source !== frame.contentWindow) { return; } const message = event.data; if (!message || typeof message !== 'object' || typeof message.type !== 'string') { return; } if ( (message.type === 'FINISH_LESSON_PLAY' || message.type === 'EXIT_LESSON_PLAY') && typeof message.lessonPlayId === 'string' ) { // Update the parent UI or navigate according to your application policy. } }); ``` The current player listener accepts the listed command names without checking `event.origin` or `event.source`. The parent-side checks above therefore remain required, and hardening the player listener itself requires an application change. This page records the released runtime contract; it is not a deep security review. # About GOFA Flutter SDK (/docs/sdk/about) Welcome to the GOFA Flutter SDK documentation! Here you'll find all the information you need to integrate our advanced AI-powered fitness solutions into your Flutter application. GF Lab International provides two primary Flutter SDKs to help enhance and optimize user experiences in health and fitness apps: ## 1. GOFA Lessons Flutter SDK [#1-gofa-lessons-flutter-sdk] The GOFA Lessons Flutter SDK allows for seamless integration of video lessons, providing detailed tracking of user interactions and lesson progress using Firebase Firestore. Features include: * Tracking lesson view counts and play statuses. * Storing progress and results of individual lesson sessions. * Managing lesson plans, combining multiple video lessons into structured programs. * Handling user-specific data, including lesson histories and daily activity records. 👉 [View the GOFA Lessons Flutter SDK README](/docs/sdk/lessons) ## 2. GOFA Challenge Flutter SDK [#2-gofa-challenge-flutter-sdk] The GOFA Challenge Flutter SDK, provided as the `gofa_pose` Flutter package, offers real-time feedback and analysis on posture and movement using cutting-edge AI. Designed for the GOFA Challenge experience, it enables users to: * Challenge themselves to perform their best within a limited time (e.g., 30 seconds). * Reach specific targets (e.g., 30 push-ups). 👉 [View the GOFA Challenge Flutter SDK README](/docs/sdk/challenge) ## Getting Started [#getting-started] Each Flutter SDK comes with a demo application to simplify the integration process and demonstrate key functionalities. The `gofa_sdk` demo app provides a practical example of how to incorporate these features into your application. 👉 [About GOFA Demo App](/docs/sdk/demo-app) # Software License (/docs/sdk/about/software-license) Please refer to the LICENSE file in the root directories of both our Demo App "gofa\_sdk" and our SDK "gofa\_pose", "gofa\_lessons". Please note that under any circumstances, you are not allowed to disclose our source code to the public, or any third-parties. We hope this wiki will help you get started with "gofa\_pose", "gofa\_lessons" and our demo app. If you have any questions, feel free to contact us. # GOFA Challenge (/docs/sdk/challenge) The **GOFA Challenge** SDK, provided as the `gofa_pose` Flutter package, enables developers to integrate real-time AI-powered motion tracking and feedback into their fitness apps. It is designed for interactive workout challenges, such as performing as many reps as possible within a set time (e.g., 30 seconds) or reaching a specific target (e.g., 30 push-ups). ## Key Features [#key-features] * **Real-Time Feedback:** Uses AI to analyze posture and count reps during workouts. * **Challenge Modes:** Supports time-based and rep-based challenges. * **Customizable Workouts:** Easily configure poses, targets, and challenge durations. * **Engaging UI:** Includes ready-to-use screens for pose selection, workout, and results. ## Typical User Flow [#typical-user-flow] 1. **HomeScreen:** Entry point with a button to start the Fitness AI Demo. 2. **SelectPoseScreen:** Users select a workout pose, set targets, and start the challenge. 3. **WorkoutSetScreen:** Displays camera preview, pose skeleton overlay, workout dashboard, and controls. 4. **WorkoutSetResultScreen:** Shows results such as total reps and time used. ## Integration Overview [#integration-overview] * Add the `gofa_pose` package to your Flutter project. * Initialize the `GofaPoseSdkManager` singleton using GetIt. * Use provided widgets and screens to compose the challenge experience. * Refer to the [Installation](/docs/sdk/challenge/installation) and [Code Setup](/docs/sdk/challenge/installation/code-setup-gofa-lessons) guides for step-by-step instructions. ## Additional Resources [#additional-resources] * [Screen and Logic Flows](/docs/sdk/challenge/references/screen-and-logic-flows): Detailed explanation of UI and logic. * [Pose API](/docs/sdk/challenge/pose-api/poses): List of supported poses and API usage. * [SDK Versions](/docs/sdk/challenge/sdk-versions): Changelog and version history. # Changelog (/docs/sdk/challenge/sdk-versions) > ⚠️ **Note:** This changelog may be outdated. For the most up-to-date information, please refer to the [OnePub CHANGELOG.md](https://onepub.dev/packages/gofa_pose/fvnuhnofly) file. ## \[2.2.19] - 6 October 2026 [#2219---6-october-2026] * Updated shared dependencies include coordinated SDK-managed WebView logout. * Use the [current package baseline and logout guide](/docs/sdk/authentication). ## \[2.2.18] - 6 October 2026 [#2218---6-october-2026] * Challenge uses the same backend-token member sign-in as Lessons and WebView. * Pass environment, clientId, clientUserId and customTokenProvider directly to GofaPoseSdkManager.init. The SDK manages the shared Firebase session. * Challenge can initialize first or alone; mixed identity/environment is rejected and coordinated logout applies across modules. * See [SDK authentication](/docs/sdk/authentication) and the [coordinated package baseline](/docs/sdk/release-notes). ## \[2.2.11] [#2211] * bump firebase dependencies to support Firebase SDK 11.0.0 ## \[2.2.10] [#2210] * fixed time attack mode issue that the timer doesn't count down normally towards the end ## \[2.2.9] [#229] * downgraded firebase dependencies to support Firebase SDK v10 instead of v11 * firebase\_auth: ^4.0.0 * firebase\_core: 2.31.0 * for issue `type 'List' is not a subtype of type 'PigeonUserDetails?' in type cast` * ref: [https://github.com/firebase/flutterfire/issues/12944](https://github.com/firebase/flutterfire/issues/12944) * firebase\_storage: ^11.0.0 * firebase\_app\_check: ^0.2.0 * firebase\_crashlytics: ^3.0.0 * cloud\_firestore: ^4.0.0 * cloud\_functions: ^4.0.0 ## \[2.2.8] [#228] * fixed \[gofa\_mediapipe\_pose], upgraded to 1.1.16 * fixed webview javascript error due to [@mediapipe/tasks-vision](https://cdn.skypack.dev/@mediapipe/tasks-vision) auto upgraded to 0.10.19 * the fix is downgrade and lock to 0.10.18 ## \[2.2.7] [#227] * added initialOrientations parameter to WorkoutSetScreen constructor * default is \[DeviceOrientation.portraitUp] if not provided * upgraded gofa\_core to 1.1.0 * switched audio library from "assets\_audio\_player" to "just\_audio" due to build error, "assets\_audio\_player" is not actively maintained ## \[2.2.6] [#226] * updated timeout values from 350ms to 600ms to tolerate bad high latency network * fixed the overlapping issue between bad network prompt and the "stay in frame" wording ## \[2.2.5] [#225] * added back button to workout set screen * updated all copywriting texts according to Bupa copywriting guideline ## \[2.2.4] [#224] * added back pubspec.lock to .gitignore, because it should not be included in the package, let main flutter app decide the version of the dependencies ## \[2.2.3] [#223] * up-ed carousel\_slider from 4.2.1 to ^5.0.0 in pubspec.yaml ## \[2.2.2] [#222] * increased firebase call timeout from 100/150 to 350ms to avoid timeout error in high latency network ## \[2.2.1] [#221] * added loggings to find out why the workout doesn't progress ("Down" is not shown at all) ## \[2.2.0] [#220] * For WorkoutSetSetup, use `key` instead of `poseName` to specify the pose * Added localization to "ready" and "go" in WorkoutSetScreen * Fixed timeout monitor that \_stableNetwork is true initially instead of false ## \[2.1.6] [#216] * updated gofa\_mediapipe\_pose to add the following to resolve blank screen issue in Android device. dependency\_overrides: webview\_flutter\_android: 3.16.1 ## \[2.1.5] [#215] * updated dependencies to latest version: connectivitiy\_plus 6.0.3 wakelock\_plus 1.2.5 ## \[2.1.4] [#214] * Fixed to use flutter channel stable instead of master ## \[2.1.3] [#213] * Fixed progress indicator theme color ## \[2.1.0], \[2.1.1], \[2.1.2] [#210-211-212] * majoring refactoring on gofa\_pose workout set screen logic on isInFrame and badNetwork streams, thus improved the performance * fixed to GS-279 \[Challenge] 4 strange horizontal lines which disappear in a few seconds * added back GS-280 \[Challenge] missing "stay in frame" keyword * gofa\_mediapipe\_pose updated to 1.1.8 ## \[2.0.5] [#205] * fixing black screen issue when quit workout set screen and "Next" in workout set result screen ## \[2.0.4] [#204] * fixed poseDetectorView's \_videoPoseDetector to use fromOfficialModelUrl ## \[2.0.3] [#203] * fixed timer icon in select pose bottom sheet ## \[2.0.2] [#202] * GofaChallenge home navigate to SelectPoseScreen by constructing MaterialPageRoute instead of using Navigator.pushNamed ## \[2.0.0], \[2.0.1] [#200-201] * major change. removed all dependencies to google\_mlkit\_pose\_detection and replaced with gofa\_mediapipe\_pose ## \[1.0.18] [#1018] !! major change on gofa\_pose * Added TimeoutMonitor for checking firebase function's connection * Changed WillPopScope to PopScope in WorkoutSetScreen * Used poseKey instead of workoutName * Fetch GOFA Challenge pose list from firebase instead of local list * Lowered resolution to medium * Use base over accurate model * Fixed endTimeoutMonitor not being paused when workout resumes * Added checking whether audio's playing before stopping ## \[1.0.17] [#1017] * Modified GofaCheckboxTile's visualDensity to more compact ## \[1.0.16] [#1016] * Revamped Settings page * Added "Delete Account" button to Settings page * Behavior modifiable by GofaSDKManager.navigateToDeleteAccount ## \[1.0.15] [#1015] * Removed face estimation markings from WorkoutSetScreen ## \[1.0.14] [#1014] * Removed "Back" button from WorkoutSetScreen pop menu * Moved GameMode to gofa\_pose * Breaking Change: you should use GameMode enum exposed by gofa\_pose instead of creating one yourself * Support in-game voice support for GameMode.timeAttack * 30/20/10 seconds left * 5 to 1 count down * Time's up sound ## \[1.0.13] [#1013] * Made WorkoutSetScreen to navigate to WorkoutSetResultScreen automatically when workout is done * Removed Stop, Play and Pause buttons from WorkoutSetScreen * Pop menu added "Quit" button to quit workout * Fixed result screen pixel overflow if reps >99. * Fixed error in firebase warmup call * Added "Time's up" text when workout ends ## \[1.0.12] (Important fix!) [#1012-important-fix] * Fixed GS-151 \[workout] at the beginning, skeletons queue up and "flood out" * Fixed GS-171 early warm up server before workout ready ## \[1.0.11] [#1011] * Removed theme selection in SettingsView. Now the only supported theme is light theme. ## \[1.0.10] [#1010] * Added google fonts to pubspec.yaml * OpenSans and ChakraPetch * Upgraded google\_mlkit\_commons to v0.4.0 ## \[1.0.9] [#109] * Added try-catch in \_createWorkoutSet to catch error when creating workout set in Firestore ## \[1.0.8] [#108] * Added sign out button to SettingsView * Added dependency on google\_sign\_in to allow google sign out ## \[1.0.7] [#107] * Added version to SettingsController * Added themeSupport to GofaSDKManager * default is both. so user can choose between light and dark theme in Settings menu ## \[1.0.6] [#106] * Added gofaFirebaseProjectName to GofaSDKManager.init to allow the SDK to connect to a Firebase project with a different name than "gofa\_sdk" * Added useFirebaseAppCheck, useFirebaseCrashlytics, useFirebaseFunctionsEmulator to GofaSDKManager * default is null. * if null, the GofaSDKManager will use isDemoApp to determine if it should use Firebase App Check, Firebase Crashlytics and Firebase Functions Emulator ## \[1.0.5] [#105] * Lock all package dependencies' version to avoid breaking changes * Updated GOFA API pose list * added key to each pose. e.g. "jumping-jacks" for "Jumping Jacks" * added localizedNames to each pose. * the localizedNames is a map of locale to localized name. e.g. "en" to "Jumping Jacks", "zh\_Hant" to "開合跳" * there are three locales supported: en, zh\_Hant, zh\_Hans * Added isShowPopupMenu to WorkoutSetSetupEvent. default is false. ## \[1.0.4] [#104] * Adding locale support: en, zh, zh\_Hant, zh\_Hans * Fixed popup menu > Settings. migrated SettingsView to gofa\_pose ## \[1.0.3] [#103] * Revamped WorkoutSetResultScreen * Lock version of package `google_mlkit_commons` to 0.3.0 ## \[1.0.2] [#102] * Added Workout Set route handling to WorkoutSetResultScreen with WorkoutSetBloc * Added navigateToPreviousDuringWorkout, navigateToNextAfterWorkout and navigateToHomeAfterWorkout Functions to override the default navigation * You can now pass these override functions with WorkoutSetSetupEvent ## \[1.0.1] [#101] * Removed debug-use-only menu items ## \[1.0.0] [#100] * As our Gofa SDK becomes integratable to any Flutter app, we update the version to 1.0.0 *** ## \[0.8.25] [#0825] * Added more sounds to Workout Set Play, e.g. 30/20/10 left. 5,4,3,2,1. Let's go. Keep it up. Great job. ## \[0.8.24] [#0824] * Added isConnected bool Stream to GofaSDKManager to indicate if the SDK is connected to the Firebase backend ## \[0.8.23] [#0823] * Modified fireCallable to accept firebaseApp input parameter ## \[0.8.22] [#0822] * Added `createdAt` to WorkoutSet in Firestore ## \[0.8.21] [#0821] * Removed dependency on vector\_math ## \[0.8.20] [#0820] * Changed environment: sdk: from `>=2.19.0 <3.0.0` to `>=2.19.0 <4.0.0` ## \[0.8.15], \[0.8.16], \[0.8.17], \[0.8.18], \[0.8.19] [#0815-0816-0817-0818-0819] * GofaSDKManager now registerSingleton: WorkoutSetFirestore * Fixed WorkoutSetFirestore to use gofaFirebaseApp from GofaSDKManager * Fixed that gofaFirebaseApp must be named "gofa\_sdk" * Fixed logo-S.png asset issue ## \[0.8.14] [#0814] * Improved GofaSDKManager initialization logic ## \[0.8.13] [#0813] * Extracted gofa-sdk firebase connection from Demo App (gofa\_sdk) to this gofa\_pose package # GOFA Lessons (/docs/sdk/lessons) GOFA Lessons SDK enables seamless integration of interactive video fitness lessons into your Flutter application, with robust tracking of user progress and engagement using Firebase Firestore. Before setting up member sign-in, read [SDK authentication](/docs/sdk/authentication). Contact GOFA for an existing integration's setup and migration guidance. ## Purpose [#purpose] * Track user interactions with lessons and plans. * Store and synchronize lesson data and user progress. * Support personalized fitness journeys with lesson plans and analytics. ## Key Concepts [#key-concepts] ### Play [#play] A "play" refers to a single instance of a user interacting with a specific lesson. It includes starting, pausing, resuming, and ending the lesson. Each play is uniquely identified by a `playId` and can have multiple sessions corresponding to different parts of the lesson. ### Plan Play [#plan-play] A "plan play" is an interaction involving a lesson plan, which consists of a combination of lessons. It encompasses starting, pausing, resuming, resetting, and completing the entire plan. Each plan play is denoted by a unique `planPlayId`. ### Firestore Structure [#firestore-structure] Data is organized into collections for Plans, Lessons, Users, UserLessons, Plays, PlanPlays, UserPlans, and DailyRecords. ## Example Use Cases [#example-use-cases] Let's explore some common use cases and how they map to the technical structure: ### 1. Finish a Lesson [#1-finish-a-lesson] When a user finishes a lesson: 1. The app updates the status of the play to "ended" in the `Users > Plays` collection.
2. Upon status update, a Firestore trigger calculates and updates `totalRepCount` and `totalSecondsSpent`.
3. The `lastPlayed` and `totalPlays` fields are updated both in the `Users > UserLessons` subcollection and the `Lessons` collection.
4. If the lesson is completed, the status in `Users > Plays` is marked as `ended`. ```dart void finishLesson(String gofaUserId, String playId) async { await FirebaseFirestore.instance.collection('Users').doc(gofaUserId).collection('Plays').doc(playId).update({ 'status': 'ended', }); } ``` ### 2. Cancel a Lesson [#2-cancel-a-lesson] When a user cancels a lesson play: 1. The app updates the status to "cancelled" in the `Users > Plays` collection.
2. The status change will be reflected in the frontend, indicating the user has cancelled the lesson. ```dart void cancelLesson(String gofaUserId, String playId) async { await FirebaseFirestore.instance.collection('Users').doc(gofaUserId).collection('Plays').doc(playId).update({ 'status': 'cancelled', }); } ``` ### 3. Finish a Plan [#3-finish-a-plan] When a user completes a lesson plan: 1. The status of the `planPlay` in `Users > PlanPlays` is updated to "ended".
2. Details like `totalRepCount` and `totalSecondsSpent` are computed and recorded in the Firestore document.
3. The `lastPlayed` and `totalPlays` fields of the lesson plan in the `Users > UserPlans` subcollection, as well as the `Plans` collection, are updated accordingly. ```dart void finishPlan(String gofaUserId, String planPlayId) async { await FirebaseFirestore.instance.collection('Users').doc(gofaUserId).collection('PlanPlays').doc(planPlayId).update({ 'status': 'ended', }); } ``` ## Firestore Structure Overview [#firestore-structure-overview] ### Collections [#collections] * **Plans**
Stores information about lesson plans including names, descriptions, and the lessons they include. * **Lessons**
Keeps track of individual video lessons, views, plays, and completions. * **Users**
`gofaUserId` (Documents) * **UserLessons**
Records user-specific data related to lessons. * **Plays**
Manages individual lesson play data. * **PlanPlays**
Manages interactions with lesson plans. * **UserPlans**
Contains aggregated data on plans accessed by the user. * **DailyRecords**
Daily activity metrics for users. ## Installation & Setup [#installation--setup] 1. **Add the SDK** Use OnePub to add `gofa_lessons` to your project. ```bash onepub pub add gofa_lessons ``` 2. **Add Required Packages** ```bash flutter pub add get_it ``` 3. **Initialize the SDK** Contact GOFA for the supported sign-in setup and environment, then register and initialize `LessonSdkManager`. 4. **Localization** Add `GofaLessonLocalizationsDelegate()` to your `MaterialApp`'s `localizationsDelegates`. ## Versioning [#versioning] See the [SDK Versions](/docs/sdk/lessons/sdk-versions) page for the latest changes and upgrade instructions. # Changelog (/docs/sdk/lessons/sdk-versions) > ⚠️ **Note:** This changelog may be outdated. For the most up-to-date information, please refer to the [OnePub CHANGELOG.md](https://onepub.dev/packages/gofa_lessons/fvnuhnofly) file. ## \[1.2.4] (2025-04-22, non-breaking) [#124-2025-04-22-non-breaking] * bump firebase dependencies to support Firebase SDK 11.0.0 ## \[1.2.3] (2024-12-12, non-breaking) [#123-2024-12-12-non-breaking] * downgraded firebase dependencies to support Firebase SDK v10 instead of v11 * firebase\_core: 2.31.0 * for issue `type 'List' is not a subtype of type 'PigeonUserDetails?' in type cast` * ref: [https://github.com/firebase/flutterfire/issues/12944](https://github.com/firebase/flutterfire/issues/12944) ## \[1.2.2] (2024-12-12, non-breaking) [#122-2024-12-12-non-breaking] * fixed \[gofa\_mediapipe\_pose], upgraded to 1.1.16 * fixed webview javascript error due to [https://cdn.skypack.dev/@mediapipe/tasks-vision](https://cdn.skypack.dev/@mediapipe/tasks-vision) auto upgraded to 0.10.19 * the fix is downgrade and lock to 0.10.18 ## \[1.2.1] (2024-12-04, non-breaking) [#121-2024-12-04-non-breaking] * updated `checkUseKeyframeBasedRepCounter` logic to check repPoseId in lesson sessions. * updated `_updateRepCounter` to fetch by repPoseId * updated with PresetPoseLandmarkWeights `WHOLE_BODY` changed to `FULL_BODY` * upgraded gofa\_core to 1.1.0 * switched audio library from "assets\_audio\_player" to "just\_audio" due to build error, "assets\_audio\_player" is not actively maintained ## \[1.2.0] (2024-11-03, non-breaking) [#120-2024-11-03-non-breaking] * upgraded share\_plus to 10.1.2 ## \[1.1.27] (2024-11-02, non-breaking) [#1127-2024-11-02-non-breaking] * fixed meditation screen to enable wakelock ## \[1.1.26] (2024-10-29, non-breaking) [#1126-2024-10-29-non-breaking] * updated firebase fetch functions for lessons/plans with membershipTier as request data ## \[1.1.25] (2024-09-26, non-breaking) [#1125-2024-09-26-non-breaking] * updated Lesson List page title as "AI Health Plan" and "AI 運動計劃" ## \[1.1.24] (2024-09-23, non-breaking) [#1124-2024-09-23-non-breaking] * updated: check for using rep counting 2.0 by lesson video id available in `LessonTimedPoseLandmarkMaps` or `RepPoses` collection ## \[1.1.23] (2024-09-10, non-breaking) [#1123-2024-09-10-non-breaking] * fixed: lesson details start with dumbbell, the bottom sheet is transparent. fixed by adding background color and barrier color to the bottom sheet * fixed: plan details > click lesson, the bottom sheet layout is a bit broken. fixed by adding background color and barrier color to the bottom sheet * updated: RepCounter and TimedPoseLandmarkMapLoader to prioritize loading from new `VideoPoseLandmarkTimeSeries` collection. * ad-hoc: added Rep Testing to `supportedKeyframeBasedRepLessonNames` to temporary support short lessons for rep counting 2.0 * updated all copywriting texts according to Bupa copywriting guideline * delayed planFinished trigger for 2 seconds to allow the last lesson's lessonCancelled/lessonFinished to be processed first ## \[1.1.22] (2024-09-08, non-breaking) [#1122-2024-09-08-non-breaking] * fixed: plan details > view statistics -> null exception. reproduced by reset plan before re-starting it; * fixed: wrongly triggered planEnded issue. fixed by adding back the missing trigger condition for planFinished * refactor planUtil.updateFirestorePlanPlay to guard against planPlay.completedLessons.length >= planPlay.plannedLessons.length; save the unnecessary firestore roundtrips ## \[1.1.20], 1.1.21 (2024-09-03, non-breaking) [#1120-1121-2024-09-03-non-breaking] * added back button in lesson calibration screen so that user can go back to previous screen, especially for iPhone without physical back button * exposed class `Localizable` from gofa\_types via gofa\_lessons * fixed LessonSdkManager.init to support re-login * lesson result screen support text wrap if the session title is too long * lesson result screen will display lesson data from `play.lesson` directly if any, instead of fetching from firestore again, so that snapshot data of the lesson is displayed * improved recent activity * single lesson display results from `play.lesson` directly if any, instead of fetching from firestore again, so that snapshot data of the lesson is displayed * plan play display results from `planPlay.plan` and `planPlay.lessons` directly if any, instead of fetching from firestore again, so that snapshot data of the plan and lessons are displayed * added new properties to `Plan` class: duration and expiryReminder * Updated skip cool down to also skip empty session in between * depend on gofa\_types ^1.0.3 ## \[1.1.19] [#1119] * changed `import 'package:gofa_lessons/src/helpers/client_util.dart';` to `import '../helpers/client_util.dart';` * added back pubspec.lock to .gitignore, because it should not be included in the package, let main flutter app decide the version of the dependencies ## \[1.1.18] [#1118] * fixing the dependency issue from gofa\_lessons to gofa\_firebase ## \[1.1.17] [#1117] * fixing the dependency issue from gofa\_lessons to gofa\_types ## \[1.1.16] [#1116] * made passRatio configurable by firestore Client.settings ## \[1.1.15] [#1115] * expose Environment and environmentFromString from gofa\_types to gofa\_lessons ## \[1.1.14] [#1114] * added GofaLessonLocalization for share texts across Lesson Result/Meditation Result/Plan Summary * added callback object data for lesson play, plan, planPlay * test: triggerCallbacks with compute to avoid UI blocking * major refactoring: use lessonId instead of lessonUniqueID ## \[1.1.13] [#1113] * updated gofa\_mediapipe\_pose to add the following to resolve blank screen issue in Android device. dependency\_overrides: webview\_flutter\_android: 3.16.1 ## \[1.1.12] [#1112] * updated share text for lesson result screen and plan summary screen share feature ## \[1.1.11] [#1111] * updated dependencies to latest version: wakelock\_plus 1.2.5 device\_info\_plus 10.1.0 share\_plus 9.0.0 ## \[1.1.8], 1.1.9, 1.1.10 [#118-119-1110] * fixed gofa\_mediapipe\_pose version issue ## \[1.1.7] [#117] * updated Exit GOFA prompt button text: Exit and Stay * fixed onLessonCancelled bug on LessonSdkManager.updateCallback, didn't pass the `passed` parameter correctly * fetch latest play data from firestore again before updateCallback * minor improved introduction pages navigation by adding 'How it works...' text link * changed settings header name to "Information" ## \[1.1.6] [#116] * added settings page to gofa\_lessons * updated gofa\_utils dependency ## \[1.1.5] [#115] * fixed breaking exception caused by outdated dependency on gofa\_mediapipe\_pose 1.1.2, should be 1.1.4 ## \[1.1.4] [#114] * fixed LessonSdkManager.init will handle GofaLessonLocalizations.loadLessonAttributes() ## \[1.1.3] [#113] * fixed LessonSplitScreen \_updateRepCounter() to use firestore() instead of FirebaseFirestore.instance ## \[1.1.2] [#112] * fixed lesson split screen issue: no need load local pose model lite ## \[1.1.1] [#111] * fixed LessonSdkManager init issue ## \[1.1.0] [#110] * preparing for official release ## \[1.0.0] [#100] * major change. removed all dependencies to google\_mlkit\_pose\_detection and replaced with gofa\_mediapipe\_pose ## \[0.2.0] [#020] * major refactoring to initalize LessonSdkManager * fixed that if initialization fails, it pop back to previous screen and shows the error toast message ## \[0.1.6] [#016] * fixed White Screen issue by marking AudioManager provider as lazy ## \[0.1.5] [#015] * fixed AudioManager by using GetIt instead of Provider to locate the singleton instance ## \[0.1.4] [#014] * fixed Image.Asset package issue * fixing AudioManager * ProviderNotFoundException (Error: Could not find the correct `Provider` above this LessonSplitScreen Widget) ## \[0.1.3] [#013] * upgraded gofa\_firebase to 0.0.2 * upgraded gofa\_core to 0.0.2 * upgraded gofa\_algo to 0.0.2 ## \[0.1.2] [#012] * migrated images to gofa\_lessons assets folder ## \[0.1.1] [#011] * add logic to check and update status when all lessons are completed ## \[0.1.0] [#010] * added firestore support to lesson plans * improved engagement lesson play ## \[0.0.6] [#006] * added engagement level in lesson play * with engagement level bar and explosion effect * Updated Plan type. * updated lessons as string array * added tags as string array * added targetAreas as string array ## \[0.0.5] [#005] * Added share feature to lesson summary screen ## \[0.0.4] [#004] * Major refactoring to rename firestore collection name and field names * Updated LessonSdkManager().init parameters: updateCallback so that it takes enum UpdateCallbackEvent instead of String * Added `cancelled` status to lesson play * Added bottom footer with version * Adding share feature ## \[0.0.3] [#003] * Added lesson plan screen flows * Added disclaimer action sheet * Added dumbbell action sheet * Added lesson summary screen * Added lesson plan detail action sheet * Refactored lesson detail screen ## \[0.0.2] [#002] * Added tutorial and tooltip for Gofa Lessons ## \[0.0.1] [#001] * Added lesson screens and logic, work in progress. # Getting Started (/docs/sdk/webview/getting-started) The GOFA WebView SDK opens GOFA web experiences inside a Flutter app. Read [SDK authentication](/docs/sdk/authentication) for the planned server-mediated member sign-in flow. **Contact GOFA for existing integration setup and migration guidance.** ## Install the package [#install-the-package] ```bash onepub pub add gofa_webview flutter pub add get_it ``` You need access to the GOFA organization on OnePub. If package resolution fails, contact GOFA to confirm your invitation and supported package version. ## Open a GOFA WebView [#open-a-gofa-webview] After GOFA confirms your sign-in setup, register `GofaWebView.routeName` in your app's `MaterialApp.routes`. The widget takes the GOFA URL assigned for your environment, your client identifier, and a locale: ```dart GofaWebView( url: gofaUrlProvidedForYourEnvironment, clientId: clientId, locale: const Locale('en'), ) ``` GOFA will confirm your URL, environment, supported SDK version, and member sign-in handoff before you launch. ## More information [#more-information] * [SDK authentication](/docs/sdk/authentication) * [Advanced configuration](/docs/sdk/webview/configuration) * [WebView SDK Manager](/docs/sdk/webview/references/webview-sdk-manager) # MSK Authentication (/docs/sdk/webview/msk-authentication) MSK WebView experiences require an authenticated GOFA member session. GOFA is preparing a [server-mediated SDK sign-in flow](/docs/sdk/authentication) in which your backend obtains a member token and the SDK uses it to open the experience. **That replacement is not yet available for production use.** For an existing app, contact GOFA for the supported SDK version, environment, member identity mapping, and migration instructions. Coordinate changes to live member sign-in with GOFA before rollout. # MSK Assessment Integration Guide (/docs/sdk/webview/msk-integration) This guide covers integrating the GOFA MSK (Musculoskeletal) Assessment module within your Flutter app using the GOFA WebView SDK. ## Overview [#overview] The MSK Assessment module provides comprehensive musculoskeletal health evaluations including: * **Assessment Survey**: Initial questionnaire about symptoms and history * **Physical Assessment**: Guided physical tests and measurements * **Pain Review**: Detailed pain location and intensity mapping * **Report Generation**: Comprehensive assessment results and recommendations ## Event-Driven Integration [#event-driven-integration] The MSK assessment communicates with your Flutter app through events posted via the WebView SDK's `updateCallback` function. These events allow you to: * Track assessment progress * Handle user interactions * Access generated reports * Respond to early exits or completion ### Key changes (Aug 2025) [#key-changes-aug-2025] * No `state` field is sent. Completion is inferred by the presence of specific data (e.g., when `risk.*` exists, the assessment is considered ended). * Every event includes BOTH of the following when available: * `mskAssessResult`: the original nested JSON shape as stored by the app (primary/main result). * `mskAssessResultFlattened`: a flattened snapshot of the assessment result (dot-notation keys, `assessmentData.` prefix removed, `createdAt` converted to `createdAt._seconds/_nanoseconds` when applicable). * The older, separate nested payloads (`surveyResult` / `physicalResult` / `painReviewResult`) are deprecated and are not sent. Your Flutter app receives events via the WebView SDK as an object with `{ type, data }`. The `type` matches the event names below and `data` contains the payload. ## Event Flow [#event-flow] ```mermaid flowchart TD A[User enters MSK module] --> B[MSK Home Screen] B --> C{User choice} C -->|Starts assessment| D[START_MSK_ASSESSMENT] C -->|Exits module| N[QUIT_MODULE / QUIT_MSK_MODULE] D --> E[User doing survey] --> F[FINISH_ASSESSMENT_SURVEY] F --> G[User doing physical tests] --> H[FINISH_PHYSICAL_ASSESSMENT] H --> I[User doing pain review] --> J[FINISH_PAIN_REVIEW] J --> K[System generates report] --> L[GENERATED_REPORT] L --> B %% Alternative flows - early quit during assessment E --> M{User exits early?} G --> M I --> M M -->|Yes| P[EARLY_QUIT_ASSESSMENT] P --> B %% Styling for event emissions classDef eventEmission fill:#e1f5fe,stroke:#0277bd,stroke-width:2px,color:#01579b classDef userAction fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px,color:#4a148c classDef systemAction fill:#e8f5e8,stroke:#388e3c,stroke-width:2px,color:#1b5e20 classDef homeScreen fill:#fff3e0,stroke:#f57c00,stroke-width:2px,color:#e65100 class D,F,H,J,L,P,N eventEmission class A,E,G,I,K userAction class C,M systemAction class B homeScreen ``` ## Implementation [#implementation] ### Basic Event Handler [#basic-event-handler] ```dart void _handleMSKEvents(String event, dynamic data) { switch (event) { case 'START_MSK_ASSESSMENT': _onAssessmentStarted(data); break; case 'FINISH_ASSESSMENT_SURVEY': _onSurveyCompleted(data); break; case 'FINISH_PHYSICAL_ASSESSMENT': _onPhysicalCompleted(data); break; case 'FINISH_PAIN_REVIEW': _onPainReviewCompleted(data); break; case 'GENERATED_REPORT': // data.reportUrl is a base64 data URL (data:application/pdf;base64,...) _onReportGenerated(data); break; case 'EARLY_QUIT_ASSESSMENT': _onEarlyQuit(data); break; case 'QUIT_MSK_MODULE': case 'QUIT_MODULE': _onModuleQuit(); break; } } ``` From each handler, you can read either `data['mskAssessResult']` (nested/original) or `data['mskAssessResultFlattened']` (flattened). Choose the one that best fits your storage/analytics needs. ### Working with nested vs flattened results [#working-with-nested-vs-flattened-results] * `mskAssessResult` (nested/original - primary) * Matches the domain model stored by the app; includes `assessmentData` subtree and optional `risk`. * `mskAssessResultFlattened` (flattened) * Dot-notation keys for nested fields. * `assessmentData.` prefix removed (e.g., `assessmentData.assessmentSurvey.age` -> `assessmentSurvey.age`). * `createdAt` appears as `createdAt._seconds` and `createdAt._nanoseconds` when applicable. Example keys (flattened): ```json { "assessmentSurvey.age": 45, "assessmentResults.rangeOfMotion": 85.5, "painReview.abductionMaxPain.right": "severe", "risk.SIS.riskLevel": "MODERATE" } ``` When `risk.*` exists in either the nested or flattened payload, the assessment is considered ended. The older per-phase payloads `surveyResult`, `physicalResult`, and `painReviewResult` are no longer sent. Use `mskAssessResult` or `mskAssessResultFlattened` instead. ## Assessment Data Reference [#assessment-data-reference] The MSK module supports three assessment codes, each with its own survey questions, physical tests, pain review, and risk calculations: | Assessment Code | Description | Risk Types | | --------------- | ------------------- | ------------------------------------------------------------------------------------------------ | | `shoulder` | Shoulder assessment | SIS (Shoulder Impingement Syndrome), FS (Frozen Shoulder) | | `low-back` | Low back assessment | LB (Low Back Muscle Strain), LDC (Lumbar Degenerative Compression), LDH (Lumbar Disc Herniation) | | `knee` | Knee assessment | OA (Knee Osteoarthritis), PFPS (Patellofemoral Pain Syndrome) | ### Common Base Fields [#common-base-fields] All assessment results share these base fields: | Key | Type | Description | | ---------------- | -------------------------------------------------------------- | --------------------------------------------------------------- | | `id` | string | Unique assessment result ID | | `clientId` | string | Client/tenant identifier | | `userId` | string (optional) | User identifier | | `assessmentCode` | `"shoulder"` \| `"low-back"` \| `"knee"` | Assessment type | | `createdAt` | ISO string (nested) or `._seconds`/`._nanoseconds` (flattened) | Creation timestamp | | `assessmentData` | object | Contains `assessmentSurvey`, `assessmentResults`, `painReview` | | `risk` | object | Risk scores per condition (present when assessment is complete) | *** ## Shoulder Assessment (`shoulder`) [#shoulder-assessment-shoulder] ### Nested (mskAssessResult) Example — Shoulder [#nested-mskassessresult-example--shoulder] ```json { "id": "523bOJ5ky183Qr5qZ61B", "clientId": "your-client-id", "userId": "mzkQP97YwpdjpmWS7a9qZTB4w9t1", "assessmentCode": "shoulder", "createdAt": "2025-08-07T12:32:58.031Z", "assessmentData": { "assessmentSurvey": { "version": "SHOULDER_ASSESSMENT_SURVEY_CONTENT_V1", "shoulderPainRecurring": false, "shoulderPainRelievedByRest": false, "shoulderPainOverheadActivity": true, "gender": "male", "shoulderPainInModerateMotion": false, "shoulderPainFixedPosition": false, "shoulderPainWithHandBehindBack": false, "shoulderPainRepetitiveMotion": false, "shoulderPainWithOverheadActivity": true, "shoulderPainAtLimit": true, "shoulderPainNoInjury": true, "diabetes": "none", "age": 45, "shoulderRangeOfMotionLimited": false }, "assessmentResults": { "version": "SHOULDER_ASSESSMENT_RESULTS_V1", "rangeOfMotion": 85.5, "strengthScore": 78.2, "abduction": { "left": 180, "right": 180 }, "flexion": { "left": 160, "right": 160 }, "extension": { "left": 60, "right": 60 }, "externalRotation": { "left": 90, "right": 90 }, "internalRotation": { "left": 70, "right": 70 }, "compression": { "left": 45, "right": 45 } }, "painReview": { "version": "SHOULDER_PAIN_REVIEW_CONTENT_V1", "flexionMidPain": { "left": "moderate", "right": "moderate" }, "flexionMaxPain": { "left": "moderate", "right": "moderate" }, "abductionMidPain": { "left": "moderate", "right": "moderate" }, "abductionMaxPain": { "left": "mild", "right": "severe" }, "rotationMidPain": { "left": "moderate", "right": "moderate" }, "rotationMaxPain": { "left": "moderate", "right": "severe" }, "pressElbowPain": { "left": "moderate", "right": "moderate" } } }, "risk": { "SIS": { "percent": 28.999999999999996, "riskLevel": "MODERATE" }, "FS": { "percent": 24, "riskLevel": "LOW" } } } ``` ### Flattened (mskAssessResultFlattened) Example — Shoulder [#flattened-mskassessresultflattened-example--shoulder] ```json { "id": "523bOJ5ky183Qr5qZ61B", "clientId": "your-client-id", "userId": "mzkQP97YwpdjpmWS7a9qZTB4w9t1", "assessmentCode": "shoulder", "createdAt._seconds": 1751949178, "createdAt._nanoseconds": 31000000, "assessmentSurvey.version": "SHOULDER_ASSESSMENT_SURVEY_CONTENT_V1", "assessmentSurvey.shoulderPainRecurring": false, "assessmentSurvey.gender": "male", "assessmentSurvey.age": 45, "assessmentResults.abduction.left": 180, "assessmentResults.abduction.right": 180, "painReview.abductionMaxPain.left": "mild", "painReview.abductionMaxPain.right": "severe", "risk.SIS.percent": 29, "risk.SIS.riskLevel": "MODERATE", "risk.FS.percent": 24, "risk.FS.riskLevel": "LOW" } ``` The flattened format uses dot-notation keys and removes the `assessmentData.` prefix. The example above is abbreviated — actual payloads include all fields. ### Field Reference — Shoulder [#field-reference--shoulder] **Assessment Survey fields (`assessmentSurvey.`):** | Key | Type | Description | | ---------------------------------- | ----------------------------------------- | ----------------------------- | | `version` | `"SHOULDER_ASSESSMENT_SURVEY_CONTENT_V1"` | Schema version | | `age` | number | User age (18–100) | | `gender` | `"male"` \| `"female"` \| `"undefined"` | User gender | | `diabetes` | `"none"` \| `"type1"` \| `"type2"` | Diabetes status | | `shoulderPainRecurring` | boolean | Recurring shoulder pain | | `shoulderPainRelievedByRest` | boolean | Pain relieved by rest | | `shoulderPainOverheadActivity` | boolean | Pain during overhead activity | | `shoulderPainInModerateMotion` | boolean | Pain in moderate motion | | `shoulderPainFixedPosition` | boolean | Pain in fixed position | | `shoulderPainWithHandBehindBack` | boolean | Pain with hand behind back | | `shoulderPainRepetitiveMotion` | boolean | Pain with repetitive motion | | `shoulderPainWithOverheadActivity` | boolean | Pain with overhead activity | | `shoulderPainAtLimit` | boolean | Pain at limit of motion | | `shoulderPainNoInjury` | boolean | Pain without injury | | `shoulderRangeOfMotionLimited` | boolean | Limited range of motion | **Assessment Results fields (`assessmentResults.`):** | Key | Type | Description | | ------------------------------------------------- | ---------------------------------- | ------------------------- | | `version` | `"SHOULDER_ASSESSMENT_RESULTS_V1"` | Schema version | | `abduction.left`, `abduction.right` | number | Abduction angle (degrees) | | `flexion.left`, `flexion.right` | number | Flexion angle (degrees) | | `extension.left`, `extension.right` | number | Extension angle (degrees) | | `externalRotation.left`, `externalRotation.right` | number | External rotation angle | | `internalRotation.left`, `internalRotation.right` | number | Internal rotation angle | | `compression.left`, `compression.right` | number | Compression test result | **Pain Review fields (`painReview.`):** | Key | Type | Description | | ------------------------------------------------- | -------------------------------------------------- | ------------------ | | `version` | `"SHOULDER_PAIN_REVIEW_CONTENT_V1"` | Schema version | | `flexionMidPain.left`, `flexionMidPain.right` | `"none"` \| `"mild"` \| `"moderate"` \| `"severe"` | Mid-flexion pain | | `flexionMaxPain.left`, `flexionMaxPain.right` | `"none"` \| `"mild"` \| `"moderate"` \| `"severe"` | Max flexion pain | | `abductionMidPain.left`, `abductionMidPain.right` | `"none"` \| `"mild"` \| `"moderate"` \| `"severe"` | Mid-abduction pain | | `abductionMaxPain.left`, `abductionMaxPain.right` | `"none"` \| `"mild"` \| `"moderate"` \| `"severe"` | Max abduction pain | | `rotationMidPain.left`, `rotationMidPain.right` | `"none"` \| `"mild"` \| `"moderate"` \| `"severe"` | Mid-rotation pain | | `rotationMaxPain.left`, `rotationMaxPain.right` | `"none"` \| `"mild"` \| `"moderate"` \| `"severe"` | Max rotation pain | | `pressElbowPain.left`, `pressElbowPain.right` | `"none"` \| `"mild"` \| `"moderate"` \| `"severe"` | Elbow press pain | **Risk Assessment fields (`risk.`):** | Key | Description | | ------------------------------ | ---------------------------------- | | `SIS.percent`, `SIS.riskLevel` | Shoulder Impingement Syndrome risk | | `FS.percent`, `FS.riskLevel` | Frozen Shoulder risk | *** ## Low-Back Assessment (`low-back`) [#low-back-assessment-low-back] ### Nested (mskAssessResult) Example — Low-Back [#nested-mskassessresult-example--low-back] ```json { "id": "abc123LowBackId", "clientId": "your-client-id", "userId": "userXYZ123", "assessmentCode": "low-back", "createdAt": "2025-08-10T09:15:00.000Z", "assessmentData": { "assessmentSurvey": { "version": "LOW_BACK_ASSESSMENT_SURVEY_CONTENT_V1", "gender": "male", "isPainFromSportsOrHandling": true, "isPainWorseSittingOrStanding": true, "hasSwellingOrWarmth": false, "hasParaspinalMusclePain": true, "hasRopeLikeLump": false, "hasMorningStiffness": true, "isPainWithProlongedSitting": true, "isPainWithBackExtension": false, "isPainWithProlongedWalking": true, "isPainWithForwardBending": true, "isPainWithKneesToChest": false, "isPainWithCoughingSneezing": false, "isPainRelievedByProneLying": true }, "assessmentResults": { "version": "LOW_BACK_ASSESSMENT_RESULTS_V1", "seatedForwardFlexion": { "normalizedFingerToToeDistance": -0.15 } }, "painReview": { "version": "LOW_BACK_PAIN_REVIEW_CONTENT_V1", "painLevel": { "left": "moderate", "right": "mild" }, "painTiming": "midrange", "radiatingPainTiming": "none" } }, "risk": { "LB": { "percent": 45, "riskLevel": "MODERATE" }, "LDC": { "percent": 30, "riskLevel": "LOW" }, "LDH": { "percent": 25, "riskLevel": "LOW" } } } ``` ### Field Reference — Low-Back [#field-reference--low-back] **Assessment Survey fields (`assessmentSurvey.`):** | Key | Type | Description | | ------------------------------ | ----------------------------------------- | ---------------------------------- | | `version` | `"LOW_BACK_ASSESSMENT_SURVEY_CONTENT_V1"` | Schema version | | `gender` | `"male"` \| `"female"` \| `"undefined"` | User gender | | `isPainFromSportsOrHandling` | boolean | Pain from sports or heavy lifting | | `isPainWorseSittingOrStanding` | boolean | Pain worsens when sitting/standing | | `hasSwellingOrWarmth` | boolean | Swelling or warmth present | | `hasParaspinalMusclePain` | boolean | Paraspinal muscle pain | | `hasRopeLikeLump` | boolean | Rope-like lump present | | `hasMorningStiffness` | boolean | Morning stiffness | | `isPainWithProlongedSitting` | boolean | Pain with prolonged sitting | | `isPainWithBackExtension` | boolean | Pain with back extension | | `isPainWithProlongedWalking` | boolean | Pain with prolonged walking | | `isPainWithForwardBending` | boolean | Pain with forward bending | | `isPainWithKneesToChest` | boolean | Pain with knees to chest | | `isPainWithCoughingSneezing` | boolean | Pain with coughing/sneezing | | `isPainRelievedByProneLying` | boolean | Pain relieved by prone lying | **Assessment Results fields (`assessmentResults.`):** | Key | Type | Description | | ---------------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `version` | `"LOW_BACK_ASSESSMENT_RESULTS_V1"` | Schema version | | `seatedForwardFlexion.normalizedFingerToToeDistance` | number | Finger-to-toe distance normalized by leg length.
0 = touching toes, negative = not reaching, positive = beyond toes | **Pain Review fields (`painReview.`):** | Key | Type | Description | | --------------------- | -------------------------------------------------------- | ---------------------------- | | `version` | `"LOW_BACK_PAIN_REVIEW_CONTENT_V1"` | Schema version | | `painLevel.left` | `"none"` \| `"mild"` \| `"moderate"` \| `"severe"` | Left back pain level | | `painLevel.right` | `"none"` \| `"mild"` \| `"moderate"` \| `"severe"` | Right back pain level | | `painTiming` | `"none"` \| `"midrange"` \| `"endpoint"` \| `"constant"` | When pain occurs during test | | `radiatingPainTiming` | `"none"` \| `"early"` \| `"midrange"` \| `"endpoint"` | When radiating pain occurs | **Risk Assessment fields (`risk.`):** | Key | Description | | ------------------------------ | ------------------------------------ | | `LB.percent`, `LB.riskLevel` | Low Back Muscle Strain risk | | `LDC.percent`, `LDC.riskLevel` | Lumbar Degenerative Compression risk | | `LDH.percent`, `LDH.riskLevel` | Lumbar Disc Herniation risk | *** ## Knee Assessment (`knee`) [#knee-assessment-knee] ### Nested (mskAssessResult) Example — Knee [#nested-mskassessresult-example--knee] ```json { "id": "knee456ExampleId", "clientId": "your-client-id", "userId": "userABC789", "assessmentCode": "knee", "createdAt": "2025-08-12T14:30:00.000Z", "assessmentData": { "assessmentSurvey": { "version": "KNEE_ASSESSMENT_SURVEY_CONTENT_V1", "ageOver35": true, "gender": "female", "isOverweight": false, "hasKneeInjuryOrSurgery": false, "hasMorningKneeStiffnessOrPain": true, "hasProlongedSittingDiscomfort": true, "hasKneePainOnWalkingStairsOrSquat": true, "hasAnteriorKneePainOnFlexion": false, "hasKneePainOnStandingUp": true, "hasKneeLockingOrInstability": false, "hasKneePainLimitingDailyTasks": false }, "assessmentResults": { "version": "KNEE_ASSESSMENT_RESULTS_V1", "squat": { "angle": 95, "valgus": { "left": 5, "right": 8 } } }, "painReview": { "version": "KNEE_PAIN_REVIEW_CONTENT_V1", "painLevel": { "left": "mild", "right": "moderate" }, "painTiming": "endpoint", "hasMildDiscomfortNearFullDepth": true, "hasSharpAnteriorKneePainMidRange": false } }, "risk": { "OA": { "percent": 35, "riskLevel": "MODERATE" }, "PFPS": { "percent": 20, "riskLevel": "LOW" } } } ``` ### Field Reference — Knee [#field-reference--knee] **Assessment Survey fields (`assessmentSurvey.`):** | Key | Type | Description | | ----------------------------------- | --------------------------------------- | ------------------------------------- | | `version` | `"KNEE_ASSESSMENT_SURVEY_CONTENT_V1"` | Schema version | | `ageOver35` | boolean (optional) | User is over 35 years old | | `gender` | `"male"` \| `"female"` \| `"undefined"` | User gender | | `isOverweight` | boolean | BMI ≥ 25 | | `hasKneeInjuryOrSurgery` | boolean | Previous knee injury or surgery | | `hasMorningKneeStiffnessOrPain` | boolean | Morning stiffness or pain | | `hasProlongedSittingDiscomfort` | boolean | Discomfort after prolonged sitting | | `hasKneePainOnWalkingStairsOrSquat` | boolean | Pain when walking stairs or squatting | | `hasAnteriorKneePainOnFlexion` | boolean | Anterior knee pain on flexion | | `hasKneePainOnStandingUp` | boolean | Pain when standing up | | `hasKneeLockingOrInstability` | boolean | Knee locking or instability | | `hasKneePainLimitingDailyTasks` | boolean | Pain limits daily activities | **Assessment Results fields (`assessmentResults.`):** | Key | Type | Description | | -------------------- | ------------------------------ | ---------------------------------------------- | | `version` | `"KNEE_ASSESSMENT_RESULTS_V1"` | Schema version | | `squat.angle` | number (0–180) | Squat depth angle in degrees | | `squat.valgus.left` | number | Left knee valgus (inward bending) measurement | | `squat.valgus.right` | number | Right knee valgus (inward bending) measurement | **Pain Review fields (`painReview.`):** | Key | Type | Description | | ---------------------------------- | -------------------------------------------------------- | ---------------------------------------- | | `version` | `"KNEE_PAIN_REVIEW_CONTENT_V1"` | Schema version | | `painLevel.left` | `"none"` \| `"mild"` \| `"moderate"` \| `"severe"` | Left knee pain level | | `painLevel.right` | `"none"` \| `"mild"` \| `"moderate"` \| `"severe"` | Right knee pain level | | `painTiming` | `"none"` \| `"midrange"` \| `"endpoint"` \| `"constant"` | When pain occurs during squat | | `hasMildDiscomfortNearFullDepth` | boolean | Mild discomfort only at full squat depth | | `hasSharpAnteriorKneePainMidRange` | boolean | Sharp anterior knee pain at mid-range | **Risk Assessment fields (`risk.`):** | Key | Description | | -------------------------------- | --------------------------------- | | `OA.percent`, `OA.riskLevel` | Knee Osteoarthritis risk | | `PFPS.percent`, `PFPS.riskLevel` | Patellofemoral Pain Syndrome risk | *** ## Event Details [#event-details] ### START\_MSK\_ASSESSMENT [#start_msk_assessment] **When**: User initiates a new MSK assessment **Use Cases**: Track assessment start, initialize local state, show progress indicator ```dart // Data structure: { 'assessmentResultId': 'uuid-string', 'assessmentCode': 'string', 'createdAt': 'iso-date-string', 'userId': 'string-or-null', 'mskAssessResult': { /* nested/original result (may be partial early on) */ }, 'mskAssessResultFlattened': { /* flattened snapshot (may be partial) */ } } ``` ### FINISH\_ASSESSMENT\_SURVEY [#finish_assessment_survey] **When**: User completes the initial questionnaire **Use Cases**: Update progress, save intermediate state, track completion rate ```dart // Data structure: { 'assessmentResultId': 'uuid-string', 'mskAssessResult': { /* nested/original */ }, 'mskAssessResultFlattened': { /* flattened */ } } ``` ### FINISH\_PHYSICAL\_ASSESSMENT [#finish_physical_assessment] **When**: User completes all physical tests **Use Cases**: Update progress, prepare for pain review phase ```dart // Data structure: { 'assessmentResultId': 'uuid-string', 'mskAssessResult': { /* nested/original */ }, 'mskAssessResultFlattened': { /* flattened */ } } ``` ### FINISH\_PAIN\_REVIEW [#finish_pain_review] **When**: User completes pain location and intensity mapping **Use Cases**: Update progress, prepare for report generation ```dart // Data structure: { 'assessmentResultId': 'uuid-string', 'mskAssessResult': { /* nested/original */ }, 'mskAssessResultFlattened': { /* flattened */ } } ``` ### GENERATED\_REPORT [#generated_report] **When**: System completes assessment processing and generates final report **Use Cases**: Notify user, save report data, display or download PDF, show completion ```dart // Data structure: { 'assessmentResultId': 'uuid-string', 'reportUrl': 'data:application/pdf;base64,...', // Base64 data URL of the PDF 'mskAssessResult': { /* nested/original */ }, 'mskAssessResultFlattened': { /* flattened */ } } ``` #### Handling the PDF Data URL [#handling-the-pdf-data-url] The `reportUrl` is provided as a base64 data URL that you can use directly in your Flutter app: ```dart void _onReportGenerated(Map data) { final String reportUrl = data['reportUrl']; // data:application/pdf;base64,... // Extract base64 data final String base64Data = reportUrl.split(',')[1]; final Uint8List pdfBytes = base64Decode(base64Data); // Save to device storage _savePdfToFile(pdfBytes, 'msk_report_${data['assessmentResultId']}.pdf'); // Or display in a PDF viewer _showPdfViewer(pdfBytes); } Future _savePdfToFile(Uint8List pdfBytes, String filename) async { final directory = await getApplicationDocumentsDirectory(); final file = File('${directory.path}/$filename'); await file.writeAsBytes(pdfBytes); print('PDF saved to: ${file.path}'); } ``` ### EARLY\_QUIT\_ASSESSMENT [#early_quit_assessment] **When**: User exits assessment before completion **Use Cases**: Handle incomplete state, show retention messaging, track dropout reasons ```dart // Data structure: { 'assessmentResultId': 'uuid-string-or-null', 'reason': 'string-or-null', 'mskAssessResult': { /* optional partial */ }, 'mskAssessResultFlattened': { /* optional partial */ } } ``` ### QUIT\_MSK\_MODULE / QUIT\_MODULE [#quit_msk_module--quit_module] **When**: User exits the entire MSK module
**Use Cases**: Navigate back, clear state, track module engagement As of gofa\_webview v1.2.0, the WebView SDK also recognises the generic `QUIT_MODULE` event. New integrations should prefer `QUIT_MODULE`. The legacy `QUIT_MSK_MODULE` name is still supported for backward compatibility. ```dart // Data structure: { 'mskAssessResult': { /* optional */ }, 'mskAssessResultFlattened': { /* optional */ } } ``` # SDK Versions (/docs/sdk/webview/sdk-versions) ## Check the Latest SDK Versions [#check-the-latest-sdk-versions] To find the latest versions of the `gofa_webview` Flutter SDK, please check here: [https://onepub.dev/packages/gofa\_webview/fvnuhnofly](https://onepub.dev/packages/gofa_webview/fvnuhnofly) # 🧘‍♂️ Poses (/docs/sdk/challenge/pose-api/poses) Our Poses API is exposed via Firebase Cloud Function Callables. | Single Pose | Preview | Pose Key | Status | Remarks | | ----------------------------------- | -------------------------------------------------- | ------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Squat | Squat | squats | ✅ | Body facing the camera.
Make sure knees are visible. Best if ankles are too. | | Jumping jack | Jumping jack | jumping-jacks | ✅ | Body facing the camera.
Stand at least 4.5 feet from the camera, so that when you begin to jump, your swinging hands are visible on the screen.
Make sure your initial position is "Stand upright with your legs together, arms at your sides". | | Push up | Push up | push-ups | ✅ | Lie down with head's down.
Head position towards the camera.
The rep counting will be more accurate if you perform wide hands push-up, but make sure your elbows are visible on the screen. | | Reverse lunge | Reverse lunge | reverse-lunges | ✅ | Body facing the camera.
Make sure your upper body is straight up when you lunge.
Try your best to make your front knee is at 90 degrees, and another kneeling knee also at 90 degrees almost touching the ground. | | Standing bicycle crunch | Standing bicycle crunch | standing-bicycle-crunches | ✅ | Body facing the camera.
Put your hands behind your head, and hold them there throughout.
Raise your left knee to touch your right elbow, and back to start position. Repeat the move with right knee with left elbow, and so on. | | Cross leg swings | Cross leg swings | cross-leg-swings | ✅ | Body facing the camera.
Initial pose is similar to "T pose".
Extend and raise the arms to shoulder level.
Make sure the arm is straight.
Try your best to swing your leg high enough to touch your hand.
If touching the hand with your foot is not possible, try your best to get them as close as possible. | | High knees | High knees | high-knees | ✅ | Face the camera with feet hip-width apart.
Alternate lifting your knees high, aim for the chest level.
Keep the pace quick, back straight, and arms moving for balance. | | Curtsey lunge | Curtsey lunge | curtsey-lunge | ✅ | Start facing the camera, feet shoulder-width apart.
Step back diagonally with one leg into a lunge.
Alternate sides, keeping the torso upright. | | Side lunge | Side lunge | side-lunge | ✅ | Face the camera, start with feet together.
Step wide to one side and lower into a lunge.
Keep the opposite leg straight, alternate sides. | | Oblique knee crunches | Oblique knee crunches | oblique-knee-crunches | ✅ | Stand facing the camera, hands behind your head.
Crunch side to side, lifting knee to the elbow.
Engage obliques, alternate knees. | | Squat jumps | Squat jumps | squat-jumps | ✅ | Face the camera, start with a regular squat.
Jump up explosively and land back in a squat.
Keep landing soft and back straight. | | Burpee | Burpee | | | | | Plank (hold duration/not rep count) | Plank | | | | # Code Setup: Only WorkoutSetScreen (/docs/sdk/challenge/installation/code-setup-only-workoutsetscreen) ## Add the required packages for GOFA Pose SDK [#add-the-required-packages-for-gofa-pose-sdk] Our GOFA SDK uses GetIt to register our GofaPoseSdkManager as a singleton service helps with initialization of our SDK functionalities. So please run the following command to add the dependencies ```powershell flutter pub add get_it flutter pub add provider flutter pub add bloc flutter pub add flutter_bloc ``` ## Starting WorkoutSetScreen directly [#starting-workoutsetscreen-directly] Define `provider`, `clientId`, `memberId` and `environment` using the shared [member authentication guide](/docs/sdk/authentication). Challenge uses the same backend-token sign-in as Lessons and WebView. ```dart ElevatedButton( onPressed: () async { await registerGofaPoseSdk( context: context, ); if (context.mounted) { await navigateToWorkoutSetScreen( context: context, workoutItem: PoseItem( key: 'squats', localizedNames: { en: "Squats", zh_Hant: "深蹲", zh_Hans: "深蹲", }, ), totalSecondsAllowed: totalSecondsAllowed, targetRepsToStart: targetRepsToStart, autoStopSetWhenRepsFulfilled: false, ); } }, child: const Text('Start Workout'), ), ``` ```dart Future registerGofaPoseSdk({ required BuildContext context, }) async { // final isPhysicalDevice = await checkPhysicalDevice(); if (!GetIt.I.isRegistered()) { final gofaPoseSDKManager = GofaPoseSdkManager(); GetIt.I.registerSingleton(gofaPoseSDKManager); } final gofaPoseSDKManager = GetIt.I(); try { await gofaPoseSDKManager.init( environment: environment, clientId: clientId, clientUserId: memberId, customTokenProvider: provider, ); if (!gofaPoseSDKManager.isInitialized) { throw StateError('Challenge not initialized'); } } catch (e) { log.e('Error registering GofaPoseSdkManager: $e'); // show scaffold messenger if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Error occurred when starting GofaPoseSdkManager'), ), ); } rethrow; } } ``` ```dart Future navigateToWorkoutSetScreen({ required BuildContext context, required PoseItem workoutItem, required int totalSecondsAllowed, required int targetRepsToStart, required bool autoStopSetWhenRepsFulfilled, }) async { if (context.mounted) { try { await GetIt.I() .isInitialized$ // timeout after 10 seconds, in case initialization fails .timeout(const Duration(seconds: 10)) .contains(true); } on TimeoutException catch (e) { log.e('Error initializing GofaPoseSdkManager: $e'); if (context.mounted) { // show scaffold with error message ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( '$e', ), ), ); } } GetIt.I().add( WorkoutSetSetup( workoutItem: workoutItem, totalSecondsAllowed: totalSecondsAllowed, targetRepsToStart: targetRepsToStart, autoStopSetWhenRepsFulfilled: autoStopSetWhenRepsFulfilled, isShowCheckpoint: true, navigateToNextAfterWorkout: (_) { // pop one screen Navigator.of(context).pop(); }, ), ); if (!context.mounted) return; // navigate to WorkoutSetScreen Navigator.push( context, MaterialPageRoute( builder: (context) => const WorkoutSetScreen(), ), ); } } ``` ## Example to get back the result of the last workout [#example-to-get-back-the-result-of-the-last-workout-] ```dart // Show a button "Get last workout result" ElevatedButton( onPressed: () async { await registerGofaPoseSdk( context: context, ); // wait till GofaPoseSdkManager is initialized await GetIt.I() .isInitialized$ .timeout(const Duration(seconds: 5)) .contains(true); final lastWorkoutSetState = GetIt.I().state; log.d('lastWorkoutSetState: $lastWorkoutSetState'); if (context.mounted) { // prompt a widget to show the last workout result showDialog( context: context, builder: (context) { return AlertDialog( title: const Text('Last Workout Result'), content: Text( 'Workout Item: ${lastWorkoutSetState.workoutItem?.poseName}\n' 'Reps done: ${lastWorkoutSetState.repsDone}\n' 'Seconds Used: ${lastWorkoutSetState.secondsUsed}\n'), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); }, child: const Text('Close'), ), ], ); }, ); } }, child: const Text('Get last workout result'), ), ``` ## Add Gofa Pose SDK language support [#add-gofa-pose-sdk-language-support] Since version v2.0.0, the locales of our supported languages are: ``` Locale('en'), Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'), ``` You are required to add `GofaLocalizationsDelegate()` to the localizationsDelegates array list of your MaterialApp. ```dart MaterialApp( ... localizationsDelegates: const [ AppLocalizations.delegate, ... GofaLocalizationsDelegate(), ], locale: currentLocale, supportedLocales: yourSupportedLocales, // supportedLocales: GofaLocalizations.supportedLocales, ... ) ``` ## # Installation (/docs/sdk/challenge/installation) [install-gofa\_pose](/docs/sdk/challenge/installation/install-gofa_pose "mention") [code-setup-gofa-lessons](/docs/sdk/challenge/installation/code-setup-gofa-lessons "mention") [screen-and-logic-flows.md](/docs/sdk/challenge/references/screen-and-logic-flows "mention") [workout-set-state-data](/docs/sdk/challenge/installation/workout-set-state-data "mention") # Migration Guide (/docs/sdk/challenge/installation/migration-guide) ## since v2.0.0 [#since-v200] * remove these code if you are using v2.0.0 or later * in your app.dart * AudioManager * WorkoutSetBloc * ```dart MultiProvider( providers: [ ... ] ) ``` * remove provider for AudioManager ``` ChangeNotifierProvider( create: (context) => AudioManager(), lazy: false, ), ``` * remove WorkoutSetBloc provider * remove this code ```dart BlocProvider( create: (context) => WorkoutSetBloc( context.read(), )), ``` * WorkoutSetBloc is the engine of the Gofa Workout Set. * It's WorkoutSetState stores all the necessary state data * WorkoutSetBloc manages events to trigger handlers to modify state and perform actions. ## since v1.0.18 [#since-v1018] remove these redundant dependencies as we do not use google ml kit anymore. ```groovy dependencies { ... // GS-152 Fatal error: ProcessLifecycleOwner - when starting workout set. // ref: https://developer.android.com/jetpack/androidx/releases/lifecycle def lifecycle_version = "2.6.1" def arch_version = "2.2.0" // ViewModel implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" // ViewModel utilities for Compose implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version" // LiveData implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" // Lifecycles only (without ViewModel or LiveData) implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" // Lifecycle utilities for Compose implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version" // Saved state module for ViewModel implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycle_version" // Annotation processor annotationProcessor "androidx.lifecycle:lifecycle-compiler:$lifecycle_version" // optional - ProcessLifecycleOwner provides a lifecycle for the whole application process implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version" } ``` # References (/docs/sdk/challenge/references) # Screen And Logic Flows (/docs/sdk/challenge/references/screen-and-logic-flows) ## Entry page [#entry-page] * You should have a screen like "SelectPoseScreen" in our Demo App * The screen displays the list of poses that user can choose from * Prepare the workout set when user chooses one of the poses. * See [#prepare-a-workout-set](/docs/sdk/challenge/references/screen-and-logic-flows#prepare-a-workout-set "mention") ### Prepare a workout set [#prepare-a-workout-set] ```dart final workoutItem = WorkoutItem( poseName: "Squats" ); BlocProvider.of<WorkoutSetBloc>(context).add( WorkoutSetSetup( workoutItem: workoutItem, totalSecondsAllowed: 30, // 30 seconds challenge targetRepsToStart: null, autoStopSetWhenRepsFulfilled: false, ), ); ``` ## Enter a workout set [#enter-a-workout-set] ```dart Future navigateToWorkoutSetScreen({ required BuildContext context, }) async { if (context.mounted) { // navigate to WorkoutSetScreen Navigator.push( context, MaterialPageRoute( builder: (context) => WorkoutSetScreen(), ), ); } } ``` ## During the workout Set [#during-the-workout-set] * WorkoutSetBloc contains all the data * WorkoutSetState * Ref: [workout-set-state-data](/docs/sdk/challenge/installation/workout-set-state-data "mention") * Notable fields: * totalSecondsAllowed * secondsLeft * secondsUsed * targetRepsToStart * repsDone * workoutSetStatus * skeletonStream * poseAnalyserStream ### Pop Menu: "Back" button [#pop-menu-back-button] #### #### Default behavior [#default-behavior] The following is the function `navigateToPreviousDuringWorkout` responsible for navigation to next screen after workout. The default behavior is to pop twice to get the the entry screen. ```dart /// navigateToPreviousDuringWorkout , navigate to the previous screen during workout Function(BuildContext)? navigateToPreviousDuringWorkout = (BuildContext context) { Navigator.pop(context); }; ``` #### Override the behavior [#override-the-behavior] You can use WorkoutSetSetup event to override the Back button navigation behavior. ```dart BlocProvider.of(context).add( WorkoutSetSetup( ... navigateToPreviousDuringWorkout: (context) { your navigation logic to previous screen... }, ), ); ``` ## Enter WorkoutSetResultScreen [#enter-workoutsetresultscreen] * When user has finished the workout set (or Fitness Challenge), user can click "See results" in WorkoutSetScreen to navigate to WorkoutSetResultScreen. * This flow is handled by GOFA package. ## Exit WorkoutSetResultScreen [#exit-workoutsetresultscreen] WorkoutSetResultScreen * Two buttons are present in result screen for users to navigate away from our Workout Set pages * Next * Back to Home ### "Next" button [#next-button] #### Default behavior [#default-behavior-1] The following is the function `navigateToNextAfterWorkout` responsible for navigation to next screen after workout. The default behavior is to pop twice to get the the entry screen. ```dart /// navigateToNextAfterWorkout, navigate to the next screen after workout Function(BuildContext) navigateToNextAfterWorkout = (BuildContext context) { // assuming this is called in WorkoutSetResultScreen, so pop twice to get to SelectPoseScreen Navigator.pop(context); Navigator.pop(context); // if the SelectPoseScreen route name is known, can instead use this // Navigator.popUntil(context, ModalRoute.withName('/select_pose')); }; ``` #### Override the behavior [#override-the-behavior-1] You can use WorkoutSetSetup event to override the Next button navigation behavior. ```dart BlocProvider.of(context).add( WorkoutSetSetup( ... navigateToNextAfterWorkout: (context) { // for example, navigate to WorkoutAfterScreen Navigator.pushNamedAndRemoveUntil( context, WorkoutAfterScreen.routeName, ModalRoute.withName(SelectPoseScreen.routeName), ); }, ), ); ``` ### "Back to Home" button [#back-to-home-button] #### Default behavior [#default-behavior-2] The following is the function `navigateToHomeAfterWorkout` responsible for navigation to home screen after workout. Since v1.0.5. The default behavior is (context) => false, that is, not showing "Back to Home" button. #### Override the behavior [#override-the-behavior-2] You can use WorkoutSetSetup event to override the Next button navigation behavior. ```dart BlocProvider.of(context).add( WorkoutSetSetup( ... navigateToHomeAfterWorkout: (context) { // for example, pop until /home screen Navigator.popUntil(context, ModalRoute.withName('/home')); // or // popUntil is used to pop all the screens until the `HomeScreen` is reached. // Navigator.popUntil(context, (route) => route.isFirst); }, // navigateToHomeAfterWorkout: (context) => false // to disable the button ), ); ``` After exiting GOFA Widgets/Screen, * you can still have access to WorkoutSetBloc's state data. * The previous workout analytic data remains until next WorkoutSetSetup event is added. ## GOFA Widget [#gofa-widget] Widgets in "gofa\_pose" to compose in your app * High-level Widgets * WorkoutSetScreen * The main screen to play out the workout set with count down and rep counting. * WorkoutSetResultScreen * The screen to show workout results after user has done the workout * Low-level Widgets * CameraView * PoseDetectorView [^1]: # Visuals (/docs/sdk/challenge/references/visuals) ## 30 Seconds Challenge [#30-seconds-challenge] [View Figma Prototype](https://www.figma.com/proto/4cERa9jn5j9PmY7O8zoU44/Gofa---Ai-Fitness-Challenge?node-id=801%3A34970\&scaling=scale-down\&page-id=2%3A38\&starting-point-node-id=801%3A34453\&show-proto-sidebar=1) Designed with Figma. [Watch Video Introduction](https://drive.google.com/file/d/19mXyRQQ7dZKGbL5pgpaDz1a0EgM4A2c9/view?usp=share_link) Video Introduction # Code Setup (/docs/sdk/lessons/installation/code-setup) The GOFA Lessons SDK uses `LessonSdkManager` for initialization and GetIt for service registration. Before implementing member sign-in, read [SDK authentication](/docs/sdk/authentication). **For an existing app, contact GOFA for its supported setup and migration guidance.** ## Add the required package [#add-the-required-package] ```bash flutter pub add get_it ``` GOFA will confirm your Lessons package version, environment, member identity mapping, and authentication setup. The planned server-mediated sign-in flow is not yet available for production use. ## Add language support [#add-language-support] Add `GofaLessonLocalizationsDelegate()` to your app's `localizationsDelegates`. Supported locales include English, Traditional Chinese, and Simplified Chinese. # Package Structure (/docs/sdk/lessons/installation) System Design # GENERAL_STATUS (/docs/sdk/lessons/data-schema/general_status) ```dart enum GENERAL_STATUS { active, testing, pending, deleted, archived, } ``` # GROUP_CATEGORY (/docs/sdk/lessons/data-schema/group_category) ```dart enum GROUP_CATEGORY { RUNNING, YOGA, STRENGTH, HIIT, STRETCH, NO_EQUIPMENT, PILATES, CARDIO, MEDITATION, } ``` # GROUP_EQUIPMENT (/docs/sdk/lessons/data-schema/group_equipment) ```dart enum GROUP_EQUIPMENT { NO_EQUIPMENT, DUMBELLS, RESISTANCE_BANDS, ANKLE_WEIGHTS, STEP, CHAIR, MAT, PILATES_BALL, } ``` # GROUP_GOAL (/docs/sdk/lessons/data-schema/group_goal) ```dart enum GROUP_GOAL { WEIGHT_LOSS, CARDIO_FITNESS, GET_LEAN_AND_TONED, BUILD_STRENGTH, INCREASE_FLEXIBILITY, REDUCE_STRESS, } ``` # GROUP_INTENSITY (/docs/sdk/lessons/data-schema/group_intensity) ```dart enum GROUP_INTENSITY { TAKING_IT_EASY, BREAK_A_SWEAT, GET_YOUR_SWEAT_ON, } ``` # GROUP_MUSCLE (/docs/sdk/lessons/data-schema/group_muscle) ```dart enum GROUP_MUSCLE { FULL_BODY, ARMS, ABS, BOOTY, LEGS, UPPER_BODY, LOWER_BODY, } ``` # GROUP_STYLE (/docs/sdk/lessons/data-schema/group_style) ```dart enum GROUP_STYLE { CARDIO, TONE, LEAN, TABATA, STRENGTH, HIIT, CHALLENGE, MOBILITY, FLEXIBILITY, BOXING, RECOVERY, POWER, } ``` # GROUP_TRAINER (/docs/sdk/lessons/data-schema/group_trainer) ```dart enum GROUP_TRAINER { TOBY, ESMEY, BREANN, JENI, JAKE, DANIELLE, AJ, } ``` # GROUP_TYPE (/docs/sdk/lessons/data-schema/group_type) ```dart enum GROUP_TYPE { STRETCH, YOGA, PILATES, RUNNING, MEDITATION, BOXING, BARRE, DANCE, KICKBOXING, CARDIO, HIIT, STRENGTH, SLEEP, } ``` # Lesson (/docs/sdk/lessons/data-schema/lesson) ```dart class Lesson { String? id; // lessonId String? baseLessonID; // reference to the original lesson's id String? playId; // for lesson started String? planId; // for lesson started from lesson plan String? planPlayId; // for lesson started from lesson plan GENERAL_STATUS status; String imageUrl; String? thumbImageUrl; Localizable? title; Localizable? description; List<String>? videoURLs; Localizable? subtitleUrl; int duration; String? intensity; WORKOUT_ALGORITHM algorithm; List<SessionConfig> sessions; GROUP_INTENSITY? groupByIntensity; List<GROUP_STYLE> groupByStyles; List<GROUP_TYPE> groupByTypes; List<GROUP_MUSCLE> groupByMuscles; List<GROUP_GOAL> groupByGoals; List<GROUP_EQUIPMENT> groupByEquipments; List<GROUP_TRAINER> groupByTrainers; bool? requiredWarmup; bool? requiredCooldown; List<Lesson>? relatedLessons; // stats final int? totalViews; final int? totalPlays; final int? totalCompletions; final DateTime? lastViewed; final DateTime? lastPlayed; final DateTime? lastCompleted; } ``` # Plan (/docs/sdk/lessons/data-schema/plan) ```dart class Plan { final String? planId; final String planName; final Localizable title; final String planDescription; final Localizable description; final String planImage; final List<String> lessons; // lessonIds final List<String> tags; final List<String> targetAreas; final int? totalViews; final int? totalPlays; final int? totalCompletions; final DateTime? lastViewed; final DateTime? lastPlayed; final DateTime? lastCompleted; final String? currentPlanPlayId; final List<String> completedLessons; // from PlanPlay } ``` # PlanPlay (/docs/sdk/lessons/data-schema/planplay) ```dart class PlanPlay { final String? planPlayId; final String planId; final PlanPlayStatus status; final List totalRepCount; final List totalSecondsSpent; final List averageEngagementLevel; final List averageMirroringLevel; final DateTime createdAt; final List plannedLessons; // string array of lessonIds final List completedLessons; // string array of lessonIds } ``` # PlanPlayStatus (/docs/sdk/lessons/data-schema/planplaystatus) ```dart enum PlanPlayStatus { pending, // please ignore this status, as we set the play status initially at `started` started, paused, // please ignore this status, as there is no way to pause the plan play in GOFA Lessons at the moment. ended, cancelled, } ``` #### PlanPlayStatus [#planplaystatus-] **1. started**\ This status means that the lesson plan is currently in progress. The user is actively engaged in the lessons outlined in the plan. **2. ended**\ Indicates that all lessons within the plan have been completed. The user has finished the entire lesson plan. **3. cancelled**\ This status means that the user has chosen to reset lesson plan before completing it. The user has not completed the plan at all, and there are no pass or fail statuses associated with the plan play. \\ # Play (/docs/sdk/lessons/data-schema/play) ```dart class Play { final String? playId; final String? planPlayId; final String? planId; final String lessonId; final PlayStatus status; final bool? passed; final DateTime createdAt; final int? totalRepCount; final double? averageEngagementLevel; final double? averageMirroringLevel; final int? totalSecondsSpent; } ``` # PlayStatus (/docs/sdk/lessons/data-schema/playstatus) ```dart enum PlayStatus { pending, // please ignore this status, as we set the play status initially at `started` started, paused, ended, cancelled, } ``` #### PlayStatus [#playstatus-] **1. started**\ This status means that the lesson is currently in progress. The user is actively engaged in watching or participating in the lesson. **2. paused**\ This status signifies that the lesson has been temporarily halted. The user has paused the lesson and can resume it later. **3. ended**\ Indicates that the lesson has been completed. The user has watched the entire lesson until the last second. **4. cancelled**\ This status means that the user has chosen to exit the lesson before completion. It does not indicate failure; substantial lesson progress can still result in `passed: true`. # Session (/docs/sdk/lessons/data-schema/session) ```dart class Session { final String? sessionId; final DateTime startedAt; final String poseName; final int? secondsSpent; final int? repCount; final double? engagementLevel; final double? mirroringLevel; } ``` # User (/docs/sdk/lessons/data-schema/user) ```dart class User { final String? userId; final List userLessons; final List plays; final List userPlans; final List planPlays; } ``` # UserLesson (/docs/sdk/lessons/data-schema/userlesson) ```dart class UserLesson { // must be present, as it is known at the time of creation final String lessonId; final int? totalViews; final int? totalPlays; final int? totalCompletions; final DateTime? lastViewed; final DateTime? lastPlayed; final DateTime? lastCompleted; } ``` # WORKOUT_ALGORITHM (/docs/sdk/lessons/data-schema/workout_algorithm) ```dart enum WORKOUT_ALGORITHM { FREE_RUN, AUDIO_DYNAMIC, AUDIO_STATIC, VIDEO_STATIC, VIDEO_COUNT, VIDEO_MIRROR, VIDEO_ENGAGEMENT, FITNESS_CHALLENGE, } ``` # In-app Fetch User Data (/docs/sdk/lessons/data-exchange/in-app-fetch-user-data) ### Active update by Flutter Main App [#active-update-by-flutter-main-app] whenever Flutter Main App needs to get the latest data for a user ``` Future LessonSdkManager.getCurrentUserData({ DateTime? startDate, DateTime? endDate, }) async { // if startDate is null, set it to 30 days ago startDate ??= DateTime.now().subtract(const Duration(days: 30)); // if endDate is null, set it to current date endDate ??= DateTime.now(); // validate startDate and endDate if (startDate.isAfter(endDate)) { throw Exception('startDate is after endDate.'); } ... } // e.g. GetIt.I().getCurrentUserData( startDate: startDate, endDate: endDate, ) ``` For details of the User class data structure, please refer to [data-schema](/docs/sdk/lessons/data-schema/user "mention") # Data Exchange (/docs/sdk/lessons/data-exchange) # Advanced Configuration (/docs/sdk/webview/configuration) The WebView SDK supports environment selection and an `updateCallback` for events from GOFA experiences. For member sign-in, see [SDK authentication](/docs/sdk/authentication). **Contact GOFA for existing integration configuration and migration guidance.** ## Environment [#environment] Choose the GOFA environment confirmed for your integration: * `Environment.DEV` for development * `Environment.UAT` for testing * `Environment.PRD` for production GOFA will provide the correct URL and supported package version for each environment. ## Event callback [#event-callback] The web app can send events to Flutter through the manager's `updateCallback`. Handle only the events relevant to the modules you use: ```dart final manager = WebViewSdkManager( environment: Environment.UAT, updateCallback: (event, data) { handleGofaEvent(event, data); }, ); ``` See the [MSK integration guide](/docs/sdk/webview/msk-integration) for its event names and payloads. Confirm your authentication setup with GOFA before initializing the SDK in a live app. # References (/docs/sdk/lessons/references) # Lesson SDK Manager (/docs/sdk/lessons/references/lesson-sdk-manager) ## init [#init] See [Code Setup](/docs/sdk/lessons/installation/code-setup) and contact GOFA for the supported initialization and member sign-in details for your app. ## isConnected [#isconnected] This isConnected property is a stream to let you know whether the gofa\_lessons can establish a connection with our Firebase project successfully. The initial value of this property is "null". And it will return boolean when we have decided the connection works or not. ```dart StreamBuilder( stream: GetIt.I().isConnected, builder: (context, snapshot) { if (snapshot.hasData) { log.d('isConnected snapshot.data: ${snapshot.data}'); if (snapshot.data as bool) { return const Text('Connected to GOFA Firebase'); } else { return const Text( 'Cannot be connected to GOFA Firebase'); } } return const Text('Still connecting to GOFA Firebase'); }, ); ``` # Screen And Logic Flows (/docs/sdk/lessons/references/screen-and-logic-flows) Screen and Logic Flows # GofaPoseSdkManager (/docs/sdk/challenge/installation/code-setup-gofa-lessons/gofaposesdkmanager) It has been renamed from GofaSDKManager. ## isConnected [#isconnected] This isConnected property is a stream to let you know whether the gofa\_pose can establish a connection with our Firebase project successfully. The initial value of this property is "null". And it will return boolean when we have decided the connection works or not. available since gofa\_pose v0.8.24 ```dart StreamBuilder( stream: GetIt.I().isConnected, builder: (context, snapshot) { if (snapshot.hasData) { log.d('isConnected snapshot.data: [${snapshot.data}'); if (snapshot.data as bool) { return const Text('Connected to GOFA Firebase'); } else { return const Text( 'Cannot be connected to GOFA Firebase'); } } return const Text('Still connecting to GOFA Firebase'); }, ); ``` # Code Setup: GOFA Challenge (/docs/sdk/challenge/installation/code-setup-gofa-lessons) ## Add the required packages for GOFA Pose SDK [#add-the-required-packages-for-gofa-pose-sdk] Our GOFA SDK uses GetIt to register our GofaPoseSdkManager as a singleton service, which helps with initialization of our SDK functionalities. So please run the following command to add the dependencies: ```powershell flutter pub add get_it flutter pub add provider flutter pub add bloc flutter pub add flutter_bloc ``` ## Set up GofaPoseSdkManager [#set-up-gofaposesdkmanager] Use the same member authentication as Lessons and WebView. First define the backend token provider and stable member ID as shown in [SDK authentication](/docs/sdk/authentication). ```dart import 'package:gofa_pose/gofa_pose.dart'; import 'package:get_it/get_it.dart'; final pose = GofaPoseSdkManager(); await pose.init( environment: Environment.UAT, clientId: yourUatClientId, clientUserId: existingStableMemberId, customTokenProvider: provider, ); if (!pose.isInitialized) throw StateError('Challenge not initialized'); GetIt.I.registerSingleton(pose); // Open the Challenge screen only after successful initialization. ``` Challenge may initialize first or run alone. Keep the same environment, member and provider across modules. Handle sign-in errors in your host app before opening a protected screen. Follow the shared logout instructions on the authentication page. ### Setting up GetIt.I.allReady builder [#setting-up-getitiallready-builder] Because our gofa\_pose depends on Firebase connection to be established in order to run successfully, we highly recommend you to add the following FutureBuilder to your widget tree where you will compose our widgets and BloC. ```dart FutureBuilder( future: GetIt.I.allReady(), builder: (context, snapshot) { // build any widget that composes Gofa Screen and Bloc Logic if (!snapshot.hasData) { return const MaterialApp( home: Scaffold( body: Center( child: CircularProgressIndicator(), ), ), ); } return const YourWidget(); }, ) ``` ## Add Gofa Pose SDK language support [#add-gofa-pose-sdk-language-support] Since version v2.0.0, the locales of our supported languages are: ```dart Locale('en'), Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'), ``` You are required to add `GofaLocalizationsDelegate()` to the `localizationsDelegates` array list of your MaterialApp. ```dart MaterialApp( ... localizationsDelegates: const [ AppLocalizations.delegate, ... GofaLocalizationsDelegate(), ], locale: currentLocale, supportedLocales: yourSupportedLocales, // supportedLocales: GofaLocalizations.supportedLocales, ... ) ``` *** ## Obsolete Docs/Code [#obsolete-docscode] ### Modify android/app/build.gradle [#modify-androidappbuildgradle] > **Warning** > No need since v1.0.18. Remove these dependencies if you are using gofa\_pose v1.0.18 or later versions. Add the following android dependencies: ```groovy dependencies { ... // GS-152 Fatal error: ProcessLifecycleOwner - when starting workout set. // ref: https://developer.android.com/jetpack/androidx/releases/lifecycle def lifecycle_version = "2.6.1" def arch_version = "2.2.0" // ViewModel implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" // ViewModel utilities for Compose implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version" // LiveData implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" // Lifecycles only (without ViewModel or LiveData) implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" // Lifecycle utilities for Compose implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version" // Saved state module for ViewModel implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycle_version" // Annotation processor annotationProcessor "androidx.lifecycle:lifecycle-compiler:$lifecycle_version" // optional - ProcessLifecycleOwner provides a lifecycle for the whole application process implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version" } ``` ### Setting up Providers [#setting-up-providers] > **Warning** > No need since v2.0.0. Remove these code if you are using v2.0.0 or later. * In your app.dart, provide the following providers: * AudioManager * WorkoutSetBloc ```dart MultiProvider( providers: [ ... ] ) ``` ### Set up AudioManager [#set-up-audiomanager] > **Warning** > No need since v2.0.0. Remove these code if you are using v2.0.0 or later. ```dart // Provider for AudioManager ChangeNotifierProvider( create: (context) => AudioManager(), lazy: false, ), ``` * AudioManager manages the audio settings of GOFA Workout Set. ### Set up WorkoutSetBloc [#set-up-workoutsetbloc] > **Warning** > No need since v2.0.0. Remove these code if you are using v2.0.0 or later. ```dart BlocProvider( create: (context) => WorkoutSetBloc( context.read(), ), lazy: false, ), ``` * WorkoutSetBloc is the engine of the Gofa Workout Set. * Its WorkoutSetState stores all the necessary state data * WorkoutSetBloc manages events to trigger handlers to modify state and perform actions. > **Warning** > You will need to navigate to our WorkoutSetScreen. In order for our screen to gain access to the WorkoutSetBloc, please make sure it is provided before the MaterialApp, so that any newly pushed page (WorkoutSetScreen, or your pages) can access WorkoutSetBloc too. Below is a working example code for your reference. ```dart @override Widget build(BuildContext context) { return MultiProvider( providers: [ // Provider for AudioManager ChangeNotifierProvider( create: (context) => AudioManager(), lazy: false, ), BlocProvider( create: (context) => WorkoutSetBloc( context.read(), ), lazy: false, ), ], builder: (context, child) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( ...existing code... ``` # WebView SDK Manager Reference (/docs/sdk/webview/references/webview-sdk-manager) `WebViewSdkManager` configures the GOFA WebView experience and receives events from it. See [SDK authentication](/docs/sdk/authentication) for the planned server-mediated member sign-in flow. **For existing initialization signatures and migration instructions, contact GOFA.** ## Constructor [#constructor] The manager accepts an `Environment` (`DEV`, `UAT`, or `PRD`) and an optional `updateCallback` for WebView events. GOFA will confirm the supported package version and sign-in setup for your environment. ## Initialization state [#initialization-state] `isInitialized` indicates whether the manager has completed initialization. Wait for your agreed sign-in setup to complete before opening a GOFA WebView. ## Events [#events] The callback receives an event name and event data. See the [MSK integration guide](/docs/sdk/webview/msk-integration) for events used by that module. # Android/iOS Permission Grants (/docs/sdk/challenge/installation/install-gofa_pose/android-ios-permission-grants) There are the permissions we require device users to grant our SDK to use the functionalities. ## Android Permissions [#android-permissions] Add the following permission settings to android/app/src/main/AndroidManifest.xml ```xml ``` ## iOS Permissions [#ios-permissions] Add the following permission settings to ios/Runner/Info.plist ```xml NSCameraUsageDescription Camera is used to capture user movement during fitness workouts NSMicrophoneUsageDescription Camera is used to capture user movement during fitness workouts ``` # Device Requirements (/docs/sdk/challenge/installation/install-gofa_pose/device-requirements) **Minimum Device Requirements:** * **OS Version:** * **Android:** 9.0 (Pie) with SDK level 28 * **iOS:** 11 or higher * **CPU:** Quad-core processor * **RAM:** 4GB * **Disk Space:** 200MB free space * **Network Connection:** Stable 4G **Recommended Device Requirements:** * **OS Version:** * **Android:** 10.0 (Q) or higher with SDK level 31 or higher * **iOS:** 13 or higher * **CPU:** Octa-core processor * **RAM:** 6GB or more * **Disk Space:** 500MB free space * **Network Connection:** Stable 4G or 5G **Flutter Android SDK Versions:** * **Minimum SDK Version:** 28 (Android 9.0 Pie) * **Recommended SDK Version:** 31 or higher (Android 10.0 Q or higher) # Install "gofa_pose" (/docs/sdk/challenge/installation/install-gofa_pose) ## Add "gofa\_pose" [#add-gofa_pose] ### Approach 1: OnePub [#approach-1-onepub] * [ ] Please contact GOFA Team to gain member access to our OnePub package. You will need to give us the developer email to grant you the access. * [ ] To install onepub run: ```bash dart pub global activate onepub onepub login ``` The `onepub login` command will land you to the login page in your browser. Use the developer email to log in. * [ ] Run this command: ```csharp onepub pub add gofa_pose ``` * [ ] Verify that lines like these are added to your package's pubspec.yaml (and run dart pub get or flutter pub get): ```yaml dependencies: gofa_pose: hosted: https://onepub.dev/api/fvnuhnofly version: ^1.0.0 ``` ### Approach 2: Local files [#approach-2-local-files] * [ ] Copy the whole "gofa\_pose" folder * from "[https://bitbucket.org/GofaDeveloper/gofa\_sdk/src/master/packages/gofa\_pose/](https://bitbucket.org/GofaDeveloper/gofa_sdk/src/master/packages/gofa_pose/)" * to your Flutter app root directory's "packages/" * [ ] In your Flutter app's "pubspec.yaml" ```yaml dependencies: ... # gofa_pose: # hosted: https://onepub.dev/api/fvnuhnofly gofa_pose: path: ./packages/gofa_pose ``` ## Upgrade "gofa\_pose" [#upgrade-gofa_pose] We will publish version upgrades of "gofa\_pose" package from time to time. Right now, our version is still v0. so we will publish breaking changes even in patch version. After we launching v1 of "gofa\_pose", we will follow the rules for publishing new changes: * **patch** version upgrade only fix bugs and improve features **without breaking changes** * **minor** version upgrade **may or may not include breaking changes**. * The changes will be listed in the CHANGELOG.md * or you can view in [https://onepub.dev/packages/gofa\_pose/fvnuhnofly](https://onepub.dev/packages/gofa_pose/fvnuhnofly) * Breaking changes will be highlight. * **major** version upgrade will include breaking changes. #### Way to upgrade [#way-to-upgrade] ``` flutter pub upgrade ``` [^1]: # Android/iOS Permission Grants (/docs/sdk/lessons/installation/install-gofa_lessons-sdk/android-ios-permission-grants) There are the permissions we require device users to grant our SDK to use the functionalities. ## Android Permissions [#android-permissions] Add the following permission settings to android/app/src/main/AndroidManifest.xml ```xml ``` ## iOS Permissions [#ios-permissions] Add the following permission settings to ios/Runner/Info.plist ```xml NSCameraUsageDescription Camera is used to capture user movement during fitness workouts NSMicrophoneUsageDescription Camera is used to capture user movement during fitness workouts ``` # Device Requirements (/docs/sdk/lessons/installation/install-gofa_lessons-sdk/device-requirements) **Minimum Device Requirements:** * **OS Version:** * **Android:** 9.0 (Pie) with SDK level 28 * **iOS:** 11 or higher * **CPU:** Quad-core processor * **RAM:** 4GB * **Disk Space:** 200MB free space * **Network Connection:** Stable 4G **Recommended Device Requirements:** * **OS Version:** * **Android:** 10.0 (Q) or higher with SDK level 31 or higher * **iOS:** 13 or higher * **CPU:** Octa-core processor * **RAM:** 6GB or more * **Disk Space:** 500MB free space * **Network Connection:** Stable 4G or 5G **Flutter Android SDK Versions:** * **Minimum SDK Version:** 28 (Android 9.0 Pie) * **Recommended SDK Version:** 31 or higher (Android 10.0 Q or higher) # Install "gofa_lessons" sdk (/docs/sdk/lessons/installation/install-gofa_lessons-sdk) ## Add "gofa\_lessons" [#add-gofa_lessons] ### Approach 1: OnePub [#approach-1-onepub] * [ ] Please contact GOFA Team to gain member access to our OnePub package. You will need to give us the developer email to grant you the access. * [ ] To install onepub run: ```bash dart pub global activate onepub onepub login ``` The `onepub login` command will land you to the login page in your browser. Use the developer email to log in. * [ ] Run this command: ```csharp onepub pub add gofa_lessons ``` * [ ] Verify that lines like these are added to your package's pubspec.yaml (and run dart pub get or flutter pub get): ```yaml dependencies: gofa_lessons: hosted: https://onepub.dev/api/fvnuhnofly/ version: "^1.0.0" # you may want to communicate with GOFA DEV team # to get the recommended version range # because the gofa_lessons is still under development ``` ## Upgrade "gofa\_lessons" [#upgrade-gofa_lessons] We will publish version upgrades of "gofa\_lessons" package from time to time. Right now, our version is still v0.x.x. so we will publish breaking changes even in patch version. After we launching v1 of "gofa\_lessons", we will follow the rules for publishing new changes: * **patch** version upgrade only fix bugs and improve features **without breaking changes** * **minor** version upgrade **may or may not include breaking changes**. * The changes will be listed in the CHANGELOG.md * Breaking changes will be highlight. * **major** version upgrade will include breaking changes. #### Way to upgrade [#way-to-upgrade] ``` flutter pub upgrade ``` [^1]: # EventData (/docs/sdk/lessons/data-exchange/in-app-callback-events/eventdata) ```dart abstract class EventData { final String? clientUserId; EventData({required this.clientUserId}); // toJson Map toJson(); } ``` # About In-app Callback Events (/docs/sdk/lessons/data-exchange/in-app-callback-events) ```dart Function updateCallback ``` Example of how you can pass the callback function for us to trigger whenever any update events occur. ```dart GetIt.I.registerSingletonAsync( () async => await LessonSdkManager().init( ... updateCallback: lessonSdkUpdateCallback, // <---- pass us the callback here ), ); ``` Here are the events. You may refer to [updatecallbackevent.md](/docs/sdk/lessons/data-exchange/in-app-callback-events/updatecallbackevent "mention"). ```dart enum UpdateCallbackEvent { lessonStarted, lessonCancelled, lessonFinished, planStarted, planCancelled, planFinished, } ``` ```dart void lessonSdkUpdateCallback( UpdateCallbackEvent eventName, EventData eventData, ) { log.d("lessonSdkUpdateCallback. eventName: $eventName"); final clientUserId = eventData.clientUserId; switch (eventName) { case UpdateCallbackEvent.lessonStarted: final lessonStartedData = eventData as LessonStartedData; final lessonId = lessonStartedData.lessonId; final lesson = lessonStartedData.lesson; final playId = lessonStartedData.playId; final play = lessonStartedData.play; final planId = lessonStartedData.planId; final plan = lessonStartedData.plan; final planPlayId = lessonStartedData.planPlayId; final planPlay = lessonStartedData.planPlay; // do your action here... log.d('[callback] LessonStart. clientUserId: $clientUserId'); log.d('[callback] LessonStart. lessonId: $lessonId'); log.d('[callback] LessonStart. lesson: $lesson'); log.d('[callback] LessonStart. playId: $playId'); log.d('[callback] LessonStart. play: $play'); log.d('[callback] LessonStart. planId: $planId'); log.d('[callback] LessonStart. plan: $plan'); log.d('[callback] LessonStart. planPlayId: $planPlayId'); log.d('[callback] LessonStart. planPlay: $planPlay'); break; case UpdateCallbackEvent.lessonCancelled: final lessonCancelledData = eventData as LessonCancelledData; final lessonId = lessonCancelledData.lessonId; final lesson = lessonCancelledData.lesson; final playId = lessonCancelledData.playId; final play = lessonCancelledData.play; final planId = lessonCancelledData.planId; final plan = lessonCancelledData.plan; final planPlayId = lessonCancelledData.planPlayId; final planPlay = lessonCancelledData.planPlay; // do your action here... // log the userId, lessonId, and playId log.d('[callback] LessonCancel. clientUserId: $clientUserId'); log.d('[callback] LessonCancel. lessonId: $lessonId'); log.d('[callback] LessonCancel. lesson: $lesson'); log.d('[callback] LessonCancel. playId: $playId'); log.d('[callback] LessonCancel. play: $play'); log.d('[callback] LessonCancel. planId: $planId'); log.d('[callback] LessonCancel. plan: $plan'); log.d('[callback] LessonCancel. planPlayId: $planPlayId'); log.d('[callback] LessonCancel. planPlay: $planPlay'); break; case UpdateCallbackEvent.lessonFinished: final lessonFinishedData = eventData as LessonFinishedData; final lessonId = lessonFinishedData.lessonId; final lesson = lessonFinishedData.lesson; final playId = lessonFinishedData.playId; final play = lessonFinishedData.play; final planId = lessonFinishedData.planId; final plan = lessonFinishedData.plan; final planPlayId = lessonFinishedData.planPlayId; final planPlay = lessonFinishedData.planPlay; // do your action here... // log the userId, lessonId, and playId log.d('[callback] LessonFinish. clientUserId: $clientUserId'); log.d('[callback] LessonFinish. lessonId: $lessonId'); log.d('[callback] LessonFinish. lesson: $lesson'); log.d('[callback] LessonFinish. playId: $playId'); log.d('[callback] LessonFinish. play: $play'); log.d('[callback] LessonFinish. planId: $planId'); log.d('[callback] LessonFinish. plan: $plan'); log.d('[callback] LessonFinish. planPlayId: $planPlayId'); log.d('[callback] LessonFinish. planPlay: $planPlay'); break; case UpdateCallbackEvent.planStarted: final planStartedData = eventData as PlanStartedData; final planId = planStartedData.planId; final plan = planStartedData.plan; final planPlayId = planStartedData.planPlayId; final planPlay = planStartedData.planPlay; // do your action here... log.d('[callback] PlanStart. clientUserId: $clientUserId'); log.d('[callback] PlanStart. planId: $planId'); log.d('[callback] PlanStart. plan: $plan'); log.d('[callback] PlanStart. planPlayId: $planPlayId'); log.d('[callback] PlanStart. planPlay: $planPlay'); break; case UpdateCallbackEvent.planCancelled: final planCancelledData = eventData as PlanCancelledData; final planId = planCancelledData.planId; final plan = planCancelledData.plan; final planPlayId = planCancelledData.planPlayId; final planPlay = planCancelledData.planPlay; // do your action here...` log.d('[callback] PlanCancel. clientUserId: $clientUserId'); log.d('[callback] PlanCancel. planId: $planId'); log.d('[callback] PlanCancel. plan: $plan'); log.d('[callback] PlanCancel. planPlayId: $planPlayId'); log.d('[callback] PlanCancel. planPlay: $planPlay'); break; case UpdateCallbackEvent.planFinished: final planFinishedData = eventData as PlanFinishedData; final planId = planFinishedData.planId; final plan = planFinishedData.plan; final planPlayId = planFinishedData.planPlayId; final planPlay = planFinishedData.planPlay; // do your action here... log.d('[callback] PlanFinish. clientUserId: $clientUserId'); log.d('[callback] PlanFinish. planId: $planId'); log.d('[callback] PlanFinish. plan: $plan'); log.d('[callback] PlanFinish. planPlayId: $planPlayId'); log.d('[callback] PlanFinish. planPlay: $planPlay'); break; default: break; } storeCallbackInSharedPreferences( clientUserId: clientUserId, eventName: eventName, eventData: eventData, ); } ``` Here is an example how you may want to implement the `updateCallback`: # LessonCancelledData (/docs/sdk/lessons/data-exchange/in-app-callback-events/lessoncancelleddata) The data of the event `lessonCancelled`, triggered when the user ends the lesson prematurely. **Note:** that this does not automatically mean the user has failed the lesson; substantial lesson progress can still contribute to a pass (indicated by `passed: true`). ```dart import '../../gofa_lessons.dart'; class LessonCancelledData extends EventData { final String lessonId; final Lesson? lesson; final String playId; final Play play; final String? planId; final Plan? plan; final String? planPlayId; final PlanPlay? planPlay; LessonCancelledData({ required String? clientUserId, required this.lessonId, required this.lesson, required this.playId, required this.play, required this.planId, required this.plan, required this.planPlayId, required this.planPlay, }) : super(clientUserId: clientUserId); // toJson method @override Map toJson() { return { 'clientUserId': clientUserId, 'lessonId': lessonId, 'lesson': lesson?.toJson(), 'playId': playId, 'play': play.toJson(), 'planId': planId, 'plan': plan?.toJson(), 'planPlayId': planPlayId, 'planPlay': planPlay?.toJson(), }; } } ``` # LessonFinishedData (/docs/sdk/lessons/data-exchange/in-app-callback-events/lessonfinisheddata) The data of the event `lessonFinished`, triggered when the user completes the entire lesson, confirming full participation. ```dart import '../../gofa_lessons.dart'; class LessonFinishedData extends EventData { final String lessonId; final Lesson? lesson; final String playId; final Play play; final String? planId; final Plan? plan; final String? planPlayId; final PlanPlay? planPlay; LessonFinishedData({ required String? clientUserId, required this.lessonId, required this.lesson, required this.playId, required this.play, required this.planId, required this.plan, required this.planPlayId, required this.planPlay, }) : super(clientUserId: clientUserId); // toJson method @override Map toJson() { return { 'clientUserId': clientUserId, 'lessonId': lessonId, 'lesson': lesson?.toJson(), 'playId': playId, 'play': play.toJson(), 'planId': planId, 'plan': plan?.toJson(), 'planPlayId': planPlayId, 'planPlay': planPlay?.toJson(), }; } } ``` # LessonStartedData (/docs/sdk/lessons/data-exchange/in-app-callback-events/lessonstarteddata) The data of the event `lessonStarted` which is triggered when entering the lesson calibration screen, marking the beginning of a lesson. ```dart import '../../gofa_lessons.dart'; class LessonStartedData extends EventData { final String lessonId; final Lesson? lesson; final String playId; final Play play; final String? planId; final Plan? plan; final String? planPlayId; final PlanPlay? planPlay; LessonStartedData({ required String? clientUserId, required this.lessonId, required this.lesson, required this.playId, required this.play, required this.planId, required this.plan, required this.planPlayId, required this.planPlay, }) : super(clientUserId: clientUserId); // toJson method @override Map toJson() { return { 'clientUserId': clientUserId, 'lessonId': lessonId, 'lesson': lesson?.toJson(), 'playId': playId, 'play': play.toJson(), 'planId': planId, 'plan': plan?.toJson(), 'planPlayId': planPlayId, 'planPlay': planPlay?.toJson(), }; } } ``` # PlanCancelledData (/docs/sdk/lessons/data-exchange/in-app-callback-events/plancancelleddata) The data of the event `planCancelled`, triggered when the user resets the plan using the reset button on the plan detail screen, indicating plan progress cancellation ```dart import '../../gofa_lessons.dart'; class PlanCancelledData extends EventData { final String planId; final Plan? plan; final String planPlayId; final PlanPlay? planPlay; PlanCancelledData({ required String? clientUserId, required this.planId, required this.plan, required this.planPlayId, required this.planPlay, }) : super(clientUserId: clientUserId); // toJson method @override Map toJson() { return { 'clientUserId': clientUserId, 'planId': planId, 'plan': plan?.toJson(), 'planPlayId': planPlayId, 'planPlay': planPlay?.toJson(), }; } } ``` # PlanFinishedData (/docs/sdk/lessons/data-exchange/in-app-callback-events/planfinisheddata) The data of the event `planFinished`, triggered when the user completes the last lesson in the plan, marking the end of the lesson plan. ```dart import '../../gofa_lessons.dart'; class PlanFinishedData extends EventData { final String planId; final Plan? plan; final String planPlayId; final PlanPlay planPlay; PlanFinishedData({ required String? clientUserId, required this.planId, required this.plan, required this.planPlayId, required this.planPlay, }) : super(clientUserId: clientUserId); // toJson method @override Map toJson() { return { 'clientUserId': clientUserId, 'planId': planId, 'plan': plan?.toJson(), 'planPlayId': planPlayId, 'planPlay': planPlay.toJson(), }; } } ``` # PlanStartedData (/docs/sdk/lessons/data-exchange/in-app-callback-events/planstarteddata) The data of the event `planStarted`, triggered when the user first starts the lesson plan. ```dart import '../../gofa_lessons.dart'; class PlanStartedData extends EventData { final String planId; final Plan plan; final String planPlayId; final PlanPlay planPlay; PlanStartedData({ required String? clientUserId, required this.planId, required this.plan, required this.planPlayId, required this.planPlay, }) : super(clientUserId: clientUserId); // toJson method @override Map toJson() { return { 'clientUserId': clientUserId, 'planId': planId, 'plan': plan, 'planPlayId': planPlayId, 'planPlay': planPlay, }; } } ``` # UpdateCallbackEvent (/docs/sdk/lessons/data-exchange/in-app-callback-events/updatecallbackevent) The `UpdateCallbackEvent` enum defines various events that occur during the lessons and plans in the GOFA Lessons SDK. Below are the specific events along with their trigger points: ``` enum UpdateCallbackEvent { lessonStarted, lessonCancelled, lessonFinished, planStarted, planCancelled, planFinished, } ``` #### 1. lessonStarted [#1-lessonstarted-] **Trigger Point**: This event is triggered when the user enters the lesson calibration screen, marking the beginning of a lesson. #### 2. lessonCancelled [#2-lessoncancelled-] **Trigger Point**: This event is activated when the user ends a lesson prematurely. **Note:** that this does not automatically mean the user has failed the lesson; substantial lesson progress can still contribute to a pass (indicated by `passed: true`). #### 3. lessonFinished [#3-lessonfinished-] **Trigger Point**: This event is fired when the user completes the entire lesson, meaning the user watches until the last second of the lesson video. #### 4. planStarted [#4-planstarted-] **Trigger Point**: This event is triggered when the user first starts the lesson plan. #### 5. planCancelled [#5-plancancelled-] **Trigger Point**: This event is activated when the user resets the plan using the reset button on the plan detail screen. #### 6. planFinished [#6-planfinished-] **Trigger Point**: This event is triggered when the user completes the last lesson in the lesson plan. # How to get "Rep Count"? (/docs/sdk/challenge/installation/workout-set-state-data/how-to-get-rep-count) ## Approach 1: using BlocSelector in Widget's build method [#approach-1-using-blocselector-in-widgets-build-method] ```dart BlocSelector( selector: (state) { return state.repsDone; }, builder: (context, state) { return Text( "${state!}", style: GoogleFonts.openSans( color: Colors.black, fontSize: 58, fontWeight: FontWeight.w700, ), ); }), ``` ## Approach 2: using BlocProvider.of [#approach-2-using-blocproviderof] ```dart BlocProvider.of(context).state.repsDone ``` # Workout Set State Data (/docs/sdk/challenge/installation/workout-set-state-data) Our WorkoutSetBloc maintains the WorkoutSetState which contains all the data you will need for basic workout settings or real-time workout set analytic results. ## WorkoutSetState [#workoutsetstate] ```dart class WorkoutSetState { /// workoutItem, the workout set final PoseItem? workoutItem; /// totalSecondsAllowed, total seconds allowed for the workout set final int? totalSecondsAllowed; /// secondsLeft, seconds left for the workout set final int? secondsLeft; /// secondsUsed, seconds used for the workout set /// getter to get the seconds used int? get secondsUsed { if (totalSecondsAllowed == null || secondsLeft == null) { return null; } return totalSecondsAllowed! - (secondsLeft! < 0 ? 0 : secondsLeft!); } /// targetRepsToStart, target reps to start the workout set final int? targetRepsToStart; /// repsDone, reps done for the workout set final int repsDone; /// workoutSetStatus, the status of the workout set final WorkoutSetStatus? workoutSetStatus; /// readyCountDown, the ready count down for the workout set final ReadyState? readyCountDown; /// resumeCountDown, the ready count down for the workout set final ResumeState? resumeCountDown; /// repsLeft, reps left for the workout set int? get repsLeft { if (targetRepsToStart == null) { return null; } return targetRepsToStart! - repsDone; } /// isShowDebugInfo, is show debug info for the workout set final bool isShowDebugInfo; /// autoStopSetWhenRepsFulfilled, is stop set after reps fulfilled for the workout set /// if true, the workout set will stop after the reps fulfilled /// if false, the workout set will stop after the time is up /// default is false final bool autoStopSetWhenRepsFulfilled; /// isRepsFulfilled, is reps fulfilled for the workout set final bool isRepsFulfilled; /// Ready mode: in-frame ready or button ready final ReadyMode readyMode; .... } ``` ## PoseItem [#poseitem] only poseName is required when you pass the PoseItem in WorkoutSetSetup event. ```dart class PoseItem { final String poseName; final String? imageUrl; PoseItem({ required this.poseName, this.imageUrl, }); ```