# appmintNative (AI-built apps) - JavaScript bridge API

> The typed wrappers every AI-built project imports from @/lib/appmintNative. Each picks the native path inside the app and a web path, or an honest none, in a browser - one call works everywhere.

- **Applies to:** Appwright
- **Source:** extracted from the app runtime and its per-method example files; this page is generated from them.
- **HTML:** https://freewebsitetoapk.com/docs/api/native

### `appmintNative.actionSheet`

```js
actionSheet(title: string, options: { title: string; destructive?: boolean }[]): Promise<number | null>
```

**Example**

Shows a "choose one of these" bottom sheet - the native control for a row's actions menu.

**Returns:** `Promise<number | null>` - the index of the chosen option, or `null` when the user dismissed the sheet (backdrop tap, Escape, the system cancel). Dismissal is normal, not an error. **Needs:** nothing.

```ts
import { actionSheet, haptic } from '@/lib/appmintNative';

export async function onNoteMenu(note: { id: string }, api: {
  edit(id: string): void; share(id: string): void; remove(id: string): void;
}) {
  const i = await actionSheet('Note', [
    { title: 'Edit' },
    { title: 'Share' },
    { title: 'Delete', destructive: true },
  ]);
  if (i === null) return;                 // dismissed
  if (i === 0) api.edit(note.id);
  if (i === 1) api.share(note.id);
  if (i === 2) { haptic('warning'); api.remove(note.id); }
}
```

Open it from a visible button, and from a long press on the row:

```ts
import { useEffect, useRef } from 'react';
import { actionSheet, longPress } from '@/lib/appmintNative';

export function Row({ label }: { label: string }) {
  const ref = useRef<HTMLLIElement>(null);
  const openMenu = () => actionSheet(label, [{ title: 'Rename' }, { title: 'Delete', destructive: true }]);
  useEffect(() => (ref.current ? longPress(ref.current, () => void openMenu()) : undefined), []);
  return <li ref={ref}>{label} <button aria-label="More actions" onClick={() => void openMenu()}>⋯</button></li>;
}
```

The Capacitor-shaped form `ActionSheet.showActions({ title?, options })` takes the same options and resolves `{ index }`, with `-1` for a dismissal:

```ts
import { ActionSheet } from '@/lib/appmintNative';

const { index } = await ActionSheet.showActions({
  title: 'Photo',
  options: [{ title: 'Save' }, { title: 'Delete', destructive: true }],
});
if (index === 1) console.log('delete');   // -1 = dismissed; never test with `if (index)`
```

**Notes:**
- In the Android app the Capacitor ActionSheet plugin draws the system sheet; `destructive` uses its destructive style.
- Elsewhere it is a real sheet in the app's palette (`--surface`, `--ink`, `--danger`), with backdrop, Escape, focus moved into the sheet and a selection haptic.
- An empty `options` list resolves `null` at once. For any content other than a list of actions, use `sheet()`.

### `appmintNative.ActionSheet`

```js
import { ActionSheet } from '@/lib/appmintNative'
```

**Example**

Documented together with `appmintNative.actionSheet` - the example there shows this one too.

Shows a "choose one of these" bottom sheet - the native control for a row's actions menu.

**Returns:** `Promise<number | null>` - the index of the chosen option, or `null` when the user dismissed the sheet (backdrop tap, Escape, the system cancel). Dismissal is normal, not an error. **Needs:** nothing.

```ts
import { actionSheet, haptic } from '@/lib/appmintNative';

export async function onNoteMenu(note: { id: string }, api: {
  edit(id: string): void; share(id: string): void; remove(id: string): void;
}) {
  const i = await actionSheet('Note', [
    { title: 'Edit' },
    { title: 'Share' },
    { title: 'Delete', destructive: true },
  ]);
  if (i === null) return;                 // dismissed
  if (i === 0) api.edit(note.id);
  if (i === 1) api.share(note.id);
  if (i === 2) { haptic('warning'); api.remove(note.id); }
}
```

Open it from a visible button, and from a long press on the row:

```ts
import { useEffect, useRef } from 'react';
import { actionSheet, longPress } from '@/lib/appmintNative';

export function Row({ label }: { label: string }) {
  const ref = useRef<HTMLLIElement>(null);
  const openMenu = () => actionSheet(label, [{ title: 'Rename' }, { title: 'Delete', destructive: true }]);
  useEffect(() => (ref.current ? longPress(ref.current, () => void openMenu()) : undefined), []);
  return <li ref={ref}>{label} <button aria-label="More actions" onClick={() => void openMenu()}>⋯</button></li>;
}
```

The Capacitor-shaped form `ActionSheet.showActions({ title?, options })` takes the same options and resolves `{ index }`, with `-1` for a dismissal:

```ts
import { ActionSheet } from '@/lib/appmintNative';

const { index } = await ActionSheet.showActions({
  title: 'Photo',
  options: [{ title: 'Save' }, { title: 'Delete', destructive: true }],
});
if (index === 1) console.log('delete');   // -1 = dismissed; never test with `if (index)`
```

**Notes:**
- In the Android app the Capacitor ActionSheet plugin draws the system sheet; `destructive` uses its destructive style.
- Elsewhere it is a real sheet in the app's palette (`--surface`, `--ink`, `--danger`), with backdrop, Escape, focus moved into the sheet and a selection haptic.
- An empty `options` list resolves `null` at once. For any content other than a list of actions, use `sheet()`.

### `appmintNative.addContact`

```js
addContact(contact: Contact): Promise<boolean>
```

**Example**

Saves a new contact into the phone's address book. App only: the shell's bridge on Android (saved directly, no Contacts screen opens), the Contacts plugin on iOS.

**Returns:** `Promise<boolean>` - `true` once saved, `false` when the user refused the contacts permission or the save failed. It rejects with an `Error` whose `code` is `'not_enabled'` (the build did not switch on writing contacts) or `'foreign_origin'`, and after a 5-minute wait with no answer. **Needs:** Android: the **Edit Contacts** switch in Step 4 (Access) - the build turns it on when your code calls this - and the user's permission on first use. iOS: the user allows contacts access.

**Save a customer from a form.**

```ts
import { addContact, can } from '@/lib/appmintNative';

export async function saveCustomer(name: string, phone: string, email: string): Promise<string> {
  if (can('contactAdd') === 'none') return 'Saving contacts works in the app only.';
  try {
    const ok = await addContact({ name, phones: [phone], emails: email ? [email] : [] });
    return ok ? 'Saved to contacts' : 'Allow contacts access to save it';
  } catch (e) {
    return (e as { code?: string }).code === 'not_enabled'
      ? 'Saving contacts is not switched on in this app.'
      : 'No answer from the phone. Try again.';     // 5-minute timeout
  }
}
```

**Skip a contact that is already saved.** `searchContacts` reads the address book, so check before you add:

```ts
import { addContact, searchContacts, can } from '@/lib/appmintNative';

export async function addIfNew(name: string, phone: string): Promise<'added' | 'exists' | 'refused' | 'app_only'> {
  if (can('contactAdd') === 'none') return 'app_only';
  const found = await searchContacts(name, 5);
  if (found.some((c) => c.phones?.includes(phone))) return 'exists';
  return (await addContact({ name, phones: [phone] })) ? 'added' : 'refused';
}
```

**Tell a build mistake from a refusal.** A rejection with `code` `'foreign_origin'` or `'not_enabled'` is a build problem, `false` is the user's choice:

```tsx
import { useState } from 'react';
import { addContact } from '@/lib/appmintNative';

export function SaveContactButton({ name, phone }: { name: string; phone: string }) {
  const [msg, setMsg] = useState('');
  async function onTap() {
    try {
      setMsg((await addContact({ name, phones: [phone] })) ? 'Saved.' : 'Contacts access was declined.');
    } catch (e) {
      const code = (e as { code?: string }).code;
      setMsg(code === 'foreign_origin' || code === 'not_enabled' ? 'This build cannot save contacts.' : 'Try again.');
    }
  }
  return <button onClick={onTap}>{msg || 'Save to contacts'}</button>;
}
```

**Notes:** It saves at once - no contact editor opens - so confirm with the user in your own screen first. Android saves the phone numbers as mobile numbers and the contact on the device (no account). Writing contacts is sensitive: declare it in your Play Data safety form and privacy policy.

### `appmintNative.ads`

```js
ads from '@/lib/appmintNative'
```

**Example**

AdMob ads for AI-built apps: a rewarded "Watch ad to earn" button, interstitials at natural breaks, and the banner. The same events arrive on Android and iOS.

**Returns:** `ads.available()` → `'native' | 'none'` (`'none'` on the web and in a build with no ad format switched on). `ads.formats()` → `{rewarded, interstitial, banner}`. `ads.isRewardedAdReady()` / `ads.isInterstitialAdReady()` / `ads.isPremium()` → boolean. `ads.showRewardedAd()` / `ads.showInterstitialAd()` → `Promise<void>`; the outcome comes as the `appmint:reward`, `appmint:ad-closed` and `appmint:ad-unavailable` events. `ads.banner.show()` → `Promise<boolean>` (`true` once an ad is in the slot), `ads.banner.hide()` → `Promise<void>`. `ads.startRemoveAdsPurchase()` → void. **Needs:** AdMob ad units in the build.

A rewarded button: shown only in the app, called only from the tap, reward granted only in the event:

```ts
import { useEffect, useState } from 'react';
import { ads } from '@/lib/appmintNative';

export function WatchAdButton({ onReward }: { onReward: (amount: number) => void }) {
  const [ready, setReady] = useState(false);
  const [note, setNote] = useState('');

  useEffect(() => {
    if (ads.available() === 'none') return;
    const refresh = () => setReady(ads.isRewardedAdReady());
    const onRewardEvt = (e: Event) => onReward((e as CustomEvent<{ type: string; amount: number }>).detail.amount);
    const onClosed = (e: Event) => {
      const d = (e as CustomEvent<{ adType: string; earned: boolean }>).detail;
      if (d.adType === 'rewarded' && !d.earned) setNote('Watch to the end to get the reward.');
      refresh();
    };
    const onUnavailable = (e: Event) => {
      const d = (e as CustomEvent<{ adType: string; reason: string }>).detail;
      if (d.reason === 'ad_free') onReward(1);                 // paid to remove ads
      else if (d.reason !== 'declined') setNote('No ad right now. Try again soon.');
      refresh();
    };
    window.addEventListener('appmint:reward', onRewardEvt);
    window.addEventListener('appmint:ad-closed', onClosed);
    window.addEventListener('appmint:ad-unavailable', onUnavailable);
    refresh();
    const t = setInterval(refresh, 2000);
    return () => {
      clearInterval(t);
      window.removeEventListener('appmint:reward', onRewardEvt);
      window.removeEventListener('appmint:ad-closed', onClosed);
      window.removeEventListener('appmint:ad-unavailable', onUnavailable);
    };
  }, [onReward]);

  if (ads.available() === 'none' || !ads.formats().rewarded) return null;   // web: no ad UI
  return (
    <>
      <button disabled={!ready} onClick={() => { setNote(''); void ads.showRewardedAd(); }}>Watch an ad for 1 coin</button>
      {note && <p role="status">{note}</p>}
    </>
  );
}
```

An interstitial between levels:

```ts
import { ads } from '@/lib/appmintNative';

export async function betweenLevels(next: () => void) {
  if (ads.isInterstitialAdReady()) await ads.showInterstitialAd();   // skipped when none is loaded
  next();
}
```

**Notes:** These calls work in both of the wizard's ad-control modes. **Automatic:** the rewarded and interstitial calls go to the app's own ad pipeline (`WebToApk.showRewardedAd()`), and the app places the banner itself (`banner.show()` resolves `false`, `formats().banner` is `false`). **Developer-controlled:** they go through `window.AppMintAds`, and `banner.show()` places the banner in an element with id `ad-slot`; `banner.hide()` removes it. On every platform a rewarded call not made from the user's tap first asks "Watch an ad?"; "No thanks" answers `appmint:ad-unavailable` with reason `declined` (the example above ignores that one). On iOS the AdMob plugin is used.

### `appmintNative.App`

```js
App.getInfo(): Promise<{ name, id, build, version }> / App.exitApp(): void
```

**Example**

The app's name, package id and version, and a way to close the app - in the shape of Capacitor's App plugin.

**Returns:** `getInfo()` → `Promise<{ name: string; id: string; build: string; version: string }>`. `exitApp()` → `void`. **Needs:** nothing.

```ts
import { App } from '@/lib/appmintNative';

const info = await App.getInfo();
aboutLine.textContent = `${info.name} ${info.version}${info.build ? ` (${info.build})` : ''}`;
```

**Close the app** from a "Quit" item that is not the only way forward:

```ts
import { App, Dialog } from '@/lib/appmintNative';

async function quit() {
  const { value } = await Dialog.confirm({ title: 'Quit?', message: 'Unsaved changes will be lost.' });
  if (value) App.exitApp();
}
```

**Show the app version in a React screen.**

```tsx
import { useEffect, useState } from 'react';
import { App } from '@/lib/appmintNative';

export function VersionLabel() {
  const [text, setText] = useState('');
  useEffect(() => {
    void App.getInfo().then((i) => setText(`${i.name} v${i.version}`));
  }, []);
  return <small>{text}</small>;
}
```

**Notes:** `getInfo` reads the Capacitor App plugin (Android and iOS); when that is missing it uses the shell bridge's name and version (`id` and `build` are then `''`), and on the web `document.title` with an empty version. `exitApp` uses the shell's `closeWindow` on Android; on iOS apps should not quit themselves and this may do nothing. For pause/resume events and deep links see `Capacitor.Plugins.App`, `launchUrl()` and `onDeepLink()`.

### `appmintNative.appLauncher`

```js
appLauncher.canOpen(url): Promise<boolean> / appLauncher.open(url): Promise<boolean>
```

**Example**

Opens another app by link - `whatsapp://send?text=…`, `tel:`, `mailto:`, `geo:`, or an https link the OS gives to its app - and asks first whether that can work.

**Returns:** `canOpen()` → `Promise<boolean>`. `open()` → `Promise<boolean>` (`false` when no app took the link). **Needs:** nothing.

```ts
import { appLauncher } from '@/lib/appmintNative';

async function chat(phone: string, text: string) {
  const url = `whatsapp://send?phone=${phone}&text=${encodeURIComponent(text)}`;
  if (!(await appLauncher.canOpen(url))) {
    showMessage('WhatsApp is not installed on this phone.');
    return;
  }
  await appLauncher.open(url);
}
```

**Call or email** (no check needed; a phone always has an app for these):

```ts
import { appLauncher } from '@/lib/appmintNative';

await appLauncher.open('tel:+15551234567');
await appLauncher.open('mailto:help@example.com?subject=Order%2042');
```

**Open the maps app** with a `geo:` link, and say so when nothing took it:

```ts
import { appLauncher } from '@/lib/appmintNative';

export async function showOnMap(lat: number, lng: number, label: string) {
  const ok = await appLauncher.open(`geo:${lat},${lng}?q=${lat},${lng}(${encodeURIComponent(label)})`);
  if (!ok) showMessage('No maps app could open this place.');
}
```

**Notes:** In the app it uses the Capacitor AppLauncher plugin. On Android 11+ `canOpen` can only see schemes the app declares (`https`, `geo`, `google.navigation`, `tel`, `mailto`, `sms`, `whatsapp`, `intent`, `upi`); for others it answers `false` even when the app is installed - call `open()` and check its result instead. On the web a custom scheme cannot be checked, so `canOpen` is `true` only for http(s), and `open` navigates to the link.

### `appmintNative.appLock`

```js
appLock({ timeoutSec?, title?, onLocked? }) — from '@/lib/appmintNative'
```

**Example**

Locks the app again when the user comes back after it was in the background for a while: it asks for fingerprint / face / PIN (`unlock()`), and turns on the privacy screen so the app switcher does not show your content.

**Returns:** a function that stops the lock (call it on sign-out or unmount). `onLocked(true)` fires when the app locks, `onLocked(false)` after a successful unlock. **Needs:** the same as `unlock()` - Android: **Fingerprint** ticked when you build.

```ts
import { useEffect, useState } from 'react';
import { appLock } from '@/lib/appmintNative';

/** Call once in your root component; render a lock screen while it returns true. */
export function useAppLock(): boolean {
  const [locked, setLocked] = useState(false);
  useEffect(() => appLock({
    timeoutSec: 60,                 // lock after 1 minute in the background (default 30)
    title: 'Unlock MyNotes',
    onLocked: setLocked,            // true → cover the screen, false → show the app again
  }), []);                          // appLock returns its own stop function = the cleanup
  return locked;
}
```

In the root component: `const locked = useAppLock(); if (locked) return <LockScreen />;`.

Stop the lock on sign-out, so a signed-out user is not asked to unlock:

```ts
import { appLock } from '@/lib/appmintNative';

let stopLock: (() => void) | null = null;

export function onSignedIn() {
  stopLock = appLock({ timeoutSec: 120, title: 'Unlock your wallet' });
}

export function onSignedOut() {
  stopLock?.();            // removes the listener and turns the privacy screen off
  stopLock = null;
}
```

Offer the lock only where the device can open it, and combine it with `unlock()` for a manual "Lock now" button:

```tsx
import { appLock, can, unlock } from '@/lib/appmintNative';

export function LockSettings({ onLockedChange }: { onLockedChange: (locked: boolean) => void }) {
  if (can('unlock') === 'none') return <p>This device has no screen lock to use.</p>;   // appLock would do nothing here
  return (
    <>
      <button onClick={() => appLock({ onLocked: onLockedChange })}>Turn on app lock</button>
      <button onClick={async () => { onLockedChange(true); if (await unlock('Unlock')) onLockedChange(false); }}>
        Lock now
      </button>
    </>
  );
}
```

**Notes:** If the device has nothing to unlock with (`can('unlock') === 'none'`) it does nothing and returns a no-op - an app lock that could never open would trap the user. If the user cancels, it asks again up to five times, then stays locked (keep your lock screen up) until the app goes to the background and comes back. This is separate from the **PIN Lock** option in the build wizard, which the app shows natively at launch.

### `appmintNative.appUpdate`

```js
appUpdate.check(): Promise<AppUpdateInfo | null> / appUpdate.start(): Promise<boolean>
```

**Example**

Checks the store for a newer version and starts the update: Google Play's in-app update on Android (downloads and installs in place), the App Store listing on iOS.

**Returns:** `check()` → `Promise<{ currentVersion, availableVersion?, currentVersionCode?, updateAvailable, inPlace } | null>` (`null` on the web, without Play services, or when Play fails). `updateAvailable` is the store's own answer. `start()` → `Promise<boolean>`: Android - `true` only when Play's update completed, `false` when the user cancelled, it failed or none was available; iOS - `true` once the App Store listing opened. **Needs:** nothing; the app must be installed from the store.

```ts
import { appUpdate } from '@/lib/appmintNative';

async function offerUpdate() {
  const info = await appUpdate.check();
  if (!info?.updateAvailable) return;            // up to date (or no store here)
  showBanner('A new version is ready', async () => {
    const done = await appUpdate.start();        // Play's full-screen flow / the App Store page
    if (!done) console.log('Not updated — the user can update later from the store');
  });
}
```

Compare Play's version code with the installed one, and treat `null` as "cannot check here":

```ts
import { appUpdate } from '@/lib/appmintNative';

export async function updateStatus(): Promise<string> {
  const info = await appUpdate.check();
  if (!info) return 'Update check is not available here.';     // web, no Play services, or Play failed
  if (!info.updateAvailable) return `You have the latest version (${info.currentVersion}).`;
  return `Update available: version code ${info.availableVersion} (you have ${info.currentVersionCode}).`;
}
```

A React banner that checks once when the screen opens:

```tsx
import { useEffect, useState } from 'react';
import { appUpdate, type AppUpdateInfo } from '@/lib/appmintNative';

export function UpdateBanner() {
  const [info, setInfo] = useState<AppUpdateInfo | null>(null);
  useEffect(() => { void appUpdate.check().then(setInfo); }, []);
  if (!info?.updateAvailable) return null;
  return <button onClick={() => void appUpdate.start()}>Update now</button>;
}
```

**Notes:** On Android `availableVersion` is Play's version CODE (Play gives no name) - compare it with `currentVersionCode`, never with `currentVersion`, which is the version name. `inPlace` is `true` when Play can run its in-app update right now. On iOS `start()` opens the App Store listing; the update itself happens there.

### `appmintNative.askForReview`

```js
askForReview(options): Promise<ReviewOutcome>
```

**Example**

Asks Google Play to show its own rating sheet inside the app and reports what happened.

**Returns:** `Promise<ReviewOutcome>` - `{ result, openedStore, message? }` with `result` `'shown' | 'not_from_play' | 'quota' | 'error' | 'unavailable'` (`'unavailable'` on the web). `{ openStore: true }` opens the Play listing when the sheet did not show. **Needs:** nothing; the sheet only appears for an app installed from Google Play.

```ts
import { askForReview } from '@/lib/appmintNative';

export async function onRateTap() {
  const r = await askForReview({ openStore: true });   // listing if Play shows no sheet
  if (r.result === 'shown' || r.openedStore) toast('Thanks for rating!');
}
```

Tell the user what happened for each outcome:

```ts
import { askForReview } from '@/lib/appmintNative';

export async function rateMessage(): Promise<string> {
  const r = await askForReview();
  switch (r.result) {
    case 'shown': return 'Thanks for rating!';
    case 'not_from_play': return 'Rating works for installs from Google Play.';
    case 'quota': return 'Google limits how often the sheet shows. Try again later.';
    case 'unavailable': return '';                       // the web: draw no rating button at all
    default: return `Could not open the rating sheet${r.message ? `: ${r.message}` : ''}.`;
  }
}
```

Ask once, after the user finishes their third task, and remember that you asked:

```ts
import { askForReview } from '@/lib/appmintNative';

export async function maybeAskAfterTask(completed: number) {
  if (completed !== 3 || localStorage.getItem('asked-review')) return;
  localStorage.setItem('asked-review', '1');
  await askForReview();
}
```

**Notes:** Ask after a success moment and rarely. A sideloaded test install always answers `not_from_play`.

### `appmintNative.backgroundSync`

```js
import { backgroundSync } from '@/lib/appmintNative'
```

**Example**

Native HTTP requests that run while the app is closed: `enqueue` (a one-off, durable auto-save sent when online), `schedule` (a repeating request, at least every 15 minutes), `cancel` and `list`.

**Returns:** `enqueue` / `schedule` → `Promise<boolean>`; `cancel` → `Promise<void>`; `list` → `Promise<BackgroundJob[]>` (`{ id, url, periodic, createdAt?, pendingDelivery? }` - `pendingDelivery` on Android: a one-off not delivered yet). **Needs:** nothing on Android (WorkManager). iOS uses the Background Runner (best effort, the system chooses when). On the web `can('backgroundSync')` is `'none'`.

**Durable auto-save:** the same id replaces the save that has not been sent yet.

```ts
import { backgroundSync, can } from '@/lib/appmintNative';

export async function saveDraft(draft: object, token: string) {
  if (can('backgroundSync') === 'none') return false;   // web: save normally instead
  return backgroundSync.enqueue({
    id: 'draft',
    url: 'https://api.example.com/drafts',
    method: 'PUT',
    headers: { Authorization: `Bearer ${token}` },
    body: JSON.stringify(draft),
  });
}
```

**Repeating sync, list and cancel:**

```ts
await backgroundSync.schedule({
  id: 'sync', url: 'https://api.example.com/sync', body: '{}', intervalMinutes: 60,
});

const jobs = await backgroundSync.list();
console.log(jobs.map((j) => `${j.id} ${j.periodic ? 'repeating' : 'one-off'}`));

await backgroundSync.cancel('sync');
```

**Raw event** (what the wrapper waits for on Android):

```js
window.addEventListener('appmint:work', function (e) {
  // { id, status: 'enqueued' | 'scheduled' | 'cancelled' | 'failed', reason?, intervalMinutes? }
  // or { requestId, jobs: [...] } for a list
  console.log('work', e.detail);
});
```

**Notes:** `true` means the job was queued, not delivered. `false` means it was not queued: the shell answered `status: 'failed'` (a URL not starting with `http`, an empty id, or the system refused) or did not answer within 10 seconds. The page's JavaScript never runs in the background; only this request does. A 4xx reply is not retried; network errors and 5xx retry up to 8 times.

### `appmintNative.badge`

```js
import { badge } from '@/lib/appmintNative'
```

**Example**

Sets or clears the unread count on the app's launcher icon.

**Returns:** `badge.set(count)` → `Promise<boolean>` (`false` when the Badge plugin is not there, e.g. on the web); `badge.clear()` → `Promise<void>`. **Needs:** nothing on Android; iOS asks for notification permission the first time.

```ts
import { badge, pushInbox } from '@/lib/appmintNative';

export async function syncBadge() {
  const unread = pushInbox().filter((m) => !m.read).length;
  await badge.set(unread);        // 0 or less clears it
}

export async function onInboxOpened() {
  await badge.clear();
}
```

Mirror the unread count when a push message arrives while the app is open:

```ts
import { badge, pushInbox } from '@/lib/appmintNative';

export function watchInbox(): () => void {
  const refresh = () => void badge.set(pushInbox().filter((m) => !m.read).length);
  window.addEventListener('appmint:push', refresh);
  refresh();
  return () => window.removeEventListener('appmint:push', refresh);
}
```

Mark everything read and clear the count together; skip the badge where it does not exist:

```ts
import { badge, can, markPushRead } from '@/lib/appmintNative';

export async function markAllRead(): Promise<boolean> {
  markPushRead();                              // no id = every message
  if (can('badge') === 'none') return false;   // the web: nothing to clear
  await badge.clear();
  return true;
}
```

**Notes:** exact on iOS. On Android it depends on the launcher: many show a number, some show only a dot, some show nothing - do not put information only in the badge. On the web `can('badge')` is `'none'`.

### `appmintNative.barcodeFormats`

```js
barcodeFormats(): Promise<string[]>
```

**Example**

Lists the barcode and QR formats this device's `BarcodeDetector` can read.

**Returns:** `Promise<string[]>` - format names such as `'qr_code'`, `'ean_13'`, `'code_128'`; an empty array when barcode reading is not available here. It never rejects. **Needs:** nothing.

Show the "Scan a product barcode" option only when the device reads that format:

```ts
import { useEffect, useState } from 'react';
import { barcodeFormats } from '@/lib/appmintNative';

export function useCanReadEan(): boolean {
  const [ok, setOk] = useState(false);
  useEffect(() => {
    void barcodeFormats().then((f) => setOk(f.includes('ean_13')));
  }, []);
  return ok;
}
```

Narrow a scan to what the device supports:

```ts
import { barcodeFormats, readBarcodeFrom, type BarcodeFormat } from '@/lib/appmintNative';

declare const photo: File;
const wanted: BarcodeFormat[] = ['qr_code', 'ean_13'];
const have = await barcodeFormats();
const codes = await readBarcodeFrom(photo, wanted.filter((f) => have.includes(f)));
```

Build a format picker that lists only what this device can read:

```tsx
import { useEffect, useState } from 'react';
import { barcodeFormats } from '@/lib/appmintNative';

export function FormatPicker({ onPick }: { onPick: (format: string) => void }) {
  const [formats, setFormats] = useState<string[]>([]);
  useEffect(() => { void barcodeFormats().then(setFormats); }, []);
  if (formats.length === 0) return <p>Barcode reading is not available on this device.</p>;   // [] = none
  return (
    <select onChange={(e) => onPick(e.target.value)}>
      {formats.map((f) => <option key={f} value={f}>{f}</option>)}
    </select>
  );
}
```

**Notes:**
- It asks the browser engine's `BarcodeDetector.getSupportedFormats()`. The Android app's WebView has it (measured: aztec, code_128, code_39, code_93, codabar, data_matrix, ean_13, ean_8, itf, pdf417, qr_code, upc_a, upc_e); many desktop browsers and Safari do not, and answer `[]`.
- This list is about `readBarcodeFrom()` / `scanBarcode()`. `scanCode()` in the app uses Google's scanner instead and does not depend on it.

### `appmintNative.bleSensors`

```js
import { bleSensors } from '@/lib/appmintNative'
```

**Example**

Standard Bluetooth fitness sensors - heart rate, cycling speed/cadence, power, running speed/cadence and sensor battery - with the numbers already parsed, on Android and iPhone.

**Returns:** every method returns `Promise<void>` (`setWheelCircumference` returns `void`). Everything else arrives as `appmint:ble` window events with `detail` = `{kind:'device', address, name, sensors, rssi}` | `{kind:'state', state, address?, services?}` | `{kind:'data', type, data}` | `{kind:'error', error}`. **Needs:** Bluetooth, which the build turns on by itself when the code uses `bleSensors`. In a browser (web preview) there is no sensor bridge: `startScan()` answers with `{kind:'error', error:'not_enabled'}`, so handle that error. To know up front, test `can('bluetooth') === 'native'` - not `!== 'none'`: in Chrome `can('bluetooth')` is `'web'` (Web Bluetooth), which `bleSensors` does not use.

```ts
import { useEffect, useState } from 'react';
import { bleSensors } from '@/lib/appmintNative';

type BleDetail = {
  kind: 'device' | 'state' | 'data' | 'error';
  address?: string; name?: string | null; sensors?: string[]; rssi?: number;
  state?: 'scanning' | 'scan_stopped' | 'connecting' | 'connected' | 'ready' | 'disconnected';
  type?: 'heart_rate' | 'speed_cadence' | 'power' | 'running' | 'battery';
  data?: { bpm?: number; speedKmh?: number; cadenceRpm?: number; watts?: number; cadenceSpm?: number; percent?: number };
  error?: string;
};

export function SensorPanel() {
  const [devices, setDevices] = useState<BleDetail[]>([]);
  const [bpm, setBpm] = useState<number | null>(null);
  const [status, setStatus] = useState('');

  useEffect(() => {
    const onBle = (e: Event) => {
      const d = (e as CustomEvent<BleDetail>).detail;
      if (d.kind === 'device') setDevices((list) => [...list, d]);
      else if (d.kind === 'state') setStatus(d.state ?? '');
      else if (d.kind === 'data' && d.type === 'heart_rate') setBpm(d.data?.bpm ?? null);
      else if (d.kind === 'error') {
        setStatus(d.error === 'not_enabled' ? 'Sensors work in the installed app.' : `Bluetooth: ${d.error}`);
      }
    };
    window.addEventListener('appmint:ble', onBle);
    return () => { window.removeEventListener('appmint:ble', onBle); void bleSensors.disconnect(); };
  }, []);

  return (
    <div>
      <button onClick={() => { setDevices([]); bleSensors.setWheelCircumference(2105); void bleSensors.startScan(); }}>
        Find sensors
      </button>
      {devices.map((d) => (
        <button key={d.address} onClick={() => void bleSensors.connect(d.address!)}>
          {d.name ?? 'Sensor'} ({d.sensors?.join(', ')})
        </button>
      ))}
      <p>{status}</p>
      {bpm !== null && <p>{bpm} bpm</p>}
    </div>
  );
}
```

**Gate the screen on the installed app.**

```ts
import { bleSensors, can } from '@/lib/appmintNative';

export function SensorsEntry() {
  // 'native' only: in Chrome can('bluetooth') is 'web', which bleSensors does not use.
  if (can('bluetooth') !== 'native') return <p>Heart-rate sensors work in the installed app.</p>;
  return <button onClick={() => void bleSensors.startScan()}>Find sensors</button>;
}
```

**Stop the scan on the first sensor and watch cycling speed.**

```ts
import { useEffect, useState } from 'react';
import { bleSensors } from '@/lib/appmintNative';

type BikeEvent = { kind: string; address?: string; type?: string; data?: { speedKmh?: number } };

export function BikeSpeed() {
  const [kmh, setKmh] = useState<number | null>(null);

  useEffect(() => {
    bleSensors.setWheelCircumference(2096);   // 700x23c
    const onBle = (e: Event) => {
      const d = (e as CustomEvent<BikeEvent>).detail;
      if (d.kind === 'device' && d.address) {
        void bleSensors.stopScan().then(() => bleSensors.connect(d.address!));
      } else if (d.kind === 'data' && d.type === 'speed_cadence') {
        setKmh(d.data?.speedKmh ?? null);
      }
    };
    window.addEventListener('appmint:ble', onBle);
    void bleSensors.startScan();
    return () => { window.removeEventListener('appmint:ble', onBle); void bleSensors.disconnect(); };
  }, []);

  return <p>{kmh === null ? 'Looking for a sensor...' : `${kmh.toFixed(1)} km/h`}</p>;
}
```

**Notes:** Scans last 15 seconds (`stopScan()` ends one early); one sensor is connected at a time. Error codes: `not_enabled`, `permission_denied`, `bluetooth_unavailable`, `bluetooth_off`, `scan_failed`, `invalid_address`, and on iPhone `connect_failed`. Speed needs two packets and the wheel size (default 2096 mm). On Android the same messages also go to `window.onAppMintBle`; on iPhone only the event fires, so listen to the event. For any other Bluetooth device use `navigator.bluetooth`.

### `appmintNative.brightness`

```js
brightness.get() / brightness.set(value) / brightness.restore()
```

**Example**

Turns the screen up for a boarding pass or QR code, then gives control back to the system. Native only - a web page cannot drive the backlight.

**Returns:** `get()` → `Promise<number | null>` (0-1 while the app overrides the level; `-1` when the app is following the system level; `null` on the web or when it cannot be read). `set(value)` → `Promise<boolean>` (value clamped to 0-1; `false` on the web). `restore()` → `Promise<void>`. **Needs:** nothing.

```ts
import { brightness, can } from '@/lib/appmintNative';
import { useEffect } from 'react';

function TicketScreen() {
  useEffect(() => {
    if (can('brightness') === 'none') return;
    void brightness.set(1);
    return () => { void brightness.restore(); };
  }, []);
  return <QrCode />;
}
```

**Remember the level, brighten, then put it back.**

```ts
import { brightness } from '@/lib/appmintNative';

export async function showBoardingPass(render: () => Promise<void>) {
  const before = await brightness.get();   // -1 = following the system, null = not readable here
  const ok = await brightness.set(1);
  try {
    await render();
  } finally {
    if (ok) await brightness.restore();    // returns to the system level
  }
  return before;
}
```

**Keep the screen bright and awake while a code is on show.**

```ts
import { brightness, keepAwake, can } from '@/lib/appmintNative';
import { useEffect } from 'react';

export function useQrPresenting(active: boolean) {
  useEffect(() => {
    if (!active) return;
    if (can('brightness') !== 'none') void brightness.set(0.95);
    void keepAwake(true);
    return () => { void brightness.restore(); void keepAwake(false); };
  }, [active]);
}
```

**Notes:** It changes only this app's window, not the phone's brightness setting, and the override ends when the app closes. `restore()` sets `-1` (follow the system) on Android. `get()` reads the window's override, so it is `-1` until you call `set()` - it is not the system level.

### `appmintNative.browser`

```js
browser.open(url, { toolbarColor? }) / browser.close() / browser.onClosed(cb)
```

**Example**

Opens a page in an in-app browser tab (Chrome Custom Tabs on Android, SFSafariViewController on iOS) - for terms, help pages or a sign-in page - and a new tab on the web.

**Returns:** `open()` → `Promise<boolean>` (`false` when the tab could not open). `close()` → `Promise<void>`. `onClosed(cb)` → an unsubscribe function; `cb` runs when the user closes the tab (in the app only). **Needs:** nothing.

```ts
import { browser } from '@/lib/appmintNative';

async function openHelp() {
  const off = browser.onClosed(() => {
    off();
    void refreshAccount();       // the user may have changed something on the site
  });
  const ok = await browser.open('https://example.com/help', { toolbarColor: '#0B0F1A' });
  if (!ok) { off(); showMessage('Could not open the help page.'); }
}
```

**A terms link in a React component.**

```ts
import { browser, toast } from '@/lib/appmintNative';

export function TermsLink() {
  return (
    <button
      onClick={async () => {
        if (!(await browser.open('https://example.com/terms'))) toast('Could not open the terms page');
      }}
    >
      Terms of use
    </button>
  );
}
```

**Close the tab from code and refresh when the user comes back.**

```ts
import { useEffect } from 'react';
import { browser } from '@/lib/appmintNative';

