# AI Trainer Chat

The **AI Personal Trainer** is a guided chat experience that walks the user through a personalized session: profile setup (with an optional camera-based fitness assessment) → readiness check-in → workout → post-workout check-in → scheduling the next session.

Your app only needs to mount the trainer view, optionally pre-fill user data, and listen for the events below — most importantly `trainer_schedule_next_workout`, which fires when the user picks a date/time for their next session and is the trigger for your reminder logic. Optionally, you can also gate workout generation behind your subscription flow and keep the fitness profile in sync with your app.

Prefer to build your own chat UI? The same intelligence is available over REST — see the [AI Trainer API](/docs/trainer-api).

## Requirements

Camera permission, an internet connection, and a recent SDK that supports the Trainer Chat integration:

| Platform | Minimum SDK |
| --- | --- |
| Swift (iOS) | `KinesteXAIKit` ≥ **1.1.3** — iOS 13+, Swift 5.5+, SwiftUI. ≥ **1.1.4** for subscription gating (adds the `workoutAction` binding) |
| Kotlin (Android) | `KinesteX-SDK-Kotlin` ≥ **2.0.6** |
| React Native | `kinestex-sdk-react-native` ≥ **1.2.9** |
| Flutter | `kinestex_sdk_flutter` ≥ **1.4.7** |
| React TS (Web) | `kinestex-sdk-react-ts` ≥ **0.0.3** |
| HTML & JS | iframe at `https://ai.kinestex.com/trainer` (HTTPS host page required) |

If you haven't installed the SDK yet, follow the [installation guide](/docs/installation) first — only trainer-specific concerns are covered below.

## Launching the Trainer

Mount the trainer with the **AI_TRAINER_CHAT** integration option (or, on Swift / Kotlin / Flutter, call `createTrainerChatView`). The trainer flow is rendered entirely inside the SDK.

_Swift (iOS)_
```swift
import SwiftUI
import KinesteXAIKit

struct TrainerScreen: View {
    @State private var isLoading = false
    @State private var showKinesteX = true

    // KinesteXAIKit is configured per-instance — no global initialize call.
    private let kit = KinesteXAIKit(
        apiKey: "<YOUR_API_KEY>",
        companyName: "<YOUR_COMPANY>",
        userId: "<USER_ID>"
    )

    var body: some View {
        if showKinesteX {
            kit.createTrainerChatView(
                user: nil,                       // or your UserDetails — see below
                style: IStyle(style: "dark"),
                isLoading: $isLoading,
                onMessageReceived: handleMessage
            )
        }
    }

    private func handleMessage(_ message: KinestexMessage) { /* see below */ }
}
```

_Kotlin (Android)_
```kotlin
import com.kinestex.kinestexsdkkotlin.KinesteXSDK
import com.kinestex.kinestexsdkkotlin.PermissionHandler
import com.kinestex.kinestexsdkkotlin.models.IStyle
import com.kinestex.kinestexsdkkotlin.models.WebViewMessage
import kotlinx.coroutines.flow.MutableStateFlow

// 1. Once at app start (Application.onCreate):
class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        KinesteXSDK.initialize(
            context = this,
            apiKey = "<YOUR_API_KEY>",
            companyName = "<YOUR_COMPANY>",
            userId = "<USER_ID>"
        )
    }
}

// 2. In your Activity / Fragment (must implement PermissionHandler):
private val isLoading = MutableStateFlow(false)

private fun mountTrainer() {
    val webView = KinesteXSDK.createTrainerChatView(
        context = this,
        style = IStyle(style = "dark"),
        isLoading = isLoading,
        onMessageReceived = ::handleWebViewMessage,
        permissionHandler = this
    )
    container.addView(webView) // attach to your view hierarchy
}
```

