Skip to content

`FormRule`

A declarative form analysis rule. References a tracking point, evaluates per frame, fires a feedback event when its trigger condition is met.

class FormRule {
final String id;
final String trackingPoint;
final ConditionOperator op;
final List<double> value;
final FormFeedback feedback;
final String? appliesToPhase;
final FeedbackTrigger trigger;
final FormRuleSeverity severity;
final RetrospectiveConfig? retrospective;
}

Example

A warning when knee depth doesn’t reach the squat target:

const FormRule(
id: 'shallow-squat',
trackingPoint: 'left_knee',
op: ConditionOperator.between,
value: [70, 110],
appliesToPhase: 'down',
trigger: FeedbackTrigger.onViolation,
severity: FormRuleSeverity.warning,
feedback: FormFeedback(
text: 'Squat deeper',
audioCue: 'cue-deeper.mp3',
),
);

When the user is in the down phase AND left_knee is outside the [70, 110] range, the form service fires a feedback event with severity: warning. The host UI surfaces it (toast deck, audio, haptic).

Fields

id

Stable identifier, unique within the movement.

trackingPoint

The channel to evaluate. Must match a TrackingPoint.id defined on the same Movement.

op + value

Same operators as PhaseCondition:

OperatorvalueMeaning
gt[threshold]tracking value > threshold
lt[threshold]tracking value < threshold
eq[threshold]tracking value ≈ threshold (±2°)
between[min, max]tracking value ∈ [min, max]

value is always a List<double> for serialisation symmetry , single-threshold operators take a 1-element list, between takes 2.

appliesToPhase

Phase id to scope the rule to. When null, the rule applies in every phase.

trigger

enum FeedbackTrigger { onViolation, onCompliance, always }
  • onViolation (default), fires when compliance flips from in-range to out-of-range. One event per breach.
  • onCompliance, fires when the user moves BACK into range. Positive reinforcement (“Good depth!”).
  • always, fires every frame. Used sparingly for live numeric readouts.

severity

enum FormRuleSeverity { info, warning, error }
  • info, neutral coaching cue.
  • warning, needs attention but not a rep-killer.
  • error, disqualifying issue (catastrophic form break).

The host filters the toast deck by severity via OverlaySettings.formFeedbackSeverities, trainers can hide info chatter during a live test.

feedback

class FormFeedback {
final String text;
final String? audioCue;
final String? hapticPattern;
}

Cue payload. text is required; audioCue + hapticPattern are optional asset references the host resolves.

retrospective

When set, the rule fires ONCE at phase boundary against an aggregated statistic instead of per-frame. See below.

Throttling

The form service throttles rapid-fire events. A rule won’t re-fire within 800 ms of its last event for the same trigger, so a wobbly knee in the down phase doesn’t generate 20 cues per second.

Compliance levels

Every evaluated rule produces a ComplianceLevel:

  • compliant, in range.
  • closeToLimit, within 10% of the boundary on either side.
  • violation, out of range.

Compliance feeds the rolling form score: weighted average across all active rules, with compliant = 100, closeToLimit = 60, violation = 0.

Retrospective rules

For form cues that only make sense AFTER a phase completes (e.g. “average knee angle through the down phase was 130°, push deeper next time”), use retrospective:

const FormRule(
id: 'avg-depth',
trackingPoint: 'left_knee',
appliesToPhase: 'down',
op: ConditionOperator.lt,
value: [120],
retrospective: RetrospectiveConfig(
op: RetrospectiveOp.average,
mode: EvaluationMode.duringPhase,
threshold: 120,
),
feedback: FormFeedback(text: 'Average depth: too shallow'),
);
enum RetrospectiveOp { average, peak, min, max, finalValue }
enum EvaluationMode { duringPhase, atPhaseEnter, atPhaseExit }

Retrospective rules don’t fire per frame; they fire ONCE at the phase boundary with the aggregated statistic. The form service evaluates them in evaluatePhaseEnd(phaseId) which MovementTracker calls automatically when the phase machine transitions.

Bucket-scoped bands

For view-dependent measurements (spine lean, knee valgus, shoulder abduction), rule ranges can vary per camera bucket. The authoring path uses Movement.angleBands (a Map<bucketId, List<AngleBand>>), at runtime, MovementTracker._resolveBucketOverrides picks the right band for the live-detected bucket and OVERRIDES the rule’s value.

The rule’s static value stays as the FALLBACK for buckets that have no authored band.