export function usePaymentTab(onBack: () => void) {
  useEffect(() => browser.onClosed(onBack), [onBack]);   // the returned function unsubscribes
  return {
    start: (url: string) => browser.open(url),
    cancel: () => browser.close(),   // closes the tab in the app; does nothing on the web
  };
}
```

**Notes:** `toolbarColor` defaults to the app's `--bg` token. The tab is a separate browser - it does not share the app's cookies or `localStorage`. `close()` closes the tab from code in both apps; on the web it does nothing. `onClosed` never fires on the web.

### `appmintNative.calendar`

```js
calendar.requestPermission / createEvent / listEvents / deleteEvent / openCalendar
```

**Example**

Adds, lists and deletes events in the user's OWN phone calendar (Android CalendarContract, iOS EventKit), and opens the calendar app at a date.

**Returns:** `requestPermission()` → `Promise<'granted' | 'denied'>`. `createEvent(input)` → `Promise<string | null>` (the event id; `null` when refused or failed). `listEvents({ from, to })` → `Promise<{ id, title, start, end, location?, notes?, allDay? }[]>` (times in epoch ms). `deleteEvent(id)` / `openCalendar(date?)` → `Promise<boolean>`. Nothing rejects. **Needs:** turn on **Calendar (read & write events)** in the permissions step when you build - the build ticks it for you when it sees `calendar.createEvent(`, `calendar.listEvents(` or `calendar.requestPermission(` in the code.

```ts
import { calendar } from '@/lib/appmintNative';

async function addBooking(b: { name: string; at: Date; minutes: number; place: string }) {
  if ((await calendar.requestPermission()) !== 'granted') {
    showMessage('Allow calendar access to add the booking.');
    return;
  }
  const id = await calendar.createEvent({
    title: `Booking: ${b.name}`,
    start: b.at,
    end: new Date(b.at.getTime() + b.minutes * 60_000),
    location: b.place,
    notes: 'Added by the app',
  });
  if (id) saveEventId(b, id);
}
```

**List this week, delete, open:**

```ts
import { calendar, can } from '@/lib/appmintNative';

if (can('calendarRead') !== 'none') {
  const week = await calendar.listEvents({ from: Date.now(), to: Date.now() + 7 * 86_400_000 });
  if (week[0]) await calendar.deleteEvent(week[0].id);
}
await calendar.openCalendar(new Date());
```

**Add to the calendar only when writing is possible.**

```ts
import { calendar, can, toast } from '@/lib/appmintNative';

export async function addReminder(title: string, when: Date) {
  if (can('calendarWrite') === 'none') { toast('Calendar is not available here'); return null; }
  const id = await calendar.createEvent({ title, start: when, end: new Date(when.getTime() + 30 * 60_000) });
  toast(id ? 'Added to your calendar' : 'Calendar access was refused');
  return id;
}
```

**Notes:** On the web `createEvent` downloads an `.ics` file the user opens in their calendar (returns its UID), `listEvents` returns `[]` (`can('calendarRead')` is `'none'`), and `deleteEvent` / `openCalendar` return `false`. Reminders and repeating events are available on `Capacitor.Plugins.Calendar`, not through this wrapper.

### `appmintNative.camera`

```js
camera.take(opts?) / camera.pick(opts?)
```

**Example**

Takes a photo with the camera or picks photos from the gallery: the Capacitor Camera plugin in both installed apps, a file input with `capture` on the web.

**Returns:** `take()` → `Promise<Photo | null>` with `{ dataUrl, mime }`; `null` on cancel or refused permission. `pick()` → `Promise<Photo[]>`, empty on cancel. **Needs:** Android: **Camera** in Step 4 (Access) - the build turns it on when your code calls `camera.take`. iOS: the camera / photo usage texts are added for you.

**Take a profile photo with the front camera.**

```ts
import { camera } from '@/lib/appmintNative';

export async function takeSelfie(img: HTMLImageElement): Promise<boolean> {
  const photo = await camera.take({ source: 'camera', direction: 'front', quality: 70 });
  if (!photo) return false;                 // cancelled or permission refused
  img.src = photo.dataUrl;                  // "data:image/jpeg;base64,..."
  return true;
}
```

**Choose one photo from the gallery with `take`, or several with `pick`.**

```ts
import { camera } from '@/lib/appmintNative';

const one = await camera.take({ source: 'photos' });          // Photo | null
const many = await camera.pick({ limit: 5, quality: 80 });    // Photo[] (limit 0 = no limit)
console.log(one?.mime, many.length);
```

**Upload a photo** by turning the data URL into a Blob.

```ts
import { camera } from '@/lib/appmintNative';

const p = await camera.take();
if (p) {
  const blob = await (await fetch(p.dataUrl)).blob();
  const form = new FormData();
  form.append('file', blob, 'photo.jpg');
  await fetch('https://api.example.com/upload', { method: 'POST', body: form });
}
```

**Notes:** Options: `source` (`'camera'` default, or `'photos'`), `quality` 0-100 (default 80), `direction` (`'rear'` default, or `'front'`), `allowEditing`. Photos are not saved to the gallery. A refused permission resolves `null` / `[]`, never throws - show a short hint so the user knows why nothing happened. Camera is sensitive: declare it in your Play Data safety form.

### `appmintNative.can`

```js
can(cap: Capability): 'native' | 'web' | 'none'
```

**Example**

Tells you where a feature will really run on the current surface, so the app only draws controls that work.

**Returns:** a string, synchronously: `'native'` (the app's own implementation - the shell bridge or a Capacitor plugin baked into the app), `'web'` (a real web standard in this browser) or `'none'` (not available here - do not render the control). **Needs:** nothing.

Hide a control that cannot work on this surface:

```ts
import { can, share } from '@/lib/appmintNative';

export function ShareButton({ url }: { url: string }) {
  if (can('share') === 'none') return null;   // never ship a dead button
  return <button onClick={() => void share({ title: 'Have a look', url })}>Share</button>;
}
```

Change the wording by surface:

```ts
import { can } from '@/lib/appmintNative';

const selling = can('store') !== 'none';       // 'none' on the web and in the preview
const lockLabel = can('unlock') === 'native' ? 'Unlock with fingerprint' : 'Unlock with a passkey';
```

**Capability names:** `haptic` `share` `clipboardWrite` `clipboardRead` `speak` `fullscreen` `notify` `deviceInfo` · `filePick` `fileSave` `unlock` · `contactPick` `contactList` `contactAdd` · `smsCompose` `smsSend` `smsList` `callLog` · `toast` `dialog` `actionSheet` `keyboardInsets` `orientationLock` `network` `statusBar` · `swipe` `backGesture` · `folderAccess` `secureKeys` `mediaControls` `cast` `bluetooth` · `shortcuts` · `appLaunch` `browser` `camera` `photos` `location` `screenReader` `textZoom` `privacyScreen` `fileViewer` `fileTransfer` `calendarRead` `calendarWrite` `backgroundSync` `motion` · `keepAwake` `brightness` `review` `appUpdate` `badge` `speech` `hapticPatterns` `docScanner` `systemPalette` `liveProgress` `widget` `nfc` `pip` `shareTarget` `notificationActions` · `barcodeScan` `codeScanner` `passkeys` · `ads` `store`.

Show a hint instead of a dead control, and turn a feature into a plain yes/no with `has()`:

```ts
import { can, has, keepAwake } from '@/lib/appmintNative';

export function KeepScreenOn({ on }: { on: boolean }) {
  if (!has('keepAwake')) return <p>Keep-awake is not available on this device.</p>;
  return <label><input type="checkbox" checked={on} onChange={(e) => void keepAwake(e.target.checked)} /> Keep screen on</label>;
}

export const scansInApp = can('codeScanner') === 'native';
```

**Notes:**
- A Capacitor plugin baked into the app always answers `'native'`; otherwise the shell bridge is checked, then the web standard.
- On iOS `smsSend`, `smsList`, `callLog` and `backGesture` are always `'none'`.
- `toast`, `actionSheet`, `dialog`, `statusBar` and `swipe` have a real web version, so they are not `'none'` in a browser.
- `contactList`, `contactAdd`, `smsSend`, `smsList`, `callLog`, `shortcuts`, `shareTarget`, `widget`, `ads` and `store` are `'none'` on the web.
- Some features exist only when the build switched them on (folder access, secure keys, media controls, SMS, contacts). Ask `can()`, not `isNativeApp()`.
- An unknown name returns `'none'`. For a plain yes/no use `has(cap)`.

### `appmintNative.clearPushInbox`

```js
import { clearPushInbox } from '@/lib/appmintNative'
```

**Example**

Empties the Appwright Push inbox on this phone. Permanent - there is no undo.

**Returns:** `boolean` synchronously - `true` when cleared; always `false` on the web and iOS. **Needs:** Android: **Appwright Push** turned on in the Integrate step when you build.

```ts
import { clearPushInbox, pushInbox } from '@/lib/appmintNative';

export async function onClearAll(confirmDelete: () => Promise<boolean>) {
  if (!(await confirmDelete())) return pushInbox();
  clearPushInbox();
  return pushInbox();   // []
}
```

Show Clear only when there is something to clear, and report the result:

```ts
import { clearPushInbox, pushInbox, toast } from '@/lib/appmintNative';

export function InboxToolbar({ onChange }: { onChange: () => void }) {
  const count = pushInbox().length;
  if (count === 0) return null;
  return (
    <button
      onClick={() => {
        // false on the web, or in an app built without Appwright Push
        if (clearPushInbox()) { toast('Inbox cleared'); onChange(); }
        else toast('Could not clear the inbox here');
      }}
    >
      Clear all ({count})
    </button>
  );
}
```

Mark everything read instead of deleting it, which loses nothing:

```ts
import { markPushRead, pushInbox } from '@/lib/appmintNative';

export function markAllRead(): number {
  const unread = pushInbox().filter((m) => !m.read).length;
  markPushRead();   // no argument = every message
  return unread;
}
```

**Notes:** tray notifications and your send history in Appwright are not touched; new messages still arrive.

### `appmintNative.clearShared`

```js
clearShared(): void
```

**Example**

Forgets the text or link another app shared into this one, once your app has saved it.

**Returns:** nothing. **Needs:** nothing (does nothing on the web and iOS).

```ts
import { sharedText, clearShared } from '@/lib/appmintNative';

export async function importPendingShare(addNote: (text: string) => Promise<void>): Promise<boolean> {
  const p = sharedText();
  if (!p) return false;
  await addNote(p.text);
  clearShared();          // otherwise sharedText() returns the same share on the next mount
  return true;
}
```

Clear the share only after it is saved, and keep it when saving fails:

```ts
import { sharedText, clearShared, toast } from '@/lib/appmintNative';

export async function saveSharedLink(save: (url: string) => Promise<void>) {
  const p = sharedText();
  if (!p?.url) return;
  try {
    await save(p.url);
    clearShared();
    toast('Saved');
  } catch {
    toast('Could not save, the shared link is still waiting');   // not cleared, so it is not lost
  }
}
```

Handle a share that arrives while the app is open:

```ts
import { useEffect } from 'react';
import { onShared, clearShared } from '@/lib/appmintNative';

export function useIncomingShare(addNote: (text: string) => void) {
  useEffect(() => onShared((p) => {
    addNote(p.subject ? `${p.subject}\n${p.text}` : p.text);
    clearShared();
  }), [addNote]);
}
```

**Notes:** `sharedText()` keeps a share until this is called, on purpose, so a late-mounting screen still sees it. Call `clearShared()` only AFTER the share is safely saved - if saving fails, leave it so the user does not lose it.

### `appmintNative.click`

```js
click() — @/lib/appmintNative
```

**Example**

Plays the app's tap sound once, for your own buttons.

**Returns:** `void`. **Needs:** **Tap sound** switched on in Step 2 (Display) when you build. On the web, and in apps built without Tap sound, it does nothing.

```ts
import { click, haptic } from '@/lib/appmintNative';

export function KeypadKey({ label, onPress }: { label: string; onPress: () => void }) {
  return (
    <button
      onClick={() => {
        click();     // sound
        haptic();    // feel
        onPress();
      }}
    >
      {label}
    </button>
  );
}
```

Let the user switch the sound off in your own settings:

```ts
import { click } from '@/lib/appmintNative';

export function useTapSound(enabled: boolean) {
  return () => { if (enabled) click(); };   // a no-op on the web and in apps built without Tap Sound
}
```

Play it from a list row together with a toast:

```ts
import { click, toast } from '@/lib/appmintNative';

export function Row({ name, onPick }: { name: string; onPick: () => void }) {
  return <li onClick={() => { click(); onPick(); toast(`${name} selected`); }}>{name}</li>;
}
```

**Notes:** With **Follow phone setting** the click is silent when the user turned off "Touch sounds" on the phone. With **Always play on every tap**, every tap already clicks, so extra `click()` calls are not needed. Android only (the shell's `WebToApk.playClick`).

### `appmintNative.Clipboard`

```js
Clipboard.write({ string?, url? }) / Clipboard.read(): Promise<{ value, type }>
```

**Example**

The clipboard in the shape of Capacitor's Clipboard plugin - the same code as `copy()` / `paste()`.

**Returns:** `write()` → `Promise<void>`. `read()` → `Promise<{ value: string; type: 'text/plain' }>` (`value` is `''` when the clipboard is empty). **Needs:** nothing.

```ts
import { Clipboard, can } from '@/lib/appmintNative';

async function copyInvite(code: string) {
  await Clipboard.write({ string: code });
}

async function pasteInvite(): Promise<string> {
  if (can('clipboardRead') === 'none') return '';   // hide the Paste button instead
  try {
    const { value } = await Clipboard.read();
    return value;
  } catch {
    return '';   // the browser denied clipboard access
  }
}
```

Copy a code and confirm with a toast:

```ts
import { Clipboard, toast } from '@/lib/appmintNative';

export async function copyCode(code: string) {
  await Clipboard.write({ string: code });
  toast('Code copied');
}
```

Write a link when there is no text, and treat an empty clipboard as nothing to paste:

```ts
import { Clipboard } from '@/lib/appmintNative';

export async function copyUrl(url: string) {
  await Clipboard.write({ url });   // used only because string is empty
}

export async function readCode(): Promise<string | null> {
  try {
    const { value } = await Clipboard.read();
    return value.trim() || null;    // '' = the clipboard is empty
  } catch {
    return null;                    // the browser denied access
  }
}
```

**Notes:** In the app it uses the Capacitor Clipboard plugin; in a browser `navigator.clipboard`. `write` sends only `string` (or `url` when `string` is empty) - no images. In a browser, `read()` can throw when the user denies access; in the app an empty clipboard is `''`, not an error. Ask `can('clipboardRead')` before you show a Paste control.

### `appmintNative.collapsingHeader`

```js
collapsingHeader(header: HTMLElement, opts?: { scroller?: HTMLElement | Window; range?: number }): () => void
```

**Example**

Makes a large-title header react to scrolling the way native ones do, by publishing a 0 → 1 value your CSS can use.

**Returns:** an unsubscribe function that also removes the variable and the attribute. **Needs:** nothing.

It sets `--collapse` (from `0` at the top to `1` after `range` px of scroll) on the header, and adds `data-collapsed` once fully collapsed:

```ts
import { useEffect, useRef } from 'react';
import { collapsingHeader } from '@/lib/appmintNative';

export function NotesScreen() {
  const headerRef = useRef<HTMLElement>(null);
  const listRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!headerRef.current || !listRef.current) return;
    return collapsingHeader(headerRef.current, { scroller: listRef.current, range: 80 });
  }, []);

  return (
    <div className="flex h-full flex-col">
      <header ref={headerRef} className="app-header">Notes</header>
      <div ref={listRef} className="flex-1 overflow-y-auto">{/* rows */}</div>
    </div>
  );
}
```

```css
.app-header { font-size: calc(28px - 10px * var(--collapse, 0)); padding-top: var(--safe-top); }
.app-header[data-collapsed] { box-shadow: 0 1px 0 var(--border); }
```

Stop listening from plain script; this also removes `--collapse` and `data-collapsed`:

```ts
import { collapsingHeader } from '@/lib/appmintNative';

const header = document.querySelector<HTMLElement>('.app-header')!;
const stop = collapsingHeader(header);   // whole-page scroll, default 64 px range
addEventListener('pagehide', stop);
```

**Notes:**
- Default `scroller` is the window (a page that scrolls as a whole); default `range` is 64 px.
- The scroll listener is passive and updates once per animation frame, so it does not slow scrolling.

### `appmintNative.composeSms`

```js
composeSms(phoneNumber: string, message: string): boolean
```

**Example**

Opens the messaging app with the number and text filled in; the user presses send. Works in the Android app (shell bridge) and on the web (an `sms:` link).

**Returns:** `boolean` right away - `true` when the messaging app was asked to open. **Needs:** no permission.

**A "Text the shop" button.**

```ts
import { composeSms, can } from '@/lib/appmintNative';

export function setUpTextButton(button: HTMLButtonElement) {
  if (can('smsCompose') === 'none') { button.hidden = true; return; }
  button.addEventListener('click', () => {
    const opened = composeSms('+911234567890', 'Hi, is my order ready?');
    if (!opened) alert('Could not open the messaging app.');
  });
}
```

**A reusable hook that hides the button when there is no number.**

```ts
import { composeSms, can } from '@/lib/appmintNative';

export function useTextShop(phone: string) {
  const available = can('smsCompose') !== 'none' && phone.trim() !== '';
  return {
    available,
    send: (orderId: string) => composeSms(phone, `Hi, about order ${orderId}.`),
  };
}
```

**Invite a friend; the user presses send.**

```ts
import { composeSms, toast } from '@/lib/appmintNative';

export function inviteFriend(number: string, link: string) {
  if (!composeSms(number, `Join me: ${link}`)) toast('Could not open the messaging app');
}
```

**Notes:** Prefer this over `sendSms()` for anything the user writes or approves - nothing leaves the phone without the user pressing send, and it is the only SMS option that exists on iOS. On the web it navigates to `sms:…?body=…`. A `composeSms(` call does not switch on **SMS (read & send)** when you build, so it adds no Play-restricted permission.

### `appmintNative.copy`

```js
copy(text: string): Promise<boolean>
```

**Example**

Puts text on the system clipboard.

**Returns:** `Promise<boolean>` - `true` when the text was copied, `false` when the clipboard is not available or the browser refused. **Needs:** nothing.

```ts
import { can, copy, haptic, toast } from '@/lib/appmintNative';

export function CopyLink({ link }: { link: string }) {
  if (can('clipboardWrite') === 'none') return null;
  return (
    <button
      onClick={async () => {
        if (await copy(link)) { haptic('success'); toast('Link copied'); }
        else toast('Could not copy the link');
      }}
    >
      Copy link
    </button>
  );
}
```

The Capacitor-shaped form does the same thing:

```ts
import { Clipboard } from '@/lib/appmintNative';

await Clipboard.write({ string: 'ABC-123' });
```

Report both outcomes, with a haptic only on success:

```ts
import { copy, haptic, toast } from '@/lib/appmintNative';

export async function copyCoupon(code: string) {
  const ok = await copy(code);
  if (ok) haptic('success');
  toast(ok ? `Copied ${code}` : 'Copy failed, press and hold to copy');
  return ok;
}
```

**Notes:** In the app the Capacitor Clipboard plugin writes the text (a plugin error rejects the promise instead of returning `false`). In a browser it uses `navigator.clipboard.writeText`, which needs an https page and usually a user tap.

### `appmintNative.copyImage`

```js
import { copyImage } from '@/lib/appmintNative'
```

**Example**

Copies an image to the clipboard so it pastes into chat and mail apps: the app's clipboard in the Android app, the Clipboard plugin on iPhone, the Async Clipboard API on the web.

**Returns:** `Promise<boolean>` - `true` once copied. **Needs:** nothing; gate the control on `can('clipboardImage')`.

```ts
import { can, copyImage, toast } from '@/lib/appmintNative';

export async function copyQr(canvas: HTMLCanvasElement) {
  if (can('clipboardImage') === 'none') return;
  const png = await new Promise<Blob | null>((r) => canvas.toBlob(r, 'image/png'));
  if (png && (await copyImage(png))) toast('QR code copied');
  else toast('Could not copy the image');
}
```

Copy a photo from the camera, then confirm it with a toast.

```ts
import { camera, copyImage, toast } from '@/lib/appmintNative';

export async function copySnapshot() {
  const photo = await camera.take({ source: 'camera' });
  if (!photo) return;                                  // the user cancelled
  const blob = await (await fetch(photo.dataUrl)).blob();
  toast((await copyImage(blob)) ? 'Photo copied' : 'Could not copy the photo');
}
```

Show the button only where the clipboard accepts images, and copy an image from a URL.

```ts
import { useEffect, useState } from 'react';
import { can, copyImage, toast } from '@/lib/appmintNative';

export function CopyLogoButton({ src }: { src: string }) {
  const [ok, setOk] = useState(false);
  useEffect(() => { setOk(can('clipboardImage') !== 'none'); }, []);
  if (!ok) return null;
  return (
    <button onClick={async () => {
      const blob = await (await fetch(src)).blob();
      toast((await copyImage(blob)) ? 'Logo copied' : 'Copy failed');
    }}>
      Copy logo
    </button>
  );
}
```

**Notes:** PNG is the safest type everywhere; the Android app also accepts JPEG and WebP.

### `appmintNative.Device`

```js
Device.getInfo() / Device.getId() / Device.getLanguageCode()
```

**Example**

The `@capacitor/device` shape: phone model, OS version, a stable ID and the language. It uses the Capacitor Device plugin in both installed apps and the shell / browser answer elsewhere.

**Returns:** `getInfo()` → `Promise<DeviceInfo & { operatingSystem, isVirtual }>` (with Capacitor also `manufacturer`, `webViewVersion`, …); `getId()` → `Promise<{ identifier }>`; `getLanguageCode()` → `Promise<{ value }>` (two letters, e.g. `"en"`). **Needs:** nothing.

**Show the model and OS version on an About screen.**

```ts
import { Device } from '@/lib/appmintNative';

export async function aboutText(): Promise<string> {
  const info = await Device.getInfo();
  if (info.platform === 'web') return `Browser (${info.language})`;
  return `${info.model ?? 'Unknown model'} · ${info.operatingSystem} ${info.osVersion ?? ''}` +
    (info.isVirtual ? ' · emulator' : '');
}
```

**A stable ID and the language code.**

```ts
import { Device } from '@/lib/appmintNative';

const { identifier } = await Device.getId();       // Capacitor ID in the apps, installId() elsewhere
const { value: lang } = await Device.getLanguageCode();
await fetch('https://api.example.com/hello', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ device: identifier, lang }),
});
```

**Show the app version and the device model in a settings footer.** The sync report gives the app version; the plugin adds the operating system.

```ts
import { deviceInfo, Device } from '@/lib/appmintNative';

export async function footerText(): Promise<string> {
  const base = deviceInfo();                            // sync: platform, language, model, appVersion
  const rich = await Device.getInfo();                  // async: adds operatingSystem, isVirtual
  const app = base.appVersion ? `v${base.appVersion}` : 'web';
  return `${app} · ${rich.model ?? 'browser'} · ${rich.operatingSystem}`;
}
```

**Notes:** Prefer `Device.getInfo()` over the sync `deviceInfo()` when you need `model` or `osVersion` on iOS - the sync one fills them on Android only. On the web `operatingSystem` is `'unknown'` and `model` is empty. `getId()` on the web is the `localStorage` ID from `installId()` and throws when storage is blocked.

### `appmintNative.deviceInfo`

```js
deviceInfo(): DeviceInfo
```

**Example**

Tells you synchronously where the app runs (`platform`) and the language, plus the Android shell's full device report inside the Android app.

**Returns:** `DeviceInfo` right away: always `{ platform: 'android' | 'ios' | 'web', language }`. In the Android app it also fills `model`, `manufacturer`, `osVersion` (e.g. `"14"`) and `appVersion` (e.g. `"1.4.0"`), and carries every key of `WebToApk.getDeviceInfo()`: `android`, `hardware`, `screen`, `app`, `runtime`, `installId`. On iOS and the web it adds `osVersion` (the user agent). **Needs:** nothing.

**Branch on the platform during render.**

```ts
import { deviceInfo } from '@/lib/appmintNative';

const info = deviceInfo();
const isAndroidApp = info.platform === 'android';
const lang = info.language;                         // e.g. "en-IN"
```

**Read Android details.** The common fields are at the top; the rest of the shell's report stays in its nested groups.

```ts
import { deviceInfo } from '@/lib/appmintNative';

const info: any = deviceInfo();
if (info.platform === 'android') {
  console.log(`${info.manufacturer} ${info.model}`);        // "OPPO CPH2269"
  console.log(`Android ${info.osVersion}, app ${info.appVersion}`);
  console.log(`Battery ${info.runtime?.batteryLevel}%`);
}
```

**Pick the right control from the platform.**

```tsx
import { deviceInfo } from '@/lib/appmintNative';

export function ShareHint() {
  const { platform, language } = deviceInfo();
  return platform === 'android'
    ? <p>Tap Share to send this from your phone.</p>
    : <p>Copy the link to share it ({language}).</p>;
}
```

**Notes:** `model` and `appVersion` are filled in the Android app only; on iOS call `Device.getInfo()` (async), which returns `model` and `osVersion` there. On the web `osVersion` is the browser's user-agent string, not an OS version. It throws if the shell's answer is not valid JSON (a real defect, never expected).

### `appmintNative.Dialog`

```js
Dialog.alert / Dialog.confirm / Dialog.prompt
```

**Example**

Real system dialogs: a message, a yes/no question, a one-line text question. Android uses the Capacitor Dialog plugin; iOS and the web use the browser's own `alert` / `confirm` / `prompt`.

**Returns:** `alert({ title?, message })` → `Promise<void>`. `confirm({ title?, message, okButtonTitle?, cancelButtonTitle? })` → `Promise<{ value: boolean }>`. `prompt({ title?, message, inputPlaceholder?, inputText? })` → `Promise<{ value: string; cancelled: boolean }>`. None of them reject. **Needs:** nothing.

```ts
import { Dialog } from '@/lib/appmintNative';

async function deleteList(id: string) {
  const { value } = await Dialog.confirm({
    title: 'Delete list?',
    message: 'All items in it will be removed.',
    okButtonTitle: 'Delete',
    cancelButtonTitle: 'Keep',
  });
  if (value) await removeList(id);
}
```

**Alert and prompt:**

```ts
import { Dialog } from '@/lib/appmintNative';

await Dialog.alert({ title: 'Offline', message: 'Your changes will sync when you are back online.' });

const r = await Dialog.prompt({ title: 'Rename', message: 'New name', inputText: current, inputPlaceholder: 'List name' });
if (!r.cancelled && r.value.trim()) rename(r.value.trim());
```

**Confirm before signing out.** Dismissing the dialog counts as "no".

```ts
import { Dialog, toast } from '@/lib/appmintNative';

export async function signOut(doSignOut: () => Promise<void>) {
  const { value } = await Dialog.confirm({
    title: 'Sign out?',
    message: 'You will need to sign in again.',
    okButtonTitle: 'Sign out',
    cancelButtonTitle: 'Stay',
  });
  if (!value) return;
  await doSignOut();
  toast('Signed out');
}
```

**Notes:** Dismissing a confirm gives `{ value: false }`; dismissing a prompt gives `{ value: '', cancelled: true }`. Dialogs block the screen - use them for real decisions, and `toast()` for "Saved"-style messages. `prompt` does not pass custom button titles to the plugin.

### `appmintNative.dismissPushMessage`

```js
import { dismissPushMessage } from '@/lib/appmintNative'
```

**Example**

Hides one message from the Appwright Push inbox (swipe-away). Undo it with `restorePushMessage(id)`.

**Returns:** `boolean` synchronously - `true` if it was hidden. Always `false` on the web and iOS. **Needs:** Android: **Appwright Push** turned on in the Integrate step when you build.

```ts
import { dismissPushMessage, restorePushMessage, pushInbox } from '@/lib/appmintNative';

export function swipeAway(id: string, setItems: (v: ReturnType<typeof pushInbox>) => void,
                          showUndo: (onUndo: () => void) => void) {
  if (!dismissPushMessage(id)) return;
  setItems(pushInbox());
  showUndo(() => {                         // e.g. a 5-second "Undo" snackbar
    restorePushMessage(id);
    setItems(pushInbox());
  });
}
```

Show the inbox with a swipe handler and an undo state, so a swipe is never final.

```tsx
import { useState } from 'react';
import { pushInbox, dismissPushMessage, restorePushMessage } from '@/lib/appmintNative';

export function Inbox() {
  const [items, setItems] = useState(pushInbox());
  const [undoId, setUndoId] = useState<string | null>(null);

  const swipe = (id: string) => {
    if (!dismissPushMessage(id)) return;     // false on the web or when Appwright Push is off
    setItems(pushInbox());
    setUndoId(id);
  };
  const undo = () => {
    if (undoId && restorePushMessage(undoId)) setItems(pushInbox());
    setUndoId(null);
  };

  return (
    <ul>
      {items.map((m) => <li key={m.id} onClick={() => swipe(m.id)}>{m.title}</li>)}
      {undoId && <button onClick={undo}>Undo</button>}
    </ul>
  );
}
```

Hide every read message at once, and report how many were hidden.

```ts
import { pushInbox, dismissPushMessage, toast } from '@/lib/appmintNative';

export function tidyReadMessages(): number {
  const hidden = pushInbox().filter((m) => m.read && dismissPushMessage(m.id)).length;
  toast(hidden ? `${hidden} message(s) hidden` : 'Nothing to hide');
  return hidden;
}
```

**Notes:** only this phone's inbox changes; the tray notification and your send history stay.

### `appmintNative.docScanner`

```js
docScanner.scan({ pageLimit?, pdf?, quality? }): Promise<{ pages, pdfPath? } | null> / docScanner.isSupported(): Promise<boolean>
```

**Example**

Opens the system document scanner - edge detection, straightening, several pages - ML Kit on Android, VisionKit on iOS. Pages come back as JPEG data URLs.

**Returns:** `Promise<{ pages: string[]; pdfPath?: string } | null>` - `null` when the user cancels, the scanner cannot start, or on the web. `isSupported()` → `Promise<boolean>`: whether this phone can scan (Google Play services on Android, VisionKit on iOS); `false` on the web. **Needs:** nothing to switch on (on Android the scanner runs in Google Play services and needs no camera permission).

```ts
import { docScanner, can } from '@/lib/appmintNative';

export function ScanButton({ onPages }: { onPages: (p: string[]) => void }) {
  if (can('docScanner') === 'none') return null;     // no web scanner: hide the button
  return (
    <button onClick={async () => {
      const res = await docScanner.scan({ pageLimit: 3, quality: 0.8 });
      if (res) onPages(res.pages);                   // each a data:image/jpeg;base64,… URL
    }}>
      Scan document
    </button>
  );
}
```

Ask the phone first, because an older device may lack the scanner, then keep the pages.

```tsx
import { useEffect, useState } from 'react';
import { docScanner, can } from '@/lib/appmintNative';

export function ScanPanel() {
  const [ready, setReady] = useState(false);
  const [pages, setPages] = useState<string[]>([]);

  useEffect(() => {
    if (can('docScanner') === 'none') return;          // web: no scanner at all
    docScanner.isSupported().then(setReady);           // false without Google Play services
  }, []);

  if (!ready) return <p>Scanning is not available on this phone.</p>;
  return (
    <>
      <button onClick={async () => {
        const res = await docScanner.scan({ pageLimit: 5 });
        if (!res) return;                              // cancelled or could not start
        setPages((p) => [...p, ...res.pages]);
      }}>Scan pages</button>
      {pages.map((src, i) => <img key={i} src={src} alt={`Page ${i + 1}`} width={120} />)}
    </>
  );
}
```

Scan, then upload the first page with the file-transfer helper.

```ts
import { docScanner, fileTransfer } from '@/lib/appmintNative';

export async function scanAndUpload(): Promise<boolean> {
  const res = await docScanner.scan({ pageLimit: 1, quality: 0.7 });
  if (!res || res.pages.length === 0) return false;
  const blob = await (await fetch(res.pages[0])).blob();
  const file = new File([blob], 'page-1.jpg', { type: 'image/jpeg' });
  const r = await fileTransfer.upload('https://example.com/api/scans', file, { fieldName: 'scan' });
  return r.responseCode >= 200 && r.responseCode < 300;
}
```

**Notes:** `pageLimit` `0` (default) = no limit; `quality` 0.1-1 (default 0.85). With `pdf: true`, `pdfPath` is a file path in the app's cache (Android), not a URL the page can load. Data URLs are large - resize or upload them rather than keeping many in `localStorage`. For a plain photo use `camera.take()`.

### `appmintNative.enableNotifications`

```js
enableNotifications(): Promise<EnableNotificationsResult>
```

**Example**

Brings a user who denied or skipped notifications back, from a button they tapped: the permission dialog while it can still be shown, otherwise (Android app) this app's notification settings.

**Returns:** `Promise<{ state, changed, openedSettings }>` - `state` is `'granted' | 'prompt' | 'blocked' | 'unavailable'`, `changed` is true when it differs from before the call, `openedSettings` is true when the settings screen was used. Never rejects. On the web it asks through `Notification.requestPermission()`. **Needs:** notifications in the build (they are on whenever the app sends or receives notifications).

```ts
import { enableNotifications, getNotificationState } from '@/lib/appmintNative';

export async function TurnOnNotificationsBanner(el: HTMLElement) {
  const state = await getNotificationState();
  el.hidden = !(state === 'prompt' || state === 'blocked');
  el.querySelector('button')!.addEventListener('click', async () => {
    const r = await enableNotifications();
    el.hidden = r.state === 'granted';
  });
}
```

**Notes:** show the banner only for `'prompt'` and `'blocked'`; hide it for `'granted'` and `'unavailable'`. Ask from a tap, never on launch.

### `appmintNative.fileTransfer`

```js
fileTransfer.download / fileTransfer.upload from '@/lib/appmintNative'
```

**Example**

Downloads or uploads a file with a progress callback. In the app the transfer runs natively (Capacitor FileTransfer); on the web it uses `fetch` / `XMLHttpRequest` with the same callback.

**Returns:** `download(url, fileName, opts?)` → `Promise<{ path?: string; blob?: Blob }>` - `path` (a file in the app's cache) in the app, `blob` on the web (where the file is also saved through the browser). `upload(url, file, opts?)` → `Promise<{ responseCode: number; responseBody: string }>`. Both reject on failure. **Needs:** nothing to switch on.

Download with a progress bar:

```ts
import { fileTransfer, fileViewer } from '@/lib/appmintNative';

async function getReport(bar: HTMLProgressElement) {
  try {
    const r = await fileTransfer.download('https://example.com/report.pdf', 'report.pdf', {
      headers: { Authorization: `Bearer ${token}` },
      onProgress: (p) => { bar.value = p.fraction; },     // {bytes, total, fraction 0..1}
    });
    if (r.path) await fileViewer.open(r.path);
  } catch (e) {
    alert(`Download failed: ${String(e)}`);
  }
}
```

Upload a `File` from an `<input type="file">`:

```ts
import { fileTransfer } from '@/lib/appmintNative';

async function sendPhoto(file: File) {
  const r = await fileTransfer.upload('https://example.com/api/photos', file, {
    fieldName: 'photo',                // form field name, default 'file'
    headers: { Authorization: `Bearer ${token}` },
    onProgress: (p) => console.log(Math.round(p.fraction * 100) + '%'),
  });
  if (r.responseCode >= 200 && r.responseCode < 300) console.log('uploaded', r.responseBody);
  else alert(`Upload failed (${r.responseCode})`);
}
```

Download with a progress state, then open the file, in one hook.

```ts
import { useState } from 'react';
import { fileTransfer, fileViewer } from '@/lib/appmintNative';