_React Native_
```jsx
import KinestexSDK from "kinestex-sdk-react-native";
import { IPostData, IntegrationOption } from "kinestex-sdk-react-native/src/types";

const postData: IPostData = {
  key: "<YOUR_API_KEY>",
  company: "<YOUR_COMPANY>",
  userId: "<USER_ID>",
  style: { style: "dark" },
};

<KinestexSDK
  data={postData}
  integrationOption={IntegrationOption.AI_TRAINER_CHAT}
  handleMessage={handleMessage}
/>
```

_Flutter_
```dart
// Once at app start (e.g. in main()):
await KinesteXAIFramework.initialize(
  apiKey: '<YOUR_API_KEY>',
  companyName: '<YOUR_COMPANY>',
  userId: '<USER_ID>',
);

// Then in your widget tree:
final showKinesteX = ValueNotifier<bool>(true);
final isLoading = ValueNotifier<bool>(false);

KinesteXAIFramework.createTrainerChatView(
  style: IStyle(style: 'dark'),
  isShowKinestex: showKinesteX,
  isLoading: isLoading,
  onMessageReceived: handleWebViewMessage,
);
```

_HTML / JavaScript_
```html
<!-- 1. Iframe container (matches the standard KinesteX install). -->
<div id="webViewContainer" style="position: fixed; inset: 0;">
  <iframe
    id="webView"
    frameborder="0"
    allow="camera; autoplay; accelerometer; gyroscope; magnetometer"
    sandbox="allow-same-origin allow-scripts"
    allowfullscreen="true"
    style="width: 100%; height: 100%;"
  ></iframe>
</div>

<script>
  const webView = document.getElementById("webView");
  const srcURL = "https://ai.kinestex.com/trainer"; // Dedicated trainer route.

  // 2. Config payload. Add age/height/weight/gender to skip those questions.
  const postData = {
    key: "<YOUR_API_KEY>",
    company: "<YOUR_COMPANY>",
    userId: "<USER_ID>",
    integration: "AI_TRAINER_CHAT",
    style: "dark",
    // Optional pre-fill — see "Pre-filling user data" below.
    // age: 32, height: 178, weight: 76, gender: "male",
  };

  // 3. Retry-aware send: iOS occasionally drops the first postMessage,
  //    so we re-send on `kinestex_loaded` (handled in step 5).
  function sendMessage() {
    if (!webView.contentWindow) return;
    try {
      webView.contentWindow.postMessage(postData, srcURL);
    } catch {
      setTimeout(() => webView.contentWindow.postMessage(postData, srcURL), 100);
    }
  }

  // 4. Load the iframe and send config when ready.
  webView.src = srcURL;
  webView.onload = sendMessage;

  // 5. Listen for events. Messages arrive as JSON strings — validate the
  //    origin before trusting anything, then parse.
  window.addEventListener("message", (event) => {
    if (event.origin !== "https://ai.kinestex.com") return;

    let message;
    try {
      message = JSON.parse(event.data);
    } catch {
      return; // ignore non-JSON messages
    }

    // Re-send config once the iframe confirms it loaded (iOS quirk).
    if (message.type === "kinestex_loaded") {
      sendMessage();
      return;
    }

    handleMessage(message.type, message.data);
  });
</script>
```

_React (TypeScript)_
```tsx
import KinestexSDK, { IntegrationOption } from "kinestex-sdk-react-ts";

export function TrainerChat() {
  return (
    // The component fills its parent — give the wrapper an explicit size.
    <div style={{ width: "100%", height: "100vh" }}>
      <KinestexSDK
        integrationOption={IntegrationOption.AI_TRAINER_CHAT}
        data={{
          key: "<YOUR_API_KEY>",
          company: "<YOUR_COMPANY>",
          userId: "<USER_ID>",
        }}
        handleMessage={handleMessage}
      />
    </div>
  );
}
```

## Pre-filling user data

The trainer asks profile questions on first use. You can skip the **basic demographics** by passing them when you mount the SDK — anything you omit, the trainer will simply ask for.

