`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:
| Operator | value | Meaning |
|---|---|---|
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.
Read next
TrackingPoint, the channels rules reference.PhaseConfig.- Form analysis.
- Form rules spec.
- Form feedback in the Studio.