export function useOpenDocument() {
  const [percent, setPercent] = useState(0);
  const [error, setError] = useState<string | null>(null);

  const run = async (url: string, name: string) => {
    setError(null);
    try {
      const r = await fileTransfer.download(url, name, { onProgress: (p) => setPercent(Math.round(p.fraction * 100)) });
      if (r.path) await fileViewer.open(r.path);        // in the app; on the web the browser saved it
    } catch (e) {
      setError(String(e));
    }
  };
  return { percent, error, run };
}
```

**Notes:** `fraction` is 0 while the server sends no length. In the app a download lands in the app's CACHE folder (not the user's Downloads); to give the user a copy, use `saveFile`. For an upload the app first copies the file into its cache, so very large files need that much free space. `method` (default `'POST'`) and `fileName` are optional upload options.

### `appmintNative.fileViewer`

```js
fileViewer.open(target) from '@/lib/appmintNative'
```

**Example**

Opens a document (PDF, image, office file) in the phone's viewer app. On the web it opens the address in a new tab.

**Returns:** `Promise<boolean>` - `true` when the viewer was opened, `false` when it failed (no app for that type, bad path) or the target cannot be opened on this surface. **Needs:** nothing to switch on; it uses the Capacitor FileViewer plugin that ships in the app.

Open a document from the web:

```ts
import { fileViewer } from '@/lib/appmintNative';

async function openManual() {
  const ok = await fileViewer.open('https://example.com/manual.pdf');
  if (!ok) alert('No app on this phone can open this file.');
}
```

Open a file you downloaded first with `fileTransfer.download` (a local path in the app):

```ts
import { fileTransfer, fileViewer } from '@/lib/appmintNative';

const r = await fileTransfer.download('https://example.com/invoice.pdf', 'invoice.pdf');
if (r.path) await fileViewer.open(r.path);            // in the app
else if (r.blob) console.log('saved by the browser'); // on the web
```

Open a link from a button and tell the user when no viewer could open it.

```tsx
import { fileViewer, toast } from '@/lib/appmintNative';

export function ManualButton({ url }: { url: string }) {
  return (
    <button onClick={async () => {
      if (!(await fileViewer.open(url))) toast('No app can open this file');
    }}>
      View manual
    </button>
  );
}
```

**Notes:** An `http(s)` target uses `openDocumentFromUrl`; anything else is treated as a local path (`file://` is stripped). On the web only `http(s):`, `blob:` and `data:` targets open; a local path answers `false`.

### `appmintNative.folder`

```js
import { folder } from '@/lib/appmintNative'
```

**Example**

A whole folder the user grants once and the app keeps across launches, with real file operations inside it (Android: the shell's folder bridge; iOS: the AppwrightFolders plugin).

**Returns:** every member is async - `connected()` → `boolean`, `label()` → `string`, `request()` → `boolean` (false on cancel), `disconnect()` → `void`, `list(path)` → `[{ name, uri, isDirectory, length, lastModified }]`, `readText(path)` → `string | null` (null = no such file), `writeText(path, text)` / `mkdir(path)` / `rename(path, newName)` / `remove(path, recursive)` → `boolean`, `stat(path)` → `{ name, size, mime, lastModified, uri, isDirectory, canWrite } | null`. **Needs:** an AI-built app turns the **Native Folder Access (SAF)** switch on by itself when the code uses `folder`; on a hand-made build tick it in Step 4 (Access).

Connect, show the folder, and disconnect:

```ts
import { can, folder } from '@/lib/appmintNative';

export async function setupFolderPanel(el: { name: HTMLElement; connect: HTMLButtonElement; disconnect: HTMLButtonElement }) {
  if (can('folderAccess') === 'none') { el.connect.hidden = true; return; }   // web build, or not shipped

  const refresh = async () => {
    const on = await folder.connected();
    el.name.textContent = on ? `Saving to: ${await folder.label()}` : 'No folder chosen';
    el.connect.hidden = on;
    el.disconnect.hidden = !on;
  };

  el.connect.onclick = async () => { await folder.request(); await refresh(); };   // from a tap only
  el.disconnect.onclick = async () => { await folder.disconnect(); await refresh(); };
  await refresh();
}
```

Read, write, list, rename and delete - every path is relative to the granted folder (`''` is its root):

```ts
import { folder } from '@/lib/appmintNative';

await folder.mkdir('notes');
const saved = await folder.writeText('notes/today.md', '# Today\n');   // parents created
const text = await folder.readText('notes/today.md');                  // null if missing
const entries = await folder.list('notes');
entries.forEach((e) => console.log(e.isDirectory ? 'dir ' : 'file', e.name, e.length));

const info = await folder.stat('notes/today.md');
if (info) console.log(info.size, 'bytes, writable:', info.canWrite);

await folder.rename('notes/today.md', 'journal.md');                   // a new NAME, not a path
await folder.remove('notes', true);                                    // recursive for a non-empty folder
```

Back up a note to the granted folder, and say so plainly when no folder is connected.

```ts
import { can, folder, toast } from '@/lib/appmintNative';

export async function backupNote(name: string, text: string) {
  if (can('folderAccess') === 'none') return;
  if (!(await folder.connected()) && !(await folder.request())) {
    toast('Choose a folder to save backups');
    return;
  }
  const ok = await folder.writeText(`backups/${name}.md`, text);
  toast(ok ? 'Backed up' : 'Could not write to the folder');
}
```

**Notes:** `can('folderAccess')` is `'none'` on the web - there is no persistent-folder equivalent in a phone browser, so do not render the feature there (use `pickFile()` / `saveFile()` for single files). Call `request()` only from a user tap. `..` and absolute paths are refused. The granted folder itself can never be removed. `stat()` works for files and sub-folders (a folder has `isDirectory: true` and `size: 0`); `stat('')` - the root - is `null`, use `label()` for it. `request()` can be called again after a cancel.

### `appmintNative.fullscreen`

```js
fullscreen.enter() / fullscreen.exit() / fullscreen.isOn()
```

**Example**

Hides the system bars (in the app) or enters browser fullscreen (on the web), for a video, a game or a reading view.

**Returns:** `enter()` and `exit()` return `Promise<void>` and never reject; `isOn()` returns `boolean` synchronously. **Needs:** nothing.

```ts
import { useState } from 'react';
import { can, fullscreen } from '@/lib/appmintNative';

export function FullscreenToggle() {
  const [on, setOn] = useState(fullscreen.isOn());
  if (can('fullscreen') === 'none') return null;
  return (
    <button
      onClick={async () => {
        if (fullscreen.isOn()) await fullscreen.exit(); else await fullscreen.enter();
        setOn(fullscreen.isOn());
      }}
    >
      {on ? 'Exit full screen' : 'Full screen'}
    </button>
  );
}
```

Leave fullscreen when the view closes:

```ts
import { useEffect } from 'react';
import { fullscreen } from '@/lib/appmintNative';

export function useFullscreenWhileOpen() {
  useEffect(() => {
    void fullscreen.enter();
    return () => { void fullscreen.exit(); };
  }, []);
}
```

Hide the system bars for a reading view and follow the state from a button.

```ts
import { useEffect, useState } from 'react';
import { fullscreen, can } from '@/lib/appmintNative';

export function useReaderMode() {
  const [on, setOn] = useState(false);
  useEffect(() => () => { void fullscreen.exit(); }, []);        // always leave on unmount
  const toggle = async () => {
    if (can('fullscreen') === 'none') return;
    if (fullscreen.isOn()) await fullscreen.exit(); else await fullscreen.enter();
    setOn(fullscreen.isOn());                                   // a refused web request leaves it off
  };
  return { on, toggle };
}
```

**Notes:**
- In the app this is the shell's own fullscreen (status and navigation bars hide); it is always available to the page.
- On the web the Fullscreen API works only from a user tap. A refused request is ignored quietly, so read `isOn()` afterwards instead of assuming.

### `appmintNative.Geolocation`

```js
Geolocation.getCurrentPosition / watchPosition / clearWatch / checkPermissions / requestPermissions
```

**Example**

The `@capacitor/geolocation` shape: the Capacitor Geolocation plugin in both installed apps, the standard `navigator.geolocation` on the web. All return the W3C position object.

**Returns:** `getCurrentPosition(opts)` → `Promise<GeolocationPosition>` (rejects on refusal, timeout or no location); `watchPosition(opts, cb)` → `Promise<string>` (the watch id); `clearWatch({ id })` → `Promise<void>`; `checkPermissions()` / `requestPermissions()` → `Promise<{ location: 'granted' | 'denied' | 'prompt' | … }>`. **Needs:** Android: **GPS** / **Location** in Step 4 (Access) - the build turns both on when your code calls `getCurrentPosition` or `watchPosition`. iOS: the location usage text is added for you.

**Where am I, once.**

```ts
import { Geolocation } from '@/lib/appmintNative';

export async function currentSpot(): Promise<string> {
  try {
    const perm = await Geolocation.checkPermissions();
    if (perm.location !== 'granted') {
      const asked = await Geolocation.requestPermissions();
      if (asked.location !== 'granted') return 'Location is off for this app.';
    }
    const pos = await Geolocation.getCurrentPosition({ enableHighAccuracy: true, timeout: 15000 });
    return `${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)}`;
  } catch {
    return 'Could not get your location. Is location turned on?';
  }
}
```

**Track a run, then stop.** The callback gets `null` and an error when a fix fails.

```ts
import { Geolocation } from '@/lib/appmintNative';

let watchId: string | null = null;

export async function startRun(onPoint: (lat: number, lng: number) => void) {
  watchId = await Geolocation.watchPosition({ enableHighAccuracy: true }, (pos, err) => {
    if (!pos) { console.warn('location error', err); return; }
    onPoint(pos.coords.latitude, pos.coords.longitude);
  });
}

export async function stopRun() {
  if (watchId) { await Geolocation.clearWatch({ id: watchId }); watchId = null; }
}
```

**Get a one-shot position.** Returns `null` when the user refuses.

```ts
import { Geolocation } from '@/lib/appmintNative';

export async function nearestCoords(): Promise<{ lat: number; lng: number } | null> {
  try {
    const pos = await Geolocation.getCurrentPosition({ timeout: 10000 });
    return { lat: pos.coords.latitude, lng: pos.coords.longitude };
  } catch {
    return null;                          // refused, timed out or location is off
  }
}
```

**Notes:** Plain `navigator.geolocation` also works in the app; use this when you want the Capacitor shape. On Android 12+ the user may allow only approximate location - you still get a position, just less accurate. On the web `requestPermissions()` makes one real position request to trigger the browser prompt. Location is personal data: declare it in your Play Data safety form.

### `appmintNative.getNotificationState`

```js
getNotificationState(): Promise<NotificationState>
```

**Example**

Whether notifications are on, and if not whether the app can still ask or only Settings can fix it.

**Returns:** `Promise<'granted' | 'prompt' | 'blocked' | 'unavailable'>`. On the web, `Notification.permission` mapped the same way (`default` → `'prompt'`, `denied` → `'blocked'`); `'unavailable'` where there are no notifications at all. **Needs:** nothing.

```ts
import { getNotificationState } from '@/lib/appmintNative';

export async function shouldShowNotifBanner(): Promise<boolean> {
  const state = await getNotificationState();
  return state === 'prompt' || state === 'blocked';
}
```

**Notes:** check again when the app comes back to the foreground (`visibilitychange`) - the user may have changed it in Settings.

### `appmintNative.getPushTags`

```js
import { getPushTags } from '@/lib/appmintNative'
```

**Example**

Reads this install's Appwright Push tags as an object.

**Returns:** `Record<string, string>` synchronously - e.g. `{ plan: 'pro', cart: 'open' }`; `{}` when there are none, on the web and on iOS. **Needs:** Android: **Appwright Push** turned on in the Integrate step when you build.

```ts
import { getPushTags, setPushTag } from '@/lib/appmintNative';

// Every setPushTag makes the install check in, so skip the calls that change nothing.
export function ensurePushTag(key: string, value: string) {
  if ((getPushTags()[key] ?? '') !== value) setPushTag(key, value);
}

ensurePushTag('plan', 'pro');
```

Set several tags in one change with `setPushTags` when a screen changes more than one, and empty a value to remove its tag:

```ts
import { setPushTags } from '@/lib/appmintNative';

// Checkout finished: the cart tag goes away and the plan tag is set, all saved or none.
export function onCheckoutDone(plan: string) {
  const saved = setPushTags({ cart: '', plan });
  if (!saved) console.warn('push tags not saved (web, Appwright Push off, or a refused key or value)');
}
```

Read the tags back to show a hint only when this install has a tag:

```ts
import { getPushTags } from '@/lib/appmintNative';

export function OffersHint() {
  const tags = getPushTags();                       // {} on the web
  if (Object.keys(tags).length === 0) return null;
  return <p>Offers for the {tags.plan ?? 'standard'} plan are on.</p>;
}
```

**Notes:** this is what the phone holds; the send center sees it after the install's next check-in. Android only: on iOS and the web it does nothing.

### `appmintNative.getPushUser`

```js
import { getPushUser } from '@/lib/appmintNative'
```

**Example**

Reads the user id this install last gave Appwright Push with `setPushUser`.

**Returns:** `string` synchronously - the saved id, or `''` when signed out, never set, on the web or on iOS. **Needs:** Android: **Appwright Push** turned on in the Integrate step when you build.

```ts
import { getPushUser, setPushUser } from '@/lib/appmintNative';

// On app start: the session expired while the app was closed, so stop personal messages here.
export function syncPushUser(currentUserId: string | null) {
  const want = currentUserId ?? '';
  if (getPushUser() !== want) setPushUser(want);
}
```

Call `setPushUser` with the account id after sign-in and with `''` on sign-out; it returns `false` when the id is refused or Appwright Push is off:

```ts
import { setPushUser } from '@/lib/appmintNative';

export function onSignedIn(userId: string) {
  if (!setPushUser(userId)) console.warn('push user id not saved');   // ids only: no email or phone number
}

export function onSignedOut() {
  setPushUser('');
}
```

Read the saved id in a React hook, together with a tag that belongs to that user:

```ts
import { useEffect, useState } from 'react';
import { getPushUser, setPushTag } from '@/lib/appmintNative';

export function usePushUser(plan: string) {
  const [id] = useState(getPushUser);
  useEffect(() => {
    if (id) setPushTag('plan', plan);     // only tag installs that belong to a signed-in user
  }, [id, plan]);
  return id;
}
```

**Notes:** this is what the phone holds; the send center sees a change after the install's next check-in, a few seconds later when online. Android only: on iOS and the web it does nothing.

### `appmintNative.haptic`

```js
haptic(kind?) — @/lib/appmintNative
```

**Example**

A short, native-feeling vibration for taps and results. Call it on every meaningful tap.

**Returns:** `void` (fire and forget). `kind`: `'light'` (default), `'medium'`, `'heavy'`, `'selection'`, `'success'`, `'warning'`, `'error'`. **Needs:** **Vibrate** in Step 4 (Access); AI builds switch it on for you when the code calls `haptic`.

```ts
import { haptic } from '@/lib/appmintNative';

export function SaveButton({ onSave }: { onSave: () => Promise<boolean> }) {
  return (
    <button
      onClick={async () => {
        haptic();                          // light tap on press
        const ok = await onSave();
        haptic(ok ? 'success' : 'error');  // result feedback
      }}
    >
      Save
    </button>
  );
}
```

For tabs, toggles and pickers use `'selection'`; for a delete confirm use `'heavy'`:

```ts
import { haptic } from '@/lib/appmintNative';

function onTabChange(tab: string) { haptic('selection'); setTab(tab); }
function onConfirmDelete() { haptic('heavy'); deleteItem(); }
```

A like button that ticks lightly on and firmer off:

```ts
import { haptic } from '@/lib/appmintNative';

export function LikeButton({ liked, onToggle }: { liked: boolean; onToggle: () => void }) {
  return (
    <button aria-pressed={liked} onClick={() => { haptic(liked ? 'light' : 'medium'); onToggle(); }}>
      {liked ? 'Liked' : 'Like'}
    </button>
  );
}
```

**Notes:** In the Android and iOS apps this uses the Capacitor Haptics plugin (`impact`, `notification`, `selectionChanged`). On the web it uses `navigator.vibrate` where the browser has it, and does nothing elsewhere; it never throws. For longer rhythms use `hapticPlay`.

### `appmintNative.hapticPlay`

```js
hapticPlay(pattern) — @/lib/appmintNative
```

**Example**

Plays a rich vibration pattern for a special moment (a win, a streak, a heartbeat). Use `haptic()` for normal taps.

**Returns:** `Promise<boolean>`: `true` when the pattern played, `false` when it could not. **Needs:** **Vibrate** in Step 4 (Access).

Named patterns: `'success-ramp'`, `'heartbeat'`, `'tick-tick-thud'`, `'drum-roll'`, `'nudge'`.

```ts
import { hapticPlay } from '@/lib/appmintNative';

async function onLevelComplete() {
  const felt = await hapticPlay('success-ramp');
  if (!felt) console.log('No vibration on this device');
  showConfetti();
}
```

Your own pattern: a list of `{ at, duration?, intensity?, sharpness? }` (ms, ms, 0-1, 0-1):

```ts
import { hapticPlay } from '@/lib/appmintNative';

await hapticPlay([
  { at: 0,   intensity: 0.4 },               // soft tap
  { at: 150, intensity: 0.4 },               // soft tap
  { at: 320, duration: 120, intensity: 1 },  // strong buzz
]);
```

Combine it with `haptic()`: a normal tap feedback for every answer, the long pattern only for the special moment:

```ts
import { haptic, hapticPlay } from '@/lib/appmintNative';

export async function onAnswer(correct: boolean, streak: number) {
  haptic(correct ? 'success' : 'error');            // every answer
  if (correct && streak > 0 && streak % 5 === 0) {
    await hapticPlay('drum-roll');                  // every fifth in a row
  }
}
```

**Notes:** Android uses the `AppwrightHaptics` plugin (strength per beat where the phone supports it), iOS uses Core Haptics. The web gets the timing only. `sharpness` matters on iOS only. `haptic.play(pattern)` is the same function.

### `appmintNative.Haptics`

```js
Haptics — @/lib/appmintNative
```

**Example**

The Capacitor-style `Haptics` object: the same names and options as `@capacitor/haptics`, running on the same code as `haptic()`.

**Returns:** every method returns `void` (not a Promise; `await` on it is harmless). **Needs:** **Vibrate** in Step 4 (Access); AI builds switch it on for you when the code uses `Haptics`.

```ts
import { Haptics } from '@/lib/appmintNative';

Haptics.impact({ style: 'LIGHT' });        // 'LIGHT' | 'MEDIUM' | 'HEAVY'  (default 'LIGHT')
Haptics.notification({ type: 'SUCCESS' }); // 'SUCCESS' | 'WARNING' | 'ERROR' (default 'SUCCESS')
Haptics.vibrate({ duration: 300 });        // ms, default 300
```

A picker that ticks as the value changes:

```ts
import { Haptics } from '@/lib/appmintNative';

export function Stepper({ value, onChange }: { value: number; onChange: (v: number) => void }) {
  const step = (d: number) => { Haptics.selectionChanged(); onChange(value + d); };
  return (
    <div onPointerDown={() => Haptics.selectionStart()} onPointerUp={() => Haptics.selectionEnd()}>
      <button aria-label="Less" onClick={() => step(-1)}>-</button>
      <span>{value}</span>
      <button aria-label="More" onClick={() => step(1)}>+</button>
    </div>
  );
}
```

A form that confirms with a notification buzz and adds a short vibration when a field is wrong:

```ts
import { Haptics } from '@/lib/appmintNative';

export function onSubmit(valid: boolean) {
  Haptics.notification({ type: valid ? 'SUCCESS' : 'ERROR' });
  if (!valid) Haptics.vibrate({ duration: 200 });
}
```

**Notes:** `selectionStart` and `selectionChanged` both give one selection tick; `selectionEnd` does nothing. Works in the Android and iOS apps; on the web it uses `navigator.vibrate` where available and otherwise does nothing.

### `appmintNative.has`

```js
has(cap: Capability): boolean
```

**Example**

The yes/no form of `can()`: true when the feature will do something real on this surface.

**Returns:** `boolean`, synchronously - exactly `can(cap) !== 'none'`. **Needs:** nothing.

```ts
import { has, copy, toast } from '@/lib/appmintNative';

export function CopyCode({ code }: { code: string }) {
  if (!has('clipboardWrite')) return <code>{code}</code>;   // no button where it cannot copy
  return (
    <button onClick={async () => { if (await copy(code)) toast('Copied'); }}>
      Copy {code}
    </button>
  );
}
```

Gate a whole feature:

```ts
import { has, scanCode } from '@/lib/appmintNative';

export async function onScanTap(): Promise<string | null> {
  if (!has('codeScanner')) return null;     // better: do not render the Scan button at all
  const hit = await scanCode({ formats: ['qr_code'] });
  return hit ? hit.value : null;
}
```

Pick a label by where it runs: `has()` decides whether to draw the control, `can()` tells you which kind it is:

```ts
import { can, has } from '@/lib/appmintNative';

export function shareLabel(): string | null {
  if (!has('share')) return null;                      // nothing to share with: draw no button
  return can('share') === 'native' ? 'Share…' : 'Share link';
}
```

**Notes:** It takes the same capability names as `can()` (see that entry for the full list). Use `can()` when you need to know WHERE it runs (`'native'` or `'web'`), for example to change a label.

### `appmintNative.hideKeyboard`

```js
hideKeyboard(): void
```

**Example**

Closes the on-screen keyboard - for example when a form is submitted or a sheet opens over the field.

**Returns:** nothing. **Needs:** nothing.

```ts
import { hideKeyboard, toast } from '@/lib/appmintNative';

export function SearchForm({ onSearch }: { onSearch: (q: string) => void }) {
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        const q = new FormData(e.currentTarget).get('q') as string;
        hideKeyboard();                       // show the results, not the keyboard
        onSearch(q);
        if (!q) toast('Type something to search');
      }}
    >
      <input name="q" type="search" enterKeyHint="search" aria-label="Search" />
    </form>
  );
}
```

Close the keyboard when a sheet opens over the field, so the sheet is not hidden behind it:

```ts
import { useState } from 'react';
import { hideKeyboard } from '@/lib/appmintNative';

export function useSheet() {
  const [open, setOpen] = useState(false);
  return {
    open,
    show: () => { hideKeyboard(); setOpen(true); },
    hide: () => setOpen(false),
  };
}
```

Close it when the user taps anywhere that is not a text field:

```ts
import type { ReactNode } from 'react';
import { hideKeyboard } from '@/lib/appmintNative';

export function Screen({ children }: { children: ReactNode }) {
  return (
    <div onPointerDown={(e) => {
      if (!(e.target as HTMLElement).closest('input, textarea')) hideKeyboard();
    }}>
      {children}
    </div>
  );
}
```

**Notes:** In the app it asks the Capacitor Keyboard plugin to hide the keyboard. On the web it blurs the focused field, which is what closes the keyboard in a browser. It is safe to call when no keyboard is open.

### `appmintNative.installId`

```js
installId(): string
```

**Example**

Gives a stable random ID for this install: the shell's own ID in the Android app, a random ID saved in `localStorage` on the web.

**Returns:** `string` right away. **Needs:** nothing.

**Key a free trial to the device.**

```ts
import { installId } from '@/lib/appmintNative';

export async function claimTrial(): Promise<boolean> {
  let id: string;
  try {
    id = installId();
  } catch {
    return false;          // web with storage blocked (private mode): no stable id exists
  }
  const r = await fetch('https://api.example.com/trial', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ device: id }),
  });
  return r.ok;
}
```

**Count this install once in your own analytics.**

```ts
import { installId } from '@/lib/appmintNative';

export function reportFirstOpen() {
  let id: string;
  try { id = installId(); } catch { return; }          // web with storage blocked: no stable id
  if (localStorage.getItem('first-open-sent')) return;
  navigator.sendBeacon('https://api.example.com/first-open', JSON.stringify({ install: id }));
  localStorage.setItem('first-open-sent', '1');
}
```

**Show a short support code that you can look up later, with a copy button.**

```ts
import { installId, copy, toast } from '@/lib/appmintNative';

export function SupportCode() {
  let code = '';
  try { code = installId().slice(0, 8); } catch { /* storage blocked: show nothing */ }
  if (!code) return null;
  return <button onClick={async () => { if (await copy(code)) toast('Copied'); }}>Support code {code}</button>;
}
```

**Notes:** It stays the same across app updates and changes on uninstall or "clear data". It is not a hardware ID. It throws on the web when `localStorage` is blocked, on purpose - a shared blank ID would mix users up. On iOS the WebView's `localStorage` can be cleared by the system; `Device.getId()` gives the more stable Capacitor ID there.

### `appmintNative.isNativeApp`

```js
isNativeApp(): boolean
```

**Example**

Tells you whether the code is running inside the installed Android app (the shell bridge `window.WebToApk` is present).

**Returns:** `boolean` right away. `false` in a browser AND in the iOS app (iOS has no shell bridge). **Needs:** nothing.

**Show an "Android app" badge, and a download link in the browser.**

```ts
import { isNativeApp, platform } from '@/lib/appmintNative';

export function appBadgeText(): string {
  if (isNativeApp()) return 'Android app';
  if (platform() === 'ios') return 'iPhone app';
  return 'Get the app on Google Play';
}
```

**Tag your analytics events with where they came from.**

```ts
import { isNativeApp, platform } from '@/lib/appmintNative';

export function analyticsSource(): string {
  return isNativeApp() ? `app-${platform()}` : 'browser';   // 'app-android' or 'browser'
}
```

**Hide the "Get the app" banner inside the app.**

```ts
import { isNativeApp } from '@/lib/appmintNative';

export function InstallBanner() {
  if (isNativeApp()) return null;        // already installed: no banner
  return <a href="https://play.google.com/store/apps/details?id=com.example.app">Get the app</a>;
}
```

**Notes:** Use `platform()` when you need to tell Android, iOS and the web apart - `isNativeApp()` only answers "is the Android shell here". To decide whether a feature can be shown, use `can('<capability>')` instead: it knows which platform has which feature.

### `appmintNative.keepAwake`

```js
keepAwake(on: boolean): Promise<boolean>
```

**Example**

Keeps the screen on while a recipe, a route, a QR code or a timer is showing, and lets it sleep again.

**Returns:** `Promise<boolean>` - `true` when the request took effect, `false` when nothing could keep the screen on (or the browser refused). **Needs:** nothing.

```ts
import { keepAwake } from '@/lib/appmintNative';
import { useEffect } from 'react';

function CookingMode() {
  useEffect(() => {
    void keepAwake(true);
    return () => { void keepAwake(false); };   // always release on unmount
  }, []);
  return <Steps />;
}
```

Keep the screen on only while a timer is running, and tell the user when it could not be done.

```ts
import { keepAwake, toast } from '@/lib/appmintNative';
import { useEffect } from 'react';

export function useAwakeWhile(running: boolean) {
  useEffect(() => {
    if (!running) return;
    let held = false;
    void keepAwake(true).then((ok) => {
      held = ok;
      if (!ok) toast('Your screen may turn off while the timer runs');
    });
    return () => { if (held) void keepAwake(false); };
  }, [running]);
}
```

Check the capability first and take the lock again when the tab comes back to the front (the web releases it when the page is hidden).

```ts
import { keepAwake, can } from '@/lib/appmintNative';

export function holdScreenOn(): () => void {
  if (can('keepAwake') === 'none') return () => {};
  void keepAwake(true);
  const again = () => { if (document.visibilityState === 'visible') void keepAwake(true); };
  document.addEventListener('visibilitychange', again);
  return () => {
    document.removeEventListener('visibilitychange', again);
    void keepAwake(false);
  };
}
```

