diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 3534aebf..51314bef 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -100,6 +100,23 @@ jobs:
node-version: "20"
cache: npm
+ - name: Restore iOS compiler cache
+ if: ${{ inputs.platform == 'ios' }}
+ uses: actions/cache@v4
+ with:
+ path: ~/Library/Caches/ccache
+ key: ios-ccache-${{ inputs.app }}-${{ github.sha }}
+ restore-keys: |
+ ios-ccache-${{ inputs.app }}-
+
+ - name: Install ccache
+ if: ${{ inputs.platform == 'ios' }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ command -v ccache >/dev/null 2>&1 || brew install ccache
+ ccache --version
+
- name: Setup Java
if: ${{ inputs.platform == 'android' }}
uses: actions/setup-java@v5
@@ -107,6 +124,10 @@ jobs:
distribution: temurin
java-version: "17"
+ - name: Setup Gradle
+ if: ${{ inputs.platform == 'android' }}
+ uses: gradle/actions/setup-gradle@v6
+
- name: Setup Android SDK
if: ${{ inputs.platform == 'android' }}
uses: android-actions/setup-android@v4
@@ -218,6 +239,7 @@ jobs:
working-directory: ${{ steps.resolve.outputs.app_dir }}
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
+ EXPO_APPLE_TEAM_TYPE: INDIVIDUAL
EAS_LOCAL_BUILD_ARTIFACTS_DIR: ${{ steps.resolve.outputs.artifact_dir }}
shell: bash
run: |
diff --git a/.gitignore b/.gitignore
index c40e195f..1435567b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -63,6 +63,7 @@ coverage/clover.xml
# other
.com.facebook.react.devsupport.BundleDownloader.swp
+/app.json
# local vendor reference docs (do not commit)
docs/local-only
diff --git a/.opencode/agents/explore.md b/.opencode/agents/explore.md
index 06d2cbfb..1616ecc5 100644
--- a/.opencode/agents/explore.md
+++ b/.opencode/agents/explore.md
@@ -73,7 +73,7 @@ permission:
context7_query-docs: allow
markitdown_convert_to_markdown: allow
model: openai/gpt-5.6-luna
-variant: max
+variant: high
---
You are a read-only exploration and research specialist supporting the primary engineer agent.
diff --git a/apps/drill-converter/.gitignore b/apps/drill-converter/.gitignore
new file mode 100644
index 00000000..5873d9ab
--- /dev/null
+++ b/apps/drill-converter/.gitignore
@@ -0,0 +1,6 @@
+
+# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
+# The following patterns were generated by expo-cli
+
+expo-env.d.ts
+# @end expo-cli
\ No newline at end of file
diff --git a/apps/drill-converter/app.config.ts b/apps/drill-converter/app.config.ts
new file mode 100644
index 00000000..cfd186dc
--- /dev/null
+++ b/apps/drill-converter/app.config.ts
@@ -0,0 +1,19 @@
+import type { ExpoConfig } from "expo/config";
+
+const config: ExpoConfig = {
+ name: "Eight2Five Drill Converter",
+ slug: "eight2five-drill-converter",
+ version: "0.1.0",
+ platforms: ["web"],
+ userInterfaceStyle: "automatic",
+ web: {
+ bundler: "metro",
+ output: "static",
+ },
+ plugins: ["expo-router"],
+ experiments: {
+ typedRoutes: true,
+ },
+};
+
+export default config;
diff --git a/apps/drill-converter/app/_layout.tsx b/apps/drill-converter/app/_layout.tsx
new file mode 100644
index 00000000..9ad258a1
--- /dev/null
+++ b/apps/drill-converter/app/_layout.tsx
@@ -0,0 +1,15 @@
+import React from "react";
+import { Stack } from "expo-router";
+
+export default function RootLayout() {
+ return (
+
+ );
+}
diff --git a/apps/drill-converter/app/index.tsx b/apps/drill-converter/app/index.tsx
new file mode 100644
index 00000000..13eb1a52
--- /dev/null
+++ b/apps/drill-converter/app/index.tsx
@@ -0,0 +1,13 @@
+import React from "react";
+import { Stack } from "expo-router";
+
+import { ConverterScreen } from "../src/converter-screen";
+
+export default function IndexRoute() {
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/apps/drill-converter/metro.config.js b/apps/drill-converter/metro.config.js
new file mode 100644
index 00000000..d84b494d
--- /dev/null
+++ b/apps/drill-converter/metro.config.js
@@ -0,0 +1,19 @@
+const { getDefaultConfig } = require("expo/metro-config");
+const path = require("path");
+
+const projectRoot = __dirname;
+const workspaceRoot = path.resolve(__dirname, "../..");
+const config = getDefaultConfig(projectRoot);
+
+config.watchFolders = Array.from(
+ new Set([...(config.watchFolders ?? []), workspaceRoot])
+);
+config.resolver.nodeModulesPaths = Array.from(
+ new Set([
+ ...(config.resolver?.nodeModulesPaths ?? []),
+ path.resolve(projectRoot, "node_modules"),
+ path.resolve(workspaceRoot, "node_modules"),
+ ])
+);
+
+module.exports = config;
diff --git a/apps/drill-converter/package.json b/apps/drill-converter/package.json
new file mode 100644
index 00000000..2d1b9ce1
--- /dev/null
+++ b/apps/drill-converter/package.json
@@ -0,0 +1,52 @@
+{
+ "name": "eight2five-drill-converter",
+ "version": "0.1.0",
+ "private": true,
+ "main": "expo-router/entry",
+ "scripts": {
+ "start": "expo start --web",
+ "web": "expo start --web",
+ "build:web": "expo export --platform web",
+ "type-check": "tsc --noEmit",
+ "lint": "expo lint",
+ "lint:fix": "expo lint --fix",
+ "test": "jest --watchAll=false --passWithNoTests --runInBand"
+ },
+ "dependencies": {
+ "@eight2five/drill-importers": "0.1.0",
+ "@eight2five/drill-schema": "0.1.0",
+ "@eight2five/ui": "0.1.0",
+ "@expo/metro-runtime": "~57.0.8",
+ "expo": "~57.0.10",
+ "expo-document-picker": "~57.0.1",
+ "expo-router": "~57.0.10",
+ "lucide-react-native": "^1.22.0",
+ "react": "19.2.3",
+ "react-dom": "^19.2.3",
+ "react-native": "0.86.2",
+ "react-native-safe-area-context": "~5.7.0",
+ "react-native-web": "^0.21.2"
+ },
+ "devDependencies": {
+ "@types/jest": "^29.5.14",
+ "@types/react": "~19.2.10",
+ "eslint": "^9.0.0",
+ "eslint-config-expo": "~57.0.1",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-prettier": "^5.5.4",
+ "jest": "^29.7.0",
+ "jest-expo": "~57.0.3",
+ "prettier": "^3.6.2",
+ "typescript": "~6.0.3"
+ },
+ "jest": {
+ "preset": "jest-expo",
+ "testEnvironment": "node",
+ "moduleNameMapper": {
+ "^@eight2five/drill-schema$": "/../../packages/drill-schema/src/index.ts",
+ "^@eight2five/drill-schema/(.*)$": "/../../packages/drill-schema/src/$1",
+ "^@eight2five/drill-importers$": "/../../packages/drill-importers/src/index.ts",
+ "^@eight2five/drill-importers/(.*)$": "/../../packages/drill-importers/src/$1"
+ }
+ }
+}
diff --git a/apps/drill-converter/src/components/details-section.tsx b/apps/drill-converter/src/components/details-section.tsx
new file mode 100644
index 00000000..d04504aa
--- /dev/null
+++ b/apps/drill-converter/src/components/details-section.tsx
@@ -0,0 +1,162 @@
+import React from "react";
+import { Text, View } from "react-native";
+import {
+ getFieldPreset,
+ getGridReference,
+ type FieldPresetId,
+} from "@eight2five/drill-schema";
+
+import {
+ ChoiceChips,
+ Disclosure,
+ FormField,
+ SectionCard,
+} from "../ui/form-controls";
+import { colors, radius, spacing } from "../ui/theme";
+import {
+ FIELD_PRESET_OPTIONS,
+ type ConverterSettings,
+} from "../converter/settings";
+
+const FIELD_OPTIONS = Object.freeze([
+ ...FIELD_PRESET_OPTIONS,
+ { value: "custom", label: "Custom" },
+] as const);
+
+export function DetailsSection({
+ settings,
+ errors,
+ onUpdate,
+}: {
+ readonly settings: ConverterSettings;
+ readonly errors: readonly string[];
+ readonly onUpdate: (patch: Partial) => void;
+}) {
+ const titleError = errors.find((error) => error.includes("title"));
+ const customFieldError = errors.find((error) =>
+ error.startsWith("Custom field"),
+ );
+ return (
+
+ onUpdate({ title })}
+ placeholder="Part 4"
+ error={titleError}
+ />
+
+ onUpdate({ fieldMode })}
+ />
+
+ {settings.fieldMode === "custom" ? (
+ onUpdate({ customFieldJson })}
+ multiline
+ autoCapitalize="none"
+ autoCorrect={false}
+ spellCheck={false}
+ error={customFieldError}
+ helper="Advanced: paste a custom field object with matched physical and marching reference lines. The editor starts from NFHS geometry so you can change only what differs."
+ />
+ ) : (
+
+ )}
+
+
+ onUpdate({ drillWriter })}
+ placeholder="Optional"
+ />
+ onUpdate({ ensemble })}
+ placeholder="Optional"
+ />
+ onUpdate({ description })}
+ multiline
+ placeholder="Optional notes about this movement"
+ />
+ onUpdate({ lucideIcon })}
+ autoCapitalize="none"
+ autoCorrect={false}
+ placeholder="music-2"
+ helper="Optional kebab-case Lucide icon name stored as metadata. The converter does not download icon assets."
+ />
+
+
+ );
+}
+
+function FieldPresetSummary({ preset }: { readonly preset: FieldPresetId }) {
+ const field = getFieldPreset(preset);
+ const frontHash = getGridReference({ type: "preset", preset }, "front-hash");
+ const backHash = getGridReference({ type: "preset", preset }, "back-hash");
+ const backSideline = getGridReference(
+ { type: "preset", preset },
+ "back-sideline",
+ );
+ return (
+
+
+ {field.name}
+
+
+ Origin: center of the 50 on the front sideline · Side 1 is negative X ·
+ Side 2 is positive X · backfield is positive Y.
+
+
+ Marching grid: Front sideline 0 · Front hash{" "}
+ {formatNumber(frontHash?.coordinateSteps)} · Back hash{" "}
+ {formatNumber(backHash?.coordinateSteps)} · Back sideline{" "}
+ {formatNumber(backSideline?.coordinateSteps)}.
+
+
+ );
+}
+
+function formatNumber(value: number | undefined): string {
+ if (value === undefined) return "—";
+ return Number.isInteger(value)
+ ? String(value)
+ : value.toFixed(3).replace(/0+$/, "").replace(/\.$/, "");
+}
diff --git a/apps/drill-converter/src/components/entity-settings-section.tsx b/apps/drill-converter/src/components/entity-settings-section.tsx
new file mode 100644
index 00000000..d4ce36a6
--- /dev/null
+++ b/apps/drill-converter/src/components/entity-settings-section.tsx
@@ -0,0 +1,506 @@
+import React from "react";
+import { Pressable, Text, View } from "react-native";
+import {
+ COLOR_PRESETS,
+ convertPropSizeValue,
+ type PropSizeUnit,
+} from "@eight2five/drill-schema";
+
+import {
+ ChoiceChips,
+ Disclosure,
+ FormField,
+ SecondaryButton,
+ ToggleRow,
+} from "../ui/form-controls";
+import { colors, radius, spacing } from "../ui/theme";
+import {
+ COLOR_PRESET_OPTIONS,
+ ENTITY_ICON_OPTIONS,
+ type ConverterSettings,
+ type EntityRuleDraft,
+} from "../converter/settings";
+
+const TARGET_OPTIONS = Object.freeze([
+ { value: "symbol", label: "Symbol" },
+ { value: "label", label: "Label" },
+ { value: "id", label: "ID" },
+] as const);
+
+const ENTITY_TYPE_OPTIONS = Object.freeze([
+ { value: "", label: "Default (Performer)" },
+ { value: "performer", label: "Performer" },
+ { value: "prop", label: "Prop" },
+] as const);
+
+const LABEL_OPTIONS = Object.freeze([
+ { value: "inherit", label: "Default (Show)" },
+ { value: "visible", label: "Show" },
+ { value: "hidden", label: "Hide" },
+] as const);
+
+const PROP_SIZE_UNIT_OPTIONS = Object.freeze([
+ { value: "8-to-5-steps", label: "8:5 steps" },
+ { value: "feet", label: "Feet" },
+ { value: "inches", label: "Inches" },
+ { value: "meters", label: "Meters" },
+] as const satisfies readonly { value: PropSizeUnit; label: string }[]);
+
+export function EntitySettingsSection({
+ settings,
+ availableSymbols,
+ errors,
+ focusRequestKey = 0,
+ onUpdate,
+ onAddRule,
+ onUpdateRule,
+ onRemoveRule,
+}: {
+ readonly settings: ConverterSettings;
+ readonly availableSymbols: readonly string[];
+ readonly errors: readonly string[];
+ readonly focusRequestKey?: number;
+ readonly onUpdate: (patch: Partial) => void;
+ readonly onAddRule: (target?: EntityRuleDraft["target"]) => void;
+ readonly onUpdateRule: (
+ id: string,
+ patch: Partial>,
+ ) => void;
+ readonly onRemoveRule: (id: string) => void;
+}) {
+ const ruleErrors = errors.filter(
+ (error) => error.startsWith("Rule ") || error.startsWith("Duplicate "),
+ );
+
+ return (
+ 0}
+ >
+
+
+
+ Rules and overrides
+
+
+ Precedence is symbol → label → ID → explicit entity values. Leave a
+ field on its Default value to inherit the broader rule or schema
+ default.
+
+
+
+ {settings.rules.map((rule, index) => (
+ onUpdateRule(rule.id, patch)}
+ onRemove={() => onRemoveRule(rule.id)}
+ />
+ ))}
+
+ {ruleErrors.length > 0 ? (
+
+ {ruleErrors.map((error) => (
+
+ {error}
+
+ ))}
+
+ ) : null}
+
+
+ onAddRule("symbol")}
+ />
+ onAddRule("label")}
+ />
+ onAddRule("id")}
+ />
+
+
+
+
+ onUpdate({ explicitStraightPaths })
+ }
+ />
+
+ onUpdate({ includeSourceReferences })
+ }
+ />
+
+ );
+}
+
+function RuleEditor({
+ index,
+ rule,
+ availableSymbols,
+ onUpdate,
+ onRemove,
+}: {
+ readonly index: number;
+ readonly rule: EntityRuleDraft;
+ readonly availableSymbols: readonly string[];
+ readonly onUpdate: (patch: Partial>) => void;
+ readonly onRemove: () => void;
+}) {
+ const colorMatchesPreset = COLOR_PRESET_OPTIONS.some(
+ (option) => option.value.toLowerCase() === rule.color.toLowerCase(),
+ );
+ const isProp = rule.entityType === "prop";
+ const defaultIcon = isProp ? "Square" : "Dot";
+
+ return (
+
+
+
+ Rule {index + 1}
+
+
+
+
+
+
+ onUpdate({ target, key: "" })}
+ />
+ {rule.target === "symbol" && availableSymbols.length > 0 ? (
+ ({
+ value: symbol,
+ label: symbol,
+ })),
+ ]}
+ onChange={(key) => onUpdate({ key })}
+ />
+ ) : null}
+ onUpdate({ key })}
+ autoCapitalize="none"
+ autoCorrect={false}
+ placeholder={
+ rule.target === "symbol"
+ ? "B"
+ : rule.target === "label"
+ ? "B1"
+ : "1595433022185"
+ }
+ />
+
+
+ onUpdate(
+ entityType === "prop"
+ ? {
+ entityType,
+ section: "",
+ instrument: "",
+ sizeLength: rule.sizeLength.trim() ? rule.sizeLength : "1",
+ sizeWidth: rule.sizeWidth.trim() ? rule.sizeWidth : "1",
+ }
+ : { entityType },
+ )
+ }
+ />
+
+ onUpdate({ name })}
+ placeholder="Optional"
+ />
+
+ {isProp ? (
+
+
+
+ Prop size
+
+
+ Default is 1 × 1 8:5 steps. One 8:5 step is 22.5 in (1.875 ft /
+ 0.5715 m).
+
+
+
+
+ onUpdate({
+ sizeUnit,
+ sizeLength: convertSizeDraftValue(
+ rule.sizeLength,
+ rule.sizeUnit,
+ sizeUnit,
+ ),
+ sizeWidth: convertSizeDraftValue(
+ rule.sizeWidth,
+ rule.sizeUnit,
+ sizeUnit,
+ ),
+ })
+ }
+ />
+
+
+
+ onUpdate({ sizeLength })}
+ inputMode="decimal"
+ placeholder="1"
+ />
+
+
+ onUpdate({ sizeWidth })}
+ inputMode="decimal"
+ placeholder="1"
+ />
+
+
+
+ ) : null}
+
+
+
+ onUpdate({ section })}
+ placeholder={isProp ? "Unavailable for props" : "Optional"}
+ editable={!isProp}
+ helper={isProp ? "Props cannot define a section." : undefined}
+ />
+
+
+ onUpdate({ instrument })}
+ placeholder={isProp ? "Unavailable for props" : "Optional"}
+ editable={!isProp}
+ helper={isProp ? "Props cannot define an instrument." : undefined}
+ />
+
+
+
+ ({
+ value: icon,
+ label: titleCase(icon),
+ })),
+ ]}
+ onChange={(icon) => onUpdate({ icon })}
+ />
+
+
+
+ Color
+
+
+ onUpdate({ color: "" })}
+ />
+ {COLOR_PRESET_OPTIONS.map((option) => (
+ onUpdate({ color: option.value })}
+ />
+ ))}
+
+ onUpdate({ color })}
+ autoCapitalize="none"
+ autoCorrect={false}
+ placeholder={COLOR_PRESETS.blue}
+ helper="Leave blank for Default (Grey) or the selected preset. Any six-digit hex color is valid."
+ />
+
+
+ onUpdate({ labelVisibility })}
+ />
+
+ );
+}
+
+function ColorChip({
+ label,
+ color,
+ selected,
+ onPress,
+}: {
+ readonly label: string;
+ readonly color?: string;
+ readonly selected: boolean;
+ readonly onPress: () => void;
+}) {
+ return (
+ ({
+ flexDirection: "row",
+ alignItems: "center",
+ gap: spacing.sm,
+ minHeight: 38,
+ borderWidth: 1,
+ borderColor: selected ? colors.accent : colors.borderStrong,
+ borderRadius: 999,
+ paddingHorizontal: 12,
+ backgroundColor: selected
+ ? colors.accentSoft
+ : pressed
+ ? colors.surfaceMuted
+ : colors.surface,
+ })}
+ >
+ {color ? (
+
+ ) : null}
+
+ {label}
+
+
+ );
+}
+
+function convertSizeDraftValue(
+ value: string,
+ fromUnit: PropSizeUnit,
+ toUnit: PropSizeUnit,
+): string {
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed) || parsed <= 0) return value;
+ const converted = convertPropSizeValue(parsed, fromUnit, toUnit);
+ return String(Number(converted.toFixed(6)));
+}
+
+function titleCase(value: string): string {
+ return value.charAt(0).toUpperCase() + value.slice(1);
+}
diff --git a/apps/drill-converter/src/components/file-section.tsx b/apps/drill-converter/src/components/file-section.tsx
new file mode 100644
index 00000000..5936612b
--- /dev/null
+++ b/apps/drill-converter/src/components/file-section.tsx
@@ -0,0 +1,124 @@
+import React from "react";
+import { ActivityIndicator, Text, View } from "react-native";
+import type { DocumentPickerAsset } from "expo-document-picker";
+
+import {
+ PrimaryButton,
+ SecondaryButton,
+ SectionCard,
+} from "../ui/form-controls";
+import { colors, radius, spacing } from "../ui/theme";
+import type { ConverterPhase } from "../converter/use-converter-controller";
+
+export function FileSection({
+ asset,
+ phase,
+ extractionError,
+ pageCount,
+ pdfJsVersion,
+ onPick,
+ onClear,
+}: {
+ readonly asset?: DocumentPickerAsset;
+ readonly phase: ConverterPhase;
+ readonly extractionError?: string;
+ readonly pageCount: number;
+ readonly pdfJsVersion: string;
+ readonly onPick: () => void;
+ readonly onClear: () => void;
+}) {
+ return (
+
+ {!asset ? (
+
+
+
+ ) : (
+
+
+
+ {asset.name}
+
+
+ {formatBytes(asset.size)}
+ {pageCount > 0
+ ? ` · ${pageCount} PDF page${pageCount === 1 ? "" : "s"}`
+ : ""}
+
+
+ {phase === "extracting" ? (
+
+
+
+ Extracting browser text…
+
+
+ ) : null}
+ {extractionError ? (
+
+
+ {extractionError}
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ )}
+
+ Text extraction uses PDF.js {pdfJsVersion}, loaded from a pinned
+ jsDelivr URL. Only the library is fetched; selected PDF bytes remain
+ local.
+
+
+ );
+}
+
+function formatBytes(value: number | undefined): string {
+ if (!value || value < 1) return "PDF file";
+ if (value < 1024) return `${value} B`;
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
+ return `${(value / (1024 * 1024)).toFixed(1)} MB`;
+}
diff --git a/apps/drill-converter/src/components/preview-section.tsx b/apps/drill-converter/src/components/preview-section.tsx
new file mode 100644
index 00000000..6441ff51
--- /dev/null
+++ b/apps/drill-converter/src/components/preview-section.tsx
@@ -0,0 +1,721 @@
+import React from "react";
+import { Modal, Pressable, ScrollView, Text, View } from "react-native";
+import type { CoordinateSheetImportResult } from "@eight2five/drill-importers";
+import {
+ drillSetSchema,
+ formatSetName,
+ type DrillDocument,
+ type DrillEntity,
+ type DrillSet,
+} from "@eight2five/drill-schema";
+import { Divider } from "@eight2five/ui/components/divider";
+import { Icon } from "@eight2five/ui/components/icon";
+import { ListPlus, Pencil } from "lucide-react-native";
+
+import {
+ FormField,
+ PrimaryButton,
+ SecondaryButton,
+ SectionCard,
+} from "../ui/form-controls";
+import { colors, radius, spacing } from "../ui/theme";
+
+export function PreviewSection({
+ importResult,
+ outputDocument,
+ settingsErrors,
+ summary,
+ canDownload,
+ onAddEntityLabelRule,
+ onUpdateEntityIdentity,
+ onUpdateSet,
+ onDownload,
+}: {
+ readonly importResult?: CoordinateSheetImportResult;
+ readonly outputDocument?: DrillDocument;
+ readonly settingsErrors: readonly string[];
+ readonly summary?: {
+ readonly performers: number;
+ readonly props: number;
+ readonly primarySets: number;
+ readonly setEntries: number;
+ readonly positions: number;
+ };
+ readonly canDownload: boolean;
+ readonly onAddEntityLabelRule: (label: string) => void;
+ readonly onUpdateEntityIdentity: (
+ entity: Pick,
+ ) => string | undefined;
+ readonly onUpdateSet: (set: DrillSet) => string | undefined;
+ readonly onDownload: () => void;
+}) {
+ const [editingEntity, setEditingEntity] = React.useState();
+ const [editingSet, setEditingSet] = React.useState();
+ const diagnostics = importResult?.diagnostics ?? [];
+ const errors = [
+ ...settingsErrors,
+ ...diagnostics
+ .filter((diagnostic) => diagnostic.severity === "error")
+ .map((diagnostic) => diagnostic.message),
+ ];
+ const warnings = diagnostics
+ .filter((diagnostic) => diagnostic.severity === "warning")
+ .map((diagnostic) => diagnostic.message);
+
+ return (
+
+ {!importResult ? (
+
+ Choose a PDF to see parsed performers, sets, coordinates, and
+ validation results.
+
+ ) : null}
+
+ {errors.length > 0 ? (
+
+ ) : null}
+ {warnings.length > 0 ? (
+
+ ) : null}
+
+ {outputDocument && summary ? (
+
+
+
+
+
+
+
+
+
+
+
+ {outputDocument.entities.map((entity, index) => (
+
+ {index > 0 ? : null}
+ onAddEntityLabelRule(entity.label)}
+ editLabel={`Edit label and symbol for ${entity.label}`}
+ onEdit={() => setEditingEntity(entity)}
+ />
+
+ ))}
+
+
+
+ {outputDocument.sets.map((set, index) => {
+ const measures = set.measureRange
+ ? set.measureRange.start === set.measureRange.end
+ ? `m. ${set.measureRange.start}`
+ : `m. ${set.measureRange.start}–${set.measureRange.end}`
+ : "measures —";
+ return (
+
+ {index > 0 ? : null}
+ setEditingSet(set)}
+ />
+
+ );
+ })}
+
+
+
+
+
+ Valid Eight2Five {outputDocument.schemaVersion} drill document.
+ The first set has zero incoming counts, set IDs follow array
+ order, and all entity/set references validate.
+
+
+
+ ) : null}
+
+
+
+
+
+ {editingEntity ? (
+ setEditingEntity(undefined)}
+ onSave={onUpdateEntityIdentity}
+ />
+ ) : null}
+
+ {editingSet ? (
+ setEditingSet(undefined)}
+ onSave={onUpdateSet}
+ />
+ ) : null}
+
+ );
+}
+
+function Metric({
+ label,
+ value,
+}: {
+ readonly label: string;
+ readonly value: number;
+}) {
+ return (
+
+
+ {value}
+
+ {label}
+
+ );
+}
+
+function ScrollablePreviewBox({
+ title,
+ children,
+}: {
+ readonly title: string;
+ readonly children: React.ReactNode;
+}) {
+ return (
+
+
+ {title}
+
+
+
+ {children}
+
+
+
+ );
+}
+
+function PreviewDivider() {
+ return (
+
+ );
+}
+
+function PreviewRow({
+ text,
+ addRuleLabel,
+ onAddRule,
+ editLabel,
+ onEdit,
+}: {
+ readonly text: string;
+ readonly addRuleLabel?: string;
+ readonly onAddRule?: () => void;
+ readonly editLabel: string;
+ readonly onEdit: () => void;
+}) {
+ return (
+
+
+ {text}
+
+ {onAddRule && addRuleLabel ? (
+ ({
+ width: 30,
+ height: 30,
+ alignItems: "center",
+ justifyContent: "center",
+ borderRadius: radius.sm,
+ backgroundColor: pressed ? colors.accentSoft : "transparent",
+ })}
+ >
+
+
+ ) : null}
+ ({
+ width: 30,
+ height: 30,
+ alignItems: "center",
+ justifyContent: "center",
+ borderRadius: radius.sm,
+ backgroundColor: pressed ? colors.accentSoft : "transparent",
+ })}
+ >
+
+
+
+ );
+}
+
+function EntityIdentityEditModal({
+ entity,
+ onClose,
+ onSave,
+}: {
+ readonly entity: DrillEntity;
+ readonly onClose: () => void;
+ readonly onSave: (
+ entity: Pick,
+ ) => string | undefined;
+}) {
+ const [label, setLabel] = React.useState(entity.label);
+ const [symbol, setSymbol] = React.useState(entity.symbol);
+ const [error, setError] = React.useState();
+
+ const save = () => {
+ const trimmedLabel = label.trim();
+ const trimmedSymbol = symbol.trim();
+ if (!trimmedLabel) {
+ setError("Entity label cannot be empty.");
+ return;
+ }
+ if (!trimmedSymbol || trimmedSymbol.length > 16) {
+ setError("Entity symbol must be 1-16 characters.");
+ return;
+ }
+
+ const documentError = onSave({
+ id: entity.id,
+ label: trimmedLabel,
+ symbol: trimmedSymbol,
+ });
+ if (documentError) {
+ setError(documentError);
+ return;
+ }
+ onClose();
+ };
+
+ return (
+
+
+
+
+
+ Edit entity {entity.label}
+
+
+ Change this entity's explicit label or symbol. Its numeric ID
+ stays {entity.id}.
+
+
+
+
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function SetEditModal({
+ set,
+ onClose,
+ onSave,
+}: {
+ readonly set: DrillSet;
+ readonly onClose: () => void;
+ readonly onSave: (set: DrillSet) => string | undefined;
+}) {
+ const [number, setNumber] = React.useState(String(set.number));
+ const [suffix, setSuffix] = React.useState(set.suffix ?? "");
+ const [counts, setCounts] = React.useState(String(set.countsFromPrevious));
+ const [measureStart, setMeasureStart] = React.useState(
+ set.measureRange ? String(set.measureRange.start) : "",
+ );
+ const [measureEnd, setMeasureEnd] = React.useState(
+ set.measureRange ? String(set.measureRange.end) : "",
+ );
+ const [error, setError] = React.useState();
+
+ const save = () => {
+ const parsedNumber = parseNonNegativeInteger(number);
+ if (parsedNumber === undefined) {
+ setError("Set number must be a non-negative whole number.");
+ return;
+ }
+ const parsedCounts = parseNonNegativeInteger(counts);
+ if (parsedCounts === undefined) {
+ setError("Counts from previous must be a non-negative whole number.");
+ return;
+ }
+
+ const trimmedSuffix = suffix.trim();
+ const hasMeasureStart = measureStart.trim().length > 0;
+ const hasMeasureEnd = measureEnd.trim().length > 0;
+ if (hasMeasureStart !== hasMeasureEnd) {
+ setError(
+ "Enter both measure start and measure end, or leave both blank.",
+ );
+ return;
+ }
+
+ let measureRange: DrillSet["measureRange"];
+ if (hasMeasureStart && hasMeasureEnd) {
+ const start = parseNonNegativeInteger(measureStart);
+ const end = parseNonNegativeInteger(measureEnd);
+ if (start === undefined || end === undefined) {
+ setError("Measure numbers must be non-negative whole numbers.");
+ return;
+ }
+ measureRange = { start, end };
+ }
+
+ const candidate = {
+ id: set.id,
+ number: parsedNumber,
+ ...(trimmedSuffix ? { suffix: trimmedSuffix } : {}),
+ kind: trimmedSuffix ? ("subset" as const) : ("set" as const),
+ countsFromPrevious: parsedCounts,
+ ...(measureRange ? { measureRange } : {}),
+ };
+ const parsed = drillSetSchema.safeParse(candidate);
+ if (!parsed.success) {
+ setError(
+ parsed.error.issues[0]?.message ?? "The set values are invalid.",
+ );
+ return;
+ }
+
+ const documentError = onSave(parsed.data);
+ if (documentError) {
+ setError(documentError);
+ return;
+ }
+ onClose();
+ };
+
+ return (
+
+
+
+
+
+ Edit set {formatSetName(set)}
+
+
+ Set identity uses a numeric number plus an optional suffix. A
+ blank suffix is a primary set; a capital letter or decimal suffix
+ such as A or .5 makes it a subset.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function parseNonNegativeInteger(value: string): number | undefined {
+ const trimmed = value.trim();
+ if (!/^(?:0|[1-9][0-9]*)$/.test(trimmed)) return undefined;
+ const parsed = Number(trimmed);
+ return Number.isSafeInteger(parsed) ? parsed : undefined;
+}
+
+function DiagnosticBox({
+ title,
+ messages,
+ tone,
+}: {
+ readonly title: string;
+ readonly messages: readonly string[];
+ readonly tone: "error" | "warning";
+}) {
+ const foreground = tone === "error" ? colors.danger : colors.warning;
+ const background = tone === "error" ? colors.dangerSoft : colors.warningSoft;
+ return (
+
+
+ {title}
+
+ {messages.map((message, index) => (
+
+ • {message}
+
+ ))}
+
+ );
+}
diff --git a/apps/drill-converter/src/converter-screen.tsx b/apps/drill-converter/src/converter-screen.tsx
new file mode 100644
index 00000000..925c8296
--- /dev/null
+++ b/apps/drill-converter/src/converter-screen.tsx
@@ -0,0 +1,135 @@
+import React from "react";
+import { ScrollView, Text, View, useWindowDimensions } from "react-native";
+
+import { DetailsSection } from "./components/details-section";
+import { EntitySettingsSection } from "./components/entity-settings-section";
+import { FileSection } from "./components/file-section";
+import { PreviewSection } from "./components/preview-section";
+import { useConverterController } from "./converter/use-converter-controller";
+import { colors, spacing } from "./ui/theme";
+
+export function ConverterScreen() {
+ const controller = useConverterController();
+ const { addLabelOverride } = controller;
+ const scrollViewRef = React.useRef(null);
+ const [rulesSectionY, setRulesSectionY] = React.useState(0);
+ const [rulesFocusRequestKey, setRulesFocusRequestKey] = React.useState(0);
+ const { width } = useWindowDimensions();
+
+ const addEntityLabelRule = React.useCallback(
+ (label: string) => {
+ addLabelOverride(label);
+ setRulesFocusRequestKey((key) => key + 1);
+ requestAnimationFrame(() => {
+ scrollViewRef.current?.scrollTo({
+ y: Math.max(0, rulesSectionY - spacing.md),
+ animated: true,
+ });
+ });
+ },
+ [addLabelOverride, rulesSectionY],
+ );
+
+ return (
+
+
+
+
+ Coordinate Sheet Converter
+
+
+ Convert Pyware-style coordinate-sheet PDFs into the portable
+ Eight2Five drill JSON schema. Sets, counts, measures, performers,
+ and marching-grid positions are extracted locally in your browser.
+
+
+
+ void controller.pickPdf()}
+ onClear={controller.clearPdf}
+ />
+
+
+
+ setRulesSectionY(event.nativeEvent.layout.y)}
+ >
+
+
+
+
+
+
+
+ Eight2Five drill schema · No account, backend, database, analytics,
+ or PDF upload.
+
+
+ The current parser expects PDFs with extractable text. OCR and
+ image-only coordinate sheets are intentionally out of scope.
+
+
+
+
+ );
+}
diff --git a/apps/drill-converter/src/converter/__tests__/settings.test.ts b/apps/drill-converter/src/converter/__tests__/settings.test.ts
new file mode 100644
index 00000000..a7fc9be5
--- /dev/null
+++ b/apps/drill-converter/src/converter/__tests__/settings.test.ts
@@ -0,0 +1,273 @@
+import {
+ COLOR_PRESETS,
+ DRILL_SCHEMA_VERSION,
+ FIELD_PRESET_IDS,
+ parseDrillDocument,
+ resolveDrillEntity,
+ type DrillDocument,
+} from "@eight2five/drill-schema";
+
+import {
+ applyConverterSettings,
+ createDefaultConverterSettings,
+ createEmptyRuleDraft,
+ downloadFileName,
+ FIELD_PRESET_OPTIONS,
+ inferTitleFromFileName,
+ validateConverterSettings,
+} from "../settings";
+
+const source: DrillDocument = parseDrillDocument({
+ schema: "https://eight2five.com/schema/drill",
+ schemaVersion: DRILL_SCHEMA_VERSION,
+ metadata: {
+ title: "Imported",
+ createdAt: "2026-08-02T18:00:00.000Z",
+ },
+ field: { type: "preset", preset: "football-nfhs" },
+ entities: [
+ { id: 1, type: "performer", symbol: "B", label: "B1" },
+ { id: 2, type: "performer", symbol: "X", label: "P2" },
+ ],
+ sets: [
+ { id: 0, number: 1, kind: "set", countsFromPrevious: 0 },
+ { id: 1, number: 2, kind: "set", countsFromPrevious: 8 },
+ ],
+ positions: [
+ { entityId: 1, setId: 0, xSteps: 0, ySteps: 0 },
+ { entityId: 1, setId: 1, xSteps: 8, ySteps: 8 },
+ { entityId: 2, setId: 0, xSteps: 0, ySteps: 4 },
+ { entityId: 2, setId: 1, xSteps: 4, ySteps: 8 },
+ ],
+ provenance: {
+ source: { kind: "coordinate-sheet-pdf", fileName: "coords.pdf" },
+ references: [
+ {
+ target: { type: "position", entityId: 1, setId: 0 },
+ page: 1,
+ rawText: "Set 1 row",
+ },
+ ],
+ },
+});
+
+describe("drill converter settings", () => {
+ test("keeps the default UI minimal and validates NFHS settings", () => {
+ const settings = { ...createDefaultConverterSettings(), title: "Part 4" };
+ expect(validateConverterSettings(settings)).toMatchObject({
+ field: { type: "preset", preset: "football-nfhs" },
+ errors: [],
+ });
+ });
+
+ test("exposes every schema field preset in schema order", () => {
+ expect(FIELD_PRESET_OPTIONS.map(({ value }) => value)).toEqual(
+ FIELD_PRESET_IDS,
+ );
+ });
+
+ test("applies metadata, props, rules, and optional explicit straight paths", () => {
+ const symbolRule = {
+ ...createEmptyRuleDraft("rule-1"),
+ key: "B",
+ instrument: "Baritone",
+ color: COLOR_PRESETS.blue,
+ };
+ const labelRule = {
+ ...createEmptyRuleDraft("rule-2"),
+ target: "label" as const,
+ key: "B1",
+ name: "Lead Baritone",
+ color: COLOR_PRESETS.green,
+ labelVisibility: "hidden" as const,
+ };
+ const propRule = {
+ ...createEmptyRuleDraft("rule-3"),
+ key: "X",
+ entityType: "prop" as const,
+ sizeLength: "45",
+ sizeWidth: "22.5",
+ sizeUnit: "inches" as const,
+ };
+ const settings = {
+ ...createDefaultConverterSettings(),
+ title: "Part 4",
+ drillWriter: "Writer",
+ ensemble: "UHS",
+ description: "Final movement",
+ lucideIcon: "music-2",
+ rules: [symbolRule, labelRule, propRule],
+ setOverrides: [
+ {
+ ...source.sets[1],
+ number: 3,
+ countsFromPrevious: 12,
+ measureRange: { start: 10, end: 12 },
+ },
+ ],
+ includeSourceReferences: false,
+ explicitStraightPaths: true,
+ };
+ const validation = validateConverterSettings(settings);
+ expect(validation.errors).toEqual([]);
+
+ const result = applyConverterSettings(source, settings, validation);
+ expect(result.metadata).toMatchObject({
+ title: "Part 4",
+ drillWriter: "Writer",
+ ensemble: "UHS",
+ description: "Final movement",
+ lucideIcon: "music-2",
+ });
+ expect(result.entities[1].type).toBe("prop");
+ expect(
+ resolveDrillEntity(result.entities[1], result.entityRules),
+ ).toMatchObject({
+ size: { length: 45, width: 22.5, unit: "inches" },
+ });
+ expect(result.sets[1]).toMatchObject({
+ number: 3,
+ countsFromPrevious: 12,
+ measureRange: { start: 10, end: 12 },
+ });
+ expect(result.provenance?.references).toBeUndefined();
+ expect(result.paths).toHaveLength(2);
+ expect(
+ resolveDrillEntity(result.entities[0], result.entityRules),
+ ).toMatchObject({
+ name: "Lead Baritone",
+ instrument: "Baritone",
+ appearance: { color: COLOR_PRESETS.green, labelVisible: false },
+ });
+ });
+
+ test("applies entity label and symbol edits before resolving rules", () => {
+ const settings = {
+ ...createDefaultConverterSettings(),
+ title: "Part 4",
+ entityOverrides: [{ id: 1, label: "T1", symbol: "T" }],
+ rules: [
+ {
+ ...createEmptyRuleDraft("edited-label-rule"),
+ target: "label" as const,
+ key: "T1",
+ name: "Edited Entity",
+ },
+ ],
+ };
+ const validation = validateConverterSettings(settings);
+ expect(validation.errors).toEqual([]);
+
+ const result = applyConverterSettings(source, settings, validation);
+ expect(result.entities[0]).toMatchObject({
+ id: 1,
+ label: "T1",
+ symbol: "T",
+ });
+ expect(
+ resolveDrillEntity(result.entities[0], result.entityRules),
+ ).toMatchObject({ name: "Edited Entity" });
+ });
+
+ test("rejects invalid entity identity overrides", () => {
+ const settings = {
+ ...createDefaultConverterSettings(),
+ title: "Part 4",
+ entityOverrides: [{ id: 1, label: " ", symbol: "ABCDEFGHIJKLMNOPQ" }],
+ };
+ expect(validateConverterSettings(settings).errors).toEqual(
+ expect.arrayContaining([
+ expect.stringContaining("label cannot be empty"),
+ expect.stringContaining("symbol must be 1-16 characters"),
+ ]),
+ );
+ });
+
+ test("defaults prop rules to 1 by 1 8-to-5 steps", () => {
+ const rule = {
+ ...createEmptyRuleDraft("prop-rule"),
+ key: "X",
+ entityType: "prop" as const,
+ };
+ const settings = {
+ ...createDefaultConverterSettings(),
+ title: "Part 4",
+ rules: [rule],
+ };
+ const validation = validateConverterSettings(settings);
+ expect(validation.errors).toEqual([]);
+ expect(validation.entityRules?.bySymbol?.X?.size).toEqual({
+ length: 1,
+ width: 1,
+ unit: "8-to-5-steps",
+ });
+ });
+
+ test("rejects invalid prop sizes", () => {
+ const settings = {
+ ...createDefaultConverterSettings(),
+ title: "Part 4",
+ rules: [
+ {
+ ...createEmptyRuleDraft("prop-rule"),
+ key: "X",
+ entityType: "prop" as const,
+ sizeLength: "0",
+ },
+ ],
+ };
+ expect(validateConverterSettings(settings).errors).toEqual(
+ expect.arrayContaining([
+ expect.stringContaining("prop length must be greater than zero"),
+ ]),
+ );
+ });
+
+ test("rejects prop rules that define performer-only fields", () => {
+ const settings = {
+ ...createDefaultConverterSettings(),
+ title: "Part 4",
+ rules: [
+ {
+ ...createEmptyRuleDraft("prop-rule"),
+ key: "X",
+ entityType: "prop" as const,
+ section: "Guard",
+ },
+ ],
+ };
+ expect(validateConverterSettings(settings).errors).toEqual(
+ expect.arrayContaining([
+ expect.stringContaining("props cannot define section or instrument"),
+ ]),
+ );
+ });
+
+ test("rejects malformed custom fields and duplicate rule targets", () => {
+ const settings = {
+ ...createDefaultConverterSettings(),
+ title: "Part 4",
+ fieldMode: "custom" as const,
+ customFieldJson: "{bad json",
+ rules: [
+ { ...createEmptyRuleDraft("1"), key: "B", instrument: "Baritone" },
+ { ...createEmptyRuleDraft("2"), key: "B", instrument: "Trombone" },
+ ],
+ };
+ expect(validateConverterSettings(settings).errors).toEqual(
+ expect.arrayContaining([
+ expect.stringContaining("Custom field JSON is invalid"),
+ expect.stringContaining("Duplicate symbol rule"),
+ ]),
+ );
+ });
+
+ test("creates stable human-readable output names", () => {
+ expect(inferTitleFromFileName("2026_UHS-Part-4_COORDINATES.pdf")).toBe(
+ "2026 UHS Part 4 COORDINATES",
+ );
+ expect(downloadFileName("Part 4 / Finale!")).toBe(
+ "part-4-finale.eight2five.json",
+ );
+ });
+});
diff --git a/apps/drill-converter/src/converter/download.web.ts b/apps/drill-converter/src/converter/download.web.ts
new file mode 100644
index 00000000..25837b7c
--- /dev/null
+++ b/apps/drill-converter/src/converter/download.web.ts
@@ -0,0 +1,23 @@
+export function downloadTextFile(
+ contents: string,
+ fileName: string,
+ mimeType = "application/json;charset=utf-8",
+): void {
+ if (typeof window === "undefined") {
+ throw new Error("Downloads are available only in the browser.");
+ }
+ const blob = new Blob([contents], { type: mimeType });
+ const url = URL.createObjectURL(blob);
+ const anchor = window.document.createElement("a");
+ try {
+ anchor.href = url;
+ anchor.download = fileName;
+ anchor.rel = "noopener";
+ anchor.style.display = "none";
+ window.document.body.appendChild(anchor);
+ anchor.click();
+ } finally {
+ anchor.remove();
+ URL.revokeObjectURL(url);
+ }
+}
diff --git a/apps/drill-converter/src/converter/settings.ts b/apps/drill-converter/src/converter/settings.ts
new file mode 100644
index 00000000..3be907ee
--- /dev/null
+++ b/apps/drill-converter/src/converter/settings.ts
@@ -0,0 +1,477 @@
+import {
+ COLOR_PRESETS,
+ FIELD_PRESET_IDS,
+ countPrimarySets,
+ drillSetSchema,
+ fieldDefinitionSchema,
+ getFieldPreset,
+ parseDrillDocument,
+ resolveEntityRuleValues,
+ type DrillDocument,
+ type DrillEntity,
+ type DrillEntityType,
+ type DrillPath,
+ type DrillSet,
+ type EntityIcon,
+ type EntityRuleValues,
+ type EntityRules,
+ type FieldDefinition,
+ type FieldPresetId,
+ type PropSizeUnit,
+} from "@eight2five/drill-schema";
+
+export const FIELD_PRESET_OPTIONS = Object.freeze(
+ FIELD_PRESET_IDS.map((value) => ({
+ value,
+ label: getFieldPreset(value).name,
+ })) satisfies readonly {
+ value: FieldPresetId;
+ label: string;
+ }[],
+);
+
+export const ENTITY_ICON_OPTIONS = Object.freeze([
+ "dot",
+ "square",
+ "triangle",
+ "diamond",
+ "star",
+ "hexagon",
+ "cross",
+] as const satisfies readonly EntityIcon[]);
+
+export const COLOR_PRESET_OPTIONS = Object.freeze([
+ { label: "Grey", value: COLOR_PRESETS.grey },
+ { label: "Red", value: COLOR_PRESETS.red },
+ { label: "Orange", value: COLOR_PRESETS.orange },
+ { label: "Yellow", value: COLOR_PRESETS.yellow },
+ { label: "Green", value: COLOR_PRESETS.green },
+ { label: "Light blue", value: COLOR_PRESETS.lightBlue },
+ { label: "Blue", value: COLOR_PRESETS.blue },
+ { label: "Dark blue", value: COLOR_PRESETS.darkBlue },
+ { label: "Purple", value: COLOR_PRESETS.purple },
+ { label: "Pink", value: COLOR_PRESETS.pink },
+ { label: "Black", value: COLOR_PRESETS.black },
+] as const);
+
+export type RuleTarget = "symbol" | "label" | "id";
+export type LabelVisibility = "inherit" | "visible" | "hidden";
+
+export interface EntityRuleDraft {
+ readonly id: string;
+ readonly target: RuleTarget;
+ readonly key: string;
+ readonly entityType: "" | DrillEntityType;
+ readonly name: string;
+ readonly section: string;
+ readonly instrument: string;
+ readonly sizeLength: string;
+ readonly sizeWidth: string;
+ readonly sizeUnit: PropSizeUnit;
+ readonly icon: "" | EntityIcon;
+ readonly color: string;
+ readonly labelVisibility: LabelVisibility;
+}
+
+export type EntityIdentityOverride = Pick<
+ DrillEntity,
+ "id" | "label" | "symbol"
+>;
+
+export interface ConverterSettings {
+ readonly title: string;
+ readonly drillWriter: string;
+ readonly ensemble: string;
+ readonly description: string;
+ readonly lucideIcon: string;
+ readonly fieldMode: FieldPresetId | "custom";
+ readonly customFieldJson: string;
+ readonly rules: readonly EntityRuleDraft[];
+ readonly entityOverrides: readonly EntityIdentityOverride[];
+ readonly setOverrides: readonly DrillSet[];
+ readonly includeSourceReferences: boolean;
+ readonly explicitStraightPaths: boolean;
+}
+
+export interface ConverterSettingsValidation {
+ readonly field?: FieldDefinition;
+ readonly entityRules?: EntityRules;
+ readonly errors: readonly string[];
+}
+
+export function createDefaultConverterSettings(): ConverterSettings {
+ return {
+ title: "",
+ drillWriter: "",
+ ensemble: "",
+ description: "",
+ lucideIcon: "",
+ fieldMode: "football-nfhs",
+ customFieldJson: createDefaultCustomFieldJson(),
+ rules: [],
+ entityOverrides: [],
+ setOverrides: [],
+ includeSourceReferences: true,
+ explicitStraightPaths: false,
+ };
+}
+
+export function createDefaultCustomFieldJson(): string {
+ const preset = getFieldPreset("football-nfhs");
+ return JSON.stringify(
+ {
+ type: "custom",
+ name: "Custom Football Field",
+ physicalGeometry: preset.physicalGeometry,
+ marchingGrid: preset.marchingGrid,
+ markings: preset.markings,
+ } satisfies FieldDefinition,
+ null,
+ 2,
+ );
+}
+
+export function createEmptyRuleDraft(id: string): EntityRuleDraft {
+ return {
+ id,
+ target: "symbol",
+ key: "",
+ entityType: "",
+ name: "",
+ section: "",
+ instrument: "",
+ sizeLength: "1",
+ sizeWidth: "1",
+ sizeUnit: "8-to-5-steps",
+ icon: "",
+ color: "",
+ labelVisibility: "inherit",
+ };
+}
+
+export function validateConverterSettings(
+ settings: ConverterSettings,
+): ConverterSettingsValidation {
+ const errors: string[] = [];
+ const title = settings.title.trim();
+ if (!title) errors.push("A drill title is required.");
+
+ let field: FieldDefinition | undefined;
+ if (settings.fieldMode === "custom") {
+ try {
+ const parsedJson = JSON.parse(settings.customFieldJson) as unknown;
+ const parsed = fieldDefinitionSchema.safeParse(parsedJson);
+ if (!parsed.success || parsed.data.type !== "custom") {
+ errors.push(
+ parsed.success
+ ? 'Custom field JSON must have type "custom".'
+ : `Custom field is invalid: ${
+ parsed.error.issues[0]?.message ?? "unknown validation error"
+ }`,
+ );
+ } else {
+ field = parsed.data;
+ }
+ } catch (cause) {
+ errors.push(
+ cause instanceof Error
+ ? `Custom field JSON is invalid: ${cause.message}`
+ : "Custom field JSON is invalid.",
+ );
+ }
+ } else {
+ field = { type: "preset", preset: settings.fieldMode };
+ }
+
+ const entityRules = buildEntityRules(settings.rules, errors);
+ const seenEntityOverrideIds = new Set();
+ for (const [index, override] of settings.entityOverrides.entries()) {
+ if (seenEntityOverrideIds.has(override.id)) {
+ errors.push(`Duplicate entity override for entity id ${override.id}.`);
+ continue;
+ }
+ seenEntityOverrideIds.add(override.id);
+ if (!Number.isSafeInteger(override.id) || override.id < 0) {
+ errors.push(`Entity override ${index + 1} has an invalid entity id.`);
+ }
+ if (!override.label.trim()) {
+ errors.push(`Entity override ${index + 1} label cannot be empty.`);
+ }
+ const symbol = override.symbol.trim();
+ if (!symbol || symbol.length > 16) {
+ errors.push(
+ `Entity override ${index + 1} symbol must be 1-16 characters.`,
+ );
+ }
+ }
+
+ const seenSetOverrideIds = new Set();
+ for (const [index, set] of settings.setOverrides.entries()) {
+ if (seenSetOverrideIds.has(set.id)) {
+ errors.push(`Duplicate set override for set id ${set.id}.`);
+ continue;
+ }
+ seenSetOverrideIds.add(set.id);
+ const parsed = drillSetSchema.safeParse(set);
+ if (!parsed.success) {
+ errors.push(
+ `Set override ${index + 1} is invalid: ${
+ parsed.error.issues[0]?.message ?? "unknown validation error"
+ }`,
+ );
+ }
+ }
+
+ return {
+ ...(field ? { field } : {}),
+ ...(entityRules ? { entityRules } : {}),
+ errors,
+ };
+}
+
+export function applyConverterSettings(
+ source: DrillDocument,
+ settings: ConverterSettings,
+ validation = validateConverterSettings(settings),
+): DrillDocument {
+ if (validation.errors.length > 0 || !validation.field) {
+ throw new Error(validation.errors[0] ?? "Converter settings are invalid.");
+ }
+
+ const entityOverrides = new Map(
+ settings.entityOverrides.map(
+ (override) => [override.id, override] as const,
+ ),
+ );
+ const entities = source.entities.map((entity) => {
+ const identityOverride = entityOverrides.get(entity.id);
+ const entityWithIdentity = identityOverride
+ ? {
+ ...entity,
+ label: identityOverride.label.trim(),
+ symbol: identityOverride.symbol.trim(),
+ }
+ : entity;
+ const ruleValues = resolveEntityRuleValues(
+ entityWithIdentity,
+ validation.entityRules,
+ );
+ const type = ruleValues.type ?? entityWithIdentity.type;
+ if (type === "prop") {
+ const {
+ section: _section,
+ instrument: _instrument,
+ ...rest
+ } = entityWithIdentity;
+ return { ...rest, type };
+ }
+ const { size: _size, ...rest } = entityWithIdentity;
+ return { ...rest, type };
+ });
+ const setOverrides = new Map(
+ settings.setOverrides.map((set) => [set.id, set] as const),
+ );
+ const sets = source.sets.map((set) => setOverrides.get(set.id) ?? set);
+ const paths = settings.explicitStraightPaths
+ ? createStraightPaths({ ...source, sets })
+ : source.paths;
+ const provenance = source.provenance
+ ? {
+ ...source.provenance,
+ ...(settings.includeSourceReferences ? {} : { references: undefined }),
+ }
+ : undefined;
+
+ return parseDrillDocument({
+ ...source,
+ metadata: {
+ title: settings.title.trim(),
+ createdAt: source.metadata.createdAt,
+ ...optionalText("drillWriter", settings.drillWriter),
+ ...optionalText("ensemble", settings.ensemble),
+ ...(settings.description.trim()
+ ? { description: settings.description.trim() }
+ : {}),
+ ...optionalText("lucideIcon", settings.lucideIcon),
+ },
+ field: validation.field,
+ ...(validation.entityRules && hasRules(validation.entityRules)
+ ? { entityRules: validation.entityRules }
+ : { entityRules: undefined }),
+ entities,
+ sets,
+ ...(paths && paths.length > 0 ? { paths } : { paths: undefined }),
+ ...(provenance ? { provenance } : { provenance: undefined }),
+ });
+}
+
+export function getDocumentSummary(document: DrillDocument) {
+ return {
+ performers: document.entities.filter(
+ (entity) => entity.type === "performer",
+ ).length,
+ props: document.entities.filter((entity) => entity.type === "prop").length,
+ primarySets: countPrimarySets(document.sets),
+ setEntries: document.sets.length,
+ positions: document.positions.length,
+ };
+}
+
+export function inferTitleFromFileName(fileName: string): string {
+ return fileName
+ .replace(/\.pdf$/i, "")
+ .replace(/[_-]+/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+export function downloadFileName(title: string): string {
+ const slug = title
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 80);
+ return `${slug || "drill"}.eight2five.json`;
+}
+
+function buildEntityRules(
+ drafts: readonly EntityRuleDraft[],
+ errors: string[],
+): EntityRules | undefined {
+ const bySymbol: Record = {};
+ const byLabel: Record = {};
+ const byId: Record = {};
+ const seen = new Set();
+
+ for (const [index, draft] of drafts.entries()) {
+ const key = draft.key.trim();
+ if (!key) {
+ errors.push(`Rule ${index + 1} needs a ${draft.target} key.`);
+ continue;
+ }
+ if (draft.target === "id") {
+ const parsed = Number(key);
+ if (!/^(?:0|[1-9][0-9]*)$/.test(key) || !Number.isSafeInteger(parsed)) {
+ errors.push(
+ `Rule ${index + 1} ID must be a non-negative safe integer.`,
+ );
+ continue;
+ }
+ }
+ if (draft.target === "symbol" && (key.length > 16 || !key.trim())) {
+ errors.push(`Rule ${index + 1} symbol must be 1-16 characters.`);
+ continue;
+ }
+ const identity = `${draft.target}:${key}`;
+ if (seen.has(identity)) {
+ errors.push(`Duplicate ${draft.target} rule for ${key}.`);
+ continue;
+ }
+ seen.add(identity);
+
+ const color = draft.color.trim();
+ if (color && !/^#[0-9A-Fa-f]{6}$/.test(color)) {
+ errors.push(`Rule ${index + 1} color must be a six-digit hex value.`);
+ continue;
+ }
+
+ if (
+ draft.entityType === "prop" &&
+ (draft.section.trim() || draft.instrument.trim())
+ ) {
+ errors.push(
+ `Rule ${index + 1} props cannot define section or instrument.`,
+ );
+ continue;
+ }
+
+ let size: EntityRuleValues["size"];
+ if (draft.entityType === "prop") {
+ const length = Number(draft.sizeLength);
+ const width = Number(draft.sizeWidth);
+ if (!Number.isFinite(length) || length <= 0) {
+ errors.push(`Rule ${index + 1} prop length must be greater than zero.`);
+ continue;
+ }
+ if (!Number.isFinite(width) || width <= 0) {
+ errors.push(`Rule ${index + 1} prop width must be greater than zero.`);
+ continue;
+ }
+ size = { length, width, unit: draft.sizeUnit };
+ }
+
+ const appearance = {
+ ...(draft.icon ? { icon: draft.icon } : {}),
+ ...(color ? { color } : {}),
+ ...(draft.labelVisibility === "inherit"
+ ? {}
+ : { labelVisible: draft.labelVisibility === "visible" }),
+ };
+ const values: EntityRuleValues = {
+ ...(draft.entityType ? { type: draft.entityType } : {}),
+ ...optionalText("name", draft.name),
+ ...optionalText("section", draft.section),
+ ...optionalText("instrument", draft.instrument),
+ ...(size ? { size } : {}),
+ ...(Object.keys(appearance).length > 0 ? { appearance } : {}),
+ };
+ if (Object.keys(values).length === 0) continue;
+ if (draft.target === "symbol") bySymbol[key] = values;
+ else if (draft.target === "label") byLabel[key] = values;
+ else byId[key] = values;
+ }
+
+ const rules: EntityRules = {
+ ...(Object.keys(bySymbol).length > 0 ? { bySymbol } : {}),
+ ...(Object.keys(byLabel).length > 0 ? { byLabel } : {}),
+ ...(Object.keys(byId).length > 0 ? { byId } : {}),
+ };
+ return hasRules(rules) ? rules : undefined;
+}
+
+function createStraightPaths(document: DrillDocument): DrillPath[] {
+ const positionKeys = new Set(
+ document.positions.map(
+ (position) => `${position.entityId}|${position.setId}`,
+ ),
+ );
+ const paths: DrillPath[] = [];
+ for (const entity of document.entities) {
+ for (
+ let fromSetId = 0;
+ fromSetId < document.sets.length - 1;
+ fromSetId += 1
+ ) {
+ const toSetId = fromSetId + 1;
+ if (
+ positionKeys.has(`${entity.id}|${fromSetId}`) &&
+ positionKeys.has(`${entity.id}|${toSetId}`)
+ ) {
+ paths.push({
+ entityId: entity.id,
+ fromSetId,
+ toSetId,
+ kind: "straight",
+ });
+ }
+ }
+ }
+ return paths;
+}
+
+function optionalText(
+ key: Key,
+ value: string,
+): Record | Record {
+ const trimmed = value.trim();
+ return trimmed ? ({ [key]: trimmed } as Record) : {};
+}
+
+function hasRules(rules: EntityRules): boolean {
+ return Boolean(
+ Object.keys(rules.bySymbol ?? {}).length ||
+ Object.keys(rules.byLabel ?? {}).length ||
+ Object.keys(rules.byId ?? {}).length,
+ );
+}
diff --git a/apps/drill-converter/src/converter/use-converter-controller.ts b/apps/drill-converter/src/converter/use-converter-controller.ts
new file mode 100644
index 00000000..25aafff9
--- /dev/null
+++ b/apps/drill-converter/src/converter/use-converter-controller.ts
@@ -0,0 +1,336 @@
+import React from "react";
+import * as DocumentPicker from "expo-document-picker";
+import {
+ importCoordinateSheetPages,
+ type CoordinateSheetImportResult,
+ type ExtractedPdfPage,
+} from "@eight2five/drill-importers";
+import {
+ serializeDrillDocument,
+ type DrillDocument,
+} from "@eight2five/drill-schema";
+
+import { downloadTextFile } from "./download.web";
+import {
+ applyConverterSettings,
+ createDefaultConverterSettings,
+ createEmptyRuleDraft,
+ downloadFileName,
+ getDocumentSummary,
+ inferTitleFromFileName,
+ validateConverterSettings,
+ type ConverterSettings,
+ type EntityIdentityOverride,
+ type EntityRuleDraft,
+} from "./settings";
+import { extractPdfText, getPdfJsVersion } from "../pdf/pdf-text-extractor.web";
+
+export type ConverterPhase = "idle" | "extracting" | "ready" | "error";
+
+let nextRuleId = 1;
+
+export function useConverterController() {
+ const [settings, setSettings] = React.useState(
+ createDefaultConverterSettings,
+ );
+ const [asset, setAsset] =
+ React.useState();
+ const [extractedPages, setExtractedPages] = React.useState<
+ readonly ExtractedPdfPage[]
+ >([]);
+ const [createdAt, setCreatedAt] = React.useState(() =>
+ new Date().toISOString(),
+ );
+ const [phase, setPhase] = React.useState("idle");
+ const [extractionError, setExtractionError] = React.useState();
+
+ const settingsValidation = React.useMemo(
+ () => validateConverterSettings(settings),
+ [settings],
+ );
+
+ const importResult = React.useMemo<
+ CoordinateSheetImportResult | undefined
+ >(() => {
+ if (extractedPages.length === 0 || !settingsValidation.field)
+ return undefined;
+ return importCoordinateSheetPages(extractedPages, {
+ title: settings.title.trim() || "Imported Drill",
+ ...(asset?.name ? { fileName: asset.name } : {}),
+ createdAt,
+ field: settingsValidation.field,
+ });
+ }, [
+ asset,
+ createdAt,
+ extractedPages,
+ settings.title,
+ settingsValidation.field,
+ ]);
+
+ const outputDocument = React.useMemo(() => {
+ if (!importResult?.document || settingsValidation.errors.length > 0) {
+ return undefined;
+ }
+ try {
+ return applyConverterSettings(
+ importResult.document,
+ settings,
+ settingsValidation,
+ );
+ } catch {
+ return undefined;
+ }
+ }, [importResult, settings, settingsValidation]);
+
+ const summary = React.useMemo(
+ () => (outputDocument ? getDocumentSummary(outputDocument) : undefined),
+ [outputDocument],
+ );
+
+ const availableSymbols = React.useMemo(
+ () =>
+ Array.from(
+ new Set([
+ ...(importResult?.sheets ?? [])
+ .map((sheet) => sheet.sourceSymbol.trim())
+ .filter(Boolean),
+ ...(outputDocument?.entities ?? [])
+ .map((entity) => entity.symbol.trim())
+ .filter(Boolean),
+ ]),
+ ).sort((left, right) => left.localeCompare(right)),
+ [importResult?.sheets, outputDocument?.entities],
+ );
+
+ const pickPdf = React.useCallback(async () => {
+ const result = await DocumentPicker.getDocumentAsync({
+ type: "application/pdf",
+ multiple: false,
+ copyToCacheDirectory: false,
+ });
+ if (result.canceled || !result.assets[0]) return;
+
+ const nextAsset = result.assets[0];
+ setPhase("extracting");
+ setExtractionError(undefined);
+ setAsset(nextAsset);
+ setExtractedPages([]);
+ const nextCreatedAt = new Date().toISOString();
+ setCreatedAt(nextCreatedAt);
+ setSettings((current) => ({
+ ...current,
+ title: inferTitleFromFileName(nextAsset.name),
+ rules: [],
+ entityOverrides: [],
+ setOverrides: [],
+ }));
+
+ try {
+ const pages = await extractPdfText(nextAsset);
+ if (
+ pages.length === 0 ||
+ pages.every((page) => page.items.length === 0)
+ ) {
+ throw new Error(
+ "The PDF contains no extractable text. Scanned/image-only coordinate sheets are not currently supported.",
+ );
+ }
+ setExtractedPages(pages);
+ setPhase("ready");
+ } catch (cause) {
+ setPhase("error");
+ setExtractionError(
+ cause instanceof Error ? cause.message : "PDF text extraction failed.",
+ );
+ }
+ }, []);
+
+ const clearPdf = React.useCallback(() => {
+ setAsset(undefined);
+ setExtractedPages([]);
+ setExtractionError(undefined);
+ setPhase("idle");
+ setSettings((current) => ({
+ ...current,
+ title: "",
+ rules: [],
+ entityOverrides: [],
+ setOverrides: [],
+ }));
+ }, []);
+
+ const updateSettings = React.useCallback(
+ (patch: Partial) =>
+ setSettings((current) => ({ ...current, ...patch })),
+ [],
+ );
+
+ const addRule = React.useCallback(
+ (target: EntityRuleDraft["target"] = "symbol") => {
+ setSettings((current) => ({
+ ...current,
+ rules: [
+ ...current.rules,
+ { ...createEmptyRuleDraft(`rule-${nextRuleId++}`), target },
+ ],
+ }));
+ },
+ [],
+ );
+
+ const addLabelOverride = React.useCallback((label: string) => {
+ const key = label.trim();
+ if (!key) return;
+ setSettings((current) => {
+ const existing = current.rules.find(
+ (rule) => rule.target === "label" && rule.key.trim() === key,
+ );
+ if (existing) {
+ return {
+ ...current,
+ rules: [
+ existing,
+ ...current.rules.filter((rule) => rule.id !== existing.id),
+ ],
+ };
+ }
+ return {
+ ...current,
+ rules: [
+ {
+ ...createEmptyRuleDraft(`rule-${nextRuleId++}`),
+ target: "label",
+ key,
+ },
+ ...current.rules,
+ ],
+ };
+ });
+ }, []);
+
+ const updateRule = React.useCallback(
+ (id: string, patch: Partial>) => {
+ setSettings((current) => ({
+ ...current,
+ rules: current.rules.map((rule) => {
+ if (rule.id !== id) return rule;
+ const nextRule = { ...rule, ...patch };
+ return nextRule.entityType === "prop"
+ ? { ...nextRule, section: "", instrument: "" }
+ : nextRule;
+ }),
+ }));
+ },
+ [],
+ );
+
+ const removeRule = React.useCallback((id: string) => {
+ setSettings((current) => ({
+ ...current,
+ rules: current.rules.filter((rule) => rule.id !== id),
+ }));
+ }, []);
+
+ const updateEntityIdentity = React.useCallback(
+ (override: EntityIdentityOverride): string | undefined => {
+ if (!importResult?.document)
+ return "Parse a PDF before editing entities.";
+ if (
+ !importResult.document.entities.some(
+ (entity) => entity.id === override.id,
+ )
+ ) {
+ return `Unknown entity id ${override.id}.`;
+ }
+
+ const nextSettings: ConverterSettings = {
+ ...settings,
+ entityOverrides: [
+ ...settings.entityOverrides.filter(
+ (entity) => entity.id !== override.id,
+ ),
+ {
+ ...override,
+ label: override.label.trim(),
+ symbol: override.symbol.trim(),
+ },
+ ],
+ };
+ const validation = validateConverterSettings(nextSettings);
+ try {
+ applyConverterSettings(importResult.document, nextSettings, validation);
+ } catch (cause) {
+ return cause instanceof Error
+ ? cause.message
+ : "The entity edit is invalid.";
+ }
+ setSettings(nextSettings);
+ return undefined;
+ },
+ [importResult, settings],
+ );
+
+ const updateSet = React.useCallback(
+ (setOverride: DrillDocument["sets"][number]): string | undefined => {
+ if (!importResult?.document) return "Parse a PDF before editing sets.";
+ if (
+ !importResult.document.sets.some((set) => set.id === setOverride.id)
+ ) {
+ return `Unknown set id ${setOverride.id}.`;
+ }
+
+ const nextSettings: ConverterSettings = {
+ ...settings,
+ setOverrides: [
+ ...settings.setOverrides.filter((set) => set.id !== setOverride.id),
+ setOverride,
+ ],
+ };
+ const validation = validateConverterSettings(nextSettings);
+ try {
+ applyConverterSettings(importResult.document, nextSettings, validation);
+ } catch (cause) {
+ return cause instanceof Error
+ ? cause.message
+ : "The set edit is invalid.";
+ }
+ setSettings(nextSettings);
+ return undefined;
+ },
+ [importResult, settings],
+ );
+
+ const download = React.useCallback(() => {
+ if (!outputDocument) return;
+ downloadTextFile(
+ serializeDrillDocument(outputDocument),
+ downloadFileName(outputDocument.metadata.title),
+ );
+ }, [outputDocument]);
+
+ return {
+ phase,
+ asset,
+ extractedPages,
+ extractionError,
+ settings,
+ settingsErrors: settingsValidation.errors,
+ importResult,
+ outputDocument,
+ summary,
+ availableSymbols,
+ pdfJsVersion: getPdfJsVersion(),
+ canDownload: Boolean(outputDocument),
+ pickPdf,
+ clearPdf,
+ updateSettings,
+ addRule,
+ addLabelOverride,
+ updateRule,
+ removeRule,
+ updateEntityIdentity,
+ updateSet,
+ download,
+ };
+}
diff --git a/apps/drill-converter/src/pdf/pdf-text-extractor.web.ts b/apps/drill-converter/src/pdf/pdf-text-extractor.web.ts
new file mode 100644
index 00000000..7352bb72
--- /dev/null
+++ b/apps/drill-converter/src/pdf/pdf-text-extractor.web.ts
@@ -0,0 +1,137 @@
+import type { DocumentPickerAsset } from "expo-document-picker";
+import type {
+ ExtractedPdfPage,
+ ExtractedPdfTextItem,
+} from "@eight2five/drill-importers";
+
+const PDFJS_VERSION = "6.2.108";
+const PDFJS_MODULE_URL = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${PDFJS_VERSION}/build/pdf.min.mjs`;
+const PDFJS_WORKER_URL = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${PDFJS_VERSION}/build/pdf.worker.min.mjs`;
+
+interface PdfJsTextItem {
+ readonly str: string;
+ readonly transform: readonly number[];
+ readonly width?: number;
+ readonly height?: number;
+}
+
+interface PdfJsTextContent {
+ readonly items: readonly (PdfJsTextItem | Record)[];
+}
+
+interface PdfJsPage {
+ readonly view: readonly number[];
+ getTextContent(): Promise;
+}
+
+interface PdfJsDocument {
+ readonly numPages: number;
+ getPage(pageNumber: number): Promise;
+}
+
+interface PdfJsLoadingTask {
+ readonly promise: Promise;
+ destroy(): Promise;
+}
+
+interface PdfJsModule {
+ readonly GlobalWorkerOptions: { workerSrc: string };
+ getDocument(source: { data: Uint8Array }): PdfJsLoadingTask;
+}
+
+let pdfJsPromise: Promise | undefined;
+
+/**
+ * Load PDF.js only in the browser. The pinned library bundle is fetched from
+ * jsDelivr, while the selected PDF bytes remain local to the user's browser.
+ */
+async function loadPdfJs(): Promise {
+ if (typeof window === "undefined") {
+ throw new Error("PDF extraction is available only in the browser.");
+ }
+ if (!pdfJsPromise) {
+ pdfJsPromise = (async () => {
+ // Metro cannot statically bundle an https: ESM import. Using the native
+ // browser importer keeps the converter package dependency-free while
+ // still pinning PDF.js to an exact version.
+ const importModule = new Function("url", "return import(url);") as (
+ url: string,
+ ) => Promise;
+ const pdfjs = await importModule(PDFJS_MODULE_URL);
+ pdfjs.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL;
+ return pdfjs;
+ })();
+ }
+ return await pdfJsPromise;
+}
+
+export async function extractPdfText(
+ asset: DocumentPickerAsset,
+): Promise {
+ const bytes = new Uint8Array(await readAssetBytes(asset));
+ const pdfjs = await loadPdfJs();
+ const loadingTask = pdfjs.getDocument({ data: bytes });
+ const document = await loadingTask.promise;
+ try {
+ const pages: ExtractedPdfPage[] = [];
+ for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) {
+ const page = await document.getPage(pageNumber);
+ const textContent = await page.getTextContent();
+ const items = textContent.items
+ .filter(isTextItem)
+ .map(toExtractedTextItem)
+ .filter((item) => item.text.trim().length > 0);
+ pages.push({
+ pageNumber,
+ width: Math.abs((page.view[2] ?? 0) - (page.view[0] ?? 0)),
+ height: Math.abs((page.view[3] ?? 0) - (page.view[1] ?? 0)),
+ items,
+ });
+ }
+ return pages;
+ } finally {
+ await loadingTask.destroy();
+ }
+}
+
+export function getPdfJsVersion(): string {
+ return PDFJS_VERSION;
+}
+
+async function readAssetBytes(
+ asset: DocumentPickerAsset,
+): Promise {
+ if (asset.file) return await asset.file.arrayBuffer();
+ const response = await fetch(asset.uri);
+ if (!response.ok) {
+ throw new Error(`Unable to read ${asset.name}: ${response.statusText}.`);
+ }
+ return await response.arrayBuffer();
+}
+
+function isTextItem(
+ value: PdfJsTextItem | Record,
+): value is PdfJsTextItem {
+ return (
+ typeof (value as { str?: unknown }).str === "string" &&
+ Array.isArray((value as { transform?: unknown }).transform)
+ );
+}
+
+function toExtractedTextItem(item: PdfJsTextItem): ExtractedPdfTextItem {
+ return {
+ text: item.str,
+ x: finiteCoordinate(item.transform[4]),
+ y: finiteCoordinate(item.transform[5]),
+ ...(typeof item.width === "number" && Number.isFinite(item.width)
+ ? { width: item.width }
+ : {}),
+ ...(typeof item.height === "number" && Number.isFinite(item.height)
+ ? { height: item.height }
+ : {}),
+ };
+}
+
+function finiteCoordinate(value: number | undefined): number {
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
+}
diff --git a/apps/drill-converter/src/ui/form-controls.tsx b/apps/drill-converter/src/ui/form-controls.tsx
new file mode 100644
index 00000000..c5a85ef1
--- /dev/null
+++ b/apps/drill-converter/src/ui/form-controls.tsx
@@ -0,0 +1,384 @@
+import React from "react";
+import {
+ Pressable,
+ Text,
+ TextInput,
+ View,
+ type TextInputProps,
+} from "react-native";
+
+import { colors, radius, spacing } from "./theme";
+
+export function FormField({
+ label,
+ helper,
+ value,
+ onChangeText,
+ multiline = false,
+ placeholder,
+ error,
+ ...props
+}: {
+ readonly label: string;
+ readonly helper?: string;
+ readonly value: string;
+ readonly onChangeText: (value: string) => void;
+ readonly multiline?: boolean;
+ readonly placeholder?: string;
+ readonly error?: string;
+} & Omit<
+ TextInputProps,
+ "value" | "onChangeText" | "multiline" | "placeholder"
+>) {
+ return (
+
+
+ {label}
+
+
+ {helper ? (
+
+ {helper}
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+}
+
+export function ChoiceChips({
+ label,
+ value,
+ options,
+ onChange,
+}: {
+ readonly label?: string;
+ readonly value: Value;
+ readonly options: readonly {
+ readonly value: Value;
+ readonly label: string;
+ }[];
+ readonly onChange: (value: Value) => void;
+}) {
+ return (
+
+ {label ? (
+
+ {label}
+
+ ) : null}
+
+ {options.map((option) => {
+ const selected = option.value === value;
+ return (
+ onChange(option.value)}
+ accessibilityRole="radio"
+ accessibilityState={{ checked: selected }}
+ style={({ pressed }) => ({
+ minHeight: 40,
+ justifyContent: "center",
+ borderWidth: 1,
+ borderColor: selected ? colors.accent : colors.borderStrong,
+ borderRadius: 999,
+ paddingHorizontal: 14,
+ backgroundColor: selected
+ ? colors.accentSoft
+ : pressed
+ ? colors.surfaceMuted
+ : colors.surface,
+ })}
+ >
+
+ {option.label}
+
+
+ );
+ })}
+
+
+ );
+}
+
+export function ToggleRow({
+ title,
+ description,
+ value,
+ onChange,
+}: {
+ readonly title: string;
+ readonly description?: string;
+ readonly value: boolean;
+ readonly onChange: (value: boolean) => void;
+}) {
+ return (
+ onChange(!value)}
+ accessibilityRole="checkbox"
+ accessibilityState={{ checked: value }}
+ style={({ pressed }) => ({
+ flexDirection: "row",
+ alignItems: "flex-start",
+ gap: spacing.md,
+ paddingVertical: spacing.sm,
+ opacity: pressed ? 0.78 : 1,
+ })}
+ >
+
+ {value ? (
+
+ ✓
+
+ ) : null}
+
+
+
+ {title}
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+
+ );
+}
+
+export function SectionCard({
+ title,
+ description,
+ children,
+}: {
+ readonly title: string;
+ readonly description?: string;
+ readonly children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {title}
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+ {children}
+
+ );
+}
+
+export function Disclosure({
+ title,
+ description,
+ initiallyOpen = false,
+ children,
+}: {
+ readonly title: string;
+ readonly description?: string;
+ readonly initiallyOpen?: boolean;
+ readonly children: React.ReactNode;
+}) {
+ const [open, setOpen] = React.useState(initiallyOpen);
+ return (
+
+ setOpen((value) => !value)}
+ accessibilityRole="button"
+ accessibilityState={{ expanded: open }}
+ style={({ pressed }) => ({
+ padding: spacing.lg,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: spacing.md,
+ backgroundColor: pressed ? colors.surfaceMuted : colors.surface,
+ })}
+ >
+
+
+ {title}
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+
+ {open ? "−" : "+"}
+
+
+ {open ? (
+
+ {children}
+
+ ) : null}
+
+ );
+}
+
+export function PrimaryButton({
+ label,
+ onPress,
+ disabled = false,
+}: {
+ readonly label: string;
+ readonly onPress: () => void;
+ readonly disabled?: boolean;
+}) {
+ return (
+ ({
+ minHeight: 44,
+ alignItems: "center",
+ justifyContent: "center",
+ borderRadius: radius.sm,
+ paddingHorizontal: 16,
+ backgroundColor: disabled
+ ? "#aab8cf"
+ : pressed
+ ? "#315ea9"
+ : colors.accent,
+ })}
+ >
+
+ {label}
+
+
+ );
+}
+
+export function SecondaryButton({
+ label,
+ onPress,
+ disabled = false,
+ danger = false,
+}: {
+ readonly label: string;
+ readonly onPress: () => void;
+ readonly disabled?: boolean;
+ readonly danger?: boolean;
+}) {
+ return (
+ ({
+ minHeight: 42,
+ alignItems: "center",
+ justifyContent: "center",
+ borderRadius: radius.sm,
+ borderWidth: 1,
+ borderColor: danger ? "#f1aaa4" : colors.borderStrong,
+ paddingHorizontal: 14,
+ backgroundColor: pressed ? colors.surfaceMuted : colors.surface,
+ opacity: disabled ? 0.5 : 1,
+ })}
+ >
+
+ {label}
+
+
+ );
+}
diff --git a/apps/drill-converter/src/ui/theme.ts b/apps/drill-converter/src/ui/theme.ts
new file mode 100644
index 00000000..a29236f1
--- /dev/null
+++ b/apps/drill-converter/src/ui/theme.ts
@@ -0,0 +1,36 @@
+import { COLOR_PRESETS } from "@eight2five/drill-schema";
+
+export const colors = Object.freeze({
+ page: "#f4f6fa",
+ surface: "#ffffff",
+ surfaceMuted: "#f8fafc",
+ text: "#172033",
+ textMuted: "#64748b",
+ border: "#d9e0ea",
+ borderStrong: "#b8c4d4",
+ accent: COLOR_PRESETS.blue,
+ accentSoft: "#edf3ff",
+ accentText: "#234b92",
+ success: "#16794a",
+ successSoft: "#e9f8ef",
+ warning: "#9a6200",
+ warningSoft: "#fff6df",
+ danger: "#b42318",
+ dangerSoft: "#fff0ef",
+ code: "#0f172a",
+});
+
+export const spacing = Object.freeze({
+ xs: 4,
+ sm: 8,
+ md: 12,
+ lg: 16,
+ xl: 24,
+ xxl: 32,
+});
+
+export const radius = Object.freeze({
+ sm: 8,
+ md: 12,
+ lg: 18,
+});
diff --git a/apps/drill-converter/tsconfig.json b/apps/drill-converter/tsconfig.json
new file mode 100644
index 00000000..ede53e2c
--- /dev/null
+++ b/apps/drill-converter/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "types": ["react", "jest"]
+ },
+ "include": [
+ "app/**/*.ts",
+ "app/**/*.tsx",
+ "src/**/*.ts",
+ "src/**/*.tsx",
+ ".expo/types/**/*.ts",
+ "expo-env.d.ts",
+ "uniwind-types.d.ts"
+ ]
+}
diff --git a/apps/drill-converter/uniwind-types.d.ts b/apps/drill-converter/uniwind-types.d.ts
new file mode 100644
index 00000000..2cc862f1
--- /dev/null
+++ b/apps/drill-converter/uniwind-types.d.ts
@@ -0,0 +1 @@
+///
diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts
index 12324659..51bd74ba 100644
--- a/apps/mobile/app.config.ts
+++ b/apps/mobile/app.config.ts
@@ -12,18 +12,45 @@ const androidVersionCode =
Number.isSafeInteger(requestedVersionCode) && requestedVersionCode > 0
? requestedVersionCode
: 1;
+const requestedIosBuildNumber = Number(
+ process.env.E2F_IOS_BUILD_NUMBER ?? process.env.GITHUB_RUN_NUMBER ?? 1,
+);
+const iosBuildNumber =
+ Number.isSafeInteger(requestedIosBuildNumber) && requestedIosBuildNumber > 0
+ ? String(requestedIosBuildNumber)
+ : "1";
+
+const appVariant = process.env.APP_VARIANT;
+const isDevelopment = appVariant === "development";
+const isPreview = appVariant === "preview";
+
+const appName = isDevelopment
+ ? "Eight2Five (Development)"
+ : isPreview
+ ? "Eight2Five (Preview)"
+ : "Eight2Five";
+
+const appIdentifier = isDevelopment
+ ? "com.eight2five.app.development"
+ : isPreview
+ ? "com.eight2five.app.preview"
+ : "com.eight2five.app";
const config: ExpoConfig = {
owner: "cdguth",
- name: "Eight2Five",
+ name: appName,
slug: "eight2five",
+ scheme: "eight2five",
platforms: ["ios", "android"],
- version: "0.0.0",
- orientation: "portrait",
+ version: "0.1.0",
+ // Field is the only route that opts into landscape; Drill and Settings
+ // apply portrait locks through their nested native stacks.
+ orientation: "default",
icon: "./assets/app-icons/mobile-android-legacy-icon.png",
userInterfaceStyle: "automatic",
ios: {
- bundleIdentifier: "com.eight2five.app",
+ bundleIdentifier: appIdentifier,
+ buildNumber: iosBuildNumber,
supportsTablet: false,
icon: {
light: "./assets/app-icons/mobile-ios-icon.png",
@@ -32,7 +59,7 @@ const config: ExpoConfig = {
},
},
android: {
- package: "com.eight2five.app",
+ package: appIdentifier,
versionCode: androidVersionCode,
icon: "./assets/app-icons/mobile-android-legacy-icon.png",
adaptiveIcon: {
@@ -46,6 +73,15 @@ const config: ExpoConfig = {
},
plugins: [
"expo-router",
+ [
+ "expo-build-properties",
+ {
+ buildReactNativeFromSource: false,
+ ios: {
+ ccacheEnabled: true,
+ },
+ },
+ ],
[
"expo-splash-screen",
{
@@ -67,6 +103,13 @@ const config: ExpoConfig = {
},
},
],
+ [
+ "expo-sensors",
+ {
+ motionPermission:
+ "Allow $(PRODUCT_NAME) to use device motion for brief live-position prediction.",
+ },
+ ],
[
"../../modules/expo-pans-ble-api/app.plugin.js",
{
diff --git a/apps/mobile/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx
new file mode 100644
index 00000000..5ba86925
--- /dev/null
+++ b/apps/mobile/app/(tabs)/_layout.tsx
@@ -0,0 +1,34 @@
+import { NativeTabs } from "expo-router/unstable-native-tabs";
+import { eight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme";
+
+import { MOBILE_TABS } from "../../src/navigation/mobile-tabs";
+import { useTabBarVisibility } from "../../src/navigation/tab-bar-visibility-context";
+
+export default function MobileTabsLayout() {
+ const theme = useEight2FiveTheme();
+ const { drillFeaturesEnabled, nativeTabBarHidden, nativeTabsRevision } =
+ useTabBarVisibility();
+
+ return (
+
+ {MOBILE_TABS.map((tab) => (
+
+ {tab.label}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/mobile/app/(tabs)/drill/_layout.tsx b/apps/mobile/app/(tabs)/drill/_layout.tsx
new file mode 100644
index 00000000..47e9fd5f
--- /dev/null
+++ b/apps/mobile/app/(tabs)/drill/_layout.tsx
@@ -0,0 +1,35 @@
+import { Redirect, Stack } from "expo-router";
+import { eight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme";
+
+import { useAppSettingsSnapshot } from "../../../src/state/app-settings-store";
+import { getDrillRouteAccess } from "../../../src/features/drill/drill-route-access";
+
+export default function DrillLayout() {
+ const theme = useEight2FiveTheme();
+ const { status, settings } = useAppSettingsSnapshot();
+ const access = getDrillRouteAccess(status, settings.drillFeaturesEnabled);
+
+ // Keep disabled drill routes inaccessible even when opened from a stale link.
+ if (access === "loading") return null;
+ if (access === "redirect") {
+ return ;
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/mobile/app/(tabs)/drill/index.tsx b/apps/mobile/app/(tabs)/drill/index.tsx
new file mode 100644
index 00000000..6fbbb336
--- /dev/null
+++ b/apps/mobile/app/(tabs)/drill/index.tsx
@@ -0,0 +1,5 @@
+import { DrillListScreen } from "../../../src/features/drill/drill-list-screen";
+
+export default function DrillRoute() {
+ return ;
+}
diff --git a/apps/mobile/app/(tabs)/field/_layout.tsx b/apps/mobile/app/(tabs)/field/_layout.tsx
new file mode 100644
index 00000000..64feb7c4
--- /dev/null
+++ b/apps/mobile/app/(tabs)/field/_layout.tsx
@@ -0,0 +1,12 @@
+import { Stack } from "expo-router";
+
+export default function FieldLayout() {
+ return (
+
+ );
+}
diff --git a/apps/mobile/app/(tabs)/field/index.tsx b/apps/mobile/app/(tabs)/field/index.tsx
new file mode 100644
index 00000000..7ffd7ff5
--- /dev/null
+++ b/apps/mobile/app/(tabs)/field/index.tsx
@@ -0,0 +1,15 @@
+import { FieldScreen } from "../../../src/features/field/field-screen";
+import { useFieldAnchorOverlay } from "../../../src/features/field/use-field-anchor-overlay";
+import { useFieldLivePosition } from "../../../src/pans/mobile-pans-context";
+
+export default function FieldRoute() {
+ const livePosition = useFieldLivePosition();
+ const anchorOverlay = useFieldAnchorOverlay();
+ return (
+
+ );
+}
diff --git a/apps/mobile/app/(tabs)/settings/_layout.tsx b/apps/mobile/app/(tabs)/settings/_layout.tsx
new file mode 100644
index 00000000..4a48cb66
--- /dev/null
+++ b/apps/mobile/app/(tabs)/settings/_layout.tsx
@@ -0,0 +1,37 @@
+import { Stack } from "expo-router";
+import { eight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme";
+
+export default function SettingsLayout() {
+ const theme = useEight2FiveTheme();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/app/(tabs)/settings/anchor/[anchorId].tsx b/apps/mobile/app/(tabs)/settings/anchor/[anchorId].tsx
new file mode 100644
index 00000000..25623d63
--- /dev/null
+++ b/apps/mobile/app/(tabs)/settings/anchor/[anchorId].tsx
@@ -0,0 +1,9 @@
+import { useLocalSearchParams } from "expo-router";
+
+import { AnchorEditorScreen } from "../../../../src/features/settings/anchor-editor-screen";
+
+export default function AnchorRoute() {
+ const { anchorId } = useLocalSearchParams<{ anchorId: string }>();
+
+ return ;
+}
diff --git a/apps/mobile/app/(tabs)/settings/anchors.tsx b/apps/mobile/app/(tabs)/settings/anchors.tsx
new file mode 100644
index 00000000..17cf5a26
--- /dev/null
+++ b/apps/mobile/app/(tabs)/settings/anchors.tsx
@@ -0,0 +1,5 @@
+import { AnchorListScreen } from "../../../src/features/settings/anchor-list-screen";
+
+export default function AnchorsRoute() {
+ return ;
+}
diff --git a/apps/mobile/app/(tabs)/settings/developer-confirmation.tsx b/apps/mobile/app/(tabs)/settings/developer-confirmation.tsx
new file mode 100644
index 00000000..e66d8ead
--- /dev/null
+++ b/apps/mobile/app/(tabs)/settings/developer-confirmation.tsx
@@ -0,0 +1,6 @@
+import { Redirect } from "expo-router";
+
+/** Legacy route kept only so stale deep links land on the current settings UI. */
+export default function DeveloperConfirmationRoute() {
+ return ;
+}
diff --git a/apps/mobile/app/(tabs)/settings/developer.tsx b/apps/mobile/app/(tabs)/settings/developer.tsx
new file mode 100644
index 00000000..ff80c9dd
--- /dev/null
+++ b/apps/mobile/app/(tabs)/settings/developer.tsx
@@ -0,0 +1,5 @@
+import { DeveloperSettingsScreen } from "../../../src/features/settings/developer-settings-screen";
+
+export default function DeveloperSettingsRoute() {
+ return ;
+}
diff --git a/apps/mobile/app/(tabs)/settings/index.tsx b/apps/mobile/app/(tabs)/settings/index.tsx
new file mode 100644
index 00000000..9d279423
--- /dev/null
+++ b/apps/mobile/app/(tabs)/settings/index.tsx
@@ -0,0 +1,5 @@
+import { SettingsScreen } from "../../../src/features/settings/settings-screen";
+
+export default function SettingsRoute() {
+ return ;
+}
diff --git a/apps/mobile/app/(tabs)/settings/network/[networkId].tsx b/apps/mobile/app/(tabs)/settings/network/[networkId].tsx
new file mode 100644
index 00000000..e6dbc9db
--- /dev/null
+++ b/apps/mobile/app/(tabs)/settings/network/[networkId].tsx
@@ -0,0 +1,9 @@
+import { useLocalSearchParams } from "expo-router";
+
+import { NetworkDetailScreen } from "../../../../src/features/settings/network-detail-screen";
+
+export default function NetworkDetailRoute() {
+ const { networkId } = useLocalSearchParams<{ networkId: string }>();
+
+ return ;
+}
diff --git a/apps/mobile/app/(tabs)/settings/network/[networkId]/anchors.tsx b/apps/mobile/app/(tabs)/settings/network/[networkId]/anchors.tsx
new file mode 100644
index 00000000..3a0a26ab
--- /dev/null
+++ b/apps/mobile/app/(tabs)/settings/network/[networkId]/anchors.tsx
@@ -0,0 +1,9 @@
+import { useLocalSearchParams } from "expo-router";
+
+import { NetworkDetailScreen } from "../../../../../src/features/settings/network-detail-screen";
+
+export default function NetworkAnchorsRoute() {
+ const { networkId } = useLocalSearchParams<{ networkId: string }>();
+
+ return ;
+}
diff --git a/apps/mobile/app/(tabs)/settings/networks.tsx b/apps/mobile/app/(tabs)/settings/networks.tsx
new file mode 100644
index 00000000..2a48874c
--- /dev/null
+++ b/apps/mobile/app/(tabs)/settings/networks.tsx
@@ -0,0 +1,5 @@
+import { NetworksScreen } from "../../../src/features/settings/networks-screen";
+
+export default function NetworksRoute() {
+ return ;
+}
diff --git a/apps/mobile/app/(tabs)/settings/tag.tsx b/apps/mobile/app/(tabs)/settings/tag.tsx
new file mode 100644
index 00000000..f4474767
--- /dev/null
+++ b/apps/mobile/app/(tabs)/settings/tag.tsx
@@ -0,0 +1,5 @@
+import { TagConnectionScreen } from "../../../src/features/settings/tag-connection-screen";
+
+export default function TagConnectionRoute() {
+ return ;
+}
diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx
index b08450ba..df25ba90 100644
--- a/apps/mobile/app/_layout.tsx
+++ b/apps/mobile/app/_layout.tsx
@@ -1,13 +1,25 @@
import React from "react";
-import { Stack } from "expo-router";
+import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
+import { StatusBar } from "expo-status-bar";
+import { SafeAreaProvider } from "react-native-safe-area-context";
+import { GestureHandlerRootView } from "react-native-gesture-handler";
import { GluestackUIProvider } from "@eight2five/ui/components/gluestack-ui-provider";
import {
- eight2FiveFonts,
+ Eight2FiveThemeProvider,
useEight2FiveFonts,
useEight2FiveTheme,
+ useEight2FiveThemeName,
} from "@eight2five/ui/theme";
+import { TabBarVisibilityProvider } from "../src/navigation/tab-bar-visibility-context";
+import { useMobileOrientationLock } from "../src/navigation/use-mobile-orientation-lock";
+import {
+ AppSettingsProvider,
+ useAppSettingsSnapshot,
+} from "../src/state/app-settings-store";
+import { MobilePansProvider } from "../src/pans/mobile-pans-context";
+
import "../global.css";
SplashScreen.setOptions({
@@ -17,7 +29,6 @@ void SplashScreen.preventAutoHideAsync();
export default function MobileRootLayout() {
const [fontsLoaded, fontError] = useEight2FiveFonts();
- const theme = useEight2FiveTheme();
React.useEffect(() => {
if (fontsLoaded || fontError) void SplashScreen.hideAsync();
@@ -26,25 +37,65 @@ export default function MobileRootLayout() {
if (!fontsLoaded && !fontError) return null;
return (
-
-
+
+
+ {/* Keep PANS above the UI portal host so modal content retains PANS context. */}
+
+
+
+
+
+
+
+
+ );
+}
+
+function MobilePansWithSettings({ children }: { children: React.ReactNode }) {
+ const { status, settings } = useAppSettingsSnapshot();
+ return (
+
+ {children}
+
+ );
+}
+
+function MobileAppearance({ children }: { children: React.ReactNode }) {
+ const { settings } = useAppSettingsSnapshot();
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function MobileNavigation() {
+ useMobileOrientationLock();
+ const { settings } = useAppSettingsSnapshot();
+ const theme = useEight2FiveTheme();
+ const themeName = useEight2FiveThemeName();
+
+ return (
+
+
-
-
-
+
+
+
);
}
diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx
index 8c5b48c5..cc483ae8 100644
--- a/apps/mobile/app/index.tsx
+++ b/apps/mobile/app/index.tsx
@@ -1,41 +1,5 @@
-import { StatusBar } from "expo-status-bar";
-import { Text, View } from "react-native";
-import { eight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme";
+import { Redirect } from "expo-router";
export default function MobileHomeRoute() {
- const theme = useEight2FiveTheme();
-
- return (
-
-
- Eight2Five
-
-
- Marching band positioning tools
-
-
-
- );
+ return ;
}
diff --git a/apps/mobile/assets/app-icons/mobile-android-adaptive-foreground.png b/apps/mobile/assets/app-icons/mobile-android-adaptive-foreground.png
index 8d0ccaec..d367765d 100644
Binary files a/apps/mobile/assets/app-icons/mobile-android-adaptive-foreground.png and b/apps/mobile/assets/app-icons/mobile-android-adaptive-foreground.png differ
diff --git a/apps/mobile/assets/app-icons/mobile-android-adaptive-monochrome.png b/apps/mobile/assets/app-icons/mobile-android-adaptive-monochrome.png
index c6681263..efcf5a62 100644
Binary files a/apps/mobile/assets/app-icons/mobile-android-adaptive-monochrome.png and b/apps/mobile/assets/app-icons/mobile-android-adaptive-monochrome.png differ
diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json
index f8e38578..3f226de6 100644
--- a/apps/mobile/eas.json
+++ b/apps/mobile/eas.json
@@ -7,18 +7,24 @@
"development": {
"developmentClient": true,
"distribution": "internal",
+ "env": {
+ "APP_VARIANT": "development"
+ },
"ios": {
"resourceClass": "m-medium"
},
"android": {
"buildType": "apk",
"env": {
- "GRADLE_OPTS": "-Dorg.gradle.jvmargs=\"-Xmx4g -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8\" -Dorg.gradle.daemon=false -Dorg.gradle.parallel=false"
+ "GRADLE_OPTS": "-Dorg.gradle.jvmargs=\"-Xmx4g -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8\" -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true"
}
}
},
"development-simulator": {
"developmentClient": true,
+ "env": {
+ "APP_VARIANT": "development"
+ },
"ios": {
"simulator": true,
"resourceClass": "m-medium"
@@ -26,29 +32,38 @@
},
"preview": {
"distribution": "internal",
+ "env": {
+ "APP_VARIANT": "preview"
+ },
"ios": {
"resourceClass": "m-medium"
},
"android": {
"buildType": "apk",
"env": {
- "GRADLE_OPTS": "-Dorg.gradle.jvmargs=\"-Xmx4g -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8\" -Dorg.gradle.daemon=false -Dorg.gradle.parallel=false"
+ "GRADLE_OPTS": "-Dorg.gradle.jvmargs=\"-Xmx4g -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8\" -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true"
}
}
},
"preview-simulator": {
+ "env": {
+ "APP_VARIANT": "preview"
+ },
"ios": {
"simulator": true,
"resourceClass": "m-medium"
}
},
"production": {
+ "env": {
+ "APP_VARIANT": "production"
+ },
"ios": {
"resourceClass": "m-medium"
},
"android": {
"env": {
- "GRADLE_OPTS": "-Dorg.gradle.jvmargs=\"-Xmx4g -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8\" -Dorg.gradle.daemon=false -Dorg.gradle.parallel=false"
+ "GRADLE_OPTS": "-Dorg.gradle.jvmargs=\"-Xmx4g -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8\" -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true"
}
}
}
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index c7a69be1..a9a83be7 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -1,6 +1,6 @@
{
"name": "eight2five-mobile",
- "version": "0.0.0",
+ "version": "0.1.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
@@ -20,9 +20,10 @@
"dependencies": {
"@eight2five/mobile": "*",
"@eight2five/ui": "*",
- "expo": "~57.0.9",
+ "expo": "~57.0.10",
+ "expo-blur": "~57.0.2",
"expo-dev-client": "~57.0.10",
- "expo-router": "~57.0.9",
+ "expo-router": "~57.0.10",
"react": "19.2.3",
"react-native": "0.86.2",
"react-native-safe-area-context": "~5.7.0"
@@ -54,6 +55,10 @@
"transformIgnorePatterns": [
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg)"
],
+ "moduleNameMapper": {
+ "^@eight2five/drill-schema$": "/../../packages/drill-schema/src/index.ts",
+ "^@eight2five/drill-schema/(.*)$": "/../../packages/drill-schema/src/$1"
+ },
"collectCoverage": true,
"collectCoverageFrom": [
"src/**/*.{ts,tsx}",
diff --git a/apps/mobile/src/components/spinning-loader-icon.tsx b/apps/mobile/src/components/spinning-loader-icon.tsx
new file mode 100644
index 00000000..f9b63124
--- /dev/null
+++ b/apps/mobile/src/components/spinning-loader-icon.tsx
@@ -0,0 +1,48 @@
+import React from "react";
+import { Animated, Easing } from "react-native";
+import { LoaderCircle } from "lucide-react-native";
+import { Icon } from "@eight2five/ui/components/icon";
+import { useEight2FiveTheme } from "@eight2five/ui/theme";
+
+export function SpinningLoaderIcon({
+ color,
+ size = "md",
+}: {
+ readonly color?: string;
+ readonly size?: React.ComponentProps["size"];
+}) {
+ const theme = useEight2FiveTheme();
+ const resolvedColor = color ?? theme.raw.white;
+ const [spin] = React.useState(() => new Animated.Value(0));
+
+ React.useEffect(() => {
+ const animation = Animated.loop(
+ Animated.timing(spin, {
+ toValue: 1,
+ duration: 900,
+ easing: Easing.linear,
+ useNativeDriver: true,
+ }),
+ );
+ animation.start();
+ return () => animation.stop();
+ }, [spin]);
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/drill/__tests__/drill-icons.test.ts b/apps/mobile/src/features/drill/__tests__/drill-icons.test.ts
new file mode 100644
index 00000000..c8276fcf
--- /dev/null
+++ b/apps/mobile/src/features/drill/__tests__/drill-icons.test.ts
@@ -0,0 +1,17 @@
+import {
+ DRILL_ICON_REGISTRY,
+ FALLBACK_DRILL_ICON,
+ isSupportedDrillIcon,
+ resolveDrillIcon,
+} from "../drill-icons";
+
+describe("drill card icon registry", () => {
+ test("resolves only controlled icon names and falls back for unknown names", () => {
+ expect(resolveDrillIcon("music-2")).toBe(DRILL_ICON_REGISTRY["music-2"]);
+ expect(resolveDrillIcon("made-up-icon")).toBe(FALLBACK_DRILL_ICON);
+ expect(resolveDrillIcon(undefined)).toBe(FALLBACK_DRILL_ICON);
+ expect(isSupportedDrillIcon("sparkle")).toBe(true);
+ expect(isSupportedDrillIcon("sparkles")).toBe(true);
+ expect(isSupportedDrillIcon("made-up-icon")).toBe(false);
+ });
+});
diff --git a/apps/mobile/src/features/drill/__tests__/drill-import.test.ts b/apps/mobile/src/features/drill/__tests__/drill-import.test.ts
new file mode 100644
index 00000000..305beb48
--- /dev/null
+++ b/apps/mobile/src/features/drill/__tests__/drill-import.test.ts
@@ -0,0 +1,303 @@
+import type { DrillRepository } from "@eight2five/mobile/drill";
+import {
+ DRILL_SCHEMA_URL,
+ DRILL_SCHEMA_VERSION,
+ getFieldPreset,
+ type DrillDocument,
+} from "@eight2five/drill-schema";
+
+import {
+ getPerformerSymbolGroups,
+ importEight2FiveDrillDocument,
+ importEight2FiveDrillJson,
+ isEight2FiveDrillFileName,
+ parseDrillPickerResult,
+ parseImportableDrillJson,
+} from "../drill-import";
+
+const VALID_DOCUMENT: DrillDocument = {
+ schema: DRILL_SCHEMA_URL,
+ schemaVersion: DRILL_SCHEMA_VERSION,
+ metadata: {
+ title: "Part 4 Finale",
+ createdAt: "2026-08-03T18:00:00.000Z",
+ },
+ field: { type: "preset", preset: "football-nfhs" },
+ entities: [
+ {
+ id: 42,
+ type: "performer",
+ symbol: "B",
+ label: "B1",
+ },
+ ],
+ sets: [
+ {
+ id: 0,
+ number: 1,
+ kind: "set",
+ countsFromPrevious: 0,
+ },
+ {
+ id: 1,
+ number: 2,
+ kind: "set",
+ countsFromPrevious: 16,
+ measureRange: { start: 12, end: 15 },
+ },
+ ],
+ positions: [
+ { entityId: 42, setId: 0, xSteps: -8, ySteps: 0 },
+ {
+ entityId: 42,
+ setId: 1,
+ xSteps: 4,
+ ySteps: 32,
+ facingDegrees: 90,
+ },
+ ],
+};
+
+const MULTI_ENTITY_DOCUMENT: DrillDocument = {
+ ...VALID_DOCUMENT,
+ entities: [
+ { id: 42, type: "performer", symbol: "B", label: "B1" },
+ { id: 43, type: "performer", symbol: "B", label: "B2" },
+ { id: 44, type: "performer", symbol: "T", label: "T1" },
+ { id: 99, type: "prop", symbol: "P", label: "P1" },
+ ],
+ positions: [
+ ...VALID_DOCUMENT.positions,
+ { entityId: 43, setId: 0, xSteps: 10, ySteps: 12 },
+ { entityId: 43, setId: 1, xSteps: 14, ySteps: 20 },
+ { entityId: 44, setId: 0, xSteps: -4, ySteps: 8 },
+ { entityId: 44, setId: 1, xSteps: -2, ySteps: 10 },
+ { entityId: 99, setId: 0, xSteps: 0, ySteps: 16 },
+ { entityId: 99, setId: 1, xSteps: 0, ySteps: 18 },
+ ],
+};
+
+function createRepository() {
+ const created = {
+ id: "drill-1",
+ name: "Part 4 Finale",
+ fieldPreset: "football-nfhs" as const,
+ createdAt: Date.parse(VALID_DOCUMENT.metadata.createdAt),
+ updatedAt: Date.parse(VALID_DOCUMENT.metadata.createdAt),
+ };
+ const repository = {
+ createImportedDrill: jest.fn(async () => created),
+ } as unknown as DrillRepository;
+ return { created, repository };
+}
+
+describe("Eight2Five drill import", () => {
+ test("treats a canceled native picker result as a no-op", async () => {
+ const readText = jest.fn(async () => JSON.stringify(VALID_DOCUMENT));
+
+ await expect(
+ parseDrillPickerResult(
+ { canceled: true, assets: null } as const,
+ readText,
+ ),
+ ).resolves.toBeUndefined();
+ expect(readText).not.toHaveBeenCalled();
+ });
+
+ test("preserves picker file rules while reading the selected asset", async () => {
+ const readText = jest.fn(async (uri: string) => {
+ expect(uri).toBe("file:///cache/finale.json");
+ return JSON.stringify(VALID_DOCUMENT);
+ });
+
+ await expect(
+ parseDrillPickerResult(
+ {
+ canceled: false,
+ assets: [
+ {
+ name: "finale.eight2five.json",
+ size: 128,
+ uri: "file:///cache/finale.json",
+ mimeType: "application/json",
+ lastModified: 0,
+ },
+ ],
+ },
+ readText,
+ ),
+ ).resolves.toEqual({
+ document: VALID_DOCUMENT,
+ fileName: "finale.eight2five.json",
+ });
+ });
+
+ test("recognizes the converter drill file extension", () => {
+ expect(isEight2FiveDrillFileName("finale.eight2five.json")).toBe(true);
+ expect(isEight2FiveDrillFileName("EIGHT2FIVE.JSON")).toBe(true);
+ expect(isEight2FiveDrillFileName("finale.json")).toBe(false);
+ });
+
+ test("validates and imports a single-performer portable drill", async () => {
+ const { created, repository } = createRepository();
+
+ await expect(
+ importEight2FiveDrillJson(repository, JSON.stringify(VALID_DOCUMENT)),
+ ).resolves.toBe(created);
+
+ expect(repository.createImportedDrill).toHaveBeenCalledWith({
+ sourceDocument: VALID_DOCUMENT,
+ selectedPerformerEntityId: 42,
+ });
+ });
+
+ test("accepts multi-performer files with props and groups selectable performers by symbol", () => {
+ const parsed = parseImportableDrillJson(
+ JSON.stringify(MULTI_ENTITY_DOCUMENT),
+ );
+
+ expect(getPerformerSymbolGroups(parsed)).toEqual([
+ {
+ symbol: "B",
+ performers: [
+ expect.objectContaining({ id: 42, label: "B1" }),
+ expect.objectContaining({ id: 43, label: "B2" }),
+ ],
+ },
+ {
+ symbol: "T",
+ performers: [expect.objectContaining({ id: 44, label: "T1" })],
+ },
+ ]);
+ });
+
+ test("passes the selected performer to atomic imported-drill creation", async () => {
+ const { repository } = createRepository();
+
+ await importEight2FiveDrillDocument(repository, MULTI_ENTITY_DOCUMENT, 43);
+
+ expect(repository.createImportedDrill).toHaveBeenCalledWith({
+ sourceDocument: MULTI_ENTITY_DOCUMENT,
+ selectedPerformerEntityId: 43,
+ });
+ });
+
+ test("requires an explicit performer selection when a file has multiple performers", async () => {
+ const { repository } = createRepository();
+
+ await expect(
+ importEight2FiveDrillDocument(repository, MULTI_ENTITY_DOCUMENT),
+ ).rejects.toThrow("Select your performer");
+ expect(repository.createImportedDrill).not.toHaveBeenCalled();
+ });
+
+ test("accepts polyline and Bézier paths without losing the import", async () => {
+ const otherEntityCurved: DrillDocument = {
+ ...MULTI_ENTITY_DOCUMENT,
+ paths: [
+ {
+ entityId: 99,
+ fromSetId: 0,
+ toSetId: 1,
+ kind: "polyline",
+ waypoints: [{ xSteps: 2, ySteps: 2 }],
+ },
+ ],
+ };
+ const firstRepository = createRepository().repository;
+ await expect(
+ importEight2FiveDrillDocument(firstRepository, otherEntityCurved, 43),
+ ).resolves.toBeDefined();
+
+ const selectedCurved: DrillDocument = {
+ ...MULTI_ENTITY_DOCUMENT,
+ paths: [
+ {
+ entityId: 43,
+ fromSetId: 0,
+ toSetId: 1,
+ kind: "polyline",
+ waypoints: [{ xSteps: 12, ySteps: 16 }],
+ },
+ ],
+ };
+ const secondRepository = createRepository().repository;
+ await expect(
+ importEight2FiveDrillDocument(secondRepository, selectedCurved, 43),
+ ).resolves.toBeDefined();
+ expect(secondRepository.createImportedDrill).toHaveBeenCalledWith({
+ sourceDocument: selectedCurved,
+ selectedPerformerEntityId: 43,
+ });
+ });
+
+ test("rejects custom fields and files without any performers", () => {
+ const customFieldDocument = {
+ ...VALID_DOCUMENT,
+ field: {
+ type: "custom" as const,
+ name: "Custom",
+ physicalGeometry: {
+ bounds: {
+ minXMeters: 0,
+ maxXMeters: 10,
+ minYMeters: 0,
+ maxYMeters: 10,
+ },
+ referenceLines: [
+ { id: "left", name: "Left", axis: "x", coordinateMeters: 0 },
+ { id: "right", name: "Right", axis: "x", coordinateMeters: 10 },
+ { id: "front", name: "Front", axis: "y", coordinateMeters: 0 },
+ { id: "back", name: "Back", axis: "y", coordinateMeters: 10 },
+ ],
+ },
+ marchingGrid: {
+ bounds: {
+ minXSteps: 0,
+ maxXSteps: 10,
+ minYSteps: 0,
+ maxYSteps: 10,
+ },
+ referenceLines: [
+ { id: "left", name: "Left", axis: "x", coordinateSteps: 0 },
+ { id: "right", name: "Right", axis: "x", coordinateSteps: 10 },
+ { id: "front", name: "Front", axis: "y", coordinateSteps: 0 },
+ { id: "back", name: "Back", axis: "y", coordinateSteps: 10 },
+ ],
+ },
+ markings: getFieldPreset("football-nfhs").markings,
+ },
+ } satisfies DrillDocument;
+ expect(() =>
+ parseImportableDrillJson(JSON.stringify(customFieldDocument)),
+ ).toThrow("Custom field definitions");
+
+ const propsOnly: DrillDocument = {
+ ...VALID_DOCUMENT,
+ entities: [{ id: 99, type: "prop", symbol: "P", label: "P1" }],
+ positions: [
+ { entityId: 99, setId: 0, xSteps: 0, ySteps: 0 },
+ { entityId: 99, setId: 1, xSteps: 0, ySteps: 8 },
+ ],
+ };
+ expect(() => parseImportableDrillJson(JSON.stringify(propsOnly))).toThrow(
+ "does not contain a performer",
+ );
+ });
+
+ test("propagates atomic import failures without a cleanup fallback", async () => {
+ const repository = {
+ createImportedDrill: jest
+ .fn()
+ .mockRejectedValueOnce(new Error("database failed")),
+ } as unknown as DrillRepository;
+
+ await expect(
+ importEight2FiveDrillJson(repository, JSON.stringify(VALID_DOCUMENT)),
+ ).rejects.toThrow("database failed");
+ expect(repository.createImportedDrill).toHaveBeenCalledWith({
+ sourceDocument: VALID_DOCUMENT,
+ selectedPerformerEntityId: 42,
+ });
+ });
+});
diff --git a/apps/mobile/src/features/drill/__tests__/drill-management.test.ts b/apps/mobile/src/features/drill/__tests__/drill-management.test.ts
new file mode 100644
index 00000000..371cdbbf
--- /dev/null
+++ b/apps/mobile/src/features/drill/__tests__/drill-management.test.ts
@@ -0,0 +1,85 @@
+import type { DrillRepository } from "@eight2five/mobile/drill";
+
+import {
+ DRILL_NAME_MAX_LENGTH,
+ deleteDrillAndRefreshSettings,
+ formatDrillCount,
+ getDrillCardActionLabels,
+ loadDrillList,
+ validateDrillName,
+} from "../drill-management";
+import { getDrillTerms } from "@eight2five/mobile/drill";
+
+describe("manual drill management", () => {
+ test("loads deterministic drill rows with page counts and supports empty state", async () => {
+ const repository = {
+ listDrills: jest.fn(async () => [
+ { id: "a", name: "First", createdAt: 1, updatedAt: 1 },
+ { id: "b", name: "Second", createdAt: 2, updatedAt: 2 },
+ ]),
+ listPages: jest.fn(async (drillId: string) =>
+ drillId === "a"
+ ? [
+ {
+ id: "page-a",
+ drillId,
+ ordinal: 0,
+ label: "1",
+ countsFromPrevious: 0,
+ position: { xMeters: 0, yMeters: 0 },
+ },
+ ]
+ : [],
+ ),
+ } as unknown as DrillRepository;
+
+ await expect(loadDrillList(repository)).resolves.toEqual([
+ { drill: expect.objectContaining({ id: "a" }), pageCount: 1 },
+ { drill: expect.objectContaining({ id: "b" }), pageCount: 0 },
+ ]);
+ expect(repository.listPages).toHaveBeenCalledWith("a");
+ expect(repository.listPages).toHaveBeenCalledWith("b");
+
+ repository.listDrills = jest.fn(async () => []);
+ await expect(loadDrillList(repository)).resolves.toEqual([]);
+ });
+
+ test("validates names for properties editing", () => {
+ expect(validateDrillName(" ")).toBe("Enter a drill name.");
+ expect(validateDrillName("x".repeat(DRILL_NAME_MAX_LENGTH + 1))).toContain(
+ String(DRILL_NAME_MAX_LENGTH),
+ );
+ });
+
+ test("formats card counts using the selected terminology", () => {
+ expect(formatDrillCount(1, getDrillTerms("sets"))).toBe("1 Set");
+ expect(formatDrillCount(3, getDrillTerms("pages"))).toBe("3 Pages");
+ });
+
+ test("provides accessible labels for the three card actions", () => {
+ expect(getDrillCardActionLabels("Finale")).toEqual({
+ info: "Info for Finale",
+ performer: "Select performer for Finale",
+ activate: "Activate Finale",
+ deactivate: "Deactivate Finale",
+ });
+ });
+
+ test("deletes the drill before refreshing cleared selection pointers", async () => {
+ const order: string[] = [];
+ const repository = {
+ deleteDrill: jest.fn(async () => {
+ order.push("delete");
+ }),
+ } as unknown as DrillRepository;
+ const reload = jest.fn(async () => {
+ order.push("reload");
+ });
+
+ await deleteDrillAndRefreshSettings(repository, "active", reload);
+
+ expect(repository.deleteDrill).toHaveBeenCalledWith("active");
+ expect(reload).toHaveBeenCalledTimes(1);
+ expect(order).toEqual(["delete", "reload"]);
+ });
+});
diff --git a/apps/mobile/src/features/drill/__tests__/drill-route-access.test.ts b/apps/mobile/src/features/drill/__tests__/drill-route-access.test.ts
new file mode 100644
index 00000000..69d94d81
--- /dev/null
+++ b/apps/mobile/src/features/drill/__tests__/drill-route-access.test.ts
@@ -0,0 +1,25 @@
+import { getDrillTerms } from "@eight2five/mobile/drill";
+
+import { getDrillRouteAccess } from "../drill-route-access";
+
+describe("drill route and terminology behavior", () => {
+ test("does not expose disabled or failed drill routes", () => {
+ expect(getDrillRouteAccess("loading", true)).toBe("loading");
+ expect(getDrillRouteAccess("ready", true)).toBe("allowed");
+ expect(getDrillRouteAccess("ready", false)).toBe("redirect");
+ expect(getDrillRouteAccess("error", true)).toBe("redirect");
+ });
+
+ test("keeps Pages and Sets as display-only terminology", () => {
+ expect(getDrillTerms("pages")).toMatchObject({
+ singular: "Page",
+ plural: "Pages",
+ lowercaseSingular: "page",
+ });
+ expect(getDrillTerms("sets")).toMatchObject({
+ singular: "Set",
+ plural: "Sets",
+ lowercaseSingular: "set",
+ });
+ });
+});
diff --git a/apps/mobile/src/features/drill/__tests__/page-form.test.ts b/apps/mobile/src/features/drill/__tests__/page-form.test.ts
new file mode 100644
index 00000000..f5c47165
--- /dev/null
+++ b/apps/mobile/src/features/drill/__tests__/page-form.test.ts
@@ -0,0 +1,195 @@
+import type { DrillSet } from "@eight2five/mobile/drill";
+import {
+ formatMarchingFrontBack,
+ formatMarchingSide,
+ marchingCoordinateToDrillGridPoint,
+} from "@eight2five/mobile/field";
+
+import {
+ createDefaultPageDraft,
+ pageToDraft,
+ validatePageDraft,
+ type MarchingCoordinateDraft,
+} from "../page-form";
+
+const VALID_DRAFT: MarchingCoordinateDraft = {
+ setNumber: "31",
+ setKind: "subset",
+ setSuffix: "A",
+ countsFromPrevious: "16",
+ measureStart: "126",
+ measureEnd: "129",
+ side: "2",
+ yardLine: "40",
+ sideRelation: "inside",
+ sideOffsetSteps: "2.25",
+ frontBackReference: "front-hash",
+ frontBackRelation: "in-front-of",
+ frontBackOffsetSteps: "4.5",
+};
+
+describe("structured marching coordinate form", () => {
+ test("defaults the first entry to zero counts and a numeric set identity", () => {
+ expect(
+ createDefaultPageDraft({ ordinal: 0, suggestedNumber: 1 }),
+ ).toMatchObject({
+ setNumber: "1",
+ setKind: "set",
+ setSuffix: "",
+ countsFromPrevious: "0",
+ measureStart: "",
+ measureEnd: "",
+ side: "center",
+ yardLine: "50",
+ sideRelation: "on",
+ frontBackReference: "front-sideline",
+ frontBackRelation: "on",
+ });
+ expect(
+ createDefaultPageDraft({ ordinal: 2, suggestedNumber: 7 })
+ .countsFromPrevious,
+ ).toBe("8");
+ });
+
+ test("converts structured fractional controls to the canonical drill grid", () => {
+ const result = validatePageDraft(VALID_DRAFT);
+ expect(result.errors).toEqual({});
+ expect(result.value).toBeDefined();
+ expect(formatMarchingSide(result.value!.coordinate.side)).toBe(
+ "Side 2: 2.25 Steps inside 40 yd ln",
+ );
+ expect(formatMarchingFrontBack(result.value!.coordinate.frontBack)).toBe(
+ "4.5 Steps in front of HS FH",
+ );
+ expect(result.value).toMatchObject({
+ number: 31,
+ kind: "subset",
+ suffix: "A",
+ countsFromPrevious: 16,
+ measureRange: { start: 126, end: 129 },
+ });
+ expect(result.value!.position).toEqual(
+ marchingCoordinateToDrillGridPoint(result.value!.coordinate),
+ );
+ });
+
+ test.each([
+ ["football-nfhs", 28],
+ ["football-ncaa", 32],
+ ["football-texas-uil", 32],
+ ] as const)(
+ "%s uses its schema-defined front-hash marching reference",
+ (fieldPreset, expectedFrontHashSteps) => {
+ const result = validatePageDraft(
+ {
+ ...VALID_DRAFT,
+ frontBackRelation: "on",
+ frontBackOffsetSteps: "0",
+ },
+ fieldPreset,
+ );
+ expect(result.value?.position.ySteps).toBeCloseTo(
+ expectedFrontHashSteps,
+ 8,
+ );
+ },
+ );
+
+ test("NFL uses its schema-defined fractional front-hash marching reference", () => {
+ const result = validatePageDraft(
+ {
+ ...VALID_DRAFT,
+ frontBackRelation: "on",
+ frontBackOffsetSteps: "0",
+ },
+ "football-nfl",
+ );
+ expect(result.value?.position.ySteps).toBeCloseTo(
+ ((70 + 9 / 12) / 160) * 84,
+ 8,
+ );
+ });
+
+ test("initializes controls through inverse grid conversion and round trips", () => {
+ const position = marchingCoordinateToDrillGridPoint({
+ side: { side: 1, yardLine: 35, relation: "outside", offsetSteps: 1.25 },
+ frontBack: {
+ reference: "back-hash",
+ relation: "behind",
+ offsetSteps: 3.75,
+ },
+ });
+ const set: DrillSet = {
+ id: "set-1",
+ drillId: "drill",
+ ordinal: 0,
+ number: 47,
+ kind: "set",
+ countsFromPrevious: 12,
+ measureRange: { start: 210, end: 214 },
+ position,
+ };
+ const draft = pageToDraft(set);
+ const roundTrip = validatePageDraft(draft);
+
+ expect(draft).toMatchObject({
+ setNumber: "47",
+ setKind: "set",
+ countsFromPrevious: "12",
+ measureStart: "210",
+ measureEnd: "214",
+ side: "1",
+ yardLine: "35",
+ sideRelation: "outside",
+ frontBackReference: "back-hash",
+ frontBackRelation: "behind",
+ });
+ expect(roundTrip.value?.position.xSteps).toBeCloseTo(position.xSteps, 10);
+ expect(roundTrip.value?.position.ySteps).toBeCloseTo(position.ySteps, 10);
+ });
+
+ test("normalizes zero offsets and the exact 50 to On with no side", () => {
+ const result = validatePageDraft({
+ ...VALID_DRAFT,
+ side: "1",
+ yardLine: "50",
+ sideRelation: "outside",
+ sideOffsetSteps: "0",
+ frontBackRelation: "behind",
+ frontBackOffsetSteps: "0",
+ });
+
+ expect(result.value?.coordinate.side).toEqual({
+ side: "center",
+ yardLine: 50,
+ relation: "on",
+ offsetSteps: 0,
+ });
+ expect(result.value?.coordinate.frontBack.relation).toBe("on");
+ });
+
+ test("returns actionable position, count, measure, relation, and bounds errors", () => {
+ expect(
+ validatePageDraft({
+ ...VALID_DRAFT,
+ setNumber: "-1",
+ setSuffix: "aa",
+ countsFromPrevious: "2.5",
+ measureStart: "130",
+ measureEnd: "129",
+ }).errors,
+ ).toMatchObject({
+ setNumber: expect.stringContaining("position number"),
+ setSuffix: expect.stringContaining("capital letter"),
+ countsFromPrevious: expect.stringContaining("whole-number"),
+ measureEnd: expect.stringContaining("after"),
+ });
+ expect(
+ validatePageDraft({
+ ...VALID_DRAFT,
+ side: "center",
+ yardLine: "45",
+ }).errors.side,
+ ).toContain("50-yard line");
+ });
+});
diff --git a/apps/mobile/src/features/drill/__tests__/transition-presentation.test.ts b/apps/mobile/src/features/drill/__tests__/transition-presentation.test.ts
new file mode 100644
index 00000000..8247b49c
--- /dev/null
+++ b/apps/mobile/src/features/drill/__tests__/transition-presentation.test.ts
@@ -0,0 +1,18 @@
+import { formatTransitionAnalysis } from "../transition-presentation";
+
+describe("transition presentation", () => {
+ test("rounds yard-line crossing xCounts to the nearest whole count", () => {
+ expect(
+ formatTransitionAnalysis(
+ {
+ distanceSteps: 24,
+ stepSizeToFive: 5.33,
+ isHalt: false,
+ yardLineCrossingCounts: [0.2, 5.333333, 10.666667],
+ },
+ true,
+ 16,
+ ).crossingCounts,
+ ).toBe("1, 5, 11");
+ });
+});
diff --git a/apps/mobile/src/features/drill/components/drill-empty-state.tsx b/apps/mobile/src/features/drill/components/drill-empty-state.tsx
new file mode 100644
index 00000000..7b9c1353
--- /dev/null
+++ b/apps/mobile/src/features/drill/components/drill-empty-state.tsx
@@ -0,0 +1,51 @@
+import { FileUp } from "lucide-react-native";
+import {
+ Button,
+ ButtonIcon,
+ ButtonText,
+} from "@eight2five/ui/components/button";
+import { Center } from "@eight2five/ui/components/center";
+import { Heading } from "@eight2five/ui/components/heading";
+import { Text } from "@eight2five/ui/components/text";
+import { VStack } from "@eight2five/ui/components/vstack";
+import {
+ eight2FiveFonts,
+ eight2FiveSpacing,
+ useEight2FiveTheme,
+} from "@eight2five/ui/theme";
+
+export function DrillEmptyState({ onUpload }: { onUpload(): void }) {
+ const theme = useEight2FiveTheme();
+ return (
+
+
+
+ No drills yet
+
+
+ Upload an Eight2Five drill file to start working with it.
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/drill/components/drill-list-item.tsx b/apps/mobile/src/features/drill/components/drill-list-item.tsx
new file mode 100644
index 00000000..64080de2
--- /dev/null
+++ b/apps/mobile/src/features/drill/components/drill-list-item.tsx
@@ -0,0 +1,256 @@
+import React from "react";
+import {
+ Animated,
+ Pressable as NativePressable,
+ View,
+ type GestureResponderEvent,
+} from "react-native";
+import {
+ CircleCheck,
+ CirclePlus,
+ CircleUserRound,
+ Info,
+} from "lucide-react-native";
+import type { Drill, DrillTerms } from "@eight2five/mobile/drill";
+import { Card } from "@eight2five/ui/components/card";
+import { HStack } from "@eight2five/ui/components/hstack";
+import { Icon } from "@eight2five/ui/components/icon";
+import { Text } from "@eight2five/ui/components/text";
+import { VStack } from "@eight2five/ui/components/vstack";
+import {
+ eight2FiveFonts,
+ eight2FiveRadii,
+ eight2FiveSpacing,
+ useEight2FiveTheme,
+} from "@eight2five/ui/theme";
+
+import { resolveDrillIcon } from "../drill-icons";
+import {
+ formatDrillCount,
+ getDrillCardActionLabels,
+} from "../drill-management";
+
+export const DrillListItem = React.memo(function DrillListItem({
+ drill,
+ pageCount,
+ terms,
+ active,
+ busy,
+ onOpenInfo,
+ onSelectPerformer,
+ onToggleActive,
+}: {
+ readonly drill: Drill;
+ readonly pageCount: number;
+ readonly terms: DrillTerms;
+ readonly active: boolean;
+ readonly busy: boolean;
+ readonly onOpenInfo: () => void;
+ readonly onSelectPerformer: () => void;
+ readonly onToggleActive: () => void;
+}) {
+ const theme = useEight2FiveTheme();
+ const countLabel = formatDrillCount(pageCount, terms);
+ const actionLabels = getDrillCardActionLabels(drill.name);
+ const DrillIcon = drill.metadata?.lucideIcon
+ ? resolveDrillIcon(drill.metadata.lucideIcon)
+ : undefined;
+
+ return (
+
+ {
+ if (!active) onToggleActive();
+ }}
+ style={({ pressed }) => ({
+ opacity: busy ? 0.55 : pressed ? 0.8 : 1,
+ })}
+ >
+
+ {DrillIcon ? (
+
+
+
+ ) : null}
+
+
+ {drill.name}
+
+
+ {countLabel}
+
+
+
+
+
+ {
+ event.stopPropagation();
+ onToggleActive();
+ }}
+ disabled={busy}
+ accessibilityRole="button"
+ accessibilityLabel={
+ active ? actionLabels.deactivate : actionLabels.activate
+ }
+ accessibilityState={{ disabled: busy, selected: active }}
+ hitSlop={4}
+ style={({ pressed }) => ({
+ width: 40,
+ height: 40,
+ alignItems: "center",
+ justifyContent: "center",
+ opacity: busy ? 0.45 : pressed ? 0.6 : 1,
+ })}
+ >
+
+
+
+
+
+
+ );
+});
+
+function DrillActionButton({
+ label,
+ icon,
+ disabled,
+ onPress,
+ iconColor,
+}: {
+ readonly label: string;
+ readonly icon: React.ElementType;
+ readonly disabled: boolean;
+ readonly onPress: () => void;
+ readonly iconColor: string;
+}) {
+ return (
+ {
+ event.stopPropagation();
+ onPress();
+ }}
+ disabled={disabled}
+ accessibilityRole="button"
+ accessibilityLabel={label}
+ accessibilityState={{ disabled }}
+ hitSlop={6}
+ style={({ pressed }) => ({
+ width: 36,
+ height: 36,
+ alignItems: "center",
+ justifyContent: "center",
+ opacity: disabled ? 0.45 : pressed ? 0.6 : 1,
+ })}
+ >
+
+
+
+
+ );
+}
+
+function AnimatedSelectionIcon({
+ active,
+ color,
+}: {
+ readonly active: boolean;
+ readonly color: string;
+}) {
+ const [progress] = React.useState(() => new Animated.Value(active ? 1 : 0));
+ React.useEffect(() => {
+ Animated.timing(progress, {
+ toValue: active ? 1 : 0,
+ duration: 180,
+ useNativeDriver: true,
+ }).start();
+ }, [active, progress]);
+
+ const plusOpacity = progress.interpolate({
+ inputRange: [0, 1],
+ outputRange: [1, 0],
+ });
+ const checkOpacity = progress;
+ const plusScale = progress.interpolate({
+ inputRange: [0, 1],
+ outputRange: [1, 0.9],
+ });
+ const checkScale = progress.interpolate({
+ inputRange: [0, 1],
+ outputRange: [0.9, 1],
+ });
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/drill/components/drill-properties-dialog.tsx b/apps/mobile/src/features/drill/components/drill-properties-dialog.tsx
new file mode 100644
index 00000000..87dbc021
--- /dev/null
+++ b/apps/mobile/src/features/drill/components/drill-properties-dialog.tsx
@@ -0,0 +1,192 @@
+import React from "react";
+import { Alert } from "react-native";
+import { Trash2, X } from "lucide-react-native";
+import type {
+ Drill,
+ DrillDocument,
+ DrillTerms,
+} from "@eight2five/mobile/drill";
+import {
+ Button,
+ ButtonIcon,
+ ButtonText,
+} from "@eight2five/ui/components/button";
+import { Heading } from "@eight2five/ui/components/heading";
+import { Icon } from "@eight2five/ui/components/icon";
+import {
+ Modal,
+ ModalBackdrop,
+ ModalBody,
+ ModalCloseButton,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+} from "@eight2five/ui/components/modal";
+import { Text } from "@eight2five/ui/components/text";
+import { VStack } from "@eight2five/ui/components/vstack";
+import {
+ eight2FiveFonts,
+ eight2FiveRadii,
+ eight2FiveSpacing,
+ useEight2FiveTheme,
+} from "@eight2five/ui/theme";
+
+import { SpinningLoaderIcon } from "../../../components/spinning-loader-icon";
+import { SettingsMessage } from "../../settings/settings-components";
+import { resolveDrillIcon } from "../drill-icons";
+
+export function DrillPropertiesDialog({
+ drill,
+ document,
+ terms,
+ isOpen,
+ loading,
+ saving,
+ error,
+ onClose,
+ onDelete,
+}: {
+ readonly drill?: Drill;
+ readonly document?: DrillDocument;
+ readonly terms: DrillTerms;
+ readonly isOpen: boolean;
+ readonly loading: boolean;
+ readonly saving: boolean;
+ readonly error?: Error;
+ readonly onClose: () => void;
+ readonly onDelete: () => Promise;
+}) {
+ const theme = useEight2FiveTheme();
+ if (!drill) return null;
+ const metadata = document?.metadata ?? drill.metadata;
+ const DrillIcon = drill.metadata?.lucideIcon
+ ? resolveDrillIcon(drill.metadata.lucideIcon)
+ : undefined;
+
+ const confirmDelete = () => {
+ Alert.alert(
+ `Delete “${drill.name}”?`,
+ `This permanently deletes the drill and all of its ${terms.lowercasePlural}.`,
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Delete",
+ style: "destructive",
+ onPress: () => void onDelete().catch(() => undefined),
+ },
+ ],
+ );
+ };
+
+ return (
+
+
+
+
+ Drill Info
+
+
+
+
+
+
+ {loading ? (
+ Loading metadata…
+ ) : null}
+ {error ? (
+ {error.message}
+ ) : null}
+
+
+ {DrillIcon ? (
+
+ ) : null}
+
+ {drill.name}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function MetadataRow({ label, value }: { label: string; value?: string }) {
+ const theme = useEight2FiveTheme();
+ return (
+
+
+ {label}
+
+ {value || "—"}
+
+ );
+}
+
+function formatMetadataDate(value: string | undefined): string | undefined {
+ if (!value) return undefined;
+ const parsed = Date.parse(value);
+ return Number.isFinite(parsed) ? new Date(parsed).toLocaleString() : value;
+}
+
+function formatSourceMetadata(
+ document: DrillDocument | undefined,
+): string | undefined {
+ if (!document?.provenance) return undefined;
+ const source = document.provenance.source;
+ const importer = document.provenance.importer;
+ const values = [
+ source?.fileName ?? source?.kind,
+ importer ? `${importer.name} ${importer.version}` : undefined,
+ ].filter((value): value is string => Boolean(value));
+ return values.length > 0 ? values.join(" · ") : undefined;
+}
diff --git a/apps/mobile/src/features/drill/components/drill-selection-dialog.tsx b/apps/mobile/src/features/drill/components/drill-selection-dialog.tsx
new file mode 100644
index 00000000..687334f5
--- /dev/null
+++ b/apps/mobile/src/features/drill/components/drill-selection-dialog.tsx
@@ -0,0 +1,129 @@
+import React from "react";
+import { useWindowDimensions } from "react-native";
+import { X } from "lucide-react-native";
+import { FlatList } from "@eight2five/ui/components/flat-list";
+import { Heading } from "@eight2five/ui/components/heading";
+import { Icon } from "@eight2five/ui/components/icon";
+import {
+ Modal,
+ ModalBackdrop,
+ ModalCloseButton,
+ ModalContent,
+ ModalHeader,
+} from "@eight2five/ui/components/modal";
+import { Text } from "@eight2five/ui/components/text";
+import { VStack } from "@eight2five/ui/components/vstack";
+import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme";
+
+import { DrillListItem } from "./drill-list-item";
+import { DrillPropertiesDialog } from "./drill-properties-dialog";
+import { PerformerSelectionDialog } from "./performer-selection-dialog";
+import { useDrillListController } from "../use-drill-list-controller";
+
+export function DrillSelectionDialog({
+ isOpen,
+ onClose,
+}: {
+ readonly isOpen: boolean;
+ readonly onClose: () => void;
+}) {
+ const { height } = useWindowDimensions();
+ const theme = useEight2FiveTheme();
+ const controller = useDrillListController();
+
+ const renderItem = React.useCallback(
+ ({ item }: { item: (typeof controller.entries)[number] }) => (
+ void controller.openProperties(item.drill)}
+ onSelectPerformer={() =>
+ void controller.openPerformerSelection(item.drill)
+ }
+ onToggleActive={() =>
+ void controller.toggleActive(item.drill).catch(() => undefined)
+ }
+ />
+ ),
+ [controller],
+ );
+
+ return (
+ <>
+
+
+
+
+ Select Drill
+
+
+
+
+ entry.drill.id}
+ renderItem={renderItem}
+ style={{ maxHeight: Math.max(260, height * 0.66) }}
+ contentContainerStyle={{
+ gap: eight2FiveSpacing.sm,
+ padding: eight2FiveSpacing.md,
+ }}
+ ListEmptyComponent={
+
+
+ No drills uploaded.
+
+
+ }
+ testID="drill-selection-list"
+ />
+
+
+
+
+
+ {
+ const drill = controller.propertiesDialog?.drill;
+ if (!drill) return;
+ await controller.remove(drill);
+ }}
+ />
+ >
+ );
+}
diff --git a/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx b/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx
new file mode 100644
index 00000000..5decc34f
--- /dev/null
+++ b/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx
@@ -0,0 +1,484 @@
+import React from "react";
+import { ChevronDown } from "lucide-react-native";
+import { Card } from "@eight2five/ui/components/card";
+import {
+ FormControl,
+ FormControlError,
+ FormControlErrorText,
+ FormControlHelper,
+ FormControlHelperText,
+ FormControlLabel,
+ FormControlLabelText,
+} from "@eight2five/ui/components/form-control";
+import { Heading } from "@eight2five/ui/components/heading";
+import { Input, InputField } from "@eight2five/ui/components/input";
+import {
+ Select,
+ SelectBackdrop,
+ SelectContent,
+ SelectDragIndicator,
+ SelectDragIndicatorWrapper,
+ SelectIcon,
+ SelectInput,
+ SelectItem,
+ SelectPortal,
+ SelectTrigger,
+} from "@eight2five/ui/components/select";
+import { Text } from "@eight2five/ui/components/text";
+import { VStack } from "@eight2five/ui/components/vstack";
+import {
+ eight2FiveFonts,
+ eight2FiveRadii,
+ eight2FiveSpacing,
+ useEight2FiveTheme,
+} from "@eight2five/ui/theme";
+
+import {
+ getFieldPreset,
+ getGridReference,
+ type FieldPresetId,
+} from "@eight2five/drill-schema";
+import { getDrillTerms, type DrillTerms } from "@eight2five/mobile/drill";
+
+import {
+ YARD_LINES,
+ previewCoordinate,
+ validatePageDraft,
+ type MarchingCoordinateDraft,
+} from "../page-form";
+
+const SIDE_CHOICES = [
+ { label: "Side 1", value: "1" },
+ { label: "Side 2", value: "2" },
+ { label: "No side (on 50)", value: "center" },
+] as const;
+
+const SIDE_RELATION_CHOICES = [
+ { label: "On", value: "on" },
+ { label: "Inside", value: "inside" },
+ { label: "Outside", value: "outside" },
+] as const;
+
+function frontBackChoices(fieldPreset: FieldPresetId) {
+ const hashPrefix = fieldHashPrefix(fieldPreset);
+ return [
+ { label: "Front Sideline", value: "front-sideline" },
+ { label: `${hashPrefix} FH`, value: "front-hash" },
+ { label: `${hashPrefix} BH`, value: "back-hash" },
+ { label: "Back Sideline", value: "back-sideline" },
+ ] as const;
+}
+
+function fieldHashPrefix(fieldPreset: FieldPresetId): string {
+ switch (fieldPreset) {
+ case "football-nfhs":
+ return "HS";
+ case "football-ncaa":
+ return "NCAA";
+ case "football-texas-uil":
+ return "UIL";
+ case "football-nfl":
+ return "NFL";
+ }
+}
+
+function frontBackHelper(fieldPreset: FieldPresetId): string {
+ const field = getFieldPreset(fieldPreset);
+ const frontHash = getGridReference(field, "front-hash")?.coordinateSteps;
+ const backHash = getGridReference(field, "back-hash")?.coordinateSteps;
+ if (frontHash === undefined || backHash === undefined) {
+ return "Offsets use the active field's marching-grid references.";
+ }
+ return `This field uses front hash ${formatStepReference(frontHash)} and back hash ${formatStepReference(backHash)}.`;
+}
+
+function formatStepReference(value: number): string {
+ return Number(value.toFixed(3)).toString();
+}
+
+const FRONT_BACK_RELATION_CHOICES = [
+ { label: "On", value: "on" },
+ { label: "In front of", value: "in-front-of" },
+ { label: "Behind", value: "behind" },
+] as const;
+
+export function MarchingCoordinateForm({
+ draft,
+ fieldPreset = "football-nfhs",
+ terms = getDrillTerms("sets"),
+ showDetails = true,
+ disabled,
+ onChange,
+}: {
+ draft: MarchingCoordinateDraft;
+ fieldPreset?: FieldPresetId;
+ terms?: DrillTerms;
+ showDetails?: boolean;
+ disabled: boolean;
+ onChange(draft: MarchingCoordinateDraft): void;
+}) {
+ const theme = useEight2FiveTheme();
+ const validation = validatePageDraft(draft, fieldPreset);
+ const preview = previewCoordinate(draft, fieldPreset);
+ const frontBackReferenceChoices = frontBackChoices(fieldPreset);
+ const setKindChoices = [
+ { label: terms.singular, value: "set" },
+ { label: "Subset", value: "subset" },
+ ] as const;
+ const update = (
+ key: Key,
+ value: MarchingCoordinateDraft[Key],
+ ) => onChange({ ...draft, [key]: value });
+
+ return (
+
+ {showDetails ? (
+
+ update("setNumber", value)}
+ />
+
+ onChange({
+ ...draft,
+ setKind: value,
+ ...(value === "set" ? { setSuffix: "" } : {}),
+ })
+ }
+ />
+ {draft.setKind === "subset" ? (
+ update("setSuffix", value)}
+ />
+ ) : null}
+ update("countsFromPrevious", value)}
+ />
+ update("measureStart", value)}
+ />
+ update("measureEnd", value)}
+ />
+
+ ) : null}
+
+
+ {
+ if (value === "center") {
+ onChange({
+ ...draft,
+ side: "center",
+ yardLine: "50",
+ sideRelation: "on",
+ sideOffsetSteps: "0",
+ });
+ } else {
+ update("side", value);
+ }
+ }}
+ />
+ ({
+ label: yardLine === 0 ? "Goal Line" : `${yardLine} yard line`,
+ value: String(yardLine),
+ }))}
+ error={validation.errors.yardLine}
+ disabled={disabled}
+ onChange={(value) => {
+ const leavingCenter = value !== "50" && draft.side === "center";
+ const exactlyOnFifty =
+ value === "50" && Number(draft.sideOffsetSteps) === 0;
+ onChange({
+ ...draft,
+ yardLine: value,
+ side: leavingCenter
+ ? "1"
+ : exactlyOnFifty
+ ? "center"
+ : draft.side,
+ ...(exactlyOnFifty
+ ? { sideRelation: "on", sideOffsetSteps: "0" }
+ : {}),
+ });
+ }}
+ />
+
+ onChange({
+ ...draft,
+ sideRelation: value,
+ ...(value === "on" ? { sideOffsetSteps: "0" } : {}),
+ })
+ }
+ />
+
+ onChange({
+ ...draft,
+ sideOffsetSteps: value,
+ ...(Number(value) === 0 ? { sideRelation: "on" } : {}),
+ })
+ }
+ />
+
+
+
+ update("frontBackReference", value)}
+ />
+
+ onChange({
+ ...draft,
+ frontBackRelation: value,
+ ...(value === "on" ? { frontBackOffsetSteps: "0" } : {}),
+ })
+ }
+ />
+
+ onChange({
+ ...draft,
+ frontBackOffsetSteps: value,
+ ...(Number(value) === 0 ? { frontBackRelation: "on" } : {}),
+ })
+ }
+ />
+
+
+
+
+ Coordinate preview
+
+ {preview ? (
+ <>
+ {preview.side}
+ {preview.frontBack}
+ >
+ ) : (
+
+ Complete a valid coordinate to preview it.
+
+ )}
+ {validation.errors.coordinate ? (
+
+ {validation.errors.coordinate}
+
+ ) : null}
+
+
+ );
+}
+
+function FormSection({
+ title,
+ children,
+}: {
+ title: string;
+ children: React.ReactNode;
+}) {
+ const theme = useEight2FiveTheme();
+ return (
+
+
+ {title}
+
+
+ {children}
+
+
+ );
+}
+
+function TextField({
+ label,
+ value,
+ error,
+ helper,
+ disabled,
+ numeric = false,
+ onChangeText,
+}: {
+ label: string;
+ value: string;
+ error?: string;
+ helper?: string;
+ disabled: boolean;
+ numeric?: boolean;
+ onChangeText(value: string): void;
+}) {
+ return (
+
+
+ {label}
+
+
+
+
+ {helper ? (
+
+ {helper}
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+}
+
+function SelectField({
+ label,
+ value,
+ choices,
+ error,
+ disabled,
+ onChange,
+}: {
+ label: string;
+ value: Value;
+ choices: readonly { readonly label: string; readonly value: Value }[];
+ error?: string;
+ disabled: boolean;
+ onChange(value: Value): void;
+}) {
+ return (
+
+
+ {label}
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/mobile/src/features/drill/components/performer-selection-dialog.tsx b/apps/mobile/src/features/drill/components/performer-selection-dialog.tsx
new file mode 100644
index 00000000..076de3c4
--- /dev/null
+++ b/apps/mobile/src/features/drill/components/performer-selection-dialog.tsx
@@ -0,0 +1,243 @@
+import React from "react";
+import { useWindowDimensions } from "react-native";
+import type { DrillDocument, DrillEntity } from "@eight2five/drill-schema";
+import { Button, ButtonText } from "@eight2five/ui/components/button";
+import { Heading } from "@eight2five/ui/components/heading";
+import { HStack } from "@eight2five/ui/components/hstack";
+import {
+ Modal,
+ ModalBackdrop,
+ ModalBody,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+} from "@eight2five/ui/components/modal";
+import { Pressable } from "@eight2five/ui/components/pressable";
+import { ScrollView } from "@eight2five/ui/components/scroll-view";
+import { Text } from "@eight2five/ui/components/text";
+import { VStack } from "@eight2five/ui/components/vstack";
+import {
+ eight2FiveRadii,
+ eight2FiveSpacing,
+ useEight2FiveTheme,
+} from "@eight2five/ui/theme";
+
+import { SpinningLoaderIcon } from "../../../components/spinning-loader-icon";
+import { SettingsMessage } from "../../settings/settings-components";
+import { getPerformerSymbolGroups } from "../drill-import";
+
+interface PerformerSelectionDialogProps {
+ readonly document?: DrillDocument;
+ readonly isOpen: boolean;
+ readonly importing: boolean;
+ readonly error?: Error;
+ readonly selectedPerformerEntityId?: number;
+ readonly title?: string;
+ readonly confirmLabel?: string;
+ readonly onClose: () => void;
+ readonly onConfirm: (performerEntityId: number) => Promise;
+}
+
+export function PerformerSelectionDialog(props: PerformerSelectionDialogProps) {
+ if (!props.document) return null;
+ return (
+
+ );
+}
+
+function PerformerSelectionDialogContent({
+ document,
+ isOpen,
+ importing,
+ error,
+ selectedPerformerEntityId,
+ title = "Select your dot",
+ confirmLabel = "Use This Dot",
+ onClose,
+ onConfirm,
+}: PerformerSelectionDialogProps & { readonly document: DrillDocument }) {
+ const theme = useEight2FiveTheme();
+ const { height } = useWindowDimensions();
+ const groups = React.useMemo(
+ () => getPerformerSymbolGroups(document),
+ [document],
+ );
+ const initiallySelectedPerformer = groups
+ .flatMap((group) => group.performers)
+ .find((performer) => performer.id === selectedPerformerEntityId);
+ const [selectedSymbol, setSelectedSymbol] = React.useState(
+ initiallySelectedPerformer?.symbol ?? groups[0]?.symbol,
+ );
+ const [selectedPerformer, setSelectedPerformer] = React.useState<
+ DrillEntity | undefined
+ >(initiallySelectedPerformer);
+ const visiblePerformers =
+ groups.find((group) => group.symbol === selectedSymbol)?.performers ?? [];
+ const listHeight = Math.min(380, Math.max(230, height * 0.45));
+
+ return (
+ {
+ if (!importing) onClose();
+ }}
+ size="lg"
+ >
+
+
+
+ {title}
+
+
+
+
+ Choose your performer symbol on the left, then select your label
+ on the right. Props may be present in the uploaded file, but they
+ are not selectable as your dot.
+
+
+ {error ? (
+ {error.message}
+ ) : null}
+
+
+
+
+
+ {groups.map((group) => {
+ const selected = group.symbol === selectedSymbol;
+ return (
+ {
+ if (importing) return;
+ setSelectedSymbol(group.symbol);
+ setSelectedPerformer(undefined);
+ }}
+ style={{
+ minHeight: 44,
+ alignItems: "center",
+ justifyContent: "center",
+ paddingHorizontal: eight2FiveSpacing.sm,
+ borderRadius: eight2FiveRadii.sm,
+ backgroundColor: selected
+ ? theme.accent
+ : theme.surfaceRaised,
+ }}
+ >
+
+ {group.symbol}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+ {visiblePerformers.map((performer) => {
+ const selected = performer.id === selectedPerformer?.id;
+ return (
+ {
+ if (!importing) setSelectedPerformer(performer);
+ }}
+ style={{
+ minHeight: 48,
+ justifyContent: "center",
+ paddingHorizontal: eight2FiveSpacing.md,
+ paddingVertical: eight2FiveSpacing.sm,
+ borderRadius: eight2FiveRadii.sm,
+ backgroundColor: selected
+ ? theme.accent
+ : theme.surfaceRaised,
+ }}
+ >
+
+ {performer.label}
+
+ {performer.name ? (
+
+ {performer.name}
+
+ ) : null}
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/drill/drill-icons.ts b/apps/mobile/src/features/drill/drill-icons.ts
new file mode 100644
index 00000000..a0b065e7
--- /dev/null
+++ b/apps/mobile/src/features/drill/drill-icons.ts
@@ -0,0 +1,50 @@
+import {
+ Activity,
+ CircleDot,
+ Flag,
+ Music2,
+ Shapes,
+ Sparkle,
+ Sparkles,
+ Star,
+ Trophy,
+ Zap,
+ type LucideIcon,
+} from "lucide-react-native";
+
+/**
+ * Only icons in this explicit registry may be resolved from imported
+ * metadata. Drill files are data, so their icon name must never be evaluated
+ * as a component lookup at runtime.
+ */
+export const DRILL_ICON_REGISTRY = Object.freeze({
+ activity: Activity,
+ "circle-dot": CircleDot,
+ flag: Flag,
+ "music-2": Music2,
+ shapes: Shapes,
+ sparkle: Sparkle,
+ sparkles: Sparkles,
+ star: Star,
+ trophy: Trophy,
+ zap: Zap,
+} satisfies Readonly>);
+
+export type DrillIconName = keyof typeof DRILL_ICON_REGISTRY;
+
+export const DRILL_ICON_NAMES = Object.freeze(
+ Object.keys(DRILL_ICON_REGISTRY) as DrillIconName[],
+);
+
+export const FALLBACK_DRILL_ICON: LucideIcon = CircleDot;
+
+export function resolveDrillIcon(iconName: string | undefined): LucideIcon {
+ if (!iconName) return FALLBACK_DRILL_ICON;
+ return DRILL_ICON_REGISTRY[iconName as DrillIconName] ?? FALLBACK_DRILL_ICON;
+}
+
+export function isSupportedDrillIcon(
+ iconName: string | undefined,
+): iconName is DrillIconName {
+ return iconName !== undefined && iconName in DRILL_ICON_REGISTRY;
+}
diff --git a/apps/mobile/src/features/drill/drill-import.ts b/apps/mobile/src/features/drill/drill-import.ts
new file mode 100644
index 00000000..3d986e53
--- /dev/null
+++ b/apps/mobile/src/features/drill/drill-import.ts
@@ -0,0 +1,185 @@
+import type { Drill, DrillRepository } from "@eight2five/mobile/drill";
+import type {
+ DocumentPickerAsset,
+ DocumentPickerResult,
+} from "expo-document-picker";
+import {
+ safeParseDrillDocument,
+ type DrillDocument,
+ type DrillEntity,
+} from "@eight2five/drill-schema";
+
+export const EIGHT2FIVE_DRILL_FILE_SUFFIX = ".eight2five.json";
+export const MAX_DRILL_UPLOAD_BYTES = 10 * 1024 * 1024;
+
+export interface ParsedDrillPickerResult {
+ readonly document: DrillDocument;
+ readonly fileName: string;
+}
+
+export interface PerformerSymbolGroup {
+ readonly symbol: string;
+ readonly performers: readonly DrillEntity[];
+}
+
+export function isEight2FiveDrillFileName(fileName: string): boolean {
+ const normalized = fileName.trim().toLowerCase();
+ return (
+ normalized === "eight2five.json" ||
+ normalized.endsWith(EIGHT2FIVE_DRILL_FILE_SUFFIX)
+ );
+}
+
+/**
+ * Applies the existing file-name and size checks to a native picker asset,
+ * then reads and validates its JSON through the caller-provided Expo file
+ * reader. Keeping the reader injectable makes the import boundary testable
+ * without a native document picker or filesystem.
+ */
+export async function parseDrillPickerResult(
+ result: DocumentPickerResult,
+ readText: (uri: string) => Promise,
+): Promise {
+ if (result.canceled) return undefined;
+ const asset = result.assets[0];
+ if (!asset) return undefined;
+ return {
+ document: await parseDrillPickerAsset(asset, readText),
+ fileName: asset.name,
+ };
+}
+
+export async function parseDrillPickerAsset(
+ asset: Pick,
+ readText: (uri: string) => Promise,
+): Promise {
+ if (!isEight2FiveDrillFileName(asset.name)) {
+ throw new Error(`Select a file ending in ${EIGHT2FIVE_DRILL_FILE_SUFFIX}.`);
+ }
+ if (typeof asset.size === "number" && asset.size > MAX_DRILL_UPLOAD_BYTES) {
+ throw new Error("The selected drill file is too large to import.");
+ }
+
+ const json = await readText(asset.uri);
+ if (json.length > MAX_DRILL_UPLOAD_BYTES) {
+ throw new Error("The selected drill file is too large to import.");
+ }
+ return parseImportableDrillJson(json);
+}
+
+export function parseImportableDrillJson(json: string): DrillDocument {
+ let value: unknown;
+ try {
+ value = JSON.parse(json) as unknown;
+ } catch {
+ throw new Error("The selected file is not valid JSON.");
+ }
+
+ const parsed = safeParseDrillDocument(value);
+ if (!parsed.success) {
+ const issue = parsed.error.issues[0];
+ const detail = issue?.message ? ` ${issue.message}` : "";
+ throw new Error(`This is not a valid Eight2Five drill file.${detail}`);
+ }
+
+ assertMobileDocumentSupport(parsed.data);
+ return parsed.data;
+}
+
+export function getPerformerSymbolGroups(
+ document: DrillDocument,
+): readonly PerformerSymbolGroup[] {
+ const groups = new Map();
+ for (const entity of document.entities) {
+ if (entity.type !== "performer") continue;
+ const performers = groups.get(entity.symbol);
+ if (performers) performers.push(entity);
+ else groups.set(entity.symbol, [entity]);
+ }
+ return [...groups.entries()].map(([symbol, performers]) => ({
+ symbol,
+ performers,
+ }));
+}
+
+export async function importEight2FiveDrillJson(
+ repository: DrillRepository,
+ json: string,
+ performerEntityId?: number,
+): Promise {
+ return await importEight2FiveDrillDocument(
+ repository,
+ parseImportableDrillJson(json),
+ performerEntityId,
+ );
+}
+
+export async function importEight2FiveDrillDocument(
+ repository: DrillRepository,
+ document: DrillDocument,
+ performerEntityId?: number,
+): Promise {
+ assertMobileDocumentSupport(document);
+ const performer = resolveSelectedPerformer(document, performerEntityId);
+ assertSelectedPerformerPositions(document, performer);
+
+ return await repository.createImportedDrill({
+ sourceDocument: document,
+ selectedPerformerEntityId: performer.id,
+ });
+}
+
+function assertMobileDocumentSupport(
+ document: DrillDocument,
+): asserts document is DrillDocument & {
+ readonly field: Extract;
+} {
+ if (document.field.type !== "preset") {
+ throw new Error(
+ "Custom field definitions are not supported in the mobile app yet.",
+ );
+ }
+
+ if (!document.entities.some((entity) => entity.type === "performer")) {
+ throw new Error("This drill file does not contain a performer to select.");
+ }
+}
+
+function resolveSelectedPerformer(
+ document: DrillDocument,
+ performerEntityId?: number,
+): DrillEntity {
+ const performers = document.entities.filter(
+ (entity) => entity.type === "performer",
+ );
+ if (performerEntityId === undefined) {
+ if (performers.length === 1) return performers[0];
+ throw new Error("Select your performer before importing this drill.");
+ }
+
+ const performer = performers.find(
+ (entity) => entity.id === performerEntityId,
+ );
+ if (!performer) {
+ throw new Error(
+ "The selected performer is not present in this drill file.",
+ );
+ }
+ return performer;
+}
+
+function assertSelectedPerformerPositions(
+ document: DrillDocument,
+ performer: DrillEntity,
+): void {
+ const positionedSetIds = new Set(
+ document.positions
+ .filter((position) => position.entityId === performer.id)
+ .map((position) => position.setId),
+ );
+ if (document.sets.some((set) => !positionedSetIds.has(set.id))) {
+ throw new Error(
+ `Every drill position must include a coordinate for ${performer.label}.`,
+ );
+ }
+}
diff --git a/apps/mobile/src/features/drill/drill-list-screen.tsx b/apps/mobile/src/features/drill/drill-list-screen.tsx
new file mode 100644
index 00000000..68fd5e82
--- /dev/null
+++ b/apps/mobile/src/features/drill/drill-list-screen.tsx
@@ -0,0 +1,167 @@
+import React from "react";
+import { Stack } from "expo-router";
+import { Plus } from "lucide-react-native";
+import { FlatList } from "@eight2five/ui/components/flat-list";
+import { Icon } from "@eight2five/ui/components/icon";
+import { Pressable } from "@eight2five/ui/components/pressable";
+import { Text } from "@eight2five/ui/components/text";
+import { VStack } from "@eight2five/ui/components/vstack";
+import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme";
+
+import { SettingsMessage } from "../settings/settings-components";
+import { DrillEmptyState } from "./components/drill-empty-state";
+import { DrillListItem } from "./components/drill-list-item";
+import { DrillPropertiesDialog } from "./components/drill-properties-dialog";
+import { PerformerSelectionDialog } from "./components/performer-selection-dialog";
+import { useDrillListController } from "./use-drill-list-controller";
+
+export function DrillListScreen() {
+ const theme = useEight2FiveTheme();
+ const controller = useDrillListController();
+
+ const renderItem = React.useCallback(
+ ({ item }: { item: (typeof controller.entries)[number] }) => (
+ void controller.openProperties(item.drill)}
+ onSelectPerformer={() =>
+ void controller.openPerformerSelection(item.drill)
+ }
+ onToggleActive={() =>
+ void controller.toggleActive(item.drill).catch(() => undefined)
+ }
+ />
+ ),
+ [controller],
+ );
+
+ return (
+ <>
+ (
+ void controller.pickFile()}
+ accessibilityRole="button"
+ accessibilityLabel="Upload Drill"
+ hitSlop={8}
+ style={{
+ width: 48,
+ height: 48,
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+
+
+ ),
+ }}
+ />
+
+ entry.drill.id}
+ renderItem={renderItem}
+ contentInsetAdjustmentBehavior="automatic"
+ contentContainerStyle={{
+ flexGrow: 1,
+ gap: eight2FiveSpacing.sm,
+ padding: eight2FiveSpacing.md,
+ paddingBottom: eight2FiveSpacing.xxl,
+ }}
+ ListHeaderComponent={
+ controller.loading || controller.error ? (
+
+ {controller.loading ? (
+
+ Loading drills…
+
+ ) : null}
+ {controller.error ? (
+
+ {controller.error.message}
+
+ ) : null}
+
+ ) : null
+ }
+ ListEmptyComponent={
+ controller.loading ? null : (
+ void controller.pickFile()} />
+ )
+ }
+ />
+
+ {
+ if (controller.pendingImport) controller.cancelPendingImport();
+ else controller.closePerformerSelection();
+ }}
+ onConfirm={async (performerEntityId) => {
+ if (controller.pendingImport) {
+ await controller.importPendingDocument(performerEntityId);
+ } else {
+ await controller.selectPerformer(performerEntityId);
+ }
+ }}
+ />
+
+ {
+ const drill = controller.propertiesDialog?.drill;
+ if (!drill) return;
+ await controller.remove(drill);
+ }}
+ />
+
+ >
+ );
+}
diff --git a/apps/mobile/src/features/drill/drill-management.ts b/apps/mobile/src/features/drill/drill-management.ts
new file mode 100644
index 00000000..638ca31a
--- /dev/null
+++ b/apps/mobile/src/features/drill/drill-management.ts
@@ -0,0 +1,71 @@
+import {
+ type Drill,
+ type DrillRepository,
+ type DrillTerms,
+} from "@eight2five/mobile/drill";
+
+export const DRILL_NAME_MAX_LENGTH = 80;
+
+export interface DrillListEntry {
+ readonly drill: Drill;
+ readonly pageCount: number;
+}
+
+export function normalizeDrillName(value: string): string {
+ return value.trim();
+}
+
+export function validateDrillName(value: string): string | undefined {
+ const name = normalizeDrillName(value);
+ if (!name) return "Enter a drill name.";
+ if (name.length > DRILL_NAME_MAX_LENGTH) {
+ return `Drill names must be ${DRILL_NAME_MAX_LENGTH} characters or fewer.`;
+ }
+ return undefined;
+}
+
+export function formatDrillCount(count: number, terms: DrillTerms): string {
+ return `${count} ${count === 1 ? terms.singular : terms.plural}`;
+}
+
+export function getDrillCardActionLabels(drillName: string): {
+ readonly info: string;
+ readonly performer: string;
+ readonly activate: string;
+ readonly deactivate: string;
+} {
+ return {
+ info: `Info for ${drillName}`,
+ performer: `Select performer for ${drillName}`,
+ activate: `Activate ${drillName}`,
+ deactivate: `Deactivate ${drillName}`,
+ };
+}
+
+export async function loadDrillList(
+ repository: DrillRepository,
+): Promise {
+ const drills = await repository.listDrills();
+ const pageCounts = await Promise.all(
+ drills.map(async (drill) => (await repository.listPages(drill.id)).length),
+ );
+ // Preserve Thread 1's repository-defined deterministic ordering.
+ return drills.map((drill, index) => ({
+ drill,
+ pageCount: pageCounts[index],
+ }));
+}
+
+export async function deleteDrillAndRefreshSettings(
+ repository: DrillRepository,
+ drillId: string,
+ reloadSettings: () => Promise,
+): Promise {
+ await repository.deleteDrill(drillId);
+ // SQLite foreign keys clear active/selected pointers; publish that snapshot.
+ await reloadSettings();
+}
+
+export function toError(value: unknown): Error {
+ return value instanceof Error ? value : new Error(String(value));
+}
diff --git a/apps/mobile/src/features/drill/drill-route-access.ts b/apps/mobile/src/features/drill/drill-route-access.ts
new file mode 100644
index 00000000..bc273288
--- /dev/null
+++ b/apps/mobile/src/features/drill/drill-route-access.ts
@@ -0,0 +1,11 @@
+import type { AppSettingsStoreStatus } from "../../state/app-settings-store";
+
+export type DrillRouteAccess = "loading" | "allowed" | "redirect";
+
+export function getDrillRouteAccess(
+ status: AppSettingsStoreStatus,
+ drillFeaturesEnabled: boolean,
+): DrillRouteAccess {
+ if (status === "loading") return "loading";
+ return status === "ready" && drillFeaturesEnabled ? "allowed" : "redirect";
+}
diff --git a/apps/mobile/src/features/drill/page-form.ts b/apps/mobile/src/features/drill/page-form.ts
new file mode 100644
index 00000000..90fa46b8
--- /dev/null
+++ b/apps/mobile/src/features/drill/page-form.ts
@@ -0,0 +1,377 @@
+import {
+ drillGridPointToMarchingCoordinate,
+ fieldPointToDrillGridPoint,
+ formatMarchingFrontBack,
+ formatMarchingSide,
+ marchingCoordinateToDrillGridPoint,
+ type FieldLateralReference,
+ type MarchingCoordinate,
+ type MarchingFrontBackRelation,
+ type MarchingSideReference,
+ type MarchingSideRelation,
+} from "@eight2five/mobile/field";
+import type {
+ DrillGridPoint,
+ DrillSet,
+ MeasureRange,
+ SetKind,
+} from "@eight2five/mobile/drill";
+import type { FieldPresetId } from "@eight2five/drill-schema";
+
+export const YARD_LINES = Object.freeze(
+ Array.from({ length: 11 }, (_, index) => index * 5),
+);
+
+export interface MarchingCoordinateDraft {
+ readonly setNumber: string;
+ readonly setKind: SetKind;
+ readonly setSuffix: string;
+ readonly countsFromPrevious: string;
+ readonly measureStart: string;
+ readonly measureEnd: string;
+ readonly side: "1" | "2" | "center";
+ readonly yardLine: string;
+ readonly sideRelation: MarchingSideRelation;
+ readonly sideOffsetSteps: string;
+ readonly frontBackReference: FieldLateralReference;
+ readonly frontBackRelation: MarchingFrontBackRelation;
+ readonly frontBackOffsetSteps: string;
+}
+
+export type SetFormField =
+ | "setNumber"
+ | "setSuffix"
+ | "countsFromPrevious"
+ | "measureStart"
+ | "measureEnd"
+ | "side"
+ | "yardLine"
+ | "sideOffsetSteps"
+ | "frontBackOffsetSteps"
+ | "coordinate";
+
+export type SetFormErrors = Partial>;
+
+export interface ValidatedSetDraft {
+ readonly number: number;
+ readonly kind: SetKind;
+ readonly suffix?: string;
+ readonly countsFromPrevious: number;
+ readonly measureRange?: MeasureRange;
+ readonly position: DrillGridPoint;
+ readonly coordinate: MarchingCoordinate;
+}
+
+export interface SetDraftValidation {
+ readonly errors: SetFormErrors;
+ readonly value?: ValidatedSetDraft;
+}
+
+export interface CoordinatePreview {
+ readonly side: string;
+ readonly frontBack: string;
+}
+
+export function createDefaultPageDraft({
+ ordinal,
+ suggestedNumber,
+ suggestedLabel,
+}: {
+ ordinal: number;
+ suggestedNumber?: number;
+ /** @deprecated Legacy callers may still pass a numeric-ish label. */
+ suggestedLabel?: string;
+}): MarchingCoordinateDraft {
+ const fallbackNumber = Number(suggestedLabel);
+ const resolvedNumber =
+ suggestedNumber ??
+ (Number.isSafeInteger(fallbackNumber) && fallbackNumber >= 0
+ ? fallbackNumber
+ : ordinal + 1);
+ return {
+ setNumber: String(resolvedNumber),
+ setKind: "set",
+ setSuffix: "",
+ countsFromPrevious: ordinal === 0 ? "0" : "8",
+ measureStart: "",
+ measureEnd: "",
+ side: "center",
+ yardLine: "50",
+ sideRelation: "on",
+ sideOffsetSteps: "0",
+ frontBackReference: "front-sideline",
+ frontBackRelation: "on",
+ frontBackOffsetSteps: "0",
+ };
+}
+
+export function pageToDraft(
+ set: DrillSet,
+ fieldPreset: FieldPresetId = "football-nfhs",
+): MarchingCoordinateDraft {
+ return setDraftFromPosition(
+ set.position,
+ {
+ number: set.number,
+ kind: set.kind,
+ suffix: set.suffix,
+ countsFromPrevious: set.countsFromPrevious,
+ measureRange: set.measureRange,
+ },
+ fieldPreset,
+ );
+}
+
+export function coordinateDraftFromFieldPoint(
+ position: {
+ readonly xMeters: number;
+ readonly yMeters: number;
+ },
+ fieldPreset: FieldPresetId = "football-nfhs",
+): MarchingCoordinateDraft {
+ return setDraftFromPosition(
+ fieldPointToDrillGridPoint(position, fieldPreset),
+ {
+ number: 0,
+ kind: "set",
+ countsFromPrevious: 0,
+ },
+ fieldPreset,
+ );
+}
+
+function setDraftFromPosition(
+ position: DrillGridPoint,
+ details: {
+ readonly number: number;
+ readonly kind: SetKind;
+ readonly suffix?: string;
+ readonly countsFromPrevious: number;
+ readonly measureRange?: MeasureRange;
+ },
+ fieldPreset: FieldPresetId = "football-nfhs",
+): MarchingCoordinateDraft {
+ const coordinate = drillGridPointToMarchingCoordinate(position, fieldPreset);
+ return {
+ setNumber: String(details.number),
+ setKind: details.kind,
+ setSuffix: details.suffix ?? "",
+ countsFromPrevious: String(details.countsFromPrevious),
+ measureStart: details.measureRange
+ ? String(details.measureRange.start)
+ : "",
+ measureEnd: details.measureRange ? String(details.measureRange.end) : "",
+ side: String(coordinate.side.side) as MarchingCoordinateDraft["side"],
+ yardLine: String(coordinate.side.yardLine),
+ sideRelation: coordinate.side.relation,
+ sideOffsetSteps: String(coordinate.side.offsetSteps),
+ frontBackReference: coordinate.frontBack.reference,
+ frontBackRelation: coordinate.frontBack.relation,
+ frontBackOffsetSteps: String(coordinate.frontBack.offsetSteps),
+ };
+}
+
+export function validatePageDraft(
+ draft: MarchingCoordinateDraft,
+ fieldPreset: FieldPresetId = "football-nfhs",
+): SetDraftValidation {
+ const errors: SetFormErrors = {};
+ const setNumber = parseNonNegativeInteger(
+ draft.setNumber,
+ "Enter a non-negative drill position number.",
+ );
+ if (typeof setNumber === "string") errors.setNumber = setNumber;
+
+ const suffix = draft.setSuffix.trim();
+ if (draft.setKind === "set" && suffix) {
+ errors.setSuffix = "Primary drill positions do not have a suffix.";
+ } else if (
+ draft.setKind === "subset" &&
+ !/^(?:[A-Z]|\.[0-9]+)$/.test(suffix)
+ ) {
+ errors.setSuffix = "Use one capital letter or a decimal suffix such as .5.";
+ }
+
+ const counts = parseNonNegativeInteger(
+ draft.countsFromPrevious,
+ "Enter non-negative whole-number counts.",
+ );
+ if (typeof counts === "string") errors.countsFromPrevious = counts;
+
+ const measureRange = parseMeasureRange(draft, errors);
+ const coordinateResult = coordinateFromDraft(draft, fieldPreset);
+ Object.assign(errors, coordinateResult.errors);
+ if (
+ Object.keys(errors).length > 0 ||
+ typeof setNumber === "string" ||
+ typeof counts === "string" ||
+ !coordinateResult.coordinate ||
+ !coordinateResult.position
+ ) {
+ return { errors };
+ }
+
+ return {
+ errors,
+ value: {
+ number: setNumber,
+ kind: draft.setKind,
+ ...(draft.setKind === "subset" ? { suffix } : {}),
+ countsFromPrevious: counts,
+ ...(measureRange ? { measureRange } : {}),
+ coordinate: coordinateResult.coordinate,
+ position: coordinateResult.position,
+ },
+ };
+}
+
+export function previewCoordinate(
+ draft: MarchingCoordinateDraft,
+ fieldPreset: FieldPresetId = "football-nfhs",
+): CoordinatePreview | undefined {
+ const result = coordinateFromDraft(draft, fieldPreset);
+ if (!result.coordinate || Object.keys(result.errors).length > 0)
+ return undefined;
+ return {
+ side: formatMarchingSide(result.coordinate.side),
+ frontBack: formatMarchingFrontBack(
+ result.coordinate.frontBack,
+ fieldPreset,
+ ),
+ };
+}
+
+function parseMeasureRange(
+ draft: MarchingCoordinateDraft,
+ errors: SetFormErrors,
+): MeasureRange | undefined {
+ const startText = draft.measureStart.trim();
+ const endText = draft.measureEnd.trim();
+ if (!startText && !endText) return undefined;
+ if (!startText || !endText) {
+ const message = "Enter both measure start and end, or leave both blank.";
+ if (!startText) errors.measureStart = message;
+ if (!endText) errors.measureEnd = message;
+ return undefined;
+ }
+ const start = parseNonNegativeInteger(
+ startText,
+ "Enter a valid start measure.",
+ );
+ const end = parseNonNegativeInteger(endText, "Enter a valid end measure.");
+ if (typeof start === "string") errors.measureStart = start;
+ if (typeof end === "string") errors.measureEnd = end;
+ if (typeof start === "string" || typeof end === "string") return undefined;
+ if (end < start) {
+ errors.measureEnd = "End measure must be at or after the start measure.";
+ return undefined;
+ }
+ return { start, end };
+}
+
+function coordinateFromDraft(
+ draft: MarchingCoordinateDraft,
+ fieldPreset: FieldPresetId,
+): {
+ readonly errors: SetFormErrors;
+ readonly coordinate?: MarchingCoordinate;
+ readonly position?: DrillGridPoint;
+} {
+ const errors: SetFormErrors = {};
+ const yardLine = parseNonNegativeNumber(
+ draft.yardLine,
+ "Choose a five-yard line.",
+ );
+ if (typeof yardLine === "string" || !YARD_LINES.includes(yardLine)) {
+ errors.yardLine = "Choose a five-yard line from 0 through 50.";
+ }
+ const sideOffset = parseNonNegativeNumber(
+ draft.sideOffsetSteps,
+ "Enter a finite, non-negative side offset.",
+ );
+ if (typeof sideOffset === "string") errors.sideOffsetSteps = sideOffset;
+ const frontBackOffset = parseNonNegativeNumber(
+ draft.frontBackOffsetSteps,
+ "Enter a finite, non-negative front-to-back offset.",
+ );
+ if (typeof frontBackOffset === "string")
+ errors.frontBackOffsetSteps = frontBackOffset;
+ if (
+ typeof yardLine === "string" ||
+ typeof sideOffset === "string" ||
+ typeof frontBackOffset === "string"
+ ) {
+ return { errors };
+ }
+
+ const side = parseSide(draft.side);
+ if (side === undefined) {
+ errors.side = "Choose Side 1, Side 2, or no side for the 50.";
+ return { errors };
+ }
+ if (side === "center" && yardLine !== 50) {
+ errors.side = "No side is available only when exactly on the 50-yard line.";
+ }
+ const normalizedSide = yardLine === 50 && sideOffset === 0 ? "center" : side;
+ const normalizedSideRelation = sideOffset === 0 ? "on" : draft.sideRelation;
+ if (
+ yardLine === 50 &&
+ normalizedSide !== "center" &&
+ normalizedSideRelation !== "outside"
+ ) {
+ errors.coordinate =
+ "An offset from the 50-yard line must be outside on Side 1 or Side 2.";
+ }
+
+ const coordinate: MarchingCoordinate = {
+ side: {
+ side: normalizedSide,
+ yardLine,
+ relation: normalizedSide === "center" ? "on" : normalizedSideRelation,
+ offsetSteps: normalizedSide === "center" ? 0 : sideOffset,
+ },
+ frontBack: {
+ reference: draft.frontBackReference,
+ relation: frontBackOffset === 0 ? "on" : draft.frontBackRelation,
+ offsetSteps: frontBackOffset,
+ },
+ };
+ if (Object.keys(errors).length > 0) return { errors, coordinate };
+
+ try {
+ return {
+ errors,
+ coordinate,
+ position: marchingCoordinateToDrillGridPoint(coordinate, fieldPreset),
+ };
+ } catch (cause) {
+ errors.coordinate = cause instanceof Error ? cause.message : String(cause);
+ return { errors, coordinate };
+ }
+}
+
+function parseNonNegativeInteger(
+ value: string,
+ message: string,
+): number | string {
+ if (!value.trim()) return message;
+ const number = Number(value);
+ return Number.isSafeInteger(number) && number >= 0 ? number : message;
+}
+
+function parseNonNegativeNumber(
+ value: string,
+ message: string,
+): number | string {
+ if (!value.trim()) return message;
+ const number = Number(value);
+ return Number.isFinite(number) && number >= 0 ? number : message;
+}
+
+function parseSide(
+ value: MarchingCoordinateDraft["side"],
+): MarchingSideReference | undefined {
+ if (value === "1") return 1;
+ if (value === "2") return 2;
+ return value === "center" ? "center" : undefined;
+}
diff --git a/apps/mobile/src/features/drill/transition-presentation.ts b/apps/mobile/src/features/drill/transition-presentation.ts
new file mode 100644
index 00000000..33358b58
--- /dev/null
+++ b/apps/mobile/src/features/drill/transition-presentation.ts
@@ -0,0 +1,52 @@
+import {
+ analyzeDrillTransition,
+ type DrillPage,
+ type TransitionAnalysis,
+} from "@eight2five/mobile/drill";
+
+export interface TransitionPresentation {
+ readonly stepSize: string;
+ readonly crossingCounts: string;
+}
+
+export function formatTransitionAnalysis(
+ analysis: TransitionAnalysis,
+ hasPreviousPage: boolean,
+ countsFromPrevious: number,
+): TransitionPresentation {
+ if (!hasPreviousPage || countsFromPrevious === 0) {
+ return { stepSize: "–", crossingCounts: "–" };
+ }
+ return {
+ stepSize: analysis.isHalt
+ ? "Hold"
+ : analysis.stepSizeToFive === undefined
+ ? "–"
+ : `${formatMetricNumber(analysis.stepSizeToFive)} to 5`,
+ crossingCounts:
+ analysis.yardLineCrossingCounts.length > 0
+ ? analysis.yardLineCrossingCounts
+ .map((count) => formatCrossingCount(count, countsFromPrevious))
+ .join(", ")
+ : "–",
+ };
+}
+
+export function getTransitionPresentation(
+ previousPage: DrillPage | undefined,
+ page: DrillPage,
+): TransitionPresentation {
+ return formatTransitionAnalysis(
+ analyzeDrillTransition(previousPage, page),
+ Boolean(previousPage),
+ page.countsFromPrevious,
+ );
+}
+
+function formatMetricNumber(value: number): string {
+ return Number(value.toFixed(6)).toString();
+}
+
+function formatCrossingCount(value: number, moveCounts: number): string {
+ return String(Math.min(moveCounts, Math.max(1, Math.round(value))));
+}
diff --git a/apps/mobile/src/features/drill/use-drill-list-controller.ts b/apps/mobile/src/features/drill/use-drill-list-controller.ts
new file mode 100644
index 00000000..4b0b44af
--- /dev/null
+++ b/apps/mobile/src/features/drill/use-drill-list-controller.ts
@@ -0,0 +1,336 @@
+import React from "react";
+import * as DocumentPicker from "expo-document-picker";
+import { File } from "expo-file-system";
+import { useFocusEffect } from "expo-router";
+import {
+ getDrillTerms,
+ type Drill,
+ type DrillDocument,
+ type DrillRepository,
+} from "@eight2five/mobile/drill";
+
+import {
+ useAppSettingsSnapshot,
+ useAppSettingsStore,
+} from "../../state/app-settings-store";
+import {
+ importEight2FiveDrillDocument,
+ parseDrillPickerResult,
+} from "./drill-import";
+import {
+ deleteDrillAndRefreshSettings,
+ loadDrillList,
+ toError,
+ type DrillListEntry,
+} from "./drill-management";
+
+interface PendingImport {
+ readonly document: DrillDocument;
+ readonly fileName: string;
+}
+
+export interface DrillPropertiesDialogState {
+ readonly drill: Drill;
+ readonly document?: DrillDocument;
+}
+
+export interface PerformerSelectionDialogState {
+ readonly drill?: Drill;
+ readonly document: DrillDocument;
+}
+
+export function useDrillListController() {
+ const snapshot = useAppSettingsSnapshot();
+ const store = useAppSettingsStore();
+ const [entries, setEntries] = React.useState([]);
+ const [loading, setLoading] = React.useState(true);
+ const [error, setError] = React.useState();
+ const [busyDrillId, setBusyDrillId] = React.useState();
+ const [pendingImport, setPendingImport] = React.useState();
+ const [selectedFileName, setSelectedFileName] = React.useState();
+ const [uploadBusy, setUploadBusy] = React.useState(false);
+ const [importing, setImporting] = React.useState(false);
+ const [importError, setImportError] = React.useState();
+ const [propertiesDialog, setPropertiesDialog] =
+ React.useState();
+ const [propertiesLoading, setPropertiesLoading] = React.useState(false);
+ const [propertiesError, setPropertiesError] = React.useState();
+ const [performerDialog, setPerformerDialog] =
+ React.useState();
+ const [performerLoading, setPerformerLoading] = React.useState(false);
+ const [performerError, setPerformerError] = React.useState();
+ const mutationInFlight = React.useRef(false);
+ const pickerInFlight = React.useRef(false);
+
+ const refresh = React.useCallback(async () => {
+ if (snapshot.status !== "ready") return;
+ try {
+ setEntries(await loadDrillList(store.getDrillRepository()));
+ setError(undefined);
+ } catch (cause) {
+ setError(toError(cause));
+ } finally {
+ setLoading(false);
+ }
+ }, [snapshot.status, store]);
+
+ useFocusEffect(
+ React.useCallback(() => {
+ void refresh();
+ }, [refresh]),
+ );
+
+ const mutate = React.useCallback(
+ async (
+ drillId: string,
+ operation: (repository: DrillRepository) => Promise,
+ ) => {
+ if (mutationInFlight.current) {
+ throw new Error("Another drill update is in progress.");
+ }
+ mutationInFlight.current = true;
+ setBusyDrillId(drillId);
+ setError(undefined);
+ try {
+ const result = await operation(store.getDrillRepository());
+ await refresh();
+ return result;
+ } catch (cause) {
+ const operationError = toError(cause);
+ setError(operationError);
+ throw operationError;
+ } finally {
+ mutationInFlight.current = false;
+ setBusyDrillId(undefined);
+ }
+ },
+ [refresh, store],
+ );
+
+ const importDocument = React.useCallback(
+ async (document: DrillDocument, performerEntityId: number) => {
+ if (snapshot.status !== "ready" || mutationInFlight.current) return;
+ mutationInFlight.current = true;
+ setImporting(true);
+ setImportError(undefined);
+ setError(undefined);
+ try {
+ await importEight2FiveDrillDocument(
+ store.getDrillRepository(),
+ document,
+ performerEntityId,
+ );
+ setPendingImport(undefined);
+ await refresh();
+ } catch (cause) {
+ const importOperationError = toError(cause);
+ setImportError(importOperationError);
+ setError(importOperationError);
+ } finally {
+ mutationInFlight.current = false;
+ setImporting(false);
+ }
+ },
+ [refresh, snapshot.status, store],
+ );
+
+ const pickFile = React.useCallback(async () => {
+ if (
+ snapshot.status !== "ready" ||
+ mutationInFlight.current ||
+ pickerInFlight.current
+ ) {
+ return;
+ }
+ pickerInFlight.current = true;
+ setUploadBusy(true);
+ try {
+ const result = await DocumentPicker.getDocumentAsync({
+ type: ["application/json", "text/json", "text/plain"],
+ copyToCacheDirectory: true,
+ multiple: false,
+ });
+ if (result.canceled) return;
+
+ const parsed = await parseDrillPickerResult(result, (uri) =>
+ new File(uri).text(),
+ );
+ if (!parsed) return;
+
+ setSelectedFileName(parsed.fileName);
+ setError(undefined);
+ setImportError(undefined);
+ const performers = parsed.document.entities.filter(
+ (entity) => entity.type === "performer",
+ );
+ if (performers.length > 1) {
+ setPendingImport(parsed);
+ return;
+ }
+ const performer = performers[0];
+ if (!performer) return;
+ await importDocument(parsed.document, performer.id);
+ } catch (cause) {
+ const pickerError = toError(cause);
+ setError(pickerError);
+ setImportError(pickerError);
+ } finally {
+ pickerInFlight.current = false;
+ setUploadBusy(false);
+ }
+ }, [importDocument, snapshot.status]);
+
+ const cancelPendingImport = React.useCallback(() => {
+ if (importing) return;
+ setPendingImport(undefined);
+ setImportError(undefined);
+ }, [importing]);
+
+ const importPendingDocument = React.useCallback(
+ async (performerEntityId: number) => {
+ if (!pendingImport) return;
+ await importDocument(pendingImport.document, performerEntityId);
+ },
+ [importDocument, pendingImport],
+ );
+
+ const toggleActive = React.useCallback(
+ async (drill: Drill) => {
+ if (mutationInFlight.current || snapshot.status !== "ready") return;
+ mutationInFlight.current = true;
+ setBusyDrillId(drill.id);
+ setError(undefined);
+ try {
+ await store.setActiveDrill(
+ snapshot.settings.activeDrillId === drill.id ? null : drill.id,
+ );
+ } catch (cause) {
+ const operationError = toError(cause);
+ setError(operationError);
+ throw operationError;
+ } finally {
+ mutationInFlight.current = false;
+ setBusyDrillId(undefined);
+ }
+ },
+ [snapshot.settings.activeDrillId, snapshot.status, store],
+ );
+
+ const openProperties = React.useCallback(
+ async (drill: Drill) => {
+ if (snapshot.status !== "ready") return;
+ setPropertiesDialog({ drill });
+ setPropertiesLoading(true);
+ setPropertiesError(undefined);
+ try {
+ const document = await store
+ .getDrillRepository()
+ .getDrillDocument(drill.id);
+ setPropertiesDialog((current) =>
+ current?.drill.id === drill.id ? { drill, document } : current,
+ );
+ } catch (cause) {
+ setPropertiesError(toError(cause));
+ } finally {
+ setPropertiesLoading(false);
+ }
+ },
+ [snapshot.status, store],
+ );
+
+ const closeProperties = React.useCallback(() => {
+ if (!propertiesLoading) {
+ setPropertiesDialog(undefined);
+ setPropertiesError(undefined);
+ }
+ }, [propertiesLoading]);
+
+ const openPerformerSelection = React.useCallback(
+ async (drill: Drill) => {
+ if (snapshot.status !== "ready") return;
+ setPerformerLoading(true);
+ setPerformerError(undefined);
+ try {
+ const document = await store
+ .getDrillRepository()
+ .getDrillDocument(drill.id);
+ if (!document) {
+ throw new Error(
+ "This drill does not contain an imported performer list.",
+ );
+ }
+ setPerformerDialog({ drill, document });
+ } catch (cause) {
+ setPerformerError(toError(cause));
+ } finally {
+ setPerformerLoading(false);
+ }
+ },
+ [snapshot.status, store],
+ );
+
+ const closePerformerSelection = React.useCallback(() => {
+ if (!busyDrillId) {
+ setPerformerDialog(undefined);
+ setPerformerError(undefined);
+ }
+ }, [busyDrillId]);
+
+ const selectPerformer = React.useCallback(
+ async (performerEntityId: number) => {
+ const current = performerDialog;
+ if (!current?.drill) return;
+ try {
+ await mutate(current.drill.id, (repository) =>
+ repository.setSelectedPerformer(current.drill!.id, performerEntityId),
+ );
+ setPerformerDialog(undefined);
+ } catch (cause) {
+ setPerformerError(toError(cause));
+ }
+ },
+ [mutate, performerDialog],
+ );
+
+ const remove = React.useCallback(
+ async (drill: Drill) =>
+ await mutate(drill.id, async (repository) => {
+ await deleteDrillAndRefreshSettings(repository, drill.id, () =>
+ store.reload(),
+ );
+ setPropertiesDialog(undefined);
+ }),
+ [mutate, store],
+ );
+
+ return {
+ entries,
+ loading: snapshot.status === "loading" || loading,
+ error: error ?? snapshot.error,
+ busyDrillId,
+ activeDrillId: snapshot.settings.activeDrillId,
+ terms: getDrillTerms(snapshot.settings.drillTerminology),
+ refresh,
+ pickFile,
+ uploadBusy,
+ selectedFileName,
+ pendingImport,
+ importing,
+ importError,
+ cancelPendingImport,
+ importPendingDocument,
+ toggleActive,
+ openProperties,
+ closeProperties,
+ propertiesDialog,
+ propertiesLoading,
+ propertiesError,
+ openPerformerSelection,
+ closePerformerSelection,
+ performerDialog,
+ performerLoading,
+ performerError,
+ selectPerformer,
+ remove,
+ } as const;
+}
diff --git a/apps/mobile/src/features/field/__tests__/effective-field-preset.test.ts b/apps/mobile/src/features/field/__tests__/effective-field-preset.test.ts
new file mode 100644
index 00000000..07bf2c7f
--- /dev/null
+++ b/apps/mobile/src/features/field/__tests__/effective-field-preset.test.ts
@@ -0,0 +1,31 @@
+import { FIELD_PRESET_IDS } from "@eight2five/drill-schema";
+
+import { resolveEffectiveFieldPreset } from "../effective-field-preset";
+
+const PRESETS = FIELD_PRESET_IDS;
+
+describe("effective marching field selection", () => {
+ test.each(PRESETS)(
+ "uses %s as the preference when no drill is loaded",
+ (preset) => {
+ expect(resolveEffectiveFieldPreset(undefined, preset)).toBe(preset);
+ },
+ );
+
+ test.each([
+ ["football-nfhs", "football-ncaa"],
+ ["football-ncaa", "football-nfhs"],
+ ["football-texas-uil", "football-ncaa"],
+ ["football-nfl", "football-nfhs"],
+ ] as const)(
+ "loaded %s drill overrides %s preference",
+ (drillPreset, defaultPreset) => {
+ expect(
+ resolveEffectiveFieldPreset(
+ { fieldPreset: drillPreset },
+ defaultPreset,
+ ),
+ ).toBe(drillPreset);
+ },
+ );
+});
diff --git a/apps/mobile/src/features/field/__tests__/field-anchor-overlay-options.test.ts b/apps/mobile/src/features/field/__tests__/field-anchor-overlay-options.test.ts
new file mode 100644
index 00000000..0497773f
--- /dev/null
+++ b/apps/mobile/src/features/field/__tests__/field-anchor-overlay-options.test.ts
@@ -0,0 +1,32 @@
+import { DEFAULT_APP_SETTINGS } from "@eight2five/mobile/settings";
+
+import { fieldAnchorOverlayOptions } from "../field-anchor-overlay-options";
+
+describe("field anchor overlay options", () => {
+ test("requires Developer Mode and cached geometry before showing range", () => {
+ expect(
+ fieldAnchorOverlayOptions({
+ ...DEFAULT_APP_SETTINGS,
+ showCachedAnchorGeometry: true,
+ showComfortableAnchorRange: true,
+ }),
+ ).toEqual({ visible: false, showRange: false, rangeMeters: 20 });
+ expect(
+ fieldAnchorOverlayOptions({
+ ...DEFAULT_APP_SETTINGS,
+ developerModeEnabled: true,
+ showCachedAnchorGeometry: false,
+ showComfortableAnchorRange: true,
+ }),
+ ).toEqual({ visible: false, showRange: false, rangeMeters: 20 });
+ expect(
+ fieldAnchorOverlayOptions({
+ ...DEFAULT_APP_SETTINGS,
+ developerModeEnabled: true,
+ showCachedAnchorGeometry: true,
+ showComfortableAnchorRange: true,
+ comfortableAnchorRangeMeters: 30,
+ }),
+ ).toEqual({ visible: true, showRange: true, rangeMeters: 30 });
+ });
+});
diff --git a/apps/mobile/src/features/field/__tests__/field-hud-state.test.ts b/apps/mobile/src/features/field/__tests__/field-hud-state.test.ts
new file mode 100644
index 00000000..fb019b46
--- /dev/null
+++ b/apps/mobile/src/features/field/__tests__/field-hud-state.test.ts
@@ -0,0 +1,48 @@
+import type { DrillSet } from "@eight2five/mobile/drill";
+
+import {
+ INITIAL_FIELD_HUD_STATE,
+ formatMeasureRange,
+ getDrillSetHudPresentation,
+ reduceFieldHudState,
+} from "../field-hud-state";
+
+const set = (overrides: Partial = {}): DrillSet => ({
+ id: "set-2",
+ drillId: "drill-1",
+ ordinal: 1,
+ number: 2,
+ kind: "set",
+ countsFromPrevious: 16,
+ measureRange: { start: 3, end: 4 },
+ position: { xSteps: 80, ySteps: 28 },
+ ...overrides,
+});
+
+describe("field HUD state", () => {
+ test("keeps expansion as explicit session state", () => {
+ const expanded = reduceFieldHudState(INITIAL_FIELD_HUD_STATE, {
+ type: "toggle-drill-pill",
+ });
+ expect(expanded.drillPillExpanded).toBe(true);
+ expect(
+ reduceFieldHudState(expanded, { type: "collapse-drill-pill" }),
+ ).toEqual({ drillPillExpanded: false });
+ });
+
+ test("formats counts, measures, metrics, terminology, and coordinates centrally", () => {
+ const presentation = getDrillSetHudPresentation({
+ page: set(),
+ previousPage: set({ id: "set-1", ordinal: 0, number: 1 }),
+ terminology: "pages",
+ metricMode: "crossing-counts",
+ });
+ expect(presentation.term).toBe("Page");
+ expect(presentation.counts).toBe("16");
+ expect(presentation.measures).toBe("3–4");
+ expect(presentation.metricLabel).toBe("xCounts");
+ expect(presentation.coordinate).not.toBeNull();
+ expect(formatMeasureRange(undefined)).toBe("–");
+ expect(formatMeasureRange({ start: 5, end: 5 })).toBe("5");
+ });
+});
diff --git a/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts b/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts
new file mode 100644
index 00000000..edc30071
--- /dev/null
+++ b/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts
@@ -0,0 +1,99 @@
+import { getFieldOverlayMetrics } from "../field-overlay-layout";
+
+const insets = { top: 24, right: 10, bottom: 20, left: 10 };
+
+describe("Field overlay layout", () => {
+ test("places a safe-area-aware HUD and right live/dial stack in landscape", () => {
+ const layout = getFieldOverlayMetrics({
+ width: 844,
+ height: 390,
+ landscape: true,
+ insets,
+ });
+
+ expect(layout.controlDiameter).toBeGreaterThanOrEqual(140);
+ expect(layout.controlDiameter).toBeLessThanOrEqual(164);
+ expect(layout.hudStyle.top).toBe(38);
+ expect(layout.hudStyle.left).toBe(24);
+ expect(layout.hudWidth).toBeGreaterThan(0);
+ expect(layout.liveStyle.right).toBe(layout.outerPadding);
+ expect(layout.dialStyle.right).toBe(layout.outerPadding);
+ expect(layout.liveStyle.right).toBe(
+ Number(layout.hudStyle.top) - insets.top,
+ );
+ const topGap = Number(layout.liveStyle.top) - insets.top;
+ const betweenGap =
+ Number(layout.dialStyle.top) -
+ (Number(layout.liveStyle.top) + layout.controlDiameter);
+ const bottomGap =
+ 390 -
+ insets.bottom -
+ (Number(layout.dialStyle.top) + layout.controlDiameter);
+ expect(topGap).toBeCloseTo(layout.controlGap);
+ expect(betweenGap).toBeCloseTo(layout.controlGap);
+ expect(bottomGap).toBeCloseTo(layout.controlGap);
+ });
+
+ test("ignores the large iPhone side inset but keeps balanced landscape padding", () => {
+ const layout = getFieldOverlayMetrics({
+ width: 844,
+ height: 390,
+ landscape: true,
+ insets: { top: 24, right: 48, bottom: 20, left: 10 },
+ });
+
+ expect(layout.liveStyle.right).toBe(layout.outerPadding);
+ expect(layout.dialStyle.right).toBe(layout.outerPadding);
+ });
+
+ test("centers the live/dial pair above the bottom inset in portrait", () => {
+ const layout = getFieldOverlayMetrics({
+ width: 390,
+ height: 844,
+ landscape: false,
+ insets,
+ });
+
+ expect(layout.controlDiameter).toBeGreaterThanOrEqual(140);
+ expect(layout.controlDiameter).toBeLessThanOrEqual(156);
+ expect(layout.hudStyle.left).toBe(22);
+ const leftGap = Number(layout.liveStyle.left) - insets.left;
+ const betweenGap =
+ Number(layout.dialStyle.left) -
+ (Number(layout.liveStyle.left) + layout.controlDiameter);
+ const rightGap =
+ 390 -
+ insets.right -
+ (Number(layout.dialStyle.left) + layout.controlDiameter);
+ expect(leftGap).toBeCloseTo(layout.controlGap);
+ expect(betweenGap).toBeCloseTo(layout.controlGap);
+ expect(rightGap).toBeCloseTo(layout.controlGap);
+ expect(layout.dialStyle.bottom).toBe(insets.bottom + layout.controlGap);
+ });
+
+ test("shrinks both portrait controls together on a narrow safe width", () => {
+ const layout = getFieldOverlayMetrics({
+ width: 360,
+ height: 740,
+ landscape: false,
+ insets: { top: 20, right: 18, bottom: 20, left: 18 },
+ });
+ const safeWidth = 360 - 18 - 18;
+ expect(layout.controlDiameter * 2 + layout.controlGap * 3).toBeCloseTo(
+ safeWidth,
+ );
+ });
+
+ test("uses the full safe landscape width for the drill-off live pill", () => {
+ const layout = getFieldOverlayMetrics({
+ width: 844,
+ height: 390,
+ landscape: true,
+ insets,
+ controlPairVisible: false,
+ });
+ expect(layout.hudWidth).toBe(
+ 844 - insets.left - insets.right - layout.outerPadding * 2,
+ );
+ });
+});
diff --git a/apps/mobile/src/features/field/__tests__/live-position-hud-state.test.ts b/apps/mobile/src/features/field/__tests__/live-position-hud-state.test.ts
new file mode 100644
index 00000000..680f40d5
--- /dev/null
+++ b/apps/mobile/src/features/field/__tests__/live-position-hud-state.test.ts
@@ -0,0 +1,55 @@
+import { EMPTY_FIELD_LIVE_POSITION_STATE } from "@eight2five/mobile/field";
+
+import { getTargetDistancePresentation } from "../live-position-hud-state";
+
+describe("live position HUD state", () => {
+ test("computes physical 8-to-5 distance and threshold tones", () => {
+ const live = {
+ ...EMPTY_FIELD_LIVE_POSITION_STATE,
+ connectionState: "connected" as const,
+ position: { xMeters: 0, yMeters: 0 },
+ isStale: false,
+ };
+ expect(
+ getTargetDistancePresentation({
+ live,
+ target: { xMeters: 0.28575, yMeters: 0 },
+ greenThresholdSteps: 0.5,
+ yellowThresholdSteps: 1,
+ }),
+ ).toMatchObject({ value: "0.5 steps", tone: "success" });
+ expect(
+ getTargetDistancePresentation({
+ live,
+ target: { xMeters: 0.5715, yMeters: 0 },
+ greenThresholdSteps: 0.5,
+ yellowThresholdSteps: 1,
+ }),
+ ).toMatchObject({ value: "one step", tone: "warning" });
+ expect(
+ getTargetDistancePresentation({
+ live,
+ target: { xMeters: 1.143, yMeters: 0 },
+ greenThresholdSteps: 0.5,
+ yellowThresholdSteps: 1,
+ }).tone,
+ ).toBe("danger");
+ expect(
+ getTargetDistancePresentation({
+ live,
+ target: { xMeters: 0.5715 * 0.62, yMeters: 0 },
+ greenThresholdSteps: 1,
+ yellowThresholdSteps: 2,
+ roundingSteps: 0.125,
+ }).value,
+ ).toBe("0.625 steps");
+ expect(
+ getTargetDistancePresentation({
+ live: { ...live, isStale: true },
+ target: { xMeters: 0, yMeters: 0 },
+ greenThresholdSteps: 0.5,
+ yellowThresholdSteps: 1,
+ }),
+ ).toEqual({ value: "–", tone: "muted" });
+ });
+});
diff --git a/apps/mobile/src/features/field/coordinate-lines-view.tsx b/apps/mobile/src/features/field/coordinate-lines-view.tsx
new file mode 100644
index 00000000..91119a96
--- /dev/null
+++ b/apps/mobile/src/features/field/coordinate-lines-view.tsx
@@ -0,0 +1,106 @@
+import React from "react";
+import { ArrowLeftRight, ArrowUpDown } from "lucide-react-native";
+import { HStack } from "@eight2five/ui/components/hstack";
+import { Icon } from "@eight2five/ui/components/icon";
+import { Text } from "@eight2five/ui/components/text";
+import { VStack } from "@eight2five/ui/components/vstack";
+import { eight2FiveFonts } from "@eight2five/ui/theme";
+
+import type { CoordinateLines } from "./field-hud-state";
+
+export function CoordinateLinesView({
+ coordinate,
+ color,
+ mutedColor,
+ fontSize = 14,
+ lineHeight = 17,
+ iconSize = 13,
+}: {
+ readonly coordinate: CoordinateLines | null;
+ readonly color: string;
+ readonly mutedColor: string;
+ readonly fontSize?: number;
+ readonly lineHeight?: number;
+ readonly iconSize?: number;
+}) {
+ if (!coordinate) {
+ return (
+
+ –
+
+ );
+ }
+
+ return (
+
+
+
+
+ );
+}
+
+function CoordinateLine({
+ icon,
+ value,
+ color,
+ iconColor,
+ fontSize,
+ lineHeight,
+ iconSize,
+}: {
+ readonly icon: React.ElementType;
+ readonly value: string;
+ readonly color: string;
+ readonly iconColor: string;
+ readonly fontSize: number;
+ readonly lineHeight: number;
+ readonly iconSize: number;
+}) {
+ return (
+
+
+
+ {value}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-layout.test.ts b/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-layout.test.ts
new file mode 100644
index 00000000..e858aae9
--- /dev/null
+++ b/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-layout.test.ts
@@ -0,0 +1,20 @@
+import { getDrillPillColumnMetrics } from "../drill-pill-layout";
+
+describe("drill pill layout", () => {
+ test.each([
+ [360, false],
+ [390, false],
+ [600, true],
+ ])("prioritizes a usable coordinate column at %ipx", (width, landscape) => {
+ const metrics = getDrillPillColumnMetrics(width, landscape);
+ const used =
+ metrics.horizontalPadding * 2 +
+ metrics.gap * 3 +
+ metrics.setWidth +
+ metrics.countWidth +
+ metrics.metricWidth +
+ metrics.coordinateWidth;
+ expect(used).toBeCloseTo(width);
+ expect(metrics.coordinateWidth).toBeGreaterThanOrEqual(100);
+ });
+});
diff --git a/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-presentation.test.ts b/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-presentation.test.ts
new file mode 100644
index 00000000..408b5890
--- /dev/null
+++ b/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-presentation.test.ts
@@ -0,0 +1,47 @@
+import {
+ getCountMetricPresentation,
+ getTransitionMetricPresentation,
+} from "../drill-pill-presentation";
+
+describe("drill pill metric modes", () => {
+ const rows = [
+ {
+ term: "Set" as const,
+ set: "1",
+ counts: "0",
+ measures: "1",
+ metricLabel: "xCounts" as const,
+ metric: "–",
+ coordinate: null,
+ },
+ {
+ term: "Set" as const,
+ set: "2",
+ counts: "16",
+ measures: "2–3",
+ metricLabel: "xCounts" as const,
+ metric: "8",
+ coordinate: null,
+ },
+ ];
+
+ test("applies one count mode coherently to every visible row", () => {
+ expect(
+ rows.map((row) => getCountMetricPresentation(row, "measures")),
+ ).toMatchObject([
+ { key: "measures", label: "Measures", value: "1" },
+ { key: "measures", label: "Measures", value: "2–3" },
+ ]);
+ });
+
+ test("uses the same animated contract for transition modes", () => {
+ expect(
+ rows.map((row) =>
+ getTransitionMetricPresentation(row, "crossing-counts"),
+ ),
+ ).toMatchObject([
+ { key: "crossing-counts", label: "xCounts" },
+ { key: "crossing-counts", label: "xCounts" },
+ ]);
+ });
+});
diff --git a/apps/mobile/src/features/field/drill-pill/animated-value-switch.tsx b/apps/mobile/src/features/field/drill-pill/animated-value-switch.tsx
new file mode 100644
index 00000000..67c67ab1
--- /dev/null
+++ b/apps/mobile/src/features/field/drill-pill/animated-value-switch.tsx
@@ -0,0 +1,42 @@
+import React from "react";
+import { View, type StyleProp, type ViewStyle } from "react-native";
+import Animated, {
+ FadeIn,
+ FadeOut,
+ ReduceMotion,
+} from "react-native-reanimated";
+
+export function AnimatedValueSwitch({
+ displayKey,
+ children,
+ style,
+ testID,
+}: {
+ readonly displayKey: string;
+ readonly children: React.ReactNode;
+ readonly style?: StyleProp;
+ readonly testID?: string;
+}) {
+ const entering = FadeIn.duration(160).reduceMotion(ReduceMotion.System);
+ const exiting = FadeOut.duration(160).reduceMotion(ReduceMotion.System);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/field/drill-pill/drill-pill-layout.ts b/apps/mobile/src/features/field/drill-pill/drill-pill-layout.ts
new file mode 100644
index 00000000..befff644
--- /dev/null
+++ b/apps/mobile/src/features/field/drill-pill/drill-pill-layout.ts
@@ -0,0 +1,31 @@
+export interface DrillPillColumnMetrics {
+ readonly horizontalPadding: number;
+ readonly gap: number;
+ readonly setWidth: number;
+ readonly countWidth: number;
+ readonly metricWidth: number;
+ readonly coordinateWidth: number;
+}
+
+export function getDrillPillColumnMetrics(
+ width: number,
+ landscape: boolean,
+): DrillPillColumnMetrics {
+ const horizontalPadding = width < 380 || landscape ? 10 : 14;
+ const gap = width < 380 || landscape ? 6 : 10;
+ const contentWidth = Math.max(0, width - horizontalPadding * 2 - gap * 3);
+ const setWidth = Math.min(64, Math.max(48, contentWidth * 0.16));
+ const countWidth = Math.min(82, Math.max(62, contentWidth * 0.2));
+ const metricWidth = Math.min(96, Math.max(72, contentWidth * 0.23));
+ return {
+ horizontalPadding,
+ gap,
+ setWidth,
+ countWidth,
+ metricWidth,
+ coordinateWidth: Math.max(
+ 0,
+ contentWidth - setWidth - countWidth - metricWidth,
+ ),
+ };
+}
diff --git a/apps/mobile/src/features/field/drill-pill/drill-pill-presentation.ts b/apps/mobile/src/features/field/drill-pill/drill-pill-presentation.ts
new file mode 100644
index 00000000..d1227933
--- /dev/null
+++ b/apps/mobile/src/features/field/drill-pill/drill-pill-presentation.ts
@@ -0,0 +1,40 @@
+import type { TransitionMetricMode } from "@eight2five/mobile/settings";
+
+import type {
+ CountDisplayMode,
+ DrillSetHudPresentation,
+} from "../field-hud-state";
+
+export interface AnimatedMetricPresentation {
+ readonly key: string;
+ readonly label: string;
+ readonly value: string;
+}
+
+export function getCountMetricPresentation(
+ presentation: DrillSetHudPresentation,
+ mode: CountDisplayMode,
+): AnimatedMetricPresentation {
+ return mode === "counts"
+ ? {
+ key: mode,
+ label: "Counts",
+ value: presentation.counts,
+ }
+ : {
+ key: mode,
+ label: "Measures",
+ value: presentation.measures,
+ };
+}
+
+export function getTransitionMetricPresentation(
+ presentation: DrillSetHudPresentation,
+ mode: TransitionMetricMode,
+): AnimatedMetricPresentation {
+ return {
+ key: mode,
+ label: presentation.metricLabel,
+ value: presentation.metric,
+ };
+}
diff --git a/apps/mobile/src/features/field/drill-pill/drill-pill.tsx b/apps/mobile/src/features/field/drill-pill/drill-pill.tsx
new file mode 100644
index 00000000..d32b9b93
--- /dev/null
+++ b/apps/mobile/src/features/field/drill-pill/drill-pill.tsx
@@ -0,0 +1,150 @@
+import React from "react";
+import Animated, {
+ ReduceMotion,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+import type { FieldPresetId } from "@eight2five/drill-schema";
+import type { DrillSet, DrillTerminology } from "@eight2five/mobile/drill";
+import type {
+ CoordinateRoundingSteps,
+ TransitionMetricMode,
+} from "@eight2five/mobile/settings";
+import { Divider } from "@eight2five/ui/components/divider";
+import { Text } from "@eight2five/ui/components/text";
+import {
+ eight2FiveRadii,
+ eight2FiveSpacing,
+ useEight2FiveTheme,
+} from "@eight2five/ui/theme";
+
+import {
+ getDrillSetHudPresentation,
+ type CountDisplayMode,
+} from "../field-hud-state";
+import { getDrillPillColumnMetrics } from "./drill-pill-layout";
+import { DRILL_SET_ROW_HEIGHT, DrillSetList } from "./drill-set-list";
+import { DrillSetMetricGrid } from "./drill-set-metric-grid";
+import { FrostedFieldSurface } from "../field-frosted-surface";
+
+export function DrillPill({
+ width,
+ landscape,
+ listMaxHeight,
+ pages,
+ selectedIndex,
+ terminology,
+ countDisplayMode,
+ metricMode,
+ fieldPreset,
+ coordinateRoundingSteps,
+ expanded,
+ controlsDisabled,
+ error,
+ onToggleCounts,
+ onToggleMetric,
+ onToggleExpanded,
+ onSelectIndex,
+}: {
+ readonly width: number;
+ readonly landscape: boolean;
+ readonly listMaxHeight: number;
+ readonly pages: readonly DrillSet[];
+ readonly selectedIndex: number;
+ readonly terminology: DrillTerminology;
+ readonly countDisplayMode: CountDisplayMode;
+ readonly metricMode: TransitionMetricMode;
+ readonly fieldPreset: FieldPresetId;
+ readonly coordinateRoundingSteps: CoordinateRoundingSteps;
+ readonly expanded: boolean;
+ readonly controlsDisabled: boolean;
+ readonly error?: Error;
+ readonly onToggleCounts: () => void;
+ readonly onToggleMetric: () => void;
+ readonly onToggleExpanded?: () => void;
+ readonly onSelectIndex: (index: number) => void;
+}) {
+ const theme = useEight2FiveTheme();
+ const columns = React.useMemo(
+ () => getDrillPillColumnMetrics(width, landscape),
+ [landscape, width],
+ );
+ const current = selectedIndex >= 0 ? pages[selectedIndex] : undefined;
+ const presentation = getDrillSetHudPresentation({
+ page: current,
+ previousPage: selectedIndex > 0 ? pages[selectedIndex - 1] : undefined,
+ metricMode,
+ fieldPreset,
+ terminology,
+ coordinateRoundingSteps,
+ });
+ const availableListHeight = Math.min(
+ listMaxHeight,
+ Math.max(0, pages.length * DRILL_SET_ROW_HEIGHT + 1),
+ );
+ const effectiveExpanded = Boolean(onToggleExpanded && expanded);
+ const animatedHeight = useSharedValue(
+ effectiveExpanded ? availableListHeight : 0,
+ );
+ React.useEffect(() => {
+ animatedHeight.value = withTiming(
+ effectiveExpanded ? availableListHeight : 0,
+ {
+ duration: 220,
+ reduceMotion: ReduceMotion.System,
+ },
+ );
+ }, [animatedHeight, availableListHeight, effectiveExpanded]);
+ const listStyle = useAnimatedStyle(() => ({ height: animatedHeight.value }));
+
+ return (
+
+
+ {error ? (
+
+ {error.message}
+
+ ) : null}
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/field/drill-pill/drill-set-list.tsx b/apps/mobile/src/features/field/drill-pill/drill-set-list.tsx
new file mode 100644
index 00000000..46ade22c
--- /dev/null
+++ b/apps/mobile/src/features/field/drill-pill/drill-set-list.tsx
@@ -0,0 +1,131 @@
+import React from "react";
+import { FlatList, type ListRenderItemInfo } from "react-native";
+import { Pressable } from "@eight2five/ui/components/pressable";
+import type { DrillSet, DrillTerminology } from "@eight2five/mobile/drill";
+import type {
+ CoordinateRoundingSteps,
+ TransitionMetricMode,
+} from "@eight2five/mobile/settings";
+import type { FieldPresetId } from "@eight2five/drill-schema";
+import { useEight2FiveTheme } from "@eight2five/ui/theme";
+
+import {
+ getDrillSetHudPresentation,
+ type CountDisplayMode,
+} from "../field-hud-state";
+import { DrillSetMetricGrid } from "./drill-set-metric-grid";
+import type { DrillPillColumnMetrics } from "./drill-pill-layout";
+
+export const DRILL_SET_ROW_HEIGHT = 104;
+
+export function DrillSetList({
+ pages,
+ selectedIndex,
+ columns,
+ countDisplayMode,
+ metricMode,
+ terminology,
+ fieldPreset,
+ coordinateRoundingSteps,
+ expanded,
+ onSelectIndex,
+}: {
+ readonly pages: readonly DrillSet[];
+ readonly selectedIndex: number;
+ readonly columns: DrillPillColumnMetrics;
+ readonly countDisplayMode: CountDisplayMode;
+ readonly metricMode: TransitionMetricMode;
+ readonly terminology: DrillTerminology;
+ readonly fieldPreset: FieldPresetId;
+ readonly coordinateRoundingSteps: CoordinateRoundingSteps;
+ readonly expanded: boolean;
+ readonly onSelectIndex: (index: number) => void;
+}) {
+ const theme = useEight2FiveTheme();
+ const listRef = React.useRef>(null);
+
+ React.useEffect(() => {
+ if (!expanded || pages.length === 0) return;
+ const frame = requestAnimationFrame(() => {
+ listRef.current?.scrollToOffset({ offset: 0, animated: false });
+ });
+ return () => cancelAnimationFrame(frame);
+ }, [expanded, pages.length]);
+
+ const renderItem = React.useCallback(
+ ({ item, index }: ListRenderItemInfo) => {
+ const selected = index === selectedIndex;
+ const presentation = getDrillSetHudPresentation({
+ page: item,
+ previousPage: index > 0 ? pages[index - 1] : undefined,
+ metricMode,
+ fieldPreset,
+ terminology,
+ coordinateRoundingSteps,
+ });
+ return (
+ onSelectIndex(index)}
+ style={{
+ height: DRILL_SET_ROW_HEIGHT,
+ justifyContent: "center",
+ backgroundColor: selected ? theme.accentSoft : "transparent",
+ }}
+ testID={`drill-set-row-${index}`}
+ >
+
+
+ );
+ },
+ [
+ columns,
+ countDisplayMode,
+ coordinateRoundingSteps,
+ fieldPreset,
+ metricMode,
+ onSelectIndex,
+ pages,
+ selectedIndex,
+ terminology,
+ theme.accentSoft,
+ ],
+ );
+
+ return (
+ page.id}
+ getItemLayout={(_data, index) => ({
+ index,
+ length: DRILL_SET_ROW_HEIGHT,
+ offset: DRILL_SET_ROW_HEIGHT * index,
+ })}
+ extraData={`${countDisplayMode}:${metricMode}:${selectedIndex}`}
+ style={{ flex: 1 }}
+ initialNumToRender={8}
+ maxToRenderPerBatch={8}
+ windowSize={7}
+ nestedScrollEnabled
+ keyboardShouldPersistTaps="handled"
+ onScrollToIndexFailed={({ index }) => {
+ listRef.current?.scrollToOffset({
+ offset: Math.max(0, index * DRILL_SET_ROW_HEIGHT),
+ animated: false,
+ });
+ }}
+ testID="drill-set-list"
+ />
+ );
+}
diff --git a/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx b/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx
new file mode 100644
index 00000000..23cff9d5
--- /dev/null
+++ b/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx
@@ -0,0 +1,334 @@
+import React from "react";
+import { ChevronDown, ChevronUp } from "lucide-react-native";
+import Animated, {
+ interpolateColor,
+ ReduceMotion,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+import { HStack } from "@eight2five/ui/components/hstack";
+import { Icon } from "@eight2five/ui/components/icon";
+import { Pressable } from "@eight2five/ui/components/pressable";
+import { Text } from "@eight2five/ui/components/text";
+import { VStack } from "@eight2five/ui/components/vstack";
+import {
+ eight2FiveFonts,
+ eight2FiveSpacing,
+ useEight2FiveTheme,
+} from "@eight2five/ui/theme";
+import type { TransitionMetricMode } from "@eight2five/mobile/settings";
+
+import type {
+ CountDisplayMode,
+ DrillSetHudPresentation,
+} from "../field-hud-state";
+import { CoordinateLinesView } from "../coordinate-lines-view";
+import { AnimatedValueSwitch } from "./animated-value-switch";
+import type { DrillPillColumnMetrics } from "./drill-pill-layout";
+import {
+ getCountMetricPresentation,
+ getTransitionMetricPresentation,
+} from "./drill-pill-presentation";
+
+export const DrillSetMetricGrid = React.memo(function DrillSetMetricGrid({
+ presentation,
+ columns,
+ countDisplayMode,
+ metricMode,
+ header = false,
+ expanded = false,
+ onToggleCounts,
+ onToggleMetric,
+ onToggleExpanded,
+}: {
+ readonly presentation: DrillSetHudPresentation;
+ readonly columns: DrillPillColumnMetrics;
+ readonly countDisplayMode: CountDisplayMode;
+ readonly metricMode: TransitionMetricMode;
+ readonly header?: boolean;
+ readonly expanded?: boolean;
+ readonly onToggleCounts?: () => void;
+ readonly onToggleMetric?: () => void;
+ readonly onToggleExpanded?: () => void;
+}) {
+ const theme = useEight2FiveTheme();
+ const labelColor = theme.textMuted;
+ const valueColor = theme.text;
+ const count = getCountMetricPresentation(presentation, countDisplayMode);
+ const metric = getTransitionMetricPresentation(presentation, metricMode);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ Coordinate
+
+
+
+ {header && onToggleExpanded ? (
+
+ ) : null}
+
+
+
+ );
+});
+
+function MetricCell({
+ width,
+ label,
+ value,
+ labelColor,
+ valueColor,
+}: {
+ readonly width?: number;
+ readonly label: string;
+ readonly value: string;
+ readonly labelColor: string;
+ readonly valueColor: string;
+}) {
+ return (
+
+
+
+ );
+}
+
+function SwitchingMetricCell({
+ displayKey,
+ label,
+ value,
+ labelColor,
+ valueColor,
+ modeIndex,
+ testID,
+}: {
+ readonly displayKey: string;
+ readonly label: string;
+ readonly value: string;
+ readonly labelColor: string;
+ readonly valueColor: string;
+ readonly modeIndex: 0 | 1;
+ readonly testID?: string;
+}) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+function ModeIndicator({
+ modeIndex,
+ labelColor,
+ valueColor,
+}: {
+ readonly modeIndex: 0 | 1;
+ readonly labelColor: string;
+ readonly valueColor: string;
+}) {
+ const progress = useSharedValue(modeIndex);
+ React.useEffect(() => {
+ progress.value = withTiming(modeIndex, {
+ duration: 180,
+ reduceMotion: ReduceMotion.System,
+ });
+ }, [modeIndex, progress]);
+
+ const topStyle = useAnimatedStyle(() => ({
+ backgroundColor: interpolateColor(
+ progress.value,
+ [0, 1],
+ [valueColor, labelColor],
+ ),
+ opacity: 1 - progress.value * 0.5,
+ }));
+ const bottomStyle = useAnimatedStyle(() => ({
+ backgroundColor: interpolateColor(
+ progress.value,
+ [0, 1],
+ [labelColor, valueColor],
+ ),
+ opacity: 0.5 + progress.value * 0.5,
+ }));
+ const dotStyle = {
+ width: 5,
+ height: 5,
+ borderRadius: 2.5,
+ } as const;
+
+ return (
+
+
+
+
+ );
+}
+
+function MetricText({
+ label,
+ value,
+ labelColor,
+ valueColor,
+}: {
+ readonly label: string;
+ readonly value: string;
+ readonly labelColor: string;
+ readonly valueColor: string;
+}) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/field/effective-field-preset.ts b/apps/mobile/src/features/field/effective-field-preset.ts
new file mode 100644
index 00000000..7bd79c08
--- /dev/null
+++ b/apps/mobile/src/features/field/effective-field-preset.ts
@@ -0,0 +1,10 @@
+import type { Drill } from "@eight2five/mobile/drill";
+import type { FieldPresetId } from "@eight2five/drill-schema";
+
+/** A loaded drill owns its field; the preference is only the no-drill default. */
+export function resolveEffectiveFieldPreset(
+ activeDrill: Pick | undefined,
+ defaultFieldPreset: FieldPresetId,
+): FieldPresetId {
+ return activeDrill?.fieldPreset ?? defaultFieldPreset;
+}
diff --git a/apps/mobile/src/features/field/field-anchor-overlay-options.ts b/apps/mobile/src/features/field/field-anchor-overlay-options.ts
new file mode 100644
index 00000000..961a469c
--- /dev/null
+++ b/apps/mobile/src/features/field/field-anchor-overlay-options.ts
@@ -0,0 +1,16 @@
+import type { FieldAnchorOverlayOptions } from "@eight2five/mobile/field";
+import {
+ getEffectiveDeveloperOverlaySettings,
+ type AppSettings,
+} from "@eight2five/mobile/settings";
+
+export function fieldAnchorOverlayOptions(
+ settings: AppSettings,
+): FieldAnchorOverlayOptions {
+ const effective = getEffectiveDeveloperOverlaySettings(settings);
+ return {
+ visible: effective.showCachedAnchorGeometry,
+ showRange: effective.showComfortableAnchorRange,
+ rangeMeters: settings.comfortableAnchorRangeMeters,
+ };
+}
diff --git a/apps/mobile/src/features/field/field-frosted-surface.tsx b/apps/mobile/src/features/field/field-frosted-surface.tsx
new file mode 100644
index 00000000..82ed1766
--- /dev/null
+++ b/apps/mobile/src/features/field/field-frosted-surface.tsx
@@ -0,0 +1,84 @@
+import React from "react";
+import { BlurView } from "expo-blur";
+import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
+import {
+ useEight2FiveTheme,
+ useEight2FiveThemeName,
+} from "@eight2five/ui/theme";
+
+export const FieldBlurTargetContext =
+ React.createContext | null>(null);
+
+export function FrostedFieldSurface({
+ children,
+ borderRadius,
+ style,
+ intensity = 82,
+ overlayOpacity = 0.72,
+ shadow = true,
+ testID,
+ accessibilityLabel,
+}: {
+ readonly children: React.ReactNode;
+ readonly borderRadius: number;
+ readonly style?: StyleProp;
+ readonly intensity?: number;
+ readonly overlayOpacity?: number;
+ readonly shadow?: boolean;
+ readonly testID?: string;
+ readonly accessibilityLabel?: string;
+}) {
+ const theme = useEight2FiveTheme();
+ const themeName = useEight2FiveThemeName();
+ const blurTarget = React.useContext(FieldBlurTargetContext);
+
+ return (
+
+
+
+ {children}
+
+ );
+}
+
+function colorWithOpacity(color: string, opacity: number): string {
+ const hex = /^#([0-9a-f]{6})$/i.exec(color);
+ if (!hex) return color;
+ const value = hex[1];
+ const red = Number.parseInt(value.slice(0, 2), 16);
+ const green = Number.parseInt(value.slice(2, 4), 16);
+ const blue = Number.parseInt(value.slice(4, 6), 16);
+ return `rgba(${red}, ${green}, ${blue}, ${opacity})`;
+}
diff --git a/apps/mobile/src/features/field/field-hud-state.ts b/apps/mobile/src/features/field/field-hud-state.ts
new file mode 100644
index 00000000..1c88de75
--- /dev/null
+++ b/apps/mobile/src/features/field/field-hud-state.ts
@@ -0,0 +1,134 @@
+import type { FieldPresetId } from "@eight2five/drill-schema";
+import {
+ drillGridPointToMarchingCoordinate,
+ formatMarchingFrontBack,
+ formatMarchingSide,
+} from "@eight2five/mobile/field";
+import {
+ formatSetName,
+ getDrillTerms,
+ type DrillSet,
+ type DrillTerminology,
+} from "@eight2five/mobile/drill";
+import type {
+ CoordinateRoundingSteps,
+ TransitionMetricMode,
+} from "@eight2five/mobile/settings";
+
+import { getTransitionPresentation } from "../drill/transition-presentation";
+
+export type { CountDisplayMode } from "@eight2five/mobile/settings";
+
+export interface FieldHudState {
+ readonly drillPillExpanded: boolean;
+}
+
+export type FieldHudAction =
+ | { readonly type: "toggle-drill-pill" }
+ | { readonly type: "collapse-drill-pill" };
+
+export const INITIAL_FIELD_HUD_STATE: FieldHudState = Object.freeze({
+ drillPillExpanded: false,
+});
+
+export function reduceFieldHudState(
+ state: FieldHudState,
+ action: FieldHudAction,
+): FieldHudState {
+ switch (action.type) {
+ case "toggle-drill-pill":
+ return { ...state, drillPillExpanded: !state.drillPillExpanded };
+ case "collapse-drill-pill":
+ return state.drillPillExpanded
+ ? { ...state, drillPillExpanded: false }
+ : state;
+ }
+}
+
+export interface CoordinateLines {
+ readonly side: string;
+ readonly frontBack: string;
+}
+
+export interface DrillSetHudPresentation {
+ readonly term: "Page" | "Set";
+ readonly set: string;
+ readonly counts: string;
+ readonly measures: string;
+ readonly metricLabel: "Step Size" | "xCounts";
+ readonly metric: string;
+ readonly coordinate: CoordinateLines | null;
+ readonly emptyMessage?: string;
+}
+
+export function formatDrillCoordinateLines(
+ position: DrillSet["position"],
+ fieldPreset: FieldPresetId = "football-nfhs",
+ roundingSteps: CoordinateRoundingSteps = 0.25,
+): CoordinateLines {
+ const coordinate = drillGridPointToMarchingCoordinate(position, fieldPreset);
+ return {
+ side: formatMarchingSide(coordinate.side, roundingSteps),
+ frontBack: formatMarchingFrontBack(
+ coordinate.frontBack,
+ fieldPreset,
+ roundingSteps,
+ ),
+ };
+}
+
+export function getDrillSetHudPresentation({
+ page,
+ previousPage,
+ metricMode,
+ fieldPreset = "football-nfhs",
+ terminology,
+ coordinateRoundingSteps = 0.25,
+}: {
+ readonly page?: DrillSet;
+ readonly previousPage?: DrillSet;
+ readonly metricMode: TransitionMetricMode;
+ readonly fieldPreset?: FieldPresetId;
+ readonly terminology: DrillTerminology;
+ readonly coordinateRoundingSteps?: CoordinateRoundingSteps;
+}): DrillSetHudPresentation {
+ const terms = getDrillTerms(terminology);
+ const metricLabel = metricMode === "step-size" ? "Step Size" : "xCounts";
+ if (!page) {
+ return {
+ term: terms.singular,
+ set: "–",
+ counts: "–",
+ measures: "–",
+ metricLabel,
+ metric: "–",
+ coordinate: null,
+ emptyMessage: `No drill ${terms.lowercaseSingular} selected`,
+ };
+ }
+
+ const transition = getTransitionPresentation(previousPage, page);
+ return {
+ term: terms.singular,
+ set: formatSetName(page),
+ counts: String(page.countsFromPrevious),
+ measures: formatMeasureRange(page.measureRange),
+ metricLabel,
+ metric:
+ metricMode === "step-size"
+ ? transition.stepSize
+ : transition.crossingCounts,
+ coordinate: formatDrillCoordinateLines(
+ page.position,
+ fieldPreset,
+ coordinateRoundingSteps,
+ ),
+ };
+}
+
+export function formatMeasureRange(range: DrillSet["measureRange"]): string {
+ if (!range) return "–";
+ return range.start === range.end
+ ? String(range.start)
+ : `${range.start}–${range.end}`;
+}
diff --git a/apps/mobile/src/features/field/field-overlay-layout.tsx b/apps/mobile/src/features/field/field-overlay-layout.tsx
new file mode 100644
index 00000000..07f483d9
--- /dev/null
+++ b/apps/mobile/src/features/field/field-overlay-layout.tsx
@@ -0,0 +1,208 @@
+import React from "react";
+import { BlurTargetView } from "expo-blur";
+import { View, type ViewStyle } from "react-native";
+
+import { FieldBlurTargetContext } from "./field-frosted-surface";
+import {
+ useSafeAreaInsets,
+ type EdgeInsets,
+} from "react-native-safe-area-context";
+
+export interface FieldOverlayMetrics {
+ readonly outerPadding: number;
+ readonly controlGap: number;
+ readonly controlDiameter: number;
+ /** @deprecated Use controlDiameter. */
+ readonly dialDiameter: number;
+ readonly hudWidth: number;
+ readonly hudListMaxHeight: number;
+ readonly hudStyle: ViewStyle;
+ readonly liveStyle: ViewStyle;
+ readonly dialStyle: ViewStyle;
+}
+
+export function getFieldOverlayMetrics({
+ width,
+ height,
+ landscape,
+ insets,
+ controlPairVisible = true,
+}: {
+ readonly width: number;
+ readonly height: number;
+ readonly landscape: boolean;
+ readonly insets: EdgeInsets;
+ readonly controlPairVisible?: boolean;
+}): FieldOverlayMetrics {
+ const outerPadding = landscape ? 14 : 12;
+ const safeWidth = Math.max(0, width - insets.left - insets.right);
+ const safeHeight = Math.max(0, height - insets.top - insets.bottom);
+
+ if (landscape) {
+ const landscapeEdgePadding = outerPadding;
+ const maximumFittingDiameter = Math.max(
+ 0,
+ (safeHeight - outerPadding * 3) / 2,
+ );
+ const controlDiameter = Math.min(164, maximumFittingDiameter);
+ const controlGap = Math.max(0, (safeHeight - controlDiameter * 2) / 3);
+ const stackTop = insets.top + controlGap;
+ // NativeTabs are hidden on the landscape Field route. Match the right
+ // visual margin to the same padding used between the status-bar safe area
+ // and the top HUD instead of inheriting the much larger side safe inset.
+ const right = landscapeEdgePadding;
+ const columnLeft = width - right - controlDiameter;
+ const hudLeft = insets.left + outerPadding;
+ const hudWidth = controlPairVisible
+ ? Math.max(0, Math.min(720, columnLeft - controlGap - hudLeft))
+ : Math.max(0, safeWidth - outerPadding * 2);
+ const hudTop = insets.top + outerPadding;
+ return {
+ outerPadding,
+ controlGap,
+ controlDiameter,
+ dialDiameter: controlDiameter,
+ hudWidth,
+ hudListMaxHeight: Math.min(
+ 320,
+ Math.max(0, height - insets.bottom - outerPadding - hudTop - 82),
+ ),
+ hudStyle: {
+ position: "absolute",
+ top: hudTop,
+ left: hudLeft,
+ width: hudWidth,
+ },
+ liveStyle: {
+ position: "absolute",
+ right,
+ top: stackTop,
+ width: controlDiameter,
+ height: controlDiameter,
+ },
+ dialStyle: {
+ position: "absolute",
+ right,
+ top: stackTop + controlDiameter + controlGap,
+ width: controlDiameter,
+ height: controlDiameter,
+ },
+ };
+ }
+
+ const maximumFittingDiameter = Math.max(
+ 0,
+ (safeWidth - outerPadding * 3) / 2,
+ );
+ const controlDiameter = Math.min(156, maximumFittingDiameter);
+ const controlGap = Math.max(0, (safeWidth - controlDiameter * 2) / 3);
+ const pairLeft = insets.left + controlGap;
+ const controlsBottom = insets.bottom + controlGap;
+ const controlsTop = height - controlsBottom - controlDiameter;
+ const hudLeft = insets.left + outerPadding;
+ const hudWidth = Math.max(0, safeWidth - outerPadding * 2);
+ const hudTop = insets.top + outerPadding;
+ return {
+ outerPadding,
+ controlGap,
+ controlDiameter,
+ dialDiameter: controlDiameter,
+ hudWidth,
+ hudListMaxHeight: Math.min(
+ 360,
+ Math.max(0, controlsTop - controlGap - hudTop - 82),
+ ),
+ hudStyle: {
+ position: "absolute",
+ top: hudTop,
+ left: hudLeft,
+ width: hudWidth,
+ },
+ liveStyle: {
+ position: "absolute",
+ left: pairLeft,
+ bottom: controlsBottom,
+ width: controlDiameter,
+ height: controlDiameter,
+ },
+ dialStyle: {
+ position: "absolute",
+ left: pairLeft + controlDiameter + controlGap,
+ bottom: controlsBottom,
+ width: controlDiameter,
+ height: controlDiameter,
+ },
+ };
+}
+
+interface FieldOverlayLayoutProps {
+ readonly width: number;
+ readonly height: number;
+ readonly landscape: boolean;
+ readonly controlPairVisible?: boolean;
+ readonly field: React.ReactNode;
+ readonly hud?: (metrics: FieldOverlayMetrics) => React.ReactNode;
+ readonly live?: (diameter: number) => React.ReactNode;
+ readonly dial?: (diameter: number) => React.ReactNode;
+}
+
+export function FieldOverlayLayout({
+ width,
+ height,
+ landscape,
+ controlPairVisible = true,
+ field,
+ hud,
+ live,
+ dial,
+}: FieldOverlayLayoutProps) {
+ const insets = useSafeAreaInsets();
+ const blurTargetRef = React.useRef(null);
+ const metrics = getFieldOverlayMetrics({
+ width,
+ height,
+ landscape,
+ insets,
+ controlPairVisible,
+ });
+
+ return (
+
+
+
+ {field}
+
+ {hud ? (
+
+ {hud(metrics)}
+
+ ) : null}
+ {live ? (
+
+ {live(metrics.controlDiameter)}
+
+ ) : null}
+ {dial ? (
+
+ {dial(metrics.controlDiameter)}
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/field/field-screen.tsx b/apps/mobile/src/features/field/field-screen.tsx
new file mode 100644
index 00000000..87f09fd6
--- /dev/null
+++ b/apps/mobile/src/features/field/field-screen.tsx
@@ -0,0 +1,331 @@
+import React from "react";
+import {
+ EMPTY_FIELD_LIVE_POSITION_STATE,
+ drillGridPointToFieldPoint,
+ shouldShowFieldGuidanceForScene,
+ shouldShowFieldTarget,
+ resolveCurrentTargetPosition,
+ type FieldAnchorGeometry,
+ type FieldAnchorOverlayOptions,
+ type FieldLivePositionInput,
+ type FieldPoint,
+} from "@eight2five/mobile/field";
+import { formatSetName } from "@eight2five/mobile/drill";
+import {
+ FIELD_FOUR_STEP_GRID_COLOR,
+ FieldCanvas,
+} from "@eight2five/mobile/field/render";
+import { useEight2FiveTheme } from "@eight2five/ui/theme";
+import { useSharedValue, type SharedValue } from "react-native-reanimated";
+
+import { FieldOverlayLayout } from "./field-overlay-layout";
+import { useFieldScreenController } from "./use-field-screen-controller";
+import { DrillSelectionDialog } from "../drill/components/drill-selection-dialog";
+import { PerformerSelectionDialog } from "../drill/components/performer-selection-dialog";
+import { DrillPill } from "./drill-pill/drill-pill";
+import {
+ INITIAL_FIELD_HUD_STATE,
+ reduceFieldHudState,
+} from "./field-hud-state";
+import { LiveOnlyPill, LivePositionSquare } from "./live-position-hud";
+import { PageDial } from "./page-dial/page-dial";
+import { TagConnectionDialog } from "./tag-connection-dialog";
+
+const EMPTY_ANCHORS: readonly FieldAnchorGeometry[] = Object.freeze([]);
+
+function setLivePositionValue(
+ sharedValue: SharedValue,
+ position: FieldPoint | null,
+): void {
+ sharedValue.value = position;
+}
+
+export function FieldScreen({
+ livePosition,
+ anchors = EMPTY_ANCHORS,
+ anchorOverlayOptions,
+}: {
+ readonly livePosition?: FieldLivePositionInput;
+ readonly anchors?: readonly FieldAnchorGeometry[];
+ readonly anchorOverlayOptions?: FieldAnchorOverlayOptions;
+}) {
+ const theme = useEight2FiveTheme();
+ const controller = useFieldScreenController();
+ const [hudState, dispatchHud] = React.useReducer(
+ reduceFieldHudState,
+ INITIAL_FIELD_HUD_STATE,
+ );
+ const [drillDialogOpen, setDrillDialogOpen] = React.useState(false);
+ const [performerDialogOpen, setPerformerDialogOpen] = React.useState(false);
+ const [tagDialogOpen, setTagDialogOpen] = React.useState(false);
+ const pansLiveState = livePosition?.state ?? EMPTY_FIELD_LIVE_POSITION_STATE;
+ const mockLivePositionEnabled =
+ controller.settings.developerModeEnabled &&
+ controller.settings.mockLivePositionEnabled;
+ const mockPosition = React.useMemo(
+ () =>
+ mockLivePositionEnabled
+ ? drillGridPointToFieldPoint(
+ {
+ xSteps: controller.settings.mockLivePositionXSteps,
+ ySteps: controller.settings.mockLivePositionYSteps,
+ },
+ controller.fieldPreset,
+ )
+ : null,
+ [
+ controller.fieldPreset,
+ controller.settings.mockLivePositionXSteps,
+ controller.settings.mockLivePositionYSteps,
+ mockLivePositionEnabled,
+ ],
+ );
+ const liveState = mockPosition
+ ? {
+ ...pansLiveState,
+ position: mockPosition,
+ isStale: false,
+ interpolationActive: false,
+ }
+ : pansLiveState;
+ const fallbackLivePosition = useSharedValue(
+ pansLiveState.position ?? null,
+ );
+ const mockLivePosition = useSharedValue(mockPosition);
+ const livePositionValue = mockLivePositionEnabled
+ ? mockLivePosition
+ : (livePosition?.positionValue ?? fallbackLivePosition);
+ const liveXMeters = pansLiveState.position?.xMeters;
+ const liveYMeters = pansLiveState.position?.yMeters;
+ React.useEffect(() => {
+ if (livePosition?.positionValue) return;
+ setLivePositionValue(
+ fallbackLivePosition,
+ liveXMeters === undefined || liveYMeters === undefined
+ ? null
+ : { xMeters: liveXMeters, yMeters: liveYMeters },
+ );
+ }, [
+ fallbackLivePosition,
+ livePosition?.positionValue,
+ liveXMeters,
+ liveYMeters,
+ ]);
+ React.useEffect(() => {
+ setLivePositionValue(mockLivePosition, mockPosition);
+ }, [mockLivePosition, mockPosition]);
+ const activeDrillId = controller.settings.activeDrillId;
+ React.useEffect(() => {
+ dispatchHud({ type: "collapse-drill-pill" });
+ }, [activeDrillId]);
+ const drillOverlayState = {
+ drillFeaturesEnabled: controller.settings.drillFeaturesEnabled,
+ hasActiveDrill: Boolean(controller.activeDrill),
+ hasSelectedPage: Boolean(controller.selectedPage),
+ hasLivePosition: Boolean(liveState.position) && !liveState.isStale,
+ guidanceEnabled: controller.settings.guidanceEnabled,
+ };
+ const fallbackTargetPosition =
+ shouldShowFieldTarget(drillOverlayState) && controller.selectedPage
+ ? drillGridPointToFieldPoint(
+ controller.selectedPage.position,
+ controller.fieldPreset,
+ )
+ : undefined;
+ const drillScene = controller.settings.drillFeaturesEnabled
+ ? controller.drillScene
+ : undefined;
+ const targetPolicy = {
+ fullDrillSceneAvailable: drillScene !== undefined,
+ sceneHasCurrent:
+ drillScene?.current !== undefined && drillScene?.current !== null,
+ legacyFallbackAvailable: fallbackTargetPosition !== undefined,
+ } as const;
+ const targetPosition = resolveCurrentTargetPosition({
+ fullDrillSceneAvailable: targetPolicy.fullDrillSceneAvailable,
+ sceneCurrent: drillScene?.current,
+ legacyFallback: fallbackTargetPosition,
+ });
+ const guidanceVisible = shouldShowFieldGuidanceForScene(
+ drillOverlayState,
+ targetPolicy,
+ );
+ const controlsDisabled =
+ controller.settingsStatus !== "ready" ||
+ controller.loadingDrills ||
+ controller.selectionBusy;
+ const canExpandDrillPill = Boolean(
+ controller.activeDrill && controller.pages.length > 0,
+ );
+ const palette = React.useMemo(
+ () => ({
+ canvasBackground: theme.background,
+ stepGrid: theme.textSubtle,
+ fieldBackground: theme.surfaceRaised,
+ fourStepGrid: FIELD_FOUR_STEP_GRID_COLOR,
+ fieldLines: theme.textMuted,
+ fieldNumbers: theme.textMuted,
+ livePosition: theme.accent,
+ guidance: theme.accent,
+ anchor: theme.accent,
+ anchorRange: colorWithAlpha(theme.accent, "24"),
+ }),
+ [theme],
+ );
+
+ return (
+ <>
+
+ }
+ hud={(metrics) =>
+ controller.settings.drillFeaturesEnabled ? (
+ void controller.toggleCountDisplayMode()}
+ onToggleMetric={() => void controller.toggleMetricMode()}
+ onToggleExpanded={
+ canExpandDrillPill
+ ? () => dispatchHud({ type: "toggle-drill-pill" })
+ : undefined
+ }
+ onSelectIndex={(index) =>
+ void controller.selectPageAtIndex(index)
+ }
+ />
+ ) : (
+ setTagDialogOpen(true)}
+ />
+ )
+ }
+ live={
+ controller.settings.drillFeaturesEnabled
+ ? (diameter) => (
+ setTagDialogOpen(true)}
+ />
+ )
+ : undefined
+ }
+ dial={
+ controller.settings.drillFeaturesEnabled
+ ? (diameter) => (
+
+ void controller.selectPageAtIndex(index)
+ }
+ onSelectDrill={() => setDrillDialogOpen(true)}
+ onSelectPerformer={
+ controller.activeDrillDocument
+ ? () => setPerformerDialogOpen(true)
+ : undefined
+ }
+ />
+ )
+ : undefined
+ }
+ />
+ setDrillDialogOpen(false)}
+ />
+ setPerformerDialogOpen(false)}
+ onConfirm={async (performerEntityId) => {
+ const saved = await controller.selectPerformer(performerEntityId);
+ if (saved) setPerformerDialogOpen(false);
+ }}
+ />
+ setTagDialogOpen(false)}
+ />
+ >
+ );
+}
+
+function colorWithAlpha(color: string, alpha: string): string {
+ return /^#[0-9a-f]{6}$/i.test(color) ? `${color}${alpha}` : color;
+}
diff --git a/apps/mobile/src/features/field/live-position-hud-state.ts b/apps/mobile/src/features/field/live-position-hud-state.ts
new file mode 100644
index 00000000..db99aba2
--- /dev/null
+++ b/apps/mobile/src/features/field/live-position-hud-state.ts
@@ -0,0 +1,73 @@
+import type { FieldPresetId } from "@eight2five/drill-schema";
+import type { CoordinateRoundingSteps } from "@eight2five/mobile/settings";
+import {
+ fieldPointToMarchingCoordinate,
+ formatMarchingFrontBack,
+ formatMarchingSide,
+ formatMarchingSteps,
+ metersToStandardSteps,
+ type FieldLivePositionState,
+ type FieldPoint,
+} from "@eight2five/mobile/field";
+
+import type { CoordinateLines } from "./field-hud-state";
+
+export function getLiveCoordinateLines(
+ live: FieldLivePositionState,
+ fieldPreset: FieldPresetId = "football-nfhs",
+ roundingSteps: CoordinateRoundingSteps = 0.25,
+): CoordinateLines | null {
+ if (!live.position || live.isStale) return null;
+ const coordinate = fieldPointToMarchingCoordinate(live.position, fieldPreset);
+ return {
+ side: formatMarchingSide(coordinate.side, roundingSteps),
+ frontBack: formatMarchingFrontBack(
+ coordinate.frontBack,
+ fieldPreset,
+ roundingSteps,
+ ),
+ };
+}
+
+export type DistanceTone = "success" | "warning" | "danger" | "muted";
+
+export interface TargetDistancePresentation {
+ readonly steps?: number;
+ readonly value: string;
+ readonly tone: DistanceTone;
+}
+
+export function getTargetDistancePresentation({
+ live,
+ target,
+ greenThresholdSteps,
+ yellowThresholdSteps,
+ roundingSteps = 0.25,
+}: {
+ readonly live: FieldLivePositionState;
+ readonly target?: FieldPoint;
+ readonly greenThresholdSteps: number;
+ readonly yellowThresholdSteps: number;
+ readonly roundingSteps?: CoordinateRoundingSteps;
+}): TargetDistancePresentation {
+ if (!live.position || live.isStale || !target) {
+ return { value: "–", tone: "muted" };
+ }
+
+ const distanceMeters = Math.hypot(
+ live.position.xMeters - target.xMeters,
+ live.position.yMeters - target.yMeters,
+ );
+ const steps = metersToStandardSteps(distanceMeters);
+ const roundedSteps = formatMarchingSteps(steps, roundingSteps);
+ return {
+ steps,
+ value: Number(roundedSteps) === 1 ? "one step" : `${roundedSteps} steps`,
+ tone:
+ steps <= greenThresholdSteps
+ ? "success"
+ : steps <= yellowThresholdSteps
+ ? "warning"
+ : "danger",
+ };
+}
diff --git a/apps/mobile/src/features/field/live-position-hud.tsx b/apps/mobile/src/features/field/live-position-hud.tsx
new file mode 100644
index 00000000..5428fc38
--- /dev/null
+++ b/apps/mobile/src/features/field/live-position-hud.tsx
@@ -0,0 +1,343 @@
+import React from "react";
+import { Animated, Easing } from "react-native";
+import {
+ BluetoothConnected,
+ BluetoothOff,
+ LoaderCircle,
+ RulerDimensionLine,
+ TriangleAlert,
+} from "lucide-react-native";
+import type {
+ FieldConnectionState,
+ FieldLivePositionState,
+ FieldPoint,
+} from "@eight2five/mobile/field";
+import type { FieldPresetId } from "@eight2five/drill-schema";
+import type { CoordinateRoundingSteps } from "@eight2five/mobile/settings";
+import { Divider } from "@eight2five/ui/components/divider";
+import { HStack } from "@eight2five/ui/components/hstack";
+import { Icon } from "@eight2five/ui/components/icon";
+import { Pressable } from "@eight2five/ui/components/pressable";
+import { Text } from "@eight2five/ui/components/text";
+import { VStack } from "@eight2five/ui/components/vstack";
+import {
+ eight2FiveFonts,
+ eight2FiveRadii,
+ eight2FiveSpacing,
+ useEight2FiveTheme,
+} from "@eight2five/ui/theme";
+
+import {
+ getLiveCoordinateLines,
+ getTargetDistancePresentation,
+ type DistanceTone,
+} from "./live-position-hud-state";
+import { CoordinateLinesView } from "./coordinate-lines-view";
+import { FrostedFieldSurface } from "./field-frosted-surface";
+
+export function LivePositionSquare({
+ diameter,
+ live,
+ target,
+ fieldPreset,
+ greenThresholdSteps,
+ yellowThresholdSteps,
+ coordinateRoundingSteps,
+ onOpenTagConnection,
+}: {
+ readonly diameter: number;
+ readonly live: FieldLivePositionState;
+ readonly target?: FieldPoint;
+ readonly fieldPreset: FieldPresetId;
+ readonly greenThresholdSteps: number;
+ readonly yellowThresholdSteps: number;
+ readonly coordinateRoundingSteps: CoordinateRoundingSteps;
+ readonly onOpenTagConnection: () => void;
+}) {
+ const theme = useEight2FiveTheme();
+ const distance = getTargetDistancePresentation({
+ live,
+ target,
+ greenThresholdSteps,
+ yellowThresholdSteps,
+ roundingSteps: coordinateRoundingSteps,
+ });
+ const distanceColor = colorForDistanceTone(distance.tone, theme);
+ const dividerThickness = 1;
+ const iconColumnWidth = 32;
+ const lowerSectionHeight = Math.max(0, (diameter - dividerThickness) / 3);
+ const sectionPadding = Math.max(
+ 0,
+ (lowerSectionHeight - iconColumnWidth) / 2,
+ );
+ const rowGap = eight2FiveSpacing.sm;
+
+ const radius = Math.min(eight2FiveRadii.lg, diameter * 0.16);
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {distance.value}
+
+
+
+
+ );
+}
+
+export function LiveOnlyPill({
+ width,
+ live,
+ fieldPreset,
+ coordinateRoundingSteps,
+ onOpenTagConnection,
+}: {
+ readonly width: number;
+ readonly live: FieldLivePositionState;
+ readonly fieldPreset: FieldPresetId;
+ readonly coordinateRoundingSteps: CoordinateRoundingSteps;
+ readonly onOpenTagConnection: () => void;
+}) {
+ return (
+
+
+
+ );
+}
+
+function LivePositionHeader({
+ live,
+ fieldPreset,
+ compact = false,
+ horizontalPadding,
+ gap,
+ iconColumnWidth,
+ coordinateRoundingSteps,
+ onOpenTagConnection,
+}: {
+ readonly live: FieldLivePositionState;
+ readonly fieldPreset: FieldPresetId;
+ readonly compact?: boolean;
+ readonly horizontalPadding?: number;
+ readonly gap?: number;
+ readonly iconColumnWidth?: number;
+ readonly coordinateRoundingSteps: CoordinateRoundingSteps;
+ readonly onOpenTagConnection: () => void;
+}) {
+ const theme = useEight2FiveTheme();
+ const coordinate = getLiveCoordinateLines(
+ live,
+ fieldPreset,
+ coordinateRoundingSteps,
+ );
+ return (
+
+
+
+
+
+
+ );
+}
+
+function BluetoothStatusButton({
+ state,
+ size = 44,
+ onPress,
+}: {
+ readonly state: FieldConnectionState;
+ readonly size?: number;
+ readonly onPress: () => void;
+}) {
+ const theme = useEight2FiveTheme();
+ const [spin] = React.useState(() => new Animated.Value(0));
+ const animated = state === "connecting" || state === "reconnecting";
+ React.useEffect(() => {
+ if (!animated) {
+ spin.stopAnimation();
+ spin.setValue(0);
+ return;
+ }
+ const animation = Animated.loop(
+ Animated.timing(spin, {
+ toValue: 1,
+ duration: 900,
+ easing: Easing.linear,
+ useNativeDriver: true,
+ }),
+ );
+ animation.start();
+ return () => animation.stop();
+ }, [animated, spin]);
+
+ const presentation = getConnectionIconPresentation(state);
+ const color =
+ presentation.tone === "success"
+ ? theme.success
+ : presentation.tone === "accent"
+ ? theme.accent
+ : presentation.tone === "danger"
+ ? theme.danger
+ : theme.textMuted;
+ const icon = ;
+
+ return (
+
+ {animated ? (
+
+ {icon}
+
+ ) : (
+ icon
+ )}
+
+ );
+}
+
+function getConnectionIconPresentation(state: FieldConnectionState) {
+ switch (state) {
+ case "connected":
+ return {
+ label: "Connected",
+ icon: BluetoothConnected,
+ tone: "success" as const,
+ };
+ case "connecting":
+ case "reconnecting":
+ return {
+ label: state === "connecting" ? "Connecting" : "Reconnecting",
+ icon: LoaderCircle,
+ tone: "accent" as const,
+ };
+ case "error":
+ return {
+ label: "Connection error",
+ icon: TriangleAlert,
+ tone: "danger" as const,
+ };
+ case "idle":
+ case "disconnected":
+ return {
+ label: "Disconnected",
+ icon: BluetoothOff,
+ tone: "muted" as const,
+ };
+ }
+}
+
+function colorForDistanceTone(
+ tone: DistanceTone,
+ theme: ReturnType,
+): string {
+ switch (tone) {
+ case "success":
+ return theme.success;
+ case "warning":
+ return theme.warning;
+ case "danger":
+ return theme.danger;
+ case "muted":
+ return theme.textMuted;
+ }
+}
diff --git a/apps/mobile/src/features/field/page-dial/__tests__/page-dial-math.test.ts b/apps/mobile/src/features/field/page-dial/__tests__/page-dial-math.test.ts
new file mode 100644
index 00000000..b6ce450f
--- /dev/null
+++ b/apps/mobile/src/features/field/page-dial/__tests__/page-dial-math.test.ts
@@ -0,0 +1,176 @@
+import {
+ getPageDialCardinalPoints,
+ getPageDialDividerSegments,
+ getPageDialRingHitRegion,
+ pageDialAngleIsInValidArc,
+ pageDialIndexForProgress,
+ pageDialPointForProgress,
+ pageDialPointIsInRingHitRegion,
+ pageDialProgressForAngle,
+ pageDialProgressForPoint,
+ pageDialProgressForPointNearReference,
+ PAGE_DIAL_START_ANGLE_DEGREES,
+ PAGE_DIAL_USABLE_ARC_DEGREES,
+ normalizePageIndex,
+ pageDialAngleForIndex,
+ pageDialIndexForAngle,
+} from "../page-dial-math";
+import {
+ getPageDialAccessibilityLabel,
+ getPageDialControlState,
+ getPageDialProportions,
+} from "../page-dial-layout";
+
+const radians = (degrees: number) => (degrees * Math.PI) / 180;
+
+describe("page dial math", () => {
+ test("maps first and last pages to the shared top endpoint", () => {
+ expect(normalizePageIndex(0, 38)).toBe(0);
+ expect(normalizePageIndex(37, 38)).toBe(1);
+ expect(pageDialAngleForIndex(0, 38)).toBeCloseTo(
+ radians(PAGE_DIAL_START_ANGLE_DEGREES),
+ );
+ expect(pageDialAngleForIndex(37, 38)).toBeCloseTo(
+ radians(PAGE_DIAL_START_ANGLE_DEGREES + PAGE_DIAL_USABLE_ARC_DEGREES),
+ );
+ const first = pageDialPointForProgress(0, 200);
+ const last = pageDialPointForProgress(1, 200);
+ expect(first.x).toBeCloseTo(last.x);
+ expect(first.y).toBeCloseTo(last.y);
+ });
+
+ test("uses drag history to disambiguate the shared top endpoint", () => {
+ const diameter = 200;
+ const center = diameter / 2;
+ const topY = center - 90;
+ expect(
+ pageDialProgressForPointNearReference(center, topY, diameter, 0.02),
+ ).toBe(0);
+ expect(
+ pageDialProgressForPointNearReference(center, topY, diameter, 0.98),
+ ).toBe(1);
+
+ const clockwisePastTop = pageDialPointForProgress(0.01, diameter, 90);
+ expect(
+ pageDialProgressForPointNearReference(
+ clockwisePastTop.x,
+ clockwisePastTop.y,
+ diameter,
+ 1,
+ ),
+ ).toBeCloseTo(0.01);
+
+ const counterClockwisePastTop = pageDialPointForProgress(
+ 0.99,
+ diameter,
+ 90,
+ );
+ expect(
+ pageDialProgressForPointNearReference(
+ counterClockwisePastTop.x,
+ counterClockwisePastTop.y,
+ diameter,
+ 0,
+ ),
+ ).toBeCloseTo(0.99);
+
+ expect(pageDialIndexForAngle(radians(-89), 38)).toBe(0);
+ expect(pageDialIndexForAngle(radians(-91), 38)).toBe(37);
+ expect(pageDialIndexForAngle(radians(90), 5)).toBe(2);
+ });
+
+ test("keeps cardinal angles on one continuous full circle", () => {
+ expect(pageDialProgressForAngle(radians(0))).toBeCloseTo(0.25);
+ expect(pageDialProgressForAngle(radians(90))).toBeCloseTo(0.5);
+ expect(pageDialProgressForAngle(radians(180))).toBeCloseTo(0.75);
+ expect(pageDialAngleIsInValidArc(radians(-90))).toBe(true);
+ expect(pageDialAngleIsInValidArc(radians(270))).toBe(true);
+ expect(pageDialIndexForProgress(0.5, 5)).toBe(2);
+ });
+
+ test("accepts an enlarged radial ring hit region but not its center", () => {
+ const diameter = 200;
+ const center = diameter / 2;
+ const region = getPageDialRingHitRegion(diameter);
+ expect(
+ pageDialPointIsInRingHitRegion(
+ center + region.innerRadius - 0.1,
+ center,
+ diameter,
+ ),
+ ).toBe(false);
+ expect(
+ pageDialPointIsInRingHitRegion(
+ center + (region.innerRadius + region.outerRadius) / 2,
+ center,
+ diameter,
+ ),
+ ).toBe(true);
+ expect(
+ pageDialPointIsInRingHitRegion(
+ center + region.outerRadius + 0.1,
+ center,
+ diameter,
+ ),
+ ).toBe(false);
+ expect(pageDialProgressForPoint(center, center, diameter)).toBe(0);
+ });
+
+ test("disables unavailable first and last actions", () => {
+ expect(getPageDialControlState(0, 4)).toEqual({
+ previousDisabled: true,
+ nextDisabled: false,
+ });
+ expect(getPageDialControlState(3, 4)).toEqual({
+ previousDisabled: false,
+ nextDisabled: true,
+ });
+ expect(getPageDialControlState(-1, 4)).toEqual({
+ previousDisabled: true,
+ nextDisabled: false,
+ });
+ });
+
+ test("uses supplied proportions and terminology-aware accessibility", () => {
+ const proportions = getPageDialProportions(100);
+ expect(proportions.ringThickness).toBeCloseTo(7.5);
+ expect(proportions.innerDiskDiameter).toBeCloseTo(86);
+ expect(proportions.centerDiskDiameter).toBeCloseTo(30);
+ expect(proportions.centerBorderWidth).toBe(0);
+ expect(proportions.knobDiameter).toBeCloseTo(16);
+ expect(proportions.controlCenterOffset).toBeCloseTo(29);
+ expect(proportions.controlButtonSize).toBeGreaterThanOrEqual(44);
+ expect(proportions.canvasOverscan).toBeGreaterThan(proportions.knobRadius);
+ expect(
+ getPageDialAccessibilityLabel({
+ selectedIndex: 21,
+ selectedLabel: "22",
+ pageCount: 38,
+ terminology: "sets",
+ }),
+ ).toBe("Set selector, set 22 of 38");
+ });
+
+ test("places controls at four equal cardinal points and keeps the knob in overscan", () => {
+ const diameter = 200;
+ const points = getPageDialCardinalPoints(diameter);
+ const center = diameter / 2;
+ const distances = [
+ Math.hypot(points.top.x - center, points.top.y - center),
+ Math.hypot(points.right.x - center, points.right.y - center),
+ Math.hypot(points.bottom.x - center, points.bottom.y - center),
+ Math.hypot(points.left.x - center, points.left.y - center),
+ ];
+ distances.forEach((distance) => expect(distance).toBeCloseTo(distances[0]));
+
+ const proportions = getPageDialProportions(diameter);
+ const knob = pageDialPointForProgress(0, diameter, proportions.ringRadius);
+ expect(knob.x - proportions.knobRadius).toBeGreaterThanOrEqual(
+ -proportions.canvasOverscan,
+ );
+ expect(knob.y - proportions.knobRadius).toBeGreaterThanOrEqual(
+ -proportions.canvasOverscan,
+ );
+ expect(getPageDialDividerSegments(diameter)).toHaveLength(4);
+ });
+});
diff --git a/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx b/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx
new file mode 100644
index 00000000..67bd19fd
--- /dev/null
+++ b/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx
@@ -0,0 +1,47 @@
+import { FieldPageDialCanvas } from "@eight2five/mobile/field/render";
+import { useDerivedValue, type SharedValue } from "react-native-reanimated";
+
+import {
+ PAGE_DIAL_START_ANGLE_DEGREES,
+ PAGE_DIAL_USABLE_ARC_DEGREES,
+} from "./page-dial-math";
+
+export function PageDialCanvas({
+ diameter,
+ pageCount,
+ provisionalProgress,
+ activeColor,
+ trackColor,
+ innerColor,
+ backgroundColor,
+ knobColor,
+}: {
+ readonly diameter: number;
+ readonly pageCount: number;
+ readonly provisionalProgress: SharedValue;
+ readonly activeColor: string;
+ readonly trackColor: string;
+ readonly innerColor?: string;
+ readonly backgroundColor?: string;
+ readonly knobColor?: string;
+}) {
+ const progress = useDerivedValue(() => {
+ if (pageCount <= 0 || !Number.isFinite(provisionalProgress.value)) return 0;
+ return Math.min(1, Math.max(0, provisionalProgress.value));
+ });
+ return (
+
+ );
+}
diff --git a/apps/mobile/src/features/field/page-dial/page-dial-controls.tsx b/apps/mobile/src/features/field/page-dial/page-dial-controls.tsx
new file mode 100644
index 00000000..65378ccf
--- /dev/null
+++ b/apps/mobile/src/features/field/page-dial/page-dial-controls.tsx
@@ -0,0 +1,211 @@
+import type { ViewStyle } from "react-native";
+import { Center } from "@eight2five/ui/components/center";
+import { Divider } from "@eight2five/ui/components/divider";
+import { Icon } from "@eight2five/ui/components/icon";
+import { Pressable } from "@eight2five/ui/components/pressable";
+import { Text } from "@eight2five/ui/components/text";
+import { CircleUserRound, Folder, Minus, Plus } from "lucide-react-native";
+import { getDrillTerms, type DrillTerminology } from "@eight2five/mobile/drill";
+
+import {
+ getPageDialAccessibilityLabel,
+ getPageDialControlState,
+ getPageDialProportions,
+} from "./page-dial-layout";
+import {
+ getPageDialCardinalPoints,
+ getPageDialControlSize,
+ getPageDialDividerSegments,
+ type PageDialLineSegment,
+} from "./page-dial-math";
+
+export function PageDialControls({
+ diameter,
+ selectedIndex,
+ selectedLabel,
+ pageCount,
+ terminology,
+ onPrevious,
+ onNext,
+ onSelectDrill,
+ onSelectPerformer,
+ foregroundColor = "#FFFFFF",
+}: {
+ readonly diameter: number;
+ readonly selectedIndex: number;
+ readonly selectedLabel?: string;
+ readonly pageCount: number;
+ readonly terminology: DrillTerminology;
+ readonly onPrevious: () => void;
+ readonly onNext: () => void;
+ readonly onSelectDrill?: () => void;
+ readonly onSelectPerformer?: () => void;
+ readonly foregroundColor?: string;
+}) {
+ const terms = getDrillTerms(terminology);
+ const proportions = getPageDialProportions(diameter);
+ const state = getPageDialControlState(selectedIndex, pageCount);
+ const buttonSize = getPageDialControlSize(diameter);
+ const center = diameter / 2;
+ const controlCenters = getPageDialCardinalPoints(
+ diameter,
+ proportions.controlCenterOffset,
+ );
+ const centerDiameter = proportions.centerDiskDiameter;
+ const buttonStyle = (x: number, y: number, disabled = false) => ({
+ position: "absolute" as const,
+ left: x - buttonSize / 2,
+ top: y - buttonSize / 2,
+ width: buttonSize,
+ height: buttonSize,
+ alignItems: "center" as const,
+ justifyContent: "center" as const,
+ opacity: disabled ? 0.34 : 1,
+ });
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {terms.plural}
+
+
+ {selectedIndex >= 0 ? (selectedLabel ?? selectedIndex + 1) : "–"}
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export function PageDialDividers({ diameter }: { readonly diameter: number }) {
+ const proportions = getPageDialProportions(diameter);
+ const dividerSegments = getPageDialDividerSegments(
+ diameter,
+ proportions.innerDiskDiameter,
+ proportions.centerDiskDiameter,
+ );
+ return (
+ <>
+ {dividerSegments.map((segment, index) => (
+
+ ))}
+ >
+ );
+}
+
+function getDividerStyle(segment: PageDialLineSegment): ViewStyle {
+ const dx = segment.end.x - segment.start.x;
+ const dy = segment.end.y - segment.start.y;
+ const length = Math.hypot(dx, dy);
+ const midpointX = (segment.start.x + segment.end.x) / 2;
+ const midpointY = (segment.start.y + segment.end.y) / 2;
+ const rotationDegrees = (Math.atan2(dy, dx) * 180) / Math.PI - 90;
+ return {
+ position: "absolute",
+ left: midpointX - 0.5,
+ top: midpointY - length / 2,
+ width: 1,
+ height: length,
+ transform: [{ rotate: `${rotationDegrees}deg` }],
+ };
+}
diff --git a/apps/mobile/src/features/field/page-dial/page-dial-gesture.ts b/apps/mobile/src/features/field/page-dial/page-dial-gesture.ts
new file mode 100644
index 00000000..474c5815
--- /dev/null
+++ b/apps/mobile/src/features/field/page-dial/page-dial-gesture.ts
@@ -0,0 +1,143 @@
+import React from "react";
+import * as Haptics from "expo-haptics";
+import { Gesture } from "react-native-gesture-handler";
+import {
+ cancelAnimation,
+ useSharedValue,
+ withSpring,
+ withTiming,
+ type SharedValue,
+} from "react-native-reanimated";
+import { scheduleOnRN } from "react-native-worklets";
+
+import {
+ normalizePageIndex,
+ pageDialIndexForProgress,
+ pageDialPointIsInControlHitTarget,
+ pageDialProgressForPointNearReference,
+ pageDialPointIsInRingHitRegion,
+} from "./page-dial-math";
+
+function setSharedValue(sharedValue: SharedValue, value: T): void {
+ "worklet";
+ sharedValue.value = value;
+}
+
+export function triggerPageDialHaptic(): void {
+ void Haptics.selectionAsync().catch(() => undefined);
+}
+
+export function usePageDialGesture({
+ diameter,
+ pageCount,
+ selectedIndex = 0,
+ provisionalProgress,
+ onCommitIndex,
+}: {
+ readonly diameter: number;
+ readonly pageCount: number;
+ readonly selectedIndex?: number;
+ readonly provisionalProgress: SharedValue;
+ readonly onCommitIndex: (index: number) => void;
+}) {
+ const ringActive = useSharedValue(false);
+ const gestureStartProgress = useSharedValue(0);
+ const gestureStartIndex = useSharedValue(0);
+ const previewIndex = useSharedValue(0);
+
+ const updateFromPoint = (x: number, y: number, shouldHaptic = true) => {
+ "worklet";
+ if (!ringActive.value || pageCount <= 0) return;
+ const nextProgress = pageDialProgressForPointNearReference(
+ x,
+ y,
+ diameter,
+ provisionalProgress.value,
+ );
+ const nextIndex = pageDialIndexForProgress(nextProgress, pageCount);
+ setSharedValue(provisionalProgress, nextProgress);
+ if (nextIndex === previewIndex.value) return;
+ setSharedValue(previewIndex, nextIndex);
+ if (shouldHaptic) scheduleOnRN(triggerPageDialHaptic);
+ };
+
+ const commitIndex = React.useCallback(
+ (index: number) => onCommitIndex(index),
+ [onCommitIndex],
+ );
+
+ const settleProgress = (index: number) => {
+ "worklet";
+ const targetProgress = normalizePageIndex(index, pageCount);
+ const shouldCommit = index !== gestureStartIndex.value;
+ setSharedValue(
+ provisionalProgress,
+ withSpring(
+ targetProgress,
+ {
+ damping: 18,
+ stiffness: 210,
+ mass: 0.7,
+ },
+ (finished) => {
+ "worklet";
+ if (finished && shouldCommit) {
+ scheduleOnRN(commitIndex, index);
+ }
+ },
+ ),
+ );
+ };
+
+ return Gesture.Pan()
+ .withTestId("page-dial-ring-gesture")
+ .manualActivation(true)
+ .onTouchesDown((event, manager) => {
+ const touch = event.allTouches[0];
+ if (
+ touch &&
+ pageCount > 0 &&
+ pageDialPointIsInRingHitRegion(touch.x, touch.y, diameter) &&
+ !pageDialPointIsInControlHitTarget(touch.x, touch.y, diameter)
+ ) {
+ manager.activate();
+ } else {
+ manager.fail();
+ }
+ })
+ .minDistance(1)
+ .onBegin((event) => {
+ const touchesRing =
+ pageDialPointIsInRingHitRegion(event.x, event.y, diameter) &&
+ !pageDialPointIsInControlHitTarget(event.x, event.y, diameter);
+ setSharedValue(ringActive, touchesRing && pageCount > 0);
+ if (!touchesRing || pageCount <= 0) return;
+ cancelAnimation(provisionalProgress);
+ setSharedValue(gestureStartProgress, provisionalProgress.value);
+ setSharedValue(
+ gestureStartIndex,
+ selectedIndex >= 0 ? selectedIndex : -1,
+ );
+ setSharedValue(previewIndex, gestureStartIndex.value);
+ updateFromPoint(event.x, event.y, false);
+ })
+ .onUpdate((event) => updateFromPoint(event.x, event.y))
+ .onEnd((_event, success) => {
+ if (!success || !ringActive.value || pageCount <= 0) return;
+ const snappedIndex = pageDialIndexForProgress(
+ provisionalProgress.value,
+ pageCount,
+ );
+ settleProgress(snappedIndex);
+ })
+ .onFinalize((_event, success) => {
+ if (!success && ringActive.value) {
+ cancelAnimation(provisionalProgress);
+ setSharedValue(
+ provisionalProgress,
+ withTiming(gestureStartProgress.value, { duration: 140 }),
+ );
+ }
+ setSharedValue(ringActive, false);
+ });
+}
diff --git a/apps/mobile/src/features/field/page-dial/page-dial-layout.ts b/apps/mobile/src/features/field/page-dial/page-dial-layout.ts
new file mode 100644
index 00000000..16f4123f
--- /dev/null
+++ b/apps/mobile/src/features/field/page-dial/page-dial-layout.ts
@@ -0,0 +1,80 @@
+import { getDrillTerms, type DrillTerminology } from "@eight2five/mobile/drill";
+
+import {
+ getPageDialCanvasOverscan,
+ getPageDialControlSize,
+ getPageDialRingHitRegion,
+ getPageDialRingRadius,
+ PAGE_DIAL_CENTER_DISK_DIAMETER_RATIO,
+ PAGE_DIAL_CONTROL_CENTER_OFFSET_RATIO,
+ PAGE_DIAL_INNER_DISK_DIAMETER_RATIO,
+ PAGE_DIAL_KNOB_DIAMETER_RATIO,
+ PAGE_DIAL_RING_THICKNESS_RATIO,
+} from "./page-dial-math";
+
+export interface PageDialProportions {
+ readonly ringThickness: number;
+ readonly ringRadius: number;
+ readonly innerDiskDiameter: number;
+ readonly centerDiskDiameter: number;
+ /** Kept for callers that used the original proportions API. No outline is rendered. */
+ readonly centerBorderWidth: number;
+ readonly knobDiameter: number;
+ readonly knobRadius: number;
+ readonly controlCenterOffset: number;
+ readonly controlButtonSize: number;
+ readonly ringHitInnerRadius: number;
+ readonly ringHitOuterRadius: number;
+ readonly canvasOverscan: number;
+}
+
+export function getPageDialProportions(diameter: number): PageDialProportions {
+ const ringThickness = diameter * PAGE_DIAL_RING_THICKNESS_RATIO;
+ const innerDiskDiameter = diameter * PAGE_DIAL_INNER_DISK_DIAMETER_RATIO;
+ const centerDiskDiameter = diameter * PAGE_DIAL_CENTER_DISK_DIAMETER_RATIO;
+ const knobDiameter = diameter * PAGE_DIAL_KNOB_DIAMETER_RATIO;
+ const ringHitRegion = getPageDialRingHitRegion(diameter);
+ return {
+ ringThickness,
+ ringRadius: getPageDialRingRadius(diameter, ringThickness),
+ innerDiskDiameter,
+ centerDiskDiameter,
+ centerBorderWidth: 0,
+ knobDiameter,
+ knobRadius: knobDiameter / 2,
+ controlCenterOffset: diameter * PAGE_DIAL_CONTROL_CENTER_OFFSET_RATIO,
+ controlButtonSize: getPageDialControlSize(diameter),
+ ringHitInnerRadius: ringHitRegion.innerRadius,
+ ringHitOuterRadius: ringHitRegion.outerRadius,
+ canvasOverscan: getPageDialCanvasOverscan(diameter),
+ };
+}
+
+export function getPageDialControlState(
+ selectedIndex: number,
+ pageCount: number,
+): { previousDisabled: boolean; nextDisabled: boolean } {
+ return {
+ previousDisabled: selectedIndex <= 0,
+ nextDisabled:
+ pageCount <= 0 || (selectedIndex >= 0 && selectedIndex >= pageCount - 1),
+ };
+}
+
+export function getPageDialAccessibilityLabel({
+ selectedIndex,
+ selectedLabel,
+ pageCount,
+ terminology,
+}: {
+ readonly selectedIndex: number;
+ readonly selectedLabel?: string;
+ readonly pageCount: number;
+ readonly terminology: DrillTerminology;
+}): string {
+ const terms = getDrillTerms(terminology);
+ if (selectedIndex < 0) {
+ return `${terms.singular} selector, no ${terms.lowercaseSingular} selected, ${pageCount} available`;
+ }
+ return `${terms.singular} selector, ${terms.lowercaseSingular} ${selectedLabel ?? selectedIndex + 1} of ${pageCount}`;
+}
diff --git a/apps/mobile/src/features/field/page-dial/page-dial-math.ts b/apps/mobile/src/features/field/page-dial/page-dial-math.ts
new file mode 100644
index 00000000..777c5ae7
--- /dev/null
+++ b/apps/mobile/src/features/field/page-dial/page-dial-math.ts
@@ -0,0 +1,376 @@
+/**
+ * Geometry shared by the React Native controls, the gesture worklet, and the
+ * Skia renderer. Angles use the React Native coordinate system: zero points
+ * right and positive values rotate clockwise because y grows downwards.
+ */
+
+export const PAGE_DIAL_DEAD_ZONE_DEGREES = 0;
+export const PAGE_DIAL_USABLE_ARC_DEGREES = 360;
+export const PAGE_DIAL_START_ANGLE_DEGREES = -90;
+export const PAGE_DIAL_END_ANGLE_DEGREES = 270;
+
+export const PAGE_DIAL_RING_THICKNESS_RATIO = 0.075;
+export const PAGE_DIAL_INNER_DISK_DIAMETER_RATIO = 0.86;
+export const PAGE_DIAL_CENTER_DISK_DIAMETER_RATIO = 0.3;
+export const PAGE_DIAL_KNOB_DIAMETER_RATIO = 0.16;
+export const PAGE_DIAL_CONTROL_CENTER_OFFSET_RATIO = 0.29;
+export const PAGE_DIAL_MIN_CONTROL_SIZE = 44;
+
+// The visual ring is about 0.46D from the center. Keeping this hit region
+// wider makes the dial usable with a thumb without making the center button
+// part of the gesture surface.
+export const PAGE_DIAL_RING_HIT_INNER_RADIUS_RATIO = 0.36;
+export const PAGE_DIAL_RING_HIT_OUTER_RADIUS_RATIO = 0.6;
+
+// The knob and its shadow extend beyond the visual ring at the two endpoints.
+// The canvas is rendered with this much overscan so neither is clipped.
+export const PAGE_DIAL_CANVAS_OVERSCAN_RATIO = 0.09;
+
+const FULL_TURN_RADIANS = Math.PI * 2;
+const DEGREES_TO_RADIANS = Math.PI / 180;
+
+export interface PageDialPoint {
+ readonly x: number;
+ readonly y: number;
+}
+
+export interface PageDialLineSegment {
+ readonly start: PageDialPoint;
+ readonly end: PageDialPoint;
+}
+
+export interface PageDialRingHitRegion {
+ readonly innerRadius: number;
+ readonly outerRadius: number;
+}
+
+export interface PageDialCardinalPoints {
+ readonly top: PageDialPoint;
+ readonly right: PageDialPoint;
+ readonly bottom: PageDialPoint;
+ readonly left: PageDialPoint;
+}
+
+function clamp(value: number, minimum: number, maximum: number): number {
+ "worklet";
+ return Math.min(maximum, Math.max(minimum, value));
+}
+
+function normalizeRadians(angleRadians: number): number {
+ "worklet";
+ const normalized = angleRadians % FULL_TURN_RADIANS;
+ return normalized < 0 ? normalized + FULL_TURN_RADIANS : normalized;
+}
+
+export function normalizePageIndex(index: number, pageCount: number): number {
+ "worklet";
+ if (
+ pageCount <= 1 ||
+ !Number.isFinite(pageCount) ||
+ !Number.isFinite(index)
+ ) {
+ return 0;
+ }
+ return clamp(index / (pageCount - 1), 0, 1);
+}
+
+export const normalizePageProgress = normalizePageIndex;
+
+export function pageDialAngleForProgress(progress: number): number {
+ "worklet";
+ const safeProgress = Number.isFinite(progress) ? progress : 0;
+ return (
+ (PAGE_DIAL_START_ANGLE_DEGREES +
+ clamp(safeProgress, 0, 1) * PAGE_DIAL_USABLE_ARC_DEGREES) *
+ DEGREES_TO_RADIANS
+ );
+}
+
+export function pageDialAngleForIndex(
+ index: number,
+ pageCount: number,
+): number {
+ "worklet";
+ return pageDialAngleForProgress(normalizePageIndex(index, pageCount));
+}
+
+export function pageDialRelativeAngle(angleRadians: number): number {
+ "worklet";
+ const start = PAGE_DIAL_START_ANGLE_DEGREES * DEGREES_TO_RADIANS;
+ return normalizeRadians(angleRadians - start);
+}
+
+export function pageDialAngleIsInValidArc(angleRadians: number): boolean {
+ "worklet";
+ return Number.isFinite(angleRadians);
+}
+
+export const isPageDialAngleInValidArc = pageDialAngleIsInValidArc;
+
+export function pageDialProgressForAngle(angleRadians: number): number {
+ "worklet";
+ if (!Number.isFinite(angleRadians)) return 0;
+ return pageDialRelativeAngle(angleRadians) / FULL_TURN_RADIANS;
+}
+
+/**
+ * Resolve the shared top seam while still allowing the dial to wrap forever.
+ * Exactly at the top, drag history decides whether the knob represents the
+ * first set (0) or last set (1). Once the pointer crosses the seam, the wrapped
+ * angular progress is returned immediately so clockwise motion moves last →
+ * first and counter-clockwise motion moves first → last on every revolution.
+ */
+export function pageDialProgressForAngleNearReference(
+ angleRadians: number,
+ referenceProgress: number,
+): number {
+ "worklet";
+ const wrapped = pageDialProgressForAngle(angleRadians);
+ const reference = clamp(
+ Number.isFinite(referenceProgress) ? referenceProgress : 0,
+ 0,
+ 1,
+ );
+
+ // atan2 resolves the exact top point to the start angle, so use the prior
+ // state to preserve the intentional first/last ambiguity at that one point.
+ if (wrapped === 0) {
+ return reference > 0.5 ? 1 : 0;
+ }
+
+ return wrapped;
+}
+
+export function pageDialProgressForPoint(
+ x: number,
+ y: number,
+ diameter: number,
+): number {
+ "worklet";
+ const center = diameter / 2;
+ if (x === center && y === center) return 0;
+ return pageDialProgressForAngle(Math.atan2(y - center, x - center));
+}
+
+export function pageDialProgressForPointNearReference(
+ x: number,
+ y: number,
+ diameter: number,
+ referenceProgress: number,
+): number {
+ "worklet";
+ const center = diameter / 2;
+ if (x === center && y === center) return clamp(referenceProgress, 0, 1);
+ return pageDialProgressForAngleNearReference(
+ Math.atan2(y - center, x - center),
+ referenceProgress,
+ );
+}
+
+export function pageDialIndexForProgress(
+ progress: number,
+ pageCount: number,
+): number {
+ "worklet";
+ if (pageCount <= 1 || !Number.isFinite(pageCount)) return 0;
+ const safeProgress = Number.isFinite(progress) ? progress : 0;
+ return Math.round(clamp(safeProgress, 0, 1) * (pageCount - 1));
+}
+
+export function pageDialIndexForAngle(
+ angleRadians: number,
+ pageCount: number,
+): number {
+ "worklet";
+ return pageDialIndexForProgress(
+ pageDialProgressForAngle(angleRadians),
+ pageCount,
+ );
+}
+
+export function pageDialIndexForPoint(
+ x: number,
+ y: number,
+ diameter: number,
+ pageCount: number,
+): number {
+ "worklet";
+ return pageDialIndexForProgress(
+ pageDialProgressForPoint(x, y, diameter),
+ pageCount,
+ );
+}
+
+export function pageDialPointForAngle(
+ angleRadians: number,
+ diameter: number,
+ radius = diameter / 2,
+): PageDialPoint {
+ "worklet";
+ const center = diameter / 2;
+ return {
+ x: center + Math.cos(angleRadians) * radius,
+ y: center + Math.sin(angleRadians) * radius,
+ };
+}
+
+export function pageDialPointForProgress(
+ progress: number,
+ diameter: number,
+ radius?: number,
+): PageDialPoint {
+ "worklet";
+ const resolvedRadius =
+ radius ?? diameter / 2 - (diameter * PAGE_DIAL_RING_THICKNESS_RATIO) / 2;
+ return pageDialPointForAngle(
+ pageDialAngleForProgress(progress),
+ diameter,
+ resolvedRadius,
+ );
+}
+
+export const pageDialKnobPointForProgress = pageDialPointForProgress;
+
+export function getPageDialRingRadius(
+ diameter: number,
+ ringThickness = diameter * PAGE_DIAL_RING_THICKNESS_RATIO,
+): number {
+ "worklet";
+ return diameter / 2 - ringThickness / 2;
+}
+
+export function getPageDialRingHitRegion(
+ diameter: number,
+): PageDialRingHitRegion {
+ "worklet";
+ return {
+ innerRadius: diameter * PAGE_DIAL_RING_HIT_INNER_RADIUS_RATIO,
+ outerRadius: diameter * PAGE_DIAL_RING_HIT_OUTER_RADIUS_RATIO,
+ };
+}
+
+export const pageDialRingHitRegion = getPageDialRingHitRegion;
+
+export function pageDialRadialDistanceForPoint(
+ x: number,
+ y: number,
+ diameter: number,
+): number {
+ "worklet";
+ const center = diameter / 2;
+ return Math.hypot(x - center, y - center);
+}
+
+export function pageDialPointIsInRingHitRegion(
+ x: number,
+ y: number,
+ diameter: number,
+): boolean {
+ "worklet";
+ const distance = pageDialRadialDistanceForPoint(x, y, diameter);
+ const region = getPageDialRingHitRegion(diameter);
+ return distance >= region.innerRadius && distance <= region.outerRadius;
+}
+
+export const isPageDialRingHit = pageDialPointIsInRingHitRegion;
+
+export function getPageDialCardinalPoints(
+ diameter: number,
+ offset?: number,
+): PageDialCardinalPoints {
+ "worklet";
+ const center = diameter / 2;
+ const resolvedOffset =
+ offset ?? diameter * PAGE_DIAL_CONTROL_CENTER_OFFSET_RATIO;
+ return {
+ top: { x: center, y: center - resolvedOffset },
+ right: { x: center + resolvedOffset, y: center },
+ bottom: { x: center, y: center + resolvedOffset },
+ left: { x: center - resolvedOffset, y: center },
+ };
+}
+
+export const pageDialCardinalPoints = getPageDialCardinalPoints;
+
+export function getPageDialControlSize(diameter: number): number {
+ "worklet";
+ return Math.max(PAGE_DIAL_MIN_CONTROL_SIZE, diameter * 0.31);
+}
+
+/**
+ * Returns whether a touch belongs to one of the four button hit boxes. The
+ * ring detector uses this only at gesture start so a ring drag can pass over a
+ * button without being interrupted after it has begun.
+ */
+export function pageDialPointIsInControlHitTarget(
+ x: number,
+ y: number,
+ diameter: number,
+): boolean {
+ "worklet";
+ const size = getPageDialControlSize(diameter);
+ const halfSize = size / 2;
+ const points = getPageDialCardinalPoints(diameter);
+ return (
+ (Math.abs(x - points.top.x) <= halfSize &&
+ Math.abs(y - points.top.y) <= halfSize) ||
+ (Math.abs(x - points.right.x) <= halfSize &&
+ Math.abs(y - points.right.y) <= halfSize) ||
+ (Math.abs(x - points.bottom.x) <= halfSize &&
+ Math.abs(y - points.bottom.y) <= halfSize) ||
+ (Math.abs(x - points.left.x) <= halfSize &&
+ Math.abs(y - points.left.y) <= halfSize)
+ );
+}
+
+export const pageDialPointHitsControl = pageDialPointIsInControlHitTarget;
+
+function pointAtRadius(
+ diameter: number,
+ angleDegrees: number,
+ radius: number,
+): PageDialPoint {
+ return pageDialPointForAngle(
+ angleDegrees * DEGREES_TO_RADIANS,
+ diameter,
+ radius,
+ );
+}
+
+/**
+ * X dividers are split at the center disk rather than drawing underneath it.
+ * This keeps both diagonals visibly flush with the blue disk edge and avoids
+ * relying on paint order to hide a line through the center label.
+ */
+export function getPageDialDividerSegments(
+ diameter: number,
+ innerDiskDiameter = diameter * PAGE_DIAL_INNER_DISK_DIAMETER_RATIO,
+ centerDiskDiameter = diameter * PAGE_DIAL_CENTER_DISK_DIAMETER_RATIO,
+): readonly PageDialLineSegment[] {
+ const outerRadius = innerDiskDiameter / 2;
+ const innerRadius = centerDiskDiameter / 2;
+ return [
+ {
+ start: pointAtRadius(diameter, -135, outerRadius),
+ end: pointAtRadius(diameter, -135, innerRadius),
+ },
+ {
+ start: pointAtRadius(diameter, -45, outerRadius),
+ end: pointAtRadius(diameter, -45, innerRadius),
+ },
+ {
+ start: pointAtRadius(diameter, 45, innerRadius),
+ end: pointAtRadius(diameter, 45, outerRadius),
+ },
+ {
+ start: pointAtRadius(diameter, 135, innerRadius),
+ end: pointAtRadius(diameter, 135, outerRadius),
+ },
+ ];
+}
+
+export const pageDialDividerSegments = getPageDialDividerSegments;
+
+export function getPageDialCanvasOverscan(diameter: number): number {
+ return diameter * PAGE_DIAL_CANVAS_OVERSCAN_RATIO;
+}
diff --git a/apps/mobile/src/features/field/page-dial/page-dial.tsx b/apps/mobile/src/features/field/page-dial/page-dial.tsx
new file mode 100644
index 00000000..c3836b85
--- /dev/null
+++ b/apps/mobile/src/features/field/page-dial/page-dial.tsx
@@ -0,0 +1,206 @@
+import React from "react";
+import { View } from "react-native";
+import { GestureDetector } from "react-native-gesture-handler";
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+ type SharedValue,
+} from "react-native-reanimated";
+import type { DrillTerminology } from "@eight2five/mobile/drill";
+import {
+ useEight2FiveTheme,
+ useEight2FiveThemeName,
+} from "@eight2five/ui/theme";
+
+import { FrostedFieldSurface } from "../field-frosted-surface";
+
+import { PageDialCanvas } from "./page-dial-canvas";
+import { PageDialControls, PageDialDividers } from "./page-dial-controls";
+import { triggerPageDialHaptic, usePageDialGesture } from "./page-dial-gesture";
+import {
+ normalizePageIndex,
+ PAGE_DIAL_KNOB_DIAMETER_RATIO,
+ pageDialPointForProgress,
+} from "./page-dial-math";
+
+function animateProgress(
+ sharedValue: SharedValue,
+ progress: number,
+): void {
+ sharedValue.value = withTiming(progress, { duration: 180 });
+}
+
+export function PageDial({
+ diameter,
+ selectedIndex,
+ selectedLabel,
+ pageCount,
+ terminology,
+ activeColor,
+ trackColor,
+ innerColor,
+ backgroundColor,
+ foregroundColor,
+ onSelectIndex,
+ onSelectDrill,
+ onSelectPerformer,
+}: {
+ readonly diameter: number;
+ readonly selectedIndex: number;
+ readonly selectedLabel?: string;
+ readonly pageCount: number;
+ readonly terminology: DrillTerminology;
+ readonly activeColor: string;
+ readonly trackColor: string;
+ readonly innerColor?: string;
+ readonly backgroundColor?: string;
+ readonly foregroundColor?: string;
+ readonly onSelectIndex: (index: number) => void;
+ readonly onSelectDrill?: () => void;
+ readonly onSelectPerformer?: () => void;
+}) {
+ const theme = useEight2FiveTheme();
+ const themeName = useEight2FiveThemeName();
+ const provisionalProgress = useSharedValue(
+ normalizePageIndex(Math.max(0, selectedIndex), pageCount),
+ );
+ React.useEffect(() => {
+ animateProgress(
+ provisionalProgress,
+ normalizePageIndex(Math.max(0, selectedIndex), pageCount),
+ );
+ }, [pageCount, provisionalProgress, selectedIndex]);
+
+ const gesture = usePageDialGesture({
+ diameter,
+ pageCount,
+ selectedIndex,
+ provisionalProgress,
+ onCommitIndex: onSelectIndex,
+ });
+ const selectFromButton = React.useCallback(
+ (index: number) => {
+ if (pageCount <= 0) return;
+ const bounded = Math.max(0, Math.min(pageCount - 1, index));
+ if (bounded === selectedIndex) return;
+ animateProgress(
+ provisionalProgress,
+ normalizePageIndex(bounded, pageCount),
+ );
+ triggerPageDialHaptic();
+ onSelectIndex(bounded);
+ },
+ [onSelectIndex, pageCount, provisionalProgress, selectedIndex],
+ );
+
+ const resolvedInnerColor = innerColor ?? "transparent";
+ const resolvedBackgroundColor = backgroundColor ?? "transparent";
+ const resolvedForegroundColor = foregroundColor ?? theme.text;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ selectFromButton(selectedIndex - 1)}
+ onNext={() =>
+ selectFromButton(selectedIndex < 0 ? 0 : selectedIndex + 1)
+ }
+ onSelectDrill={onSelectDrill}
+ onSelectPerformer={onSelectPerformer}
+ />
+
+ );
+}
+
+function PageDialKnob({
+ diameter,
+ progress,
+ color,
+}: {
+ readonly diameter: number;
+ readonly progress: SharedValue;
+ readonly color: string;
+}) {
+ const knobDiameter = diameter * PAGE_DIAL_KNOB_DIAMETER_RATIO;
+ const animatedStyle = useAnimatedStyle(() => {
+ const point = pageDialPointForProgress(progress.value, diameter);
+ return {
+ left: point.x - knobDiameter / 2,
+ top: point.y - knobDiameter / 2,
+ };
+ });
+ return (
+
+ );
+}
diff --git a/apps/mobile/src/features/field/tag-connection-dialog.tsx b/apps/mobile/src/features/field/tag-connection-dialog.tsx
new file mode 100644
index 00000000..fa966be9
--- /dev/null
+++ b/apps/mobile/src/features/field/tag-connection-dialog.tsx
@@ -0,0 +1,42 @@
+import React from "react";
+import { useWindowDimensions } from "react-native";
+import { X } from "lucide-react-native";
+import { Heading } from "@eight2five/ui/components/heading";
+import { Icon } from "@eight2five/ui/components/icon";
+import {
+ Modal,
+ ModalBackdrop,
+ ModalBody,
+ ModalCloseButton,
+ ModalContent,
+ ModalHeader,
+} from "@eight2five/ui/components/modal";
+
+import { TagConnectionContent } from "../settings/tag-connection-screen";
+
+export function TagConnectionDialog({
+ isOpen,
+ onClose,
+}: {
+ readonly isOpen: boolean;
+ readonly onClose: () => void;
+}) {
+ const { height } = useWindowDimensions();
+ if (!isOpen) return null;
+ return (
+
+
+
+
+ Tag Connection
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/field/use-field-anchor-overlay.ts b/apps/mobile/src/features/field/use-field-anchor-overlay.ts
new file mode 100644
index 00000000..f2c98820
--- /dev/null
+++ b/apps/mobile/src/features/field/use-field-anchor-overlay.ts
@@ -0,0 +1,24 @@
+import React from "react";
+
+import { useAppSettingsSnapshot } from "../../state/app-settings-store";
+import {
+ useKnownPansAnchors,
+ useRememberedPansTag,
+} from "../../pans/mobile-pans-context";
+import { cachedAnchorGeometry } from "../../pans/pans-anchor-cache";
+import { fieldAnchorOverlayOptions } from "./field-anchor-overlay-options";
+
+export function useFieldAnchorOverlay() {
+ const { settings } = useAppSettingsSnapshot();
+ const rememberedTag = useRememberedPansTag();
+ const knownAnchors = useKnownPansAnchors();
+ const anchors = React.useMemo(
+ () => cachedAnchorGeometry(rememberedTag, knownAnchors),
+ [knownAnchors, rememberedTag],
+ );
+ const options = React.useMemo(
+ () => fieldAnchorOverlayOptions(settings),
+ [settings],
+ );
+ return { anchors, options } as const;
+}
diff --git a/apps/mobile/src/features/field/use-field-screen-controller.ts b/apps/mobile/src/features/field/use-field-screen-controller.ts
new file mode 100644
index 00000000..501bc703
--- /dev/null
+++ b/apps/mobile/src/features/field/use-field-screen-controller.ts
@@ -0,0 +1,284 @@
+import React from "react";
+import { useFocusEffect } from "expo-router";
+import { useWindowDimensions } from "react-native";
+import type { FieldViewport } from "@eight2five/mobile/field";
+import {
+ buildDrillRenderScene,
+ resolveSelectedSourceSetId,
+ shouldBuildDrillRenderScene,
+ type Drill,
+ type DrillDocument,
+ type DrillSet,
+ type DrillRenderScene,
+} from "@eight2five/mobile/drill";
+
+import { useFieldOrientation } from "../../navigation/use-field-orientation";
+import {
+ useAppSettingsSnapshot,
+ useAppSettingsStore,
+} from "../../state/app-settings-store";
+import { resolveEffectiveFieldPreset } from "./effective-field-preset";
+
+let committedFieldViewport: FieldViewport | undefined;
+
+/**
+ * Owns viewport commits outside the renderer. The module-level session value is
+ * deliberate: native-tab presentation changes may remount the route, but they
+ * must not reset the performer's field center or zoom.
+ */
+export function useFieldScreenController() {
+ const orientation = useFieldOrientation();
+ const { width, height } = useWindowDimensions();
+ const snapshot = useAppSettingsSnapshot();
+ const store = useAppSettingsStore();
+ const [initialViewport] = React.useState(() => committedFieldViewport);
+ const [drills, setDrills] = React.useState([]);
+ const [drillEntries, setDrillEntries] = React.useState<
+ readonly { readonly drill: Drill; readonly pageCount: number }[]
+ >([]);
+ const [activeDrill, setActiveDrill] = React.useState();
+ const [activeDrillDocument, setActiveDrillDocument] =
+ React.useState();
+ const [pages, setPages] = React.useState([]);
+ const [loadingDrills, setLoadingDrills] = React.useState(true);
+ const [fieldError, setFieldError] = React.useState();
+ const [selectionBusy, setSelectionBusy] = React.useState(false);
+ const [optimisticSelection, setOptimisticSelection] = React.useState<{
+ readonly activeDrillId: string | null;
+ readonly pageId: string;
+ }>();
+ const refreshGeneration = React.useRef(0);
+ const pageSelectionGeneration = React.useRef(0);
+ const commitViewport = React.useCallback((viewport: FieldViewport) => {
+ committedFieldViewport = viewport;
+ }, []);
+
+ const refreshDrills = React.useCallback(async () => {
+ if (snapshot.status !== "ready") return;
+ const generation = ++refreshGeneration.current;
+ setLoadingDrills(true);
+ try {
+ const repository = store.getDrillRepository();
+ const activeDrillId = snapshot.settings.activeDrillId;
+ const nextDrills = await repository.listDrills();
+ const [nextActiveDrill, nextPages, nextDocument, pageCounts] =
+ await Promise.all([
+ activeDrillId ? repository.getDrill(activeDrillId) : undefined,
+ activeDrillId ? repository.listSets(activeDrillId) : [],
+ activeDrillId
+ ? repository.getDrillDocument(activeDrillId)
+ : undefined,
+ Promise.all(
+ nextDrills.map(async (drill) => ({
+ drill,
+ pageCount: (await repository.listSets(drill.id)).length,
+ })),
+ ),
+ ]);
+ if (generation !== refreshGeneration.current) return;
+ setDrills(nextDrills);
+ setDrillEntries(pageCounts);
+ setActiveDrill(nextActiveDrill);
+ setActiveDrillDocument(nextDocument);
+ setPages(nextPages);
+ setFieldError(undefined);
+ } catch (cause) {
+ if (generation !== refreshGeneration.current) return;
+ setFieldError(cause instanceof Error ? cause : new Error(String(cause)));
+ } finally {
+ if (generation === refreshGeneration.current) setLoadingDrills(false);
+ }
+ }, [snapshot.settings.activeDrillId, snapshot.status, store]);
+
+ useFocusEffect(
+ React.useCallback(() => {
+ void refreshDrills();
+ }, [refreshDrills]),
+ );
+
+ const selectActiveDrill = React.useCallback(
+ async (drillId: string | null) => {
+ if (selectionBusy || snapshot.status !== "ready") return;
+ setSelectionBusy(true);
+ setFieldError(undefined);
+ try {
+ await store.setActiveDrill(drillId);
+ } catch (cause) {
+ setFieldError(
+ cause instanceof Error ? cause : new Error(String(cause)),
+ );
+ } finally {
+ setSelectionBusy(false);
+ }
+ },
+ [selectionBusy, snapshot.status, store],
+ );
+
+ const toggleMetricMode = React.useCallback(async () => {
+ if (snapshot.status !== "ready") return;
+ try {
+ await store.update({
+ transitionMetricMode:
+ snapshot.settings.transitionMetricMode === "step-size"
+ ? "crossing-counts"
+ : "step-size",
+ });
+ } catch (cause) {
+ setFieldError(cause instanceof Error ? cause : new Error(String(cause)));
+ }
+ }, [snapshot.settings.transitionMetricMode, snapshot.status, store]);
+
+ const toggleCountDisplayMode = React.useCallback(async () => {
+ if (snapshot.status !== "ready") return;
+ try {
+ await store.update({
+ countDisplayMode:
+ snapshot.settings.countDisplayMode === "counts"
+ ? "measures"
+ : "counts",
+ });
+ } catch (cause) {
+ setFieldError(cause instanceof Error ? cause : new Error(String(cause)));
+ }
+ }, [snapshot.settings.countDisplayMode, snapshot.status, store]);
+
+ const selectPageAtIndex = React.useCallback(
+ async (index: number) => {
+ const page = pages[index];
+ if (!page || snapshot.status !== "ready") return;
+ const generation = ++pageSelectionGeneration.current;
+ setOptimisticSelection({
+ activeDrillId: snapshot.settings.activeDrillId,
+ pageId: page.id,
+ });
+ setFieldError(undefined);
+ try {
+ await store.setSelectedDrillSet(page.id);
+ if (generation === pageSelectionGeneration.current) {
+ setOptimisticSelection(undefined);
+ }
+ } catch (cause) {
+ if (generation === pageSelectionGeneration.current) {
+ setOptimisticSelection(undefined);
+ setFieldError(
+ cause instanceof Error ? cause : new Error(String(cause)),
+ );
+ }
+ }
+ },
+ [pages, snapshot.settings.activeDrillId, snapshot.status, store],
+ );
+
+ const selectPerformer = React.useCallback(
+ async (performerEntityId: number) => {
+ if (!activeDrill || selectionBusy || snapshot.status !== "ready") {
+ return false;
+ }
+ setSelectionBusy(true);
+ setFieldError(undefined);
+ try {
+ await store
+ .getDrillRepository()
+ .setSelectedPerformer(activeDrill.id, performerEntityId);
+ await refreshDrills();
+ return true;
+ } catch (cause) {
+ setFieldError(
+ cause instanceof Error ? cause : new Error(String(cause)),
+ );
+ return false;
+ } finally {
+ setSelectionBusy(false);
+ }
+ },
+ [activeDrill, refreshDrills, selectionBusy, snapshot.status, store],
+ );
+
+ const effectiveSelectedPageId =
+ optimisticSelection?.activeDrillId === snapshot.settings.activeDrillId
+ ? optimisticSelection.pageId
+ : snapshot.settings.selectedDrillSetId;
+ const selectedIndex = pages.findIndex(
+ (page) => page.id === effectiveSelectedPageId,
+ );
+ const selectedPage = selectedIndex >= 0 ? pages[selectedIndex] : undefined;
+ const fieldPreset = resolveEffectiveFieldPreset(
+ activeDrill,
+ snapshot.settings.defaultFieldPreset,
+ );
+ const selectedSourceSetId = resolveSelectedSourceSetId(selectedPage);
+ const selectedPerformerEntityId = activeDrill?.selectedPerformerEntityId;
+ const drillScene = React.useMemo(() => {
+ if (
+ !shouldBuildDrillRenderScene(snapshot.settings.drillFeaturesEnabled) ||
+ !activeDrillDocument ||
+ selectedSourceSetId === undefined ||
+ selectedPerformerEntityId === undefined
+ ) {
+ return undefined;
+ }
+ return buildDrillRenderScene({
+ document: activeDrillDocument,
+ field: fieldPreset,
+ selectedPerformerEntityId,
+ selectedSourceSetId,
+ settings: {
+ showPerformerLabels: snapshot.settings.showPerformerLabels,
+ showPerformerNames: snapshot.settings.showPerformerNames,
+ showPropLabels: snapshot.settings.showPropLabels,
+ showPropNames: snapshot.settings.showPropNames,
+ markerEnabled: snapshot.settings.showTransitionMarkers,
+ showAll: snapshot.settings.showAllTransitionSets,
+ previousTotalCount: snapshot.settings.previousTransitionSetCount,
+ nextTotalCount: snapshot.settings.nextTransitionSetCount,
+ },
+ });
+ }, [
+ activeDrillDocument,
+ fieldPreset,
+ selectedPerformerEntityId,
+ selectedSourceSetId,
+ snapshot.settings.nextTransitionSetCount,
+ snapshot.settings.previousTransitionSetCount,
+ snapshot.settings.drillFeaturesEnabled,
+ snapshot.settings.showAllTransitionSets,
+ snapshot.settings.showPerformerLabels,
+ snapshot.settings.showPerformerNames,
+ snapshot.settings.showPropLabels,
+ snapshot.settings.showPropNames,
+ snapshot.settings.showTransitionMarkers,
+ ]);
+
+ return {
+ width,
+ height,
+ landscape: orientation.landscape,
+ defaultViewport: initialViewport,
+ commitViewport,
+ settingsStatus: snapshot.status,
+ settings: snapshot.settings,
+ drills,
+ drillEntries,
+ activeDrill,
+ activeDrillDocument,
+ drillScene,
+ pages,
+ selectedIndex,
+ selectedPage,
+ previousPage: selectedIndex > 0 ? pages[selectedIndex - 1] : undefined,
+ fieldPreset,
+ loadingDrills,
+ selectionBusy,
+ error: fieldError ?? snapshot.error,
+ selectActiveDrill,
+ toggleMetricMode,
+ toggleCountDisplayMode,
+ selectPageAtIndex,
+ selectPerformer,
+ refreshDrills,
+ } as const;
+}
+
+export function resetFieldViewportSessionForTests(): void {
+ committedFieldViewport = undefined;
+}
diff --git a/apps/mobile/src/features/placeholder-screen.tsx b/apps/mobile/src/features/placeholder-screen.tsx
new file mode 100644
index 00000000..8c8011ac
--- /dev/null
+++ b/apps/mobile/src/features/placeholder-screen.tsx
@@ -0,0 +1,47 @@
+import { Center } from "@eight2five/ui/components/center";
+import { Heading } from "@eight2five/ui/components/heading";
+import { Text } from "@eight2five/ui/components/text";
+import { VStack } from "@eight2five/ui/components/vstack";
+import {
+ eight2FiveFonts,
+ eight2FiveSpacing,
+ useEight2FiveTheme,
+} from "@eight2five/ui/theme";
+
+export function PlaceholderScreen({
+ title,
+ description,
+}: {
+ title: string;
+ description: string;
+}) {
+ const theme = useEight2FiveTheme();
+
+ return (
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/settings/__tests__/anchor-display.test.ts b/apps/mobile/src/features/settings/__tests__/anchor-display.test.ts
new file mode 100644
index 00000000..67bfb70c
--- /dev/null
+++ b/apps/mobile/src/features/settings/__tests__/anchor-display.test.ts
@@ -0,0 +1,38 @@
+import type { ManagedDevice } from "@eight2five/mobile/pans-manager";
+
+import { getDeveloperAnchorDisplayName } from "../anchor-display";
+
+const anchor = (overrides: Partial = {}): ManagedDevice => ({
+ id: "anchor-id",
+ transportDeviceId: "transport-id",
+ createdAt: 1,
+ updatedAt: 1,
+ ...overrides,
+});
+
+describe("developer anchor display names", () => {
+ test("prefers the hardware PANS label and falls back to identifiers", () => {
+ expect(
+ getDeveloperAnchorDisplayName(
+ anchor({
+ nickname: "Legacy local name",
+ nodeIdHex: "A001",
+ label: "HW",
+ lastKnownConfig: {
+ role: "anchor",
+ label: " Front 50 ",
+ uwbMode: "active",
+ ledEnabled: true,
+ firmwareUpdateEnabled: false,
+ initiatorEnabled: false,
+ },
+ }),
+ ),
+ ).toBe("Front 50");
+ expect(getDeveloperAnchorDisplayName(anchor({ label: "HW" }))).toBe("HW");
+ expect(getDeveloperAnchorDisplayName(anchor({ nodeIdHex: "A001" }))).toBe(
+ "A001",
+ );
+ expect(getDeveloperAnchorDisplayName(anchor())).toBe("anchor-id");
+ });
+});
diff --git a/apps/mobile/src/features/settings/__tests__/anchor-editor-form.test.ts b/apps/mobile/src/features/settings/__tests__/anchor-editor-form.test.ts
new file mode 100644
index 00000000..a320bc63
--- /dev/null
+++ b/apps/mobile/src/features/settings/__tests__/anchor-editor-form.test.ts
@@ -0,0 +1,97 @@
+import { Alert } from "react-native";
+
+import {
+ createAnchorEditorDrafts,
+ convertMarchingHeightUnit,
+ formatAnchorCanonicalPreview,
+ standardDraftFromPosition,
+ validateMarchingAnchorDraft,
+ validateStandardAnchorDraft,
+} from "../anchor-editor-form";
+import { confirmAnchorPositionWrite } from "../anchor-write-confirmation";
+
+describe("anchor editor form", () => {
+ test("reuses the marching drill-grid coordinate domain", () => {
+ const draft = createAnchorEditorDrafts();
+ const result = validateMarchingAnchorDraft({
+ ...draft.marching,
+ height: "6",
+ heightUnit: "feet",
+ });
+
+ expect(result.errors).toEqual({});
+ expect(result.position).toMatchObject({
+ xMeters: 0,
+ yMeters: 0,
+ zMeters: expect.closeTo(1.8288, 8),
+ });
+ expect(formatAnchorCanonicalPreview(result.position)?.marching).toContain(
+ "On 50 yd ln",
+ );
+ });
+
+ test("standard mode validates signed offsets and preserves canonical position", () => {
+ const result = validateStandardAnchorDraft({
+ reference: "center-field",
+ unit: "feet",
+ sideToSideOffset: "3",
+ frontToBackOffset: "-6",
+ height: "8",
+ });
+ expect(result.errors).toEqual({});
+ const converted = standardDraftFromPosition(
+ result.position!,
+ "center-field",
+ "feet",
+ );
+ expect(converted).toEqual({
+ reference: "center-field",
+ unit: "feet",
+ sideToSideOffset: "3",
+ frontToBackOffset: "-6",
+ height: "8",
+ });
+ });
+
+ test("changing marching height units preserves canonical height", () => {
+ const draft = { ...createAnchorEditorDrafts().marching, height: "2" };
+ const feet = convertMarchingHeightUnit(draft, "feet");
+ expect(feet.height).toBe("6.56168");
+ expect(validateMarchingAnchorDraft(feet).position?.zMeters).toBeCloseTo(2);
+ });
+
+ test("rejects incomplete and unreasonable submissions", () => {
+ const marching = createAnchorEditorDrafts().marching;
+ expect(
+ validateMarchingAnchorDraft({ ...marching, height: "" }).errors,
+ ).toHaveProperty("height");
+ expect(
+ validateStandardAnchorDraft({
+ reference: "side-1-front-corner",
+ unit: "meters",
+ sideToSideOffset: "-1",
+ frontToBackOffset: "0",
+ height: "1",
+ }).errors,
+ ).toHaveProperty("position");
+ });
+
+ test("requires explicit confirmation before invoking one hardware action", () => {
+ const alert = jest
+ .spyOn(Alert, "alert")
+ .mockImplementation(() => undefined);
+ const write = jest.fn();
+
+ confirmAnchorPositionWrite({ xMeters: 1, yMeters: 2, zMeters: 3 }, write);
+
+ expect(write).not.toHaveBeenCalled();
+ const buttons = alert.mock.calls[0][2];
+ expect(buttons?.map((button) => button.text)).toEqual([
+ "Cancel",
+ "Write Position",
+ ]);
+ buttons?.[1].onPress?.();
+ expect(write).toHaveBeenCalledTimes(1);
+ alert.mockRestore();
+ });
+});
diff --git a/apps/mobile/src/features/settings/__tests__/anchor-overlay-settings.test.ts b/apps/mobile/src/features/settings/__tests__/anchor-overlay-settings.test.ts
new file mode 100644
index 00000000..26cc9c5b
--- /dev/null
+++ b/apps/mobile/src/features/settings/__tests__/anchor-overlay-settings.test.ts
@@ -0,0 +1,13 @@
+import { DEFAULT_APP_SETTINGS } from "@eight2five/mobile/settings";
+
+import { parseComfortableAnchorRange } from "../comfortable-anchor-range";
+
+describe("anchor overlay settings", () => {
+ test("uses the planning default and accepts a finite bounded range", () => {
+ expect(DEFAULT_APP_SETTINGS.comfortableAnchorRangeMeters).toBe(20);
+ expect(parseComfortableAnchorRange("20.0")).toEqual({ value: 20 });
+ expect(parseComfortableAnchorRange("0")).toHaveProperty("error");
+ expect(parseComfortableAnchorRange("Infinity")).toHaveProperty("error");
+ expect(parseComfortableAnchorRange("200.1")).toHaveProperty("error");
+ });
+});
diff --git a/apps/mobile/src/features/settings/__tests__/developer-mode.test.ts b/apps/mobile/src/features/settings/__tests__/developer-mode.test.ts
new file mode 100644
index 00000000..0eb18bec
--- /dev/null
+++ b/apps/mobile/src/features/settings/__tests__/developer-mode.test.ts
@@ -0,0 +1,63 @@
+import { DEFAULT_APP_SETTINGS } from "@eight2five/mobile/settings";
+
+import { buildDeveloperDiagnosticRows } from "../developer-diagnostics";
+import {
+ canUseDeveloperControls,
+ disableDeveloperMode,
+ enableDeveloperMode,
+} from "../developer-mode-actions";
+
+describe("Developer Mode", () => {
+ test("enables directly through the developer settings toggle", async () => {
+ const enabled = { ...DEFAULT_APP_SETTINGS, developerModeEnabled: true };
+ const writer = { update: jest.fn(async () => enabled) };
+
+ await expect(enableDeveloperMode(writer)).resolves.toEqual(enabled);
+
+ expect(writer.update).toHaveBeenCalledWith({ developerModeEnabled: true });
+ });
+
+ test("disabling hides controls and clears developer-only live mocking", async () => {
+ const disabled = { ...DEFAULT_APP_SETTINGS, developerModeEnabled: false };
+ const writer = { update: jest.fn(async () => disabled) };
+
+ await disableDeveloperMode(writer);
+
+ expect(writer.update).toHaveBeenCalledWith({
+ developerModeEnabled: false,
+ mockLivePositionEnabled: false,
+ mockLivePositionXSteps: 0,
+ mockLivePositionYSteps: 0,
+ });
+ expect(canUseDeveloperControls(disabled)).toBe(false);
+ });
+
+ test("diagnostic presentation never includes PANS quality", () => {
+ const rows = buildDeveloperDiagnosticRows({
+ initialization: "ready",
+ connectionState: "connected",
+ discoveries: [],
+ livePosition: {
+ connectionState: "connected",
+ isStale: false,
+ position: { xMeters: 1, yMeters: 2 },
+ },
+ rawPosition: { xMeters: 1, yMeters: 2, zMeters: 3, quality: 99 } as never,
+ lastUpdateAt: 1_700_000_000_000,
+ effectiveUpdateRateHz: 9.5,
+ diagnosticMessages: [],
+ knownAnchors: [],
+ networks: [],
+ discoveryRssiCutoff: -75,
+ });
+
+ expect(rows).toEqual(
+ expect.arrayContaining([
+ { label: "Raw PANS X", value: "1.000 m" },
+ { label: "Effective update rate", value: "9.5 Hz" },
+ ]),
+ );
+ expect(JSON.stringify(rows).toLowerCase()).not.toContain("quality");
+ expect(JSON.stringify(rows)).not.toContain("99");
+ });
+});
diff --git a/apps/mobile/src/features/settings/__tests__/network-form.test.ts b/apps/mobile/src/features/settings/__tests__/network-form.test.ts
new file mode 100644
index 00000000..437ccc8e
--- /dev/null
+++ b/apps/mobile/src/features/settings/__tests__/network-form.test.ts
@@ -0,0 +1,95 @@
+import {
+ DEFAULT_MANAGED_NETWORK_SETTINGS,
+ type ManagedNetwork,
+} from "@eight2five/mobile/pans-manager";
+
+import { networkDraftFromNetwork, validateNetworkDraft } from "../network-form";
+
+jest.mock("expo-pans-ble-api", () => ({}));
+jest.mock("react-native-worklets", () => ({
+ ...jest.requireActual("react-native-worklets/lib/module/mock"),
+ scheduleOnRN: (callback: (...args: unknown[]) => void, ...args: unknown[]) =>
+ callback(...args),
+}));
+jest.mock("react-native-reanimated", () =>
+ jest.requireActual("react-native-reanimated/mock"),
+);
+jest.mock(
+ "@shopify/react-native-skia",
+ () => ({
+ Canvas: () => null,
+ Fill: () => null,
+ Group: () => null,
+ Path: () => null,
+ Circle: () => null,
+ Line: () => null,
+ Rect: () => null,
+ useFont: () => ({}),
+ vec: (x: number, y: number) => ({ x, y }),
+ }),
+ { virtual: true },
+);
+
+describe("mobile network profile validation", () => {
+ test("accepts trimmed names and hexadecimal PAN IDs", () => {
+ const result = validateNetworkDraft({
+ name: " Stadium ",
+ panId: "0x00a0",
+ });
+
+ expect(result).toEqual({
+ errors: {},
+ value: { name: "Stadium", panId: 160 },
+ });
+ });
+
+ test("rejects an empty name, reserved PAN 0, and duplicate PAN IDs", () => {
+ const networks = [network("existing", "Existing", 160)];
+
+ expect(validateNetworkDraft({ name: " ", panId: "0" }, networks)).toEqual({
+ errors: {
+ name: "Enter a network name.",
+ panId:
+ "Saved network PAN ID must be an integer from 1 to 65535; PAN 0 is the PANS default used for unassigned devices.",
+ },
+ });
+ expect(
+ validateNetworkDraft({ name: "Other", panId: "160" }, networks).errors,
+ ).toEqual({ panId: "A network with this PAN ID already exists." });
+ });
+
+ test("rejects duplicate names without blocking the network being edited", () => {
+ const existing = network("existing", "Field", 160);
+ const other = network("other", "Auxiliary", 161);
+
+ expect(
+ validateNetworkDraft(
+ { name: " field ", panId: "0x00a2" },
+ [existing, other],
+ existing.id,
+ ),
+ ).toEqual({ errors: {}, value: { name: "field", panId: 162 } });
+ expect(
+ validateNetworkDraft({ name: "FIELD", panId: "162" }, [existing, other])
+ .errors,
+ ).toEqual({ name: "A network with this name already exists." });
+ });
+
+ test("round-trips the detail form draft", () => {
+ expect(networkDraftFromNetwork(network("one", "One", 42))).toEqual({
+ name: "One",
+ panId: "42",
+ });
+ });
+});
+
+function network(id: string, name: string, panId: number): ManagedNetwork {
+ return {
+ id,
+ name,
+ panId,
+ settings: DEFAULT_MANAGED_NETWORK_SETTINGS,
+ createdAt: 1,
+ updatedAt: 1,
+ };
+}
diff --git a/apps/mobile/src/features/settings/__tests__/network-ui.test.ts b/apps/mobile/src/features/settings/__tests__/network-ui.test.ts
new file mode 100644
index 00000000..59ea85b3
--- /dev/null
+++ b/apps/mobile/src/features/settings/__tests__/network-ui.test.ts
@@ -0,0 +1,99 @@
+import type {
+ DiscoveredDeviceSnapshot,
+ ManagedDevice,
+} from "@eight2five/mobile/pans-manager";
+
+import {
+ anchorInitiatorLabel,
+ commissioningWarningText,
+ selectAssociatedCachedAnchors,
+ selectNetworkAnchorDiscoveries,
+} from "../network-ui";
+
+describe("mobile network commissioning presentation", () => {
+ test("keeps associated cached anchors and presents initiator state", () => {
+ const associated = anchor("one", "network-a", true);
+ const unrelated = anchor("two", "network-b", false);
+
+ expect(
+ selectAssociatedCachedAnchors("network-a", [associated, unrelated]),
+ ).toEqual([associated]);
+ expect(anchorInitiatorLabel(associated)).toBe("Yes");
+ expect(anchorInitiatorLabel(anchor("unknown", undefined, false))).toBe(
+ "No",
+ );
+ expect(
+ anchorInitiatorLabel({ ...associated, lastKnownConfig: undefined }),
+ ).toBe("Unknown");
+ });
+
+ test("starts with current compatible discoveries and keeps uncached anchors", () => {
+ const cached = anchor("cached", "network-a", false);
+ const rows = selectNetworkAnchorDiscoveries(
+ [
+ discovery("uncached", "anchor", -50),
+ discovery("cached", "anchor", -60),
+ discovery("tag", "tag", -55),
+ { ...discovery("stale", "anchor", -40), stale: true },
+ { ...discovery("weak", "anchor", -90) },
+ { ...discovery("bad", "anchor", -45), compatibility: "incompatible" },
+ ],
+ [cached],
+ -75,
+ );
+
+ expect(rows.map((row) => row.discovery.transportDeviceId)).toEqual([
+ "uncached",
+ "tag",
+ "cached",
+ ]);
+ expect(rows[0].cachedAnchor).toBeUndefined();
+ expect(rows[1].requiresRoleChangeConfirmation).toBe(true);
+ expect(rows[2].cachedAnchor).toBe(cached);
+ expect(rows[2].requiresRoleChangeConfirmation).toBe(false);
+ });
+
+ test("does not invent a success message when verification is incomplete", () => {
+ const warning = "Initiator set, but one prior anchor was unreachable.";
+ expect(commissioningWarningText(warning)).toBe(warning);
+ expect(commissioningWarningText(" ")).toBeUndefined();
+ expect(commissioningWarningText(undefined)).toBeUndefined();
+ });
+});
+
+function discovery(
+ transportDeviceId: string,
+ role: "tag" | "anchor",
+ rssi: number,
+): DiscoveredDeviceSnapshot {
+ return {
+ transportDeviceId,
+ name: transportDeviceId,
+ rssi,
+ lastSeenAt: 1,
+ compatibility: "compatible",
+ presence: { role } as never,
+ };
+}
+
+function anchor(
+ id: string,
+ networkId: string | undefined,
+ initiatorEnabled: boolean,
+): ManagedDevice {
+ return {
+ id,
+ networkId,
+ transportDeviceId: id,
+ role: "anchor",
+ lastKnownConfig: {
+ role: "anchor",
+ uwbMode: "active",
+ initiatorEnabled,
+ ledEnabled: true,
+ firmwareUpdateEnabled: true,
+ },
+ createdAt: 1,
+ updatedAt: 1,
+ };
+}
diff --git a/apps/mobile/src/features/settings/__tests__/settings-actions.test.ts b/apps/mobile/src/features/settings/__tests__/settings-actions.test.ts
new file mode 100644
index 00000000..a1dd17f2
--- /dev/null
+++ b/apps/mobile/src/features/settings/__tests__/settings-actions.test.ts
@@ -0,0 +1,53 @@
+import { DEFAULT_APP_SETTINGS } from "@eight2five/mobile/settings";
+
+import {
+ RESET_SETTINGS_MESSAGE,
+ resetAppSettings,
+ updateDrillFeatures,
+} from "../settings-actions";
+
+describe("settings actions", () => {
+ test("persists Drill disablement before reconfiguring native tabs", async () => {
+ const events: string[] = [];
+ const writer = {
+ update: jest.fn(async () => {
+ events.push("persist");
+ return { ...DEFAULT_APP_SETTINGS, drillFeaturesEnabled: false };
+ }),
+ resetPreferences: jest.fn(),
+ };
+
+ await updateDrillFeatures(
+ writer,
+ (enabled) => events.push(`reconfigure:${enabled}`),
+ false,
+ );
+
+ expect(writer.update).toHaveBeenCalledWith({ drillFeaturesEnabled: false });
+ expect(events).toEqual(["persist", "reconfigure:false"]);
+ });
+
+ test("reset preserves selection through the repository and restores Drill", async () => {
+ const reset = {
+ ...DEFAULT_APP_SETTINGS,
+ activeDrillId: "drill-1",
+ selectedDrillPageId: "page-2",
+ };
+ const writer = {
+ update: jest.fn(),
+ resetPreferences: jest.fn(async () => reset),
+ };
+ const reconfigure = jest.fn();
+
+ await expect(resetAppSettings(writer, reconfigure)).resolves.toEqual(reset);
+ expect(reconfigure).toHaveBeenCalledTimes(1);
+ expect(reconfigure).toHaveBeenCalledWith(true);
+ });
+
+ test("reset confirmation states destructive boundaries", () => {
+ expect(RESET_SETTINGS_MESSAGE).toBe(
+ "This restores display, drill-feature, terminology, and developer preferences to their defaults.\n\n" +
+ "It does not delete drills, forget the remembered tag, delete cached anchor positions, or modify PANS hardware.",
+ );
+ });
+});
diff --git a/apps/mobile/src/features/settings/__tests__/settings-screen-policy.test.ts b/apps/mobile/src/features/settings/__tests__/settings-screen-policy.test.ts
new file mode 100644
index 00000000..e5830a05
--- /dev/null
+++ b/apps/mobile/src/features/settings/__tests__/settings-screen-policy.test.ts
@@ -0,0 +1,8 @@
+import { shouldShowTransitionCountControls } from "../settings-screen-policy";
+
+describe("settings screen transition count policy", () => {
+ test("hides detailed count controls when Show all is enabled", () => {
+ expect(shouldShowTransitionCountControls(true)).toBe(false);
+ expect(shouldShowTransitionCountControls(false)).toBe(true);
+ });
+});
diff --git a/apps/mobile/src/features/settings/__tests__/tag-connection-lifecycle.test.ts b/apps/mobile/src/features/settings/__tests__/tag-connection-lifecycle.test.ts
new file mode 100644
index 00000000..2bb33559
--- /dev/null
+++ b/apps/mobile/src/features/settings/__tests__/tag-connection-lifecycle.test.ts
@@ -0,0 +1,26 @@
+import { ownTagDiscoveryWhileFocused } from "../tag-connection-lifecycle";
+
+describe("Tag Connection discovery lifecycle", () => {
+ test("focus starts a page-owned scan and blur stops it", async () => {
+ const store = {
+ startTagDiscovery: jest.fn(async () => undefined),
+ stopManualDiscovery: jest.fn(),
+ };
+ const cleanup = ownTagDiscoveryWhileFocused(store, true, false, jest.fn());
+ await Promise.resolve();
+ expect(store.startTagDiscovery).toHaveBeenCalledTimes(1);
+ cleanup();
+ expect(store.stopManualDiscovery).toHaveBeenCalledTimes(1);
+ });
+
+ test("does not replace an already-connected global session", () => {
+ const store = {
+ startTagDiscovery: jest.fn(async () => undefined),
+ stopManualDiscovery: jest.fn(),
+ };
+ const cleanup = ownTagDiscoveryWhileFocused(store, true, true, jest.fn());
+ expect(store.startTagDiscovery).not.toHaveBeenCalled();
+ cleanup();
+ expect(store.stopManualDiscovery).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/mobile/src/features/settings/anchor-display.ts b/apps/mobile/src/features/settings/anchor-display.ts
new file mode 100644
index 00000000..83ccfe91
--- /dev/null
+++ b/apps/mobile/src/features/settings/anchor-display.ts
@@ -0,0 +1,10 @@
+import type { ManagedDevice } from "@eight2five/mobile/pans-manager";
+
+export function getDeveloperAnchorDisplayName(anchor: ManagedDevice): string {
+ return (
+ anchor.lastKnownConfig?.label?.trim() ||
+ anchor.label?.trim() ||
+ anchor.nodeIdHex?.trim() ||
+ anchor.id
+ );
+}
diff --git a/apps/mobile/src/features/settings/anchor-editor-form.ts b/apps/mobile/src/features/settings/anchor-editor-form.ts
new file mode 100644
index 00000000..6c691705
--- /dev/null
+++ b/apps/mobile/src/features/settings/anchor-editor-form.ts
@@ -0,0 +1,188 @@
+import type { FieldPresetId } from "@eight2five/drill-schema";
+import type { CoordinateRoundingSteps } from "@eight2five/mobile/settings";
+import {
+ ANCHOR_POSITION_REFERENCE_LABELS,
+ ANCHOR_POSITION_REFERENCES,
+ anchorFieldPositionFromMarchingCoordinate,
+ anchorFieldPositionToStandard,
+ anchorPositionUnitsToMeters,
+ convertAnchorPositionUnits,
+ formatMarchingCoordinate,
+ getAnchorPositionReferencePoint,
+ parseAnchorPositionDraft,
+ type AnchorFieldPosition,
+ type AnchorPositionReference,
+ type AnchorPositionUnit,
+ type StandardAnchorPositionDraft,
+} from "@eight2five/mobile/field";
+
+import {
+ coordinateDraftFromFieldPoint,
+ createDefaultPageDraft,
+ validatePageDraft,
+ type MarchingCoordinateDraft,
+} from "../drill/page-form";
+
+export type AnchorEditorMode = "marching" | "standard";
+export type MarchingHeightUnit = "meters" | "feet";
+const DEFAULT_ANCHOR_HEIGHT_METERS = 2;
+
+export interface MarchingAnchorDraft {
+ readonly coordinate: MarchingCoordinateDraft;
+ readonly height: string;
+ readonly heightUnit: MarchingHeightUnit;
+}
+
+export interface AnchorDraftValidation {
+ readonly errors: Readonly>;
+ readonly position?: AnchorFieldPosition;
+}
+
+export const ANCHOR_REFERENCE_CHOICES = ANCHOR_POSITION_REFERENCES.map(
+ (value) => ({ label: ANCHOR_POSITION_REFERENCE_LABELS[value], value }),
+);
+
+export const ANCHOR_UNIT_CHOICES: readonly {
+ readonly label: string;
+ readonly value: AnchorPositionUnit;
+}[] = [
+ { label: "Meters", value: "meters" },
+ { label: "Yards", value: "yards" },
+ { label: "Feet", value: "feet" },
+];
+
+export function createAnchorEditorDrafts(
+ position?: AnchorFieldPosition,
+ fieldPreset: FieldPresetId = "football-nfhs",
+): {
+ readonly marching: MarchingAnchorDraft;
+ readonly standard: StandardAnchorPositionDraft;
+} {
+ const center = getAnchorPositionReferencePoint("center-field", fieldPreset);
+ const initial = position ?? {
+ ...center,
+ zMeters: DEFAULT_ANCHOR_HEIGHT_METERS,
+ };
+ const coordinate = position
+ ? coordinateDraftFromFieldPoint(position, fieldPreset)
+ : createDefaultPageDraft({ ordinal: 0, suggestedNumber: 0 });
+ const standard = anchorFieldPositionToStandard(
+ initial,
+ "center-field",
+ "meters",
+ fieldPreset,
+ );
+ return {
+ marching: {
+ coordinate,
+ height: String(initial.zMeters),
+ heightUnit: "meters",
+ },
+ standard: {
+ reference: standard.reference,
+ unit: standard.unit,
+ sideToSideOffset: formatDraftNumber(standard.sideToSideOffset),
+ frontToBackOffset: formatDraftNumber(standard.frontToBackOffset),
+ height: formatDraftNumber(standard.height),
+ },
+ };
+}
+
+export function validateMarchingAnchorDraft(
+ draft: MarchingAnchorDraft,
+ fieldPreset: FieldPresetId = "football-nfhs",
+): AnchorDraftValidation {
+ const coordinate = validatePageDraft(draft.coordinate, fieldPreset);
+ const errors: Record = { ...coordinate.errors };
+ const height = Number(draft.height);
+ if (!draft.height.trim() || !Number.isFinite(height)) {
+ errors.height = "Enter a finite height.";
+ } else if (height < 0) {
+ errors.height = "Height cannot be negative.";
+ }
+ if (!coordinate.value || Object.keys(errors).length > 0) return { errors };
+ try {
+ return {
+ errors,
+ position: anchorFieldPositionFromMarchingCoordinate(
+ coordinate.value.coordinate,
+ anchorPositionUnitsToMeters(height, draft.heightUnit),
+ fieldPreset,
+ ),
+ };
+ } catch (cause) {
+ return {
+ errors: {
+ ...errors,
+ position: cause instanceof Error ? cause.message : String(cause),
+ },
+ };
+ }
+}
+
+export function convertMarchingHeightUnit(
+ draft: MarchingAnchorDraft,
+ heightUnit: MarchingHeightUnit,
+): MarchingAnchorDraft {
+ if (heightUnit === draft.heightUnit) return draft;
+ const value = Number(draft.height);
+ return {
+ ...draft,
+ heightUnit,
+ height:
+ draft.height.trim() && Number.isFinite(value)
+ ? formatDraftNumber(
+ convertAnchorPositionUnits(value, draft.heightUnit, heightUnit),
+ )
+ : draft.height,
+ };
+}
+
+export function validateStandardAnchorDraft(
+ draft: StandardAnchorPositionDraft,
+ fieldPreset: FieldPresetId = "football-nfhs",
+): AnchorDraftValidation {
+ const result = parseAnchorPositionDraft(draft, fieldPreset);
+ return { errors: result.errors, position: result.value };
+}
+
+export function formatAnchorCanonicalPreview(
+ position: AnchorFieldPosition | undefined,
+ fieldPreset: FieldPresetId = "football-nfhs",
+ coordinateRoundingSteps: CoordinateRoundingSteps = 0.25,
+): { readonly marching: string; readonly meters: string } | undefined {
+ if (!position) return undefined;
+ return {
+ marching: formatMarchingCoordinate(
+ position,
+ fieldPreset,
+ coordinateRoundingSteps,
+ ),
+ meters: `X ${position.xMeters.toFixed(3)} m · Y ${position.yMeters.toFixed(3)} m · Z ${position.zMeters.toFixed(3)} m`,
+ };
+}
+
+export function standardDraftFromPosition(
+ position: AnchorFieldPosition,
+ reference: AnchorPositionReference,
+ unit: AnchorPositionUnit,
+ fieldPreset: FieldPresetId = "football-nfhs",
+): StandardAnchorPositionDraft {
+ const standard = anchorFieldPositionToStandard(
+ position,
+ reference,
+ unit,
+ fieldPreset,
+ );
+ return {
+ reference,
+ unit,
+ sideToSideOffset: formatDraftNumber(standard.sideToSideOffset),
+ frontToBackOffset: formatDraftNumber(standard.frontToBackOffset),
+ height: formatDraftNumber(standard.height),
+ };
+}
+
+function formatDraftNumber(value: number): string {
+ return Number(value.toFixed(6)).toString();
+}
diff --git a/apps/mobile/src/features/settings/anchor-editor-screen.tsx b/apps/mobile/src/features/settings/anchor-editor-screen.tsx
new file mode 100644
index 00000000..896861c3
--- /dev/null
+++ b/apps/mobile/src/features/settings/anchor-editor-screen.tsx
@@ -0,0 +1,276 @@
+import React from "react";
+import { Radio, Save, TriangleAlert } from "lucide-react-native";
+import {
+ Button,
+ ButtonIcon,
+ ButtonText,
+} from "@eight2five/ui/components/button";
+import { Card } from "@eight2five/ui/components/card";
+import {
+ FormControl,
+ FormControlHelper,
+ FormControlHelperText,
+ FormControlLabel,
+ FormControlLabelText,
+} from "@eight2five/ui/components/form-control";
+import { HStack } from "@eight2five/ui/components/hstack";
+import { Icon } from "@eight2five/ui/components/icon";
+import { Input, InputField } from "@eight2five/ui/components/input";
+import { Text } from "@eight2five/ui/components/text";
+import { VStack } from "@eight2five/ui/components/vstack";
+import {
+ eight2FiveRadii,
+ eight2FiveSpacing,
+ useEight2FiveTheme,
+} from "@eight2five/ui/theme";
+
+import { MarchingCoordinateForm } from "../drill/components/marching-coordinate-form";
+import { formatAnchorCanonicalPreview } from "./anchor-editor-form";
+import { getDeveloperAnchorDisplayName } from "./anchor-display";
+import { confirmAnchorPositionWrite } from "./anchor-write-confirmation";
+import {
+ AnchorNumberInput,
+ StandardAnchorPositionForm,
+} from "./standard-anchor-position-form";
+import { SpinningLoaderIcon } from "../../components/spinning-loader-icon";
+import { useAnchorEditorController } from "./use-anchor-editor-controller";
+import {
+ SettingsMessage,
+ SettingsScreenContainer,
+ SettingsSection,
+ SettingsValueRow,
+} from "./settings-components";
+
+export function AnchorEditorScreen({
+ anchorId,
+}: {
+ readonly anchorId: string;
+}) {
+ const theme = useEight2FiveTheme();
+ const controller = useAnchorEditorController(anchorId);
+ const preview = formatAnchorCanonicalPreview(
+ controller.validation.position,
+ controller.fieldPreset,
+ controller.coordinateRoundingSteps,
+ );
+
+ if (!controller.developerModeEnabled) {
+ return (
+
+
+ Enable Developer Mode before editing anchor positions.
+
+
+ );
+ }
+
+ return (
+
+ {controller.error ? (
+
+ {controller.error.message}
+
+ ) : null}
+ {controller.saved ? (
+
+ Anchor position written. PANS positions are write-only, so the cache
+ records this successful unverified write.
+
+ ) : null}
+
+
+
+
+
+ Anchor name
+
+
+
+
+
+
+ Written to the PANS device name and advertised over Bluetooth.
+
+
+
+
+
+
+
+
+
+
+
+
+ {controller.mode === "marching" ? (
+
+
+ controller.setMarchingDraft({
+ ...controller.marchingDraft,
+ coordinate,
+ })
+ }
+ />
+
+
+
+ {(["meters", "feet"] as const).map((unit) => (
+
+ ))}
+
+
+ controller.setMarchingDraft({
+ ...controller.marchingDraft,
+ height,
+ })
+ }
+ />
+
+
+
+ ) : (
+
+
+
+
+
+ )}
+
+
+ Canonical preview
+
+ {preview?.marching ?? "Complete a valid in-bounds position."}
+
+ {preview ? (
+
+ {preview.meters}
+
+ ) : null}
+
+
+ {!controller.canWritePosition ? (
+
+
+
+ Select this anchor's network as active or connect an associated
+ tag before the confirmed hardware write.
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/settings/anchor-list-screen.tsx b/apps/mobile/src/features/settings/anchor-list-screen.tsx
new file mode 100644
index 00000000..403b5924
--- /dev/null
+++ b/apps/mobile/src/features/settings/anchor-list-screen.tsx
@@ -0,0 +1,141 @@
+import { useRouter } from "expo-router";
+import { Database, Pencil, RefreshCw, Triangle } from "lucide-react-native";
+import {
+ Button,
+ ButtonIcon,
+ ButtonText,
+} from "@eight2five/ui/components/button";
+import { HStack } from "@eight2five/ui/components/hstack";
+import { Icon } from "@eight2five/ui/components/icon";
+import { Pressable } from "@eight2five/ui/components/pressable";
+import { Text } from "@eight2five/ui/components/text";
+import { VStack } from "@eight2five/ui/components/vstack";
+import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme";
+
+import { SpinningLoaderIcon } from "../../components/spinning-loader-icon";
+import { getDeveloperAnchorDisplayName } from "./anchor-display";
+import { useAnchorListController } from "./use-anchor-list-controller";
+import {
+ SettingsMessage,
+ SettingsScreenContainer,
+ SettingsSection,
+ SettingsValueRow,
+} from "./settings-components";
+
+export function AnchorListScreen() {
+ const router = useRouter();
+ const theme = useEight2FiveTheme();
+ const controller = useAnchorListController();
+
+ if (!controller.developerModeEnabled) {
+ return (
+
+
+ Enable Developer Mode before viewing or editing cached anchors.
+
+
+ );
+ }
+
+ return (
+
+ {controller.error ? (
+
+ {controller.error.message}
+
+ ) : null}
+
+
+
+
+
+
+
+
+ {controller.anchors.length === 0 ? (
+
+
+
+ No anchors are cached. Discover the deployment with the tag to
+ cache nearby anchors.
+
+
+ ) : (
+ controller.anchors.map((anchor) => {
+ const config =
+ anchor.lastKnownConfig?.role === "anchor"
+ ? anchor.lastKnownConfig
+ : undefined;
+ const position = config?.position;
+ return (
+
+ router.push({
+ pathname: "/(tabs)/settings/anchor/[anchorId]",
+ params: { anchorId: anchor.id },
+ })
+ }
+ >
+
+
+
+
+ {getDeveloperAnchorDisplayName(anchor)}
+
+
+ Initiator:{" "}
+ {config
+ ? config.initiatorEnabled
+ ? "Yes"
+ : "No"
+ : "Unknown"}
+
+
+ {position
+ ? `${position.xMeters.toFixed(3)}, ${position.yMeters.toFixed(3)}, ${position.zMeters.toFixed(3)} m`
+ : "Coordinate not cached"}
+
+
+ {position
+ ? "Source: local PANS cache"
+ : "Status: not configured"}
+
+
+
+
+
+ );
+ })
+ )}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/settings/anchor-write-confirmation.ts b/apps/mobile/src/features/settings/anchor-write-confirmation.ts
new file mode 100644
index 00000000..2e91b421
--- /dev/null
+++ b/apps/mobile/src/features/settings/anchor-write-confirmation.ts
@@ -0,0 +1,16 @@
+import { Alert } from "react-native";
+import type { AnchorFieldPosition } from "@eight2five/mobile/field";
+
+export function confirmAnchorPositionWrite(
+ position: AnchorFieldPosition,
+ onConfirm: () => void,
+): void {
+ Alert.alert(
+ "Write anchor position?",
+ `This will write X ${position.xMeters.toFixed(3)} m, Y ${position.yMeters.toFixed(3)} m, Z ${position.zMeters.toFixed(3)} m to PANS hardware. Incorrect positions can make reported locations inaccurate.`,
+ [
+ { text: "Cancel", style: "cancel" },
+ { text: "Write Position", onPress: onConfirm },
+ ],
+ );
+}
diff --git a/apps/mobile/src/features/settings/comfortable-anchor-range.ts b/apps/mobile/src/features/settings/comfortable-anchor-range.ts
new file mode 100644
index 00000000..4fe8f4ef
--- /dev/null
+++ b/apps/mobile/src/features/settings/comfortable-anchor-range.ts
@@ -0,0 +1,20 @@
+import { MAX_COMFORTABLE_ANCHOR_RANGE_METERS } from "@eight2five/mobile/settings";
+export interface ComfortableAnchorRangeResult {
+ readonly value?: number;
+ readonly error?: string;
+}
+
+export function parseComfortableAnchorRange(
+ input: string,
+): ComfortableAnchorRangeResult {
+ if (!input.trim()) return { error: "Enter a comfortable range in meters." };
+ const value = Number(input);
+ if (!Number.isFinite(value)) return { error: "Range must be finite." };
+ if (value <= 0) return { error: "Range must be greater than 0 meters." };
+ if (value > MAX_COMFORTABLE_ANCHOR_RANGE_METERS) {
+ return {
+ error: `Range must not exceed ${MAX_COMFORTABLE_ANCHOR_RANGE_METERS} meters.`,
+ };
+ }
+ return { value };
+}
diff --git a/apps/mobile/src/features/settings/connection-status-row.tsx b/apps/mobile/src/features/settings/connection-status-row.tsx
new file mode 100644
index 00000000..e808d84d
--- /dev/null
+++ b/apps/mobile/src/features/settings/connection-status-row.tsx
@@ -0,0 +1,99 @@
+import React from "react";
+import { Animated, Easing } from "react-native";
+import {
+ Bluetooth,
+ BluetoothConnected,
+ BluetoothOff,
+ LoaderCircle,
+ TriangleAlert,
+} from "lucide-react-native";
+import { HStack } from "@eight2five/ui/components/hstack";
+import { Icon } from "@eight2five/ui/components/icon";
+import { Text } from "@eight2five/ui/components/text";
+import {
+ eight2FiveFonts,
+ eight2FiveSpacing,
+ useEight2FiveTheme,
+} from "@eight2five/ui/theme";
+
+import type { TagConnectionState } from "../../pans/mobile-pans-model";
+import { connectionStatusViewModel } from "../../pans/mobile-pans-ui";
+
+const ICONS = {
+ connected: BluetoothConnected,
+ searching: LoaderCircle,
+ connecting: Bluetooth,
+ disconnected: BluetoothOff,
+ error: TriangleAlert,
+} as const;
+
+export function ConnectionStatusRow({
+ state,
+}: {
+ readonly state: TagConnectionState;
+}) {
+ const theme = useEight2FiveTheme();
+ const presentation = connectionStatusViewModel(state);
+ const [spin] = React.useState(() => new Animated.Value(0));
+
+ React.useEffect(() => {
+ if (!presentation.animated) {
+ spin.stopAnimation();
+ spin.setValue(0);
+ return;
+ }
+ const animation = Animated.loop(
+ Animated.timing(spin, {
+ toValue: 1,
+ duration: 900,
+ easing: Easing.linear,
+ useNativeDriver: true,
+ }),
+ );
+ animation.start();
+ return () => animation.stop();
+ }, [presentation.animated, spin]);
+
+ const color =
+ presentation.tone === "success"
+ ? theme.success
+ : presentation.tone === "accent"
+ ? theme.accent
+ : presentation.tone === "danger"
+ ? theme.danger
+ : theme.textMuted;
+ const icon = (
+
+ );
+ return (
+
+ {presentation.animated ? (
+
+ {icon}
+
+ ) : (
+ icon
+ )}
+
+ {presentation.label}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/settings/developer-confirmation-screen.tsx b/apps/mobile/src/features/settings/developer-confirmation-screen.tsx
new file mode 100644
index 00000000..d60b3f11
--- /dev/null
+++ b/apps/mobile/src/features/settings/developer-confirmation-screen.tsx
@@ -0,0 +1,2 @@
+// Compatibility export for stale imports while the old confirmation flow is retired.
+export { DeveloperSettingsScreen as DeveloperConfirmationScreen } from "./developer-settings-screen";
diff --git a/apps/mobile/src/features/settings/developer-diagnostics.ts b/apps/mobile/src/features/settings/developer-diagnostics.ts
new file mode 100644
index 00000000..d0ebbcd4
--- /dev/null
+++ b/apps/mobile/src/features/settings/developer-diagnostics.ts
@@ -0,0 +1,109 @@
+import type {
+ ManagedDevice,
+ PansDiagnosticsResult,
+} from "@eight2five/mobile/pans-manager";
+
+import type { MobilePansSnapshot } from "../../pans/mobile-pans-store";
+
+export interface DeveloperDiagnosticRow {
+ readonly label: string;
+ readonly value: string;
+}
+
+/** Produces the intentionally quality-free diagnostics shown in production. */
+export function buildDeveloperDiagnosticRows(
+ snapshot: MobilePansSnapshot,
+): readonly DeveloperDiagnosticRow[] {
+ const diagnostics = snapshot.hardwareDiagnostics;
+ const position = snapshot.rawPosition;
+ return [
+ { label: "Connection state", value: snapshot.connectionState },
+ {
+ label: "Node ID",
+ value:
+ diagnostics?.deviceInfo?.nodeIdHex ??
+ snapshot.rememberedTag?.nodeIdHex ??
+ "Unavailable",
+ },
+ {
+ label: "PAN ID",
+ value:
+ diagnostics?.panId === undefined
+ ? (snapshot.rememberedTag?.lastKnownConfig?.panId?.toString() ??
+ "Unavailable")
+ : formatHex(diagnostics.panId, 4),
+ },
+ { label: "Firmware version", value: firmwareVersion(diagnostics) },
+ { label: "Raw PANS X", value: formatMeters(position?.xMeters) },
+ { label: "Raw PANS Y", value: formatMeters(position?.yMeters) },
+ { label: "Raw PANS Z", value: formatMeters(position?.zMeters) },
+ {
+ label: "Last update",
+ value: snapshot.lastUpdateAt
+ ? new Date(snapshot.lastUpdateAt).toISOString()
+ : "Never",
+ },
+ {
+ label: "Effective update rate",
+ value: `${snapshot.effectiveUpdateRateHz.toFixed(1)} Hz`,
+ },
+ {
+ label: "Locally known anchors",
+ value: snapshot.knownAnchors.length.toString(),
+ },
+ ...(snapshot.counters
+ ? [
+ {
+ label: "Notification events",
+ value: snapshot.counters.notificationEvents.toString(),
+ },
+ {
+ label: "Decoded position frames",
+ value: snapshot.counters.positionFrames.toString(),
+ },
+ ]
+ : []),
+ ...snapshot.diagnosticMessages.map((message, index) => ({
+ label: `Stream diagnostic ${index + 1}`,
+ value: message,
+ })),
+ ...(diagnostics?.warnings.map((warning) => ({
+ label: `Hardware warning: ${warning.section}`,
+ value: warning.message,
+ })) ?? []),
+ ...snapshot.knownAnchors.map(anchorDiagnosticRow),
+ ];
+}
+
+function anchorDiagnosticRow(
+ anchor: ManagedDevice,
+ index: number,
+): DeveloperDiagnosticRow {
+ const position =
+ anchor.lastKnownConfig?.role === "anchor"
+ ? anchor.lastKnownConfig.position
+ : undefined;
+ return {
+ label: `Cached anchor ${anchor.nodeIdHex ?? anchor.label ?? index + 1}`,
+ value: position
+ ? `${position.xMeters.toFixed(3)}, ${position.yMeters.toFixed(3)}, ${position.zMeters.toFixed(3)} m`
+ : "Position unavailable",
+ };
+}
+
+function firmwareVersion(
+ diagnostics: PansDiagnosticsResult | undefined,
+): string {
+ if (!diagnostics?.deviceInfo) return "Unavailable";
+ return diagnostics.operationMode.selectedFirmware === 2
+ ? diagnostics.deviceInfo.firmware2Version.toString()
+ : diagnostics.deviceInfo.firmware1Version.toString();
+}
+
+function formatMeters(value: number | undefined): string {
+ return value === undefined ? "Unavailable" : `${value.toFixed(3)} m`;
+}
+
+function formatHex(value: number, width: number): string {
+ return `0x${value.toString(16).toUpperCase().padStart(width, "0")}`;
+}
diff --git a/apps/mobile/src/features/settings/developer-mode-actions.ts b/apps/mobile/src/features/settings/developer-mode-actions.ts
new file mode 100644
index 00000000..3e842006
--- /dev/null
+++ b/apps/mobile/src/features/settings/developer-mode-actions.ts
@@ -0,0 +1,30 @@
+import {
+ DEFAULT_APP_SETTINGS,
+ type AppSettings,
+ type AppSettingsUpdate,
+} from "@eight2five/mobile/settings";
+
+export interface DeveloperModeWriter {
+ update(partial: AppSettingsUpdate): Promise;
+}
+
+export async function enableDeveloperMode(
+ writer: DeveloperModeWriter,
+): Promise {
+ return await writer.update({ developerModeEnabled: true });
+}
+
+export async function disableDeveloperMode(
+ writer: DeveloperModeWriter,
+): Promise {
+ return await writer.update({
+ developerModeEnabled: false,
+ mockLivePositionEnabled: false,
+ mockLivePositionXSteps: DEFAULT_APP_SETTINGS.mockLivePositionXSteps,
+ mockLivePositionYSteps: DEFAULT_APP_SETTINGS.mockLivePositionYSteps,
+ });
+}
+
+export function canUseDeveloperControls(settings: AppSettings): boolean {
+ return settings.developerModeEnabled;
+}
diff --git a/apps/mobile/src/features/settings/developer-settings-screen.tsx b/apps/mobile/src/features/settings/developer-settings-screen.tsx
new file mode 100644
index 00000000..f614cc36
--- /dev/null
+++ b/apps/mobile/src/features/settings/developer-settings-screen.tsx
@@ -0,0 +1,511 @@
+import React from "react";
+import { Alert } from "react-native";
+import { useRouter } from "expo-router";
+import {
+ Activity,
+ CircleDotDashed,
+ Code2,
+ Crosshair,
+ Database,
+ Grid3X3,
+ MapPinned,
+ Network,
+ RefreshCw,
+ Radio,
+ SlidersHorizontal,
+ Triangle,
+} from "lucide-react-native";
+import {
+ Button,
+ ButtonIcon,
+ ButtonText,
+} from "@eight2five/ui/components/button";
+import { HStack } from "@eight2five/ui/components/hstack";
+import { Text } from "@eight2five/ui/components/text";
+import { VStack } from "@eight2five/ui/components/vstack";
+import {
+ eight2FiveFonts,
+ eight2FiveSpacing,
+ useEight2FiveTheme,
+} from "@eight2five/ui/theme";
+
+import { SpinningLoaderIcon } from "../../components/spinning-loader-icon";
+import {
+ useAppSettingsSnapshot,
+ useAppSettingsStore,
+} from "../../state/app-settings-store";
+import {
+ useMobilePansSnapshot,
+ useMobilePansStore,
+} from "../../pans/mobile-pans-context";
+import { buildDeveloperDiagnosticRows } from "./developer-diagnostics";
+import { parseComfortableAnchorRange } from "./comfortable-anchor-range";
+import {
+ disableDeveloperMode,
+ enableDeveloperMode,
+} from "./developer-mode-actions";
+import { AnchorNumberInput } from "./standard-anchor-position-form";
+import {
+ SettingsMessage,
+ SettingsNavigationRow,
+ SettingsScreenContainer,
+ SettingsSection,
+ SettingsSwitchRow,
+ SettingsValueRow,
+} from "./settings-components";
+
+export function DeveloperSettingsScreen() {
+ const router = useRouter();
+ const theme = useEight2FiveTheme();
+ const settingsStore = useAppSettingsStore();
+ const pansStore = useMobilePansStore();
+ const { status, settings, error: settingsError } = useAppSettingsSnapshot();
+ const pans = useMobilePansSnapshot();
+ const [refreshing, setRefreshing] = React.useState(false);
+ const [rebuildingDatabase, setRebuildingDatabase] = React.useState(false);
+ const [operationError, setOperationError] = React.useState();
+ const [rangeDraft, setRangeDraft] = React.useState(() =>
+ settings.comfortableAnchorRangeMeters.toString(),
+ );
+ const [rssiDraft, setRssiDraft] = React.useState(() =>
+ pans.discoveryRssiCutoff.toString(),
+ );
+ const [mockXDraft, setMockXDraft] = React.useState(() =>
+ settings.mockLivePositionXSteps.toString(),
+ );
+ const [mockYDraft, setMockYDraft] = React.useState(() =>
+ settings.mockLivePositionYSteps.toString(),
+ );
+ const rows = React.useMemo(() => buildDeveloperDiagnosticRows(pans), [pans]);
+
+ const setDeveloperMode = async (enabled: boolean) => {
+ setOperationError(undefined);
+ try {
+ if (enabled) {
+ await enableDeveloperMode(settingsStore);
+ } else {
+ await disableDeveloperMode(settingsStore);
+ }
+ } catch (cause) {
+ setOperationError(
+ cause instanceof Error ? cause : new Error(String(cause)),
+ );
+ }
+ };
+
+ const refresh = async () => {
+ if (refreshing) return;
+ setRefreshing(true);
+ setOperationError(undefined);
+ try {
+ await pansStore.refreshDiagnostics();
+ } catch (cause) {
+ setOperationError(
+ cause instanceof Error ? cause : new Error(String(cause)),
+ );
+ } finally {
+ setRefreshing(false);
+ }
+ };
+
+ const rebuildDatabase = async () => {
+ if (rebuildingDatabase) return;
+ setRebuildingDatabase(true);
+ setOperationError(undefined);
+ try {
+ await settingsStore.rebuildDatabase();
+ } catch (cause) {
+ setOperationError(
+ cause instanceof Error ? cause : new Error(String(cause)),
+ );
+ } finally {
+ setRebuildingDatabase(false);
+ }
+ };
+
+ const confirmDatabaseRebuild = () => {
+ Alert.alert(
+ "Rebuild app database?",
+ "This deletes all locally stored drills and app settings, then recreates the app database from the current schema. PANS device and network data is not deleted.",
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Rebuild",
+ style: "destructive",
+ onPress: () => void rebuildDatabase(),
+ },
+ ],
+ );
+ };
+
+ const updateOverlay = async (partial: {
+ showCachedAnchorGeometry?: boolean;
+ showComfortableAnchorRange?: boolean;
+ showPerimeterStepGrid?: boolean;
+ comfortableAnchorRangeMeters?: number;
+ }) => {
+ setOperationError(undefined);
+ try {
+ await settingsStore.update(partial);
+ } catch (cause) {
+ setOperationError(
+ cause instanceof Error ? cause : new Error(String(cause)),
+ );
+ }
+ };
+
+ const rangeValidation = parseComfortableAnchorRange(rangeDraft);
+ const mockX = parseStaticPositionCoordinate(mockXDraft);
+ const mockY = parseStaticPositionCoordinate(mockYDraft);
+ const parsedRssi = Number(rssiDraft);
+ const validRssi =
+ Number.isInteger(parsedRssi) && parsedRssi >= -100 && parsedRssi <= -30;
+
+ if (!settings.developerModeEnabled) {
+ return (
+
+ {settingsError || operationError ? (
+
+ {(operationError ?? settingsError)?.message}
+
+ ) : null}
+
+ {
+ if (enabled) void setDeveloperMode(true);
+ }}
+ disabled={status !== "ready"}
+ testID="developer-mode-setting"
+ />
+
+ {status === "error" ? (
+
+
+
+ If the app database cannot open after a schema change, rebuild
+ it from the current schema. This clears local drills and app
+ settings but leaves PANS device and network data alone.
+
+
+
+
+ ) : null}
+
+ );
+ }
+
+ return (
+
+ {settingsError || operationError ? (
+
+ {(operationError ?? settingsError)?.message}
+
+ ) : null}
+ {pans.commissioningWarning ? (
+
+ {pans.commissioningWarning}
+
+ ) : null}
+
+ void setDeveloperMode(enabled)}
+ disabled={status !== "ready"}
+ testID="developer-mode-setting"
+ />
+
+
+
+
+
+ {rows.slice(1).map((row) => (
+
+ {row.label}
+
+ {row.value}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ void settingsStore
+ .update({ mockLivePositionEnabled })
+ .catch((cause) =>
+ setOperationError(
+ cause instanceof Error ? cause : new Error(String(cause)),
+ ),
+ )
+ }
+ testID="mock-live-position-setting"
+ />
+ {settings.mockLivePositionEnabled ? (
+
+
+
+
+
+ ) : null}
+
+
+
+
+
+ Delete the disposable app SQLite database and recreate it from the
+ current schema. This clears local drills and app settings but does
+ not delete PANS device or network data.
+
+
+
+
+
+
+ router.push("/(tabs)/settings/networks" as never)}
+ testID="networks-link"
+ />
+ router.push("/(tabs)/settings/anchors")}
+ testID="cached-anchors-link"
+ />
+
+
+
+
+ void updateOverlay({ showPerimeterStepGrid })
+ }
+ testID="show-perimeter-step-grid-setting"
+ />
+
+ void updateOverlay({ showCachedAnchorGeometry })
+ }
+ testID="show-cached-anchor-geometry-setting"
+ />
+
+ void updateOverlay({ showComfortableAnchorRange })
+ }
+ disabled={!settings.showCachedAnchorGeometry}
+ testID="show-comfortable-anchor-range-setting"
+ />
+
+
+
+
+
+
+ );
+}
+
+function parseStaticPositionCoordinate(value: string): {
+ readonly value?: number;
+ readonly error?: string;
+} {
+ const normalized = value.trim();
+ if (!normalized) return { error: "Enter a coordinate." };
+ const parsed = Number(normalized);
+ if (!Number.isFinite(parsed)) return { error: "Enter a finite number." };
+ if (Math.abs(parsed) > 1000) {
+ return { error: "Enter a value between -1000 and 1000 steps." };
+ }
+ return { value: parsed };
+}
diff --git a/apps/mobile/src/features/settings/network-detail-screen.tsx b/apps/mobile/src/features/settings/network-detail-screen.tsx
new file mode 100644
index 00000000..b1082f59
--- /dev/null
+++ b/apps/mobile/src/features/settings/network-detail-screen.tsx
@@ -0,0 +1,514 @@
+import React from "react";
+import { Alert } from "react-native";
+import { useFocusEffect, useRouter } from "expo-router";
+import {
+ Check,
+ Pencil,
+ Radio,
+ ShieldCheck,
+ Trash2,
+ Triangle,
+ TriangleAlert,
+} from "lucide-react-native";
+import {
+ formatPanId,
+ type ManagedDevice,
+} from "@eight2five/mobile/pans-manager";
+import {
+ Button,
+ ButtonIcon,
+ ButtonText,
+} from "@eight2five/ui/components/button";
+import { HStack } from "@eight2five/ui/components/hstack";
+import { Icon } from "@eight2five/ui/components/icon";
+import { Pressable } from "@eight2five/ui/components/pressable";
+import { Text } from "@eight2five/ui/components/text";
+import { VStack } from "@eight2five/ui/components/vstack";
+import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme";
+
+import { useAppSettingsSnapshot } from "../../state/app-settings-store";
+import {
+ useMobilePansSnapshot,
+ useMobilePansStore,
+} from "../../pans/mobile-pans-context";
+import {
+ networkDraftFromNetwork,
+ validateNetworkDraft,
+ type NetworkDraft,
+} from "./network-form";
+import { SpinningLoaderIcon } from "../../components/spinning-loader-icon";
+import { getDeveloperAnchorDisplayName } from "./anchor-display";
+import { NetworkProfileForm } from "./network-profile-form";
+import {
+ anchorInitiatorLabel,
+ commissioningWarningText,
+ selectAssociatedCachedAnchors,
+ selectNetworkAnchorDiscoveries,
+ type NetworkAnchorDiscoveryRow,
+} from "./network-ui";
+import {
+ SettingsMessage,
+ SettingsScreenContainer,
+ SettingsSection,
+ SettingsValueRow,
+} from "./settings-components";
+
+export function NetworkDetailScreen({
+ networkId,
+}: {
+ readonly networkId: string;
+}) {
+ const router = useRouter();
+ const theme = useEight2FiveTheme();
+ const store = useMobilePansStore();
+ const snapshot = useMobilePansSnapshot();
+ const { settings } = useAppSettingsSnapshot();
+ const network = snapshot.networks.find((item) => item.id === networkId);
+ const [draftOverride, setDraftOverride] = React.useState();
+ const draft = React.useMemo(
+ () =>
+ draftOverride ??
+ (network ? networkDraftFromNetwork(network) : { name: "", panId: "" }),
+ [draftOverride, network],
+ );
+ const connectionStateRef = React.useRef(snapshot.connectionState);
+ React.useEffect(() => {
+ connectionStateRef.current = snapshot.connectionState;
+ }, [snapshot.connectionState]);
+ const [busy, setBusy] = React.useState(false);
+ const [error, setError] = React.useState();
+
+ useFocusEffect(
+ React.useCallback(() => {
+ if (
+ settings.developerModeEnabled &&
+ snapshot.initialization === "ready" &&
+ connectionStateRef.current !== "connected"
+ ) {
+ void store
+ .startTagDiscovery()
+ .catch((cause) => setError(toError(cause)));
+ }
+ return () => store.stopManualDiscovery();
+ }, [settings.developerModeEnabled, snapshot.initialization, store]),
+ );
+
+ const validation = React.useMemo(
+ () => validateNetworkDraft(draft, snapshot.networks, network?.id),
+ [draft, network?.id, snapshot.networks],
+ );
+ const associatedAnchors = React.useMemo(
+ () => selectAssociatedCachedAnchors(networkId, snapshot.knownAnchors),
+ [networkId, snapshot.knownAnchors],
+ );
+ const discoveryRows = React.useMemo(
+ () =>
+ selectNetworkAnchorDiscoveries(
+ snapshot.discoveries,
+ snapshot.knownAnchors,
+ snapshot.discoveryRssiCutoff,
+ ),
+ [snapshot.discoveries, snapshot.discoveryRssiCutoff, snapshot.knownAnchors],
+ );
+
+ if (snapshot.initialization === "loading") {
+ return (
+
+
+ Preparing network details…
+
+
+ );
+ }
+
+ if (!settings.developerModeEnabled) {
+ return (
+
+
+ Enable Developer Mode before managing PANS networks.
+
+
+ );
+ }
+
+ if (!network) {
+ return (
+
+
+ The selected network no longer exists.
+
+
+ );
+ }
+
+ const run = async (action: () => Promise) => {
+ if (busy) return;
+ setBusy(true);
+ setError(undefined);
+ try {
+ await action();
+ } catch (cause) {
+ setError(toError(cause));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const ensureActiveNetwork = async () => {
+ if (snapshot.activeNetworkId !== network.id) {
+ await store.setActiveNetwork(network.id);
+ }
+ };
+
+ const save = () => {
+ if (!validation.value) return;
+ void run(async () => {
+ const saved = await store.updateNetwork(network.id, {
+ name: validation.value!.name,
+ panId: validation.value!.panId,
+ });
+ setDraftOverride(networkDraftFromNetwork(saved));
+ });
+ };
+
+ const setActive = () =>
+ void run(async () => {
+ await store.setActiveNetwork(network.id);
+ });
+
+ const openAnchor = (anchorId: string) => {
+ router.push({
+ pathname: "/(tabs)/settings/anchor/[anchorId]",
+ params: { anchorId },
+ });
+ };
+
+ const persistDiscovery = async (
+ row: NetworkAnchorDiscoveryRow,
+ confirmRoleChange: boolean,
+ ) => {
+ await run(async () => {
+ await ensureActiveNetwork();
+ if (row.cachedAnchor && row.advertisedRole === "anchor") {
+ await store.assignDeviceToActiveNetwork(row.cachedAnchor.id);
+ openAnchor(row.cachedAnchor.id);
+ return;
+ }
+ const saved = await store.persistDiscoveredAnchor(
+ row.discovery.transportDeviceId,
+ confirmRoleChange,
+ );
+ if (saved.networkId !== network.id) {
+ await store.assignDeviceToActiveNetwork(saved.id);
+ }
+ openAnchor(saved.id);
+ });
+ };
+
+ const handleDiscovery = (row: NetworkAnchorDiscoveryRow) => {
+ if (
+ row.cachedAnchor?.networkId === network.id &&
+ row.advertisedRole === "anchor"
+ ) {
+ openAnchor(row.cachedAnchor.id);
+ return;
+ }
+ if (row.requiresRoleChangeConfirmation) {
+ Alert.alert(
+ "Convert device to anchor?",
+ `${
+ row.discovery.name ?? row.discovery.transportDeviceId
+ } is advertising as ${
+ row.advertisedRole === "unknown" ? "an unknown role" : "a tag"
+ }. Converting it changes the PANS hardware role and may interrupt its current use.`,
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Convert to Anchor",
+ style: "destructive",
+ onPress: () => void persistDiscovery(row, true),
+ },
+ ],
+ );
+ return;
+ }
+ void persistDiscovery(row, false);
+ };
+
+ const confirmDelete = () => {
+ Alert.alert(
+ "Delete network profile?",
+ `Delete ${network.name}? Its cached device associations will be removed, but this does not erase hardware.`,
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Delete Network",
+ style: "destructive",
+ onPress: () =>
+ void run(async () => {
+ await store.deleteNetwork(network.id);
+ router.back();
+ }),
+ },
+ ],
+ );
+ };
+
+ return (
+
+ {error || snapshot.error ? (
+
+ {(error ?? snapshot.error)?.message}
+
+ ) : null}
+ {commissioningWarningText(snapshot.commissioningWarning) ? (
+
+ {commissioningWarningText(snapshot.commissioningWarning)}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {associatedAnchors.length === 0 ? (
+
+
+
+ No cached anchors are associated with this network yet. Start with
+ the live discovery list below.
+
+
+ ) : (
+ associatedAnchors.map((anchor) => (
+ openAnchor(anchor.id)}
+ onSetInitiator={() =>
+ void run(async () => {
+ await ensureActiveNetwork();
+ await store.setAnchorInitiator(anchor.id);
+ })
+ }
+ />
+ ))
+ )}
+
+
+
+ {snapshot.connectionState === "connected" ? (
+
+ Disconnect the current performer tag before starting anchor
+ discovery.
+
+ ) : null}
+ {discoveryRows.length === 0 ? (
+
+ {snapshot.connectionState === "scanning"
+ ? "Looking for compatible PANS devices…"
+ : "No current compatible devices meet the signal requirement."}
+
+ ) : (
+ discoveryRows.map((row) => (
+ handleDiscovery(row)}
+ />
+ ))
+ )}
+
+
+
+
+ {snapshot.commissioningWarning ? (
+
+
+
+ Hardware verification is incomplete. Keep the warning above until
+ the unreachable anchors can be checked.
+
+
+ ) : null}
+
+ );
+}
+
+function CachedAnchorRow({
+ anchor,
+ busy,
+ onEdit,
+ onSetInitiator,
+}: {
+ readonly anchor: ManagedDevice;
+ readonly busy: boolean;
+ readonly onEdit: () => void;
+ readonly onSetInitiator: () => void;
+}) {
+ const theme = useEight2FiveTheme();
+ const initiator = anchorInitiatorLabel(anchor);
+ return (
+
+
+
+
+
+
+ {getDeveloperAnchorDisplayName(anchor)}
+
+
+ Initiator: {initiator}
+
+
+ {anchor.transportDeviceId}
+
+
+
+
+
+ {initiator === "Yes" ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+function DiscoveryAnchorRow({
+ row,
+ busy,
+ onPress,
+}: {
+ readonly row: NetworkAnchorDiscoveryRow;
+ readonly busy: boolean;
+ readonly onPress: () => void;
+}) {
+ const theme = useEight2FiveTheme();
+ const cached = row.cachedAnchor;
+ const title = cached
+ ? getDeveloperAnchorDisplayName(cached)
+ : (row.discovery.name ?? row.discovery.transportDeviceId);
+ const action = cached ? "Assign to this network" : "Add as anchor";
+ return (
+
+
+
+
+
+ {title}
+
+
+ {row.advertisedRole === "unknown"
+ ? "Unknown role"
+ : `Advertised ${row.advertisedRole}`}{" "}
+ · {row.discovery.rssi} dBm
+
+
+ {cached
+ ? `Cached · Initiator: ${anchorInitiatorLabel(cached)}`
+ : "Not cached"}
+
+
+
+
+
+ );
+}
+
+function toError(value: unknown): Error {
+ return value instanceof Error ? value : new Error(String(value));
+}
diff --git a/apps/mobile/src/features/settings/network-form.ts b/apps/mobile/src/features/settings/network-form.ts
new file mode 100644
index 00000000..bf5e7889
--- /dev/null
+++ b/apps/mobile/src/features/settings/network-form.ts
@@ -0,0 +1,86 @@
+import {
+ assertNetworkProfilePanId,
+ assertUniqueName,
+ parsePanId,
+ type ManagedNetwork,
+} from "@eight2five/mobile/pans-manager";
+
+export interface NetworkDraft {
+ readonly name: string;
+ readonly panId: string;
+}
+
+export interface NetworkDraftErrors {
+ readonly name?: string;
+ readonly panId?: string;
+}
+
+export interface ValidatedNetworkDraft {
+ readonly name: string;
+ readonly panId: number;
+}
+
+export interface NetworkDraftValidation {
+ readonly errors: NetworkDraftErrors;
+ readonly value?: ValidatedNetworkDraft;
+}
+
+/**
+ * Validates the small app-local network profile form before it reaches the
+ * store. The store remains the authority for persistence and validates again;
+ * this keeps incomplete forms from starting an asynchronous operation.
+ */
+export function validateNetworkDraft(
+ draft: NetworkDraft,
+ networks: readonly ManagedNetwork[] = [],
+ currentNetworkId?: string,
+): NetworkDraftValidation {
+ const errors: { name?: string; panId?: string } = {};
+ const name = draft.name.trim();
+
+ if (!name) {
+ errors.name = "Enter a network name.";
+ } else {
+ const currentName = networks.find(
+ (network) => network.id === currentNetworkId,
+ )?.name;
+ try {
+ assertUniqueName(
+ name,
+ networks.map((network) => network.name),
+ currentName,
+ );
+ } catch (cause) {
+ errors.name = toMessage(cause, "That network name is already in use.");
+ }
+ }
+
+ let panId: number | undefined;
+ try {
+ panId = parsePanId(draft.panId);
+ assertNetworkProfilePanId(panId);
+ } catch (cause) {
+ errors.panId = toMessage(cause, "Enter a PAN ID from 1 to 65535.");
+ }
+
+ if (
+ panId !== undefined &&
+ networks.some(
+ (network) => network.id !== currentNetworkId && network.panId === panId,
+ )
+ ) {
+ errors.panId = "A network with this PAN ID already exists.";
+ }
+
+ return Object.keys(errors).length > 0
+ ? { errors }
+ : { errors, value: { name, panId: panId! } };
+}
+
+export function networkDraftFromNetwork(network: ManagedNetwork): NetworkDraft {
+ return { name: network.name, panId: String(network.panId) };
+}
+
+function toMessage(cause: unknown, fallback: string): string {
+ return cause instanceof Error && cause.message ? cause.message : fallback;
+}
diff --git a/apps/mobile/src/features/settings/network-profile-form.tsx b/apps/mobile/src/features/settings/network-profile-form.tsx
new file mode 100644
index 00000000..f530c000
--- /dev/null
+++ b/apps/mobile/src/features/settings/network-profile-form.tsx
@@ -0,0 +1,105 @@
+import React from "react";
+import {
+ FormControl,
+ FormControlError,
+ FormControlErrorText,
+ FormControlHelper,
+ FormControlHelperText,
+ FormControlLabel,
+ FormControlLabelText,
+} from "@eight2five/ui/components/form-control";
+import { Input, InputField } from "@eight2five/ui/components/input";
+import {
+ Button,
+ ButtonIcon,
+ ButtonText,
+} from "@eight2five/ui/components/button";
+import { Save } from "lucide-react-native";
+import { VStack } from "@eight2five/ui/components/vstack";
+import { eight2FiveSpacing } from "@eight2five/ui/theme";
+
+import { SpinningLoaderIcon } from "../../components/spinning-loader-icon";
+import type { NetworkDraft, NetworkDraftErrors } from "./network-form";
+
+export function NetworkProfileForm({
+ draft,
+ errors,
+ saving,
+ submitLabel,
+ onChange,
+ onSubmit,
+}: {
+ readonly draft: NetworkDraft;
+ readonly errors: NetworkDraftErrors;
+ readonly saving: boolean;
+ readonly submitLabel: string;
+ readonly onChange: (draft: NetworkDraft) => void;
+ readonly onSubmit: () => void;
+}) {
+ return (
+
+
+
+ Network name
+
+
+ onChange({ ...draft, name })}
+ />
+
+
+
+ A local name for this PANS network profile.
+
+
+ {errors.name ? (
+
+ {errors.name}
+
+ ) : null}
+
+
+
+
+ PAN ID
+
+
+ onChange({ ...draft, panId })}
+ />
+
+
+
+ Enter decimal or hexadecimal (for example, 0x1234). PAN 0 is
+ reserved for unassigned devices.
+
+
+ {errors.panId ? (
+
+ {errors.panId}
+
+ ) : null}
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/settings/network-ui.ts b/apps/mobile/src/features/settings/network-ui.ts
new file mode 100644
index 00000000..f1841d01
--- /dev/null
+++ b/apps/mobile/src/features/settings/network-ui.ts
@@ -0,0 +1,98 @@
+import type {
+ DiscoveredDeviceSnapshot,
+ ManagedDevice,
+} from "@eight2five/mobile/pans-manager";
+
+export type AdvertisedAnchorRole = "anchor" | "tag" | "unknown";
+
+export interface NetworkAnchorDiscoveryRow {
+ readonly discovery: DiscoveredDeviceSnapshot;
+ readonly cachedAnchor?: ManagedDevice;
+ readonly advertisedRole: AdvertisedAnchorRole;
+ readonly requiresRoleChangeConfirmation: boolean;
+}
+
+export function isManagedAnchor(device: ManagedDevice): boolean {
+ return device.role === "anchor" || device.lastKnownConfig?.role === "anchor";
+}
+
+export function selectAssociatedCachedAnchors(
+ networkId: string,
+ knownAnchors: readonly ManagedDevice[],
+): readonly ManagedDevice[] {
+ return knownAnchors.filter(
+ (anchor) => anchor.networkId === networkId && isManagedAnchor(anchor),
+ );
+}
+
+/**
+ * Discovery is intentionally the first source for this list. A cached device
+ * is attached to a discovery row when the transport identity matches, but a
+ * compatible advertisement with no cache entry is retained as well.
+ */
+export function selectNetworkAnchorDiscoveries(
+ discoveries: readonly DiscoveredDeviceSnapshot[],
+ knownAnchors: readonly ManagedDevice[],
+ cutoff: number,
+): readonly NetworkAnchorDiscoveryRow[] {
+ return discoveries
+ .filter(
+ (discovery) =>
+ !discovery.stale &&
+ discovery.compatibility === "compatible" &&
+ discovery.rssi >= cutoff,
+ )
+ .sort((left, right) => {
+ const rssiDifference = right.rssi - left.rssi;
+ return rssiDifference !== 0
+ ? rssiDifference
+ : left.transportDeviceId.localeCompare(right.transportDeviceId);
+ })
+ .map((discovery) => {
+ const cachedAnchor = knownAnchors.find(
+ (anchor) =>
+ normalizeTransportKey(anchor.transportDeviceId) ===
+ normalizeTransportKey(discovery.transportDeviceId) &&
+ isManagedAnchor(anchor),
+ );
+ const advertisedRole = advertisedRoleForDiscovery(discovery);
+ return {
+ discovery,
+ ...(cachedAnchor ? { cachedAnchor } : {}),
+ advertisedRole,
+ requiresRoleChangeConfirmation: advertisedRole !== "anchor",
+ };
+ });
+}
+
+export function advertisedRoleForDiscovery(
+ discovery: DiscoveredDeviceSnapshot,
+): AdvertisedAnchorRole {
+ const role = discovery.presence?.role;
+ return role === "anchor" || role === "tag" ? role : "unknown";
+}
+
+export function anchorInitiatorLabel(
+ anchor: ManagedDevice | undefined,
+): "Yes" | "No" | "Unknown" {
+ const config =
+ anchor?.lastKnownConfig?.role === "anchor"
+ ? anchor.lastKnownConfig
+ : undefined;
+ return config ? (config.initiatorEnabled ? "Yes" : "No") : "Unknown";
+}
+
+/** Preserve the store's wording; an absent warning must not imply verification. */
+export function commissioningWarningText(
+ warning: string | undefined,
+): string | undefined {
+ return warning?.trim() || undefined;
+}
+
+function normalizeTransportKey(deviceId: string): string {
+ const trimmed = deviceId.trim();
+ const compact = trimmed.replace(/[:-]/g, "");
+ return /^[0-9a-f]+$/i.test(compact)
+ ? compact.toUpperCase()
+ : trimmed.toLocaleLowerCase();
+}
diff --git a/apps/mobile/src/features/settings/networks-screen.tsx b/apps/mobile/src/features/settings/networks-screen.tsx
new file mode 100644
index 00000000..dd065626
--- /dev/null
+++ b/apps/mobile/src/features/settings/networks-screen.tsx
@@ -0,0 +1,265 @@
+import React from "react";
+import { useRouter } from "expo-router";
+import {
+ Check,
+ ChevronRight,
+ Network,
+ Plus,
+ TriangleAlert,
+} from "lucide-react-native";
+import {
+ formatPanId,
+ type ManagedNetwork,
+} from "@eight2five/mobile/pans-manager";
+import {
+ Button,
+ ButtonIcon,
+ ButtonText,
+} from "@eight2five/ui/components/button";
+import { HStack } from "@eight2five/ui/components/hstack";
+import { Icon } from "@eight2five/ui/components/icon";
+import { Pressable } from "@eight2five/ui/components/pressable";
+import { Text } from "@eight2five/ui/components/text";
+import { VStack } from "@eight2five/ui/components/vstack";
+import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme";
+
+import {
+ useMobilePansSnapshot,
+ useMobilePansStore,
+} from "../../pans/mobile-pans-context";
+import { useAppSettingsSnapshot } from "../../state/app-settings-store";
+import { NetworkProfileForm } from "./network-profile-form";
+import { validateNetworkDraft, type NetworkDraft } from "./network-form";
+import { commissioningWarningText } from "./network-ui";
+import {
+ SettingsMessage,
+ SettingsScreenContainer,
+ SettingsSection,
+ SettingsValueRow,
+} from "./settings-components";
+
+const EMPTY_DRAFT: NetworkDraft = { name: "", panId: "" };
+
+export function NetworksScreen() {
+ const router = useRouter();
+ const theme = useEight2FiveTheme();
+ const store = useMobilePansStore();
+ const snapshot = useMobilePansSnapshot();
+ const { settings } = useAppSettingsSnapshot();
+ const [creating, setCreating] = React.useState(false);
+ const [draft, setDraft] = React.useState(EMPTY_DRAFT);
+ const [busy, setBusy] = React.useState(false);
+ const [error, setError] = React.useState();
+ const validation = React.useMemo(
+ () => validateNetworkDraft(draft, snapshot.networks),
+ [draft, snapshot.networks],
+ );
+ const activeNetwork = snapshot.networks.find(
+ (network) => network.id === snapshot.activeNetworkId,
+ );
+
+ if (!snapshot.initialization || snapshot.initialization === "loading") {
+ return (
+
+ Preparing PANS networks…
+
+ );
+ }
+
+ if (!settings.developerModeEnabled) {
+ return (
+
+
+ Enable Developer Mode before managing PANS networks.
+
+
+ );
+ }
+
+ const run = async (action: () => Promise) => {
+ if (busy) return;
+ setBusy(true);
+ setError(undefined);
+ try {
+ await action();
+ } catch (cause) {
+ setError(toError(cause));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const save = () => {
+ if (!validation.value) return;
+ void run(async () => {
+ const network = await store.createNetwork(
+ validation.value!.name,
+ validation.value!.panId,
+ );
+ setDraft(EMPTY_DRAFT);
+ setCreating(false);
+ openNetwork(router, network);
+ });
+ };
+
+ return (
+
+ {error || snapshot.error ? (
+
+ {(error ?? snapshot.error)?.message}
+
+ ) : null}
+ {commissioningWarningText(snapshot.commissioningWarning) ? (
+
+ {commissioningWarningText(snapshot.commissioningWarning)}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ {creating ? (
+
+
+
+ ) : null}
+
+ {snapshot.networks.length === 0 ? (
+
+
+
+ No network profiles are saved on this device.
+
+
+ ) : (
+ snapshot.networks.map((network) => (
+ openNetwork(router, network)}
+ onSetActive={() =>
+ void run(async () => {
+ await store.setActiveNetwork(network.id);
+ })
+ }
+ />
+ ))
+ )}
+
+
+
+ );
+}
+
+function NetworkRow({
+ network,
+ active,
+ disabled,
+ onOpen,
+ onSetActive,
+}: {
+ readonly network: ManagedNetwork;
+ readonly active: boolean;
+ readonly disabled: boolean;
+ readonly onOpen: () => void;
+ readonly onSetActive: () => void;
+}) {
+ const theme = useEight2FiveTheme();
+ return (
+
+
+
+
+
+
+ {network.name}
+
+
+ PAN {formatPanId(network.panId)}
+
+
+ {active ? (
+
+
+
+ Active
+
+
+ ) : null}
+
+
+
+ {!active ? (
+
+ ) : null}
+
+ );
+}
+
+function openNetwork(
+ router: ReturnType,
+ network: ManagedNetwork,
+) {
+ router.push({
+ pathname: "/(tabs)/settings/network/[networkId]" as never,
+ params: { networkId: network.id },
+ });
+}
+
+function toError(value: unknown): Error {
+ return value instanceof Error ? value : new Error(String(value));
+}
diff --git a/apps/mobile/src/features/settings/reset-settings-control.tsx b/apps/mobile/src/features/settings/reset-settings-control.tsx
new file mode 100644
index 00000000..7254ba4c
--- /dev/null
+++ b/apps/mobile/src/features/settings/reset-settings-control.tsx
@@ -0,0 +1,55 @@
+import React from "react";
+import { Alert } from "react-native";
+import { RotateCcw } from "lucide-react-native";
+import { Pressable } from "@eight2five/ui/components/pressable";
+
+import { useTabBarVisibility } from "../../navigation/tab-bar-visibility-context";
+import { useAppSettingsStore } from "../../state/app-settings-store";
+import { RESET_SETTINGS_MESSAGE, resetAppSettings } from "./settings-actions";
+import { SettingsRowContent } from "./settings-components";
+
+export function ResetSettingsControl({
+ disabled,
+ onError,
+}: {
+ disabled?: boolean;
+ onError(error: Error): void;
+}) {
+ const store = useAppSettingsStore();
+ const { reconfigureDrillFeatures } = useTabBarVisibility();
+ const [resetting, setResetting] = React.useState(false);
+
+ const reset = async () => {
+ setResetting(true);
+ try {
+ await resetAppSettings(store, reconfigureDrillFeatures);
+ } catch (cause) {
+ onError(cause instanceof Error ? cause : new Error(String(cause)));
+ } finally {
+ setResetting(false);
+ }
+ };
+
+ const confirmReset = () => {
+ Alert.alert("Reset App Settings?", RESET_SETTINGS_MESSAGE, [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Reset",
+ style: "destructive",
+ onPress: () => void reset(),
+ },
+ ]);
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/settings/settings-actions.ts b/apps/mobile/src/features/settings/settings-actions.ts
new file mode 100644
index 00000000..15e10ab8
--- /dev/null
+++ b/apps/mobile/src/features/settings/settings-actions.ts
@@ -0,0 +1,34 @@
+import type {
+ AppSettings,
+ AppSettingsUpdate,
+} from "@eight2five/mobile/settings";
+
+export interface SettingsWriter {
+ update(partial: AppSettingsUpdate): Promise;
+ resetPreferences(): Promise;
+}
+
+export const RESET_SETTINGS_MESSAGE =
+ "This restores display, drill-feature, terminology, and developer preferences to their defaults.\n\n" +
+ "It does not delete drills, forget the remembered tag, delete cached anchor positions, or modify PANS hardware.";
+
+/** Persistence completes before the native-tab layout is reconfigured. */
+export async function updateDrillFeatures(
+ writer: SettingsWriter,
+ reconfigureTabs: (enabled: boolean) => void,
+ enabled: boolean,
+): Promise {
+ const settings = await writer.update({ drillFeaturesEnabled: enabled });
+ reconfigureTabs(settings.drillFeaturesEnabled);
+ return settings;
+}
+
+/** Reset affects preferences only, then reconciles Drill tab membership once. */
+export async function resetAppSettings(
+ writer: SettingsWriter,
+ reconfigureTabs: (enabled: boolean) => void,
+): Promise {
+ const settings = await writer.resetPreferences();
+ reconfigureTabs(settings.drillFeaturesEnabled);
+ return settings;
+}
diff --git a/apps/mobile/src/features/settings/settings-components.tsx b/apps/mobile/src/features/settings/settings-components.tsx
new file mode 100644
index 00000000..663baf89
--- /dev/null
+++ b/apps/mobile/src/features/settings/settings-components.tsx
@@ -0,0 +1,341 @@
+import React from "react";
+import {
+ ChevronDown,
+ ChevronRight,
+ type LucideIcon,
+} from "lucide-react-native";
+import { Card } from "@eight2five/ui/components/card";
+import { Heading } from "@eight2five/ui/components/heading";
+import { HStack } from "@eight2five/ui/components/hstack";
+import { Icon } from "@eight2five/ui/components/icon";
+import { Pressable } from "@eight2five/ui/components/pressable";
+import { ScrollView } from "@eight2five/ui/components/scroll-view";
+import {
+ Select,
+ SelectBackdrop,
+ SelectContent,
+ SelectDragIndicator,
+ SelectDragIndicatorWrapper,
+ SelectIcon,
+ SelectInput,
+ SelectItem,
+ SelectPortal,
+ SelectTrigger,
+} from "@eight2five/ui/components/select";
+import { Switch } from "@eight2five/ui/components/switch";
+import { Text } from "@eight2five/ui/components/text";
+import { VStack } from "@eight2five/ui/components/vstack";
+import {
+ eight2FiveFonts,
+ eight2FiveRadii,
+ eight2FiveSpacing,
+ useEight2FiveTheme,
+} from "@eight2five/ui/theme";
+
+export function SettingsScreenContainer({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const theme = useEight2FiveTheme();
+ return (
+
+ {children}
+
+ );
+}
+
+export function SettingsSection({
+ title,
+ children,
+}: {
+ title: string;
+ children: React.ReactNode;
+}) {
+ const theme = useEight2FiveTheme();
+ return (
+
+
+ {title}
+
+
+ {children}
+
+
+ );
+}
+
+interface SettingsRowContentProps {
+ icon: LucideIcon;
+ title: string;
+ description?: string;
+ accessory?: React.ReactNode;
+ danger?: boolean;
+}
+
+function SettingsRowContent({
+ icon,
+ title,
+ description,
+ accessory,
+ danger = false,
+}: SettingsRowContentProps) {
+ const theme = useEight2FiveTheme();
+ const color = danger ? theme.danger : theme.text;
+ return (
+
+
+
+
+ {title}
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+ {accessory}
+
+ );
+}
+
+export function SettingsNavigationRow({
+ icon,
+ title,
+ description,
+ onPress,
+ testID,
+}: SettingsRowContentProps & { onPress(): void; testID?: string }) {
+ const theme = useEight2FiveTheme();
+ return (
+
+
+ }
+ />
+
+ );
+}
+
+export function SettingsValueRow({
+ icon,
+ title,
+ description,
+ value,
+}: SettingsRowContentProps & { value: string }) {
+ const theme = useEight2FiveTheme();
+ return (
+
+ {value}
+
+ }
+ />
+ );
+}
+
+export function SettingsSwitchRow({
+ icon,
+ title,
+ description,
+ value,
+ onChange,
+ disabled,
+ testID,
+}: SettingsRowContentProps & {
+ value: boolean;
+ onChange(value: boolean): void;
+ disabled?: boolean;
+ testID?: string;
+}) {
+ const theme = useEight2FiveTheme();
+ return (
+
+ }
+ />
+ );
+}
+
+export interface SettingsSelectChoice {
+ readonly label: string;
+ readonly value: T;
+}
+
+export function SettingsSelectRow({
+ icon,
+ title,
+ description,
+ value,
+ choices,
+ onChange,
+ disabled,
+ testID,
+}: SettingsRowContentProps & {
+ value: T;
+ choices: readonly SettingsSelectChoice[];
+ onChange(value: T): void;
+ disabled?: boolean;
+ testID?: string;
+}) {
+ const theme = useEight2FiveTheme();
+ const selectedLabel =
+ choices.find((choice) => choice.value === value)?.label ?? value;
+ return (
+
+
+
+
+
+
+ );
+}
+
+export function SettingsMessage({
+ tone,
+ children,
+}: {
+ tone: "info" | "error" | "warning";
+ children: React.ReactNode;
+}) {
+ const theme = useEight2FiveTheme();
+ const color =
+ tone === "error"
+ ? theme.danger
+ : tone === "warning"
+ ? theme.warning
+ : theme.text;
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export { SettingsRowContent };
diff --git a/apps/mobile/src/features/settings/settings-screen-policy.ts b/apps/mobile/src/features/settings/settings-screen-policy.ts
new file mode 100644
index 00000000..a4f91914
--- /dev/null
+++ b/apps/mobile/src/features/settings/settings-screen-policy.ts
@@ -0,0 +1,3 @@
+export function shouldShowTransitionCountControls(showAll: boolean): boolean {
+ return !showAll;
+}
diff --git a/apps/mobile/src/features/settings/settings-screen.tsx b/apps/mobile/src/features/settings/settings-screen.tsx
new file mode 100644
index 00000000..b5b94128
--- /dev/null
+++ b/apps/mobile/src/features/settings/settings-screen.tsx
@@ -0,0 +1,412 @@
+import React from "react";
+import { useRouter } from "expo-router";
+import {
+ Activity,
+ BookOpenText,
+ Code2,
+ Eye,
+ ListChecks,
+ Map,
+ Navigation,
+ Palette,
+ Radio,
+ Route,
+ Rows3,
+ RulerDimensionLine,
+} from "lucide-react-native";
+import {
+ COORDINATE_ROUNDING_PRESETS,
+ type AppearanceMode,
+ type AppSettingsUpdate,
+ type CoordinateRoundingSteps,
+ type FieldPerspective,
+} from "@eight2five/mobile/settings";
+import type { DrillTerminology } from "@eight2five/mobile/drill";
+import {
+ FIELD_PRESET_IDS,
+ getFieldPreset,
+ type FieldPresetId,
+} from "@eight2five/drill-schema";
+
+import { useTabBarVisibility } from "../../navigation/tab-bar-visibility-context";
+import { useMobilePansSnapshot } from "../../pans/mobile-pans-context";
+import {
+ useAppSettingsSnapshot,
+ useAppSettingsStore,
+} from "../../state/app-settings-store";
+import { ResetSettingsControl } from "./reset-settings-control";
+import { ConnectionStatusRow } from "./connection-status-row";
+import { updateDrillFeatures } from "./settings-actions";
+import { shouldShowTransitionCountControls } from "./settings-screen-policy";
+import {
+ SettingsMessage,
+ SettingsNavigationRow,
+ SettingsScreenContainer,
+ SettingsSection,
+ SettingsSelectRow,
+ SettingsSwitchRow,
+} from "./settings-components";
+
+const PERSPECTIVE_CHOICES = [
+ { label: "Director", value: "director" },
+ { label: "Performer", value: "performer" },
+] as const;
+
+const APPEARANCE_CHOICES = [
+ { label: "System", value: "system" },
+ { label: "Light", value: "light" },
+ { label: "Dark", value: "dark" },
+] as const;
+
+const TERMINOLOGY_CHOICES = [
+ { label: "Sets", value: "sets" },
+ { label: "Pages", value: "pages" },
+] as const;
+
+const FIELD_PRESET_CHOICES = FIELD_PRESET_IDS.map((value) => ({
+ label: getFieldPreset(value).name,
+ value,
+})) satisfies readonly { label: string; value: FieldPresetId }[];
+
+const COORDINATE_ROUNDING_CHOICES = COORDINATE_ROUNDING_PRESETS.map(
+ (value) => ({
+ label:
+ value === 0.125
+ ? "⅛ step"
+ : value === 0.25
+ ? "¼ step"
+ : value === 0.5
+ ? "½ step"
+ : "1 step",
+ value: String(value),
+ }),
+);
+
+const TRANSITION_COUNT_CHOICES = Array.from({ length: 6 }, (_, count) => ({
+ label: String(count),
+ value: String(count),
+}));
+
+const DISTANCE_THRESHOLD_VALUES = [0, 0.25, 0.5, 0.75, 1, 1.5, 2, 3];
+
+function distanceThresholdChoices(values: readonly number[]) {
+ return values.map((value) => ({
+ label: value === 1 ? "one step" : `${value} steps`,
+ value: String(value),
+ }));
+}
+
+export function SettingsScreen() {
+ const router = useRouter();
+ const store = useAppSettingsStore();
+ const { status, settings, error: loadError } = useAppSettingsSnapshot();
+ const { reconfigureDrillFeatures } = useTabBarVisibility();
+ const pans = useMobilePansSnapshot();
+ const [operationError, setOperationError] = React.useState();
+ const disabled = status !== "ready";
+
+ const update = async (partial: AppSettingsUpdate) => {
+ setOperationError(undefined);
+ try {
+ await store.update(partial);
+ } catch (cause) {
+ setOperationError(toError(cause));
+ }
+ };
+
+ const setDrillFeatures = async (enabled: boolean) => {
+ setOperationError(undefined);
+ try {
+ await updateDrillFeatures(store, reconfigureDrillFeatures, enabled);
+ } catch (cause) {
+ setOperationError(toError(cause));
+ }
+ };
+
+ return (
+
+ {status === "loading" ? (
+ Loading app settings…
+ ) : null}
+ {loadError || operationError ? (
+
+ {(operationError ?? loadError)?.message}
+
+ ) : null}
+
+
+ router.push("/(tabs)/settings/tag")}
+ testID="tag-connection-link"
+ />
+
+
+
+
+
+ icon={Palette}
+ title="App appearance"
+ description="Follow the system appearance or always use a light or dark theme."
+ value={settings.appearanceMode}
+ choices={APPEARANCE_CHOICES}
+ onChange={(appearanceMode) => void update({ appearanceMode })}
+ disabled={disabled}
+ testID="appearance-mode-setting"
+ />
+
+
+
+ void setDrillFeatures(enabled)}
+ disabled={disabled}
+ testID="drill-features-setting"
+ />
+
+ icon={BookOpenText}
+ title="Drill terminology"
+ description="Choose the name used for drill positions."
+ value={settings.drillTerminology}
+ choices={TERMINOLOGY_CHOICES}
+ onChange={(drillTerminology) => void update({ drillTerminology })}
+ disabled={disabled}
+ testID="drill-terminology-setting"
+ />
+
+
+
+
+ icon={Map}
+ title="Default marching field"
+ description="Used when no drill is loaded and for new manual drills. A loaded drill overrides this default."
+ value={settings.defaultFieldPreset}
+ choices={FIELD_PRESET_CHOICES}
+ onChange={(defaultFieldPreset) => void update({ defaultFieldPreset })}
+ disabled={disabled}
+ testID="default-field-preset-setting"
+ />
+
+ icon={Eye}
+ title="Field perspective"
+ description="Choose how the field is oriented."
+ value={settings.fieldPerspective}
+ choices={PERSPECTIVE_CHOICES}
+ onChange={(fieldPerspective) => void update({ fieldPerspective })}
+ disabled={disabled}
+ testID="field-perspective-setting"
+ />
+
+ icon={RulerDimensionLine}
+ title="Coordinate rounding"
+ description="Round displayed marching coordinates to this step increment."
+ value={String(settings.coordinateRoundingSteps)}
+ choices={COORDINATE_ROUNDING_CHOICES}
+ onChange={(value) =>
+ void update({
+ coordinateRoundingSteps: Number(value) as CoordinateRoundingSteps,
+ })
+ }
+ disabled={disabled}
+ testID="coordinate-rounding-setting"
+ />
+
+ void update({ showAuxiliaryFieldMarks })
+ }
+ disabled={disabled}
+ testID="auxiliary-field-marks-setting"
+ />
+
+ void update({ motionInterpolationEnabled })
+ }
+ disabled={disabled}
+ testID="motion-interpolation-setting"
+ />
+
+
+
+
+ icon={RulerDimensionLine}
+ title="Green distance threshold"
+ description="Show target distance as green at or below this value."
+ value={String(settings.distanceGreenThresholdSteps)}
+ choices={distanceThresholdChoices(
+ Array.from(
+ new Set([
+ ...DISTANCE_THRESHOLD_VALUES.filter(
+ (value) => value <= settings.distanceYellowThresholdSteps,
+ ),
+ settings.distanceGreenThresholdSteps,
+ ]),
+ ).sort((left, right) => left - right),
+ )}
+ onChange={(value) =>
+ void update({ distanceGreenThresholdSteps: Number(value) })
+ }
+ disabled={disabled}
+ testID="distance-green-threshold-setting"
+ />
+
+ icon={RulerDimensionLine}
+ title="Yellow distance threshold"
+ description="Show target distance as yellow through this value, then red."
+ value={String(settings.distanceYellowThresholdSteps)}
+ choices={distanceThresholdChoices(
+ Array.from(
+ new Set([
+ ...DISTANCE_THRESHOLD_VALUES.filter(
+ (value) => value >= settings.distanceGreenThresholdSteps,
+ ),
+ settings.distanceYellowThresholdSteps,
+ ]),
+ ).sort((left, right) => left - right),
+ )}
+ onChange={(value) =>
+ void update({ distanceYellowThresholdSteps: Number(value) })
+ }
+ disabled={disabled}
+ testID="distance-yellow-threshold-setting"
+ />
+
+ void update({ showTransitionMarkers })
+ }
+ disabled={disabled}
+ testID="show-transition-markers-setting"
+ />
+
+ void update({ showAllTransitionSets })
+ }
+ disabled={disabled}
+ testID="show-all-transition-sets-setting"
+ />
+ {shouldShowTransitionCountControls(settings.showAllTransitionSets) ? (
+ <>
+
+ icon={Route}
+ title="Previous transition positions"
+ description="Total positions, including the immediate previous marker."
+ value={String(settings.previousTransitionSetCount)}
+ choices={TRANSITION_COUNT_CHOICES}
+ onChange={(value) =>
+ void update({ previousTransitionSetCount: Number(value) })
+ }
+ disabled={disabled}
+ testID="previous-transition-set-count-setting"
+ />
+
+ icon={Route}
+ title="Next transition positions"
+ description="Total positions, including the immediate next marker."
+ value={String(settings.nextTransitionSetCount)}
+ choices={TRANSITION_COUNT_CHOICES}
+ onChange={(value) =>
+ void update({ nextTransitionSetCount: Number(value) })
+ }
+ disabled={disabled}
+ testID="next-transition-set-count-setting"
+ />
+ >
+ ) : null}
+
+
+
+
+ void update({ showPerformerLabels })
+ }
+ disabled={disabled}
+ testID="show-performer-labels-setting"
+ />
+ void update({ showPerformerNames })}
+ disabled={disabled}
+ testID="show-performer-names-setting"
+ />
+ void update({ showPropLabels })}
+ disabled={disabled}
+ testID="show-prop-labels-setting"
+ />
+ void update({ showPropNames })}
+ disabled={disabled}
+ testID="show-prop-names-setting"
+ />
+
+
+
+ void update({ guidanceEnabled })}
+ disabled={disabled}
+ testID="guidance-enabled-setting"
+ />
+
+
+
+ router.push("/(tabs)/settings/developer")}
+ testID="developer-settings-link"
+ />
+
+
+
+
+
+
+ );
+}
+
+function toError(value: unknown): Error {
+ return value instanceof Error ? value : new Error(String(value));
+}
diff --git a/apps/mobile/src/features/settings/standard-anchor-position-form.tsx b/apps/mobile/src/features/settings/standard-anchor-position-form.tsx
new file mode 100644
index 00000000..4aff0089
--- /dev/null
+++ b/apps/mobile/src/features/settings/standard-anchor-position-form.tsx
@@ -0,0 +1,209 @@
+import { ChevronDown } from "lucide-react-native";
+import type {
+ AnchorPositionReference,
+ AnchorPositionUnit,
+ StandardAnchorPositionDraft,
+} from "@eight2five/mobile/field";
+import {
+ FormControl,
+ FormControlError,
+ FormControlErrorText,
+ FormControlHelper,
+ FormControlHelperText,
+ FormControlLabel,
+ FormControlLabelText,
+} from "@eight2five/ui/components/form-control";
+import { Input, InputField } from "@eight2five/ui/components/input";
+import {
+ Select,
+ SelectBackdrop,
+ SelectContent,
+ SelectDragIndicator,
+ SelectDragIndicatorWrapper,
+ SelectIcon,
+ SelectInput,
+ SelectItem,
+ SelectPortal,
+ SelectTrigger,
+} from "@eight2five/ui/components/select";
+import { Text } from "@eight2five/ui/components/text";
+import { VStack } from "@eight2five/ui/components/vstack";
+import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme";
+
+import {
+ ANCHOR_REFERENCE_CHOICES,
+ ANCHOR_UNIT_CHOICES,
+} from "./anchor-editor-form";
+
+export function StandardAnchorPositionForm({
+ draft,
+ errors,
+ disabled,
+ onChange,
+ onReferenceChange,
+ onUnitChange,
+}: {
+ readonly draft: StandardAnchorPositionDraft;
+ readonly errors: Readonly>;
+ readonly disabled: boolean;
+ readonly onChange: (draft: StandardAnchorPositionDraft) => void;
+ readonly onReferenceChange: (reference: AnchorPositionReference) => void;
+ readonly onUnitChange: (unit: AnchorPositionUnit) => void;
+}) {
+ const theme = useEight2FiveTheme();
+ return (
+
+
+
+
+ onChange({ ...draft, sideToSideOffset })
+ }
+ />
+
+ Negative side-to-side: toward Side 1{"\n"}Positive side-to-side: toward
+ Side 2
+
+
+ onChange({ ...draft, frontToBackOffset })
+ }
+ />
+
+ Negative front-to-back: toward front sideline{"\n"}Positive
+ front-to-back: toward back sideline
+
+ onChange({ ...draft, height })}
+ />
+ {errors.position ? (
+
+ {errors.position}
+
+ ) : null}
+
+ );
+}
+
+export function AnchorNumberInput({
+ label,
+ value,
+ error,
+ helper,
+ disabled,
+ onChange,
+}: {
+ readonly label: string;
+ readonly value: string;
+ readonly error?: string;
+ readonly helper?: string;
+ readonly disabled: boolean;
+ readonly onChange: (value: string) => void;
+}) {
+ return (
+
+
+ {label}
+
+
+
+
+ {helper ? (
+
+ {helper}
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+}
+
+function AnchorSelect({
+ label,
+ value,
+ choices,
+ disabled,
+ onChange,
+}: {
+ readonly label: string;
+ readonly value: Value;
+ readonly choices: readonly {
+ readonly label: string;
+ readonly value: Value;
+ }[];
+ readonly disabled: boolean;
+ readonly onChange: (value: Value) => void;
+}) {
+ const theme = useEight2FiveTheme();
+ return (
+
+
+ {label}
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/settings/tag-connection-lifecycle.ts b/apps/mobile/src/features/settings/tag-connection-lifecycle.ts
new file mode 100644
index 00000000..6bfb1393
--- /dev/null
+++ b/apps/mobile/src/features/settings/tag-connection-lifecycle.ts
@@ -0,0 +1,19 @@
+export interface TagDiscoveryPageOwner {
+ startTagDiscovery(): Promise;
+ stopManualDiscovery(): void;
+}
+
+export function ownTagDiscoveryWhileFocused(
+ store: TagDiscoveryPageOwner,
+ servicesReady: boolean,
+ alreadyConnected: boolean,
+ onError: (error: Error) => void,
+): () => void {
+ if (!servicesReady || alreadyConnected) return () => undefined;
+ void store
+ .startTagDiscovery()
+ .catch((cause) =>
+ onError(cause instanceof Error ? cause : new Error(String(cause))),
+ );
+ return () => store.stopManualDiscovery();
+}
diff --git a/apps/mobile/src/features/settings/tag-connection-screen.tsx b/apps/mobile/src/features/settings/tag-connection-screen.tsx
new file mode 100644
index 00000000..9cc46c99
--- /dev/null
+++ b/apps/mobile/src/features/settings/tag-connection-screen.tsx
@@ -0,0 +1,379 @@
+import React from "react";
+import { useFocusEffect, useRouter } from "expo-router";
+import {
+ BluetoothConnected,
+ Edit3,
+ Network,
+ Signal,
+ SignalHigh,
+ SignalLow,
+ SignalMedium,
+ Trash2,
+ TriangleAlert,
+} from "lucide-react-native";
+import {
+ Button,
+ ButtonIcon,
+ ButtonText,
+} from "@eight2five/ui/components/button";
+import { HStack } from "@eight2five/ui/components/hstack";
+import { Icon } from "@eight2five/ui/components/icon";
+import { Input, InputField } from "@eight2five/ui/components/input";
+import { Pressable } from "@eight2five/ui/components/pressable";
+import { ScrollView } from "@eight2five/ui/components/scroll-view";
+import { Text } from "@eight2five/ui/components/text";
+import { VStack } from "@eight2five/ui/components/vstack";
+import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme";
+
+import { useAppSettingsSnapshot } from "../../state/app-settings-store";
+import {
+ useMobilePansSnapshot,
+ useMobilePansStore,
+} from "../../pans/mobile-pans-context";
+import {
+ selectVisibleDiscoveries,
+ signalStrengthForRssi,
+ type SignalStrength,
+} from "../../pans/mobile-pans-ui";
+import { ConnectionStatusRow } from "./connection-status-row";
+import { ownTagDiscoveryWhileFocused } from "./tag-connection-lifecycle";
+import {
+ SettingsMessage,
+ SettingsNavigationRow,
+ SettingsScreenContainer,
+ SettingsSection,
+ SettingsSelectRow,
+ SettingsValueRow,
+} from "./settings-components";
+
+const SIGNAL_ICONS: Record = {
+ full: Signal,
+ high: SignalHigh,
+ medium: SignalMedium,
+ low: SignalLow,
+};
+
+export function TagConnectionScreen() {
+ return ;
+}
+
+/**
+ * Shared route/modal entry point. Modal content owns discovery for its mounted
+ * lifetime instead of depending on navigation focus, which keeps it usable
+ * when rendered through the field HUD modal portal.
+ */
+export function TagConnectionContent({
+ modal = false,
+}: {
+ readonly modal?: boolean;
+}) {
+ return modal ? (
+
+ ) : (
+
+ );
+}
+
+function FocusedTagConnectionContent() {
+ const router = useRouter();
+ const store = useMobilePansStore();
+ const snapshot = useMobilePansSnapshot();
+ const [lifecycleError, setLifecycleError] = React.useState();
+
+ useFocusEffect(
+ React.useCallback(() => {
+ setLifecycleError(undefined);
+ return ownTagDiscoveryWhileFocused(
+ store,
+ snapshot.initialization === "ready",
+ store.getSnapshot().connectionState === "connected",
+ setLifecycleError,
+ );
+ }, [snapshot.initialization, store]),
+ );
+
+ return (
+ router.push("/(tabs)/settings/networks" as never)}
+ />
+ );
+}
+
+function MountedTagConnectionContent() {
+ const store = useMobilePansStore();
+ const snapshot = useMobilePansSnapshot();
+ const [lifecycleError, setLifecycleError] = React.useState();
+
+ React.useEffect(() => {
+ return ownTagDiscoveryWhileFocused(
+ store,
+ snapshot.initialization === "ready",
+ store.getSnapshot().connectionState === "connected",
+ setLifecycleError,
+ );
+ }, [snapshot.initialization, store]);
+
+ return ;
+}
+
+function TagConnectionBody({
+ modal = false,
+ lifecycleError,
+ onOpenNetworks,
+}: {
+ readonly modal?: boolean;
+ readonly lifecycleError?: Error;
+ readonly onOpenNetworks?: () => void;
+}) {
+ const theme = useEight2FiveTheme();
+ const store = useMobilePansStore();
+ const snapshot = useMobilePansSnapshot();
+ const { settings } = useAppSettingsSnapshot();
+ const developerMode = settings.developerModeEnabled;
+ const [operation, setOperation] = React.useState(false);
+ const [error, setError] = React.useState();
+ const [labelEdit, setLabelEdit] = React.useState<{
+ readonly deviceId?: string;
+ readonly value: string;
+ }>({ value: "" });
+ const candidates = React.useMemo(
+ () =>
+ selectVisibleDiscoveries(snapshot.discoveries, {
+ developerMode,
+ cutoff: snapshot.discoveryRssiCutoff,
+ }),
+ [developerMode, snapshot.discoveries, snapshot.discoveryRssiCutoff],
+ );
+
+ const selectedLabel =
+ snapshot.rememberedTag?.lastKnownConfig?.label ??
+ snapshot.rememberedTag?.label ??
+ "";
+ const labelDraft =
+ labelEdit.deviceId === snapshot.rememberedTag?.id
+ ? labelEdit.value
+ : selectedLabel;
+
+ const run = async (action: () => Promise) => {
+ if (operation) return;
+ setOperation(true);
+ setError(undefined);
+ try {
+ await action();
+ } catch (cause) {
+ setError(toError(cause));
+ } finally {
+ setOperation(false);
+ }
+ };
+
+ const content = (
+ <>
+ {snapshot.error || lifecycleError || error ? (
+
+ {(error ?? lifecycleError ?? snapshot.error)?.message}
+
+ ) : null}
+
+
+
+ {snapshot.rememberedTag ? (
+
+
+
+ {snapshot.rememberedTag.lastKnownConfig?.label ??
+ snapshot.rememberedTag.label ??
+ "Selected tag"}
+
+
+
+ ) : null}
+
+
+
+ {candidates.length === 0 ? (
+
+ {snapshot.connectionState === "scanning"
+ ? "Looking for nearby tags…"
+ : "No nearby tags meet the signal requirement."}
+
+ ) : (
+ candidates.map((device) => {
+ const strength = signalStrengthForRssi(
+ device.rssi,
+ snapshot.discoveryRssiCutoff,
+ );
+ const role = device.presence?.role;
+ return (
+
+ void run(async () => {
+ if (role === "tag") {
+ await store.selectConfigureAndConnectTag(
+ device.transportDeviceId,
+ );
+ } else if (developerMode) {
+ await store.persistDiscoveredAnchor(
+ device.transportDeviceId,
+ );
+ }
+ })
+ }
+ >
+
+ {developerMode ? (
+
+ {device.rssi} dBm
+
+ ) : (
+
+ )}
+
+
+ {device.name ?? "Unnamed tag"}
+
+ {developerMode ? (
+
+ {role ?? "unknown"} · {device.transportDeviceId}
+
+ ) : null}
+
+
+
+ );
+ })
+ )}
+
+
+ {developerMode ? (
+
+
+ icon={Network}
+ title="Active network"
+ description="A selected network is verified on tags during connection."
+ value={snapshot.activeNetworkId ?? "none"}
+ choices={[
+ { label: "None", value: "none" },
+ ...snapshot.networks.map((network) => ({
+ label: network.name,
+ value: network.id,
+ })),
+ ]}
+ onChange={(value) =>
+ void run(() =>
+ store.setActiveNetwork(value === "none" ? undefined : value),
+ )
+ }
+ disabled={operation}
+ testID="active-network-setting"
+ />
+ {!modal && onOpenNetworks ? (
+
+ ) : null}
+ {snapshot.rememberedTag ? (
+
+
+
+ setLabelEdit({
+ deviceId: snapshot.rememberedTag?.id,
+ value,
+ })
+ }
+ />
+
+
+
+
+ ) : null}
+
+ ) : null}
+
+ {snapshot.connectionState === "error" ? (
+
+
+
+ Move closer, verify Bluetooth is available, and try again.
+
+
+ ) : null}
+ >
+ );
+
+ if (!modal)
+ return {content};
+ return (
+
+ {content}
+
+ );
+}
+
+function toError(value: unknown): Error {
+ return value instanceof Error ? value : new Error(String(value));
+}
diff --git a/apps/mobile/src/features/settings/use-anchor-editor-controller.ts b/apps/mobile/src/features/settings/use-anchor-editor-controller.ts
new file mode 100644
index 00000000..12de1a4e
--- /dev/null
+++ b/apps/mobile/src/features/settings/use-anchor-editor-controller.ts
@@ -0,0 +1,248 @@
+import React from "react";
+import { useFocusEffect } from "expo-router";
+import type {
+ AnchorFieldPosition,
+ AnchorPositionUnit,
+ StandardAnchorPositionDraft,
+} from "@eight2five/mobile/field";
+import type { ManagedDevice } from "@eight2five/mobile/pans-manager";
+
+import {
+ useAppSettingsSnapshot,
+ useAppSettingsStore,
+} from "../../state/app-settings-store";
+import {
+ useMobilePansSnapshot,
+ useMobilePansStore,
+} from "../../pans/mobile-pans-context";
+import { resolveEffectiveFieldPreset } from "../field/effective-field-preset";
+import {
+ createAnchorEditorDrafts,
+ convertMarchingHeightUnit,
+ standardDraftFromPosition,
+ validateMarchingAnchorDraft,
+ validateStandardAnchorDraft,
+ type AnchorEditorMode,
+ type MarchingAnchorDraft,
+} from "./anchor-editor-form";
+
+export function useAnchorEditorController(anchorId: string) {
+ const settings = useAppSettingsSnapshot();
+ const settingsStore = useAppSettingsStore();
+ const pans = useMobilePansSnapshot();
+ const pansStore = useMobilePansStore();
+ const [anchor, setAnchor] = React.useState();
+ const [fieldPreset, setFieldPreset] = React.useState(
+ settings.settings.defaultFieldPreset,
+ );
+ const [mode, setModeState] = React.useState("marching");
+ const [marchingDraft, setMarchingDraft] = React.useState(
+ () => createAnchorEditorDrafts().marching,
+ );
+ const [standardDraft, setStandardDraft] = React.useState(
+ () => createAnchorEditorDrafts().standard,
+ );
+ const [anchorName, setAnchorName] = React.useState("");
+ const [loading, setLoading] = React.useState(true);
+ const [saving, setSaving] = React.useState(false);
+ const [savingName, setSavingName] = React.useState(false);
+ const [saved, setSaved] = React.useState(false);
+ const [error, setError] = React.useState();
+
+ const load = React.useCallback(async () => {
+ if (pans.initialization !== "ready" || settings.status !== "ready") return;
+ setLoading(true);
+ setError(undefined);
+ try {
+ const [next, activeDrill] = await Promise.all([
+ pansStore.getRuntime().repository.getDevice(anchorId),
+ settings.settings.activeDrillId
+ ? settingsStore
+ .getDrillRepository()
+ .getDrill(settings.settings.activeDrillId)
+ : Promise.resolve(undefined),
+ ]);
+ if (
+ !next ||
+ (next.role !== "anchor" && next.lastKnownConfig?.role !== "anchor")
+ ) {
+ throw new Error("The cached anchor could not be found.");
+ }
+ const position =
+ next.lastKnownConfig?.role === "anchor"
+ ? next.lastKnownConfig.position
+ : undefined;
+ const nextFieldPreset = resolveEffectiveFieldPreset(
+ activeDrill,
+ settings.settings.defaultFieldPreset,
+ );
+ const drafts = createAnchorEditorDrafts(position, nextFieldPreset);
+ setAnchor(next);
+ setAnchorName(next.lastKnownConfig?.label ?? next.label ?? "");
+ setFieldPreset(nextFieldPreset);
+ setMarchingDraft(drafts.marching);
+ setStandardDraft(drafts.standard);
+ } catch (cause) {
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
+ } finally {
+ setLoading(false);
+ }
+ }, [
+ anchorId,
+ pans.initialization,
+ pansStore,
+ settings.settings.activeDrillId,
+ settings.settings.defaultFieldPreset,
+ settings.status,
+ settingsStore,
+ ]);
+
+ useFocusEffect(
+ React.useCallback(() => {
+ void load();
+ }, [load]),
+ );
+
+ const validation =
+ mode === "marching"
+ ? validateMarchingAnchorDraft(marchingDraft, fieldPreset)
+ : validateStandardAnchorDraft(standardDraft, fieldPreset);
+ const canWritePosition = Boolean(
+ anchor &&
+ (pans.connectionState === "connected" ||
+ (pans.activeNetworkId && anchor.networkId === pans.activeNetworkId)),
+ );
+
+ const setMode = (nextMode: AnchorEditorMode) => {
+ if (nextMode === mode) return;
+ const position = validation.position;
+ if (position) {
+ if (nextMode === "standard") {
+ setStandardDraft(
+ standardDraftFromPosition(
+ position,
+ standardDraft.reference,
+ standardDraft.unit,
+ fieldPreset,
+ ),
+ );
+ } else {
+ setMarchingDraft(
+ createAnchorEditorDrafts(position, fieldPreset).marching,
+ );
+ }
+ }
+ setSaved(false);
+ setModeState(nextMode);
+ };
+
+ const updateStandardReference = (
+ reference: StandardAnchorPositionDraft["reference"],
+ ) => {
+ const position = validateStandardAnchorDraft(
+ standardDraft,
+ fieldPreset,
+ ).position;
+ setStandardDraft(
+ position
+ ? standardDraftFromPosition(
+ position,
+ reference,
+ standardDraft.unit,
+ fieldPreset,
+ )
+ : { ...standardDraft, reference },
+ );
+ };
+
+ const updateStandardUnit = (unit: AnchorPositionUnit) => {
+ const position = validateStandardAnchorDraft(
+ standardDraft,
+ fieldPreset,
+ ).position;
+ setStandardDraft(
+ position
+ ? standardDraftFromPosition(
+ position,
+ standardDraft.reference,
+ unit,
+ fieldPreset,
+ )
+ : { ...standardDraft, unit },
+ );
+ };
+
+ const saveAnchorName = async () => {
+ if (!anchor || savingName || !settings.settings.developerModeEnabled) {
+ return;
+ }
+ setSavingName(true);
+ setError(undefined);
+ try {
+ const savedAnchor = await pansStore.renameAnchor(anchor.id, anchorName);
+ setAnchor(savedAnchor);
+ setAnchorName(
+ savedAnchor.lastKnownConfig?.label ?? savedAnchor.label ?? "",
+ );
+ } catch (cause) {
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
+ } finally {
+ setSavingName(false);
+ }
+ };
+
+ const save = async (position: AnchorFieldPosition) => {
+ if (saving || !settings.settings.developerModeEnabled) return;
+ setSaving(true);
+ setSaved(false);
+ setError(undefined);
+ try {
+ await pansStore.writeAnchorPosition(anchorId, position);
+ setSaved(true);
+ await load();
+ } catch (cause) {
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return {
+ developerModeEnabled: settings.settings.developerModeEnabled,
+ connectionState: pans.connectionState,
+ canWritePosition,
+ anchor,
+ fieldPreset,
+ coordinateRoundingSteps: settings.settings.coordinateRoundingSteps,
+ mode,
+ marchingDraft,
+ standardDraft,
+ validation,
+ anchorName,
+ anchorNameDirty:
+ anchorName.trim() !==
+ (anchor?.lastKnownConfig?.label ?? anchor?.label ?? "").trim(),
+ loading,
+ saving,
+ savingName,
+ saved,
+ error,
+ setMode,
+ setAnchorName,
+ saveAnchorName,
+ setMarchingDraft: (draft: MarchingAnchorDraft) => {
+ setSaved(false);
+ setMarchingDraft(draft);
+ },
+ setStandardDraft: (draft: StandardAnchorPositionDraft) => {
+ setSaved(false);
+ setStandardDraft(draft);
+ },
+ updateStandardReference,
+ updateStandardUnit,
+ updateMarchingHeightUnit: (heightUnit: MarchingAnchorDraft["heightUnit"]) =>
+ setMarchingDraft((draft) => convertMarchingHeightUnit(draft, heightUnit)),
+ save,
+ reload: load,
+ } as const;
+}
diff --git a/apps/mobile/src/features/settings/use-anchor-list-controller.ts b/apps/mobile/src/features/settings/use-anchor-list-controller.ts
new file mode 100644
index 00000000..aa897949
--- /dev/null
+++ b/apps/mobile/src/features/settings/use-anchor-list-controller.ts
@@ -0,0 +1,51 @@
+import React from "react";
+import { useFocusEffect } from "expo-router";
+
+import { useAppSettingsSnapshot } from "../../state/app-settings-store";
+import {
+ useMobilePansSnapshot,
+ useMobilePansStore,
+} from "../../pans/mobile-pans-context";
+import { selectNetworkAnchors } from "../../pans/pans-anchor-cache";
+
+export function useAnchorListController() {
+ const { settings } = useAppSettingsSnapshot();
+ const pans = useMobilePansSnapshot();
+ const store = useMobilePansStore();
+ const [refreshing, setRefreshing] = React.useState(false);
+ const refreshingRef = React.useRef(false);
+ const [error, setError] = React.useState();
+ const anchors = React.useMemo(
+ () => selectNetworkAnchors(pans.rememberedTag, pans.knownAnchors),
+ [pans.knownAnchors, pans.rememberedTag],
+ );
+
+ const refresh = React.useCallback(async () => {
+ if (pans.initialization !== "ready" || refreshingRef.current) return;
+ refreshingRef.current = true;
+ setRefreshing(true);
+ setError(undefined);
+ try {
+ await store.refreshCachedAnchors();
+ } catch (cause) {
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
+ } finally {
+ refreshingRef.current = false;
+ setRefreshing(false);
+ }
+ }, [pans.initialization, store]);
+
+ useFocusEffect(
+ React.useCallback(() => {
+ void refresh();
+ }, [refresh]),
+ );
+
+ return {
+ developerModeEnabled: settings.developerModeEnabled,
+ anchors,
+ refreshing,
+ error: error ?? pans.error,
+ refresh,
+ } as const;
+}
diff --git a/apps/mobile/src/navigation/__tests__/mobile-orientation-lock.test.ts b/apps/mobile/src/navigation/__tests__/mobile-orientation-lock.test.ts
new file mode 100644
index 00000000..8bbb404e
--- /dev/null
+++ b/apps/mobile/src/navigation/__tests__/mobile-orientation-lock.test.ts
@@ -0,0 +1,23 @@
+import * as ScreenOrientation from "expo-screen-orientation";
+
+import { getMobileOrientationLock } from "../mobile-orientation";
+
+describe("mobile route orientation", () => {
+ test("allows device rotation only on Field routes", () => {
+ expect(getMobileOrientationLock("/field")).toBe(
+ ScreenOrientation.OrientationLock.DEFAULT,
+ );
+ expect(getMobileOrientationLock("/field/details")).toBe(
+ ScreenOrientation.OrientationLock.DEFAULT,
+ );
+ });
+
+ test.each(["/", "/drill", "/drill/example", "/settings", "/settings/tag"])(
+ "locks %s to upright portrait",
+ (pathname) => {
+ expect(getMobileOrientationLock(pathname)).toBe(
+ ScreenOrientation.OrientationLock.PORTRAIT_UP,
+ );
+ },
+ );
+});
diff --git a/apps/mobile/src/navigation/__tests__/mobile-tabs.test.ts b/apps/mobile/src/navigation/__tests__/mobile-tabs.test.ts
new file mode 100644
index 00000000..2db39ec0
--- /dev/null
+++ b/apps/mobile/src/navigation/__tests__/mobile-tabs.test.ts
@@ -0,0 +1,55 @@
+import {
+ INITIAL_MOBILE_TAB_NAVIGATION_STATE,
+ MOBILE_TABS,
+ reduceMobileTabNavigationState,
+ shouldHideNativeTabBar,
+} from "../mobile-tabs";
+
+describe("mobile native tab navigation", () => {
+ test("keeps Field first and exposes the expected tabs", () => {
+ expect(MOBILE_TABS.map(({ name, label }) => ({ name, label }))).toEqual([
+ { name: "field", label: "Field" },
+ { name: "drill", label: "Drill" },
+ { name: "settings", label: "Settings" },
+ ]);
+ });
+
+ test("hides the entire tab bar only for focused landscape Field", () => {
+ expect(
+ shouldHideNativeTabBar({ fieldFocused: true, fieldLandscape: true }),
+ ).toBe(true);
+ expect(
+ shouldHideNativeTabBar({ fieldFocused: true, fieldLandscape: false }),
+ ).toBe(false);
+ expect(
+ shouldHideNativeTabBar({ fieldFocused: false, fieldLandscape: true }),
+ ).toBe(false);
+ });
+
+ test("remounts native tabs exactly once per drill-feature change", () => {
+ const disabled = reduceMobileTabNavigationState(
+ INITIAL_MOBILE_TAB_NAVIGATION_STATE,
+ { type: "drill-features-reconfigured", enabled: false },
+ );
+ expect(disabled).toMatchObject({
+ drillFeaturesEnabled: false,
+ nativeTabsRevision: 1,
+ });
+
+ const unchanged = reduceMobileTabNavigationState(disabled, {
+ type: "drill-features-reconfigured",
+ enabled: false,
+ });
+ expect(unchanged).toBe(disabled);
+
+ expect(
+ reduceMobileTabNavigationState(unchanged, {
+ type: "drill-features-reconfigured",
+ enabled: true,
+ }),
+ ).toMatchObject({
+ drillFeaturesEnabled: true,
+ nativeTabsRevision: 2,
+ });
+ });
+});
diff --git a/apps/mobile/src/navigation/mobile-orientation.ts b/apps/mobile/src/navigation/mobile-orientation.ts
new file mode 100644
index 00000000..33fd188b
--- /dev/null
+++ b/apps/mobile/src/navigation/mobile-orientation.ts
@@ -0,0 +1,11 @@
+import * as ScreenOrientation from "expo-screen-orientation";
+
+export function getMobileOrientationLock(
+ pathname: string,
+): ScreenOrientation.OrientationLock {
+ const fieldRoute = pathname === "/field" || pathname.startsWith("/field/");
+
+ return fieldRoute
+ ? ScreenOrientation.OrientationLock.DEFAULT
+ : ScreenOrientation.OrientationLock.PORTRAIT_UP;
+}
diff --git a/apps/mobile/src/navigation/mobile-tabs.ts b/apps/mobile/src/navigation/mobile-tabs.ts
new file mode 100644
index 00000000..a8500b57
--- /dev/null
+++ b/apps/mobile/src/navigation/mobile-tabs.ts
@@ -0,0 +1,94 @@
+import type { NativeTabsTriggerIconProps } from "expo-router/unstable-native-tabs";
+
+export type MobileTabName = "field" | "drill" | "settings";
+
+export interface MobileTabConfig {
+ name: MobileTabName;
+ label: string;
+ icon: NativeTabsTriggerIconProps;
+}
+
+export const MOBILE_TABS = [
+ {
+ name: "field",
+ label: "Field",
+ icon: {
+ sf: { default: "map", selected: "map.fill" },
+ md: "map",
+ },
+ },
+ {
+ name: "drill",
+ label: "Drill",
+ icon: {
+ sf: {
+ default: "list.bullet.rectangle",
+ selected: "list.bullet.rectangle.fill",
+ },
+ md: "format_list_numbered",
+ },
+ },
+ {
+ name: "settings",
+ label: "Settings",
+ icon: {
+ sf: { default: "gearshape", selected: "gearshape.fill" },
+ md: "settings",
+ },
+ },
+] as const satisfies readonly MobileTabConfig[];
+
+export function shouldHideNativeTabBar({
+ fieldFocused,
+ fieldLandscape,
+}: Pick): boolean {
+ return fieldFocused && fieldLandscape;
+}
+
+export interface MobileTabNavigationState {
+ fieldFocused: boolean;
+ fieldLandscape: boolean;
+ drillFeaturesEnabled: boolean;
+ nativeTabsRevision: number;
+}
+
+export const INITIAL_MOBILE_TAB_NAVIGATION_STATE: MobileTabNavigationState = {
+ fieldFocused: false,
+ fieldLandscape: false,
+ drillFeaturesEnabled: true,
+ nativeTabsRevision: 0,
+};
+
+export type MobileTabNavigationAction =
+ | {
+ type: "field-presentation-changed";
+ fieldFocused: boolean;
+ fieldLandscape: boolean;
+ }
+ | { type: "drill-features-reconfigured"; enabled: boolean };
+
+export function reduceMobileTabNavigationState(
+ state: MobileTabNavigationState,
+ action: MobileTabNavigationAction,
+): MobileTabNavigationState {
+ if (action.type === "field-presentation-changed") {
+ if (
+ state.fieldFocused === action.fieldFocused &&
+ state.fieldLandscape === action.fieldLandscape
+ ) {
+ return state;
+ }
+ return {
+ ...state,
+ fieldFocused: action.fieldFocused,
+ fieldLandscape: action.fieldLandscape,
+ };
+ }
+
+ if (state.drillFeaturesEnabled === action.enabled) return state;
+ return {
+ ...state,
+ drillFeaturesEnabled: action.enabled,
+ nativeTabsRevision: state.nativeTabsRevision + 1,
+ };
+}
diff --git a/apps/mobile/src/navigation/tab-bar-visibility-context.tsx b/apps/mobile/src/navigation/tab-bar-visibility-context.tsx
new file mode 100644
index 00000000..04869884
--- /dev/null
+++ b/apps/mobile/src/navigation/tab-bar-visibility-context.tsx
@@ -0,0 +1,95 @@
+import React from "react";
+import { useRouter } from "expo-router";
+
+import {
+ INITIAL_MOBILE_TAB_NAVIGATION_STATE,
+ reduceMobileTabNavigationState,
+ shouldHideNativeTabBar,
+ type MobileTabNavigationState,
+} from "./mobile-tabs";
+
+interface FieldPresentation {
+ focused: boolean;
+ landscape: boolean;
+}
+
+interface TabBarVisibilityContextValue extends MobileTabNavigationState {
+ nativeTabBarHidden: boolean;
+ setFieldPresentation(presentation: FieldPresentation): void;
+ /**
+ * Call only after the setting has been persisted. This safely leaves Drill,
+ * then changes tab membership and remounts the native navigator exactly once.
+ */
+ reconfigureDrillFeatures(enabled: boolean): void;
+}
+
+const TabBarVisibilityContext = React.createContext<
+ TabBarVisibilityContextValue | undefined
+>(undefined);
+
+export function TabBarVisibilityProvider({
+ children,
+ drillFeaturesEnabled,
+}: {
+ children: React.ReactNode;
+ drillFeaturesEnabled: boolean;
+}) {
+ const router = useRouter();
+ const [state, dispatch] = React.useReducer(reduceMobileTabNavigationState, {
+ ...INITIAL_MOBILE_TAB_NAVIGATION_STATE,
+ drillFeaturesEnabled,
+ });
+ const configuredDrillFeatures = React.useRef(drillFeaturesEnabled);
+
+ const setFieldPresentation = React.useCallback(
+ ({ focused, landscape }: FieldPresentation) => {
+ dispatch({
+ type: "field-presentation-changed",
+ fieldFocused: focused,
+ fieldLandscape: landscape,
+ });
+ },
+ [],
+ );
+
+ const reconfigureDrillFeatures = React.useCallback(
+ (enabled: boolean) => {
+ if (configuredDrillFeatures.current === enabled) return;
+
+ configuredDrillFeatures.current = enabled;
+ router.replace("/(tabs)/settings");
+ dispatch({ type: "drill-features-reconfigured", enabled });
+ },
+ [router],
+ );
+
+ React.useEffect(() => {
+ reconfigureDrillFeatures(drillFeaturesEnabled);
+ }, [drillFeaturesEnabled, reconfigureDrillFeatures]);
+
+ const value = React.useMemo(
+ () => ({
+ ...state,
+ nativeTabBarHidden: shouldHideNativeTabBar(state),
+ setFieldPresentation,
+ reconfigureDrillFeatures,
+ }),
+ [reconfigureDrillFeatures, setFieldPresentation, state],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useTabBarVisibility(): TabBarVisibilityContextValue {
+ const value = React.useContext(TabBarVisibilityContext);
+ if (!value) {
+ throw new Error(
+ "useTabBarVisibility must be used inside TabBarVisibilityProvider.",
+ );
+ }
+ return value;
+}
diff --git a/apps/mobile/src/navigation/use-field-orientation.ts b/apps/mobile/src/navigation/use-field-orientation.ts
new file mode 100644
index 00000000..143a2bd7
--- /dev/null
+++ b/apps/mobile/src/navigation/use-field-orientation.ts
@@ -0,0 +1,36 @@
+import React from "react";
+import { useFocusEffect } from "expo-router";
+import { useWindowDimensions } from "react-native";
+
+import { useTabBarVisibility } from "./tab-bar-visibility-context";
+
+export interface FieldOrientationState {
+ focused: boolean;
+ landscape: boolean;
+}
+
+/** Bridges Field focus and physical viewport orientation to the native tabs. */
+export function useFieldOrientation(): FieldOrientationState {
+ const [focused, setFocused] = React.useState(false);
+ const { width, height } = useWindowDimensions();
+ const landscape = width > height;
+ const { setFieldPresentation } = useTabBarVisibility();
+
+ useFocusEffect(
+ React.useCallback(() => {
+ setFocused(true);
+ return () => setFocused(false);
+ }, []),
+ );
+
+ React.useEffect(() => {
+ setFieldPresentation({ focused, landscape });
+ }, [focused, landscape, setFieldPresentation]);
+
+ React.useEffect(
+ () => () => setFieldPresentation({ focused: false, landscape: false }),
+ [setFieldPresentation],
+ );
+
+ return { focused, landscape };
+}
diff --git a/apps/mobile/src/navigation/use-mobile-orientation-lock.ts b/apps/mobile/src/navigation/use-mobile-orientation-lock.ts
new file mode 100644
index 00000000..67630fc8
--- /dev/null
+++ b/apps/mobile/src/navigation/use-mobile-orientation-lock.ts
@@ -0,0 +1,14 @@
+import React from "react";
+import { usePathname } from "expo-router";
+import * as ScreenOrientation from "expo-screen-orientation";
+
+import { getMobileOrientationLock } from "./mobile-orientation";
+
+/** Keeps every route portrait-only except Field, which may rotate freely. */
+export function useMobileOrientationLock(): void {
+ const pathname = usePathname();
+
+ React.useEffect(() => {
+ void ScreenOrientation.lockAsync(getMobileOrientationLock(pathname));
+ }, [pathname]);
+}
diff --git a/apps/mobile/src/pans/__tests__/mobile-pans-position-publisher.test.ts b/apps/mobile/src/pans/__tests__/mobile-pans-position-publisher.test.ts
new file mode 100644
index 00000000..6adaaa9c
--- /dev/null
+++ b/apps/mobile/src/pans/__tests__/mobile-pans-position-publisher.test.ts
@@ -0,0 +1,168 @@
+import type { FusedPositionOutput, FieldPoint } from "@eight2five/mobile/field";
+import type {
+ DeviceMotionAdapter,
+ DeviceMotionSample,
+} from "@eight2five/mobile/motion";
+import type { PansPositionStreamSample } from "@eight2five/mobile/pans-manager";
+import type { SharedValue } from "react-native-reanimated";
+
+import {
+ INITIAL_MOBILE_PANS_SNAPSHOT,
+ type MobilePansSnapshot,
+} from "../mobile-pans-model";
+import { MobilePansPositionPublisher } from "../mobile-pans-position-publisher";
+
+jest.mock("expo-pans-ble-api", () => ({}));
+jest.mock("react-native-worklets", () => ({
+ ...jest.requireActual("react-native-worklets/lib/module/mock"),
+ scheduleOnRN: (callback: (...args: unknown[]) => void, ...args: unknown[]) =>
+ callback(...args),
+}));
+jest.mock("react-native-reanimated", () =>
+ jest.requireActual("react-native-reanimated/mock"),
+);
+jest.mock(
+ "@shopify/react-native-skia",
+ () => ({
+ Canvas: () => null,
+ Fill: () => null,
+ Group: () => null,
+ Path: () => null,
+ Circle: () => null,
+ Line: () => null,
+ Rect: () => null,
+ useFont: () => ({}),
+ vec: (x: number, y: number) => ({ x, y }),
+ }),
+ { virtual: true },
+);
+
+describe("MobilePansPositionPublisher", () => {
+ test("publishes UWB-first fused output to both shared values and HUD state", async () => {
+ let snapshot: MobilePansSnapshot = {
+ ...INITIAL_MOBILE_PANS_SNAPSHOT,
+ connectionState: "connected",
+ };
+ let motionListener!: (sample: DeviceMotionSample) => void;
+ const adapter: DeviceMotionAdapter = {
+ start: jest.fn(async (listener) => {
+ motionListener = listener;
+ return "active";
+ }),
+ stop: jest.fn(),
+ };
+ const positionValue = { value: null } as SharedValue;
+ const fusionValue = {
+ value: null,
+ } as SharedValue;
+ const publisher = new MobilePansPositionPublisher(
+ {
+ staleAfterMs: 2_500,
+ schedule: setTimeout,
+ cancel: clearTimeout,
+ isConnectionCurrent: () => true,
+ getSnapshot: () => snapshot,
+ publish: (next) => {
+ snapshot = next;
+ },
+ },
+ { motionAdapter: adapter, motionInterpolationEnabled: true },
+ );
+ publisher.attachPositionValue(positionValue);
+ publisher.attachFusionValue(fusionValue);
+ await publisher.startMotion(1);
+
+ publisher.receiveSample(positionSample(1_000, 0), 1);
+ publisher.receiveSample(positionSample(2_000, 1), 1);
+ motionListener({
+ receivedAt: 2_200,
+ acceleration: { x: 2, y: 0, z: 0 },
+ rotationRate: { x: 0, y: 0, z: 0 },
+ });
+
+ expect(positionValue.value).toEqual({ xMeters: 0.78, yMeters: 0 });
+ expect(fusionValue.value).toMatchObject({
+ source: "motion-predicted",
+ interpolationActive: true,
+ freshnessMs: 200,
+ lastUwbAt: 2_000,
+ });
+ expect(snapshot.livePosition).toMatchObject({
+ position: { xMeters: 0.78, yMeters: 0 },
+ source: "motion-predicted",
+ interpolationActive: true,
+ lastUwbPosition: { xMeters: 1, yMeters: 0 },
+ lastUwbAt: 2_000,
+ });
+ expect(snapshot.rawPosition).toEqual({
+ xMeters: 1,
+ yMeters: 0,
+ zMeters: 0,
+ });
+ publisher.setMotionInterpolationEnabled(false);
+ expect(positionValue.value).toEqual({ xMeters: 1, yMeters: 0 });
+ expect(fusionValue.value).toMatchObject({
+ source: "uwb",
+ interpolationActive: false,
+ position: { xMeters: 1, yMeters: 0 },
+ });
+ publisher.dispose();
+ });
+
+ test("bypasses the adapter and keeps raw UWB output when the preference is off", async () => {
+ let snapshot: MobilePansSnapshot = {
+ ...INITIAL_MOBILE_PANS_SNAPSHOT,
+ connectionState: "connected",
+ };
+ const adapter: DeviceMotionAdapter = {
+ start: jest.fn(async () => "active"),
+ stop: jest.fn(),
+ };
+ const fusionValue = {
+ value: null,
+ } as SharedValue;
+ const publisher = new MobilePansPositionPublisher(
+ {
+ staleAfterMs: 2_500,
+ schedule: setTimeout,
+ cancel: clearTimeout,
+ isConnectionCurrent: () => true,
+ getSnapshot: () => snapshot,
+ publish: (next) => {
+ snapshot = next;
+ },
+ },
+ { motionAdapter: adapter, motionInterpolationEnabled: false },
+ );
+ publisher.attachFusionValue(fusionValue);
+
+ await publisher.startMotion(1);
+ publisher.receiveSample(positionSample(1_000, 5), 1);
+ publisher.receiveSample(positionSample(2_000, 6), 1);
+
+ expect(adapter.start).not.toHaveBeenCalled();
+ expect(fusionValue.value).toMatchObject({
+ position: { xMeters: 6, yMeters: 0 },
+ source: "uwb",
+ interpolationActive: false,
+ });
+ expect(snapshot.livePosition.interpolationActive).toBe(false);
+ publisher.dispose();
+ });
+});
+
+function positionSample(
+ receivedAt: number,
+ xMeters: number,
+): PansPositionStreamSample {
+ return {
+ deviceId: "tag",
+ transportDeviceId: "transport-tag",
+ receivedAt,
+ source: "notification",
+ position: { xMeters, yMeters: 0, zMeters: 0, quality: 80 },
+ distances: [],
+ diagnostics: [],
+ decoderDiagnostics: [],
+ };
+}
diff --git a/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts b/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts
new file mode 100644
index 00000000..2d9aa638
--- /dev/null
+++ b/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts
@@ -0,0 +1,758 @@
+import {
+ DEFAULT_MANAGED_NETWORK_SETTINGS,
+ InMemoryPansManagerRepository,
+} from "@eight2five/mobile/pans-manager";
+import type {
+ DiscoveredDeviceSnapshot,
+ ManagedDevice,
+ PansPosition,
+ PansInspectionResult,
+ PansPositionStreamSample,
+ StartPansPositionStreamOptions,
+} from "@eight2five/mobile/pans-manager";
+import type { SharedValue } from "react-native-reanimated";
+import type { FieldPoint } from "@eight2five/mobile/field";
+
+import type { MobilePansRuntime } from "../mobile-pans-runtime";
+import {
+ MobilePansStore,
+ pansPositionToFieldPoint,
+} from "../mobile-pans-store";
+
+jest.mock("expo-pans-ble-api", () => ({}));
+jest.mock("react-native-worklets", () => ({
+ ...jest.requireActual("react-native-worklets/lib/module/mock"),
+ scheduleOnRN: (callback: (...args: unknown[]) => void, ...args: unknown[]) =>
+ callback(...args),
+}));
+jest.mock("react-native-reanimated", () =>
+ jest.requireActual("react-native-reanimated/mock"),
+);
+jest.mock(
+ "@shopify/react-native-skia",
+ () => ({
+ Canvas: () => null,
+ Fill: () => null,
+ Group: () => null,
+ Path: () => null,
+ Circle: () => null,
+ Line: () => null,
+ Rect: () => null,
+ useFont: () => ({}),
+ vec: (x: number, y: number) => ({ x, y }),
+ }),
+ { virtual: true },
+);
+
+const DISCOVERY: DiscoveredDeviceSnapshot = {
+ transportDeviceId: "tag-transport",
+ name: "Field Tag",
+ rssi: -48,
+ lastSeenAt: 1,
+ stale: false,
+ compatibility: "compatible",
+ presence: { role: "tag" } as never,
+};
+
+const ANCHOR_DISCOVERY: DiscoveredDeviceSnapshot = {
+ ...DISCOVERY,
+ transportDeviceId: "anchor-transport",
+ name: "Field Anchor",
+ presence: { role: "anchor" } as never,
+};
+
+describe("MobilePansStore", () => {
+ afterEach(() => jest.useRealTimers());
+
+ test("shares one connection attempt and one position stream", async () => {
+ const start = deferred();
+ const harness = await createHarness({
+ streamStart: jest.fn(
+ async (_options: StartPansPositionStreamOptions) => await start.promise,
+ ),
+ });
+ const store = new MobilePansStore({
+ createRuntime: async () => harness.runtime,
+ });
+ await store.initialize();
+ await store.selectTag(DISCOVERY.transportDeviceId);
+
+ const first = store.connect();
+ const second = store.connect();
+ await flushPromises();
+ expect(harness.streamStart).toHaveBeenCalledTimes(1);
+
+ start.resolve();
+ await Promise.all([first, second]);
+ expect(store.getSnapshot().connectionState).toBe("connected");
+ await store.dispose();
+ });
+
+ test("cancels bounded reconnect after explicit disconnect", async () => {
+ jest.useFakeTimers();
+ const remembered = managedTag();
+ const streamStart = jest.fn(
+ async (_options: StartPansPositionStreamOptions) => {
+ throw new Error("offline");
+ },
+ );
+ const harness = await createHarness({ remembered, streamStart });
+ const store = new MobilePansStore({
+ createRuntime: async () => harness.runtime,
+ reconnectDelaysMs: [100, 200],
+ });
+
+ await store.initialize();
+ await flushPromises();
+ expect(streamStart).toHaveBeenCalledTimes(1);
+ await store.disconnect();
+ jest.advanceTimersByTime(1_000);
+ await flushPromises();
+
+ expect(streamStart).toHaveBeenCalledTimes(1);
+ expect(store.getSnapshot().connectionState).toBe("disconnected");
+ await store.dispose();
+ });
+
+ test("does not start a pending connection after the app backgrounds", async () => {
+ const harness = await createHarness({
+ remembered: managedTag(),
+ discoveries: [],
+ });
+ const store = new MobilePansStore({
+ createRuntime: async () => harness.runtime,
+ });
+
+ await store.initialize();
+ await flushPromises();
+ expect(harness.streamStart).not.toHaveBeenCalled();
+ store.setForeground(false);
+ harness.emitDiscoveries([DISCOVERY]);
+ await flushPromises();
+
+ expect(harness.streamStart).not.toHaveBeenCalled();
+ await store.dispose();
+ });
+
+ test("updates the shared marker immediately and marks old data stale", async () => {
+ jest.useFakeTimers();
+ const harness = await createHarness();
+ const marker = { value: null } as SharedValue;
+ const store = new MobilePansStore({
+ createRuntime: async () => harness.runtime,
+ staleAfterMs: 500,
+ });
+ store.attachPositionValue(marker);
+ await store.initialize();
+ await store.selectTag(DISCOVERY.transportDeviceId);
+ await store.connect();
+
+ harness.emitSample(positionSample(1_000, 12.5, 7.25, 1.8));
+ harness.emitSample(positionSample(1_100, 12.5, 7.25, 1.8));
+ expect(marker.value).toEqual({ xMeters: 12.5, yMeters: 7.25 });
+ expect(store.getSnapshot()).toMatchObject({
+ connectionState: "connected",
+ rawPosition: { xMeters: 12.5, yMeters: 7.25, zMeters: 1.8 },
+ livePosition: { isStale: false },
+ effectiveUpdateRateHz: 10,
+ });
+
+ jest.advanceTimersByTime(500);
+ expect(marker.value).toBeNull();
+ expect(store.getSnapshot().livePosition).toMatchObject({
+ position: { xMeters: 12.5, yMeters: 7.25 },
+ isStale: true,
+ });
+ await store.dispose();
+ });
+
+ test("starts only one reconnect loop for duplicate disconnect events", async () => {
+ const harness = await createHarness();
+ const store = new MobilePansStore({
+ createRuntime: async () => harness.runtime,
+ });
+ await store.initialize();
+ await store.selectTag(DISCOVERY.transportDeviceId);
+ await store.connect();
+ expect(harness.streamStart).toHaveBeenCalledTimes(1);
+
+ harness.emitConnectionState("disconnected");
+ harness.emitConnectionState("disconnected");
+ await flushPromises();
+
+ expect(harness.streamStart).toHaveBeenCalledTimes(2);
+ await store.dispose();
+ });
+
+ test("forgets persisted identity without deleting the device cache", async () => {
+ const harness = await createHarness();
+ const store = new MobilePansStore({
+ createRuntime: async () => harness.runtime,
+ });
+ await store.initialize();
+ await store.selectTag(DISCOVERY.transportDeviceId);
+ const selectedId = store.getSnapshot().rememberedTag?.id;
+
+ await store.forgetTag();
+
+ expect(
+ (await harness.repository.getSettings())?.rememberedTagDeviceId,
+ ).toBeUndefined();
+ expect(await harness.repository.getDevice(selectedId!)).toBeDefined();
+ expect(store.getSnapshot().connectionState).toBe("idle");
+ await store.dispose();
+ });
+
+ test("rejects stale or incompatible tag selections", async () => {
+ const harness = await createHarness();
+ const store = new MobilePansStore({
+ createRuntime: async () => harness.runtime,
+ });
+ await store.initialize();
+ harness.emitDiscoveries([
+ { ...DISCOVERY, compatibility: "incompatible", stale: true },
+ ]);
+
+ await expect(store.selectTag(DISCOVERY.transportDeviceId)).rejects.toThrow(
+ "compatible",
+ );
+ expect(store.getSnapshot().rememberedTag).toBeUndefined();
+ await store.dispose();
+ });
+
+ test("uses the documented identity-aligned PANS-to-field conversion", () => {
+ expect(
+ pansPositionToFieldPoint({
+ xMeters: -2,
+ yMeters: 4,
+ zMeters: 1,
+ quality: 20,
+ }),
+ ).toEqual({ xMeters: -2, yMeters: 4 });
+ });
+
+ test("verifies PAN data before associating a fresh deployment", async () => {
+ const harness = await createHarness();
+ await harness.repository.saveNetwork({
+ id: "network-33",
+ name: "Field deployment",
+ panId: 33,
+ settings: DEFAULT_MANAGED_NETWORK_SETTINGS,
+ createdAt: 1,
+ updatedAt: 1,
+ });
+ harness.configurationInspect.mockImplementation(
+ async (deviceId: string) => {
+ const device = (await harness.repository.getDevice(deviceId))!;
+ await harness.repository.saveDevice({
+ ...device,
+ lastKnownConfig:
+ device.role === "anchor"
+ ? { ...anchorConfig(), panId: 33 }
+ : { ...tagConfig(), panId: 33 },
+ });
+ return {} as never;
+ },
+ );
+ const store = new MobilePansStore({
+ createRuntime: async () => harness.runtime,
+ });
+ await store.initialize();
+ harness.emitDiscoveries([DISCOVERY, ANCHOR_DISCOVERY]);
+
+ await store.selectTag(DISCOVERY.transportDeviceId);
+
+ expect(store.getSnapshot().rememberedTag).toMatchObject({
+ networkId: "network-33",
+ lastKnownConfig: { panId: 33 },
+ });
+ expect(store.getSnapshot().knownAnchors).toEqual([
+ expect.objectContaining({
+ networkId: "network-33",
+ lastKnownConfig: expect.objectContaining({ panId: 33 }),
+ }),
+ ]);
+ await store.dispose();
+ });
+
+ test("writes once with internal quality 100 and caches only successful writes", async () => {
+ const harness = await createHarness();
+ await harness.repository.saveDevice({
+ ...managedTag(),
+ networkId: "network-a",
+ });
+ await harness.repository.saveDevice(managedAnchor("anchor-1", "network-a"));
+ await harness.repository.saveDevice(managedAnchor("anchor-2", "network-a"));
+ const store = new MobilePansStore({
+ createRuntime: async () => harness.runtime,
+ });
+ await store.initialize();
+ await store.selectTag(DISCOVERY.transportDeviceId);
+ await store.connect();
+
+ const position = { xMeters: 10, yMeters: 20, zMeters: 2 };
+ const first = store.writeAnchorPosition("anchor-1", position);
+ await expect(
+ store.writeAnchorPosition("anchor-1", position),
+ ).rejects.toMatchObject({ code: "OPERATION_CANCELLED" });
+ await first;
+
+ expect(harness.configurationApply).toHaveBeenCalledTimes(1);
+ expect(harness.configurationApply).toHaveBeenCalledWith("anchor-1", {
+ position: { ...position, quality: 100 },
+ });
+ expect(await harness.repository.getDevice("anchor-1")).toMatchObject({
+ lastKnownConfig: { position: { ...position, quality: 100 } },
+ });
+ await store.writeAnchorPosition("anchor-1", position);
+ expect(harness.configurationApply).toHaveBeenCalledTimes(1);
+
+ harness.configurationApply.mockRejectedValueOnce(new Error("write failed"));
+ await expect(
+ store.writeAnchorPosition("anchor-2", position),
+ ).rejects.toBeDefined();
+ expect(
+ (await harness.repository.getDevice("anchor-2"))?.lastKnownConfig,
+ ).not.toHaveProperty("position");
+ await store.dispose();
+ });
+
+ test("inspects and sparsely repairs the performer profile before streaming", async () => {
+ const harness = await createHarness();
+ harness.configurationInspect.mockResolvedValueOnce(
+ correctTagInspection("selection"),
+ );
+ harness.configurationInspect.mockResolvedValueOnce({
+ ...correctTagInspection("selected"),
+ operationMode: {
+ ...correctTagInspection("selected").operationMode,
+ ledEnabled: false,
+ },
+ });
+ harness.configurationApply.mockResolvedValueOnce({
+ deviceId: "selected",
+ transportDeviceId: DISCOVERY.transportDeviceId,
+ outcome: "verified",
+ writes: [
+ {
+ field: "ledEnabled",
+ status: "verified",
+ requested: true,
+ actual: true,
+ },
+ ],
+ warnings: [],
+ } as never);
+ const store = new MobilePansStore({
+ createRuntime: async () => harness.runtime,
+ });
+ await store.initialize();
+ await store.selectTag(DISCOVERY.transportDeviceId);
+ await store.connect();
+
+ expect(harness.configurationApply).toHaveBeenCalledWith(
+ expect.any(String),
+ { ledEnabled: true },
+ );
+ expect(harness.streamStart).toHaveBeenCalledTimes(1);
+ await store.dispose();
+ });
+
+ test("matches PAN only when Developer Mode has an active network", async () => {
+ const harness = await createHarness();
+ const store = new MobilePansStore({
+ createRuntime: async () => harness.runtime,
+ developerModeEnabled: true,
+ });
+ await store.initialize();
+ const network = await store.createNetwork("Field", 42);
+ await store.setActiveNetwork(network.id);
+ await store.selectTag(DISCOVERY.transportDeviceId);
+ await store.connect();
+
+ expect(harness.commissioningAssign).toHaveBeenCalledWith({
+ deviceId: expect.any(String),
+ targetNetworkId: network.id,
+ });
+ await store.dispose();
+ });
+
+ test("resets the developer RSSI override when Developer Mode is disabled", async () => {
+ const harness = await createHarness();
+ const store = new MobilePansStore({
+ createRuntime: async () => harness.runtime,
+ developerModeEnabled: true,
+ });
+ await store.initialize();
+ await store.setDiscoveryRssiCutoff(-90);
+ await store.setDeveloperModeEnabled(false);
+
+ expect((await harness.repository.getSettings())?.discoveryRssiCutoff).toBe(
+ -75,
+ );
+ expect(store.getSnapshot().discoveryRssiCutoff).toBe(-75);
+ await store.dispose();
+ });
+
+ test("creates, edits, selects, and deletes network profiles locally", async () => {
+ const harness = await createHarness();
+ const store = new MobilePansStore({
+ createRuntime: async () => harness.runtime,
+ developerModeEnabled: true,
+ });
+ await store.initialize();
+ const created = await store.createNetwork("Field", 100);
+ await store.setActiveNetwork(created.id);
+ await store.updateNetwork(created.id, { name: "Stadium", panId: 101 });
+ expect(store.getSnapshot()).toMatchObject({
+ activeNetworkId: created.id,
+ networks: [expect.objectContaining({ name: "Stadium", panId: 101 })],
+ });
+ await store.deleteNetwork(created.id);
+ expect(store.getSnapshot().networks).toEqual([]);
+ expect(store.getSnapshot().activeNetworkId).toBeUndefined();
+ await store.dispose();
+ });
+
+ test("persists a directly discovered uncached anchor before editing", async () => {
+ const harness = await createHarness();
+ const store = new MobilePansStore({
+ createRuntime: async () => harness.runtime,
+ developerModeEnabled: true,
+ });
+ await store.initialize();
+ harness.emitDiscoveries([ANCHOR_DISCOVERY]);
+ harness.configurationInspect.mockResolvedValueOnce(
+ anchorInspection("anchor"),
+ );
+ const saved = await store.persistDiscoveredAnchor(
+ ANCHOR_DISCOVERY.transportDeviceId,
+ );
+ expect(saved).toMatchObject({
+ role: "anchor",
+ transportDeviceId: ANCHOR_DISCOVERY.transportDeviceId,
+ });
+ expect(store.getSnapshot().knownAnchors).toContainEqual(
+ expect.objectContaining({ id: saved.id }),
+ );
+ await store.dispose();
+ });
+
+ test("writes an anchor name to the hardware PANS label and refreshes the cache", async () => {
+ const harness = await createHarness();
+ const store = new MobilePansStore({
+ createRuntime: async () => harness.runtime,
+ developerModeEnabled: true,
+ });
+ await store.initialize();
+ await harness.repository.saveDevice(managedAnchor("named-anchor"));
+ await store.refreshCachedAnchors();
+
+ const saved = await store.renameAnchor("named-anchor", " Front 50 ");
+
+ expect(saved.lastKnownConfig?.label).toBe("Front 50");
+ expect(harness.configurationApply).toHaveBeenCalledWith("named-anchor", {
+ label: "Front 50",
+ });
+ expect(store.getSnapshot().knownAnchors).toContainEqual(
+ expect.objectContaining({
+ id: "named-anchor",
+ lastKnownConfig: expect.objectContaining({ label: "Front 50" }),
+ }),
+ );
+ await store.dispose();
+ });
+
+ test("sets a reachable initiator and reports unreachable prior initiators", async () => {
+ const harness = await createHarness();
+ const store = new MobilePansStore({
+ createRuntime: async () => harness.runtime,
+ developerModeEnabled: true,
+ });
+ await store.initialize();
+ const network = await store.createNetwork("Field", 55);
+ await store.setActiveNetwork(network.id);
+ await harness.repository.saveDevice({
+ ...managedAnchor("new-initiator", network.id),
+ lastKnownConfig: { ...anchorConfig(), initiatorEnabled: false },
+ });
+ await harness.repository.saveDevice({
+ ...managedAnchor("old-initiator", network.id),
+ lastKnownConfig: { ...anchorConfig(), initiatorEnabled: true },
+ });
+ harness.configurationInspect.mockResolvedValueOnce({
+ ...anchorInspection("new-initiator"),
+ operationMode: {
+ ...anchorInspection("new-initiator").operationMode,
+ initiatorEnabled: true,
+ },
+ });
+ await store.setAnchorInitiator("new-initiator");
+ expect(harness.configurationApply).toHaveBeenCalledWith("new-initiator", {
+ initiatorEnabled: true,
+ });
+ expect(store.getSnapshot().commissioningWarning).toContain("unreachable");
+ await store.dispose();
+ });
+});
+
+async function createHarness(
+ options: {
+ remembered?: ManagedDevice;
+ discoveries?: DiscoveredDeviceSnapshot[];
+ streamStart?: jest.Mock, [StartPansPositionStreamOptions]>;
+ } = {},
+) {
+ const repository = new InMemoryPansManagerRepository();
+ await repository.initialize();
+ if (options.remembered) {
+ await repository.saveDevice(options.remembered);
+ const settings = await repository.getSettings();
+ await repository.saveSettings({
+ ...settings!,
+ rememberedTagDeviceId: options.remembered.id,
+ });
+ }
+ let streamOptions: StartPansPositionStreamOptions | undefined;
+ let connectionListener:
+ | ((event: { deviceId: string; state: "disconnected" }) => void)
+ | undefined;
+ let discoveries = options.discoveries ?? [DISCOVERY];
+ const discoveryListeners = new Set<
+ (items: DiscoveredDeviceSnapshot[]) => void
+ >();
+ const streamStart = options.streamStart ?? jest.fn(async () => undefined);
+ const configurationApply = jest.fn(
+ async (
+ deviceId: string,
+ changes: { position?: PansPosition; label?: string },
+ ) => {
+ const device = (await repository.getDevice(deviceId))!;
+ const baseConfig =
+ device.lastKnownConfig?.role === "anchor"
+ ? device.lastKnownConfig
+ : anchorConfig();
+ await repository.saveDevice({
+ ...device,
+ ...(changes.label !== undefined ? { label: changes.label } : {}),
+ lastKnownConfig: {
+ ...baseConfig,
+ ...(changes.position !== undefined
+ ? { position: changes.position }
+ : {}),
+ ...(changes.label !== undefined ? { label: changes.label } : {}),
+ },
+ });
+ if (changes.label !== undefined) {
+ return {
+ deviceId,
+ transportDeviceId: device.transportDeviceId,
+ outcome: "applied" as const,
+ writes: [
+ {
+ field: "label",
+ status: "verified" as const,
+ requested: changes.label,
+ actual: changes.label,
+ },
+ ],
+ warnings: [],
+ };
+ }
+ return {
+ deviceId,
+ transportDeviceId: device.transportDeviceId,
+ outcome: "partial" as const,
+ writes: [
+ {
+ field: "position",
+ status: "written-unverified" as const,
+ requested: changes.position,
+ },
+ ],
+ warnings: [],
+ };
+ },
+ );
+ const configurationInspect = jest.fn, [string]>(
+ async (deviceId: string) => correctTagInspection(deviceId),
+ );
+ const commissioningAssign = jest.fn(
+ async ({ deviceId, targetNetworkId }) => ({
+ deviceId,
+ targetNetworkId,
+ stage: "complete" as const,
+ outcome: "assigned" as const,
+ }),
+ );
+ const runtime = {
+ repository,
+ discovery: {
+ getPermissionStatus: () => ({ bluetooth: "granted" }),
+ requestPermissions: async () => ({ bluetooth: "granted" }),
+ start: jest.fn(async () => undefined),
+ stop: jest.fn(async () => undefined),
+ subscribe: (listener: (items: DiscoveredDeviceSnapshot[]) => void) => {
+ discoveryListeners.add(listener);
+ listener(discoveries);
+ return { remove: () => discoveryListeners.delete(listener) };
+ },
+ subscribeErrors: () => ({ remove: jest.fn() }),
+ subscribeState: (listener: (state: string) => void) => {
+ listener("idle");
+ return { remove: jest.fn() };
+ },
+ },
+ sessions: {
+ addConnectionStateListener: (listener: typeof connectionListener) => {
+ connectionListener = listener;
+ return { remove: () => (connectionListener = undefined) };
+ },
+ closeAll: jest.fn(async () => undefined),
+ },
+ stream: {
+ start: jest.fn(async (next: StartPansPositionStreamOptions) => {
+ streamOptions = next;
+ await streamStart(next);
+ }),
+ stop: jest.fn(async () => undefined),
+ },
+ configuration: {
+ applyConfigurationDiff: configurationApply,
+ inspectAndCache: configurationInspect,
+ },
+ commissioning: {
+ assignDeviceToNetworkProfile: commissioningAssign,
+ },
+ diagnostics: {},
+ close: jest.fn(async () => undefined),
+ } as unknown as MobilePansRuntime;
+ return {
+ repository,
+ runtime,
+ streamStart,
+ configurationApply,
+ configurationInspect,
+ commissioningAssign,
+ emitDiscoveries(next: DiscoveredDeviceSnapshot[]) {
+ discoveries = next;
+ for (const listener of discoveryListeners) listener(discoveries);
+ },
+ emitSample(sample: PansPositionStreamSample) {
+ streamOptions?.onSample(sample);
+ },
+ emitConnectionState(state: "disconnected") {
+ connectionListener?.({ deviceId: DISCOVERY.transportDeviceId, state });
+ },
+ };
+}
+
+function correctTagInspection(deviceId: string): PansInspectionResult {
+ return {
+ deviceId,
+ transportDeviceId: DISCOVERY.transportDeviceId,
+ inspectedAt: 1,
+ operationMode: {
+ role: "tag" as const,
+ uwbMode: "active" as const,
+ selectedFirmware: 1 as const,
+ accelerometerEnabled: false,
+ ledEnabled: true,
+ firmwareUpdateEnabled: true,
+ initiatorEnabled: false,
+ lowPowerModeEnabled: false,
+ locationEngineEnabled: true,
+ raw: [0, 0] as [number, number],
+ },
+ locationDataMode: 2 as const,
+ warnings: [],
+ };
+}
+
+function anchorInspection(deviceId: string): PansInspectionResult {
+ return {
+ ...correctTagInspection(deviceId),
+ operationMode: {
+ ...correctTagInspection(deviceId).operationMode,
+ role: "anchor" as const,
+ initiatorEnabled: false,
+ lowPowerModeEnabled: false,
+ locationEngineEnabled: false,
+ },
+ locationDataMode: undefined,
+ };
+}
+
+function managedAnchor(id: string, networkId?: string): ManagedDevice {
+ return {
+ id,
+ transportDeviceId: `transport-${id}`,
+ role: "anchor",
+ ...(networkId ? { networkId } : {}),
+ lastKnownConfig: anchorConfig(),
+ createdAt: 1,
+ updatedAt: 1,
+ };
+}
+
+function anchorConfig() {
+ return {
+ role: "anchor" as const,
+ uwbMode: "active" as const,
+ ledEnabled: true,
+ firmwareUpdateEnabled: false,
+ initiatorEnabled: false,
+ };
+}
+
+function tagConfig() {
+ return {
+ role: "tag" as const,
+ uwbMode: "active" as const,
+ ledEnabled: true,
+ firmwareUpdateEnabled: false,
+ locationEngineEnabled: true,
+ lowPowerModeEnabled: false,
+ stationaryDetectionEnabled: true,
+ };
+}
+
+function managedTag(): ManagedDevice {
+ return {
+ id: "remembered-tag",
+ transportDeviceId: DISCOVERY.transportDeviceId,
+ role: "tag",
+ createdAt: 1,
+ updatedAt: 1,
+ };
+}
+
+function positionSample(
+ receivedAt: number,
+ xMeters: number,
+ yMeters: number,
+ zMeters: number,
+): PansPositionStreamSample {
+ return {
+ deviceId: "tag",
+ transportDeviceId: DISCOVERY.transportDeviceId,
+ receivedAt,
+ source: "notification",
+ position: { xMeters, yMeters, zMeters, quality: 40 },
+ distances: [],
+ diagnostics: [],
+ decoderDiagnostics: [],
+ };
+}
+
+function deferred() {
+ let resolve!: (value: T | PromiseLike) => void;
+ const promise = new Promise((done) => {
+ resolve = done;
+ });
+ return { promise, resolve };
+}
+
+async function flushPromises(): Promise {
+ for (let index = 0; index < 12; index += 1) await Promise.resolve();
+}
diff --git a/apps/mobile/src/pans/__tests__/mobile-pans-ui.test.ts b/apps/mobile/src/pans/__tests__/mobile-pans-ui.test.ts
new file mode 100644
index 00000000..b4eb09fe
--- /dev/null
+++ b/apps/mobile/src/pans/__tests__/mobile-pans-ui.test.ts
@@ -0,0 +1,69 @@
+import type { DiscoveredDeviceSnapshot } from "@eight2five/mobile/pans-manager";
+import {
+ connectionStatusViewModel,
+ selectVisibleDiscoveries,
+ signalStrengthForRssi,
+} from "../mobile-pans-ui";
+
+const tag = discovery("tag", "tag", -60);
+const anchor = discovery("anchor", "anchor", -55);
+
+describe("mobile PANS UI selectors", () => {
+ test.each([
+ ["connected", "Connected", "connected", false],
+ ["scanning", "Searching", "searching", true],
+ ["connecting", "Connecting", "connecting", false],
+ ["reconnecting", "Reconnecting", "connecting", false],
+ ["disconnected", "Disconnected", "disconnected", false],
+ ["error", "Connection error", "error", false],
+ ] as const)("maps %s status", (state, label, icon, animated) => {
+ expect(connectionStatusViewModel(state)).toMatchObject({
+ label,
+ icon,
+ animated,
+ });
+ });
+
+ test("ordinary mode filters anchors and weak advertisements", () => {
+ expect(
+ selectVisibleDiscoveries(
+ [tag, anchor, { ...tag, transportDeviceId: "weak", rssi: -80 }],
+ {
+ developerMode: false,
+ cutoff: -75,
+ },
+ ).map((item) => item.transportDeviceId),
+ ).toEqual(["tag"]);
+ });
+
+ test("developer mode includes anchors and sorts by raw RSSI", () => {
+ expect(
+ selectVisibleDiscoveries([tag, anchor], {
+ developerMode: true,
+ cutoff: -75,
+ }).map((item) => item.transportDeviceId),
+ ).toEqual(["anchor", "tag"]);
+ });
+
+ test("derives signal bands relative to cutoff", () => {
+ expect(signalStrengthForRssi(-75, -75)).toBe("low");
+ expect(signalStrengthForRssi(-65, -75)).toBe("medium");
+ expect(signalStrengthForRssi(-50, -75)).toBe("high");
+ expect(signalStrengthForRssi(-35, -75)).toBe("full");
+ });
+});
+
+function discovery(
+ transportDeviceId: string,
+ role: "tag" | "anchor",
+ rssi: number,
+): DiscoveredDeviceSnapshot {
+ return {
+ transportDeviceId,
+ name: transportDeviceId,
+ rssi,
+ lastSeenAt: 1,
+ compatibility: "compatible",
+ presence: { role } as never,
+ };
+}
diff --git a/apps/mobile/src/pans/__tests__/pans-anchor-cache.test.ts b/apps/mobile/src/pans/__tests__/pans-anchor-cache.test.ts
new file mode 100644
index 00000000..8fdb4ae5
--- /dev/null
+++ b/apps/mobile/src/pans/__tests__/pans-anchor-cache.test.ts
@@ -0,0 +1,96 @@
+import type { ManagedDevice } from "@eight2five/mobile/pans-manager";
+
+import {
+ areDevicesNetworkAssociated,
+ cachedAnchorGeometry,
+ selectNetworkAnchors,
+} from "../pans-anchor-cache";
+
+const base = {
+ transportDeviceId: "transport",
+ createdAt: 1,
+ updatedAt: 1,
+};
+
+describe("network-associated anchor cache", () => {
+ test("never mixes geometry from another saved network", () => {
+ const tag = {
+ ...base,
+ id: "tag",
+ role: "tag" as const,
+ networkId: "network-a",
+ };
+ const anchors = [
+ anchor("a", "network-a", 1),
+ anchor("b", "network-b", 2),
+ anchor("unassociated", undefined, 3),
+ ];
+
+ expect(selectNetworkAnchors(tag, anchors).map((item) => item.id)).toEqual([
+ "a",
+ ]);
+ expect(cachedAnchorGeometry(tag, anchors)).toEqual([
+ {
+ id: "a",
+ position: { xMeters: 1, yMeters: 2, zMeters: 3 },
+ },
+ ]);
+ expect(areDevicesNetworkAssociated(tag, anchors[0])).toBe(true);
+ expect(areDevicesNetworkAssociated(tag, anchors[1])).toBe(false);
+ });
+
+ test("falls back to equal verified PAN IDs and otherwise hides geometry", () => {
+ const tag = {
+ ...base,
+ id: "tag",
+ role: "tag" as const,
+ lastKnownConfig: tagConfig(44),
+ };
+ const anchors = [
+ anchor("same", undefined, 1, 44),
+ anchor("other", undefined, 2, 45),
+ ];
+ expect(selectNetworkAnchors(tag, anchors).map((item) => item.id)).toEqual([
+ "same",
+ ]);
+ expect(
+ selectNetworkAnchors({ ...tag, lastKnownConfig: tagConfig() }, anchors),
+ ).toEqual([]);
+ });
+});
+
+function anchor(
+ id: string,
+ networkId: string | undefined,
+ xMeters: number,
+ panId = 44,
+): ManagedDevice {
+ return {
+ ...base,
+ id,
+ ...(networkId ? { networkId } : {}),
+ role: "anchor",
+ lastKnownConfig: {
+ role: "anchor",
+ panId,
+ uwbMode: "active",
+ ledEnabled: true,
+ firmwareUpdateEnabled: false,
+ initiatorEnabled: false,
+ position: { xMeters, yMeters: 2, zMeters: 3, quality: 100 },
+ },
+ };
+}
+
+function tagConfig(panId?: number) {
+ return {
+ role: "tag" as const,
+ ...(panId === undefined ? {} : { panId }),
+ uwbMode: "active" as const,
+ ledEnabled: true,
+ firmwareUpdateEnabled: false,
+ locationEngineEnabled: true,
+ lowPowerModeEnabled: false,
+ stationaryDetectionEnabled: true,
+ };
+}
diff --git a/apps/mobile/src/pans/mobile-pans-connection-controller.ts b/apps/mobile/src/pans/mobile-pans-connection-controller.ts
new file mode 100644
index 00000000..884d9642
--- /dev/null
+++ b/apps/mobile/src/pans/mobile-pans-connection-controller.ts
@@ -0,0 +1,385 @@
+import {
+ normalizeManagerError,
+ normalizeTransportDeviceId,
+ type DiscoveredDeviceSnapshot,
+ type ManagedDevice,
+ type PansConnectionStateEvent,
+} from "@eight2five/mobile/pans-manager";
+
+import {
+ findDiscovery,
+ isSelectableTagDiscovery,
+ staleLivePosition,
+ type MobilePansSnapshot,
+ type TagConnectionState,
+} from "./mobile-pans-model";
+import type { MobilePansPositionPublisher } from "./mobile-pans-position-publisher";
+import type { MobilePansRuntime } from "./mobile-pans-runtime";
+
+interface ConnectionControllerHost {
+ readonly reconnectDelaysMs: readonly number[];
+ readonly discoveryTimeoutMs: number;
+ readonly schedule: typeof setTimeout;
+ readonly cancel: typeof clearTimeout;
+ readonly positionPublisher: MobilePansPositionPublisher;
+ getRuntime(): MobilePansRuntime | undefined;
+ getRememberedTag(): ManagedDevice | undefined;
+ getDiscoveries(): readonly DiscoveredDeviceSnapshot[];
+ getSnapshot(): MobilePansSnapshot;
+ publish(snapshot: MobilePansSnapshot): void;
+ publishState(
+ state: TagConnectionState,
+ changes?: Partial,
+ ): void;
+ prepareTagForStreaming(): Promise;
+}
+
+/** Coordinates one connection attempt and one bounded reconnect loop. */
+export class MobilePansConnectionController {
+ private connectionGeneration = 0;
+ private reconnectGeneration = 0;
+ private foreground = true;
+ private wantsConnection = false;
+ private connectPromise?: Promise;
+ private reconnectPromise?: Promise;
+ private reconnectTimer?: ReturnType;
+ private reconnectDelayResolve?: () => void;
+ private cancelPendingDiscovery?: () => void;
+ private backgroundShutdown: Promise = Promise.resolve();
+
+ constructor(private readonly host: ConnectionControllerHost) {}
+
+ get shouldReconnect(): boolean {
+ return this.wantsConnection && this.foreground;
+ }
+
+ get isConnecting(): boolean {
+ return Boolean(this.connectPromise);
+ }
+
+ setWantsConnection(value: boolean): void {
+ this.wantsConnection = value;
+ }
+
+ isConnectionCurrent(generation: number): boolean {
+ return (
+ generation === this.connectionGeneration &&
+ this.wantsConnection &&
+ this.foreground
+ );
+ }
+
+ async connect(reconnecting: boolean): Promise {
+ if (
+ !reconnecting &&
+ this.host.getSnapshot().connectionState === "connected"
+ ) {
+ return;
+ }
+ this.wantsConnection = true;
+ this.cancelReconnect();
+ await this.connectOnce(reconnecting);
+ }
+
+ async disconnect(): Promise {
+ this.wantsConnection = false;
+ this.invalidateConnection();
+ this.host.positionPublisher.stopMotion();
+ const runtime = this.requireRuntime();
+ await Promise.allSettled([runtime.stream.stop(), runtime.discovery.stop()]);
+ const rememberedTag = this.host.getRememberedTag();
+ this.host.publishState(rememberedTag ? "disconnected" : "idle", {
+ livePosition: staleLivePosition(
+ this.host.getSnapshot().livePosition,
+ rememberedTag ? "disconnected" : "idle",
+ ),
+ error: undefined,
+ });
+ }
+
+ setForeground(foreground: boolean): void {
+ if (this.foreground === foreground) return;
+ this.foreground = foreground;
+ if (!foreground) {
+ this.invalidateConnection();
+ const runtime = this.host.getRuntime();
+ if (runtime) {
+ this.backgroundShutdown = Promise.allSettled([
+ runtime.stream.stop(),
+ runtime.discovery.stop(),
+ ]);
+ }
+ this.host.positionPublisher.stopMotion();
+ if (this.wantsConnection) {
+ this.host.publishState("reconnecting", {
+ livePosition: staleLivePosition(
+ this.host.getSnapshot().livePosition,
+ "reconnecting",
+ ),
+ });
+ }
+ return;
+ }
+ if (this.wantsConnection && this.host.getRememberedTag()) {
+ void this.resumeAfterBackground();
+ }
+ }
+
+ async pauseForOperation(): Promise {
+ this.connectionGeneration += 1;
+ this.host.positionPublisher.resetStreamState();
+ this.host.positionPublisher.clearLiveMarker();
+ this.host.positionPublisher.stopMotion();
+ this.host.publishState("reconnecting", {
+ livePosition: staleLivePosition(
+ this.host.getSnapshot().livePosition,
+ "reconnecting",
+ ),
+ error: undefined,
+ });
+ await this.requireRuntime().stream.stop();
+ }
+
+ async resumeAfterOperation(): Promise {
+ if (this.shouldReconnect) await this.connectOnce(true);
+ }
+
+ startReconnectLoop(): Promise {
+ if (!this.shouldReconnect || !this.host.getRememberedTag()) {
+ return Promise.resolve();
+ }
+ if (this.reconnectPromise) return this.reconnectPromise;
+ const generation = ++this.reconnectGeneration;
+ const operation = this.runReconnectLoop(generation);
+ const tracked = operation.finally(() => {
+ if (this.reconnectPromise === tracked) this.reconnectPromise = undefined;
+ });
+ this.reconnectPromise = tracked;
+ return tracked;
+ }
+
+ receiveConnectionEvent(event: PansConnectionStateEvent): void {
+ const tag = this.host.getRememberedTag();
+ if (
+ !tag ||
+ normalizeTransportDeviceId(event.deviceId) !==
+ normalizeTransportDeviceId(tag.transportDeviceId) ||
+ event.state !== "disconnected" ||
+ this.host.getSnapshot().connectionState !== "connected"
+ ) {
+ return;
+ }
+ this.host.positionPublisher.clearLiveMarker();
+ this.host.positionPublisher.stopMotion();
+ this.host.publishState(
+ this.wantsConnection ? "reconnecting" : "disconnected",
+ {
+ livePosition: staleLivePosition(
+ this.host.getSnapshot().livePosition,
+ this.wantsConnection ? "reconnecting" : "disconnected",
+ event.reason,
+ ),
+ },
+ );
+ if (this.shouldReconnect) void this.startReconnectLoop();
+ }
+
+ dispose(): void {
+ this.wantsConnection = false;
+ this.invalidateConnection();
+ }
+
+ private async connectOnce(reconnecting: boolean): Promise {
+ if (this.connectPromise) return await this.connectPromise;
+ const generation = ++this.connectionGeneration;
+ const operation = this.performConnect(generation, reconnecting);
+ const tracked = operation.finally(() => {
+ if (this.connectPromise === tracked) this.connectPromise = undefined;
+ });
+ this.connectPromise = tracked;
+ return await tracked;
+ }
+
+ private async performConnect(
+ generation: number,
+ reconnecting: boolean,
+ ): Promise {
+ const runtime = this.requireRuntime();
+ const tag = this.host.getRememberedTag();
+ if (!tag) throw new Error("Select a PANS tag before connecting.");
+ if (!this.foreground) return;
+ const state = reconnecting ? "reconnecting" : "connecting";
+ this.host.publishState(state, { error: undefined });
+ try {
+ const available = await this.ensureDiscovered(tag, generation);
+ if (!this.isConnectionCurrent(generation)) return;
+ this.host.publishState(state);
+ await runtime.discovery.stop();
+ await this.host.prepareTagForStreaming();
+ if (!this.isConnectionCurrent(generation)) return;
+ this.host.positionPublisher.resetStreamState();
+ await runtime.stream.start({
+ deviceId: tag.id,
+ transportDeviceId: available.transportDeviceId,
+ onSample: (sample) =>
+ this.host.positionPublisher.receiveSample(sample, generation),
+ onDiagnostic: (message) =>
+ this.host.positionPublisher.receiveDiagnostic(message, generation),
+ onCounters: (counters) => {
+ if (this.isConnectionCurrent(generation)) {
+ this.host.publish({ ...this.host.getSnapshot(), counters });
+ }
+ },
+ });
+ if (!this.isConnectionCurrent(generation)) {
+ await runtime.stream.stop();
+ return;
+ }
+ void this.host.positionPublisher.startMotion(generation);
+ await runtime.discovery.stop().catch(() => undefined);
+ this.host.publishState("connected", {
+ livePosition: {
+ ...this.host.getSnapshot().livePosition,
+ connectionState: "connected",
+ },
+ error: undefined,
+ });
+ } catch (cause) {
+ await runtime.discovery.stop().catch(() => undefined);
+ if (!this.isConnectionCurrent(generation)) return;
+ const error = normalizeManagerError(cause, {
+ deviceId: tag.id,
+ operation: reconnecting ? "reconnect tag" : "connect tag",
+ });
+ this.host.positionPublisher.clearLiveMarker();
+ this.host.publishState("error", {
+ livePosition: staleLivePosition(
+ this.host.getSnapshot().livePosition,
+ "error",
+ error.message,
+ ),
+ error,
+ });
+ throw error;
+ }
+ }
+
+ private async ensureDiscovered(
+ tag: ManagedDevice,
+ generation: number,
+ ): Promise {
+ const existing = findDiscovery(
+ this.host.getDiscoveries(),
+ tag.transportDeviceId,
+ );
+ if (existing && isSelectableTagDiscovery(existing)) return existing;
+ const runtime = this.requireRuntime();
+ this.host.publishState("scanning");
+ const permission = runtime.discovery.getPermissionStatus();
+ if (permission.bluetooth !== "granted") {
+ await runtime.discovery.requestPermissions();
+ }
+ await runtime.discovery.start();
+ return await new Promise((resolve, reject) => {
+ let settled = false;
+ let subscription: { remove(): void } | undefined;
+ const finish = (action: () => void) => {
+ if (settled) return;
+ settled = true;
+ this.host.cancel(timer);
+ subscription?.remove();
+ if (this.cancelPendingDiscovery === cancelWait) {
+ this.cancelPendingDiscovery = undefined;
+ }
+ action();
+ };
+ const cancelWait = () =>
+ finish(() =>
+ reject(new Error("The connection attempt was cancelled.")),
+ );
+ const timer = this.host.schedule(() => {
+ finish(() =>
+ reject(new Error("The remembered PANS tag was not found nearby.")),
+ );
+ }, this.host.discoveryTimeoutMs);
+ this.cancelPendingDiscovery = cancelWait;
+ subscription = runtime.discovery.subscribe((items) => {
+ if (!this.isConnectionCurrent(generation)) {
+ cancelWait();
+ return;
+ }
+ const match = findDiscovery(items, tag.transportDeviceId);
+ if (!match || !isSelectableTagDiscovery(match)) return;
+ finish(() => resolve(match));
+ });
+ if (settled) subscription.remove();
+ });
+ }
+
+ private async runReconnectLoop(generation: number): Promise {
+ for (
+ let attempt = 0;
+ attempt <= this.host.reconnectDelaysMs.length;
+ attempt += 1
+ ) {
+ if (!this.isReconnectCurrent(generation)) return;
+ if (attempt > 0) {
+ await new Promise((resolve) => {
+ this.reconnectDelayResolve = resolve;
+ this.reconnectTimer = this.host.schedule(
+ () => {
+ this.reconnectTimer = undefined;
+ this.reconnectDelayResolve = undefined;
+ resolve();
+ },
+ this.host.reconnectDelaysMs[attempt - 1],
+ );
+ });
+ if (!this.isReconnectCurrent(generation)) return;
+ }
+ try {
+ await this.connectOnce(true);
+ return;
+ } catch {
+ // connectOnce publishes the bounded error for each attempt.
+ }
+ }
+ }
+
+ private async resumeAfterBackground(): Promise {
+ await this.backgroundShutdown.catch(() => undefined);
+ if (this.shouldReconnect && this.host.getRememberedTag()) {
+ await this.startReconnectLoop();
+ }
+ }
+
+ private invalidateConnection(): void {
+ this.connectionGeneration += 1;
+ this.cancelReconnect();
+ this.cancelPendingDiscovery?.();
+ this.cancelPendingDiscovery = undefined;
+ this.host.positionPublisher.resetStreamState();
+ this.host.positionPublisher.clearLiveMarker();
+ }
+
+ private isReconnectCurrent(generation: number): boolean {
+ return generation === this.reconnectGeneration && this.shouldReconnect;
+ }
+
+ private cancelReconnect(): void {
+ this.reconnectGeneration += 1;
+ if (this.reconnectTimer !== undefined) {
+ this.host.cancel(this.reconnectTimer);
+ }
+ this.reconnectTimer = undefined;
+ const resolve = this.reconnectDelayResolve;
+ this.reconnectDelayResolve = undefined;
+ resolve?.();
+ }
+
+ private requireRuntime(): MobilePansRuntime {
+ const runtime = this.host.getRuntime();
+ if (!runtime) throw new Error("PANS services are not ready.");
+ return runtime;
+ }
+}
diff --git a/apps/mobile/src/pans/mobile-pans-context.tsx b/apps/mobile/src/pans/mobile-pans-context.tsx
new file mode 100644
index 00000000..90a98340
--- /dev/null
+++ b/apps/mobile/src/pans/mobile-pans-context.tsx
@@ -0,0 +1,149 @@
+import React from "react";
+import { AppState, type AppStateStatus } from "react-native";
+import { useSharedValue, type SharedValue } from "react-native-reanimated";
+import type { FieldPoint, FusedPositionOutput } from "@eight2five/mobile/field";
+import {
+ createExpoDeviceMotionAdapter,
+ type DeviceMotionAdapter,
+} from "@eight2five/mobile/motion";
+import type { ManagedDevice } from "@eight2five/mobile/pans-manager";
+
+import { MobilePansStore, type MobilePansSnapshot } from "./mobile-pans-store";
+
+interface MobilePansContextValue {
+ readonly store: MobilePansStore;
+ readonly positionValue: SharedValue;
+ readonly fusionValue: SharedValue;
+}
+
+const MobilePansContext = React.createContext(
+ null,
+);
+
+export function MobilePansProvider({
+ children,
+ store: injectedStore,
+ motionAdapter,
+ motionInterpolationEnabled,
+ developerModeEnabled,
+ appState = AppState,
+}: {
+ readonly children: React.ReactNode;
+ readonly store?: MobilePansStore;
+ readonly motionAdapter?: DeviceMotionAdapter;
+ readonly motionInterpolationEnabled?: boolean;
+ readonly developerModeEnabled?: boolean;
+ readonly appState?: {
+ readonly currentState: AppStateStatus | null;
+ addEventListener(
+ type: "change",
+ listener: (state: AppStateStatus) => void,
+ ): { remove(): void };
+ };
+}) {
+ const [ownedStore] = React.useState(
+ () =>
+ new MobilePansStore({
+ motionAdapter: motionAdapter ?? createExpoDeviceMotionAdapter(),
+ motionInterpolationEnabled: motionInterpolationEnabled ?? false,
+ developerModeEnabled: developerModeEnabled ?? false,
+ }),
+ );
+ const store = injectedStore ?? ownedStore;
+ const positionValue = useSharedValue(null);
+ const fusionValue = useSharedValue(null);
+
+ React.useEffect(() => {
+ if (motionInterpolationEnabled !== undefined) {
+ store.setMotionInterpolationEnabled(motionInterpolationEnabled);
+ }
+ }, [motionInterpolationEnabled, store]);
+
+ React.useEffect(() => {
+ if (developerModeEnabled !== undefined) {
+ void store.setDeveloperModeEnabled(developerModeEnabled);
+ }
+ }, [developerModeEnabled, store]);
+
+ React.useEffect(() => {
+ store.attachPositionValue(positionValue);
+ store.attachFusionValue(fusionValue);
+ store.setForeground(appState.currentState === "active");
+ void store.initialize();
+ const subscription = appState.addEventListener("change", (state) => {
+ store.setForeground(state === "active");
+ });
+ return () => {
+ subscription.remove();
+ void store.dispose();
+ };
+ }, [appState, fusionValue, positionValue, store]);
+
+ const value = React.useMemo(
+ () => ({ store, positionValue, fusionValue }),
+ [fusionValue, positionValue, store],
+ );
+ return (
+
+ {children}
+
+ );
+}
+
+export function useMobilePansStore(): MobilePansStore {
+ return useMobilePansContext().store;
+}
+
+export function useMobilePansSnapshot(): MobilePansSnapshot {
+ const store = useMobilePansStore();
+ return React.useSyncExternalStore(
+ store.subscribe,
+ store.getSnapshot,
+ store.getSnapshot,
+ );
+}
+
+export function useFieldLivePosition() {
+ const context = useMobilePansContext();
+ const livePosition = React.useSyncExternalStore(
+ context.store.subscribe,
+ () => context.store.getSnapshot().livePosition,
+ () => context.store.getSnapshot().livePosition,
+ );
+ return React.useMemo(
+ () => ({
+ state: livePosition,
+ positionValue: context.positionValue,
+ fusionValue: context.fusionValue,
+ }),
+ [context.fusionValue, context.positionValue, livePosition],
+ );
+}
+
+export function useRememberedPansTag(): ManagedDevice | undefined {
+ const store = useMobilePansStore();
+ return React.useSyncExternalStore(
+ store.subscribe,
+ () => store.getSnapshot().rememberedTag,
+ () => store.getSnapshot().rememberedTag,
+ );
+}
+
+export function useKnownPansAnchors(): readonly ManagedDevice[] {
+ const store = useMobilePansStore();
+ return React.useSyncExternalStore(
+ store.subscribe,
+ () => store.getSnapshot().knownAnchors,
+ () => store.getSnapshot().knownAnchors,
+ );
+}
+
+function useMobilePansContext(): MobilePansContextValue {
+ const context = React.useContext(MobilePansContext);
+ if (!context) {
+ throw new Error(
+ "Mobile PANS hooks must be used inside MobilePansProvider.",
+ );
+ }
+ return context;
+}
diff --git a/apps/mobile/src/pans/mobile-pans-device-cache.ts b/apps/mobile/src/pans/mobile-pans-device-cache.ts
new file mode 100644
index 00000000..34c707ba
--- /dev/null
+++ b/apps/mobile/src/pans/mobile-pans-device-cache.ts
@@ -0,0 +1,122 @@
+import {
+ deviceFromDiscovery,
+ normalizeTransportDeviceId,
+ type DiscoveredDeviceSnapshot,
+ type ManagedDevice,
+} from "@eight2five/mobile/pans-manager";
+
+import { createLocalId } from "./mobile-pans-model";
+import type { MobilePansRuntime } from "./mobile-pans-runtime";
+
+export async function persistSelectedTagAndNearbyAnchors(
+ runtime: MobilePansRuntime,
+ discovery: DiscoveredDeviceSnapshot,
+ discoveries: readonly DiscoveredDeviceSnapshot[],
+ now: number,
+): Promise {
+ const devices = await runtime.repository.listDevices();
+ const existingTag = findSavedDevice(devices, discovery);
+ let tag = await runtime.repository.saveDevice({
+ ...deviceFromDiscovery(discovery, existingTag, {
+ id: existingTag?.id ?? createLocalId("tag"),
+ now,
+ }),
+ role: "tag",
+ });
+ const anchors: ManagedDevice[] = [];
+ for (const nearbyAnchor of discoveries.filter(isCurrentCompatibleAnchor)) {
+ const existingAnchor = findSavedDevice(devices, nearbyAnchor);
+ anchors.push(
+ await runtime.repository.saveDevice({
+ ...deviceFromDiscovery(nearbyAnchor, existingAnchor, {
+ id: existingAnchor?.id ?? createLocalId("anchor"),
+ now,
+ }),
+ role: "anchor",
+ }),
+ );
+ }
+
+ // Selection is an explicit refresh boundary. Read each discovered device
+ // once so PAN-based association is verified rather than inferred by proximity.
+ await runtime.discovery.stop();
+ tag = await inspectAndReload(runtime, tag);
+ for (let index = 0; index < anchors.length; index += 1) {
+ anchors[index] = await inspectAndReload(runtime, anchors[index]);
+ }
+
+ const panId = tag.lastKnownConfig?.panId;
+ if (panId === undefined) return tag;
+ const matchingNetworks = (await runtime.repository.listNetworks()).filter(
+ (network) => network.panId === panId,
+ );
+ if (matchingNetworks.length !== 1) return tag;
+ const network = matchingNetworks[0];
+ tag = await runtime.repository.associateDevice({
+ networkId: network.id,
+ deviceId: tag.id,
+ associatedAt: now,
+ });
+ for (const anchor of anchors) {
+ if (anchor.lastKnownConfig?.panId !== panId) continue;
+ await runtime.repository.associateDevice({
+ networkId: network.id,
+ deviceId: anchor.id,
+ associatedAt: now,
+ });
+ }
+ return tag;
+}
+
+export function sortedCachedAnchors(
+ devices: readonly ManagedDevice[],
+): readonly ManagedDevice[] {
+ return devices
+ .filter(
+ (device) =>
+ device.role === "anchor" || device.lastKnownConfig?.role === "anchor",
+ )
+ .sort((left, right) =>
+ (left.nodeIdHex ?? left.label ?? left.id).localeCompare(
+ right.nodeIdHex ?? right.label ?? right.id,
+ ),
+ );
+}
+
+function findSavedDevice(
+ devices: readonly ManagedDevice[],
+ discovery: DiscoveredDeviceSnapshot,
+): ManagedDevice | undefined {
+ const transportId = normalizeTransportDeviceId(discovery.transportDeviceId);
+ return devices.find(
+ (device) =>
+ normalizeTransportDeviceId(device.transportDeviceId) === transportId ||
+ Boolean(
+ discovery.macAddress && device.macAddress === discovery.macAddress,
+ ),
+ );
+}
+
+function isCurrentCompatibleAnchor(
+ discovery: DiscoveredDeviceSnapshot,
+): boolean {
+ return (
+ !discovery.stale &&
+ discovery.compatibility === "compatible" &&
+ discovery.presence?.role === "anchor"
+ );
+}
+
+async function inspectAndReload(
+ runtime: MobilePansRuntime,
+ device: ManagedDevice,
+): Promise {
+ try {
+ await runtime.configuration.inspectAndCache?.(device.id);
+ return (await runtime.repository.getDevice(device.id)) ?? device;
+ } catch {
+ // Keep the discovery record pending; selectors exclude it until a later
+ // explicit refresh verifies network data.
+ return device;
+ }
+}
diff --git a/apps/mobile/src/pans/mobile-pans-model.ts b/apps/mobile/src/pans/mobile-pans-model.ts
new file mode 100644
index 00000000..6d7b2837
--- /dev/null
+++ b/apps/mobile/src/pans/mobile-pans-model.ts
@@ -0,0 +1,139 @@
+import type {
+ DiscoveredDeviceSnapshot,
+ ManagedDevice,
+ ManagerError,
+ PansDiagnosticsResult,
+ PansPosition,
+ PansPositionStreamCounters,
+ ManagedNetwork,
+} from "@eight2five/mobile/pans-manager";
+import {
+ DEFAULT_DISCOVERY_RSSI_CUTOFF,
+ normalizeTransportDeviceId,
+} from "@eight2five/mobile/pans-manager";
+import type {
+ FieldLivePositionState,
+ FieldPoint,
+} from "@eight2five/mobile/field";
+import type { DeviceMotionAdapter } from "@eight2five/mobile/motion";
+
+import type { CreateMobilePansRuntime } from "./mobile-pans-runtime";
+
+export type TagConnectionState =
+ | "idle"
+ | "scanning"
+ | "connecting"
+ | "connected"
+ | "reconnecting"
+ | "disconnected"
+ | "error";
+
+export interface MobilePansSnapshot {
+ readonly initialization: "loading" | "ready" | "error";
+ readonly connectionState: TagConnectionState;
+ readonly rememberedTag?: ManagedDevice;
+ readonly discoveries: readonly DiscoveredDeviceSnapshot[];
+ readonly livePosition: FieldLivePositionState;
+ readonly rawPosition?: Readonly<
+ Pick
+ >;
+ readonly lastUpdateAt?: number;
+ readonly effectiveUpdateRateHz: number;
+ readonly counters?: Readonly;
+ readonly hardwareDiagnostics?: PansDiagnosticsResult;
+ readonly knownAnchors: readonly ManagedDevice[];
+ readonly networks: readonly ManagedNetwork[];
+ readonly activeNetworkId?: string;
+ readonly discoveryRssiCutoff: number;
+ readonly commissioningWarning?: string;
+ readonly diagnosticMessages: readonly string[];
+ readonly error?: ManagerError | Error;
+}
+
+export interface MobilePansStoreOptions {
+ readonly createRuntime?: CreateMobilePansRuntime;
+ readonly motionAdapter?: DeviceMotionAdapter;
+ readonly motionInterpolationEnabled?: boolean;
+ readonly now?: () => number;
+ readonly schedule?: typeof setTimeout;
+ readonly cancel?: typeof clearTimeout;
+ readonly reconnectDelaysMs?: readonly number[];
+ readonly staleAfterMs?: number;
+ readonly discoveryTimeoutMs?: number;
+ readonly developerModeEnabled?: boolean;
+}
+
+export const EMPTY_DISCOVERIES: readonly DiscoveredDeviceSnapshot[] =
+ Object.freeze([]);
+export const DEFAULT_RECONNECT_DELAYS = Object.freeze([500, 1_500, 3_000]);
+
+export const INITIAL_MOBILE_PANS_SNAPSHOT: MobilePansSnapshot = Object.freeze({
+ initialization: "loading",
+ connectionState: "idle",
+ discoveries: EMPTY_DISCOVERIES,
+ livePosition: Object.freeze({
+ connectionState: "idle",
+ isStale: false,
+ interpolationActive: false,
+ }),
+ effectiveUpdateRateHz: 0,
+ diagnosticMessages: Object.freeze([]),
+ knownAnchors: Object.freeze([]),
+ networks: Object.freeze([]),
+ discoveryRssiCutoff: DEFAULT_DISCOVERY_RSSI_CUTOFF,
+});
+
+/**
+ * MVP deployments use an identity-aligned frame: PANS +X is Side 1→Side 2 and
+ * PANS +Y is front→back. A calibrated arbitrary-frame transform is deferred.
+ */
+export function pansPositionToFieldPoint(position: PansPosition): FieldPoint {
+ return { xMeters: position.xMeters, yMeters: position.yMeters };
+}
+
+export function findDiscovery(
+ discoveries: readonly DiscoveredDeviceSnapshot[],
+ transportDeviceId: string,
+): DiscoveredDeviceSnapshot | undefined {
+ const normalized = normalizeTransportDeviceId(transportDeviceId);
+ return discoveries.find(
+ (item) => normalizeTransportDeviceId(item.transportDeviceId) === normalized,
+ );
+}
+
+export function isSelectableTagDiscovery(
+ discovery: DiscoveredDeviceSnapshot,
+): boolean {
+ return (
+ !discovery.stale &&
+ discovery.compatibility === "compatible" &&
+ discovery.presence?.role === "tag"
+ );
+}
+
+export function fieldConnectionState(
+ state: TagConnectionState,
+): FieldLivePositionState["connectionState"] {
+ return state === "scanning" ? "connecting" : state;
+}
+
+export function staleLivePosition(
+ live: FieldLivePositionState,
+ connectionState: FieldLivePositionState["connectionState"],
+ errorMessage?: string,
+): FieldLivePositionState {
+ return {
+ ...live,
+ connectionState,
+ isStale: Boolean(live.position),
+ interpolationActive: false,
+ ...(errorMessage ? { errorMessage } : {}),
+ };
+}
+
+export function createLocalId(prefix: string): string {
+ const uuid = globalThis.crypto?.randomUUID?.();
+ return uuid
+ ? `${prefix}-${uuid}`
+ : `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
+}
diff --git a/apps/mobile/src/pans/mobile-pans-position-publisher.ts b/apps/mobile/src/pans/mobile-pans-position-publisher.ts
new file mode 100644
index 00000000..e4b143ff
--- /dev/null
+++ b/apps/mobile/src/pans/mobile-pans-position-publisher.ts
@@ -0,0 +1,384 @@
+import type { FieldPoint, FusedPositionOutput } from "@eight2five/mobile/field";
+import {
+ ConservativePositionFusion,
+ type DeviceMotionAdapter,
+ type DeviceMotionSample,
+} from "@eight2five/mobile/motion";
+import type { PansPositionStreamSample } from "@eight2five/mobile/pans-manager";
+import type { SharedValue } from "react-native-reanimated";
+
+import {
+ pansPositionToFieldPoint,
+ staleLivePosition,
+ type MobilePansSnapshot,
+} from "./mobile-pans-model";
+
+const HUD_PUBLICATION_INTERVAL_MS = 100;
+
+interface PositionPublisherHost {
+ readonly staleAfterMs: number;
+ readonly schedule: typeof setTimeout;
+ readonly cancel: typeof clearTimeout;
+ isConnectionCurrent(generation: number): boolean;
+ getSnapshot(): MobilePansSnapshot;
+ publish(snapshot: MobilePansSnapshot): void;
+}
+
+export interface MobilePansPositionPublisherOptions {
+ readonly motionAdapter?: DeviceMotionAdapter;
+ readonly motionInterpolationEnabled?: boolean;
+}
+
+/** Publishes high-rate samples to Skia and coalesced human-readable state to React. */
+export class MobilePansPositionPublisher {
+ private positionValue?: SharedValue;
+ private fusionValue?: SharedValue;
+ private staleTimer?: ReturnType;
+ private lastHudPublicationAt = Number.NEGATIVE_INFINITY;
+ private sampleTimes: number[] = [];
+ private readonly fusion = new ConservativePositionFusion();
+ private readonly motionAdapter?: DeviceMotionAdapter;
+ private motionInterpolationEnabled: boolean;
+ private motionSensorActive = false;
+ private activeGeneration?: number;
+ private motionStartToken = 0;
+
+ constructor(
+ private readonly host: PositionPublisherHost,
+ options: MobilePansPositionPublisherOptions = {},
+ ) {
+ this.motionAdapter = options.motionAdapter;
+ this.motionInterpolationEnabled =
+ options.motionInterpolationEnabled ?? Boolean(options.motionAdapter);
+ }
+
+ attachPositionValue(value: SharedValue): void {
+ if (this.positionValue && this.positionValue !== value) {
+ this.positionValue.value = null;
+ }
+ this.positionValue = value;
+ const live = this.host.getSnapshot().livePosition;
+ value.value = live.isStale ? null : (live.position ?? null);
+ }
+
+ attachFusionValue(value: SharedValue): void {
+ if (this.fusionValue && this.fusionValue !== value) {
+ this.fusionValue.value = null;
+ }
+ this.fusionValue = value;
+ const live = this.host.getSnapshot().livePosition;
+ if (live.isStale || !live.position) {
+ value.value = null;
+ return;
+ }
+ const fusedAt = live.receivedAt ?? live.lastUwbAt ?? 0;
+ value.value = {
+ position: live.position,
+ source: live.source ?? "uwb",
+ fusedAt,
+ freshnessMs: live.freshnessMs ?? 0,
+ lastUwbAt: live.lastUwbAt ?? fusedAt,
+ lastUwbPosition: live.lastUwbPosition ?? live.position,
+ interpolationActive: live.interpolationActive ?? false,
+ };
+ }
+
+ setMotionInterpolationEnabled(enabled: boolean): void {
+ if (this.motionInterpolationEnabled === enabled) return;
+ this.motionInterpolationEnabled = enabled;
+ if (!enabled) {
+ this.stopMotion();
+ this.fusion.reset();
+ this.publishUwbOnlyState();
+ return;
+ }
+ if (this.activeGeneration !== undefined) {
+ void this.startMotion(this.activeGeneration);
+ }
+ }
+
+ async startMotion(generation: number): Promise {
+ this.activeGeneration = generation;
+ this.stopMotionSubscription();
+ this.fusion.setMotionSensorActive(false);
+ if (!this.motionInterpolationEnabled || !this.motionAdapter) return;
+
+ const token = ++this.motionStartToken;
+ try {
+ const result = await this.motionAdapter.start((sample) => {
+ if (
+ token !== this.motionStartToken ||
+ this.activeGeneration !== generation ||
+ !this.host.isConnectionCurrent(generation)
+ ) {
+ return;
+ }
+ this.receiveMotionSample(sample, generation);
+ });
+ if (
+ token !== this.motionStartToken ||
+ this.activeGeneration !== generation ||
+ !this.host.isConnectionCurrent(generation) ||
+ !this.motionInterpolationEnabled
+ ) {
+ try {
+ this.motionAdapter.stop();
+ } catch {
+ // A best-effort cleanup must not turn optional motion into a
+ // connection failure.
+ }
+ return;
+ }
+ this.motionSensorActive = result === "active";
+ this.fusion.setMotionSensorActive(this.motionSensorActive);
+ } catch {
+ // Sensor permission/availability is optional. UWB continues as the sole
+ // source when a phone cannot provide DeviceMotion.
+ this.motionSensorActive = false;
+ this.fusion.setMotionSensorActive(false);
+ }
+ }
+
+ stopMotion(): void {
+ ++this.motionStartToken;
+ this.activeGeneration = undefined;
+ this.stopMotionSubscription();
+ this.fusion.setMotionSensorActive(false);
+ }
+
+ receiveSample(sample: PansPositionStreamSample, generation: number): void {
+ if (!this.host.isConnectionCurrent(generation) || !sample.position) return;
+ const receivedAt = sample.receivedAt;
+ const fieldPoint = pansPositionToFieldPoint(sample.position);
+ if (!this.motionInterpolationEnabled) {
+ this.sampleTimes = this.sampleTimes.filter(
+ (time) => receivedAt - time <= 1_000,
+ );
+ this.sampleTimes.push(receivedAt);
+ this.publishOutput(
+ createRawUwbOutput(fieldPoint, receivedAt),
+ generation,
+ {
+ acceptedUwb: true,
+ rawPosition: {
+ xMeters: sample.position.xMeters,
+ yMeters: sample.position.yMeters,
+ zMeters: sample.position.zMeters,
+ },
+ },
+ );
+ return;
+ }
+ const output = this.fusion.acceptUwb({
+ position: fieldPoint,
+ receivedAt,
+ });
+ if (!output) return;
+
+ const acceptedUwb = output.lastUwbAt === receivedAt;
+ if (acceptedUwb) {
+ this.sampleTimes = this.sampleTimes.filter(
+ (time) => receivedAt - time <= 1_000,
+ );
+ this.sampleTimes.push(receivedAt);
+ }
+ this.publishOutput(output, generation, {
+ acceptedUwb,
+ rawPosition: {
+ xMeters: sample.position.xMeters,
+ yMeters: sample.position.yMeters,
+ zMeters: sample.position.zMeters,
+ },
+ });
+ }
+
+ private receiveMotionSample(
+ sample: DeviceMotionSample,
+ generation: number,
+ ): void {
+ const output = this.fusion.acceptMotion(sample);
+ if (!output) return;
+ this.publishOutput(output, generation, { acceptedUwb: false });
+ }
+
+ receiveDiagnostic(message: string, generation: number): void {
+ if (!this.host.isConnectionCurrent(generation)) return;
+ const snapshot = this.host.getSnapshot();
+ this.host.publish({
+ ...snapshot,
+ diagnosticMessages: [...snapshot.diagnosticMessages, message].slice(-8),
+ });
+ }
+
+ resetStreamState(): void {
+ this.stopMotion();
+ this.fusion.reset();
+ this.sampleTimes = [];
+ this.lastHudPublicationAt = Number.NEGATIVE_INFINITY;
+ this.cancelStaleTimer();
+ const snapshot = this.host.getSnapshot();
+ if (snapshot.effectiveUpdateRateHz !== 0 || snapshot.counters) {
+ this.host.publish({
+ ...snapshot,
+ effectiveUpdateRateHz: 0,
+ counters: undefined,
+ });
+ }
+ }
+
+ clearLiveMarker(): void {
+ if (this.positionValue) this.positionValue.value = null;
+ if (this.fusionValue) this.fusionValue.value = null;
+ }
+
+ dispose(): void {
+ this.stopMotion();
+ this.cancelStaleTimer();
+ this.clearLiveMarker();
+ this.positionValue = undefined;
+ this.fusionValue = undefined;
+ }
+
+ private publishOutput(
+ output: FusedPositionOutput,
+ generation: number,
+ options: {
+ readonly acceptedUwb: boolean;
+ readonly rawPosition?: MobilePansSnapshot["rawPosition"];
+ },
+ ): void {
+ if (!this.host.isConnectionCurrent(generation)) return;
+ const fieldPoint = output.position;
+ if (this.positionValue) this.positionValue.value = fieldPoint;
+ if (this.fusionValue) this.fusionValue.value = output;
+ this.scheduleStale(generation, output.freshnessMs);
+ if (
+ output.fusedAt - this.lastHudPublicationAt <
+ HUD_PUBLICATION_INTERVAL_MS
+ ) {
+ return;
+ }
+ this.lastHudPublicationAt = output.fusedAt;
+ const snapshot = this.host.getSnapshot();
+ this.host.publish({
+ ...snapshot,
+ connectionState: "connected",
+ livePosition: {
+ connectionState: "connected",
+ position: fieldPoint,
+ receivedAt: output.fusedAt,
+ isStale: false,
+ source: output.source,
+ freshnessMs: output.freshnessMs,
+ lastUwbAt: output.lastUwbAt,
+ lastUwbPosition: output.lastUwbPosition,
+ interpolationActive: output.interpolationActive,
+ },
+ ...(options.acceptedUwb && options.rawPosition
+ ? { rawPosition: options.rawPosition }
+ : {}),
+ lastUpdateAt: output.fusedAt,
+ effectiveUpdateRateHz: effectiveRate(this.sampleTimes),
+ error: undefined,
+ });
+ }
+
+ private scheduleStale(generation: number, freshnessMs: number): void {
+ this.cancelStaleTimer();
+ this.staleTimer = this.host.schedule(
+ () => {
+ this.staleTimer = undefined;
+ if (!this.host.isConnectionCurrent(generation)) return;
+ this.clearLiveMarker();
+ const snapshot = this.host.getSnapshot();
+ this.host.publish({
+ ...snapshot,
+ livePosition: staleLivePosition(
+ snapshot.livePosition,
+ snapshot.connectionState === "connected"
+ ? "connected"
+ : "reconnecting",
+ ),
+ });
+ },
+ Math.max(0, this.host.staleAfterMs - Math.max(0, freshnessMs)),
+ );
+ }
+
+ private cancelStaleTimer(): void {
+ if (this.staleTimer !== undefined) this.host.cancel(this.staleTimer);
+ this.staleTimer = undefined;
+ }
+
+ private stopMotionSubscription(): void {
+ if (!this.motionSensorActive && !this.motionAdapter) return;
+ try {
+ this.motionAdapter?.stop();
+ } catch {
+ // Sensor cleanup is best effort; UWB remains independently usable.
+ }
+ this.motionSensorActive = false;
+ }
+
+ private publishUwbOnlyState(): void {
+ const snapshot = this.host.getSnapshot();
+ const live = snapshot.livePosition;
+ if (!live.position) {
+ if (this.fusionValue) this.fusionValue.value = null;
+ return;
+ }
+ const position = live.lastUwbPosition ?? live.position;
+ const lastUwbAt = live.lastUwbAt ?? live.receivedAt ?? 0;
+ const fusedAt = live.receivedAt ?? lastUwbAt;
+ const freshnessMs =
+ live.lastUwbAt !== undefined ? Math.max(0, fusedAt - live.lastUwbAt) : 0;
+ const output: FusedPositionOutput = {
+ position,
+ source: "uwb",
+ fusedAt,
+ freshnessMs,
+ lastUwbAt,
+ lastUwbPosition: position,
+ interpolationActive: false,
+ };
+ if (this.positionValue)
+ this.positionValue.value = live.isStale ? null : position;
+ if (this.fusionValue) this.fusionValue.value = live.isStale ? null : output;
+ this.host.publish({
+ ...snapshot,
+ livePosition: {
+ ...live,
+ position,
+ receivedAt: fusedAt,
+ source: "uwb",
+ freshnessMs,
+ lastUwbAt: output.lastUwbAt,
+ lastUwbPosition: position,
+ interpolationActive: false,
+ isStale: live.isStale,
+ },
+ lastUpdateAt: fusedAt,
+ });
+ }
+}
+
+function effectiveRate(sampleTimes: readonly number[]): number {
+ if (sampleTimes.length < 2) return 0;
+ const elapsedMs = sampleTimes[sampleTimes.length - 1] - sampleTimes[0];
+ return elapsedMs > 0 ? ((sampleTimes.length - 1) * 1_000) / elapsedMs : 0;
+}
+
+function createRawUwbOutput(
+ position: FieldPoint,
+ receivedAt: number,
+): FusedPositionOutput {
+ return {
+ position,
+ source: "uwb",
+ fusedAt: receivedAt,
+ freshnessMs: 0,
+ lastUwbAt: receivedAt,
+ lastUwbPosition: position,
+ interpolationActive: false,
+ };
+}
diff --git a/apps/mobile/src/pans/mobile-pans-runtime.ts b/apps/mobile/src/pans/mobile-pans-runtime.ts
new file mode 100644
index 00000000..d5097831
--- /dev/null
+++ b/apps/mobile/src/pans/mobile-pans-runtime.ts
@@ -0,0 +1,67 @@
+import type {
+ PansConfigurationService,
+ PansCommissioningService,
+ PansDeviceSessionManager,
+ PansDiagnosticsService,
+ PansDiscoveryService,
+ PansManagerRepository,
+ PansPositionStreamService,
+} from "@eight2five/mobile/pans-manager";
+
+export interface MobilePansRuntime {
+ readonly repository: PansManagerRepository;
+ readonly discovery: PansDiscoveryService;
+ readonly sessions: PansDeviceSessionManager;
+ readonly stream: PansPositionStreamService;
+ readonly configuration: PansConfigurationService;
+ readonly commissioning: PansCommissioningService;
+ readonly diagnostics: PansDiagnosticsService;
+ close(): Promise;
+}
+
+export type CreateMobilePansRuntime = () => Promise;
+
+export const createDefaultMobilePansRuntime: CreateMobilePansRuntime =
+ async () => {
+ // The native module is unavailable in Expo Go and registry-style tests.
+ // Keep it lazy so the app can surface initialization failure safely.
+ const manager = await import("@eight2five/mobile/pans-manager");
+ const storage = await manager.openPansManagerRepository();
+ try {
+ await storage.repository.initialize();
+ const settings = manager.normalizePansManagerSettings(
+ await storage.repository.getSettings(),
+ );
+ const discovery = new manager.PansDiscoveryService(undefined, {
+ staleAfterMs: settings.discoveryStaleAfterMs,
+ });
+ const sessions = new manager.PansDeviceSessionManager(
+ undefined,
+ settings.connectionTimeoutMs,
+ );
+ const configuration = new manager.PansConfigurationService(
+ sessions,
+ storage.repository,
+ );
+ return {
+ repository: storage.repository,
+ discovery,
+ sessions,
+ stream: new manager.PansPositionStreamService(sessions),
+ configuration,
+ commissioning: new manager.PansCommissioningService(
+ storage.repository,
+ configuration,
+ ),
+ diagnostics: new manager.PansDiagnosticsService(sessions),
+ close: async () => {
+ await discovery.stop().catch(() => undefined);
+ await sessions.closeAll().catch(() => undefined);
+ await storage.close();
+ },
+ };
+ } catch (error) {
+ await storage.close();
+ throw error;
+ }
+ };
diff --git a/apps/mobile/src/pans/mobile-pans-store.ts b/apps/mobile/src/pans/mobile-pans-store.ts
new file mode 100644
index 00000000..cf05822f
--- /dev/null
+++ b/apps/mobile/src/pans/mobile-pans-store.ts
@@ -0,0 +1,1103 @@
+import {
+ assertNetworkProfilePanId,
+ assertUniqueName,
+ assertValidLabel,
+ DEFAULT_MANAGED_NETWORK_SETTINGS,
+ DEFAULT_DISCOVERY_RSSI_CUTOFF,
+ deviceFromDiscovery,
+ diffPerformerTagProfile,
+ MAX_DISCOVERY_RSSI_CUTOFF,
+ MIN_DISCOVERY_RSSI_CUTOFF,
+ normalizeManagerError,
+ normalizePansManagerSettings,
+ normalizeTransportDeviceId,
+ type DiscoveredDeviceSnapshot,
+ type ManagedDevice,
+ type ManagedNetwork,
+ ManagerError,
+ type PansManagerSettings,
+ type PansDiagnosticsResult,
+} from "@eight2five/mobile/pans-manager";
+import type {
+ AnchorFieldPosition,
+ FieldPoint,
+ FusedPositionOutput,
+} from "@eight2five/mobile/field";
+import type { SharedValue } from "react-native-reanimated";
+
+import {
+ createDefaultMobilePansRuntime,
+ type CreateMobilePansRuntime,
+ type MobilePansRuntime,
+} from "./mobile-pans-runtime";
+import {
+ DEFAULT_RECONNECT_DELAYS,
+ EMPTY_DISCOVERIES,
+ fieldConnectionState,
+ INITIAL_MOBILE_PANS_SNAPSHOT,
+ isSelectableTagDiscovery,
+ createLocalId,
+ type MobilePansSnapshot,
+ type MobilePansStoreOptions,
+ type TagConnectionState,
+} from "./mobile-pans-model";
+import { MobilePansPositionPublisher } from "./mobile-pans-position-publisher";
+import { MobilePansConnectionController } from "./mobile-pans-connection-controller";
+import { areDevicesNetworkAssociated } from "./pans-anchor-cache";
+import {
+ persistSelectedTagAndNearbyAnchors,
+ sortedCachedAnchors,
+} from "./mobile-pans-device-cache";
+
+export {
+ pansPositionToFieldPoint,
+ type MobilePansSnapshot,
+ type MobilePansStoreOptions,
+ type TagConnectionState,
+} from "./mobile-pans-model";
+
+/**
+ * Composes the one production PANS runtime, connection controller, position
+ * publisher, persistent device cache, and low-rate React snapshot.
+ */
+export class MobilePansStore {
+ private snapshot: MobilePansSnapshot = INITIAL_MOBILE_PANS_SNAPSHOT;
+ private readonly listeners = new Set<() => void>();
+ private readonly createRuntime: CreateMobilePansRuntime;
+ private readonly now: () => number;
+ private readonly schedule: typeof setTimeout;
+ private readonly cancel: typeof clearTimeout;
+ private readonly reconnectDelaysMs: readonly number[];
+ private readonly staleAfterMs: number;
+ private readonly discoveryTimeoutMs: number;
+ private runtime?: MobilePansRuntime;
+ private readonly positionPublisher: MobilePansPositionPublisher;
+ private readonly connectionController: MobilePansConnectionController;
+ private lifecycleGeneration = 0;
+ private discoverySubscription?: { remove(): void };
+ private discoveryErrorSubscription?: { remove(): void };
+ private discoveryStateSubscription?: { remove(): void };
+ private connectionSubscription?: { remove(): void };
+ private settings?: PansManagerSettings;
+ private rememberedTag?: ManagedDevice;
+ private discoveries: readonly DiscoveredDeviceSnapshot[] = EMPTY_DISCOVERIES;
+ private anchorWritePromise?: Promise;
+ private hardwareOperationPromise?: Promise;
+ private manualDiscoveryRequested = false;
+ private developerModeEnabled: boolean;
+
+ constructor(options: MobilePansStoreOptions = {}) {
+ this.createRuntime =
+ options.createRuntime ?? createDefaultMobilePansRuntime;
+ this.now = options.now ?? Date.now;
+ this.schedule = options.schedule ?? setTimeout;
+ this.cancel = options.cancel ?? clearTimeout;
+ this.reconnectDelaysMs =
+ options.reconnectDelaysMs ?? DEFAULT_RECONNECT_DELAYS;
+ this.staleAfterMs = options.staleAfterMs ?? 2_500;
+ this.discoveryTimeoutMs = options.discoveryTimeoutMs ?? 10_000;
+ this.developerModeEnabled = options.developerModeEnabled ?? false;
+ this.positionPublisher = new MobilePansPositionPublisher(
+ {
+ staleAfterMs: this.staleAfterMs,
+ schedule: this.schedule,
+ cancel: this.cancel,
+ isConnectionCurrent: (generation) =>
+ this.connectionController.isConnectionCurrent(generation),
+ getSnapshot: this.getSnapshot,
+ publish: (snapshot) => this.publish(snapshot),
+ },
+ {
+ motionAdapter: options.motionAdapter,
+ motionInterpolationEnabled: options.motionInterpolationEnabled,
+ },
+ );
+ this.connectionController = new MobilePansConnectionController({
+ reconnectDelaysMs: this.reconnectDelaysMs,
+ discoveryTimeoutMs: this.discoveryTimeoutMs,
+ schedule: this.schedule,
+ cancel: this.cancel,
+ positionPublisher: this.positionPublisher,
+ getRuntime: () => this.runtime,
+ getRememberedTag: () => this.rememberedTag,
+ getDiscoveries: () => this.discoveries,
+ getSnapshot: this.getSnapshot,
+ publish: (snapshot) => this.publish(snapshot),
+ publishState: (state, changes) => this.publishState(state, changes),
+ prepareTagForStreaming: () => this.prepareSelectedTagForStreaming(),
+ });
+ }
+
+ readonly getSnapshot = (): MobilePansSnapshot => this.snapshot;
+
+ readonly subscribe = (listener: () => void): (() => void) => {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ };
+
+ attachPositionValue(value: SharedValue): void {
+ this.positionPublisher.attachPositionValue(value);
+ }
+
+ attachFusionValue(value: SharedValue): void {
+ this.positionPublisher.attachFusionValue(value);
+ }
+
+ setMotionInterpolationEnabled(enabled: boolean): void {
+ this.positionPublisher.setMotionInterpolationEnabled(enabled);
+ }
+
+ async setDeveloperModeEnabled(enabled: boolean): Promise {
+ this.developerModeEnabled = enabled;
+ if (
+ !enabled &&
+ this.runtime &&
+ this.snapshot.initialization === "ready" &&
+ this.settings?.discoveryRssiCutoff !== DEFAULT_DISCOVERY_RSSI_CUTOFF
+ ) {
+ await this.setDiscoveryRssiCutoff(DEFAULT_DISCOVERY_RSSI_CUTOFF, true);
+ }
+ }
+
+ async setDiscoveryRssiCutoff(
+ cutoff: number,
+ productionReset = false,
+ ): Promise {
+ if (!productionReset && !this.developerModeEnabled) {
+ throw new Error("Developer Mode is required to change signal filtering.");
+ }
+ if (
+ !Number.isInteger(cutoff) ||
+ cutoff < MIN_DISCOVERY_RSSI_CUTOFF ||
+ cutoff > MAX_DISCOVERY_RSSI_CUTOFF
+ ) {
+ throw new Error(
+ `Signal cutoff must be an integer from ${MIN_DISCOVERY_RSSI_CUTOFF} to ${MAX_DISCOVERY_RSSI_CUTOFF} dBm.`,
+ );
+ }
+ await this.saveManagerSettings({ discoveryRssiCutoff: cutoff });
+ this.publish({ ...this.snapshot, discoveryRssiCutoff: cutoff });
+ }
+
+ async initialize(): Promise {
+ const generation = ++this.lifecycleGeneration;
+ this.publish(INITIAL_MOBILE_PANS_SNAPSHOT);
+ try {
+ const runtime = await this.createRuntime();
+ if (generation !== this.lifecycleGeneration) {
+ await runtime.close();
+ return;
+ }
+ this.runtime = runtime;
+ this.settings = normalizePansManagerSettings(
+ await runtime.repository.getSettings(),
+ );
+ if (
+ !this.developerModeEnabled &&
+ this.settings.discoveryRssiCutoff !== DEFAULT_DISCOVERY_RSSI_CUTOFF
+ ) {
+ this.settings = normalizePansManagerSettings({
+ ...this.settings,
+ discoveryRssiCutoff: DEFAULT_DISCOVERY_RSSI_CUTOFF,
+ });
+ await runtime.repository.saveSettings(this.settings);
+ }
+ this.rememberedTag = this.settings.rememberedTagDeviceId
+ ? await runtime.repository.getDevice(
+ this.settings.rememberedTagDeviceId,
+ )
+ : undefined;
+ const [devices, networks] = await Promise.all([
+ runtime.repository.listDevices(),
+ runtime.repository.listNetworks(),
+ ]);
+ const knownAnchors = sortedCachedAnchors(devices);
+ if (!this.rememberedTag && this.settings.rememberedTagDeviceId) {
+ await this.saveRememberedTag(undefined);
+ }
+ if (
+ this.settings.activeNetworkId &&
+ !networks.some(
+ (network) => network.id === this.settings?.activeNetworkId,
+ )
+ ) {
+ await this.saveManagerSettings({ activeNetworkId: undefined });
+ }
+ this.installRuntimeListeners(runtime, generation);
+ this.connectionController.setWantsConnection(Boolean(this.rememberedTag));
+ this.publishState(this.rememberedTag ? "disconnected" : "idle", {
+ initialization: "ready",
+ knownAnchors,
+ networks,
+ activeNetworkId: this.settings.activeNetworkId,
+ discoveryRssiCutoff: this.settings.discoveryRssiCutoff,
+ });
+ void this.connectionController.startReconnectLoop();
+ } catch (cause) {
+ if (generation !== this.lifecycleGeneration) return;
+ this.publish({
+ ...INITIAL_MOBILE_PANS_SNAPSHOT,
+ initialization: "error",
+ connectionState: "error",
+ livePosition: {
+ connectionState: "error",
+ isStale: false,
+ interpolationActive: false,
+ },
+ error: normalizeManagerError(cause, { operation: "initialize" }),
+ });
+ }
+ }
+
+ async startDiscovery(): Promise {
+ const runtime = this.requireRuntime();
+ if (this.snapshot.connectionState === "connected") {
+ throw new Error(
+ "Disconnect the current tag before discovering another tag.",
+ );
+ }
+ this.manualDiscoveryRequested = true;
+ this.publishState("scanning", { error: undefined });
+ try {
+ const permission = runtime.discovery.getPermissionStatus();
+ if (permission.bluetooth !== "granted") {
+ await runtime.discovery.requestPermissions();
+ }
+ await runtime.discovery.start();
+ } catch (cause) {
+ this.manualDiscoveryRequested = false;
+ const error = normalizeManagerError(cause, { operation: "discover tag" });
+ this.publishState("error", { error });
+ throw error;
+ }
+ }
+
+ async startTagDiscovery(): Promise {
+ if (this.snapshot.connectionState === "connected") return;
+ await this.startDiscovery();
+ }
+
+ async stopDiscovery(): Promise {
+ this.manualDiscoveryRequested = false;
+ const runtime = this.runtime;
+ if (!runtime) return;
+ await runtime.discovery.stop();
+ if (this.snapshot.connectionState === "scanning") {
+ this.publishState(this.rememberedTag ? "disconnected" : "idle");
+ }
+ }
+
+ stopManualDiscovery(): void {
+ if (this.manualDiscoveryRequested) void this.stopDiscovery();
+ }
+
+ async selectTag(transportDeviceId: string): Promise {
+ const runtime = this.requireRuntime();
+ const discovery = this.discoveries.find(
+ (item) => item.transportDeviceId === transportDeviceId,
+ );
+ if (!discovery) throw new Error("The selected tag is no longer available.");
+ if (!isSelectableTagDiscovery(discovery)) {
+ throw new Error("Select a compatible, current PANS tag advertisement.");
+ }
+ const saved = await persistSelectedTagAndNearbyAnchors(
+ runtime,
+ discovery,
+ this.discoveries,
+ this.now(),
+ );
+ this.rememberedTag = saved;
+ await this.saveRememberedTag(saved.id);
+ this.connectionController.setWantsConnection(true);
+ await this.refreshCachedAnchors();
+ this.publishState("disconnected", {
+ rememberedTag: saved,
+ error: undefined,
+ });
+ }
+
+ async selectConfigureAndConnectTag(transportDeviceId: string): Promise {
+ await this.selectTag(transportDeviceId);
+ await this.stopDiscovery();
+ await this.connect();
+ }
+
+ async connect(): Promise {
+ await this.connectionController.connect(false);
+ }
+
+ async reconnect(): Promise {
+ await this.connectionController.connect(true);
+ }
+
+ async disconnect(): Promise {
+ await this.connectionController.disconnect();
+ }
+
+ async forgetTag(): Promise {
+ await this.disconnect();
+ this.rememberedTag = undefined;
+ await this.saveRememberedTag(undefined);
+ this.positionPublisher.resetStreamState();
+ this.publish({
+ ...this.snapshot,
+ connectionState: "idle",
+ rememberedTag: undefined,
+ livePosition: {
+ connectionState: "idle",
+ isStale: false,
+ interpolationActive: false,
+ },
+ rawPosition: undefined,
+ lastUpdateAt: undefined,
+ effectiveUpdateRateHz: 0,
+ error: undefined,
+ });
+ }
+
+ async clearSelectedTag(): Promise {
+ await this.forgetTag();
+ }
+
+ async renameSelectedTag(label: string): Promise {
+ if (!this.developerModeEnabled) {
+ throw new Error("Developer Mode is required to rename a tag.");
+ }
+ assertValidLabel(label);
+ const runtime = this.requireRuntime();
+ const tag = this.rememberedTag;
+ if (!tag) throw new Error("Select a tag before changing its name.");
+ await this.runHardwareOperation(async () => {
+ const result = await runtime.configuration.applyConfigurationDiff(
+ tag.id,
+ {
+ label,
+ },
+ );
+ if (
+ result.error ||
+ result.writes.some(
+ (write) => write.status === "failed" || write.status === "mismatch",
+ )
+ ) {
+ throw new Error(
+ result.error?.message ?? "The tag name could not be verified.",
+ );
+ }
+ this.rememberedTag = (await runtime.repository.getDevice(tag.id)) ?? tag;
+ this.publish({ ...this.snapshot, rememberedTag: this.rememberedTag });
+ });
+ }
+
+ async createNetwork(name: string, panId: number): Promise {
+ const runtime = this.requireDeveloperRuntime();
+ const networks = await runtime.repository.listNetworks();
+ assertUniqueName(
+ name,
+ networks.map((network) => network.name),
+ );
+ assertNetworkProfilePanId(panId);
+ if (networks.some((network) => network.panId === panId)) {
+ throw new Error("A network with this PAN ID already exists.");
+ }
+ const now = this.now();
+ const network = await runtime.repository.saveNetwork({
+ id: createLocalId("network"),
+ name: name.trim(),
+ panId,
+ settings: DEFAULT_MANAGED_NETWORK_SETTINGS,
+ createdAt: now,
+ updatedAt: now,
+ });
+ await this.refreshNetworksAndDevices();
+ return network;
+ }
+
+ async updateNetwork(
+ networkId: string,
+ changes: { readonly name: string; readonly panId: number },
+ ): Promise {
+ const runtime = this.requireDeveloperRuntime();
+ const [network, networks] = await Promise.all([
+ runtime.repository.getNetwork(networkId),
+ runtime.repository.listNetworks(),
+ ]);
+ if (!network) throw new Error("The selected network no longer exists.");
+ assertUniqueName(
+ changes.name,
+ networks.filter((item) => item.id !== networkId).map((item) => item.name),
+ );
+ assertNetworkProfilePanId(changes.panId);
+ if (
+ networks.some(
+ (item) => item.id !== networkId && item.panId === changes.panId,
+ )
+ ) {
+ throw new Error("A network with this PAN ID already exists.");
+ }
+ // Profile edits are app-local. Physical nodes change only via explicit assignment.
+ const saved = await runtime.repository.saveNetwork({
+ ...network,
+ name: changes.name.trim(),
+ panId: changes.panId,
+ updatedAt: this.now(),
+ });
+ await this.refreshNetworksAndDevices();
+ return saved;
+ }
+
+ async deleteNetwork(networkId: string): Promise {
+ const runtime = this.requireDeveloperRuntime();
+ const devices = await runtime.repository.listNetworkDevices(networkId);
+ for (const device of devices) {
+ await runtime.repository.dissociateDevice(
+ networkId,
+ device.id,
+ this.now(),
+ );
+ }
+ await runtime.repository.deleteNetwork(networkId);
+ if (this.settings?.activeNetworkId === networkId) {
+ await this.saveManagerSettings({ activeNetworkId: undefined });
+ }
+ await this.refreshNetworksAndDevices();
+ }
+
+ async setActiveNetwork(networkId: string | undefined): Promise {
+ const runtime = this.requireDeveloperRuntime();
+ if (networkId && !(await runtime.repository.getNetwork(networkId))) {
+ throw new Error("The selected network no longer exists.");
+ }
+ await this.saveManagerSettings({ activeNetworkId: networkId });
+ this.publish({ ...this.snapshot, activeNetworkId: networkId });
+ }
+
+ async persistDiscoveredAnchor(
+ transportDeviceId: string,
+ confirmRoleChange = false,
+ ): Promise {
+ const runtime = this.requireDeveloperRuntime();
+ const discovery = this.discoveries.find(
+ (item) => item.transportDeviceId === transportDeviceId,
+ );
+ if (
+ !discovery ||
+ discovery.stale ||
+ discovery.compatibility !== "compatible"
+ ) {
+ throw new Error("The selected device is no longer available.");
+ }
+ const advertisedRole = discovery.presence?.role;
+ if (advertisedRole !== "anchor" && !confirmRoleChange) {
+ throw new Error("Confirm changing this device from a tag to an anchor.");
+ }
+ const devices = await runtime.repository.listDevices();
+ const existing = devices.find(
+ (device) => device.transportDeviceId === transportDeviceId,
+ );
+ let saved = await runtime.repository.saveDevice({
+ ...deviceFromDiscovery(discovery, existing, {
+ id: existing?.id ?? createLocalId("anchor"),
+ now: this.now(),
+ }),
+ role: advertisedRole ?? existing?.role,
+ });
+ await runtime.discovery.stop();
+ await this.runHardwareOperation(async () => {
+ const inspection = await runtime.configuration.inspectAndCache(saved.id);
+ if (inspection.operationMode.role !== "anchor") {
+ if (!confirmRoleChange) {
+ throw new Error(
+ "Confirm changing this device from a tag to an anchor.",
+ );
+ }
+ const result = await runtime.configuration.applyConfigurationDiff(
+ saved.id,
+ {
+ role: "anchor",
+ uwbMode: "active",
+ ledEnabled: true,
+ firmwareUpdateEnabled: true,
+ initiatorEnabled: false,
+ },
+ );
+ if (result.error || result.inspected?.operationMode.role !== "anchor") {
+ throw new Error(
+ result.error?.message ?? "The anchor role could not be verified.",
+ );
+ }
+ const reinspection = await runtime.configuration.inspectAndCache(
+ saved.id,
+ );
+ if (reinspection.operationMode.role !== "anchor") {
+ throw new Error(
+ "The anchor role did not persist after reconnecting.",
+ );
+ }
+ }
+ if (this.settings?.activeNetworkId) {
+ const assignment =
+ await runtime.commissioning.assignDeviceToNetworkProfile({
+ deviceId: saved.id,
+ targetNetworkId: this.settings.activeNetworkId,
+ });
+ if (assignment.outcome !== "assigned") {
+ throw new Error(
+ assignment.error?.message ??
+ "The anchor network could not be verified.",
+ );
+ }
+ }
+ });
+ saved = (await runtime.repository.getDevice(saved.id)) ?? saved;
+ await this.refreshNetworksAndDevices();
+ return saved;
+ }
+
+ async assignDeviceToActiveNetwork(deviceId: string): Promise {
+ const runtime = this.requireDeveloperRuntime();
+ const targetNetworkId = this.settings?.activeNetworkId;
+ if (!targetNetworkId) throw new Error("Select an active network first.");
+ await this.runHardwareOperation(async () => {
+ const result = await runtime.commissioning.assignDeviceToNetworkProfile({
+ deviceId,
+ targetNetworkId,
+ });
+ if (result.outcome !== "assigned") {
+ throw new Error(result.error?.message ?? "Network assignment failed.");
+ }
+ });
+ await this.refreshNetworksAndDevices();
+ }
+
+ async setAnchorInitiator(anchorId: string): Promise {
+ const runtime = this.requireDeveloperRuntime();
+ const activeNetworkId = this.settings?.activeNetworkId;
+ if (!activeNetworkId) throw new Error("Select an active network first.");
+ const anchors = (
+ await runtime.repository.listNetworkDevices(activeNetworkId)
+ ).filter(
+ (device) =>
+ device.lastKnownConfig?.role === "anchor" || device.role === "anchor",
+ );
+ const selected = anchors.find((anchor) => anchor.id === anchorId);
+ if (!selected)
+ throw new Error("The selected anchor is not in the active network.");
+ const unreachable: string[] = [];
+ await this.runHardwareOperation(async () => {
+ const setResult = await runtime.configuration.applyConfigurationDiff(
+ anchorId,
+ {
+ initiatorEnabled: true,
+ },
+ );
+ if (
+ setResult.error ||
+ setResult.writes.some(
+ (write) => write.status === "mismatch" || write.status === "failed",
+ )
+ ) {
+ throw new Error(
+ setResult.error?.message ?? "Initiator readback failed.",
+ );
+ }
+ for (const prior of anchors.filter((anchor) => anchor.id !== anchorId)) {
+ const reachable = this.discoveries.some(
+ (item) =>
+ !item.stale &&
+ normalizeTransportDeviceId(item.transportDeviceId) ===
+ normalizeTransportDeviceId(prior.transportDeviceId),
+ );
+ if (!reachable) {
+ unreachable.push(
+ prior.lastKnownConfig?.label ?? prior.label ?? prior.id,
+ );
+ continue;
+ }
+ const clearResult = await runtime.configuration.applyConfigurationDiff(
+ prior.id,
+ { initiatorEnabled: false },
+ );
+ if (
+ clearResult.error ||
+ clearResult.writes.some(
+ (write) => write.status === "mismatch" || write.status === "failed",
+ )
+ ) {
+ unreachable.push(
+ prior.lastKnownConfig?.label ?? prior.label ?? prior.id,
+ );
+ }
+ }
+ const verification =
+ await runtime.configuration.inspectAndCache(anchorId);
+ if (
+ verification.operationMode.role !== "anchor" ||
+ !verification.operationMode.initiatorEnabled
+ ) {
+ throw new Error("The selected initiator could not be verified.");
+ }
+ });
+ await this.refreshNetworksAndDevices();
+ this.publish({
+ ...this.snapshot,
+ commissioningWarning: unreachable.length
+ ? `Initiator set, but ${unreachable.length} prior anchor${unreachable.length === 1 ? " was" : "s were"} unreachable and could not be verified.`
+ : undefined,
+ });
+ }
+
+ async refreshDiagnostics(): Promise {
+ const runtime = this.requireRuntime();
+ const tag = this.rememberedTag;
+ if (!tag || this.snapshot.connectionState !== "connected") {
+ throw new Error(
+ "Connect the remembered PANS tag before refreshing diagnostics.",
+ );
+ }
+ try {
+ const hardwareDiagnostics = await this.runHardwareOperation(
+ async () =>
+ await runtime.diagnostics.inspect(tag.id, tag.transportDeviceId),
+ );
+ this.publish({ ...this.snapshot, hardwareDiagnostics });
+ return hardwareDiagnostics;
+ } catch (cause) {
+ const error = normalizeManagerError(cause, {
+ deviceId: tag.id,
+ operation: "refresh diagnostics",
+ });
+ this.publish({ ...this.snapshot, error });
+ throw error;
+ }
+ }
+
+ setForeground(foreground: boolean): void {
+ this.connectionController.setForeground(foreground);
+ }
+
+ async dispose(): Promise {
+ ++this.lifecycleGeneration;
+ this.connectionController.dispose();
+ this.positionPublisher.dispose();
+ this.removeRuntimeListeners();
+ const runtime = this.runtime;
+ this.runtime = undefined;
+ if (runtime) {
+ await runtime.stream.stop().catch(() => undefined);
+ await runtime.close();
+ }
+ }
+
+ getRuntime(): MobilePansRuntime {
+ return this.requireRuntime();
+ }
+
+ async refreshCachedAnchors(): Promise {
+ const runtime = this.requireRuntime();
+ const devices = await runtime.repository.listDevices();
+ const knownAnchors = sortedCachedAnchors(devices);
+ this.publish({ ...this.snapshot, knownAnchors });
+ return knownAnchors;
+ }
+
+ async renameAnchor(anchorId: string, label: string): Promise {
+ if (!this.developerModeEnabled) {
+ throw new ManagerError(
+ "INVALID_CONFIGURATION",
+ "Enable Developer Mode before renaming anchors.",
+ );
+ }
+ const requestedLabel = label.trim();
+ assertValidLabel(requestedLabel);
+ const runtime = this.requireRuntime();
+ const anchor = await runtime.repository.getDevice(anchorId);
+ if (
+ !anchor ||
+ (anchor.role !== "anchor" && anchor.lastKnownConfig?.role !== "anchor")
+ ) {
+ throw new Error("The selected cached anchor does not exist.");
+ }
+ try {
+ await this.runHardwareOperation(async () => {
+ const result = await runtime.configuration.applyConfigurationDiff(
+ anchor.id,
+ { label: requestedLabel },
+ );
+ const write = result.writes.find((item) => item.field === "label");
+ if (
+ result.error ||
+ write?.status === "failed" ||
+ write?.status === "mismatch"
+ ) {
+ throw new ManagerError(
+ result.error?.code ?? "WRITE_FAILED",
+ result.error?.message ?? "The anchor name could not be verified.",
+ { deviceId: anchor.id, operation: "rename anchor" },
+ );
+ }
+ });
+ await this.refreshCachedAnchors();
+ return (await runtime.repository.getDevice(anchor.id)) ?? anchor;
+ } catch (cause) {
+ throw normalizeManagerError(cause, {
+ deviceId: anchor.id,
+ operation: "rename anchor",
+ });
+ }
+ }
+
+ async writeAnchorPosition(
+ anchorId: string,
+ position: AnchorFieldPosition,
+ ): Promise {
+ if (this.anchorWritePromise) {
+ throw new ManagerError(
+ "OPERATION_CANCELLED",
+ "An anchor position write is already in progress.",
+ );
+ }
+ const operation = this.performAnchorPositionWrite(anchorId, position);
+ const tracked = operation.finally(() => {
+ if (this.anchorWritePromise === tracked)
+ this.anchorWritePromise = undefined;
+ });
+ this.anchorWritePromise = tracked;
+ return await tracked;
+ }
+
+ private async performAnchorPositionWrite(
+ anchorId: string,
+ position: AnchorFieldPosition,
+ ): Promise {
+ const runtime = this.requireRuntime();
+ const anchor = await runtime.repository.getDevice(anchorId);
+ if (
+ !anchor ||
+ (anchor.role !== "anchor" && anchor.lastKnownConfig?.role !== "anchor")
+ ) {
+ throw new Error("The selected cached anchor does not exist.");
+ }
+ const cachedPosition =
+ anchor.lastKnownConfig?.role === "anchor"
+ ? anchor.lastKnownConfig.position
+ : undefined;
+ if (
+ cachedPosition?.xMeters === position.xMeters &&
+ cachedPosition.yMeters === position.yMeters &&
+ cachedPosition.zMeters === position.zMeters &&
+ cachedPosition.quality === 100
+ ) {
+ return;
+ }
+ const activeNetworkMatch =
+ this.developerModeEnabled &&
+ this.settings?.activeNetworkId !== undefined &&
+ anchor.networkId === this.settings.activeNetworkId;
+ const connectedTagMatch = Boolean(
+ this.rememberedTag &&
+ this.snapshot.connectionState === "connected" &&
+ areDevicesNetworkAssociated(this.rememberedTag, anchor),
+ );
+ if (!activeNetworkMatch && !connectedTagMatch) {
+ throw new ManagerError(
+ "INVALID_CONFIGURATION",
+ "The anchor is not verified on the active network.",
+ { deviceId: anchor.id, operation: "write anchor position" },
+ );
+ }
+ try {
+ await this.runHardwareOperation(async () => {
+ const result = await runtime.configuration.applyConfigurationDiff(
+ anchor.id,
+ {
+ position: { ...position, quality: 100 },
+ },
+ );
+ const write = result.writes.find((item) => item.field === "position");
+ if (result.error || write?.status !== "written-unverified") {
+ throw new ManagerError(
+ result.error?.code ?? "WRITE_FAILED",
+ result.error?.message ?? "The anchor rejected the position write.",
+ { deviceId: anchor.id, operation: "write anchor position" },
+ );
+ }
+ });
+ await this.refreshCachedAnchors();
+ } catch (cause) {
+ const error = normalizeManagerError(cause, {
+ deviceId: anchor.id,
+ operation: "write anchor position",
+ });
+ throw error;
+ }
+ }
+
+ private async prepareSelectedTagForStreaming(): Promise {
+ const runtime = this.requireRuntime();
+ const tag = this.rememberedTag;
+ if (!tag) throw new Error("Select a tag before connecting.");
+ await this.runHardwareOperation(async () => {
+ const inspection = await runtime.configuration.inspectAndCache(tag.id);
+ const profileChanges = diffPerformerTagProfile(inspection);
+ let reconnectVerificationRequired = false;
+ let locationModeWrittenUnverified = false;
+ if (Object.keys(profileChanges).length > 0) {
+ const configured = await runtime.configuration.applyConfigurationDiff(
+ tag.id,
+ profileChanges,
+ );
+ if (
+ configured.error ||
+ configured.writes.some(
+ (write) => write.status === "failed" || write.status === "mismatch",
+ )
+ ) {
+ throw new Error(
+ configured.error?.message ??
+ "The performer tag profile could not be verified.",
+ );
+ }
+ reconnectVerificationRequired = configured.writes.length > 0;
+ locationModeWrittenUnverified = configured.writes.some(
+ (write) =>
+ write.field === "locationDataMode" &&
+ write.status === "written-unverified",
+ );
+ if (configured.inspected) {
+ const remaining = diffPerformerTagProfile(configured.inspected);
+ if (
+ configured.inspected.locationDataMode === undefined &&
+ locationModeWrittenUnverified
+ ) {
+ delete remaining.locationDataMode;
+ }
+ if (Object.keys(remaining).length > 0) {
+ throw new Error(
+ "The performer tag profile readback did not match.",
+ );
+ }
+ }
+ }
+ const activeNetworkId = this.developerModeEnabled
+ ? this.settings?.activeNetworkId
+ : undefined;
+ if (activeNetworkId) {
+ const assignment =
+ await runtime.commissioning.assignDeviceToNetworkProfile({
+ deviceId: tag.id,
+ targetNetworkId: activeNetworkId,
+ });
+ if (assignment.outcome !== "assigned") {
+ throw new Error(
+ assignment.error?.message ??
+ "The tag network could not be verified.",
+ );
+ }
+ reconnectVerificationRequired =
+ reconnectVerificationRequired ||
+ Boolean(assignment.configuration?.writes.length);
+ }
+ if (reconnectVerificationRequired) {
+ const reinspection = await runtime.configuration.inspectAndCache(
+ tag.id,
+ );
+ const remaining = diffPerformerTagProfile(reinspection);
+ if (
+ reinspection.locationDataMode === undefined &&
+ locationModeWrittenUnverified
+ ) {
+ delete remaining.locationDataMode;
+ }
+ if (Object.keys(remaining).length > 0) {
+ throw new Error(
+ "The performer tag profile did not persist after reconnecting.",
+ );
+ }
+ if (activeNetworkId) {
+ const activeNetwork =
+ await runtime.repository.getNetwork(activeNetworkId);
+ if (activeNetwork && reinspection.panId !== activeNetwork.panId) {
+ throw new Error(
+ "The active network did not persist after reconnecting.",
+ );
+ }
+ }
+ }
+ // The native PANS gateway exposes no hardware-reset command. Closing the
+ // serialized configuration session here and opening the stream session
+ // below provides the required reconnect/readback boundary.
+ this.rememberedTag =
+ (await runtime.repository.getDevice(tag.id)) ?? this.rememberedTag;
+ });
+ this.publish({ ...this.snapshot, rememberedTag: this.rememberedTag });
+ }
+
+ private async runHardwareOperation(action: () => Promise): Promise {
+ if (this.hardwareOperationPromise) {
+ throw new ManagerError(
+ "OPERATION_CANCELLED",
+ "Another PANS hardware operation is already in progress.",
+ );
+ }
+ const wasConnected = this.snapshot.connectionState === "connected";
+ const operation = (async () => {
+ if (wasConnected) await this.connectionController.pauseForOperation();
+ try {
+ return await action();
+ } finally {
+ if (wasConnected) {
+ await this.connectionController
+ .resumeAfterOperation()
+ .catch(() => undefined);
+ }
+ }
+ })();
+ const tracked = operation.finally(() => {
+ if (this.hardwareOperationPromise === tracked) {
+ this.hardwareOperationPromise = undefined;
+ }
+ });
+ this.hardwareOperationPromise = tracked;
+ return await tracked;
+ }
+
+ private async refreshNetworksAndDevices(): Promise {
+ const runtime = this.requireRuntime();
+ const [networks, devices] = await Promise.all([
+ runtime.repository.listNetworks(),
+ runtime.repository.listDevices(),
+ ]);
+ this.publish({
+ ...this.snapshot,
+ networks,
+ knownAnchors: sortedCachedAnchors(devices),
+ activeNetworkId: this.settings?.activeNetworkId,
+ });
+ }
+
+ private installRuntimeListeners(
+ runtime: MobilePansRuntime,
+ generation: number,
+ ): void {
+ this.discoverySubscription = runtime.discovery.subscribe((discoveries) => {
+ if (!this.isLifecycleCurrent(generation)) return;
+ this.discoveries = discoveries;
+ this.publish({ ...this.snapshot, discoveries });
+ });
+ this.discoveryErrorSubscription = runtime.discovery.subscribeErrors(
+ (error) => {
+ if (this.isLifecycleCurrent(generation)) {
+ this.publish({ ...this.snapshot, error });
+ if (this.snapshot.connectionState === "scanning") {
+ this.publishState("error", { error });
+ }
+ }
+ },
+ );
+ this.discoveryStateSubscription = runtime.discovery.subscribeState(
+ (state) => {
+ if (!this.isLifecycleCurrent(generation)) return;
+ if (state === "error" && this.snapshot.connectionState === "scanning") {
+ this.publishState("error");
+ } else if (
+ state === "idle" &&
+ this.snapshot.connectionState === "scanning" &&
+ !this.connectionController.isConnecting
+ ) {
+ this.publishState(this.rememberedTag ? "disconnected" : "idle");
+ }
+ },
+ );
+ this.connectionSubscription = runtime.sessions.addConnectionStateListener(
+ (event) => {
+ if (this.isLifecycleCurrent(generation)) {
+ this.connectionController.receiveConnectionEvent(event);
+ }
+ },
+ );
+ }
+
+ private async saveRememberedTag(deviceId: string | undefined): Promise {
+ await this.saveManagerSettings({ rememberedTagDeviceId: deviceId });
+ }
+
+ private async saveManagerSettings(
+ changes: Partial,
+ ): Promise {
+ const runtime = this.runtime;
+ if (!runtime) throw new Error("PANS services are not ready.");
+ const candidate: Partial = {
+ ...this.settings,
+ ...changes,
+ };
+ if (
+ Object.prototype.hasOwnProperty.call(changes, "rememberedTagDeviceId") &&
+ changes.rememberedTagDeviceId === undefined
+ ) {
+ delete candidate.rememberedTagDeviceId;
+ }
+ if (
+ Object.prototype.hasOwnProperty.call(changes, "activeNetworkId") &&
+ changes.activeNetworkId === undefined
+ ) {
+ delete candidate.activeNetworkId;
+ }
+ this.settings = normalizePansManagerSettings(candidate);
+ await runtime.repository.saveSettings(this.settings);
+ }
+
+ private publishState(
+ connectionState: TagConnectionState,
+ changes: Partial = {},
+ ): void {
+ const fieldState = fieldConnectionState(connectionState);
+ this.publish({
+ ...this.snapshot,
+ ...changes,
+ connectionState,
+ rememberedTag: changes.rememberedTag ?? this.rememberedTag,
+ discoveries: this.discoveries,
+ livePosition:
+ changes.livePosition ??
+ ({
+ ...this.snapshot.livePosition,
+ connectionState: fieldState,
+ } as const),
+ });
+ }
+
+ private publish(snapshot: MobilePansSnapshot): void {
+ this.snapshot = Object.freeze(snapshot);
+ for (const listener of this.listeners) listener();
+ }
+
+ private isLifecycleCurrent(generation: number): boolean {
+ return generation === this.lifecycleGeneration;
+ }
+
+ private requireRuntime(): MobilePansRuntime {
+ if (!this.runtime || this.snapshot.initialization !== "ready") {
+ throw new Error("PANS services are not ready.");
+ }
+ return this.runtime;
+ }
+
+ private requireDeveloperRuntime(): MobilePansRuntime {
+ if (!this.developerModeEnabled) {
+ throw new Error("Developer Mode is required for network commissioning.");
+ }
+ return this.requireRuntime();
+ }
+
+ private removeRuntimeListeners(): void {
+ this.discoverySubscription?.remove();
+ this.discoveryErrorSubscription?.remove();
+ this.discoveryStateSubscription?.remove();
+ this.connectionSubscription?.remove();
+ this.discoverySubscription = undefined;
+ this.discoveryErrorSubscription = undefined;
+ this.discoveryStateSubscription = undefined;
+ this.connectionSubscription = undefined;
+ }
+}
diff --git a/apps/mobile/src/pans/mobile-pans-ui.ts b/apps/mobile/src/pans/mobile-pans-ui.ts
new file mode 100644
index 00000000..afb0c43f
--- /dev/null
+++ b/apps/mobile/src/pans/mobile-pans-ui.ts
@@ -0,0 +1,95 @@
+import type { DiscoveredDeviceSnapshot } from "@eight2five/mobile/pans-manager";
+
+import type { TagConnectionState } from "./mobile-pans-model";
+
+export type ConnectionStatusIcon =
+ | "connected"
+ | "searching"
+ | "connecting"
+ | "disconnected"
+ | "error";
+
+export interface ConnectionStatusViewModel {
+ readonly label: string;
+ readonly icon: ConnectionStatusIcon;
+ readonly tone: "success" | "accent" | "muted" | "danger";
+ readonly animated: boolean;
+}
+
+export function connectionStatusViewModel(
+ state: TagConnectionState,
+): ConnectionStatusViewModel {
+ switch (state) {
+ case "connected":
+ return {
+ label: "Connected",
+ icon: "connected",
+ tone: "success",
+ animated: false,
+ };
+ case "scanning":
+ return {
+ label: "Searching",
+ icon: "searching",
+ tone: "accent",
+ animated: true,
+ };
+ case "connecting":
+ return {
+ label: "Connecting",
+ icon: "connecting",
+ tone: "accent",
+ animated: false,
+ };
+ case "reconnecting":
+ return {
+ label: "Reconnecting",
+ icon: "connecting",
+ tone: "accent",
+ animated: false,
+ };
+ case "error":
+ return {
+ label: "Connection error",
+ icon: "error",
+ tone: "danger",
+ animated: false,
+ };
+ case "idle":
+ case "disconnected":
+ return {
+ label: "Disconnected",
+ icon: "disconnected",
+ tone: "muted",
+ animated: false,
+ };
+ }
+}
+
+export type SignalStrength = "full" | "high" | "medium" | "low";
+
+export function signalStrengthForRssi(
+ rssi: number,
+ cutoff: number,
+): SignalStrength {
+ const aboveCutoff = rssi - cutoff;
+ if (aboveCutoff >= 40) return "full";
+ if (aboveCutoff >= 25) return "high";
+ if (aboveCutoff >= 10) return "medium";
+ return "low";
+}
+
+export function selectVisibleDiscoveries(
+ discoveries: readonly DiscoveredDeviceSnapshot[],
+ options: { readonly developerMode: boolean; readonly cutoff: number },
+): readonly DiscoveredDeviceSnapshot[] {
+ return discoveries
+ .filter(
+ (device) =>
+ !device.stale &&
+ device.compatibility === "compatible" &&
+ device.rssi >= options.cutoff &&
+ (options.developerMode || device.presence?.role === "tag"),
+ )
+ .sort((left, right) => right.rssi - left.rssi);
+}
diff --git a/apps/mobile/src/pans/pans-anchor-cache.ts b/apps/mobile/src/pans/pans-anchor-cache.ts
new file mode 100644
index 00000000..6d557897
--- /dev/null
+++ b/apps/mobile/src/pans/pans-anchor-cache.ts
@@ -0,0 +1,61 @@
+import type { FieldAnchorGeometry } from "@eight2five/mobile/field";
+import type { ManagedDevice } from "@eight2five/mobile/pans-manager";
+
+/**
+ * Selects only anchors explicitly associated with the remembered tag's saved
+ * network, falling back to a verified cached PAN ID when no profile exists.
+ */
+export function selectNetworkAnchors(
+ rememberedTag: ManagedDevice | undefined,
+ knownAnchors: readonly ManagedDevice[],
+): readonly ManagedDevice[] {
+ if (!rememberedTag) return [];
+ if (rememberedTag.networkId) {
+ return knownAnchors.filter(
+ (anchor) => anchor.networkId === rememberedTag.networkId,
+ );
+ }
+ const panId = rememberedTag.lastKnownConfig?.panId;
+ if (panId === undefined) return [];
+ return knownAnchors.filter(
+ (anchor) => anchor.lastKnownConfig?.panId === panId,
+ );
+}
+
+export function areDevicesNetworkAssociated(
+ tag: ManagedDevice,
+ anchor: ManagedDevice,
+): boolean {
+ if (tag.networkId) return anchor.networkId === tag.networkId;
+ const tagPanId = tag.lastKnownConfig?.panId;
+ return tagPanId !== undefined && anchor.lastKnownConfig?.panId === tagPanId;
+}
+
+export function cachedAnchorGeometry(
+ rememberedTag: ManagedDevice | undefined,
+ knownAnchors: readonly ManagedDevice[],
+): readonly FieldAnchorGeometry[] {
+ return selectNetworkAnchors(rememberedTag, knownAnchors).flatMap((anchor) => {
+ const position =
+ anchor.lastKnownConfig?.role === "anchor"
+ ? anchor.lastKnownConfig.position
+ : undefined;
+ if (
+ !position ||
+ !Number.isFinite(position.xMeters) ||
+ !Number.isFinite(position.yMeters)
+ ) {
+ return [];
+ }
+ return [
+ {
+ id: anchor.id,
+ position: {
+ xMeters: position.xMeters,
+ yMeters: position.yMeters,
+ zMeters: position.zMeters,
+ },
+ },
+ ];
+ });
+}
diff --git a/apps/mobile/src/state/__tests__/app-settings-store.test.ts b/apps/mobile/src/state/__tests__/app-settings-store.test.ts
new file mode 100644
index 00000000..09bb6611
--- /dev/null
+++ b/apps/mobile/src/state/__tests__/app-settings-store.test.ts
@@ -0,0 +1,176 @@
+import {
+ DEFAULT_APP_SETTINGS,
+ type AppSettings,
+} from "@eight2five/mobile/settings";
+import type { OpenMobileRepositoriesResult } from "@eight2five/mobile/storage";
+
+import { AppSettingsStore } from "../app-settings-store";
+import { selectFieldSession } from "../field-session-store";
+
+describe("AppSettingsStore", () => {
+ test("hydrates, serializes updates, resets preferences, and closes storage", async () => {
+ let settings: AppSettings = {
+ ...DEFAULT_APP_SETTINGS,
+ drillTerminology: "sets",
+ activeDrillId: "drill-1",
+ selectedDrillPageId: "page-1",
+ };
+ const close = jest.fn(async () => undefined);
+ const settingsRepository = {
+ load: jest.fn(async () => settings),
+ update: jest.fn(async (partial) => {
+ settings = { ...settings, ...partial };
+ return settings;
+ }),
+ resetPreferences: jest.fn(async () => {
+ settings = {
+ ...DEFAULT_APP_SETTINGS,
+ activeDrillId: settings.activeDrillId,
+ selectedDrillPageId: settings.selectedDrillPageId,
+ };
+ return settings;
+ }),
+ };
+ const drillRepository = {
+ setActiveDrill: jest.fn(async (id: string | null) => {
+ settings = {
+ ...settings,
+ activeDrillId: id,
+ selectedDrillPageId: null,
+ };
+ return settings;
+ }),
+ setSelectedDrillPage: jest.fn(async (id: string | null) => {
+ settings = { ...settings, selectedDrillPageId: id };
+ return settings;
+ }),
+ };
+ const storage = {
+ settingsRepository,
+ drillRepository,
+ close,
+ } as unknown as OpenMobileRepositoriesResult;
+ const store = new AppSettingsStore(async () => storage);
+ const listener = jest.fn();
+ store.subscribe(listener);
+
+ await store.initialize();
+ expect(store.getSnapshot()).toMatchObject({
+ status: "ready",
+ settings: { drillTerminology: "sets", activeDrillId: "drill-1" },
+ });
+
+ await Promise.all([
+ store.update({ guidanceEnabled: false }),
+ store.update({ appearanceMode: "dark" }),
+ ]);
+ expect(settingsRepository.update.mock.calls).toEqual([
+ [{ guidanceEnabled: false }],
+ [{ appearanceMode: "dark" }],
+ ]);
+
+ await store.resetPreferences();
+ expect(store.getSnapshot().settings).toEqual({
+ ...DEFAULT_APP_SETTINGS,
+ activeDrillId: "drill-1",
+ selectedDrillPageId: "page-1",
+ });
+ expect(listener).toHaveBeenCalled();
+
+ await store.dispose();
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ test("publishes initialization errors and rejects writes before ready", async () => {
+ const store = new AppSettingsStore(async () => {
+ throw new Error("open failed");
+ });
+ await store.initialize();
+ expect(store.getSnapshot()).toMatchObject({
+ status: "error",
+ error: new Error("open failed"),
+ });
+ await expect(store.update({ guidanceEnabled: false })).rejects.toThrow(
+ "not ready",
+ );
+ });
+
+ test("can rebuild the app database after an initialization failure", async () => {
+ const close = jest.fn(async () => undefined);
+ const storage = {
+ settingsRepository: {
+ load: jest.fn(async () => DEFAULT_APP_SETTINGS),
+ update: jest.fn(),
+ resetPreferences: jest.fn(),
+ },
+ drillRepository: {},
+ close,
+ } as unknown as OpenMobileRepositoriesResult;
+ const openStorage = jest
+ .fn, []>()
+ .mockRejectedValueOnce(new Error("schema failed"))
+ .mockResolvedValueOnce(storage);
+ const deleteStorage = jest.fn(async () => undefined);
+ const store = new AppSettingsStore(openStorage, deleteStorage);
+
+ await store.initialize();
+ expect(store.getSnapshot().status).toBe("error");
+
+ const settings = await store.rebuildDatabase();
+
+ expect(deleteStorage).toHaveBeenCalledTimes(1);
+ expect(openStorage).toHaveBeenCalledTimes(2);
+ expect(settings).toEqual(DEFAULT_APP_SETTINGS);
+ expect(store.getSnapshot()).toEqual({
+ status: "ready",
+ settings: DEFAULT_APP_SETTINGS,
+ });
+
+ await store.dispose();
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ test("waits for queued writes before closing storage", async () => {
+ let releaseUpdate!: () => void;
+ const close = jest.fn(async () => undefined);
+ const storage = {
+ settingsRepository: {
+ load: jest.fn(async () => DEFAULT_APP_SETTINGS),
+ update: jest.fn(
+ async () =>
+ await new Promise((resolve) => {
+ releaseUpdate = () => resolve(DEFAULT_APP_SETTINGS);
+ }),
+ ),
+ resetPreferences: jest.fn(async () => DEFAULT_APP_SETTINGS),
+ },
+ drillRepository: {},
+ close,
+ } as unknown as OpenMobileRepositoriesResult;
+ const store = new AppSettingsStore(async () => storage);
+ await store.initialize();
+
+ const update = store.update({ guidanceEnabled: false });
+ await Promise.resolve();
+ const disposal = store.dispose();
+ expect(close).not.toHaveBeenCalled();
+
+ releaseUpdate();
+ await update;
+ await disposal;
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ test("derives the persisted field session contract", () => {
+ expect(
+ selectFieldSession({
+ ...DEFAULT_APP_SETTINGS,
+ activeDrillId: "drill-1",
+ selectedDrillPageId: "page-2",
+ }),
+ ).toEqual({
+ activeDrillId: "drill-1",
+ selectedDrillPageId: "page-2",
+ });
+ });
+});
diff --git a/apps/mobile/src/state/__tests__/appearance-theme.test.ts b/apps/mobile/src/state/__tests__/appearance-theme.test.ts
new file mode 100644
index 00000000..8b3f48a6
--- /dev/null
+++ b/apps/mobile/src/state/__tests__/appearance-theme.test.ts
@@ -0,0 +1,42 @@
+///
+
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import {
+ eight2FiveDrillColors,
+ eight2FiveThemes,
+ resolveEight2FiveThemeName,
+} from "@eight2five/ui/theme";
+import { COLOR_PRESETS } from "@eight2five/drill-schema";
+
+describe("app appearance", () => {
+ test("uses the OS appearance only in system mode", () => {
+ expect(resolveEight2FiveThemeName("system", "light")).toBe("light");
+ expect(resolveEight2FiveThemeName("system", "dark")).toBe("dark");
+ expect(resolveEight2FiveThemeName("system", null)).toBe("light");
+ expect(resolveEight2FiveThemeName("system", "unspecified")).toBe("light");
+ });
+
+ test("light and dark modes override the OS appearance", () => {
+ expect(resolveEight2FiveThemeName("light", "dark")).toBe("light");
+ expect(resolveEight2FiveThemeName("dark", "light")).toBe("dark");
+ expect(eight2FiveThemes.light.background).not.toBe(
+ eight2FiveThemes.dark.background,
+ );
+ });
+
+ test("shares the drill color presets with the UI theme", () => {
+ expect(eight2FiveDrillColors).toBe(COLOR_PRESETS);
+ expect(eight2FiveThemes.light.accent).toBe(COLOR_PRESETS.blue);
+ expect(eight2FiveThemes.dark.accent).toBe(COLOR_PRESETS.blue);
+
+ const css = readFileSync(
+ path.resolve(__dirname, "../../../../../packages/ui/theme/theme.css"),
+ "utf8",
+ );
+ const red = Number.parseInt(COLOR_PRESETS.blue.slice(1, 3), 16);
+ const green = Number.parseInt(COLOR_PRESETS.blue.slice(3, 5), 16);
+ const blue = Number.parseInt(COLOR_PRESETS.blue.slice(5, 7), 16);
+ expect(css).toContain(`--eight2five-drill-blue: ${red} ${green} ${blue};`);
+ });
+});
diff --git a/apps/mobile/src/state/app-settings-store.tsx b/apps/mobile/src/state/app-settings-store.tsx
new file mode 100644
index 00000000..977eb3d6
--- /dev/null
+++ b/apps/mobile/src/state/app-settings-store.tsx
@@ -0,0 +1,258 @@
+import React from "react";
+import {
+ DEFAULT_APP_SETTINGS,
+ type AppSettings,
+ type AppSettingsUpdate,
+} from "@eight2five/mobile/settings";
+import {
+ deleteMobileDatabase,
+ openMobileRepositories,
+ type OpenMobileRepositoriesResult,
+} from "@eight2five/mobile/storage";
+
+export type AppSettingsStoreStatus = "loading" | "ready" | "error";
+
+export interface AppSettingsStoreSnapshot {
+ readonly status: AppSettingsStoreStatus;
+ readonly settings: AppSettings;
+ readonly error?: Error;
+}
+
+export type OpenAppSettingsStorage =
+ () => Promise;
+export type DeleteAppSettingsStorage = () => Promise;
+
+const INITIAL_SNAPSHOT: AppSettingsStoreSnapshot = Object.freeze({
+ status: "loading",
+ settings: DEFAULT_APP_SETTINGS,
+});
+
+/** Owns the app database lifecycle and publishes one stable settings snapshot. */
+export class AppSettingsStore {
+ private snapshot: AppSettingsStoreSnapshot = INITIAL_SNAPSHOT;
+ private readonly listeners = new Set<() => void>();
+ private storage?: OpenMobileRepositoriesResult;
+ private lifecycleGeneration = 0;
+ private writeQueue: Promise = Promise.resolve();
+
+ constructor(
+ private readonly openStorage: OpenAppSettingsStorage = () =>
+ openMobileRepositories(),
+ private readonly deleteStorage: DeleteAppSettingsStorage = () =>
+ deleteMobileDatabase(),
+ ) {}
+
+ readonly getSnapshot = (): AppSettingsStoreSnapshot => this.snapshot;
+
+ readonly subscribe = (listener: () => void): (() => void) => {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ };
+
+ async initialize(): Promise {
+ const generation = ++this.lifecycleGeneration;
+ this.publish(INITIAL_SNAPSHOT);
+ const previousStorage = this.storage;
+ this.storage = undefined;
+ if (previousStorage) {
+ await this.writeQueue;
+ await closeStorageQuietly(previousStorage);
+ if (generation !== this.lifecycleGeneration) return;
+ }
+ let storage: OpenMobileRepositoriesResult | undefined;
+ try {
+ storage = await this.openStorage();
+ if (generation !== this.lifecycleGeneration) {
+ await closeStorageQuietly(storage);
+ return;
+ }
+ const settings = await storage.settingsRepository.load();
+ if (generation !== this.lifecycleGeneration) {
+ await closeStorageQuietly(storage);
+ return;
+ }
+ this.storage = storage;
+ this.publish(Object.freeze({ status: "ready", settings }));
+ } catch (cause) {
+ if (storage && storage !== this.storage)
+ await closeStorageQuietly(storage);
+ if (generation !== this.lifecycleGeneration) return;
+ this.publish(
+ Object.freeze({
+ status: "error",
+ settings: this.snapshot.settings,
+ error: toError(cause),
+ }),
+ );
+ }
+ }
+
+ async update(partial: AppSettingsUpdate): Promise {
+ return await this.enqueue(async (storage) => {
+ const settings = await storage.settingsRepository.update(partial);
+ this.publish(Object.freeze({ status: "ready", settings }));
+ return settings;
+ });
+ }
+
+ async resetPreferences(): Promise {
+ return await this.enqueue(async (storage) => {
+ const settings = await storage.settingsRepository.resetPreferences();
+ this.publish(Object.freeze({ status: "ready", settings }));
+ return settings;
+ });
+ }
+
+ async setActiveDrill(id: string | null): Promise {
+ return await this.enqueue(async (storage) => {
+ const settings = await storage.drillRepository.setActiveDrill(id);
+ this.publish(Object.freeze({ status: "ready", settings }));
+ return settings;
+ });
+ }
+
+ async setSelectedDrillSet(id: string | null): Promise {
+ return await this.enqueue(async (storage) => {
+ const settings = await storage.drillRepository.setSelectedDrillSet(id);
+ this.publish(Object.freeze({ status: "ready", settings }));
+ return settings;
+ });
+ }
+
+ /** @deprecated Use setSelectedDrillSet. */
+ async setSelectedDrillPage(id: string | null): Promise {
+ return await this.setSelectedDrillSet(id);
+ }
+
+ async reload(): Promise {
+ return await this.enqueue(async (storage) => {
+ const settings = await storage.settingsRepository.load();
+ this.publish(Object.freeze({ status: "ready", settings }));
+ return settings;
+ });
+ }
+
+ /**
+ * Destructively rebuild the disposable app database from the current schema.
+ * This intentionally does not touch the separate PANS manager database.
+ */
+ async rebuildDatabase(): Promise {
+ this.publish(INITIAL_SNAPSHOT);
+ try {
+ await this.dispose();
+ await this.deleteStorage();
+ await this.initialize();
+ const snapshot = this.snapshot;
+ if (snapshot.status !== "ready") {
+ throw snapshot.error ?? new Error("App database rebuild failed.");
+ }
+ return snapshot.settings;
+ } catch (cause) {
+ const error = toError(cause);
+ this.publish(
+ Object.freeze({
+ status: "error",
+ settings: DEFAULT_APP_SETTINGS,
+ error,
+ }),
+ );
+ throw error;
+ }
+ }
+
+ getDrillRepository() {
+ return this.requireStorage().drillRepository;
+ }
+
+ async dispose(): Promise {
+ this.lifecycleGeneration += 1;
+ const storage = this.storage;
+ this.storage = undefined;
+ await this.writeQueue;
+ if (storage) await storage.close();
+ }
+
+ private async enqueue(
+ operation: (storage: OpenMobileRepositoriesResult) => Promise,
+ ): Promise