| Field | Unit / Allowed values |
| --- | --- |
| `age` | years |
| `height` | **cm** (UI lets users switch to imperial after) |
| `weight` | **kg** |
| `gender` | `"male"` or `"female"` |

Don't pass empty strings or zero — omit the field instead. On **Swift**, **Kotlin**, and **Flutter** the `UserDetails` initializer requires **all** fields, so either pass the complete value or skip `user:` entirely (`nil` on Swift, omit on Kotlin/Flutter).

**Fitness-profile prefill.** Beyond demographics, you can pre-fill the trainer's fitness profile via `customParams` so returning users skip questions you already have answers for. The field names match the `trainer_profile_updated` event exactly:

| Field | Type | Notes |
| --- | --- | --- |
| `fitness_goals` | string[] | `"weight_loss"`, `"muscle_gain"`, `"strength"`, `"general_fitness"`, `"wellness_flexibility"`, `"cardio_endurance"` |
| `injuries` | object[] | `{ "body_part", "preference": "avoid" \| "include", "severity": "light" \| "moderate" \| "severe" }` |
| `health_conditions` | string[] | e.g. `["Asthma"]` |
| `fitness_level_squats` / `_pushups` / `_cardio` | string | Descriptive levels; the in-app assessment sets the first two automatically |
| `other_preferences` | string | Free text, passed to the AI verbatim |

The trainer merges what you pass into the stored profile (your values win). Only send fields that hold real values — never placeholders, empty strings, or zeros.

_Swift (iOS)_
```swift
// UserDetails on Swift requires ALL five fields — pass the full value when
// you know everything, otherwise pass `user: nil` and let the trainer ask.
let user = UserDetails(
    age: 32,
    height: 178,         // cm
    weight: 76,          // kg
    gender: .Male,
    lifestyle: .Active   // not used in the trainer UI, but required by the initializer
)

kit.createTrainerChatView(
    user: user,
    style: IStyle(style: "dark"),
    isLoading: $isLoading,
    onMessageReceived: handleMessage
)
```

_Kotlin (Android)_
```kotlin
import com.kinestex.kinestexsdkkotlin.models.Gender
import com.kinestex.kinestexsdkkotlin.models.Lifestyle
import com.kinestex.kinestexsdkkotlin.models.UserDetails

// UserDetails on Kotlin requires ALL five fields — pass the full object when
// you know everything, otherwise omit `user:` and let the trainer ask.
val user = UserDetails(
    age = 32,
    height = 178,             // cm
    weight = 76,              // kg
    gender = Gender.MALE,
    lifestyle = Lifestyle.ACTIVE   // not used in the trainer UI, but required by the constructor
)

KinesteXSDK.createTrainerChatView(
    context = this,
    user = user,
    style = IStyle(style = "dark"),
    isLoading = isLoading,
    onMessageReceived = ::handleWebViewMessage,
    permissionHandler = this
)
```

_React Native_
```jsx
const postData: IPostData = {
  key: "<YOUR_API_KEY>",
  company: "<YOUR_COMPANY>",
  userId: "<USER_ID>",
  age: 32,
  height: 178,   // cm
  weight: 76,    // kg
  gender: "male",
  style: { style: "dark" },
};
```

_Flutter_
```dart
// UserDetails on Flutter requires ALL fields — pass the full object when
// you know everything, otherwise omit `user:` and let the trainer ask.
final user = UserDetails(
  age: 32,
  height: 178,                  // cm
  weight: 76,                   // kg
  gender: Gender.Male,
  lifestyle: Lifestyle.Active,  // not used in the trainer UI, but required by the constructor
);

KinesteXAIFramework.createTrainerChatView(
  user: user,
  style: IStyle(style: 'dark'),
  isShowKinestex: showKinesteX,
  isLoading: isLoading,
  onMessageReceived: handleWebViewMessage,
);
```