**Notes:** In the app it uses the KeepAwake plugin (the window's keep-screen-on flag); on the web the Screen Wake Lock API, which browsers release by themselves when the tab is hidden - call `keepAwake(true)` again when the page becomes visible if the screen must stay on. It only works while the app is in front.

### `appmintNative.Keyboard`

```js
Keyboard.hide() / Keyboard.addListener(event, handler): { remove() }
```

**Example**

The software keyboard in the shape of Capacitor's Keyboard plugin: close it, and hear when the space it covers changes.

**Returns:** `hide()` → `Promise<void>`. `addListener(event, handler)` → `{ remove(): void }` (returned at once, not a Promise). `event` is `'keyboardWillShow'`, `'keyboardDidShow'`, `'keyboardWillHide'` or `'keyboardDidHide'`; the handler gets `{ keyboardHeight }`. **Needs:** nothing.

**Close the keyboard** when a form is sent or a sheet opens over the field:

```ts
import { Keyboard } from '@/lib/appmintNative';

async function onSubmit(e: React.FormEvent) {
  e.preventDefault();
  await Keyboard.hide();
  await save();
}
```

**Listen** (remove the handle on unmount):

```ts
import { Keyboard } from '@/lib/appmintNative';
import { useEffect } from 'react';

useEffect(() => {
  const h = Keyboard.addListener('keyboardDidShow', ({ keyboardHeight }) => {
    composer.style.paddingBottom = `${keyboardHeight}px`;
  });
  const g = Keyboard.addListener('keyboardDidHide', () => { composer.style.paddingBottom = '0px'; });
  return () => { h.remove(); g.remove(); };
}, []);
```

**Combine both** to scroll the focused field into view when the keyboard opens, then close the keyboard on Enter:

```ts
import { Keyboard } from '@/lib/appmintNative';
import { useEffect } from 'react';

export function useKeyboardScroll() {
  useEffect(() => {
    const h = Keyboard.addListener('keyboardDidShow', () => {
      document.activeElement?.scrollIntoView({ block: 'center', behavior: 'smooth' });
    });
    const onKey = (e: KeyboardEvent) => { if (e.key === 'Enter') void Keyboard.hide(); };
    window.addEventListener('keydown', onKey);
    return () => { h.remove(); window.removeEventListener('keydown', onKey); };
  }, []);
}
```

**Notes:** `keyboardHeight` is the part of the keyboard the page still has to clear, not the raw height: in the Android app the shell resizes the page for the keyboard, so it is `0` there while the keyboard is open (the show event still fires, and fires again when the number changes). The hide events fire only when the keyboard really closes, so they can track "is the keyboard open". "Will" and "did" events fire together. For padding alone, `onKeyboard((px) => …)` is simpler.

### `appmintNative.launchUrl`

```js
launchUrl(): Promise<string>
```

**Example**

Gives the link that launched or resumed the app - a deep link, an icon shortcut, a widget tap, a notification - so the app can open the right screen.

**Returns:** `Promise<string>` - the full URL, or `''` when the app was opened normally. Always `''` on the web, where the address bar already is the route. **Needs:** nothing for shortcuts and widget taps; outside links need the **Deep Linking** option when you build.

Route on it once when the app mounts:

```ts
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { launchUrl } from '@/lib/appmintNative';

export function useLaunchRoute() {
  const navigate = useNavigate();
  useEffect(() => {
    void launchUrl().then((u) => {
      if (!u) return;
      const { pathname, search } = new URL(u);
      navigate(pathname + search, { replace: true });
    });
  }, [navigate]);
}
```

Pair it with `onDeepLink()` for links that arrive while the app is already open:

```ts
import { onDeepLink } from '@/lib/appmintNative';

const off = onDeepLink((url) => console.log('opened with', url));
```

Treat the empty string as "opened normally" and pick the start screen from the link only when there is one. A shortcut such as `appmint-shortcut://<package>/orders` has the route in its path.

```ts
import { launchUrl, onDeepLink } from '@/lib/appmintNative';

export async function startRoute(): Promise<string> {
  const u = await launchUrl();
  if (!u) return '/';                         // normal start, or the web
  const { pathname, search } = new URL(u);
  return pathname + search;
}

// While the app stays open, later links arrive here.
export function watchLinks(go: (route: string) => void): () => void {
  return onDeepLink((u) => { const x = new URL(u); go(x.pathname + x.search); });
}
```

**Notes:**
- The value is KEPT, not consumed: a component that mounts late still sees it. A newer link replaces it.
- Android: read from the shell (`WebToApk.getLaunchUrl()`); never call that directly in an AI-built app. Shortcuts and widget taps arrive as `appmint-shortcut://<package><route>`. iOS: the Capacitor App plugin's launch URL.
- Parse with `new URL(u)` and navigate to its path; do not load the URL itself.

### `appmintNative.listCallLog`

```js
listCallLog(limit = 100, offset = 0, sinceMillis = 0): Promise<CallLogEntry[]>
```

**Example**

Reads the call history, newest first. Android app only.

**Returns:** `Promise<CallLogEntry[]>` - each `{ number, name, type, date, duration }`; `type` is `incoming`, `outgoing`, `missed`, `voicemail`, `rejected`, `blocked` or `other`, `date` is epoch milliseconds, `duration` is seconds. Empty when Call log is off or permission is refused. **Needs:** the **Call log (read)** switch in Step 4 (Access) - the build turns it on when your code calls this - and the user's permission on first use.

**Missed calls from the last 7 days.**

```ts
import { listCallLog, can } from '@/lib/appmintNative';

export async function missedThisWeek() {
  if (can('callLog') === 'none') return null;          // web and iOS: not possible
  const since = Date.now() - 7 * 24 * 60 * 60 * 1000;
  try {
    const calls = await listCallLog(500, 0, since);   // a number here; the wrapper converts it
    return calls.filter((c) => c.type === 'missed');
  } catch {
    return [];                                          // 2-minute timeout
  }
}
```

**Total talk time per contact** from outgoing and incoming calls, longest first.

```ts
import { listCallLog, can } from '@/lib/appmintNative';

export async function talkTimeByPerson() {
  if (can('callLog') === 'none') return null;
  const calls = await listCallLog(1000, 0).catch(() => []);
  const totals = new Map<string, number>();
  for (const c of calls) {
    if (c.type !== 'incoming' && c.type !== 'outgoing') continue;
    const who = c.name || c.number || 'Unknown';
    totals.set(who, (totals.get(who) ?? 0) + (c.duration ?? 0));   // seconds
  }
  return [...totals].sort((a, b) => b[1] - a[1]);
}
```

**A screen that explains the empty list**, since "no calls" and "permission refused" look the same.

```ts
import { listCallLog, can } from '@/lib/appmintNative';
import { useEffect, useState } from 'react';

export function useCalls() {
  const [calls, setCalls] = useState<Awaited<ReturnType<typeof listCallLog>> | null>(null);
  const supported = can('callLog') !== 'none';
  useEffect(() => {
    if (supported) void listCallLog(50).then(setCalls).catch(() => setCalls([]));
  }, [supported]);
  const hint = !supported ? 'Open the app on your phone to see calls'
    : calls && calls.length === 0 ? 'No calls found. Allow call log access in Settings if you expected some.' : '';
  return { calls: calls ?? [], hint };
}
```

**Notes:** `limit` is clamped to 2000 by the app. An empty list cannot tell "no calls" from "permission refused". `can('callLog')` is `'none'` on the web and on iOS (Apple offers no call-history API). Call log is a Google Play restricted permission: you need an approved Permissions Declaration in Play Console, or the app is rejected.

### `appmintNative.listContacts`

```js
listContacts(limit = 200, offset = 0): Promise<Contact[]>
```

**Example**

Reads the address book, a page at a time. App only: the shell's bridge on Android, the Contacts plugin on iOS.

**Returns:** `Promise<Contact[]>`. An empty array when there are no contacts, when access is refused, or when Contacts was not enabled at build time. **Needs:** Android: the **Contacts** switch in Step 4 (Access) - the build turns it on when your code calls this. iOS: the user allows access on the first call.

**A contact list screen.** Render it only where it can work. Each contact is `{ id?, name?, phones?: string[], emails?: string[], photoUri? }` on both stores.

```ts
import { listContacts, can, type Contact } from '@/lib/appmintNative';

type Row = { id: string; name: string; phone: string };

function toRow(c: Contact): Row {
  return { id: c.id ?? '', name: c.name ?? '', phone: c.phones?.[0] ?? '' };
}

export async function loadPage(page: number): Promise<Row[] | null> {
  if (can('contactList') === 'none') return null;   // web: show "open the app" instead
  try {
    const contacts = await listContacts(100, page * 100);
    return contacts.map(toRow);
  } catch {
    return [];                                        // 2-minute timeout
  }
}
```

**Search the whole address book** by reading it page by page into one list.

```ts
import { listContacts, can, type Contact } from '@/lib/appmintNative';

export async function findContacts(query: string): Promise<Contact[] | null> {
  if (can('contactList') === 'none') return null;
  const all: Contact[] = [];
  for (let offset = 0; ; offset += 200) {
    const page = await listContacts(200, offset).catch(() => []);
    all.push(...page);
    if (page.length < 200) break;
  }
  const q = query.trim().toLowerCase();
  return all.filter((c) => (c.name ?? '').toLowerCase().includes(q));
}
```

**Pair it with `sendSms`** to message the first number of a contact.

```ts
import { sendSms, type Contact } from '@/lib/appmintNative';

export async function invite(c: Contact): Promise<boolean> {
  const phone = c.phones?.[0];
  if (!phone) return false;
  return sendSms(phone, `Hi ${c.name ?? ''}, join me on the app!`);
}
```

**Notes:** An empty result cannot tell "no contacts" from "permission refused" - show a short hint ("Allow contacts in Settings") when the list is empty. Android sorts A to Z by name; `limit` is clamped to 2000. For a single person use `pickContact()`, which needs no permission. Contacts is personal data: declare it in your Play Data safety form and privacy policy.

### `appmintNative.listSms`

```js
listSms(box: 'inbox' | 'sent' | 'draft' = 'inbox', limit = 100, offset = 0): Promise<SmsMessage[]>
```

**Example**

Reads SMS messages from the inbox, the sent box or the drafts, newest first. Android app only.

**Returns:** `Promise<SmsMessage[]>` - each `{ id, address, body, date, read, box }`. Empty when SMS is off, permission is refused, or the box is empty. **Needs:** the **SMS (read & send)** switch in Step 4 (Access) - the build turns it on when your code calls this - and the user's permission on first use.

**Show the latest 20 messages from the inbox.**

```ts
import { listSms, can, type SmsMessage } from '@/lib/appmintNative';

export async function latestInbox(): Promise<SmsMessage[] | null> {
  if (can('smsList') === 'none') return null;          // web and iOS: hide the screen
  try {
    const msgs = await listSms('inbox', 20, 0);
    return msgs.map((m) => ({ ...m, body: (m.body ?? '').slice(0, 120) }));
  } catch {
    return [];                                          // 2-minute timeout
  }
}
```

**Unread messages from one sender**, newest first.

```ts
import { listSms, can } from '@/lib/appmintNative';

export async function unreadFrom(address: string) {
  if (can('smsList') === 'none') return null;
  const msgs = await listSms('inbox', 200, 0).catch(() => []);
  return msgs.filter((m) => m.address === address && !m.read);
}
```

**Read the sent box and the drafts** in one go, and sort them together by date.

```ts
import { listSms, type SmsMessage } from '@/lib/appmintNative';

export async function outgoing(): Promise<SmsMessage[]> {
  const [sent, drafts] = await Promise.all([
    listSms('sent', 50, 0).catch(() => []),
    listSms('draft', 50, 0).catch(() => []),
  ]);
  return [...sent, ...drafts].sort((a, b) => (b.date ?? 0) - (a.date ?? 0));
}
```

**Notes:** An empty list cannot tell "no messages" from "permission refused". `can('smsList')` is `'none'` on the web and iOS. New incoming messages are not part of this wrapper; hand-written pages get them from the `appmint:sms-received` event (see `listSms` for the raw bridge). SMS is a Google Play restricted permission: you need an approved Permissions Declaration in Play Console, normally only granted to default SMS apps.

### `appmintNative.liveProgress`

```js
import { liveProgress } from '@/lib/appmintNative'
```

**Example**

A sticky, silent status card in the notification shade that you update in place - a delivery, an upload, a timer, a workout. `start`, `update`, `end`.

**Returns:** `start` / `update` → `Promise<boolean>` (`false` if not available, notifications are not allowed, or the user turned off its "Live status" category); `end` → `Promise<void>`. **Needs:** Android, and the notification permission. On iOS and the web `can('liveProgress')` is `'none'` - show the same state inside your app there.

```ts
import { liveProgress, can } from '@/lib/appmintNative';

export async function trackUpload(files: File[]) {
  const card = can('liveProgress') !== 'none';
  if (card) await liveProgress.start({ id: 'upload', title: 'Uploading photos', text: 'Starting…', indeterminate: true });

  for (let i = 0; i < files.length; i++) {
    await uploadOne(files[i]);
    if (card) await liveProgress.update({
      id: 'upload', title: 'Uploading photos',
      text: `${i + 1} of ${files.length}`,
      progress: (i + 1) / files.length,       // 0–1
      route: '/uploads',                      // opens this screen when tapped
    });
  }
  if (card) await liveProgress.end('upload'); // removes the card
}
```

**A running timer:** `startedAt` shows a clock counting up from that moment.

```ts
await liveProgress.start({ id: 'run', title: 'Morning run', text: '5 km goal', startedAt: Date.now() });
// ...later
await liveProgress.end('run');
```

**Handle a refusal.** `start` answers `false`; `liveProgress.lastError` says why.

```ts
import { liveProgress, toast } from '@/lib/appmintNative';

export async function startDelivery() {
  const ok = await liveProgress.start({ id: 'order', title: 'Order on its way', text: 'Packing', progress: 0.1, route: '/orders' });
  if (!ok) {
    if (liveProgress.lastError === 'permission') toast('Allow notifications to follow your order here');
    else if (liveProgress.lastError === 'channel_disabled') toast('Live status is turned off in system settings');
  }
  return ok;
}
```

**Notes:** the same `id` updates one card; different ids make separate cards (the default id is `'live'`). The user cannot swipe it away, so always call `end`. Ask for notification permission first on Android 13+ (`Notification.requestPermission()`), or `start` returns `false`.

### `appmintNative.LocalNotifications`

```js
import { LocalNotifications } from '@/lib/appmintNative'
```

**Example**

The `@capacitor/local-notifications` shape (`LocalNotifications.schedule({ notifications })`) for code written against the Capacitor plugin. It shows each notification immediately through `notify()`.

**Returns:** `Promise<{ notifications }>` - the same list you passed in. It does not tell you whether each one was shown; use `notify()` when you need that. **Needs:** Android: **Notifications** turned on in the Permissions step when you build.

```ts
import { LocalNotifications } from '@/lib/appmintNative';

export async function announceSync(count: number) {
  await LocalNotifications.schedule({
    notifications: [
      { title: 'Sync complete', body: `${count} items updated`, tag: 'sync' },
    ],
  });
}
```

Several notifications in one call, each replacing an earlier one that has the same `tag`.

```ts
import { LocalNotifications } from '@/lib/appmintNative';

export async function reportBatch(done: number, failed: number) {
  await LocalNotifications.schedule({
    notifications: [
      { title: 'Upload finished', body: `${done} files sent`, tag: 'upload-ok' },
      ...(failed ? [{ title: 'Some files failed', body: `${failed} need a retry`, tag: 'upload-failed' }] : []),
    ],
  });
}
```

When you need to know whether it was shown, use `notify()` instead and react to the answer.

```ts
import { notify, toast } from '@/lib/appmintNative';

export async function remind(text: string) {
  const shown = await notify({ title: 'Reminder', body: text, tag: 'reminder' });
  if (!shown) toast(text);   // notifications are off: tell them in the app
}
```

**Notes:** immediate only - the Capacitor fields `id`, `schedule`, `at`, `actionTypeId` and `extra` are not supported; each item is `{ title, body?, icon?, tag? }`. For a notification at a later time, a hand-written page uses `WebToApk.scheduleNotificationEx` (Android). For buttons use `notifyWithActions`.

### `appmintNative.lockOrientation`

```js
lockOrientation(to: 'portrait' | 'landscape' | 'any'): Promise<boolean>
```

**Example**

Pins the screen one way up for a view that only makes sense that way - a game board, a wide chart, a signature pad. `'any'` releases the lock.

**Returns:** `Promise<boolean>` - `true` when the lock (or release) was applied, `false` when the platform refused. It never rejects. **Needs:** nothing.

Lock while the view is open, release on leave:

```ts
import { useEffect } from 'react';
import { can, lockOrientation } from '@/lib/appmintNative';

export function ChartScreen() {
  useEffect(() => {
    if (can('orientationLock') === 'none') return;
    void lockOrientation('landscape');
    return () => { void lockOrientation('any'); };   // always give the rotation back
  }, []);
  return <div className="h-full w-full">{/* the chart */}</div>;
}
```

Check the answer when the view depends on it:

```ts
import { lockOrientation, toast } from '@/lib/appmintNative';

const ok = await lockOrientation('portrait');
if (!ok) toast('Turn your phone upright for the best view');
```

Release the lock when the screen goes away, as a hook shared by several screens.

```ts
import { useEffect } from 'react';
import { lockOrientation, type OrientationLock } from '@/lib/appmintNative';

export function useOrientation(mode: OrientationLock) {
  useEffect(() => {
    void lockOrientation(mode);
    return () => { void lockOrientation('any'); };
  }, [mode]);
}

// useOrientation('landscape') inside the game screen
```

**Notes:**
- In the app the Capacitor ScreenOrientation plugin applies the lock.
- On the web it uses `screen.orientation.lock()`. Most browsers refuse it outside fullscreen and desktop browsers always refuse it, so expect `false` there.
- Always give the user a way out of a locked view.

### `appmintNative.longPress`

```js
longPress(el: HTMLElement, handler: (e: PointerEvent) => void, opts?: { ms?: number; haptics?: boolean }): () => void
```

**Example**

Calls you when the user holds a finger on an element - for a row's context menu, reactions, or card actions.

**Returns:** an unsubscribe function - call it on unmount. **Needs:** nothing.

**Options:** `ms` - hold time, default `450`; `haptics` - medium haptic when it fires, default `true`.

Open an action sheet on long press, with a visible button for the same menu:

```ts
import { useEffect, useRef } from 'react';
import { actionSheet, longPress } from '@/lib/appmintNative';

export function PhotoTile({ src, onDelete }: { src: string; onDelete(): void }) {
  const ref = useRef<HTMLDivElement>(null);
  const openMenu = async () => {
    const i = await actionSheet('Photo', [{ title: 'Delete', destructive: true }]);
    if (i === 0) onDelete();
  };

  useEffect(() => {
    if (!ref.current) return;
    return longPress(ref.current, () => void openMenu());
  }, []);

  return (
    <div ref={ref} className="relative">
      <img src={src} alt="" />
      <button aria-label="Photo actions" className="absolute right-1 top-1" onClick={() => void openMenu()}>⋯</button>
    </div>
  );
}
```

A slower hold, without the haptic:

```ts
import { longPress } from '@/lib/appmintNative';

declare const card: HTMLElement;
const off = longPress(card, (e) => console.log('held at', e.clientX, e.clientY), { ms: 700, haptics: false });
```

Pair it with `haptic` for a custom confirmation, and clean up for each list row.

```ts
import { useEffect, useRef } from 'react';
import { haptic, longPress } from '@/lib/appmintNative';

export function SelectableRow({ id, onSelect, children }: { id: string; onSelect(id: string): void; children: React.ReactNode }) {
  const ref = useRef<HTMLLIElement>(null);
  useEffect(() => {
    if (!ref.current) return;
    return longPress(ref.current, () => { haptic('heavy'); onSelect(id); }, { haptics: false });
  }, [id, onSelect]);
  return <li ref={ref}>{children}</li>;
}
```

**Notes:**
- It fires once per hold and swallows the click that would follow, so a long press does not also "tap".
- Moving more than 8 px cancels it, so scrolling never triggers it. It also blocks the browser's own long-press menu on that element.
- A long press is invisible. Always keep a visible button for the same action.

### `appmintNative.markPushRead`

```js
import { markPushRead } from '@/lib/appmintNative'
```

**Example**

Marks one Appwright Push inbox message as read, or every message when called with no argument.

**Returns:** `boolean` synchronously - `true` if something changed. Always `false` on the web and iOS. **Needs:** Android: **Appwright Push** turned on in the Integrate step when you build.

```ts
import { pushInbox, markPushRead } from '@/lib/appmintNative';

export function openMessage(id: string) {
  markPushRead(id);
  return pushInbox().find((m) => m.id === id);
}

export function markAllRead() {
  markPushRead();          // no id = all messages
  return pushInbox();
}
```

Count the unread messages after marking one, for a badge in your own header.

```ts
import { pushInbox, markPushRead } from '@/lib/appmintNative';

export function openAndCount(id: string) {
  const changed = markPushRead(id);          // false on the web or when nothing changed
  const unread = pushInbox().filter((m) => !m.read).length;
  return { changed, unread };
}
```

Mark everything read when the user opens the inbox screen and refresh when a new message arrives.

```ts
import { useEffect, useState } from 'react';
import { pushInbox, markPushRead } from '@/lib/appmintNative';

export function useInbox() {
  const [items, setItems] = useState(() => pushInbox());
  useEffect(() => {
    markPushRead();
    const refresh = () => setItems(pushInbox());
    window.addEventListener('appmint:push', refresh);
    return () => window.removeEventListener('appmint:push', refresh);
  }, []);
  return items;
}
```

**Notes:** read state lives only on this phone; the tray notification is not removed.

### `appmintNative.mediaSession`

```js
mediaSession — @/lib/appmintNative
```

**Example**

Lock-screen and notification playback controls for an app that plays audio or video: publish what is playing, receive the Play / Pause / Next / Previous / Stop buttons, and hand a video to a TV.

**Returns:** `setMetadata`, `setPlaybackState`, `clear` → `void`. `onAction(handler)` → an unsubscribe function. `cast(url)` → `Promise<boolean>` (`false` when nothing could be offered). **Needs:** Android: **Media Notification** in Step 4 (Access); AI builds switch it on for you when the code uses `mediaSession`.

```ts
import { useEffect, useRef } from 'react';
import { mediaSession } from '@/lib/appmintNative';

type Track = { url: string; title: string; artist: string; cover: string };

export function Player({ track, onNext, onPrev }: { track: Track; onNext: () => void; onPrev: () => void }) {
  const audio = useRef<HTMLAudioElement>(null);
  const unsubscribe = useRef<(() => void) | null>(null);

  const publish = () => {
    const a = audio.current;
    if (!a) return;
    mediaSession.setPlaybackState({
      playing: !a.paused,
      positionMs: a.currentTime * 1000,
      durationMs: (a.duration || 0) * 1000,
    });
  };

  const onPlay = () => {
    // Register the buttons when playback starts, not on mount (see Notes).
    if (!unsubscribe.current) {
      unsubscribe.current = mediaSession.onAction((action) => {
        const a = audio.current;
        if (!a) return;
        if (action === 'play') void a.play();
        if (action === 'pause') a.pause();
        if (action === 'next') onNext();
        if (action === 'prev') onPrev();
        if (action === 'stop') { a.pause(); mediaSession.clear(); }
      });
    }
    mediaSession.setMetadata({ title: track.title, artist: track.artist, artworkUrl: track.cover });
    publish();
  };

  useEffect(() => () => { unsubscribe.current?.(); mediaSession.clear(); }, []);

  return <audio ref={audio} src={track.url} controls onPlay={onPlay} onPause={publish} onSeeked={publish} />;
}
```

Send the current video to a TV:

```ts
import { can, mediaSession } from '@/lib/appmintNative';

export function CastButton({ url }: { url: string }) {
  if (can('cast') === 'none') return null;
  return <button onClick={async () => { if (!(await mediaSession.cast(url))) alert('No cast target found'); }}>Cast</button>;
}
```

Clear the lock-screen card when playback ends, and leave it alone when the platform has no media controls.

```ts
import { can, mediaSession } from '@/lib/appmintNative';

export function publishEnded(audio: HTMLAudioElement): () => void {
  if (can('mediaControls') === 'none') return () => {};
  const ended = () => {
    mediaSession.setPlaybackState({ playing: false, positionMs: 0, durationMs: audio.duration * 1000 || 0 });
    mediaSession.clear();
  };
  audio.addEventListener('ended', ended);
  return () => audio.removeEventListener('ended', ended);
}
```

**Notes:**
- On Android the buttons come through the app's `navigator.mediaSession`, which is installed when the page finishes loading. An `onAction` registered earlier (for example in a mount effect) is lost, so register it on the first play.
- The `seek` action (with `positionMs`) is delivered on iOS and the web only; the Android notification has no seek.
- `artworkUrl` must be a full `https://` address on the internet. On Android, `cast(url)` opens the "Cast to..." chooser (a hand-off; it resolves `true` once the chooser is asked for). iOS shows the AirPlay picker; the web uses the Remote Playback API.

### `appmintNative.Motion`

```js
Motion.addListener('accel' | 'orientation', cb): Promise<{ remove() }>
```

**Example**

The `@capacitor/motion` shape over the standard `devicemotion` and `deviceorientation` events: movement and tilt of the phone. The same code runs in the apps and in a browser.

**Returns:** `Promise<{ remove(): Promise<void> }>` - call `remove()` to stop. `'accel'` calls back with `{ acceleration: {x,y,z}, accelerationIncludingGravity: {x,y,z}, rotationRate: {alpha,beta,gamma}, interval }`; `'orientation'` with `{ alpha, beta, gamma }` (degrees). **Needs:** nothing on Android. iOS 13+ asks the user first, so start it from a tap.

**Shake to refresh.** Start it from a button click so iOS can show its prompt.

```ts
import { Motion, type AccelEvent } from '@/lib/appmintNative';

let handle: { remove(): Promise<void> } | null = null;

export async function startShakeDetect(onShake: () => void) {
  let last = 0;
  handle = await Motion.addListener('accel', (e) => {
    const g = (e as AccelEvent).accelerationIncludingGravity;
    const force = Math.sqrt(g.x * g.x + g.y * g.y + g.z * g.z);
    if (force > 25 && Date.now() - last > 1000) { last = Date.now(); onShake(); }
  });
}

export async function stopShakeDetect() {
  await handle?.remove();
  handle = null;
}
```

**Tilt a level bubble** with the orientation event.

```ts
import { Motion, type OrientationEvent } from '@/lib/appmintNative';

const tilt = await Motion.addListener('orientation', (e) => {
  const { beta, gamma } = e as OrientationEvent;       // front-back, left-right
  bubble.style.transform = `translate(${gamma * 2}px, ${beta * 2}px)`;
});
// later: await tilt.remove();
```

**Hook form with automatic cleanup** for a React component that reads the phone's movement.

```ts
import { useEffect, useState } from 'react';
import { Motion, can, type AccelEvent } from '@/lib/appmintNative';

export function useAcceleration() {
  const [g, setG] = useState({ x: 0, y: 0, z: 0 });
  const [supported] = useState(() => can('motion') !== 'none');
  useEffect(() => {
    if (!supported) return;
    let handle: { remove(): Promise<void> } | undefined;
    let cancelled = false;
    void Motion.addListener('accel', (e) => setG((e as AccelEvent).accelerationIncludingGravity)).then((h) => {
      if (cancelled) void h.remove(); else handle = h;
    });
    return () => { cancelled = true; void handle?.remove(); };
  }, [supported]);
  return { g, supported };
}
```

**Notes:** If the iOS user refuses, the handle is returned but no events arrive. `can('motion')` is `'none'` where the browser has no `DeviceMotionEvent`. Missing sensor values arrive as `0`. Always `remove()` when the screen closes - motion events fire many times a second.

### `appmintNative.musicVolume`

```js
musicVolume() — @/lib/appmintNative
```

**Example**

Reads the level of the app's looping background music, 0 to 100.

**Returns:** `number`, synchronously: `0`-`100`, or `-1` when there is no music (on the web, or an app built without a music file). **Needs:** a file chosen under **Background Music** in Step 2 (Display).

Draw a music slider only when there is music:

```ts
import { useState } from 'react';
import { musicVolume, setMusicVolume } from '@/lib/appmintNative';

export function MusicSetting() {
  const [level, setLevel] = useState(() => musicVolume());
  if (level < 0) return null;                  // no music in this app: no control

  return (
    <label>
      Music
      <input
        type="range" min={0} max={100} value={level}
        onChange={(e) => {
          const v = Number(e.target.value);
          setLevel(v);
          setMusicVolume(v);
        }}
      />
    </label>
  );
}
```

Keep the level in step with a "Mute music" switch, remembering the volume to come back to.

```ts
import { useRef, useState } from 'react';
import { musicVolume, setMusicVolume } from '@/lib/appmintNative';

export function MuteMusicSwitch() {
  const before = useRef(Math.max(musicVolume(), 50));
  const [muted, setMuted] = useState(() => musicVolume() === 0);
  if (musicVolume() < 0) return null;            // no music in this build

  const toggle = () => {
    if (muted) setMusicVolume(before.current);
    else { before.current = musicVolume(); setMusicVolume(0); }
    setMuted(!muted);
  };
  return <button onClick={toggle}>{muted ? 'Unmute music' : 'Mute music'}</button>;
}
```

Quieten the music while a video plays, then restore it.

```ts
import { musicVolume, setMusicVolume } from '@/lib/appmintNative';

export function duckMusicDuring(video: HTMLVideoElement) {
  const level = musicVolume();
  if (level < 0) return;                         // nothing to duck
  video.addEventListener('play', () => setMusicVolume(0));
  video.addEventListener('pause', () => setMusicVolume(level));
  video.addEventListener('ended', () => setMusicVolume(level));
}
```

**Notes:** Android only; the iOS app and the web answer `-1`. A level set with `setMusicVolume` is kept for the next launch; until the page sets one, it is the build level.

### `appmintNative.navEnd`

```js
import { navEnd } from '@/lib/appmintNative'
```

**Example**

Documented together with `appmintNative.navStart` - the example there shows this one too.

`navStart()` shows the app's thin top loading bar for a screen change the app cannot see by itself; `navEnd()` ends it.

**Returns:** nothing (both are synchronous). **Needs:** nothing. On the web (and on iOS, where there is no Android shell bar) both do nothing.

Use them only for a wait where nothing in the DOM changes until data arrives, and always pair them with `try/finally`:

```ts
import { navStart, navEnd } from '@/lib/appmintNative';

export async function openOrder(id: string, render: (order: unknown) => void) {
  navStart();
  try {
    const res = await fetch(`/api/orders/${id}`);
    render(await res.json());
  } finally {
    navEnd();                // always, also when the fetch failed
  }
}
```

Wrap a submit that takes a while, so the user sees the bar while the server works.

```ts
import { navStart, navEnd } from '@/lib/appmintNative';

export async function submitForm(data: FormData): Promise<boolean> {
  navStart();
  try {
    const res = await fetch('/api/submit', { method: 'POST', body: data });
    return res.ok;
  } catch {
    return false;            // the bar still ends in finally
  } finally {
    navEnd();
  }
}
```

Make a small helper that wraps any promise.

```ts
import { navStart, navEnd } from '@/lib/appmintNative';

export async function withNavBar<T>(work: Promise<T>): Promise<T> {
  navStart();
  try { return await work; } finally { navEnd(); }
}

// usage inside a click handler:
// const items = await withNavBar(loadItems(categoryId));
```

**Notes:** They call `WebToApk.navStarted()` / `navSettled()`. The app already drives the bar when your app swaps screens in the DOM, so most route changes need nothing. Nothing is drawn for a change shorter than about 200 ms, and a bar that is never ended stops by itself after 2.5 seconds. Keep rendering your own spinner or skeleton; the bar is extra.

### `appmintNative.navStart`

```js
navStart() / navEnd() from '@/lib/appmintNative'
```

**Example**

`navStart()` shows the app's thin top loading bar for a screen change the app cannot see by itself; `navEnd()` ends it.

**Returns:** nothing (both are synchronous). **Needs:** nothing. On the web (and on iOS, where there is no Android shell bar) both do nothing.

Use them only for a wait where nothing in the DOM changes until data arrives, and always pair them with `try/finally`:

```ts
import { navStart, navEnd } from '@/lib/appmintNative';

export async function openOrder(id: string, render: (order: unknown) => void) {
  navStart();
  try {
    const res = await fetch(`/api/orders/${id}`);
    render(await res.json());
  } finally {
    navEnd();                // always, also when the fetch failed
  }
}
```

Wrap a submit that takes a while, so the user sees the bar while the server works.

```ts
import { navStart, navEnd } from '@/lib/appmintNative';

export async function submitForm(data: FormData): Promise<boolean> {
  navStart();
  try {
    const res = await fetch('/api/submit', { method: 'POST', body: data });
    return res.ok;
  } catch {
    return false;            // the bar still ends in finally
  } finally {
    navEnd();
  }
}
```

Make a small helper that wraps any promise.

```ts
import { navStart, navEnd } from '@/lib/appmintNative';

export async function withNavBar<T>(work: Promise<T>): Promise<T> {
  navStart();
  try { return await work; } finally { navEnd(); }
}

// usage inside a click handler:
// const items = await withNavBar(loadItems(categoryId));
```

**Notes:** They call `WebToApk.navStarted()` / `navSettled()`. The app already drives the bar when your app swaps screens in the DOM, so most route changes need nothing. Nothing is drawn for a change shorter than about 200 ms, and a bar that is never ended stops by itself after 2.5 seconds. Keep rendering your own spinner or skeleton; the bar is extra.

### `appmintNative.Network`

```js
Network.getStatus(): Promise<{ connected, connectionType }> / Network.addListener(event, handler): { remove() }
```

**Example**

Online/offline state in the shape of Capacitor's Network plugin - the same code as `networkStatus()` / `onNetworkChange()`.

**Returns:** `getStatus()` → `Promise<{ connected: boolean; connectionType: string }>`. `addListener(event, handler)` → `{ remove(): void }` at once; the handler gets the same shape on every change. **Needs:** nothing.

```ts
import { Network } from '@/lib/appmintNative';
import { useEffect, useState } from 'react';

function useOnline() {
  const [online, setOnline] = useState(true);
  useEffect(() => {
    void Network.getStatus().then((s) => setOnline(s.connected));
    const h = Network.addListener('networkStatusChange', (s) => setOnline(s.connected));
    return () => h.remove();
  }, []);
  return online;
}
```

Read the current type once and show it in a status chip.

```ts
import { Network } from '@/lib/appmintNative';
import { useEffect, useState } from 'react';

export function ConnectionChip() {
  const [label, setLabel] = useState('Checking...');
  useEffect(() => {
    void Network.getStatus().then((s) =>
      setLabel(s.connected ? (s.connectionType === 'cellular' ? 'Mobile data' : 'Wi-Fi') : 'Offline'));
  }, []);
  return <span className="chip">{label}</span>;
}
```

Hold back a large download on mobile data, and answer the offline case too.

```ts
import { Network, toast } from '@/lib/appmintNative';

export async function startBigDownload(download: () => Promise<void>) {
  const s = await Network.getStatus();
  if (s.connectionType === 'none' || !s.connected) { toast('You are offline.'); return; }
  if (s.connectionType === 'cellular' && !window.confirm('Download over mobile data?')) return;
  await download();
}
```

**Notes:** The event name is ignored - every listener gets connectivity changes. `connectionType` is `'wifi'`, `'cellular'`, `'none'` or `'unknown'`: in the app `getStatus()` and the listener use the Network plugin, on the web `navigator.onLine` / `navigator.connection.type`. `connected` means the phone has a network, not that your server answers.

### `appmintNative.networkStatus`

```js
networkStatus(): { connected: boolean; type: string }
```

**Example**

Says, right now, whether the device is online.

**Returns:** `{ connected, type }`, synchronously. `type` is `'wifi'`, `'cellular'`, `'none'` (offline) or `'unknown'`. **Needs:** nothing.

Cheap enough to call during render:

```ts
import { networkStatus } from '@/lib/appmintNative';

export function SyncButton({ onSync }: { onSync: () => void }) {
  const { connected } = networkStatus();
  return (
    <button disabled={!connected} onClick={onSync}>
      {connected ? 'Sync now' : 'Offline — will sync later'}
    </button>
  );
}
```

Read the start value, then follow changes:

```ts
import { useEffect, useState } from 'react';
import { networkStatus, onNetworkChange } from '@/lib/appmintNative';

export function useOnline(): boolean {
  const [online, setOnline] = useState(() => networkStatus().connected);
  useEffect(() => onNetworkChange((s) => setOnline(s.connected)), []);
  return online;
}
```

Choose a lighter image size on mobile data, with the `'none'` (offline) case answered too.

```ts
import { networkStatus } from '@/lib/appmintNative';

export function imageUrl(id: string): string {
  const { connected, type } = networkStatus();
  if (!connected) return '/img/placeholder.svg';         // offline: use the packaged picture
  return `/api/images/${id}?size=${type === 'cellular' ? 'small' : 'large'}`;
}
```

**Notes:**
- In the app it is the Network plugin's answer (kept current in the background, so the very first call may still read the WebView's own value). On the web it is `navigator.onLine` plus `navigator.connection.type` where the browser reports one, else `'unknown'` - a speed class such as `'4g'` is never reported as the type.
- "Connected" means the device has a network, not that your server answers. Handle failed requests anyway.

### `appmintNative.nfc`

```js
import { nfc } from '@/lib/appmintNative'
```

**Example**

One-call helpers over Web NFC (`NDEFReader`): check support, read tags, write a text tag. Android uses the app's NDEFReader, iPhone the app's NFC plugin, the web Chrome's own Web NFC.

**Returns:** `isSupported(): Promise<boolean>`; `scan(onTag): Promise<() => Promise<void>>` (the stop function), and `onTag` gets `Array<{recordType, text, mediaType?}>` per tag - Web NFC's type names (`'text'`, `'url'`, `'mime'`, …) and the decoded text on every platform; `write(text): Promise<boolean>` (`true` once a tag was written, `false` on any failure). **Needs:** on Android, turn on **Web NFC Support** in Step 4 (Access) when you build. `scan()` throws when there is no `NDEFReader` - check `can('nfc')` first.

```ts
import { useEffect, useRef, useState } from 'react';
import { can, nfc } from '@/lib/appmintNative';

export function TagReader() {
  const [last, setLast] = useState('');
  const stopRef = useRef<null | (() => Promise<void>)>(null);

  useEffect(() => () => { void stopRef.current?.(); }, []);

  // Check at the tap: on Android the app installs NDEFReader while the page loads.
  const start = async () => {
    if (can('nfc') === 'none' || !(await nfc.isSupported())) { setLast('NFC is not available here.'); return; }
    try {
      stopRef.current = await nfc.scan((records) => {
        setLast(records.filter((r) => r.recordType === 'text' || r.recordType === 'url').map((r) => r.text).join(' | '));
      });
      setLast('Hold a tag to the phone.');
    } catch (e) {
      setLast(`NFC error: ${(e as Error).message}`);
    }
  };

  const writeTag = async () => {
    if (can('nfc') === 'none') { setLast('NFC is not available here.'); return; }
    setLast('Tap a tag to write it...');
    setLast((await nfc.write('Hello from my app')) ? 'Tag written.' : 'Write failed or cancelled.');
  };

  return (
    <div>
      <button onClick={start}>Read tags</button>
      <button onClick={writeTag}>Write tag</button>
      <p>{last}</p>
    </div>
  );
}
```

Write a link to a tag, and tell the user why it failed when it did.

```ts
import { can, nfc, toast } from '@/lib/appmintNative';

export async function writeShareTag(url: string) {
  if (can('nfc') === 'none') { toast('This device cannot write NFC tags.'); return; }
  toast('Hold a blank tag to the back of the phone...');
  const ok = await nfc.write(url);
  toast(ok ? 'Tag written.' : 'Could not write the tag. Try again.');
}
```

Read one tag, then stop scanning.

```ts
import { nfc } from '@/lib/appmintNative';

export function readOnce(): Promise<string> {
  return new Promise<string>((resolve, reject) => {
    let stop: (() => Promise<void>) | undefined;
    let done = false;
    nfc.scan((records) => {
      if (done) return;
      done = true;
      void stop?.();
      resolve(records.map((r) => r.text).join(' '));
    }).then((s) => { stop = s; if (done) void s(); }, reject);
  });
}
```

**Notes:** On Android `isSupported()` is `true` whenever the build has NFC - it does not say whether NFC is switched on (`window.AndroidNFC.startScan()` does). After the stop function, `onTag` is never called again. On Android the app itself keeps catching tags while it is in front (so a tap does not open another app), it just stops passing them to you. A text record's `text` has no language prefix; a URL record's `text` is the full URL. `write()` waits until a tag is tapped. For writing URL or mime records, and for locking a tag, use `NDEFReader` directly.

### `appmintNative.notify`

```js
import { notify } from '@/lib/appmintNative'
```

**Example**

Shows one notification right now, on Android, iOS and the web, from an AI-built app.

**Returns:** `Promise<boolean>` - `true` when posted, `false` when the user refused the notification permission, notifications are switched off for the app, or they are not available here. It asks for the permission itself the first time (Android 13+, iOS, the web). **Needs:** Android: **Notifications** turned on in the Permissions step when you build.

```ts
import { notify, can } from '@/lib/appmintNative';

export async function onOrderReady(orderId: string) {
  if (can('notify') === 'none') return;          // hide the feature instead
  const ok = await notify({
    title: 'Order ready',
    body: `Order #${orderId} is ready to collect`,
    icon: 'https://example.com/img/order.png',    // shown beside the text
    tag: `order-${orderId}`,                     // same tag replaces the old one
  });
  if (!ok) setBanner('Turn on notifications to get order alerts.');
}
```

**Ask at a good moment:** the first `notify()` shows the permission dialog. To ask from your own "Turn on alerts" button instead - before anything is posted - use the standard API, which the app also supports:

```ts
async function enableAlerts() {
  if (typeof Notification === 'undefined') return false;
  const perm = Notification.permission === 'granted' ? 'granted' : await Notification.requestPermission();
  return perm === 'granted';
}
```

Open the right screen when the user taps the notification, combining `notify` with `onNotificationClick`.

```ts
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { notify, onNotificationClick } from '@/lib/appmintNative';

export async function announceMessage(id: string, from: string) {
  await notify({ title: `New message from ${from}`, tag: `msg-${id}`, data: { messageId: id } });
}

export function NotificationRouter() {
  const navigate = useNavigate();
  useEffect(() => onNotificationClick((e) => {
    const { messageId } = (e.data ?? {}) as { messageId?: string };
    if (messageId) navigate(`/messages/${messageId}`);
  }), [navigate]);
  return null;
}
```

**Notes:** Android uses the shell's notification bridge (`icon` becomes the large icon; use an `https://` or `data:` image); iOS uses the LocalNotifications plugin; the web uses the Notification API. Once the user has refused for good, `notify()` resolves `false` without asking again - point them to the phone's settings. For buttons use `notifyWithActions`; for a sticky progress card use `liveProgress`.

### `appmintNative.notifyWithActions`

```js
import { notifyWithActions } from '@/lib/appmintNative'
```

**Example**

Shows a notification with up to 3 buttons, optionally with an inline reply box ("Mark done", "Snooze", "Reply…"). The tap comes back through `onNotificationAction`.

**Returns:** `Promise<boolean>` - `true` when posted, `false` when the notification permission was refused (it asks the first time, like `notify()`). **Needs:** Android: **Notifications** turned on in the Permissions step when you build; iOS asks for permission on first use.

```ts
import { notifyWithActions, onNotificationAction, can } from '@/lib/appmintNative';

export async function remindTask(taskId: string, title: string) {
  if (can('notificationActions') === 'none') return false;
  return notifyWithActions({
    title,
    body: 'Due now',
    tag: `task-${taskId}`,                       // comes back as e.tag
    actions: [
      { id: 'done', title: 'Done' },
      { id: 'snooze', title: 'Snooze' },
      { id: 'reply', title: 'Reply', input: 'Add a note' }, // string = reply placeholder
    ],
  });
}

// Mount once (e.g. in App.tsx):
export const stopListening = onNotificationAction((e) => {
  // e = { actionId: 'snooze', tag: 'task-42', text: '' }
  const taskId = e.tag.replace('task-', '');
  if (e.actionId === 'done') completeTask(taskId);
  if (e.actionId === 'snooze') snoozeTask(taskId);
  if (e.actionId === 'reply') addNote(taskId, e.text);
});
```

A timer finished: one button adds five minutes, the other stops it.

```ts
import { notifyWithActions, onNotificationAction, can } from '@/lib/appmintNative';

export async function timerDone() {
  if (can('notificationActions') === 'none') return false;
  return notifyWithActions({
    title: 'Timer finished',
    tag: 'timer',
    actions: [{ id: 'more', title: '+5 min' }, { id: 'stop', title: 'Stop' }],
  });
}

export const stopTimerButtons = onNotificationAction((e) => {
  if (e.tag !== 'timer') return;                 // ignore other notifications' buttons
  if (e.actionId === 'more') startTimer(5);
});
```

Handle a refused permission after the call, and clean up in a component.

```ts
import { useEffect, useState } from 'react';
import { notifyWithActions, onNotificationAction } from '@/lib/appmintNative';

export function ApprovalRequest({ requestId }: { requestId: string }) {
  const [note, setNote] = useState('');
  useEffect(() => onNotificationAction((e) => {
    if (e.tag === `req-${requestId}`) setNote(e.actionId === 'approve' ? 'Approved' : 'Rejected');
  }), [requestId]);

  const ask = async () => {
    const posted = await notifyWithActions({
      title: 'Approve request?', tag: `req-${requestId}`,
      actions: [{ id: 'approve', title: 'Approve' }, { id: 'reject', title: 'Reject' }],
    });
    if (!posted) setNote('Notifications are off, so no buttons were shown.');
  };
  return <button onClick={ask}>{note || 'Ask me'}</button>;
}
```

**Notes:** Each button shows its `title`; its `id` is what comes back as `e.actionId`. Android shows at most 3 buttons. Android and iOS support the reply box. The web has no buttons a page can hear, so `can('notificationActions')` is `'none'` there; if called anyway it shows a plain notification without the buttons. A tap reaches the page only while the app is running.

### `appmintNative.offlineBanner`

```js
offlineBanner(opts?: { message?: string; onlineMessage?: string }): () => void
```

**Example**

Mounts a ready-made strip that slides in under the status bar when the connection drops, and briefly says "Back online" when it returns.

**Returns:** an unsubscribe function that removes the banner and stops listening. **Needs:** nothing.

Mount it once at app start:

```ts
import { useEffect } from 'react';
import { offlineBanner } from '@/lib/appmintNative';

export function App() {
  useEffect(() => offlineBanner(), []);          // cleanup removes it
  return <main>{/* … */}</main>;
}
```

Your own words (for example in the app's language):

```ts
import { offlineBanner } from '@/lib/appmintNative';

const off = offlineBanner({ message: 'Sin conexión', onlineMessage: 'Conectado de nuevo' });
```

Mount the banner only for the signed-in area of the app, and remove it on sign-out.

```ts
import { useEffect } from 'react';
import { offlineBanner } from '@/lib/appmintNative';

export function useOfflineBanner(signedIn: boolean) {
  useEffect(() => {
    if (!signedIn) return;
    return offlineBanner({ message: 'Offline: changes will sync later' });
  }, [signedIn]);
}
```

**Notes:**
- Defaults: "You are offline" and "Back online". The online message hides after about 1.8 seconds; the offline one stays until the connection returns.
- If the device is already offline when it mounts, the banner shows at once.
- Built on `onNetworkChange()`, so it is real on every surface. Colours come from the app's tokens (`--ink` / `--bg` offline, `--accent` / `--accent-ink` online). It has `role="status"` for screen readers.
- Mount it once. Two calls draw two banners.

### `appmintNative.onBackGesture`

```js
onBackGesture(handler: () => boolean): () => void
```

**Example**

Lets an open sheet, modal, lightbox or wizard step answer the BACK gesture first - the Android edge swipe / back button in the app, the browser's back button on the web.

**Returns:** an unsubscribe function - call it when the view closes or unmounts. Your handler returns `true` to consume the press (you closed something) or `false` to let it through. **Needs:** nothing.

Register only while the sheet is open:

```ts
import { useEffect, useState } from 'react';
import { onBackGesture } from '@/lib/appmintNative';

export function FilterPanel() {
  const [open, setOpen] = useState(false);

  useEffect(() => {
    if (!open) return;
    return onBackGesture(() => { setOpen(false); return true; });   // back closes THIS, not the app
  }, [open]);

  return (
    <>
      <button onClick={() => setOpen(true)}>Filters</button>
      {open && <div role="dialog" aria-label="Filters">{/* … */}</div>}
    </>
  );
}
```

Step back through a wizard, and let the last step's back leave normally:

```ts
import { useEffect } from 'react';
import { onBackGesture } from '@/lib/appmintNative';

export function useWizardBack(step: number, setStep: (n: number) => void) {
  useEffect(() => onBackGesture(() => {
    if (step === 0) return false;       // not ours: normal back navigation happens
    setStep(step - 1);
    return true;
  }), [step, setStep]);
}
```

Close a lightbox with the back gesture, keeping the registration tied to the image being open.

```ts
import { useEffect } from 'react';
import { onBackGesture } from '@/lib/appmintNative';

export function Lightbox({ src, onClose }: { src: string | null; onClose: () => void }) {
  useEffect(() => {
    if (!src) return;
    return onBackGesture(() => { onClose(); return true; });
  }, [src, onClose]);
  return src ? <img src={src} alt="" onClick={onClose} /> : null;
}
```

**Notes:**
- Handlers form a stack: the one registered last (the sheet on top) is asked first. A handler that throws consumes nothing.
- On Android it goes through the shell's back handler. Without it, swiping back with a sheet open closes the whole app.
- On the web it pushes one history entry while any handler is registered and removes it when the last one unsubscribes.
- `can('backGesture')` is `'none'` on iOS - no back gesture reaches the page there, so also draw a visible back / close control.
- React Router already handles normal page back; use this only for state the router does not know about. Always unsubscribe, or a closed sheet keeps eating back presses.

### `appmintNative.onDeepLink`

```js
onDeepLink(cb: (url: string) => void): () => void
```

**Example**

Calls you when a link opens the app while it is already running - a deep link, an icon shortcut, a widget tap.

**Returns:** an unsubscribe function - call it on unmount. **Needs:** nothing for shortcuts and widget taps; outside links need the **Deep Linking** option when you build.

```ts
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { launchUrl, onDeepLink } from '@/lib/appmintNative';

export function DeepLinkRouter() {
  const navigate = useNavigate();
  useEffect(() => {
    const go = (u: string) => { const { pathname, search } = new URL(u); navigate(pathname + search); };
    void launchUrl().then((u) => { if (u) go(u); });   // the link that started the app
    return onDeepLink(go);                             // links while it runs; returns the unsubscribe
  }, [navigate]);
  return null;
}
```

A hand-written page (no React) can listen to the same shell event directly:

```js
window.addEventListener('appmint:deep-link', function (e) {
  var url = e.detail && e.detail.url;
  if (url) console.log('deep link', url);
});
```

Open a specific product when a widget or shortcut is tapped, ignoring links that are not ours.

```ts
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { onDeepLink } from '@/lib/appmintNative';

export function ProductLinks() {
  const navigate = useNavigate();
  useEffect(() => onDeepLink((url) => {
    const m = new URL(url).pathname.match(/^\/product\/(\w+)$/);
    if (m) navigate(`/products/${m[1]}`);
  }), [navigate]);
  return null;
}
```

**Notes:**
- Android: listens to the shell's `appmint:deep-link` event (`detail.url`); the shell also calls `window.onAppMintDeepLink(detail)` when a page defines it. After a link arrives, `launchUrl()` returns it too.
- iOS: the Capacitor App plugin's `appUrlOpen` event.
- Web: never fires (the callback is not called) and the unsubscribe does nothing.

### `appmintNative.onKeyboard`

```js
onKeyboard(cb: (insetPx: number) => void): () => void
```

**Example**

Tells you how many pixels of the page the on-screen keyboard covers, every time that changes, so a bottom bar can ride above it.

**Returns:** an unsubscribe function - call it on unmount. The callback gets a number in CSS pixels: the extra bottom padding you need, `0` when the keyboard is closed. **Needs:** nothing.

```ts
import { useEffect, useState } from 'react';
import { onKeyboard } from '@/lib/appmintNative';

export function Composer() {
  const [kb, setKb] = useState(0);
  useEffect(() => onKeyboard(setKb), []);          // the returned function unsubscribes

  return (
    <form
      className="fixed inset-x-0 bottom-0 flex gap-2 p-3"
      style={{ paddingBottom: `calc(var(--safe-bottom) + 12px + ${kb}px)` }}
    >
      <input className="flex-1" enterKeyHint="send" aria-label="Message" />
      <button type="submit">Send</button>
    </form>
  );
}
```

Keep a chat list scrolled to the newest message while the keyboard opens:

```ts
import { useEffect, useRef } from 'react';
import { onKeyboard } from '@/lib/appmintNative';

export function MessageList({ children }: { children: React.ReactNode }) {
  const endRef = useRef<HTMLDivElement>(null);
  useEffect(() => onKeyboard((inset) => {
    if (inset > 0) endRef.current?.scrollIntoView({ block: 'end' });   // keyboard just opened
  }), []);
  return <div className="overflow-y-auto">{children}<div ref={endRef} /></div>;
}
```

A reusable hook, with a safe-area gap that disappears while the keyboard is open:

```ts
import { useEffect, useState } from 'react';
import { onKeyboard } from '@/lib/appmintNative';

export function useKeyboardInset(): number {
  const [inset, setInset] = useState(0);        // 0 until the keyboard first opens
  useEffect(() => onKeyboard(setInset), []);
  return inset;
}

// In a component: the home-indicator gap is only needed when the keyboard is closed.
// const kb = useKeyboardInset();
// <footer style={{ paddingBottom: kb > 0 ? kb : 'var(--safe-bottom)' }}>...</footer>
```

**Notes:**
- In the app it listens to the Capacitor Keyboard plugin and reports only the part of the keyboard the layout did not already absorb. The shell usually resizes the page for the keyboard, so the value is often `0` there - that is correct; do not add the raw keyboard height yourself.
- In a fullscreen or kiosk app (page not resized) it reports the whole keyboard height.
- On the web it uses `visualViewport`, calls the callback once right away, and treats under 80 px as browser chrome (reported as `0`).
- In the app the first call comes when the keyboard first opens, so start your state at `0`.

### `appmintNative.onNetworkChange`

```js
onNetworkChange(cb: (status: { connected: boolean; type: string }) => void): () => void
```

**Example**

Calls you whenever the connection drops or comes back - for an offline banner, or to retry queued writes.

**Returns:** an unsubscribe function - call it on unmount. **Needs:** nothing.

Retry queued work when the device is back online:

```ts
import { useEffect } from 'react';
import { onNetworkChange, toast } from '@/lib/appmintNative';

export function useRetryWhenOnline(flushQueue: () => Promise<number>) {
  useEffect(() => {
    const off = onNetworkChange(async (s) => {
      if (!s.connected) return;
      const sent = await flushQueue();
      if (sent) toast(`Synced ${sent} change(s)`);
    });
    return off;                                  // unsubscribe on unmount
  }, [flushQueue]);
}
```

Show the current state at once, then follow changes (the callback is not called on subscribe):

```ts
import { useEffect, useState } from 'react';
import { networkStatus, onNetworkChange } from '@/lib/appmintNative';

export function useOnline(): boolean {
  const [online, setOnline] = useState(() => networkStatus().connected);
  useEffect(() => onNetworkChange((s) => setOnline(s.connected)), []);
  return online;
}
```

Hold large downloads back on mobile data, and treat `'none'` as offline:

```ts
import { useEffect } from 'react';
import { onNetworkChange, toast } from '@/lib/appmintNative';

export function useDataSaver(setHeavyAllowed: (ok: boolean) => void) {
  useEffect(() => onNetworkChange((s) => {
    if (s.type === 'none' || !s.connected) { toast('You are offline'); setHeavyAllowed(false); return; }
    setHeavyAllowed(s.type !== 'cellular');   // 'wifi' and 'unknown' may download freely
  }), [setHeavyAllowed]);
}
```

**Notes:**
- It does NOT call you with the current state when you subscribe. Read `networkStatus()` for the starting value.
- In the app it listens to the Capacitor Network plugin (`networkStatusChange`); `type` is `'wifi'`, `'cellular'`, `'none'` or `'unknown'`.
- On the web it listens to the `online` / `offline` events and reports `networkStatus()`.
- For a ready-made banner, use `offlineBanner()`, which is built on this.

### `appmintNative.onNotificationAction`

```js
import { onNotificationAction } from '@/lib/appmintNative'
```

**Example**

Listens for taps on notification buttons (from `notifyWithActions`), including the text typed into an inline reply.

**Returns:** a function that stops listening. The callback gets `{ actionId, tag, text }` - `text` is the typed reply, or `''`. **Needs:** the same as `notifyWithActions`.

```ts
import { useEffect } from 'react';
import { onNotificationAction } from '@/lib/appmintNative';

export function useNotificationActions(onReply: (chatId: string, text: string) => void) {
  useEffect(() => {
    const stop = onNotificationAction(({ actionId, tag, text }) => {
      if (actionId === 'Reply' && text) onReply(tag, text);
      if (actionId === 'Mute') muteChat(tag);
    });
    return stop;                              // remove on unmount
  }, [onReply]);
}
```

**Raw event in a hand-written page** (Android shell):

```js
window.addEventListener('appmint:notification-action', function (e) {
  // e.detail = { actionId: 'Reply', tag: 'chat-7', text: 'On my way' }
  console.log(e.detail.actionId, e.detail.tag, e.detail.text);
});
```

Post a notification with buttons only where the build supports them, and handle the tap by the notification's tag:

```ts
import { useEffect } from 'react';
import { can, notifyWithActions, onNotificationAction, toast } from '@/lib/appmintNative';

export async function remindWithButtons(taskId: string, title: string) {
  if (can('notificationActions') === 'none') { toast(title); return; }   // no buttons here
  await notifyWithActions({
    title, body: 'Tap a button', tag: `task-${taskId}`,
    actions: [{ id: 'done', title: 'Done' }, { id: 'later', title: 'Later' }],
  });
}

export function useTaskActions(complete: (id: string) => void, snooze: (id: string) => void) {
  useEffect(() => onNotificationAction(({ actionId, tag }) => {
    const id = tag.replace(/^task-/, '');
    if (actionId === 'done') complete(id);
    else if (actionId === 'later') snooze(id);
  }), [complete, snooze]);
}
```

**Notes:** Android: the `appmint:notification-action` event (also `window.onAppMintNotificationAction(detail)`); iOS: the LocalNotifications `localNotificationActionPerformed` listener; on the web it does nothing. On Android a tap is delivered only while the app is running - tapping a button does not open the app.

### `appmintNative.onNotificationClick`

```js
import { onNotificationClick } from '@/lib/appmintNative'
```

**Example**

Runs a callback when the user taps a notification the app posted - including the tap that started the app from cold - so the app can open the right screen.

**Returns:** the unsubscribe function. The callback gets `{ tag, title, body, data }` - `data` is what you passed to `notify({ …, data })`. **Needs:** notifications, like `notify()`. Event behind it: `appmint:notification-click`.

```ts
import { useEffect } from 'react';
import { notify, onNotificationClick } from '@/lib/appmintNative';

export function useOrderAlerts(navigate: (path: string) => void) {
  useEffect(() => onNotificationClick((e) => {
    const d = e.data as { orderId?: number } | null;
    if (d?.orderId) navigate(`/orders/${d.orderId}`);
  }), [navigate]);
}

async function orderShipped(orderId: number) {
  await notify({ title: 'Order shipped', body: `#${orderId} is on its way`, tag: `order-${orderId}`, data: { orderId } });
}
```

Open the right screen after the tap, even when it started the app from cold, and ignore notifications that carry no data:

```ts
import { useEffect } from 'react';
import { onNotificationClick } from '@/lib/appmintNative';

export function useNotificationRouting(navigate: (path: string) => void) {
  useEffect(() => onNotificationClick(({ tag, data }) => {
    const d = data as { screen?: string; id?: string } | null;
    if (!d?.screen) { console.log('plain notification tapped:', tag); return; }
    navigate(d.id ? `/${d.screen}/${d.id}` : `/${d.screen}`);
  }), [navigate]);
}
```

Send a notification only where notifications exist, with data for the tap (the `data` needs a `tag`):

```ts
import { can, notify, toast } from '@/lib/appmintNative';

export async function announceMessage(chatId: string, from: string, text: string) {
  if (can('notify') === 'none') return;                 // nothing to tap on this surface
  const shown = await notify({ title: from, body: text, tag: `chat-${chatId}`, data: { screen: 'chat', id: chatId } });
  if (!shown) toast('Notifications are turned off');
}
```

**Notes:** Android: the tap travels through the splash and the PIN screen to the page. iOS: the LocalNotifications tap. `data` must be JSON (under 16 KB) and needs a `tag`.

### `appmintNative.onPullToRefresh`

```js
onPullToRefresh(el: HTMLElement, onRefresh: () => void | Promise<void>, opts?: { threshold?: number }): () => void
```

**Example**

Adds the pull-down-to-refresh gesture to a scrolling list: pull down at the top, release, and your data reloads.

**Returns:** an unsubscribe function - call it on unmount. **Needs:** nothing.

`el` is the element that scrolls (or `document.documentElement` when the whole page scrolls). The spinner stays until the promise from `onRefresh` settles:

```ts
import { useEffect, useRef } from 'react';
import { onPullToRefresh, toast } from '@/lib/appmintNative';

export function Inbox({ reload }: { reload: () => Promise<void> }) {
  const listRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!listRef.current) return;
    return onPullToRefresh(listRef.current, async () => {
      try { await reload(); }
      catch { toast('Could not refresh — check your connection'); }
    });
  }, [reload]);

  return <div ref={listRef} className="h-full overflow-y-auto">{/* rows */}</div>;
}
```

A page that scrolls as a whole, with a longer pull:

```ts
import { onPullToRefresh } from '@/lib/appmintNative';

declare function reloadFeed(): Promise<void>;
const off = onPullToRefresh(document.documentElement, reloadFeed, { threshold: 96 });
```

Refresh a list through a data hook, with a haptic tick when the data has arrived:

```ts
import { useEffect, useRef } from 'react';
import { onPullToRefresh, haptic } from '@/lib/appmintNative';