_HTML / JavaScript_
```html
// Add the demographic fields to the same postData you already send.
const postData = {
  key: "<YOUR_API_KEY>",
  company: "<YOUR_COMPANY>",
  userId: "<USER_ID>",
  integration: "AI_TRAINER_CHAT",
  age: 32,
  height: 178,   // cm
  weight: 76,    // kg
  gender: "male",
  style: "dark",
};
```

_React (TypeScript)_
```tsx
<KinestexSDK
  integrationOption={IntegrationOption.AI_TRAINER_CHAT}
  data={{
    key: "<YOUR_API_KEY>",
    company: "<YOUR_COMPANY>",
    userId: "<USER_ID>",
    age: 32,
    height: 178,   // cm
    weight: 76,    // kg
    gender: "male",
  }}
  handleMessage={handleMessage}
/>
```

## Branding & appearance

Two optional `customParams` rebrand the trainer without any KinesteX involvement:

| Key | Type | Effect |
| --- | --- | --- |
| `aiTrainerName` | string | Renames the AI trainer everywhere it's labelled — the header title and the label above each reply. Shown verbatim in every language. Example: `"My Coach"` |
| `aiTrainerColor` | string | Recolors the trainer's star icon — any CSS color, e.g. `"#7C3AED"` |

The overall theme (colors, fonts) is configured by KinesteX per company — send your KinesteX contact the palette you want, or manage it via your [white-label theme](/docs/customization-parameters).

## Handling messages

The trainer emits the standard SDK events plus its own trainer-specific ones. All of them arrive as JSON with a snake_case `type` — on Swift/Kotlin/Flutter they arrive through the generic `custom_type` / `CustomType` case you already handle:

- `workout_exit_request` — the user exited a workout that was launched from the trainer.
- `trainer_schedule_next_workout` — the user picked a date/time for their next session or assessment (see below).
- `open_subscription_flow` — a non-subscribed user tried to generate a workout (see [Subscription gating](#subscriptions)).
- `trainer_assessment_started` / `trainer_assessment_completed` / `trainer_assessment_skipped` — in-app fitness assessment lifecycle (see [Assessment & profile sync](#profile-sync)).
- `trainer_profile_updated` — the user's fitness profile changed; persist it on your side (see [Assessment & profile sync](#profile-sync)).

Add the new cases to your existing handler.

_Swift (iOS)_
```swift
private func handleMessage(_ message: KinestexMessage) {
    switch message {
    case .exit_kinestex:
        // User exited the trainer screen via the back button.
        showKinesteX = false

    case .error_occurred(let data):
        print("KinesteX error:", data)

    // `trainer_schedule_next_workout` is not yet a typed case — it
    // arrives as .custom_type, so check the `type` field on the payload.
    case .custom_type(let data):
        guard let type = data["type"] as? String else { return }
        switch type {
        case "workout_exit_request":
            // User exited a workout that was launched from the trainer.
            break
        case "trainer_schedule_next_workout":
            // payload: { type, scheduledFor: "YYYY-MM-DDTHH:MM" }
            if let scheduledFor = data["scheduledFor"] as? String {
                scheduleNextWorkoutReminder(scheduledFor: scheduledFor)
            }
        default:
            break
        }

    default:
        break
    }
}
```

_Kotlin (Android)_
```kotlin
private fun handleWebViewMessage(message: WebViewMessage) {
    when (message) {
        is WebViewMessage.ExitKinestex -> {
            finish() // or hide your container
        }
        is WebViewMessage.ErrorOccurred -> {
            Log.e("KinesteX", "error: ${message.data}")
        }
        is WebViewMessage.WorkoutExitRequest -> {
            // User exited a workout that was launched from the trainer.
        }

        // `trainer_schedule_next_workout` is not yet a typed subclass — it
        // arrives as CustomType, so check the `type` field on message.data.
        is WebViewMessage.CustomType -> {
            val type = message.data["type"] as? String
            val scheduledFor = message.data["scheduledFor"] as? String
            if (type == "trainer_schedule_next_workout" && scheduledFor != null) {
                scheduleNextWorkoutReminder(this, scheduledFor)
            }
        }

        else -> { /* other events */ }
    }
}
```

_React Native_
```jsx
const handleMessage = (type: string, data: { [key: string]: any }) => {
  switch (type) {
    case "exit_kinestex":
      // User exited the trainer screen via the back button.
      break;
    case "workout_exit_request":
      // User exited a workout that was launched from the trainer.
      break;
    case "trainer_schedule_next_workout":
      // NEW — see "Scheduling the next workout" below.
      // data: { type, scheduledFor: "YYYY-MM-DDTHH:MM" }
      scheduleNextWorkoutReminder(data.scheduledFor);
      break;
    case "error_occurred":
      console.warn("KinesteX error", data);
      break;
  }
};
```

_Flutter_
```dart
void handleWebViewMessage(WebViewMessage message) {
  if (message is ExitKinestex) {
    showKinesteX.value = false;
    return;
  }
  if (message is ErrorOccurred) {
    debugPrint('KinesteX error: ${message.data}');
    return;
  }

  // `trainer_schedule_next_workout` is not yet a typed subclass — it
  // arrives as CustomType, so check the `type` field on message.data.
  if (message is CustomType) {
    final type = message.data['type'] as String?;
    final scheduledFor = message.data['scheduledFor'] as String?;
    if (type == 'trainer_schedule_next_workout' && scheduledFor != null) {
      scheduleNextWorkoutReminder(scheduledFor);
    }
  }
}
```

_HTML / JavaScript_
```html
function handleMessage(type, data) {
  switch (type) {
    case "exit_kinestex":
      // User exited the trainer (e.g. close the modal hosting the iframe).
      break;
    case "workout_exit_request":
      // User exited a workout that was launched from the trainer.
      break;
    case "trainer_schedule_next_workout":
      // NEW — see "Scheduling the next workout" below.
      // data: { type, scheduledFor: "YYYY-MM-DDTHH:MM" }
      scheduleNextWorkoutReminder(data.scheduledFor);
      break;
    case "error_occurred":
      console.warn("KinesteX error", data);
      break;
  }
}
```

_React (TypeScript)_
```tsx
const handleMessage = (type: string, payload: Record<string, any>) => {
  switch (type) {
    case "exit_kinestex":
      setShowTrainer(false);
      break;
    case "workout_exit_request":
      // User exited a workout that was launched from the trainer.
      break;
    case "trainer_schedule_next_workout":
      // NEW — see "Scheduling the next workout" below.
      // payload: { type, scheduledFor: "YYYY-MM-DDTHH:MM" }
      scheduleNextWorkoutReminder(payload.scheduledFor);
      break;
    case "error_occurred":
      console.warn("KinesteX error", payload);
      break;
  }
};
```

## Subscription gating

Workout **generation** can be gated behind your app's subscription; browsing, onboarding, the free fitness assessment, and Q&A stay available to everyone. KinesteX never shows a paywall and never processes payments — your app owns the purchase flow.

- Pass `isSubscribed` in `customParams` on every mount. Missing or `true` = subscribed; only an explicit `false` gates generation.
- When a non-subscriber taps **Generate workout**, the SDK parks the request and emits `open_subscription_flow` — overlay your subscription screen on top of the trainer view.
- When your flow closes, post `{ "subscription_result": "purchased" }` or `{ "subscription_result": "dismissed" }` back into the SDK (Swift: the `workoutAction` binding, ≥ 1.1.4; Kotlin: `sendAction`). On `"purchased"` the parked generation resumes automatically — no second tap.
- Standalone/link integrations with no host app can pass `subscriptionReturnUrl` instead — the trainer redirects the browser there rather than posting the event.

Full walkthrough with per-platform code, edge cases, and a launch checklist: [Subscription gating guide](/docs/guides/guide-subscription-gating). To enforce the status server-side (so clients can't bypass it), see [Session Auth & Managed Subscriptions](/docs/trainer-api/trainer-api-subscriptions).

## Assessment & profile sync

**In-app fitness assessment.** During onboarding the trainer offers a guided, camera-based squat and push-up assessment (30 seconds each) instead of self-reporting fitness levels — results set the user's measured fitness level automatically. Users can also tap **Set manually** or schedule the assessment for later. The lifecycle is reported to your app (dates are `DD MM YYYY HH:mm:ss` in device-local time — informational, don't parse them for logic):

```json
{ "type": "trainer_assessment_started", "date": "09 07 2026 14:52:10" }
{ "type": "trainer_assessment_skipped", "reason": "set_manually", "date": "09 07 2026 14:52:10" }
{
  "type": "trainer_assessment_completed",
  "date": "09 07 2026 14:52:10",
  "results": {
    "squats":  { "reps": 21, "level": "intermediate" },
    "pushups": { "reps": 14, "level": "intermediate" }
  },
  "fitnessLevel": "intermediate"
}
```

`level` and `fitnessLevel` are `"beginner"`, `"intermediate"`, or `"advanced"`.

**Profile sync (KinesteX → your app).** Every profile change — onboarding answers, assessment results, edits the user makes in chat ("my knee hurts", "I'm 70kg now") — fires `trainer_profile_updated` with the **full current profile**:

```json
{
  "type": "trainer_profile_updated",
  "source": "assessment",
  "profile": {
    "age": 29,
    "height": 172,
    "weight": 68,
    "gender": "female",
    "fitness_goals": ["weight_loss", "cardio_endurance"],
    "fitness_level_squats": "I can do 21 squats in 30 seconds",
    "fitness_level_pushups": "I can do 14 push ups in 30 seconds",
    "fitness_level_cardio": "I can run 1.6km (1 mile) with walking breaks",
    "injuries": [
      { "body_part": "lower_back", "preference": "avoid", "severity": "moderate" }
    ],
    "health_conditions": [],
    "other_preferences": "Prefers morning workouts",
    "updated_at": "2026-07-06T14:52:10Z"
  }
}
```

`source` is `"onboarding"`, `"assessment"`, or `"chat"`. Persist the whole `profile` object keyed by user and store `updated_at` — last write wins if both sides edit the same field. Empty arrays/strings are always included so you receive the complete current profile.

**Your app → KinesteX.** No runtime call is needed — pass current values the next time you mount the trainer: demographics via `user:` / `UserDetails`, everything else via `customParams` using the same field names as the event (see [Pre-filling user data](#prefill)).

## Scheduling the next workout

When the user schedules their next workout — or the in-app fitness assessment — for later, the SDK fires:

```json
{
  "type": "trainer_schedule_next_workout",
  "scheduledFor": "2026-04-29T14:30",
  "sessionType": "workout"
}
```

`scheduledFor` is **`YYYY-MM-DDTHH:MM`** with **no timezone** — interpret it as the device's local time (`new Date(scheduledFor)` in JS and `DateTime.parse(scheduledFor)` in Dart both do this correctly). The event is **not** sent if the user skips scheduling.

`sessionType` is `"workout"` or `"assessment"` — treat a missing value as `"workout"`. Use it to word the reminder and to pick a **stable notification identifier per session type** so re-scheduling replaces instead of stacking.

The SDK does **not** schedule the reminder for you — it just tells you when. **The host app delivers the reminder.** Two options:

- **Local notification (recommended for native).** No backend, works offline, fires even if the app is force-quit. Example below.
- **Server-side push.** POST `{ userId, scheduledFor, timezone }` to your backend and queue a push (APNs / FCM / web-push / OneSignal). Use this when the user has multiple devices, you want delivery analytics, or the platform has no reliable local-notification path (e.g. plain web).

Whichever you pick, **use a stable identifier per user** (e.g. `"trainer-next-workout"`) so a re-schedule replaces the previous reminder instead of stacking, **skip past timestamps**, and request notification permission early.

**Local-notification example**

_Swift (iOS)_
```swift
import UserNotifications

private let trainerReminderId = "kinestex.trainer.next_workout" // stable per user

func scheduleNextWorkoutReminder(scheduledFor: String) {
    // Parse "YYYY-MM-DDTHH:MM" as local time. Don't use ISO8601DateFormatter —
    // it expects seconds and/or a timezone designator.
    let formatter = DateFormatter()
    formatter.dateFormat = "yyyy-MM-dd'T'HH:mm"
    formatter.timeZone = .current
    formatter.locale = Locale(identifier: "en_US_POSIX")

    guard let date = formatter.date(from: scheduledFor),
          date.timeIntervalSinceNow > 5 else { return }

    let center = UNUserNotificationCenter.current()
    center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
        guard granted else { return }

        // Replace the previous reminder so re-schedules don't stack.
        center.removePendingNotificationRequests(withIdentifiers: [trainerReminderId])

        let content = UNMutableNotificationContent()
        content.title = "Time for your workout"
        content.body  = "Your AI Trainer has your next session ready."
        content.sound = .default

        let components = Calendar.current.dateComponents(
            [.year, .month, .day, .hour, .minute], from: date
        )
        let trigger = UNCalendarNotificationTrigger(
            dateMatching: components, repeats: false
        )
        center.add(UNNotificationRequest(
            identifier: trainerReminderId,
            content: content,
            trigger: trigger
        ))
    }
}
```

_Kotlin (Android)_
```kotlin
// AndroidManifest.xml:
//   <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
//   <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
//   <receiver android:name=".TrainerReminderReceiver" android:exported="false" />

import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.content.getSystemService
import java.time.LocalDateTime
import java.time.ZoneId

const val TRAINER_REMINDER_ID = 1001 // stable per user

fun scheduleNextWorkoutReminder(context: Context, scheduledFor: String) {
    // Parse "YYYY-MM-DDTHH:MM" as device-local time, then convert to epoch millis.
    val triggerAtMillis = runCatching {
        LocalDateTime.parse(scheduledFor)
            .atZone(ZoneId.systemDefault())
            .toInstant()
            .toEpochMilli()
    }.getOrNull() ?: return
    if (triggerAtMillis - System.currentTimeMillis() < 5_000L) return

    val alarmManager = context.getSystemService<AlarmManager>() ?: return
    val canExact = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
        alarmManager.canScheduleExactAlarms() else true

    val intent = Intent(context, TrainerReminderReceiver::class.java)
    val pi = PendingIntent.getBroadcast(
        context, TRAINER_REMINDER_ID, intent,
        // FLAG_UPDATE_CURRENT replaces any previously scheduled reminder with this id.
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
    )
    alarmManager.cancel(pi) // explicit cancel for safety

    if (canExact) {
        alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pi)
    } else {
        // Fall back to inexact if SCHEDULE_EXACT_ALARM hasn't been granted on Android 12+.
        alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pi)
    }
}

// Receiver that posts the actual notification when the alarm fires.
class TrainerReminderReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val launch = context.packageManager
            .getLaunchIntentForPackage(context.packageName)
            ?.apply { putExtra("openTrainer", true) }
        val contentIntent = PendingIntent.getActivity(
            context, 0, launch,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )
        val notif = NotificationCompat.Builder(context, "trainer_reminders")
            .setSmallIcon(R.drawable.ic_notification) // your icon
            .setContentTitle("Time for your workout")
            .setContentText("Your AI Trainer has your next session ready.")
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .setContentIntent(contentIntent)
            .setAutoCancel(true)
            .build()
        context.getSystemService<NotificationManager>()
            ?.notify(TRAINER_REMINDER_ID, notif)
    }
}
```

_React Native_
```jsx
import * as Notifications from "expo-notifications";

const TRAINER_REMINDER_ID = "trainer-next-workout";

async function scheduleNextWorkoutReminder(scheduledFor: string) {
  const date = new Date(scheduledFor); // local time, no timezone in the string
  if (Number.isNaN(date.getTime()) || date.getTime() - Date.now() < 5_000) return;

  const { granted } = await Notifications.getPermissionsAsync();
  if (!granted && !(await Notifications.requestPermissionsAsync()).granted) return;

  // Replace the previous reminder so re-schedules don't stack.
  await Notifications.cancelScheduledNotificationAsync(TRAINER_REMINDER_ID).catch(() => {});

  await Notifications.scheduleNotificationAsync({
    identifier: TRAINER_REMINDER_ID,
    content: {
      title: "Time for your workout",
      body: "Your AI Trainer has your next session ready.",
      sound: "default",
    },
    trigger: { type: Notifications.SchedulableTriggerInputTypes.DATE, date },
  });
}
```

_Flutter_
```dart
// pubspec.yaml: flutter_local_notifications + timezone
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz;

const trainerReminderId = 1001; // stable per user
final notifications = FlutterLocalNotificationsPlugin();

Future<void> scheduleNextWorkoutReminder(String scheduledFor) async {
  final date = DateTime.tryParse(scheduledFor); // local time
  if (date == null || date.difference(DateTime.now()).inSeconds < 5) return;

  // Replace the previous reminder so re-schedules don't stack.
  await notifications.cancel(trainerReminderId);

  await notifications.zonedSchedule(
    trainerReminderId,
    'Time for your workout',
    'Your AI Trainer has your next session ready.',
    tz.TZDateTime.from(date, tz.local),
    const NotificationDetails(
      android: AndroidNotificationDetails(
        'trainer_reminders', 'Trainer reminders',
        importance: Importance.high, priority: Priority.high,
      ),
      iOS: DarwinNotificationDetails(),
    ),
    androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
    uiLocalNotificationDateInterpretation:
        UILocalNotificationDateInterpretation.absoluteTime,
  );
}
```

_HTML / JavaScript_
```html
// Browser notifications fire only while the page (or a service worker) is alive.
// For reminders that need to wake the user up later, POST to your backend and
// schedule a real push notification (web-push / FCM) instead.

async function scheduleNextWorkoutReminder(scheduledFor) {
  const delay = new Date(scheduledFor).getTime() - Date.now();
  if (Number.isNaN(delay) || delay < 5_000) return;

  if (Notification.permission !== "granted") {
    if ((await Notification.requestPermission()) !== "granted") return;
  }

  // Replace any previously queued in-page reminder.
  clearTimeout(window.__trainerReminderTimer);
  window.__trainerReminderTimer = setTimeout(() => {
    new Notification("Time for your workout", {
      body: "Your AI Trainer has your next session ready.",
      tag: "trainer-next-workout",
    });
  }, delay);
}
```

_React (TypeScript)_
```tsx
// Browser notifications fire only while the page (or a service worker) is alive.
// For reminders that need to wake the user up later, POST to your backend and
// schedule a real push notification (web-push / FCM) instead.

export async function scheduleNextWorkoutReminder(scheduledFor: string) {
  const delay = new Date(scheduledFor).getTime() - Date.now();
  if (Number.isNaN(delay) || delay < 5_000) return;

  if (Notification.permission !== "granted") {
    if ((await Notification.requestPermission()) !== "granted") return;
  }

  const w = window as unknown as { __trainerReminderTimer?: number };
  if (w.__trainerReminderTimer) clearTimeout(w.__trainerReminderTimer);
  w.__trainerReminderTimer = window.setTimeout(() => {
    new Notification("Time for your workout", {
      body: "Your AI Trainer has your next session ready.",
      tag: "trainer-next-workout",
    });
  }, delay);
}
```

---
Source: https://www.kinestex.com/docs/ai-trainer-chat · Index: https://www.kinestex.com/llms.txt