export function useListRefresh(refetch: () => Promise<unknown>) {
  const ref = useRef<HTMLUListElement>(null);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    return onPullToRefresh(el, async () => {
      await refetch();
      haptic('success');                     // the spinner stays until this promise settles
    }, { threshold: 80 });
  }, [refetch]);
  return ref;                                // <ul ref={ref} className="h-full overflow-y-auto">
}
```

**Notes:**
- It starts only when the list is scrolled to the very top. The default `threshold` is 72 px of pull.
- It draws a round indicator in the app's colours (`--surface`, `--accent`), fires a medium haptic at the commit point, and ignores new pulls while a refresh runs.
- It refreshes your DATA, not the page. The shell's own page-reload pull stays off in AI-built apps.
- Built on Pointer Events, so it is the same on every surface. It sets `overscroll-behavior-y: contain` on `el` and restores it on unsubscribe.

### `appmintNative.onShared`

```js
onShared(cb: (p: { text, subject, url, receivedAt }) => void): () => void
```

**Example**

Calls you when another app shares text or a link into this app while it is already open.

**Returns:** an unsubscribe function - call it on unmount. **Needs:** the build's **Receive Shares From Other Apps** option with "Text and links" - switched on automatically when your code calls `onShared(` or `sharedText(`.

Handle both cases - a share that started the app, and one that arrives later:

```ts
import { useEffect } from 'react';
import { can, sharedText, clearShared, onShared, toast } from '@/lib/appmintNative';

export function useIncomingShares(save: (text: string, url: string) => Promise<void>) {
  useEffect(() => {
    if (can('shareTarget') === 'none') return;
    const handle = async (p: { text: string; url: string }) => {
      await save(p.text, p.url);
      clearShared();
      toast('Added');
    };
    const waiting = sharedText();
    if (waiting) void handle(waiting);                    // shared before the app opened
    return onShared((p) => void handle(p));              // shared while open; returns the unsubscribe
  }, [save]);
}
```

A hand-written page can listen to the shell event itself:

```js
window.addEventListener('appmint:shared', function (e) {
  var p = e.detail;                  // { text, subject, url, receivedAt }
  if (p && p.text) console.log('shared:', p.text);
});
```

Prefill a "new note" form from a shared link, only where the build can receive shares:

```ts
import { useEffect, useState } from 'react';
import { can, onShared, toast } from '@/lib/appmintNative';

export function NewNote() {
  const [body, setBody] = useState('');
  useEffect(() => {
    if (can('shareTarget') === 'none') return;
    return onShared((p) => {
      setBody([p.subject, p.text, p.url].filter(Boolean).join('\n'));
      toast('Shared text added');
    });
  }, []);
  return <textarea value={body} onChange={(e) => setBody(e.target.value)} />;
}
```

**Notes:**
- Android: listens to the shell's `appmint:shared` event; the shell also calls `window.onAppMintShared(detail)` when a page defines it. The same payload is also kept for `sharedText()` until `clearShared()`.
- On the web and iOS it never fires and returns a no-op unsubscribe.

### `appmintNative.openNotificationSettings`

```js
openNotificationSettings(): Promise<boolean>
```

**Example**

Opens this app's own notification settings screen (Android app).

**Returns:** `Promise<boolean>` - true when the screen opened; false on the web and wherever there is no such screen. Check `can('notificationSettings')` before drawing the control. **Needs:** nothing.

```ts
import { can, openNotificationSettings } from '@/lib/appmintNative';

export function NotificationSettingsRow(row: HTMLElement) {
  row.hidden = can('notificationSettings') === 'none';
  row.addEventListener('click', () => { void openNotificationSettings(); });
}
```

**Notes:** for a one-tap "Turn on notifications" use `enableNotifications()`, which opens this screen only when the permission dialog can no longer be shown.

### `appmintNative.passkeys`

```js
passkeys — available / create / get / origin / assetLinks
```

**Example**

Passkey sign-in and registration with the JSON options your passkey server makes. In the Android app it uses Android's passkey manager; on the web it uses `navigator.credentials`.

**Returns:** `available()` → `'native' | 'web' | 'none'`; `create(optionsJSON)` → Promise of the RegistrationResponseJSON; `get(optionsJSON)` → Promise of the AuthenticationResponseJSON (POST either back to your server). They reject with a `DOMException` (`NotAllowedError`, `InvalidStateError`, `SecurityError`, `NotSupportedError`). `origin()` → string, `assetLinks()` → array (app only; `''` / `[]` on the web). **Needs:** a passkey server; in the app also Android 9+ with Google Play services and your domain's `assetlinks.json`.

```ts
import { passkeys } from '@/lib/appmintNative';

export async function signInWithPasskey(): Promise<boolean> {
  if (passkeys.available() === 'none') return false;          // hide the passkey button
  const options = await (await fetch('https://your-domain.com/passkey/login-options')).json();
  try {
    const answer = await passkeys.get(options);
    const res = await fetch('https://your-domain.com/passkey/login-verify', {
      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(answer),
    });
    return res.ok;
  } catch (e) {
    if ((e as DOMException).name === 'NotAllowedError') return false;   // cancelled / none here
    throw e;
  }
}

export async function addPasskey() {
  const options = await (await fetch('https://your-domain.com/passkey/register-options')).json();
  const answer = await passkeys.create(options);   // InvalidStateError = already registered
  await fetch('https://your-domain.com/passkey/register-verify', {
    method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(answer),
  });
}
```

One-time setup values, from the app installed via Google Play:

```ts
import { passkeys } from '@/lib/appmintNative';

console.log(JSON.stringify(passkeys.assetLinks(), null, 2));  // → /.well-known/assetlinks.json
console.log(passkeys.origin());                               // → add to expected origins
```

Offer the passkey button only where it can work, and say so when it cannot:

```ts
import { passkeys } from '@/lib/appmintNative';

export function PasskeyButton({ onClick }: { onClick: () => void }) {
  const kind = passkeys.available();          // 'native' | 'web' | 'none'
  if (kind === 'none') return <p>Passkeys are not available on this device.</p>;
  return <button onClick={onClick}>{kind === 'native' ? 'Use your phone passkey' : 'Use a passkey'}</button>;
}
```

**Notes:** iOS answers `'none'` (the app is served from `capacitor://localhost`, which no relying party can be). A page bundled in the app has no domain: your server's options must set `rp.id` / `rpId` to your domain. Passkey autofill (`mediation: 'conditional'`) is not available in the app - start from a button.

### `appmintNative.paste`

```js
paste(): Promise<string>
```

**Example**

Reads the text on the system clipboard.

**Returns:** `Promise<string>` - the clipboard text, or `''` when the clipboard is empty. It REJECTS when this browser cannot read the clipboard at all, or when the user denies permission. **Needs:** nothing.

```ts
import { can, paste, toast } from '@/lib/appmintNative';

export function PasteCode({ onCode }: { onCode: (c: string) => void }) {
  if (can('clipboardRead') === 'none') return null;   // e.g. Firefox and Safari
  return (
    <button
      onClick={async () => {
        try {
          const text = (await paste()).trim();
          if (!text) { toast('The clipboard is empty'); return; }
          onCode(text);
        } catch {
          toast('Clipboard access was blocked');       // denied permission, not "empty"
        }
      }}
    >
      Paste code
    </button>
  );
}
```

The Capacitor-shaped form:

```ts
import { Clipboard } from '@/lib/appmintNative';

const { value } = await Clipboard.read();
```

Paste into a text box with a message for each outcome, and pair it with `copy`:

```ts
import { copy, paste, toast } from '@/lib/appmintNative';

export async function pasteInto(setValue: (s: string) => void) {
  try {
    const text = await paste();
    if (text) setValue(text);
    else toast('Nothing to paste');
  } catch {
    toast('Allow clipboard access to paste');
  }
}

export async function shoutClipboard() {
  const text = await paste().catch(() => '');
  if (text && await copy(text.toUpperCase())) toast('Converted and copied');
}
```

**Notes:** In the app the Clipboard plugin reads the text; an empty clipboard gives `''`, not an error. In a browser it is `navigator.clipboard.readText()` (Chromium only), which asks the user for permission - call it from a tap.

### `appmintNative.pickContact`

```js
pickContact(): Promise<Contact | null>
```

**Example**

Opens the system contact picker so the user chooses one person: Android's picker in the app, iOS's picker through Capacitor, the Contact Picker API in Chrome on Android.

**Returns:** `Promise<Contact | null>` - `null` when the user cancels or access is refused. **Needs:** nothing on Android (no permission); iOS asks for contacts access.

**Pick a person and fill a form.** The contact has the same shape everywhere: `{ id?, name?, phones?: string[], emails?: string[], photoUri? }`.

```ts
import { pickContact, can } from '@/lib/appmintNative';

export async function onPickClick(setName: (s: string) => void, setPhone: (s: string) => void) {
  if (can('contactPick') === 'none') return;   // hide the button instead
  try {
    const c = await pickContact();
    if (!c) return;                            // cancelled
    setName(c.name ?? '');
    setPhone(c.phones?.[0] ?? '');
  } catch {
    // only a 5-minute timeout rejects
  }
}
```

A "Choose a contact" button that shows the pick, and a plain number box where the picker does not exist:

```ts
import { useState } from 'react';
import { can, pickContact, type Contact } from '@/lib/appmintNative';

export function InvitePicker() {
  const [who, setWho] = useState<Contact | null>(null);
  if (can('contactPick') === 'none') return <input placeholder="Phone number" />;   // type it by hand
  return (
    <div>
      <button onClick={async () => setWho((await pickContact()) ?? who)}>Choose a contact</button>
      {who && <p>{who.name} {who.phones?.[0]}</p>}
    </div>
  );
}
```

Hand the chosen number to the texting app, with a message when the contact has none:

```ts
import { pickContact, toast } from '@/lib/appmintNative';

export async function textAFriend(message: string) {
  const c = await pickContact();
  if (!c) return;                              // cancelled
  const phone = c.phones?.[0];
  if (!phone) { toast(`${c.name ?? 'That contact'} has no phone number`); return; }
  window.location.href = `sms:${encodeURIComponent(phone)}?body=${encodeURIComponent(message)}`;
}
```

**Notes:** On Android only the tapped number comes back, never emails, and `photoUri` is a `content://` address of the contact photo. `can('contactPick')` is `'none'` in browsers without the Contact Picker API (desktop, Safari) - do not render the button there. On iOS the contacts permission sheet appears first; a refusal resolves `null`.

### `appmintNative.pickFile`

```js
pickFile(accept?) from '@/lib/appmintNative'
```

**Example**

Lets the user choose one file and read it as text. In the app it opens Android's document picker; on the web it opens a normal file input.

**Returns:** `Promise<PickedFile | null>` - `{ name, mime, size, text(): Promise<string> }`, or `null` only when the user cancels. It rejects with an `Error` whose `code` is `'no-picker'` (the phone has no document picker app), `'disabled'` or `'failed'` when no picker could open. `text()` reads the whole file and rejects with the bridge's code (for example `'not-found'`) when it cannot. **Needs:** nothing. With **Native Folder Access (SAF)** ticked in Step 4 (Access) the app uses Android's document picker; without it the app uses the page's own file chooser.

```ts
import { pickFile } from '@/lib/appmintNative';

async function importCsv() {
  let f;
  try {
    f = await pickFile('text/csv');              // default '*/*' = any file
  } catch (e) {
    alert(`Could not open the file picker (${(e as { code?: string }).code ?? 'error'}).`);
    return;
  }
  if (!f) return;                                // the user cancelled
  if (f.size > 2_000_000) { alert('Please choose a file under 2 MB.'); return; }
  try {
    setRows(parseCsv(await f.text()));
  } catch (e) {
    alert(`Could not read ${f.name}: ${String(e)}`);
  }
}
```

Import a JSON backup and handle each way `pickFile` can fail:

```ts
import { pickFile, toast } from '@/lib/appmintNative';

export async function restoreBackup(apply: (data: unknown) => void) {
  try {
    const f = await pickFile('application/json');
    if (!f) return;                                    // cancelled
    apply(JSON.parse(await f.text()));
    toast(`Restored ${f.name}`);
  } catch (e) {
    const code = (e as { code?: string }).code;
    if (code === 'no-picker') toast('This phone has no file picker app');
    else if (e instanceof SyntaxError) toast('That file is not a valid backup');
    else toast('Could not open the file');
  }
}
```

Choose a text file and put it in an editor, next to the file's details:

```ts
import { useState } from 'react';
import { pickFile } from '@/lib/appmintNative';

export function OpenText() {
  const [doc, setDoc] = useState<{ name: string; kb: number; text: string } | null>(null);
  return (
    <div>
      <button onClick={async () => {
        const f = await pickFile('text/*');
        if (f) setDoc({ name: f.name, kb: Math.ceil(f.size / 1024), text: await f.text() });
      }}>Open a text file</button>
      {doc && <><h3>{doc.name} ({doc.kb} KB)</h3><textarea defaultValue={doc.text} /></>}
    </div>
  );
}
```

**Notes:** In the app `text()` reads the file in 4 MB pieces until the end and decodes it as UTF-8, so large files are read in full - keep your own size limit (as above) for what the screen can handle. On the web `text()` is the browser's own `File.text()`. For binary files or writing back, hand-written pages use `WebToApkFS`.

### `appmintNative.pip`

```js
pip.enter(video?) / pip.exit(video?)
```

**Example**

Keeps a video playing in a small floating window while the user does something else.

**Returns:** `enter()` returns `Promise<boolean>` - `true` when the floating window opened. `exit()` returns `Promise<void>`. Neither rejects. **Needs:** in the Android app the build's **Picture-in-Picture (PiP)** option - it is switched on automatically when your code calls `pip.enter(`.

A "minimise" button on a player:

```ts
import { useRef } from 'react';
import { can, pip, toast } from '@/lib/appmintNative';

export function Player({ src }: { src: string }) {
  const videoRef = useRef<HTMLVideoElement>(null);
  return (
    <div>
      <video ref={videoRef} src={src} controls playsInline />
      {can('pip') !== 'none' && (
        <button
          onClick={async () => {
            const ok = await pip.enter(videoRef.current ?? undefined);
            if (!ok) toast('Picture-in-picture is not available here');
          }}
        >
          Minimise
        </button>
      )}
    </div>
  );
}
```

Leave it when the player closes (web and iOS):

```ts
import { pip } from '@/lib/appmintNative';

declare const video: HTMLVideoElement;
await pip.exit(video);
```

Enter picture-in-picture automatically when the user leaves a playing video, only where it is supported:

```ts
import { useEffect, type RefObject } from 'react';
import { can, pip } from '@/lib/appmintNative';

export function useAutoPip(videoRef: RefObject<HTMLVideoElement>) {
  useEffect(() => {
    if (can('pip') === 'none') return;
    const onHide = () => {
      const v = videoRef.current;
      if (document.hidden && v && !v.paused) void pip.enter(v);
    };
    document.addEventListener('visibilitychange', onHide);
    return () => document.removeEventListener('visibilitychange', onHide);
  }, [videoRef]);
}
```

**Notes:**
- Android (8+): the WHOLE app shrinks into the floating window, and the `video` argument is ignored. It returns `false` on older Android or in a build without the PiP option. The user expands the window to come back; `exit()` does nothing there.
- iOS and the web: the `<video>` element's own picture-in-picture. The video must be loaded (usually playing); a call without a `video` returns `false`.
- Call it from a tap.

### `appmintNative.platform`

```js
platform(): 'android' | 'ios' | 'web'
```

**Example**

Tells you which surface the code runs on: the Android app, the iOS app, or a web browser.

**Returns:** `'android' | 'ios' | 'web'` right away. It asks Capacitor first (`Capacitor.getPlatform()`), then checks for the Android shell bridge, else `'web'`. **Needs:** nothing.

**Pick the right store link and copy.**

```ts
import { platform } from '@/lib/appmintNative';

const p = platform();

const rateLink =
  p === 'android' ? 'https://play.google.com/store/apps/details?id=com.example.app' :
  p === 'ios'     ? 'https://apps.apple.com/app/id0000000000' :
                    '/feedback';

const backHint = p === 'ios' ? 'Swipe from the left edge to go back' : 'Press Back to go back';
```

**Show app-only controls.**

```ts
import { platform } from '@/lib/appmintNative';

export function InstallBanner() {
  // In the installed app there is nothing to install.
  if (platform() === 'android') return null;
  return <a href="https://play.google.com/store/apps/details?id=com.example.app">Get the Android app</a>;
}
```

**Tag analytics events with the surface.**

```ts
import { platform } from '@/lib/appmintNative';

export function track(event: string, data: Record<string, unknown> = {}) {
  return fetch('/api/track', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ event, surface: platform(), ...data }),
  });
}
```

**Notes:** `platform()` never answers `'web'` inside either installed app. Do not use it to decide whether a feature exists - some features differ by build settings too; `can('<capability>')` answers that.

### `appmintNative.privacyScreen`

```js
privacyScreen — enable() / disable() / isEnabled()
```

**Example**

Blocks screenshots, screen recording and the app-switcher preview while a screen with secrets is open.

**Returns:** `enable()` → `Promise<boolean>` (`false` where nothing can block it); `disable()` → `Promise<void>`; `isEnabled()` → `Promise<boolean>`. **Needs:** nothing. Android uses the shell's screenshot block; iOS uses the PrivacyScreen plugin (capture blocked, blurred in the switcher).

```ts
import { useEffect } from 'react';
import { privacyScreen } from '@/lib/appmintNative';

/** Use in the component that shows the secret (a card number, a statement). */
export function usePrivacyScreen() {
  useEffect(() => {
    void privacyScreen.enable();
    return () => { void privacyScreen.disable(); };   // e.g. a modal closed without a route change
  }, []);
}
```

Tell the user what is protected - only when it really is:

```ts
import { privacyScreen, can } from '@/lib/appmintNative';

export async function protectionLabel(): Promise<string> {
  if (can('privacyScreen') === 'none') return '';           // web: nothing can stop a screenshot
  return (await privacyScreen.isEnabled()) ? 'Screenshots are blocked on this screen' : '';
}
```

Turn protection on for one sensitive view and tell the user if it could not be applied:

```ts
import { privacyScreen, toast } from '@/lib/appmintNative';

export async function openStatement(show: () => void) {
  const protectedNow = await privacyScreen.enable();   // false where nothing can block capture
  if (!protectedNow) toast('Screenshots cannot be blocked here');
  show();
}

export async function closeStatement(hide: () => void) {
  hide();
  await privacyScreen.disable();
}
```

**Notes:** On Android the block belongs to the current page and is released when the app navigates to another route, so enable it again when that screen shows again. On the web `can('privacyScreen')` is `'none'` and `enable()` returns `false` - do not claim protection there. Nothing stops a camera pointed at the screen.

### `appmintNative.pushInbox`

```js
import { pushInbox } from '@/lib/appmintNative'
```

**Example**

Returns the Appwright Push messages this install received (newest first, up to 50), for an in-app notifications screen.

**Returns:** `PushMessage[]` synchronously: `{ id, title, body, imageUrl, link, receivedAt, read }`. An empty array on the web, on iOS, and when Appwright Push is off. **Needs:** Android: **Appwright Push** turned on in the Integrate step when you build.

```ts
import { useEffect, useState } from 'react';
import { pushInbox, markPushRead, type PushMessage } from '@/lib/appmintNative';

export function useInbox() {
  const [items, setItems] = useState<PushMessage[]>(() => pushInbox());
  useEffect(() => {
    const refresh = () => setItems(pushInbox());
    // detail: { type: 'received', message } or { type: 'revoked', id }
    window.addEventListener('appmint:push', refresh);
    return () => window.removeEventListener('appmint:push', refresh);
  }, []);
  const unread = items.filter((m) => !m.read).length;
  const open = (m: PushMessage) => {
    markPushRead(m.id);
    setItems(pushInbox());
  };
  return { items, unread, open };
}
```

Show an unread badge count that follows new arrivals:

```ts
import { useEffect, useState } from 'react';
import { pushInbox } from '@/lib/appmintNative';

export function UnreadBadge() {
  const [n, setN] = useState(() => pushInbox().filter((m) => !m.read).length);
  useEffect(() => {
    const recount = () => setN(pushInbox().filter((m) => !m.read).length);
    window.addEventListener('appmint:push', recount);
    return () => window.removeEventListener('appmint:push', recount);
  }, []);
  return n > 0 ? <span aria-label={`${n} unread`}>{n}</span> : null;
}
```

Render the list, with an empty state for the web and for builds without Appwright Push, and a "Mark all read" button:

```ts
import { useState } from 'react';
import { pushInbox, markPushRead } from '@/lib/appmintNative';

export function NotificationsScreen() {
  const [items, setItems] = useState(() => pushInbox());
  if (items.length === 0) return <p>No notifications yet.</p>;
  return (
    <section>
      <button onClick={() => { markPushRead(); setItems(pushInbox()); }}>Mark all read</button>
      <ul>
        {items.map((m) => (
          <li key={m.id} style={{ fontWeight: m.read ? 400 : 700 }}>
            {m.title} <small>{new Date(m.receivedAt).toLocaleString()}</small>
            <p>{m.body}</p>
          </li>
        ))}
      </ul>
    </section>
  );
}
```

**Notes:** the inbox is the saved truth - read it on mount; the `appmint:push` event only fires while the app is on screen. Dismissed messages are left out.

### `appmintNative.readBarcodeFrom`

```js
readBarcodeFrom(source: Blob | CanvasImageSource, formats?: BarcodeFormat[]): Promise<{ value, format }[]>
```

**Example**

Reads barcodes and QR codes from a picture the page already has - a photo file, a Blob, an `<img>`, a `<canvas>` or a video frame. No camera is opened.

**Returns:** `Promise<{ value: string; format: string }[]>` - every code found, in the detector's order; an empty array when there is none, when the image cannot be read, or when barcode reading is not available. It never rejects. **Needs:** nothing.

Read a code from a photo the user picks:

```ts
import { can, readBarcodeFrom, toast } from '@/lib/appmintNative';

export function ReadFromPhoto({ onCode }: { onCode: (v: string) => void }) {
  if (can('barcodeScan') === 'none') return null;
  return (
    <label>
      Read a code from a photo
      <input
        type="file"
        accept="image/*"
        onChange={async (e) => {
          const file = e.currentTarget.files?.[0];
          if (!file) return;
          const codes = await readBarcodeFrom(file, ['qr_code']);
          if (codes.length) onCode(codes[0].value);
          else toast('No QR code found in that photo');
        }}
      />
    </label>
  );
}
```

From an image already on the page:

```ts
import { readBarcodeFrom } from '@/lib/appmintNative';

const img = document.querySelector('img#ticket') as HTMLImageElement;
const [first] = await readBarcodeFrom(img);
if (first) console.log(first.format, first.value);
```

Read a code from a canvas frame, checking what the device supports first:

```ts
import { barcodeFormats, readBarcodeFrom } from '@/lib/appmintNative';

export async function scanCanvas(canvas: HTMLCanvasElement): Promise<string | null> {
  const supported = await barcodeFormats();
  if (supported.length === 0) return null;                     // no reader on this device
  const wanted = ['qr_code', 'ean_13'].filter((f) => supported.includes(f)) as ('qr_code' | 'ean_13')[];
  const [hit] = await readBarcodeFrom(canvas, wanted);
  return hit?.value ?? null;
}
```

**Notes:**
- Uses the browser engine's `BarcodeDetector` (present in the Android app's WebView; missing in Safari and many desktop browsers - then it returns `[]`).
- A plain `<input type="file" accept="image/*">` opens the Android photo picker and needs no storage permission.
- Narrow `formats` when you know them: it is faster and misreads less. `barcodeFormats()` lists what the device supports.

### `appmintNative.readOtp`

```js
import { readOtp } from '@/lib/appmintNative'
```

**Example**

Waits for the SMS one-time code for this app and returns it - Google's SMS consent sheet in the Android app, WebOTP in Chrome, and `can('otp') === 'none'` on iPhone (use `autocomplete="one-time-code"` there).

**Returns:** `Promise<string | null>` - the code, or `null` when the user declined, five minutes passed, or `signal` aborted. Throws when `can('otp')` is `'none'`. **Needs:** nothing to switch on. The SMS must end with `@<host> #<code>` (in the Android app the host is `appassets.androidplatform.net`).

```ts
import { useEffect, useState } from 'react';
import { can, readOtp } from '@/lib/appmintNative';

export function CodeField({ onDone }: { onDone: (code: string) => void }) {
  const [code, setCode] = useState('');

  useEffect(() => {
    if (can('otp') === 'none') return;
    const ac = new AbortController();
    readOtp({ signal: ac.signal }).then((c) => { if (c) { setCode(c); onDone(c); } });
    return () => ac.abort();          // leaving the screen stops the wait
  }, [onDone]);

  return (
    <input value={code} onChange={(e) => setCode(e.target.value)}
      inputMode="numeric" autoComplete="one-time-code" aria-label="Verification code" />
  );
}
```

Handle the "no code" answer, and offer a resend when the wait ends without one:

```ts
import { useState } from 'react';
import { can, readOtp } from '@/lib/appmintNative';

export function VerifyStep({ resend, submit }: { resend: () => void; submit: (c: string) => void }) {
  const [waited, setWaited] = useState(false);

  async function start() {
    resend();
    if (can('otp') === 'none') return;          // readOtp would throw here — the user types it
    const code = await readOtp();               // null: declined, 5 minutes passed, or aborted
    if (code) submit(code); else setWaited(true);
  }

  return (
    <div>
      <button onClick={start}>Send code</button>
      {waited && <p>No code detected. <button onClick={start}>Send again</button></p>}
    </div>
  );
}
```

Catch the throw when you cannot check `can('otp')` first:

```ts
import { readOtp } from '@/lib/appmintNative';

export async function tryAutoFill(): Promise<string | null> {
  try {
    return await readOtp({ signal: AbortSignal.timeout(60_000) });
  } catch {
    return null;                                // one-time codes unavailable: the user types it
  }
}
```

**Notes:** it is an accelerator - always keep the field typeable.

### `appmintNative.recognizeText`

```js
import { recognizeText } from '@/lib/appmintNative'
```

**Example**

Reads the text in an image on the device (`can('ocr')`): the Android app's ML Kit recognizer, or Chrome's TextDetector where enabled.

**Returns:** Promise of `{ text, blocks: [{ text, box, lines: [{ text, box }] }] }`, boxes in image pixels. Rejects with `code` `'unavailable'` where `can('ocr')` is `'none'`, or a DOMException when recognition fails. **Needs:** nothing, no permission.

```ts
import { useState } from 'react';
import { can, recognizeText } from '@/lib/appmintNative';

export function ReceiptReader() {
  const [total, setTotal] = useState<string | null>(null);
  const [busy, setBusy] = useState(false);
  if (can('ocr') === 'none') return null;
  return (
    <label>
      Scan a receipt
      <input type="file" accept="image/*" capture="environment" onChange={async (e) => {
        const file = e.target.files?.[0];
        if (!file) return;
        setBusy(true);
        try {
          const { text } = await recognizeText(file);
          setTotal(/total\s*[:$€£]?\s*([\d.,]+)/i.exec(text)?.[1] ?? 'not found');
        } finally { setBusy(false); }
      }} />
      {busy ? <span role="status">Reading…</span> : total && <strong>Total: {total}</strong>}
    </label>
  );
}
```

Handle the "no recognizer" and failure cases explicitly:

```ts
import { can, recognizeText } from '@/lib/appmintNative';

export async function readLabel(file: Blob): Promise<string> {
  if (can('ocr') === 'none') return '';
  try {
    const { text } = await recognizeText(file);
    return text.trim();
  } catch (e) {
    if ((e as { code?: string }).code === 'unavailable') return '';
    throw e;                                    // a real recognition failure
  }
}
```

Read a script other than Latin, and list each line with its position:

```ts
import { recognizeText } from '@/lib/appmintNative';

export async function readHindiSign(img: HTMLImageElement) {
  const { blocks } = await recognizeText(img, { script: 'devanagari' });
  return blocks.flatMap((b) => b.lines).map((l) => ({ text: l.text, box: l.box }));
}
```

**Notes:** Latin scripts only. The first call on a fresh phone can take a few seconds while Play services fetches the model - show progress.

### `appmintNative.remoteConfig`

```js
remoteConfig.start() / fetch() / apply() / manifestUrl() / handledHere() / dismissed() / dismiss()
```

**Example**

Shows the announcement strip and the maintenance screen the creator publishes from Remote Update, without a rebuild - on iOS and on the website.

**Returns:** `start(manifestUrl?)` → `Promise<void>` (fetch + draw; never rejects); `fetch(url)` → `Promise<RemoteConfig | null>` (never rejects; `null` = nothing applies); `apply(cfg)` → a teardown function; `manifestUrl()` → `string` (`''` when the build baked none); `handledHere()` → `boolean`; `dismissed(id)` → `boolean`; `dismiss(id)` → `void`. **Needs:** nothing - the manifest URL is written into the page when the app is exported.

One line at startup is enough:

```ts
import { useEffect } from 'react';
import { remoteConfig } from '@/lib/appmintNative';

export function App() {
  useEffect(() => { void remoteConfig.start(); }, []);   // does nothing on Android
  return <main>{/* … */}</main>;
}
```

Or read the values and draw your own UI:

```ts
import { remoteConfig, type RemoteConfig } from '@/lib/appmintNative';

export async function loadNotice(): Promise<RemoteConfig | null> {
  if (!remoteConfig.handledHere()) return null;         // Android draws its own
  const cfg = await remoteConfig.fetch(remoteConfig.manifestUrl());
  if (!cfg || !cfg.announcementText) return cfg;
  return remoteConfig.dismissed(cfg.announcementId) ? null : cfg;
}
// when the user closes your banner: remoteConfig.dismiss(cfg.announcementId)
```

Show the maintenance text yourself on the web, and leave the app to its own screen:

```ts
import { useEffect, useState } from 'react';
import { remoteConfig } from '@/lib/appmintNative';

export function useMaintenance(url: string): string | null {
  const [text, setText] = useState<string | null>(null);
  useEffect(() => {
    if (!remoteConfig.handledHere()) return;               // the Android app shows its own screen
    void remoteConfig.fetch(url).then((cfg) => {
      setText(cfg?.maintenanceMode ? cfg.maintenanceText : null);
    });
  }, [url]);
  return text;
}
```

**Notes:**
- `RemoteConfig` = `{ version, announcementText, announcementId, announcementImageUrl, announcementBgColor, announcementTextColor, maintenanceMode, maintenanceText }`.
- `handledHere()` is `false` on Android: the shell reads the same manifest natively and draws the announcement itself, so `start()` returns at once there and you never see two banners.
- `fetch()` returns `null` for a paused or reverted publish, a manifest aimed at other platforms, a newer manifest format, or any network error - the app simply runs without remote config.
- Maintenance mode wins: `apply()` then covers the whole app with the maintenance text. A dismissed announcement stays hidden until a new `announcementId` is published.

### `appmintNative.requestReview`

```js
requestReview(): Promise<boolean>
```

**Example**

Asks the store to show its own rating sheet inside the app (Play In-App Review on Android, the App Store sheet on iOS).

**Returns:** `Promise<boolean>` - `true` only when the store showed the sheet; `false` on the web, for an install that did not come from Google Play, when Play declined (its quota) or failed. Use `askForReview()` to learn which. On iOS the system never reports anything, so a request that reached it is `true`. **Needs:** nothing; the sheet only appears for an app installed from the store.

```ts
import { requestReview } from '@/lib/appmintNative';

async function onOrderDelivered(count: number) {
  // After a success moment, and rarely — not on launch, not after an error.
  if (count === 3 && !localStorage.getItem('askedReview')) {
    localStorage.setItem('askedReview', '1');
    await requestReview();
  }
}
```

Learn why the sheet did not show, without sending the user away:

```ts
import { askForReview } from '@/lib/appmintNative';

export async function maybeAsk() {
  const { result } = await askForReview();
  // 'shown' | 'not_from_play' | 'quota' | 'error' | 'unavailable'
  if (result !== 'shown') console.info('review sheet not shown:', result);
}
```

Only ask after a success moment, and at most once a month:

```ts
import { requestReview } from '@/lib/appmintNative';

const KEY = 'lastReviewAsk';

export async function afterSuccess() {
  const last = Number(localStorage.getItem(KEY) ?? 0);
  if (Date.now() - last < 30 * 24 * 3600 * 1000) return;
  localStorage.setItem(KEY, String(Date.now()));
  await requestReview();
}
```

**Notes:** The store rate-limits the sheet and often shows nothing - never reward a review and never build a "Rate us" button that expects the sheet to open (use `askForReview({ openStore: true })` for a button). In the app it uses the InAppReview plugin (`can('review')` is `'none'` without it). It never leaves the app for the store listing.

### `appmintNative.restorePushMessage`

```js
import { restorePushMessage } from '@/lib/appmintNative'
```

**Example**

Undoes `dismissPushMessage(id)` - the message returns to the Appwright Push inbox in its old place.

**Returns:** `boolean` synchronously - `true` if it was restored; `false` if the id is unknown or was not dismissed (always `false` on the web and iOS). **Needs:** Android: **Appwright Push** turned on in the Integrate step when you build.

```ts
import { dismissPushMessage, restorePushMessage, pushInbox } from '@/lib/appmintNative';

let lastDismissed: string | null = null;

export function dismiss(id: string) {
  if (dismissPushMessage(id)) lastDismissed = id;
  return pushInbox();
}

export function undo() {
  if (lastDismissed) restorePushMessage(lastDismissed);
  lastDismissed = null;
  return pushInbox();
}
```

Offer an Undo right after a swipe-away, in a small React row:

```ts
import { useState } from 'react';
import { dismissPushMessage, restorePushMessage } from '@/lib/appmintNative';

export function SwipeRow({ id, onChange }: { id: string; onChange: () => void }) {
  const [undoable, setUndoable] = useState(false);
  return undoable ? (
    <button onClick={() => { if (restorePushMessage(id)) { setUndoable(false); onChange(); } }}>
      Undo
    </button>
  ) : (
    <button onClick={() => { if (dismissPushMessage(id)) { setUndoable(true); onChange(); } }}>
      Dismiss
    </button>
  );
}
```

Check the answer, because an unknown or never-dismissed id gives `false`:

```ts
import { restorePushMessage, pushInbox } from '@/lib/appmintNative';

export function undoOrReport(id: string): string {
  if (!restorePushMessage(id)) return 'Nothing to restore';
  return `Back in your inbox (${pushInbox().length} messages)`;
}
```

**Notes:** messages removed with `clearPushInbox()` or retracted by you cannot be restored.

### `appmintNative.safeArea`

```js
safeArea(): { top: number; right: number; bottom: number; left: number }
```

**Example**

Gives the space taken by the status bar, notch and home indicator, in CSS pixels, so fixed bars do not sit under them.

**Returns:** `{ top, right, bottom, left }` numbers, synchronously. **Needs:** nothing.

Most of the time CSS is enough - the runtime puts `--safe-top`, `--safe-right`, `--safe-bottom`, `--safe-left` on `:root`:

```ts
export function BottomNav() {
  return (
    <nav className="fixed inset-x-0 bottom-0" style={{ paddingBottom: 'var(--safe-bottom)' }}>
      {/* tabs */}
    </nav>
  );
}
```

Use the numbers when code must do the maths, for example to place a floating button:

```ts
import { useEffect, useState } from 'react';
import { safeArea } from '@/lib/appmintNative';

export function useBottomInset(): number {
  const [bottom, setBottom] = useState(0);
  useEffect(() => {
    const read = () => setBottom(safeArea().bottom);
    read();
    window.addEventListener('resize', read);        // rotation changes the insets
    return () => window.removeEventListener('resize', read);
  }, []);
  return bottom;
}
```

Keep a scrolling list clear of the bars by sizing its padding from the numbers:

```ts
import { useEffect, useState, type ReactNode } from 'react';
import { safeArea } from '@/lib/appmintNative';

export function PaddedList({ children }: { children: ReactNode }) {
  const [inset, setInset] = useState({ top: 0, bottom: 0 });
  useEffect(() => {
    const read = () => { const s = safeArea(); setInset({ top: s.top, bottom: s.bottom }); };
    read();
    window.addEventListener('resize', read);
    return () => window.removeEventListener('resize', read);
  }, []);
  return <div style={{ paddingTop: inset.top + 8, paddingBottom: inset.bottom + 8 }}>{children}</div>;
}
```

**Notes:**
- Measured from CSS `env(safe-area-inset-*)`. The value is cached and re-measured after a window resize or rotation.
- Before `document.body` exists (very early at import time) it returns all zeros - call it from an effect.
- On a desktop browser all four are usually `0`.

### `appmintNative.saveFile`

```js
saveFile(fileName, data, mime?) from '@/lib/appmintNative'
```

**Example**

Saves text or a Blob as a file the user keeps, with the name you choose. In the app it opens Android's "Save as…" sheet with that name; in a browser it is a normal download.

**Returns:** (sync) `boolean` - `true` when the download was started, `false` when there is no `document` or creating it threw. It does not tell you whether the user saved or cancelled the sheet. **Needs:** nothing.

```ts
import { saveFile } from '@/lib/appmintNative';

function exportScores(rows: { name: string; score: number }[]) {
  const csv = ['name,score', ...rows.map((r) => `${r.name},${r.score}`)].join('\n');
  if (!saveFile('Scores.csv', csv, 'text/csv')) alert('Could not start the download.');
}
```

A Blob keeps its own type (the third argument is ignored):

```ts
import { saveFile } from '@/lib/appmintNative';

const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' });
saveFile('Backup.json', blob);
```

Save the result of a fetch, and say so when it could not start:

```ts
import { saveFile, toast } from '@/lib/appmintNative';

export async function downloadInvoice(id: string) {
  const res = await fetch(`/api/invoices/${id}.pdf`);
  if (!res.ok) { toast('Could not get the invoice'); return; }
  const pdf = await res.blob();                          // the Blob keeps its own type
  if (!saveFile(`Invoice-${id}.pdf`, pdf)) toast('Could not start the download');
}
```

**Notes:** It is a plain `<a download>` under the hood, which the app turns into the "Save as…" sheet (see `downloads`). Always pass a name with an extension; without one the file is saved as `download`. The default MIME type for a string is `text/plain`.

### `appmintNative.saveToGallery`

```js
import { saveToGallery } from '@/lib/appmintNative'
```

**Example**

Saves a photo or video into the phone's gallery - `can('gallery')` is `'native'` in the Android app and `'web'` in a browser (the file downloads there).

**Returns:** Promise of the saved item's URI (`''` on the web). Rejects with `code` `'permission-denied'`, `'download-failed'`, `'bad-source'` or `'failed'`. **Needs:** nothing; no permission on Android 10+.

```ts
import { can, saveToGallery, toast } from '@/lib/appmintNative';

export function SaveButton({ canvas }: { canvas: HTMLCanvasElement }) {
  if (can('gallery') === 'none') return null;
  return (
    <button onClick={() => canvas.toBlob(async (blob) => {
      if (!blob) return;
      try {
        await saveToGallery(blob, { album: 'My Cards', fileName: 'card.png' });
        toast('Saved to gallery');
      } catch (e) {
        toast('Could not save');
      }
    }, 'image/png')}>Save to gallery</button>
  );
}
```

Save a video clip and handle the failure codes it can reject with:

```ts
import { saveToGallery, toast } from '@/lib/appmintNative';

export async function saveClip(url: string) {
  try {
    await saveToGallery(url, { kind: 'video', album: 'My Clips', fileName: 'clip.mp4' });
    toast('Saved to your Movies folder');
  } catch (e) {
    const code = (e as { code?: string }).code;
    toast(code === 'download-failed' ? 'Check your connection and try again' : 'Could not save the clip');
  }
}
```

Save an image from a data URL, and hand it to the share sheet only when it was saved:

```ts
import { can, saveToGallery, share } from '@/lib/appmintNative';

export async function saveAndShare(dataUrl: string) {
  if (can('gallery') !== 'none') await saveToGallery(dataUrl, { album: 'Receipts' });
  await share({ title: 'My receipt', text: 'Here is my receipt' });
}
```

**Notes:** `source` may also be a `data:` URL or an http(s) URL; pass `{ kind: 'video' }` for a video URL without a video MIME type.

### `appmintNative.scanBarcode`

```js
scanBarcode(video: HTMLVideoElement, opts?: { signal?, formats?, facingMode? }): Promise<{ value, format } | null>
```

**Example**

Opens the camera in a `<video>` YOU place in your layout, and resolves with the first barcode or QR code it sees.

**Returns:** `Promise<{ value: string; format: string } | null>` - the first code, or `null` when cancelled (the `signal` aborted), when the camera was refused, or when scanning is not available. It never rejects and always stops the camera. **Needs:** camera access - in the Android app tick **Camera** in the permissions list when you build.

Use it only when the viewfinder must sit inside your own screen; otherwise `scanCode()` is simpler.

```ts
import { useEffect, useRef, useState } from 'react';
import { can, scanBarcode, haptic } from '@/lib/appmintNative';

export function InlineScanner({ onCode }: { onCode: (v: string) => void }) {
  const videoRef = useRef<HTMLVideoElement>(null);
  const [error, setError] = useState('');

  useEffect(() => {
    const video = videoRef.current;
    if (!video || can('barcodeScan') === 'none') return;
    const ctl = new AbortController();
    void scanBarcode(video, { signal: ctl.signal, formats: ['qr_code'] }).then((hit) => {
      if (hit) { haptic('success'); onCode(hit.value); }
      else if (!ctl.signal.aborted) setError('Camera not available. Type the code instead.');
    });
    return () => ctl.abort();          // leaving the screen stops the camera
  }, [onCode]);

  return (
    <div>
      <video ref={videoRef} className="aspect-square w-full object-cover" muted playsInline />
      {error && <p role="alert">{error}</p>}
    </div>
  );
}
```

Use the front camera:

```ts
import { scanBarcode } from '@/lib/appmintNative';

declare const video: HTMLVideoElement;
const hit = await scanBarcode(video, { facingMode: 'user' });
```

Close the scanner after the first hit and offer a typed code when the camera is unavailable:

```ts
import { useRef, useState } from 'react';
import { can, scanBarcode } from '@/lib/appmintNative';

export function TicketCheck({ onCode }: { onCode: (v: string) => void }) {
  const videoRef = useRef<HTMLVideoElement>(null);
  const ctl = useRef<AbortController | null>(null);
  const [typed, setTyped] = useState(false);

  async function start() {
    if (!videoRef.current || can('barcodeScan') === 'none') { setTyped(true); return; }
    ctl.current = new AbortController();
    const hit = await scanBarcode(videoRef.current, { signal: ctl.current.signal, formats: ['ean_13', 'code_128'] });
    if (hit) onCode(hit.value);
    else if (!ctl.current.signal.aborted) setTyped(true);   // null and not cancelled: no camera
  }

  return (
    <div>
      <video ref={videoRef} muted playsInline />
      <button onClick={start}>Scan ticket</button>
      <button onClick={() => ctl.current?.abort()}>Cancel</button>
      {typed && <input placeholder="Type the ticket number" onBlur={(e) => onCode(e.target.value)} />}
    </div>
  );
}
```

**Notes:**
- It calls `getUserMedia` (so the user sees the camera permission prompt) and decodes with `BarcodeDetector` on each frame. `can('barcodeScan')` is `'none'` where either is missing.
- The build scans your code: a `scanBarcode(` call switches **Camera** on for you. If you turned it off by hand, the camera request fails and you get `null`.
- Start it from a user action (a Scan button or opening a scan screen), never on app launch. Handle `null` as a normal outcome with a manual-entry option.

### `appmintNative.scanCode`

```js
scanCode(opts?: { formats?: BarcodeFormat[]; manualInput?: boolean }): Promise<ScanCodeResult | null>
```

**Example**

Opens a full-screen scanner and resolves with the first barcode or QR code - one call, nothing to draw.

**Returns:** `Promise<ScanCodeResult | null>` - `{ value, format, type?, displayValue?, url?, wifi?, phone?, email?, sms?, geo?, contact? }`, or `null` when the user closed the scanner. Anything else rejects with an `Error` whose `code` is `'unavailable'` (no scanner here - Play services missing in the app; no `BarcodeDetector` or camera on the web), `'busy'` (a scan is already open), `'camera-denied'` (web: camera permission refused) or `'failed'`. **Needs:** nothing in the app (Google's scanner needs no camera permission).

From a Scan button:

```ts
import { can, scanCode, haptic, toast } from '@/lib/appmintNative';

export function ScanButton({ onCode }: { onCode: (v: string) => void }) {
  if (can('codeScanner') === 'none') return null;       // nothing can scan here
  return (
    <button
      onClick={async () => {
        try {
          const hit = await scanCode({ formats: ['qr_code'] });
          if (!hit) return;                               // closed: a normal outcome
          haptic('success');
          onCode(hit.value);
        } catch (e) {
          const code = (e as { code?: string }).code;
          toast(code === 'camera-denied' ? 'Allow the camera to scan.' : 'Scanning is not available right now.');
        }
      }}
    >
      Scan QR code
    </button>
  );
}
```

Use the parsed parts the app fills in (Wi-Fi, links, contacts …), with a typed-in fallback in Google's scanner:

```ts
import { scanCode } from '@/lib/appmintNative';

const hit = await scanCode({ manualInput: true });
if (hit?.wifi) console.log('Network', hit.wifi.ssid, hit.wifi.encryption);
else if (hit?.url) window.open(hit.url.url, '_blank');
else if (hit) console.log(hit.type ?? 'text', hit.value);
```

**Notes:**
- In the app this is Google's code scanner (Play services, through the shell's `AppMint.scanCode`): auto-zoom, no camera prompt. `type` is `'url'`, `'wifi'`, `'text'`, `'phone'`, `'email'`, `'sms'`, `'geo'`, `'contact'`, `'calendar'`, `'isbn'`, `'product'` or `'driver_license'`, and the matching field is filled. `manualInput` lets the user type the code.
- On the web it draws its own full-screen camera layer (close button, back gesture closes it) and decodes with `BarcodeDetector`; only `value` and `format` are filled there, and the browser asks for camera permission.
- `null` always means the user closed the scanner; every other outcome rejects with a `code`, so show a message in `catch`.
- Call it from a tap, never on page load. Narrow `formats` when you know them.

### `appmintNative.ScreenOrientation`

```js
ScreenOrientation.lock({ orientation }) / ScreenOrientation.unlock()
```

**Example**

Pins the screen to portrait or landscape for one view, in the shape of Capacitor's ScreenOrientation plugin - the same code as `lockOrientation()`.

**Returns:** `Promise<void>` for both. They never reject; use `lockOrientation()` when you need to know whether the lock took. **Needs:** nothing.

```ts
import { ScreenOrientation } from '@/lib/appmintNative';
import { useEffect } from 'react';

function GameBoard() {
  useEffect(() => {
    void ScreenOrientation.lock({ orientation: 'landscape' });
    return () => { void ScreenOrientation.unlock(); };   // release when leaving
  }, []);
  return <canvas id="board" />;
}
```

Lock for one screen only when a lock is allowed, and let the user rotate back:

```ts
import { useEffect, useState } from 'react';
import { ScreenOrientation } from '@/lib/appmintNative';

export function ChartView() {
  const [locked, setLocked] = useState(true);
  useEffect(() => {
    if (locked) void ScreenOrientation.lock({ orientation: 'landscape-primary' });   // any 'landscape*' value
    else void ScreenOrientation.unlock();
    return () => { void ScreenOrientation.unlock(); };
  }, [locked]);
  return <button onClick={() => setLocked((v) => !v)}>{locked ? 'Free rotation' : 'Lock landscape'}</button>;
}
```

When you need to know whether the lock took, use `lockOrientation()` directly (it answers `false` where locking is refused, such as desktop browsers):

```ts
import { lockOrientation, toast } from '@/lib/appmintNative';

export async function openSignaturePad() {
  const ok = await lockOrientation('landscape');
  if (!ok) toast('Turn your phone sideways for more room');
}
```

**Notes:** Any `orientation` starting with `landscape` locks to landscape; anything else locks to portrait (`'landscape-secondary'` becomes plain landscape). Desktop browsers refuse locks; phone browsers usually allow them only in fullscreen. `lockOrientation(to)` returns `true`/`false`. Give the user a way out of a locked view.

### `appmintNative.screenReader`

```js
screenReader.isEnabled() / screenReader.speak(text, lang?) / screenReader.onChange(cb)
```

**Example**

Tells the app whether TalkBack (Android) or VoiceOver (iOS) is on, and announces short messages.

**Returns:** `isEnabled()` → `Promise<boolean>` (`false` on the web - no web API exists). `speak()` → `Promise<void>`. `onChange(cb)` → an unsubscribe function; `cb(enabled)` runs when the screen reader is switched on or off (in the app only). **Needs:** nothing.

```ts
import { screenReader } from '@/lib/appmintNative';
import { useEffect, useState } from 'react';

function useScreenReader() {
  const [on, setOn] = useState(false);
  useEffect(() => {
    void screenReader.isEnabled().then(setOn);
    return screenReader.onChange(setOn);
  }, []);
  return on;   // e.g. skip auto-advancing carousels when true
}

async function onPaid() {
  await screenReader.speak('Payment complete', 'en');
}
```

Announce a result only to people using the screen reader, and leave visual toasts for everyone else:

```ts
import { screenReader, toast } from '@/lib/appmintNative';

export async function notifySaved() {
  if (await screenReader.isEnabled()) await screenReader.speak('Note saved', 'en');
  else toast('Note saved');
}
```

Remove an animation while TalkBack is on, and stop listening when the component goes away:

```ts
import { useEffect, useState } from 'react';
import { screenReader } from '@/lib/appmintNative';

export function Banner({ text }: { text: string }) {
  const [reduce, setReduce] = useState(false);
  useEffect(() => {
    let alive = true;
    void screenReader.isEnabled().then((v) => { if (alive) setReduce(v); });
    const off = screenReader.onChange(setReduce);   // returns an unsubscribe function
    return () => { alive = false; off(); };
  }, []);
  return <div className={reduce ? '' : 'animate-pulse'} role="status" aria-live="polite">{text}</div>;
}
```

**Notes:** In the app `speak` goes through the ScreenReader plugin; on the web (or if the plugin fails) it is spoken with text-to-speech. Prefer good markup (`aria-label`, `aria-live` regions) for most announcements; use `speak` for short, important changes.

### `appmintNative.searchContacts`

```js
searchContacts(query: string, limit = 50): Promise<Contact[]>
```

**Example**

Finds contacts that match a search word. App only.

**Returns:** `Promise<Contact[]>` - empty when nothing matches, access is refused, or Contacts is off. **Needs:** the same as `listContacts`: Android's **Contacts** switch (turned on by the build when your code calls this), iOS's contacts permission.

**Search box with a small delay while typing.**

```ts
import { searchContacts, can } from '@/lib/appmintNative';

let timer: ReturnType<typeof setTimeout> | undefined;
let latest = 0;

export function onSearchInput(text: string, show: (names: string[]) => void) {
  if (can('contactList') === 'none') return;
  clearTimeout(timer);
  timer = setTimeout(async () => {
    const mine = ++latest;
    const found = await searchContacts(text, 20).catch(() => []);
    if (mine !== latest) return;                     // a newer search already started
    show(found.map((c) => c.name ?? ''));
  }, 250);
}
```

Show a clear state when Contacts is not available and when nothing matches:

```ts
import { useState } from 'react';
import { can, searchContacts, type Contact } from '@/lib/appmintNative';

export function ContactPicker({ onPick }: { onPick: (c: Contact) => void }) {
  const [rows, setRows] = useState<Contact[] | null>(null);
  if (can('contactList') === 'none') return <p>Choosing a contact works only in the Android app.</p>;
  return (
    <div>
      <input placeholder="Search contacts" onChange={async (e) => setRows(await searchContacts(e.target.value, 10))} />
      {rows && rows.length === 0 && <p>No contacts match, or access was not allowed.</p>}
      {rows?.map((c) => <button key={c.id ?? c.name} onClick={() => onPick(c)}>{c.name} {c.phones?.[0]}</button>)}
    </div>
  );
}
```

Look up a person by name, then pre-fill a text message to them:

```ts
import { searchContacts, composeSms } from '@/lib/appmintNative';

export async function textPerson(name: string, message: string): Promise<boolean> {
  const [first] = await searchContacts(name, 1);
  const number = first?.phones?.[0];
  return number ? composeSms(number, message) : false;   // the user presses send
}
```

**Notes:** On Android the match is done by the phone's contacts search on names. On iOS the whole book is read once and matched on name, phone and email. Each contact is `{ id?, name?, phones?: string[], emails?: string[], photoUri? }` on both stores. Contacts is personal data: declare it in your Play Data safety form and privacy policy.

### `appmintNative.SecureKeyError`

```js
class SecureKeyError extends Error { code } — from '@/lib/appmintNative'
```

**Example**

The error `secureKeys` throws. Its `code` is one short word you can branch on, the same on Android, iOS and the web.

**Returns:** an `Error` with `name === 'SecureKeyError'` and `code`: `'no-such-key'`, `'auth-required'` (unlock and retry), `'key-invalidated'` (biometrics or the screen lock changed - the data is gone), `'strongbox-unavailable'`, `'disabled'` (Secure Keys not in this build / no Web Crypto), `'bad-alias'`, `'bad-payload'` (not valid base64 or too short), `'too-large'` (over 4 MB in one call on Android), `'access-control'` (iOS could not set up the key's unlock rule), `'foreign-origin'` (a page from another site asked), `'failed'` (anything else). **Needs:** nothing.

```ts
import { secureKeys, SecureKeyError, unlock } from '@/lib/appmintNative';

export async function openSecret(sealed: string): Promise<string | null> {
  try {
    return await secureKeys.decrypt('vault', sealed);
  } catch (e) {
    if (!(e instanceof SecureKeyError)) throw e;
    switch (e.code) {
      case 'auth-required':
        if (await unlock('Open vault')) return secureKeys.decrypt('vault', sealed);
        return null;
      case 'no-such-key':
      case 'key-invalidated':
        alert('This secret can no longer be opened.');
        return null;
      case 'disabled':
        alert('Secure storage is not available in this app.');
        return null;
      default:
        alert('Could not open it: ' + e.code);
        return null;
    }
  }
}
```

Turn the code into a short message for the user in one place:

```ts
import { SecureKeyError } from '@/lib/appmintNative';

export function secureKeyMessage(e: unknown): string {
  if (!(e instanceof SecureKeyError)) return 'Something went wrong';
  switch (e.code) {
    case 'auth-required': return 'Unlock your phone to continue';
    case 'key-invalidated': return 'Your screen lock changed, so this data was erased';
    case 'too-large': return 'That is too big to protect in one piece';
    case 'foreign-origin': return 'This page is not allowed to use secure storage';
    default: return 'Secure storage failed (' + e.code + ')';
  }
}
```

Treat a tampered or wrong payload as a normal outcome and clear it, but keep a missing key as an error you show:

```ts
import { secureKeys, SecureKeyError } from '@/lib/appmintNative';

export async function readNote(sealed: string): Promise<string | null> {
  try {
    return await secureKeys.decrypt('notes', sealed);
  } catch (e) {
    if (e instanceof SecureKeyError && (e.code === 'bad-payload' || e.code === 'failed')) {
      localStorage.removeItem('note');   // the stored text cannot be read any more
      return null;
    }
    throw e;
  }
}
```

**Notes:** `e.message` is `code` or `"code: detail"`; branch on `e.code`, never on the message. The platforms' own error words are turned into these codes, and their original text is kept in the message. A changed or foreign payload is `'bad-payload'` on the web and `'failed'` on Android and iOS, which cannot tell it apart from other cipher errors.

### `appmintNative.secureKeys`

```js
secureKeys — generate / encrypt / decrypt / remove / has / list / isHardwareBacked
```

**Example**

Encryption keys the device holds and the app can never export: the Android Keystore, the iOS Secure Enclave, and a non-extractable Web Crypto key on the web. Removing the key is the one real "secure delete".

**Returns:** all Promises. `generate(alias, {requireAuth?, authValiditySeconds?})` → `{hardwareBacked}`; `encrypt(alias, text)` → base64 string; `decrypt(alias, base64)` → the text; `remove(alias)` / `has(alias)` / `isHardwareBacked(alias)` → boolean; `list()` → string[]. `generate`, `encrypt` and `decrypt` throw `SecureKeyError` with a `code`. **Needs:** Android: **Secure Keys (encryption)** turned on when you build.

```ts
import { secureKeys, SecureKeyError, can } from '@/lib/appmintNative';

export async function saveDiary(text: string) {
  if (can('secureKeys') === 'none') throw new Error('Secure storage is not available here');
  if (!(await secureKeys.has('diary'))) {
    const { hardwareBacked } = await secureKeys.generate('diary', { requireAuth: true, authValiditySeconds: 60 });
    console.log(hardwareBacked ? 'Key in secure hardware' : 'Key in software');
  }
  const sealed = await secureKeys.encrypt('diary', text);    // strings in, base64 out
  localStorage.setItem('diary', sealed);
}

export async function readDiary(): Promise<string | null> {
  const sealed = localStorage.getItem('diary');
  if (!sealed) return null;
  try {
    return await secureKeys.decrypt('diary', sealed);
  } catch (e) {
    if (e instanceof SecureKeyError && e.code === 'key-invalidated') return null;  // data is gone
    throw e;
  }
}
```

Delete for real - nobody can read the sealed data after this:

```ts
import { secureKeys } from '@/lib/appmintNative';

export async function wipeDiary() {
  await secureKeys.remove('diary');
  localStorage.removeItem('diary');
}
```

List the keys the app holds and show whether each one is protected by hardware (the web always answers `false`):

```ts
import { secureKeys, can } from '@/lib/appmintNative';

export async function describeKeys(): Promise<string[]> {
  if (can('secureKeys') === 'none') return [];
  const aliases = await secureKeys.list();
  return Promise.all(aliases.map(async (alias) =>
    `${alias}: ${(await secureKeys.isHardwareBacked(alias)) ? 'secure hardware' : 'software'}`));
}
```

**Notes:** `can('secureKeys')` is `'native'` in the app and `'web'` in a browser, where `isHardwareBacked()` is always `false` - never say "hardware" there. A `requireAuth` key works differently per platform: on Android `encrypt`/`decrypt` throw `auth-required` without showing anything once the unlock window has passed - call `unlock()` and try again (as above); on iOS `decrypt` shows Face ID / the passcode itself (a cancel is `auth-required`); on the web the runtime shows `unlock()` itself. Encrypt one record at a time (Android caps a call at 4 MB). Aliases: letters, digits, `_ - .`, max 64.

### `appmintNative.sendSms`

```js
sendSms(phoneNumber: string, message: string): Promise<boolean>
```

**Example**

Sends an SMS directly, with no confirmation screen. Android app only.

**Returns:** `Promise<boolean>` - `true` when the phone reports the message as sent, `false` when it failed, when SMS was not enabled, or when the user refused permission. **Needs:** the **SMS (read & send)** switch in Step 4 (Access) - the build turns it on when your code calls this - and the user's permission on first use.

**Send an alert and tell the user what happened.**

```ts
import { sendSms, can } from '@/lib/appmintNative';

export async function sendAlert(to: string, text: string): Promise<string> {
  if (can('smsSend') === 'none') return 'Sending SMS works only in the Android app.';
  try {
    const ok = await sendSms(to, text);
    return ok ? 'Alert sent' : 'The SMS could not be sent';
  } catch {
    return 'No answer from the phone after 3 minutes';
  }
}
```

Ask for a confirmation first, since the message goes out with no screen to cancel it:

```ts
import { sendSms, composeSms, can, toast } from '@/lib/appmintNative';

export async function sendCheckIn(to: string) {
  if (can('smsSend') === 'none') { composeSms(to, 'I am safe'); return; }   // the user presses send
  if (!window.confirm('Send "I am safe" to ' + to + '? This may cost money.')) return;
  toast((await sendSms(to, 'I am safe')) ? 'Sent' : 'Could not send. Check the SMS permission.');
}
```

Send the same alert to several people, one at a time, and count what went out:

```ts
import { sendSms } from '@/lib/appmintNative';

export async function alertAll(numbers: string[], text: string) {
  let sent = 0;
  for (const n of numbers) {
    if (await sendSms(n, text)) sent += 1;      // waits for the phone's answer for each one
  }
  return { sent, failed: numbers.length - sent };
}
```

**Notes:** The wrapper resolves on the FIRST answer, which is `sent` (the message left the phone), not `delivered`. For delivery reports use the raw `WebToApk.sendSms` with the `appmint:sms` event. `can('smsSend')` is `'none'` on the web and on iOS (Apple has no API for it). Each SMS may cost the user money. SMS is a Google Play restricted permission: you need an approved Permissions Declaration in Play Console, so prefer `composeSms()` when the user can press send.

### `appmintNative.setAdFreeProduct`

```js
setAdFreeProduct(productId) from '@/lib/appmintNative'
```

**Example**

Names the product that removes ads on iOS, so `ads.isPremium()` and `ads.startRemoveAdsPurchase()` work there too.

**Returns:** nothing. **Needs:** on iOS, the product created in App Store Connect with the same ID. On Android it is not needed: the build wizard's **Remove Ads (IAP)** product ID does this job, and the bridge answers `isPremium` itself.

Call it once at start, before any ad or premium UI reads `isPremium()`:

```ts
import { setAdFreeProduct, ads } from '@/lib/appmintNative';

setAdFreeProduct('remove_ads');           // same id as the "ad-free product" in your plan

export function RemoveAdsButton() {
  if (ads.available() === 'none' || ads.isPremium()) return null;
  return <button onClick={() => ads.startRemoveAdsPurchase()}>Remove ads</button>;
}

window.addEventListener('appmint:premium', (e) => {
  const { premium } = (e as CustomEvent<{ premium: boolean }>).detail;
  if (premium) document.body.classList.add('premium');
});
```

**Notes:** On iOS, without this call `ads.startRemoveAdsPurchase()` answers `appmint:purchase-failed` `{productId:'', reason:'disabled'}`. After it, `startRemoveAdsPurchase()` buys the product through `store.purchase(id)`, and `appmint:premium` `{premium:true}` fires when the purchase or the launch restore shows it is owned. It changes nothing on the web.

### `appmintNative.setMusicVolume`

```js
setMusicVolume(percent) — @/lib/appmintNative
```

**Example**

Sets the level of the app's looping background music, 0 to 100 (rounded and clamped for you).

**Returns:** `void`. **Needs:** a file chosen under **Background Music** in Step 2 (Display). Does nothing when `musicVolume()` is `-1`.

The app keeps the level for the next launch, so a mute toggle is all you need:

```ts
import { musicVolume, setMusicVolume } from '@/lib/appmintNative';

export const hasMusic = () => musicVolume() >= 0;
export const toggleMute = () => setMusicVolume(musicVolume() > 0 ? 0 : 40);   // remembered by the app
```

A volume slider that appears only when the app ships music:

```ts
import { useState } from 'react';
import { musicVolume, setMusicVolume } from '@/lib/appmintNative';

export function MusicSlider() {
  const [level, setLevel] = useState(musicVolume());
  if (level < 0) return null;                       // no music in this app: draw no control
  return (
    <input type="range" min={0} max={100} value={level}
      onChange={(e) => { const v = Number(e.target.value); setLevel(v); setMusicVolume(v); }} />
  );
}
```

Quieten the music while a video plays and put it back afterwards:

```ts
import { musicVolume, setMusicVolume } from '@/lib/appmintNative';

let before = -1;
export function duckMusic() { before = musicVolume(); if (before > 0) setMusicVolume(10); }
export function restoreMusic() { if (before >= 0) setMusicVolume(before); before = -1; }
```

**Notes:** `0` makes the music silent but it keeps playing. The level is saved on the phone (until the user clears the app's data). Android only; on the web and in the iOS app nothing happens.

### `appmintNative.setPushPresentation`

```js
import { setPushPresentation } from '@/lib/appmintNative'
```

**Example**

Chooses who draws an Appwright Push message you sent with an in-app style (banner, pop-up, bottom sheet or full screen) while the app is open: the app shell (`'auto'`, the default) or your own code (`'none'`).

**Returns:** `boolean` synchronously - `true` when the mode was saved. Always `false` on the web and iOS. **Needs:** Android: **Appwright Push** turned on in the Integrate step when you build.

```ts
import { useEffect } from 'react';
import { setPushPresentation, pushInbox } from '@/lib/appmintNative';

// Option A — keep the shell's banner, and never draw a second one on top of it.
export function usePushBanner(show: (title: string) => void) {
  useEffect(() => {
    const onPush = (e: Event) => {
      const d = (e as CustomEvent).detail;
      // { type: 'received', message, presented } — presented = the shell already showed it
      if (d.type === 'received' && !d.presented) show(d.message.title);
    };
    window.addEventListener('appmint:push', onPush);
    return () => window.removeEventListener('appmint:push', onPush);
  }, [show]);
}

// Option B — the app draws every message itself.
export function takeOverPushBanners() {
  setPushPresentation('none');   // saved on the phone; 'auto' gives it back to the shell
  return pushInbox();
}
```

Check the answer, since it is `false` on the web or when Appwright Push is off:

```ts
import { setPushPresentation } from '@/lib/appmintNative';

export function useOwnBanners(on: boolean): boolean {
  const saved = setPushPresentation(on ? 'none' : 'auto');
  if (!saved) console.info('Push banners are not controllable here');
  return saved;
}
```

Draw your own banner for each message and keep the inbox in step:

```ts
import { useEffect, useState } from 'react';
import { setPushPresentation, markPushRead, pushInbox, type PushMessage } from '@/lib/appmintNative';

export function useCustomBanner() {
  const [msg, setMsg] = useState<PushMessage | null>(null);
  useEffect(() => {
    setPushPresentation('none');                  // the shell draws nothing; the page does
    const onPush = (e: Event) => {
      const d = (e as CustomEvent).detail;
      if (d.type === 'received') setMsg(d.message);
      if (d.type === 'revoked') setMsg((m) => (m?.id === d.id ? null : m));
    };
    window.addEventListener('appmint:push', onPush);
    return () => { window.removeEventListener('appmint:push', onPush); setPushPresentation('auto'); };
  }, []);
  return { msg, unread: pushInbox().filter((m) => !m.read).length, dismiss: () => { if (msg) markPushRead(msg.id); setMsg(null); } };
}
```

**Notes:** the inbox and the `appmint:push` event work the same in both modes. Setting `'none'` also closes a message the shell is showing right now.

### `appmintNative.setPushTag`

```js
import { setPushTag } from '@/lib/appmintNative'
```

**Example**

Sets one Appwright Push tag on this install. The send center can send to everyone with a tag, and a tag automation can send a message a set time after the tag is set. `''` removes the tag.

**Returns:** `boolean` synchronously - `true` when saved; `false` when the key is not 1-32 letters, digits or `_`, the value is over 128 characters or the install would pass 50 tags. Always `false` on the web and iOS. **Needs:** Android: **Appwright Push** turned on in the Integrate step when you build; tag targeting and automations reach apps built from 30 Sep 2026 on.

```ts
import { useEffect } from 'react';
import { setPushTag } from '@/lib/appmintNative';

// Abandoned cart: a tag automation on "cart" (say 60 minutes) reminds only phones whose cart is
// still open when the time is up. Setting 'open' again does not restart the clock.
export function useCartPushTag(itemCount: number) {
  const hasItems = itemCount > 0;
  useEffect(() => {
    setPushTag('cart', hasItems ? 'open' : '');
  }, [hasItems]);
}

export function onCheckoutComplete() {
  setPushTag('cart', '');
}
```

Check the result: `false` means the tag was refused (bad key, value over 128 characters, more than 50 tags) or this is the web, so keep your own record of the state.

```ts
import { setPushTag } from '@/lib/appmintNative';

export function markNewsletterChoice(subscribed: boolean) {
  const saved = setPushTag('newsletter', subscribed ? 'yes' : '');
  if (!saved) console.warn('Push tag not saved (web, Push off, or invalid key/value)');
  return saved;
}
```

Tag a phone once it reaches a milestone, so the send center can target everyone at that level.

```ts
import { useEffect } from 'react';
import { setPushTag } from '@/lib/appmintNative';

export function useLevelTag(level: number) {
  useEffect(() => {
    if (level >= 10) setPushTag('veteran', 'yes');
    setPushTag('level', String(level));
  }, [level]);
}
```

**Notes:** tags belong to the install, not the signed-in user. Never put private data in a tag. Android only: on iOS and the web it does nothing.

### `appmintNative.setPushTags`

```js
import { setPushTags } from '@/lib/appmintNative'
```

**Example**

Sets several Appwright Push tags in one change; a `''` value removes that tag, tags you do not name are kept.

**Returns:** `boolean` synchronously - `true` when saved; `false` when any key or value breaks the `setPushTag` rules (then nothing is saved). Always `false` on the web and iOS. **Needs:** Android: **Appwright Push** turned on in the Integrate step when you build; tag targeting and automations reach apps built from 30 Sep 2026 on.

```ts
import { setPushTags, getPushTags, setPushUser } from '@/lib/appmintNative';

export function tagProfile(profile: { plan: 'free' | 'pro'; level: number; team?: string }) {
  return setPushTags({
    plan: profile.plan,
    level: String(profile.level),   // values are text
    team: profile.team ?? '',       // '' removes the tag
  });
}

// Sign-out: clear every tag and the user id.
export function signOutOfPush() {
  const cleared = Object.fromEntries(Object.keys(getPushTags()).map((k) => [k, '']));
  setPushTags(cleared);
  setPushUser('');
}
```

Tag a phone with its chosen language and topics when the user saves their settings, and check that every tag was accepted.

```ts
import { setPushTags } from '@/lib/appmintNative';

export function savePreferences(prefs: { lang: string; topics: string[] }) {
  const ok = setPushTags({
    lang: prefs.lang,
    topics: prefs.topics.join(',').slice(0, 128),   // a value is at most 128 characters
    onboarded: 'yes',
  });
  if (!ok) console.warn('Push tags not saved: nothing was changed');
  return ok;
}
```

Keep tags in step with the signed-in account in a React effect, removing the ones that no longer apply.

```ts
import { useEffect } from 'react';
import { setPushTags, setPushUser } from '@/lib/appmintNative';

export function usePushProfile(user: { id: string; plan: 'free' | 'pro'; trialEnds?: string } | null) {
  useEffect(() => {
    if (!user) return;
    setPushUser(user.id);
    setPushTags({ plan: user.plan, trial: user.plan === 'pro' ? '' : (user.trialEnds ?? '') });
  }, [user?.id, user?.plan, user?.trialEnds]);
}
```

**Notes:** all of one call is saved or none of it. Android only: on iOS and the web it does nothing.

### `appmintNative.setPushUser`

```js
import { setPushUser } from '@/lib/appmintNative'
```

**Example**

Tells Appwright Push which of your users is signed in on this install, so the send center can send a message to that one user. Pass your own stable account id after sign-in and `''` on sign-out.

**Returns:** `boolean` synchronously - `true` when the id was saved; `false` for an id with anything other than letters, digits and `_ @ . : + -` (up to 128). Always `false` on the web and iOS. **Needs:** Android: **Appwright Push** turned on in the Integrate step when you build; sending to a user reaches apps built from 30 Sep 2026 on.

```ts
import { useEffect } from 'react';
import { setPushUser, getPushUser } from '@/lib/appmintNative';
import { supabase } from '@/lib/supabase';

// Keeps Appwright Push in step with the signed-in account: the auth user id, never the email.
export function usePushUser() {
  useEffect(() => {
    const { data } = supabase.auth.onAuthStateChange((_event, session) => {
      const id = session?.user.id ?? '';
      if (getPushUser() !== id) setPushUser(id);
    });
    return () => data.subscription.unsubscribe();
  }, []);
}
```

Handle a refused id: `setPushUser` answers `false` for an email address or any id with characters outside the allowed set, so send a stable id instead.

```ts
import { setPushUser } from '@/lib/appmintNative';

export function linkPushToAccount(account: { id: string; email: string }) {
  const ok = setPushUser(account.id);   // the id, never the email
  if (!ok) console.warn('Push user id not saved (web, Push off, or invalid characters)');
  return ok;
}
```

Clear the id when the user signs out, in a plain click handler.

```tsx
import { setPushUser, getPushUser } from '@/lib/appmintNative';

export function SignOutButton({ signOut }: { signOut: () => Promise<void> }) {
  return (
    <button
      onClick={async () => {
        await signOut();
        if (getPushUser()) setPushUser('');   // this install stops receiving "send to this user" messages
      }}
    >
      Sign out
    </button>
  );
}
```

Combine it with a tag so one sign-in step identifies the user and their plan together.

```ts
import { setPushUser, setPushTag } from '@/lib/appmintNative';

export function onSignedIn(userId: string, plan: 'free' | 'pro') {
  setPushUser(userId);
  setPushTag('plan', plan);
}
```

**Notes:** the id belongs to the install, so sign-out must clear it. It does not clear tags - call `setPushTags` with `''` values for user-specific ones. Android only: on iOS and the web it does nothing.

### `appmintNative.setStatusBar`

```js
setStatusBar(backgroundColor: string | null, style?: 'light' | 'dark' | null): void
```

**Example**

Paints the system status bar to match the screen, and picks light or dark icons for it.

**Returns:** nothing. **Needs:** nothing.

`style` is the colour of the ICONS: `'light'` = light icons for a dark bar (the default), `'dark'` = dark icons for a light bar. Call it once at startup with the app's `--bg`, and again on any screen with a different background:

```ts
import { useEffect } from 'react';
import { setStatusBar } from '@/lib/appmintNative';

export function App() {
  useEffect(() => { setStatusBar('#0B0F1A', 'light'); }, []);   // dark app, light icons
  return <main>{/* … */}</main>;
}

export function PhotoViewer() {
  useEffect(() => {
    setStatusBar('#000000', 'light');
    return () => setStatusBar('#0B0F1A', 'light');                // restore on leave
  }, []);
  return null;
}
```

A light screen:

```ts
import { setStatusBar } from '@/lib/appmintNative';

setStatusBar('#FFFFFF', 'dark');
```

The Capacitor-shaped form (the style is named after the BACKGROUND: `'DARK'` = light icons):

```ts
import { StatusBar } from '@/lib/appmintNative';

await StatusBar.setBackgroundColor({ color: '#0B0F1A' });
await StatusBar.setStyle({ style: 'DARK' });
```

**Notes:**
- In the Android app the shell paints the bar itself, which also works on Android 15 edge-to-edge; an unparseable colour is ignored - pass `#RRGGBB`. A style-only call (`backgroundColor = null`) keeps the colour this page last set and flips the icons; before the page set any colour it flips the icons through the StatusBar plugin and the bar keeps the colour chosen at build time.
- On iOS the Capacitor StatusBar plugin is used.
- On the web it sets `<meta name="theme-color">`; the browser picks the icon colour itself.

### `appmintNative.share`

```js
share(input: { title?, text?, url?, files? }): Promise<boolean>
```

**Example**

Opens the system share sheet in the app, and the Web Share API in a browser.

**Returns:** `Promise<boolean>` - `true` when the sheet opened; `false` when the user dismissed it or sharing is not available. It never rejects. **Needs:** nothing.

```ts
import { can, share, toast } from '@/lib/appmintNative';

export function ShareRecipe({ recipe }: { recipe: { title: string; url: string } }) {
  if (can('share') === 'none') return null;
  return (
    <button
      onClick={async () => {
        const ok = await share({ title: recipe.title, text: 'Try this recipe', url: recipe.url });
        if (ok) toast('Shared');           // false = the user closed the sheet; say nothing
      }}
    >
      Share
    </button>
  );
}
```

Text only (no link) is fine too:

```ts
import { share } from '@/lib/appmintNative';

await share({ text: 'My score today: 42' });
```

The Capacitor-shaped form `Share.share(opts)` calls the same code but REJECTS when the user cancels (Capacitor's contract) and resolves `{ activityType: '' }` otherwise; `Share.canShare()` resolves `{ value: can('share') !== 'none' }`:

```ts
import { Share } from '@/lib/appmintNative';

if ((await Share.canShare()).value) {
  try {
    await Share.share({ title: 'Invite', url: 'https://example.com/join' });
  } catch {
    /* cancelled or unavailable — never report success here */
  }
}
```

**Notes:**
- Pass only the fields you have. In the app, text and links go through the Capacitor Share plugin.
- `files` works in the Android app (the files go to the share sheet as real attachments; a `url` is added to the text) and on the web where the browser can share files (`navigator.canShare({ files })`). Keep files small - they are passed to the app in memory.
- On the web, call it from a tap; browsers refuse a share that no user action started.

### `appmintNative.Share`

```js
import { Share } from '@/lib/appmintNative'
```

**Example**

Documented together with `appmintNative.share` - the example there shows this one too.

Opens the system share sheet in the app, and the Web Share API in a browser.

**Returns:** `Promise<boolean>` - `true` when the sheet opened; `false` when the user dismissed it or sharing is not available. It never rejects. **Needs:** nothing.

```ts
import { can, share, toast } from '@/lib/appmintNative';

export function ShareRecipe({ recipe }: { recipe: { title: string; url: string } }) {
  if (can('share') === 'none') return null;
  return (
    <button
      onClick={async () => {
        const ok = await share({ title: recipe.title, text: 'Try this recipe', url: recipe.url });
        if (ok) toast('Shared');           // false = the user closed the sheet; say nothing
      }}
    >
      Share
    </button>
  );
}
```

Text only (no link) is fine too:

```ts
import { share } from '@/lib/appmintNative';

await share({ text: 'My score today: 42' });
```

The Capacitor-shaped form `Share.share(opts)` calls the same code but REJECTS when the user cancels (Capacitor's contract) and resolves `{ activityType: '' }` otherwise; `Share.canShare()` resolves `{ value: can('share') !== 'none' }`:

```ts
import { Share } from '@/lib/appmintNative';

if ((await Share.canShare()).value) {
  try {
    await Share.share({ title: 'Invite', url: 'https://example.com/join' });
  } catch {
    /* cancelled or unavailable — never report success here */
  }
}
```

**Notes:**
- Pass only the fields you have. In the app, text and links go through the Capacitor Share plugin.
- `files` works in the Android app (the files go to the share sheet as real attachments; a `url` is added to the text) and on the web where the browser can share files (`navigator.canShare({ files })`). Keep files small - they are passed to the app in memory.
- On the web, call it from a tap; browsers refuse a share that no user action started.

### `appmintNative.sharedText`

```js
sharedText(): { text, subject, url, receivedAt } | null
```

**Example**

Gives the text or link another app shared INTO this app ("Share → your app"), for example a link shared from a browser or YouTube.

**Returns:** synchronously, `{ text, subject, url, receivedAt }` or `null` when nothing is waiting. `url` is the first `http(s)` link found in `text` (`''` if none); `receivedAt` is milliseconds since 1970. **Needs:** the build's **Receive Shares From Other Apps** option with "Text and links" - switched on automatically when your code calls `sharedText(` or `onShared(`.

Check on mount, save it, then clear it:

```ts
import { useEffect } from 'react';
import { can, sharedText, clearShared, toast } from '@/lib/appmintNative';

export function useSaveSharedLink(save: (item: { title: string; url: string; text: string }) => Promise<void>) {
  useEffect(() => {
    if (can('shareTarget') === 'none') return;     // web and iOS
    const p = sharedText();
    if (!p) return;
    void save({ title: p.subject || p.url || 'Shared note', url: p.url, text: p.text })
      .then(() => { clearShared(); toast('Saved'); });
  }, [save]);
}
```

Listen for a share that arrives while the app is already open with `onShared`, and clear it after it is used.

```ts
import { useEffect, useState } from 'react';
import { sharedText, onShared, clearShared } from '@/lib/appmintNative';

export function useIncomingShare() {
  const [text, setText] = useState(() => sharedText()?.text ?? '');
  useEffect(() => onShared((p) => setText(p.text)), []);
  return { text, done: () => { clearShared(); setText(''); } };
}
```

Prefill a form from a shared link and show nothing on the web, where `sharedText()` is `null`.

```tsx
import { useEffect, useState } from 'react';
import { can, sharedText } from '@/lib/appmintNative';

export function NewBookmark() {
  const [url, setUrl] = useState('');
  useEffect(() => {
    if (can('shareTarget') === 'none') return;   // web: nobody can share into the page
    const p = sharedText();
    if (p?.url) setUrl(p.url);
  }, []);
  return <input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="Paste a link" />;
}
```

**Notes:**
- The share is KEPT until you call `clearShared()`, so an app that mounts after the share still sees it. Clear it after saving, or the next mount saves it again.
- For a share that arrives while the app is open, use `onShared()`.
- A photo or video shared WITH a caption: the caption arrives here, the file through the opened-file API.
- Android only. `can('shareTarget')` is `'none'` on the web and on iOS (no Share Extension), and `sharedText()` returns `null` there.

### `appmintNative.sheet`

```js
sheet(opts: { title?, content, dismissible? }): { close(), element, closed }
```

**Example**

Opens a modal bottom sheet with real sheet behaviour - for a filter panel, a composer or a detail preview.

**Returns:** a handle: `close()` closes it, `element` is the body element (fill or update it), `closed` is a `Promise<void>` that resolves when the sheet has closed, however it closed. **Needs:** nothing.

**Options:** `title` (optional heading), `content` - an element, plain text (not HTML), or a function `(host) => { … }` that fills the body; `dismissible` (default `true`: backdrop tap, drag down, Escape and the back gesture close it).

Plain DOM content:

```ts
import { sheet } from '@/lib/appmintNative';

const h = sheet({
  title: 'About this list',
  content: (host) => {
    const p = document.createElement('p');
    p.textContent = 'Items sync to all your devices.';
    host.appendChild(p);
  },
});
await h.closed;
```

React content, rendered into the sheet with a portal:

```ts
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { sheet } from '@/lib/appmintNative';

export function FiltersButton() {
  const [host, setHost] = useState<HTMLElement | null>(null);
  const [close, setClose] = useState<() => void>(() => () => {});

  const open = () => {
    const h = sheet({ title: 'Filters', content: () => {} });
    setHost(h.element);
    setClose(() => h.close);
    void h.closed.then(() => setHost(null));      // unmount the portal after any close
  };

  return (
    <>
      <button onClick={open}>Filters</button>
      {host && createPortal(
        <form onSubmit={(e) => { e.preventDefault(); close(); }}>
          <label><input type="checkbox" name="open" /> Open now</label>
          <button type="submit">Apply</button>
        </form>,
        host,
      )}
    </>
  );
}
```

A sheet that stays open until the user chooses, using `dismissible: false` and the `closed` promise.

```ts
import { sheet } from '@/lib/appmintNative';

export async function confirmDelete(name: string): Promise<boolean> {
  let answer = false;
  const h = sheet({
    title: 'Delete this item?',
    dismissible: false,                       // only the buttons below close it
    content: (host) => {
      const p = document.createElement('p');
      p.textContent = `"${name}" will be removed.`;
      const yes = document.createElement('button');
      yes.textContent = 'Delete';
      yes.onclick = () => { answer = true; h.close(); };
      const no = document.createElement('button');
      no.textContent = 'Keep it';
      no.onclick = () => h.close();
      host.append(p, yes, no);
    },
  });
  await h.closed;                             // resolves however the sheet closed
  return answer;
}
```

**Notes:**
- Drawn in the app's tokens (`--surface`, `--ink`, `--muted`, `--border`), above the safe area, with a light haptic on open.
- Drag down closes it when dragged past 120 px or flicked; the body scrolls first when it is not at the top.
- With `dismissible: false` only your own `close()` closes it - give the user a button.
- For a plain list of actions use `actionSheet()`.

### `appmintNative.shortcuts`

```js
shortcuts.set(items: { id?, title, route }[]) / shortcuts.clear()
```

**Example**

Adds shortcuts to the app icon's long-press menu (Android dynamic shortcuts, iOS Home Screen quick actions). A tap opens the app at that route.

**Returns:** `set()` returns `Promise<boolean>` - `true` when the shortcuts were handed to the system, `false` where there are none (the web). `clear()` returns `Promise<void>`. **Needs:** nothing.

Set them once at start, and route on the launch link:

```ts
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { can, shortcuts, launchUrl } from '@/lib/appmintNative';

export function useAppShortcuts() {
  const navigate = useNavigate();
  useEffect(() => {
    if (can('shortcuts') !== 'none') {
      void shortcuts.set([
        { id: 'new', title: 'New note', route: '/new' },
        { id: 'search', title: 'Search', route: '/search' },
      ]);
    }
    void launchUrl().then((u) => { if (u) navigate(new URL(u).pathname); });
  }, [navigate]);
}
```

Remove them (for example after sign-out):

```ts
import { shortcuts } from '@/lib/appmintNative';

await shortcuts.clear();
```

Open the matching screen when a shortcut is tapped while the app is already running, with `onDeepLink`.

```ts
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { onDeepLink } from '@/lib/appmintNative';

export function useShortcutRouting() {
  const navigate = useNavigate();
  useEffect(() => onDeepLink((url) => navigate(new URL(url).pathname)), [navigate]);
}
```

Show shortcuts only for a signed-in user, and handle the web, where `set()` answers `false`.

```ts
import { useEffect } from 'react';
import { shortcuts } from '@/lib/appmintNative';

export function useAccountShortcuts(signedIn: boolean) {
  useEffect(() => {
    if (!signedIn) { void shortcuts.clear(); return; }
    void shortcuts.set([
      { id: 'inbox', title: 'Inbox', route: '/inbox' },
      { id: 'compose', title: 'Write a message', route: 'compose' },   // gets a leading "/"
    ]).then((handed) => { if (!handed) console.info('No launcher shortcuts on this platform'); });
  }, [signedIn]);
}
```

**Notes:**
- At most 4 items; extra items are dropped. A `route` without a leading `/` gets one. `id` is optional.
- Android 7.1+; on older Android the call does nothing. Titles are cut to 25 characters (short label) and 50 (long label).
- On Android a tap opens the app with the launch link `appmint-shortcut://<package><route>`, so `new URL(u).pathname` is your route. Read it with `launchUrl()` on mount and `onDeepLink()` while the app runs.
- `can('shortcuts')` is `'none'` on the web: a browser has no app icon.

### `appmintNative.speak`

```js
speak(text, opts?) — @/lib/appmintNative
```

**Example**

Reads text aloud with the phone's text-to-speech engine.

**Returns:** `void` - it starts speaking (or queues after the current speech) and gives no "finished" signal. `opts`: `{ lang?: string; rate?: number; pitch?: number }` (defaults: engine language, 1, 1). **Needs:** nothing.

```ts
import { speak, stopSpeaking } from '@/lib/appmintNative';

export function ReadAloud({ text }: { text: string }) {
  return (
    <div>
      <button onClick={() => speak(text, { lang: 'en-IN', rate: 0.9 })}>Read aloud</button>
      <button onClick={() => stopSpeaking()}>Stop</button>
    </div>
  );
}
```

Different languages and speeds:

```ts
import { speak } from '@/lib/appmintNative';

speak('வணக்கம்', { lang: 'ta-IN' });          // Tamil
speak('Slow and clear', { rate: 0.7 });       // rate 0.1..4, 1 = normal
speak('High voice', { pitch: 1.5 });          // pitch 0.1..4, 1 = normal
```

Speak a short confirmation after an action, and tell the user in text too.

```ts
import { speak, toast } from '@/lib/appmintNative';

export function announceSaved(title: string) {
  toast('Saved');
  speak(`${title} saved`, { rate: 1.1 });
}
```

Read a list one item after another; `speak()` queues, so call it in a loop and stop everything with `stopSpeaking()` when the screen closes.

```tsx
import { useEffect } from 'react';
import { speak, stopSpeaking } from '@/lib/appmintNative';

export function ReadHeadlines({ items }: { items: string[] }) {
  useEffect(() => () => stopSpeaking(), []);   // leaving the screen stops the voice
  return <button onClick={() => items.forEach((t) => speak(t))}>Read all</button>;
}
```

**Notes:** Android: the app's native engine. iOS: the TextToSpeech plugin. Web: `speechSynthesis`. Several `speak()` calls play one after another. If you need to know when speech ends (to highlight words or move on), use `window.speechSynthesis` with an utterance's `onend` directly; it works in the Android app too.

### `appmintNative.speech`

```js
speech — @/lib/appmintNative
```

**Example**

Dictation: turns the user's speech into text. Android's speech recognizer in the Android app, Apple's in the iOS app, the Web Speech API in Chrome.

**Returns:** `speech.isAvailable()` → `Promise<boolean>`. `speech.start(onResult, opts?)` → `Promise<boolean>`: `true` when listening started, `false` when the microphone permission was refused or dictation is not available. `onResult(text, isFinal)` is called with the text. `speech.stop()` → `Promise<void>`. `opts`: `{ lang?: string; partial?: boolean; prompt?: string }`. **Needs:** **Mic** in Step 4 (Access); AI builds switch it on for you when the code uses speech recognition.

```ts
import { useEffect, useState } from 'react';
import { speech } from '@/lib/appmintNative';

export function VoiceNote() {
  const [ok, setOk] = useState(false);
  const [text, setText] = useState('');
  const [msg, setMsg] = useState('');

  useEffect(() => { void speech.isAvailable().then(setOk); }, []);
  if (!ok) return null;                      // no dictation here: hide the mic button

  const listen = async () => {
    setMsg('Listening...');
    const started = await speech.start(
      (t, isFinal) => { setText(t); if (isFinal) setMsg(''); },
      { lang: 'en-US' },                     // partial results by default
    );
    if (!started) setMsg('Microphone permission is needed for voice input.');
  };

  return (
    <div>
      <button aria-label="Speak" onClick={listen}>🎤</button>
      <button onClick={() => { void speech.stop(); setMsg(''); }}>Stop</button>
      <p>{text}</p>
      <p role="status">{msg}</p>
    </div>
  );
}
```

Dictate into a text field and say so when the microphone was refused, since `start()` answers `false`.

```tsx
import { useState } from 'react';
import { speech } from '@/lib/appmintNative';

export function DictateField() {
  const [value, setValue] = useState('');
  const [error, setError] = useState('');

  const dictate = async () => {
    setError('');
    const started = await speech.start((text, isFinal) => { if (isFinal) setValue((v) => (v ? `${v} ${text}` : text)); }, { partial: false });
    if (!started) setError('Voice input is not available. Check the microphone permission.');
  };

  return (
    <div>
      <textarea value={value} onChange={(e) => setValue(e.target.value)} />
      <button onClick={dictate}>Dictate</button>
      {error && <p role="alert">{error}</p>}
    </div>
  );
}
```

Stop listening when the screen closes, and read the result back with `speak`.

```tsx
import { useEffect, useState } from 'react';
import { speech, speak } from '@/lib/appmintNative';

export function VoiceCommand() {
  const [heard, setHeard] = useState('');
  useEffect(() => () => { void speech.stop(); }, []);

  return (
    <button
      onClick={() => void speech.start((t, isFinal) => {
        setHeard(t);
        if (isFinal) speak(`You said ${t}`);
      }, { lang: 'en-US' })}
    >
      {heard || 'Say something'}
    </button>
  );
}
```

**Notes:**
- With `partial` on (the default) `onResult(text, false)` arrives as words come in, then once `onResult(text, true)` with the final text. With `partial: false` you get only the final one, when the user stops talking.
- The permission prompt is shown on the first `start()`.
- If the user says nothing, `onResult` is not called; listening just ends. Keep a Stop button or a timeout in your UI.
- Listening stops by itself after a pause. `lang` defaults to the phone's language.

### `appmintNative.splash`

```js
splash.hold() / splash.ready()
```

**Example**

Keeps the launch splash on screen until the first real screen is ready, instead of showing "splash, then a spinner".

**Returns:** nothing (both are synchronous). **Needs:** nothing.

By default the splash hides by itself at the app's first paint - you need nothing. When the first screen needs data, hold the splash at module top and release it when that screen is on:

```ts
// main.tsx
import { splash } from '@/lib/appmintNative';

splash.hold();   // at import time, before the first render
```

```ts
// Home.tsx
import { useEffect, useState } from 'react';
import { splash } from '@/lib/appmintNative';

export function Home({ load }: { load: () => Promise<string[]> }) {
  const [items, setItems] = useState<string[] | null>(null);

  useEffect(() => {
    load()
      .then(setItems)
      .catch(() => setItems([]))
      .finally(() => splash.ready());     // ALWAYS release, also when loading failed
  }, [load]);

  if (!items) return null;
  return <ul>{items.map((t) => <li key={t}>{t}</li>)}</ul>;
}
```

Hold the splash while a signed-in session is restored, and release it in a `finally` so a failure cannot keep it up.

```ts
// main.tsx
import { splash } from '@/lib/appmintNative';
import { supabase } from '@/lib/supabase';

splash.hold();
supabase.auth.getSession().finally(() => splash.ready());
```

Release it once, from a hook, after the first screen's data is on screen.

```ts
import { useEffect } from 'react';
import { splash } from '@/lib/appmintNative';

export function useReleaseSplash(loaded: boolean) {
  useEffect(() => {
    if (loaded) splash.ready();   // calling it again later does nothing harmful
  }, [loaded]);
}
```

**Notes:**
- Android: `hold()` tells the shell to keep its splash; `ready()` hides it (the creator's minimum splash time still applies).
- iOS: `ready()` hides the SplashScreen plugin's launch screen.
- Web: both do nothing - there is no splash.
- Both platforms cap the hold at about 6 seconds, so a page that never calls `ready()` still opens.

### `appmintNative.StatusBar`

```js
StatusBar.setBackgroundColor({ color }) / StatusBar.setStyle({ style })
```

**Example**

The status bar in the shape of Capacitor's StatusBar plugin, built on `setStatusBar()`.

**Returns:** `Promise<void>` for both. **Needs:** nothing.

```ts
import { StatusBar } from '@/lib/appmintNative';

// Colour only — the icon style is left alone (the shell picks readable icons on Android).
await StatusBar.setBackgroundColor({ color: '#0B0F1A' });
```

**Colour and icons together** - prefer `setStatusBar()`, which sets both in one call:

```ts
import { setStatusBar } from '@/lib/appmintNative';

setStatusBar('#0B0F1A', 'light');   // dark bar, light icons
setStatusBar('#FFFFFF', 'dark');    // light bar, dark icons
```

Switch the bar with the app's own light and dark theme, using the Capacitor-shaped calls together.

```ts
import { useEffect } from 'react';
import { StatusBar } from '@/lib/appmintNative';

export function useStatusBarForTheme(dark: boolean) {
  useEffect(() => {
    void StatusBar.setBackgroundColor({ color: dark ? '#0B0F1A' : '#FFFFFF' });
    void StatusBar.setStyle({ style: dark ? 'DARK' : 'LIGHT' });   // 'DARK' = dark bar, light icons
  }, [dark]);
}
```

**Notes:** `style` follows Capacitor: `'DARK'` means a dark background (light icons), anything else means dark icons. In the Android app the shell bridge paints the bar (the plugin's colour call does nothing on Android 15+), and `setStyle()` flips the icons on the bar's current colour. On the web the colour becomes `<meta name="theme-color">`.

### `appmintNative.stopSpeaking`

```js
stopSpeaking() — @/lib/appmintNative
```

**Example**

Stops text-to-speech now and drops anything still waiting to be spoken.

**Returns:** `void`. **Needs:** nothing.

```ts
import { useEffect } from 'react';
import { speak, stopSpeaking } from '@/lib/appmintNative';

export function StoryPage({ story }: { story: string }) {
  // Stop reading when the user leaves the page.
  useEffect(() => () => stopSpeaking(), []);

  return (
    <article>
      <p>{story}</p>
      <button onClick={() => { stopSpeaking(); speak(story); }}>Read from the start</button>
      <button onClick={stopSpeaking}>Stop</button>
    </article>
  );
}
```

Read a long text aloud with the language and speed the user picked, and stop it from one button:

```ts
import { useState } from 'react';
import { speak, stopSpeaking } from '@/lib/appmintNative';

export function ReadAloud({ text, lang }: { text: string; lang: string }) {
  const [rate, setRate] = useState(1);
  return (
    <div>
      <input type="range" min={0.5} max={2} step={0.1} value={rate} onChange={(e) => setRate(Number(e.target.value))} />
      <button onClick={() => { stopSpeaking(); speak(text, { lang, rate }); }}>Read aloud</button>
      <button onClick={() => stopSpeaking()}>Stop</button>
    </div>
  );
}
```

Cut the voice off when the user starts typing a reply:

```ts
import { stopSpeaking } from '@/lib/appmintNative';

export function ReplyBox({ onSend }: { onSend: (text: string) => void }) {
  return <textarea onFocus={() => stopSpeaking()} onBlur={(e) => onSend(e.currentTarget.value)} />;
}
```

**Notes:** Safe to call when nothing is speaking. Android: the native engine's stop. iOS: the TextToSpeech plugin's `stop`. Web: `speechSynthesis.cancel()`.

### `appmintNative.store`

```js
store from '@/lib/appmintNative'
```

**Example**

In-app purchases for AI-built apps: Play Billing on Android and StoreKit 2 on iOS, with the same product IDs and the same `appmint:*` events on both.

**Returns:** `store.available()` → `'native' | 'none'` (`'none'` on the web). `store.getProducts(requestId, ids)` → `Promise<StoreProduct[]>` (`{productId, type:'inapp'|'subs', title, description, price, currency, period?, owned}`), also sent as `appmint:products`. `store.purchase(id)` → `Promise<boolean>` (on Android `true` only means the sheet was asked for; the result is the event). `store.isOwned(id)` → boolean. `store.getOwnedProducts()` → `string[]`. `store.restore()` → `Promise<string[]>`. **Needs:** **💰 Sell In-App Products** and the products created in Play Console / App Store Connect.

A paywall: live price from the store, unlock only on the purchase event:

```ts
import { useEffect, useState } from 'react';
import { store, type StoreProduct } from '@/lib/appmintNative';

export function ProPaywall({ onUnlock }: { onUnlock: () => void }) {
  const [pro, setPro] = useState<StoreProduct | null>(null);
  const [busy, setBusy] = useState(false);
  const [note, setNote] = useState('');

  useEffect(() => {
    if (store.available() === 'none') return;
    if (store.isOwned('pro_unlock')) onUnlock();
    void store.getProducts('paywall', ['pro_unlock']).then((list) => setPro(list[0] ?? null));

    const onPurchase = (e: Event) => {
      if ((e as CustomEvent<{ productId: string }>).detail.productId === 'pro_unlock') onUnlock();
      setBusy(false);
    };
    const onFailed = (e: Event) => {
      const { reason } = (e as CustomEvent<{ productId: string; reason: string }>).detail;
      setBusy(false);
      setNote(reason === 'pending' ? 'Payment pending. It unlocks once the store confirms.' : `Purchase unavailable (${reason}).`);
    };
    const onOwned = (e: Event) => {
      if ((e as CustomEvent<{ products: string[] }>).detail.products.includes('pro_unlock')) onUnlock();
    };
    const onVisible = () => { if (!document.hidden) setBusy(false); };   // a cancel sends no event on Android
    window.addEventListener('appmint:purchase', onPurchase);
    window.addEventListener('appmint:purchase-failed', onFailed);
    window.addEventListener('appmint:owned-changed', onOwned);
    document.addEventListener('visibilitychange', onVisible);
    return () => {
      window.removeEventListener('appmint:purchase', onPurchase);
      window.removeEventListener('appmint:purchase-failed', onFailed);
      window.removeEventListener('appmint:owned-changed', onOwned);
      document.removeEventListener('visibilitychange', onVisible);
    };
  }, [onUnlock]);

  if (store.available() === 'none') return <p>Available in the app.</p>;
  if (!pro) return null;                          // not created in the store yet: hide the offer
  return (
    <>
      <button disabled={busy} onClick={() => { setBusy(true); void store.purchase('pro_unlock'); }}>
        Unlock Pro · {pro.price}
      </button>
      <button onClick={() => void store.restore()}>Restore purchases</button>
      {note && <p role="status">{note}</p>}
    </>
  );
}
```

**Notes:** `appmint:purchase-failed` reasons: `disabled`, `not_found`, `billing_error`, `pending`, and on iOS also `cancelled`; Android sends no event for a plain cancel. `store.restore()` re-reads the owned list on Android (it restores at every launch anyway) and asks the App Store on iOS; App Review expects a "Restore purchases" button. Show `price` as the store gives it; never store it.

### `appmintNative.swipeable`

```js
swipeable(el: HTMLElement, opts: SwipeOptions): () => void
```

**Example**

Adds swipe gestures to an element - swipe-to-delete rows, paging, dismissing a card.

**Returns:** an unsubscribe function that also restores the element's original `touch-action`. **Needs:** nothing.

**Options:** `onLeft`, `onRight`, `onUp`, `onDown` (at least one), `threshold` (px of travel, default `56`), `edgeGuard` (px from the left/right screen edge where horizontal swipes are ignored, default `24`), `haptics` (selection haptic on a swipe, default `true`).

Swipe left to delete, with a visible button for the same action:

```ts
import { useEffect, useRef } from 'react';
import { swipeable, toast } from '@/lib/appmintNative';

export function MessageRow({ id, text, onDelete, onUndo }: {
  id: string; text: string; onDelete(id: string): void; onUndo(id: string): void;
}) {
  const ref = useRef<HTMLLIElement>(null);
  useEffect(() => {
    if (!ref.current) return;
    return swipeable(ref.current, {
      onLeft: () => { onDelete(id); toast('Deleted'); },
    });
  }, [id, onDelete]);

  return (
    <li ref={ref} className="flex items-center justify-between p-4">
      <span>{text}</span>
      <button onClick={() => onDelete(id)}>Delete</button>
      {/* swipe is never the only way to an action */}
    </li>
  );
}
```

Page between tabs with left / right:

```ts
import { swipeable } from '@/lib/appmintNative';

declare const panel: HTMLElement;
declare let tab: number;
const off = swipeable(panel, { onLeft: () => { tab += 1; }, onRight: () => { tab -= 1; }, threshold: 72 });
// later: off();
```

Dismiss a card by swiping it up, with the handler removed on unmount:

```ts
import { useEffect, useRef } from 'react';
import { swipeable } from '@/lib/appmintNative';

export function Banner({ onClose, children }: { onClose: () => void; children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => (ref.current ? swipeable(ref.current, { onUp: onClose, threshold: 40 }) : undefined), [onClose]);
  return <div ref={ref} className="banner">{children}</div>;
}
```

**Notes:**
- Built on Pointer Events, so the same code works with touch, pen and a mouse drag.
- It sets `touch-action` for you: `pan-y` for horizontal handlers (vertical scrolling still works), `pan-x` for vertical ones, `none` for both.
- A swipe must be clearly one direction (1.5 times more on one axis); diagonal scrolls do not fire. A second finger cancels the gesture.
- The edge guard exists because Android's system back gesture owns the screen edges.

### `appmintNative.systemPalette`

```js
systemPalette(): Promise<SystemPalette>
```

**Example**

Reads the phone's Material You colours (built from the wallpaper on Android 12+), so the app's accent can follow the user's wallpaper like the system apps.

**Returns:** `Promise<{ available: boolean; accent1?, accent2?, accent3?, neutral1?, neutral2? }>` - each swatch maps a tone (`'0'`, `'10'`, `'50'`, `'100'` … `'900'`, `'1000'`) to a hex colour. `{ available: false }` below Android 12, on iOS and on the web. **Needs:** nothing.

```ts
import { systemPalette } from '@/lib/appmintNative';

export async function applyWallpaperAccent() {
  const p = await systemPalette();
  if (!p.available || !p.accent1) return;          // keep the app's own palette
  const root = document.documentElement.style;
  root.setProperty('--accent', p.accent1['600']);
  root.setProperty('--accent-soft', p.accent1['100']);
}
```

Use the wallpaper colours only when the phone offers them, and re-read them when the app comes back to the screen:

```ts
import { useEffect, useState } from 'react';
import { systemPalette } from '@/lib/appmintNative';

export function useWallpaperAccent(enabled: boolean) {
  const [accent, setAccent] = useState<string | null>(null);
  useEffect(() => {
    if (!enabled) { setAccent(null); return; }
    const read = () => systemPalette().then((p) => setAccent(p.available ? p.accent1?.['600'] ?? null : null));
    void read();
    document.addEventListener('visibilitychange', read);
    return () => document.removeEventListener('visibilitychange', read);
  }, [enabled]);
  return accent;   // null = no wallpaper palette on this device (Android 11 and older, or the web)
}
```

Show the "Match my wallpaper" switch only when a palette exists, using `{ available: false }` as the answer to "not here":

```ts
import { useEffect, useState } from 'react';
import { can, systemPalette } from '@/lib/appmintNative';

export function WallpaperToggle({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) {
  const [ok, setOk] = useState(false);
  useEffect(() => { if (can('systemPalette') !== 'none') systemPalette().then((p) => setOk(p.available)); }, []);
  if (!ok) return null;
  return <label><input type="checkbox" checked={value} onChange={(e) => onChange(e.target.checked)} /> Match my wallpaper</label>;
}
```

**Notes:** Check contrast before you use a wallpaper tone for text or buttons - `--accent` must still reach 3:1 on your background. Offer it as an option ("Match my wallpaper"), not a forced theme. Call it again when the app resumes; the user may have changed the wallpaper.

### `appmintNative.TextToSpeech`

```js
TextToSpeech — @/lib/appmintNative
```

**Example**

The Capacitor-style `TextToSpeech` object (`speak` / `stop`), running on the same code as `speak()` and `stopSpeaking()`.

**Returns:** `speak({ text, lang?, rate?, pitch? })` → `Promise<void>` that resolves when the text is **queued**, not when it has been spoken. `stop()` → `Promise<void>`. **Needs:** nothing.

```ts
import { TextToSpeech } from '@/lib/appmintNative';

async function announce(orderNo: number) {
  await TextToSpeech.speak({ text: `Order ${orderNo} is ready`, lang: 'en-US', rate: 1, pitch: 1 });
  // Here the text is queued; it may still be speaking.
}

async function silence() {
  await TextToSpeech.stop();
}
```

Read out a list of lines one after the other; each `speak` resolves when queued, so the engine plays them in order:

```ts
import { TextToSpeech } from '@/lib/appmintNative';

export async function readSteps(steps: string[]) {
  await TextToSpeech.stop();   // drop anything still waiting from before
  for (const [i, s] of steps.entries()) {
    await TextToSpeech.speak({ text: `Step ${i + 1}. ${s}`, lang: 'en-US' });
  }
}
```

A read-aloud button that stops speaking when the screen is left:

```ts
import { useEffect } from 'react';
import { TextToSpeech } from '@/lib/appmintNative';

export function ArticleReader({ text }: { text: string }) {
  useEffect(() => () => { void TextToSpeech.stop(); }, []);
  return <button onClick={() => void TextToSpeech.speak({ text, rate: 0.9 })}>Listen</button>;
}
```

**Notes:** Because `speak` resolves early, do not chain "the next step after speaking" on it. For a real end signal use `window.speechSynthesis` with an utterance's `onend`. Android: the app's native engine; iOS: the TextToSpeech plugin; web: `speechSynthesis`.

### `appmintNative.textZoom`

```js
textZoom.get() / textZoom.getPreferred() / textZoom.set(value)
```

**Example**

An in-app text-size control. In the app it changes the page's text zoom (`1` = 100%); on the web it changes the root font size, which scales every `rem`-based layout.

**Returns:** `get()` → `Promise<number>`. `getPreferred()` → `Promise<number>` - the size the user chose in the phone's settings (`1` on the web). `set(value)` → `Promise<void>`; the value is clamped to 0.5-3. **Needs:** nothing.

```ts
import { textZoom } from '@/lib/appmintNative';

async function bigger() { await textZoom.set((await textZoom.get()) + 0.1); }
async function smaller() { await textZoom.set((await textZoom.get()) - 0.1); }
async function reset() { await textZoom.set(await textZoom.getPreferred()); }
```

**Remember the choice** across launches:

```ts
import { textZoom } from '@/lib/appmintNative';

const saved = Number(localStorage.getItem('textZoom'));
if (saved) await textZoom.set(saved);

export async function setSize(v: number) {
  await textZoom.set(v);
  localStorage.setItem('textZoom', String(v));
}
```

A text-size stepper that reads the current value, keeps it inside the allowed range, and shows it in percent:

```ts
import { useEffect, useState } from 'react';
import { textZoom } from '@/lib/appmintNative';

export function TextSizeStepper() {
  const [z, setZ] = useState(1);
  useEffect(() => { textZoom.get().then(setZ); }, []);
  const change = async (d: number) => {
    const next = Math.min(3, Math.max(0.5, Math.round((z + d) * 10) / 10));
    await textZoom.set(next);
    setZ(next);
  };
  return (
    <div>
      <button onClick={() => change(-0.1)}>A-</button>
      <span>{Math.round(z * 100)}%</span>
      <button onClick={() => change(0.1)}>A+</button>
    </div>
  );
}
```

**Notes:** The app already follows the phone's font-size setting; this is an extra, in-app control. Test the layout at large sizes - fixed heights on text controls clip.

### `appmintNative.toast`

```js
toast(message: string, opts?: { duration?: 'short' | 'long' }): void
```

**Example**

Shows a brief message that needs no answer - "Copied", "Saved", "Added to list".

**Returns:** nothing (fire and forget). **Needs:** nothing.

```ts
import { toast, haptic } from '@/lib/appmintNative';

export function SaveButton({ onSave }: { onSave: () => Promise<void> }) {
  return (
    <button
      onClick={async () => {
        await onSave();
        haptic('success');
        toast('Saved');                              // 'short' ≈ 2 s
      }}
    >
      Save
    </button>
  );
}
```

A longer message stays about 3.5 seconds:

```ts
import { toast } from '@/lib/appmintNative';

toast('Your export will be ready in a minute', { duration: 'long' });
```

The Capacitor-shaped form `Toast.show({ text, duration? })` does the same and resolves `Promise<void>`:

```ts
import { Toast } from '@/lib/appmintNative';

await Toast.show({ text: 'Added to list' });
```

For a message the user MUST see, use a dialog instead:

```ts
import { Dialog } from '@/lib/appmintNative';

await Dialog.alert({ title: 'Payment failed', message: 'Your card was declined.' });
```

**Notes:**
- In the Android app the Capacitor Toast plugin shows it at the bottom of the screen.
- Everywhere else it is a small snackbar drawn over the page in the app's own colours (`--ink` on `--bg`), above the safe-area inset, with `role="status"` so screen readers announce it.
- Only one is shown at a time; a new toast replaces the old one.
- A toast is easy to miss by design. Never use it for an error the user has to act on.

### `appmintNative.Toast`

```js
import { Toast } from '@/lib/appmintNative'
```

**Example**

Documented together with `appmintNative.toast` - the example there shows this one too.

Shows a brief message that needs no answer - "Copied", "Saved", "Added to list".

**Returns:** nothing (fire and forget). **Needs:** nothing.

```ts
import { toast, haptic } from '@/lib/appmintNative';

export function SaveButton({ onSave }: { onSave: () => Promise<void> }) {
  return (
    <button
      onClick={async () => {
        await onSave();
        haptic('success');
        toast('Saved');                              // 'short' ≈ 2 s
      }}
    >
      Save
    </button>
  );
}
```

A longer message stays about 3.5 seconds:

```ts
import { toast } from '@/lib/appmintNative';

toast('Your export will be ready in a minute', { duration: 'long' });
```

The Capacitor-shaped form `Toast.show({ text, duration? })` does the same and resolves `Promise<void>`:

```ts
import { Toast } from '@/lib/appmintNative';

await Toast.show({ text: 'Added to list' });
```

For a message the user MUST see, use a dialog instead:

```ts
import { Dialog } from '@/lib/appmintNative';

await Dialog.alert({ title: 'Payment failed', message: 'Your card was declined.' });
```

**Notes:**
- In the Android app the Capacitor Toast plugin shows it at the bottom of the screen.
- Everywhere else it is a small snackbar drawn over the page in the app's own colours (`--ink` on `--bg`), above the safe-area inset, with `role="status"` so screen readers announce it.
- Only one is shown at a time; a new toast replaces the old one.
- A toast is easy to miss by design. Never use it for an error the user has to act on.

### `appmintNative.torch`

```js
import { torch } from '@/lib/appmintNative'
```

**Example**

The phone's flashlight: `torch.on()`, `off()`, `toggle()`, `isOn()`, `isAvailable()`. Android; `can('torch')` is `'none'` on iPhone and the web.

**Returns:** `on()`/`off()` → Promise (rejects with `code` `'unavailable'` or `'CAMERA_IN_USE'`); `toggle()` → the new state; `isOn()` / `isAvailable()` → boolean. **Needs:** nothing, no permission.

```ts
import { useEffect, useState } from 'react';
import { can, torch } from '@/lib/appmintNative';

export function LightButton() {
  const [on, setOn] = useState(false);
  const [ok, setOk] = useState(false);
  useEffect(() => { if (can('torch') !== 'none') torch.isAvailable().then(setOk); }, []);
  if (!ok) return null;
  return (
    <button aria-pressed={on} aria-label="Flashlight" onClick={async () => setOn(await torch.toggle())}>
      {on ? 'Light off' : 'Light on'}
    </button>
  );
}
```

Switch the light off when the user leaves the screen and show the error the phone reports when it cannot be switched:

```ts
import { useEffect, useState } from 'react';
import { torch } from '@/lib/appmintNative';

export function LightSwitch() {
  const [on, setOn] = useState(false);
  const [err, setErr] = useState('');
  useEffect(() => () => { void torch.off().catch(() => {}); }, []);
  const flip = async () => {
    try { setOn(await torch.toggle()); setErr(''); }
    catch (e) {
      const code = (e as { code?: string }).code;
      setErr(code === 'CAMERA_IN_USE' ? 'Close the camera first' : 'No flashlight on this device');
    }
  };
  return <div><button onClick={flip}>{on ? 'Off' : 'On'}</button>{err && <p role="alert">{err}</p>}</div>;
}
```

Blink the light as a signal, then leave it off:

```ts
import { can, torch } from '@/lib/appmintNative';

export async function blink(times = 3) {
  if (can('torch') === 'none') return;
  for (let i = 0; i < times; i++) {
    await torch.on();
    await new Promise((r) => setTimeout(r, 200));
    await torch.off();
    await new Promise((r) => setTimeout(r, 200));
  }
}
```

**Notes:** switched off when the app closes. While the app's own camera stream is open, light it with the track's `torch` constraint instead.

### `appmintNative.transition`

```js
transition(update: () => void | Promise<void>, opts?: { type?: 'push' | 'pop' | 'fade' }): Promise<void>
```

**Example**

Runs a screen change with a native-feeling animation: `push` slides the new screen in from the right, `pop` slides back, `fade` cross-fades.

**Returns:** `Promise<void>` that settles after the change and its animation. `update` is your state change (a router `navigate`, a `setState`) and is always applied. **Needs:** nothing.

```ts
import { useNavigate } from 'react-router-dom';
import { transition, haptic } from '@/lib/appmintNative';

export function NoteRow({ id, title }: { id: string; title: string }) {
  const navigate = useNavigate();
  return (
    <button
      onClick={() => { haptic('light'); void transition(() => navigate(`/note/${id}`)); }}   // push
    >
      {title}
    </button>
  );
}

export function BackButton() {
  const navigate = useNavigate();
  return <button onClick={() => void transition(() => navigate(-1), { type: 'pop' })}>Back</button>;
}
```

Fade between two tabs:

```ts
import { transition } from '@/lib/appmintNative';

declare function setTab(t: 'list' | 'map'): void;
await transition(() => setTab('map'), { type: 'fade' });
```

Use a fade for a tab change, in one handler per tab:

```ts
import { transition } from '@/lib/appmintNative';

export function Tabs({ tab, setTab }: { tab: 'home' | 'stats'; setTab: (t: 'home' | 'stats') => void }) {
  return (
    <nav>
      <button onClick={() => void transition(() => setTab('home'), { type: 'fade' })} aria-current={tab === 'home'}>Home</button>
      <button onClick={() => void transition(() => setTab('stats'), { type: 'fade' })} aria-current={tab === 'stats'}>Stats</button>
    </nav>
  );
}
```

**Notes:**
- It uses the View Transitions API when the WebView has it. Otherwise it applies the update and plays a short enter animation on `#root` (or the first child of `<body>`).
- When the user asked the system for reduced motion, it only applies the update - no animation.
- Animations are about 240 ms.

### `appmintNative.unlock`

```js
unlock(title?, subtitle?) — from '@/lib/appmintNative'
```

**Example**

Asks the phone's owner to prove they are present - fingerprint, face, or the device PIN/passcode - and tells you whether they did.

**Returns:** `Promise<boolean>` - `true` when unlocked; `false` when cancelled, failed, locked out, or nothing can check on this device. It never throws. **Needs:** Android: **Fingerprint** ticked when you build. iOS: Face ID / Touch ID. Web: the browser's platform authenticator (WebAuthn).

```ts
import { unlock, can } from '@/lib/appmintNative';

export async function openPrivateNotes(show: () => void) {
  if (can('unlock') === 'none') {
    alert('This device has no fingerprint, face or PIN check.');
    return;
  }
  const ok = await unlock('Unlock notes', 'Use your fingerprint or PIN');
  if (ok) show();
}
```

Hide the button where there is nothing to unlock with:

```ts
import { can } from '@/lib/appmintNative';

const showLockButton = can('unlock') !== 'none';   // 'native' | 'web' | 'none'
```

Guard a delete with a fresh unlock, then confirm with a haptic and a toast:

```ts
import { unlock, haptic, toast } from '@/lib/appmintNative';

export async function deleteAccountData(wipe: () => Promise<void>) {
  const ok = await unlock('Confirm delete', 'This removes your saved data');
  if (!ok) { haptic('warning'); toast('Not unlocked, nothing was deleted'); return; }
  await wipe();
  haptic('success');
  toast('Data deleted');
}
```

**Notes:** Android and iOS both allow the device PIN/passcode as the second way in. On the web, the first call enrols the browser's platform authenticator and counts as unlocked. This proves the device owner is present; it is NOT a login - never use it as the only guard on data that lives on a server. On Android 11 and newer the prompt opens on any phone with a screen lock - a PIN-only phone gets it too, and unlocks with the PIN; on Android 10 and older a fingerprint must be saved first, otherwise it answers `false` without a prompt.

### `appmintNative.updates`

```js
updates.supported() / download() / stage() / confirm() / revertIfUnconfirmed() / current()
```

**Example**

The page-side updater for web-bundle updates. Page code does not need it: Remote Update is done natively by the app on both stores, and `updates.supported()` returns `false` everywhere.

**Returns:** `supported()` → `boolean` (currently always `false`); `download(url, sha256, version, binaryVersion)` → `Promise<boolean>` (`false` while unsupported); `stage(dir, version, binaryVersion)` → `Promise<boolean>`; `confirm()` → `void`; `revertIfUnconfirmed(binaryVersion)` → `Promise<boolean>`; `current()` → `{ path, version, binaryVersion } | null`. **Needs:** nothing for your app - updates are published from Remote Update in Appwright.

The only safe use in app code is to check, and do nothing when it is off:

```ts
import { updates } from '@/lib/appmintNative';

if (updates.supported()) {
  // Not reached today: the native updaters own the whole update lane.
  const staged = await updates.download(
    'https://example.com/bundle-1.2.0.zip',
    'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',  // sha256, required
    '1.2.0',
    '36',
  );
  console.log('update staged:', staged);
}
```

Show which bundle is running (a settings "About" row):

```ts
import { updates } from '@/lib/appmintNative';

const active = updates.current();          // null = the version the store installed
const label = active ? `Content ${active.version}` : 'Built-in content';
```

Show the running content version in a settings row, with the built-in label when the page has no staged bundle:

```ts
import { useMemo } from 'react';
import { updates } from '@/lib/appmintNative';

export function AboutRow() {
  const label = useMemo(() => {
    const c = updates.current();
    return c ? `Content ${c.version} (app ${c.binaryVersion})` : 'Built-in content';
  }, []);
  return <p>{label}</p>;
}
```

**Notes:**
- Android: the shell's Remote Update manager downloads, verifies and serves updates, and rolls back a bundle that fails to load. iOS: the scaffold's own updater does the same at every launch. A bundle staged from page code would be overridden on the next start, which is why `supported()` is `false`.
- Updates can change HTML, CSS and JavaScript only; a new native feature still needs a new app build.

### `appmintNative.watchIdle`

```js
import { watchIdle } from '@/lib/appmintNative'
```

**Example**

Tells the app when the user goes idle or locks the screen, and when they are back (`can('idle')`). Start it from a tap the first time - the permission request needs one.

**Returns:** Promise of the stop function. The callback gets `{ user: 'active' | 'idle', screen: 'locked' | 'unlocked' }` at start and on every change. Rejects with `code` `'permission-denied'` or `'unavailable'`. **Needs:** nothing, no Android permission.

```ts
import { useRef } from 'react';
import { can, watchIdle } from '@/lib/appmintNative';

export function AwayStatus({ setAway }: { setAway: (away: boolean) => void }) {
  const stop = useRef<(() => void) | null>(null);
  if (can('idle') === 'none') return null;
  return (
    <button onClick={async () => {
      stop.current?.();
      stop.current = await watchIdle((s) => setAway(s.user === 'idle' || s.screen === 'locked'), { thresholdMs: 5 * 60000 });
    }}>Show me as away when idle</button>
  );
}
```

Show an away state and stop watching on unmount; a refused permission is reported with its `code`:

```ts
import { useEffect, useRef, useState } from 'react';
import { can, watchIdle } from '@/lib/appmintNative';

export function useIdleWatch() {
  const stop = useRef<(() => void) | null>(null);
  const [state, setState] = useState<'off' | 'active' | 'idle' | 'denied'>('off');
  useEffect(() => () => stop.current?.(), []);
  // Call this from a tap: the first permission request needs a gesture.
  const start = async () => {
    if (can('idle') === 'none') return;
    try {
      stop.current = await watchIdle((s) => setState(s.user === 'idle' || s.screen === 'locked' ? 'idle' : 'active'));
    } catch (e) {
      if ((e as { code?: string }).code === 'permission-denied') setState('denied');
    }
  };
  return { state, start };
}
```

Pause a polling timer while the user is away and refresh when they are back:

```ts
import { watchIdle } from '@/lib/appmintNative';

export async function pausePollingWhenIdle(refresh: () => void) {
  let timer: ReturnType<typeof setInterval> | undefined = setInterval(refresh, 30000);
  const stop = await watchIdle((s) => {
    const away = s.user === 'idle' || s.screen === 'locked';
    if (away && timer) { clearInterval(timer); timer = undefined; }
    if (!away && !timer) { refresh(); timer = setInterval(refresh, 30000); }
  });
  return () => { stop(); if (timer) clearInterval(timer); };
}
```

**Notes:** the threshold is at least one minute (the spec's floor).

### `appmintNative.widget`

```js
widget.set(card) / widget.clear() / widget.placedCount() / widget.requestPin()
```

**Example**

Fills the app's home-screen widget - one card with a title, a big value, up to six lines, and a tap that opens the app at a route.

**Returns:** `set(card)` → `Promise<boolean>` (`true` when saved); `clear()` → `Promise<void>`; `placedCount()` → `Promise<number>` (how many copies the user placed); `requestPin()` → `Promise<boolean>` (`true` when the launcher's "add widget" flow opened). None of them reject. **Needs:** nothing in the Android app (the widget is always part of the build).

Update the card after every change the user would want to see on the home screen:

```ts
import { can, widget } from '@/lib/appmintNative';

export async function updateTodayWidget(tasks: { title: string; done: boolean }[]) {
  if (can('widget') === 'none') return;          // web and iOS
  const open = tasks.filter((t) => !t.done);
  await widget.set({
    title: 'Today',
    value: `${open.length} left`,
    lines: open.slice(0, 6).map((t) => '• ' + t.title),
    route: '/today',                              // a tap opens the app here
  });
}
```

An "Add to home screen" button:

```ts
import { can, widget, toast } from '@/lib/appmintNative';

export function AddWidgetButton() {
  if (can('widget') === 'none') return null;
  return (
    <button
      onClick={async () => {
        if (!(await widget.requestPin())) toast('Long-press your home screen, tap Widgets, and pick this app');
      }}
    >
      Add widget to home screen
    </button>
  );
}
```

Empty the card when the user signs out, and show how many copies are on the home screen:

```ts
import { useEffect, useState } from 'react';
import { can, widget } from '@/lib/appmintNative';

export function WidgetStatus({ signedIn }: { signedIn: boolean }) {
  const [placed, setPlaced] = useState(0);
  useEffect(() => {
    if (can('widget') === 'none') return;
    if (!signedIn) void widget.clear();
    widget.placedCount().then(setPlaced);
  }, [signedIn]);
  return <p>{placed === 0 ? 'Not on your home screen yet' : `On your home screen (${placed})`}</p>;
}
```

**Notes:**
- The card is saved on the device, survives a reboot, and every placed copy is refreshed. `placedCount()` of `0` means no copy is on the home screen yet.
- Card fields: `title` (empty = the app name), `value` (shown large in `accent`, hidden when empty), `lines` (max 6), `route` (default `/`), and `bg` / `ink` / `accent`. The colours default to the CSS tokens `--bg`, `--ink`, `--accent`; they must be colours Android can read, such as `#RRGGBB` - an `hsl()`/`oklch()` value falls back to the default colours.
- A tap opens the app with `appmint-shortcut://<package><route>`; read it with `launchUrl()` / `onDeepLink()`.
- `requestPin()` needs Android 8+ and a launcher that supports it. `clear()` shows the empty "Nothing to show yet" card.
- Android only: `can('widget')` is `'none'` on iOS and the web, where `set()` and `requestPin()` return `false`.

