Sensor‑Based Angle Guidance for Tire Sidewall Capture with Anyline Cloud API - (Flutter + BLoC)

Sensor‑Based Angle Guidance for Tire Sidewall Capture with Anyline Cloud API - (Flutter + BLoC)

This article helps integrators implement sensor-based angle guidance for tire sidewall capture. The feature is used in our TireBuddy app and can also be added to your own app when integrating with the Anyline Cloud API using Flutter.

The goal of implementing this feature is to prevent poor images by guiding users to hold the phone parallel to the tire and by gating capture until the device angle is within tolerance.

PLEASE NOTE:

  • The sensor-based angle guidance described here applies to mounted tires. Free-standing or unmounted tires are out of scope for this guidance.

  • Below is the same Guidance Overlay used in the TireBuddy App, attached as a downloadable element should you wish to use it

Overlay-20251027-145313.png
TireBuddy TSW Guidance Overlay

 


Summary - What You Get

A step-by-step instructional article on how to add a sensor-based angle guidance Flutter/BLoC Feature that you can drop into your capture flow. This feature should:

  • Read the device’s accelerometer/gravity vector in real time.

  • Compute a single forward/backward lean angle (0°–90°) when the phone is held parallel to the tire.

  • Expose the angle for UI guidance (banner/indicator) and capture gating (enable/disable shutter).

Angle explanations:

  • 0–15°→ phone is nicely parallel to the tire (green).

  • 16–74° → noticeable lean; prompt the user (orange).

  • 75–90° → Ready (green).

IMPORTANT:

This guidance applies to mounted tires.

 


Prerequisites

Add these to pubspec.yaml:

Packages (example):

dependencies: flutter_bloc: ^8.1.0 equatable: ^2.0.5 sensors_plus: ^<current> # or: dchs_motion_sensors: ^<current>

PLEASE NOTE:

  • While it is recommended to use sensors_plus since it’s the more standard package, depending on your dependencies, you can also use dchs_motion_sensors.

 


Files to Add

Add the following to your project:

lib/accelerometer/ bloc/ accelerometer_bloc.dart accelerometer_event.dart accelerometer_state.dart bloc.dart accelerometer.dart

IMPORTANT:

Inside lib/accelerometer, you can add the following code to accelerometer.dart:

export 'bloc/bloc.dart';

Code Examples

What it Does:

  • Subscribes to motionSensors.accelerometer (or plugin of choice).

  • Normalizes (x,y,z) and computes angleDeg as above; round() to int.

  • Emits AccelerometerState(tiltAngle: angle).

  • Cancels the subscription in close().

Code Example (with sensors_plus):

import 'dart:async'; import 'dart:math' as math; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:sensors_plus/sensors_plus.dart' as sp; // <-- alias to avoid name clash part 'accelerometer_event.dart'; part 'accelerometer_state.dart'; class AccelerometerBloc extends Bloc<AccelerometerEvent, AccelerometerState> { AccelerometerBloc() : super(const AccelerometerState()) { on<StartAccelerometerMonitoring>(_startMonitoring); on<AccelerometerDataReceived>(_onDataReceived); } StreamSubscription<sp.AccelerometerEvent>? _accelerometerSubscription; Future<void> _startMonitoring( StartAccelerometerMonitoring event, Emitter<AccelerometerState> emit, ) async { // sensors_plus stream name can differ by version: // Option A (common): sp.accelerometerEvents _accelerometerSubscription = sp.accelerometerEvents.listen((e) { add(AccelerometerDataReceived(x: e.x, y: e.y, z: e.z)); }); // Option B (older): sp.accelerometerEventStream() // _accelerometerSubscription = sp.accelerometerEventStream().listen((e) { // add(AccelerometerDataReceived(x: e.x, y: e.y, z: e.z)); // }); } void _onDataReceived( AccelerometerDataReceived event, Emitter<AccelerometerState> emit, ) { // Normalize gravity vector (m/s^2 → ~g) final gx = event.x / 9.81; // left/right final gy = event.y / 9.81; // along phone body final gz = event.z / 9.81; // screen normal final absGx = gx.abs(); final absGy = gy.abs(); final absGz = gz.abs(); final reference = math.max(absGx, absGy); final angleDeg = (reference < 1e-3) ? 90.0 : math.atan2(absGz, reference) * (180.0 / math.pi); emit(state.copyWith(tiltAngle: angleDeg.round())); } @override Future<void> close() { _accelerometerSubscription?.cancel(); return super.close(); } }

Code Example (with dchs_motion_sensors):

import 'dart:async'; import 'dart:math' as math; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:dchs_motion_sensors/dchs_motion_sensors.dart' as s; part 'accelerometer_event.dart'; part 'accelerometer_state.dart'; class AccelerometerBloc extends Bloc<AccelerometerEvent, AccelerometerState> { AccelerometerBloc() : super(const AccelerometerState()) { on<StartAccelerometerMonitoring>(_startMonitoring); on<AccelerometerDataReceived>(_onDataReceived); } StreamSubscription<s.AccelerometerEvent>? _accelerometerSubscription; Future<void> _startMonitoring( StartAccelerometerMonitoring event, Emitter<AccelerometerState> emit, ) async { _accelerometerSubscription = s.motionSensors.accelerometer.listen((e) { add(AccelerometerDataReceived(x: e.x, y: e.y, z: e.z)); }); } void _onDataReceived( AccelerometerDataReceived event, Emitter<AccelerometerState> emit, ) { final gx = event.x / 9.81; final gy = event.y / 9.81; final gz = event.z / 9.81; final absGx = gx.abs(); final absGy = gy.abs(); final absGz = gz.abs(); final reference = math.max(absGx, absGy); final angleDeg = (reference < 1e-3) ? 90.0 : math.atan2(absGz, reference) * (180.0 / math.pi); emit(state.copyWith(tiltAngle: angleDeg.round())); } @override Future<void> close() { _accelerometerSubscription?.cancel(); return super.close(); } }

What it Does:

  • StartAccelerometerMonitoring — start stream.

  • AccelerometerDataReceived(x,y,z) — internal per-sample event.

Code Example:

part of 'accelerometer_bloc.dart'; abstract class AccelerometerEvent extends Equatable { const AccelerometerEvent(); @override List<Object> get props => []; } class StartAccelerometerMonitoring extends AccelerometerEvent { const StartAccelerometerMonitoring(); } class AccelerometerDataReceived extends AccelerometerEvent { const AccelerometerDataReceived({ required this.x, required this.y, required this.z, }); final double x; final double y; final double z; }

What it Does:

  • Holds the angle of the device.

  • Equatable is needed for comparison operations and to prevent unnecessary rebuilds.

  • copyWith is a helper function used to rebuild a new state with updated values.

Code Example:

part of 'accelerometer_bloc.dart'; /// {@template accelerometer_state} /// AccelerometerState description /// {@endtemplate} class AccelerometerState extends Equatable { /// {@macro accelerometer_state} const AccelerometerState({ this.tiltAngle, }); /// A description for customProperty final int? tiltAngle; @override List<Object> get props => [tiltAngle ?? 0]; /// Creates a copy of the current AccelerometerState with property changes AccelerometerState copyWith({ int? tiltAngle, }) { return AccelerometerState( tiltAngle: tiltAngle ?? this.tiltAngle, ); } }

What it Does:

  • Serves as a “barrel” file.

  • Used for standardization of exports within the Bloc structure.

Code Example:

export 'package:flutter_bloc/flutter_bloc.dart'; export 'accelerometer_bloc.dart';

 


Wire It Into Your Screen

Start monitoring when your TSW capture screen mounts. Scoping the bloc to the page ensures the subscription stops when you navigate away.

import 'package:flutter/material.dart'; import 'accelerometer/bloc/bloc.dart'; class TswCapturePage extends StatelessWidget { const TswCapturePage({super.key}); @override Widget build(BuildContext context) { return BlocProvider( create: (_) => AccelerometerBloc() ..add(const StartAccelerometerMonitoring()), child: const _TswCaptureView(), ); } }

Stop is handled automatically when the BlocProvider goes out of scope and the widget tree disposes the bloc (close() cancels the subscription). If you scope the bloc higher, ensure you dispose/close it appropriately.

 


Live Guidance Overlay (UX)

Show real-time feedback and color states; tweak thresholds to match your product policy.

TIP:

Recommended starting thresholds

  • Green (ready): angle ≤ 15° or angle ≥ 75°

  • Orange (adjust): 16°–74°

PLEASE NOTE:

This snippet uses isReadyAngle (defined below in Configuration Hooks).

Code Example

BlocBuilder<AccelerometerBloc, AccelerometerState>( builder: (context, state) { final angle = state.tiltAngle ?? 90; final ready = isReadyAngle(angle); final String msg = ready ? 'Ready — $angle°' : 'Adjust angle ($angle°)'; final Color bg = ready ? Colors.green.withOpacity(0.85) : Colors.orange.withOpacity(0.85); return Semantics( label: 'Angle guidance: $msg', child: Container( margin: const EdgeInsets.all(12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(12), ), child: Text( msg, style: const TextStyle( color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600, ), ), ), ); }, )

 


Gate the Capture

TIP:

Rule of thumb:

Only allow capture when angle ≤ 15° or angle ≥ 75°. Otherwise, block or disable and nudge the user.
Orange (adjust): any angle in 16°–74°.

Recommended defaults:

readyLowMaxDeg = 15, readyHighMinDeg = 75 (tune with QA).

Option A — block on press (show nudge)

BlocBuilder<AccelerometerBloc, AccelerometerState>( builder: (context, state) { final angle = state.tiltAngle; return ElevatedButton( onPressed: () { if (isReadyAngle(angle)) { _captureTsw(); } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Please adjust the phone angle before capturing.')), ); } }, child: const Text('Capture'), ); }, )

Option B — hard gate (disable button)

BlocSelector<AccelerometerBloc, AccelerometerState, int?>( selector: (s) => s.tiltAngle, builder: (context, angle) { final canCapture = isReadyAngle(angle); return ElevatedButton( onPressed: canCapture ? _captureTsw : null, child: Text(canCapture ? 'Capture' : 'Hold Flatter'), ); }, )

Tips

  • UX: Pair gating with the banner’s color/text so users know why it’s blocked.

  • Haptics: Brief success haptic when entering green helps users adjust quickly.

  • Analytics (optional): Log tiltAngle at capture time to monitor field quality.

 


Configuration Hooks

Keep thresholds and texts configurable so behavior can be tuned without code changes. Read these values once at app start or screen entry (you can later source them from Remote Config/feature flags).

class CapturePolicy { /// Low “ready” band upper bound (inclusive) final int readyLowMaxDeg; // e.g., 15 /// High “ready” band lower bound (inclusive) final int readyHighMinDeg; // e.g., 75 /// Smoothing + cadence final int smoothN; // e.g., 5 final Duration updateInterval; // e.g., 100ms const CapturePolicy({ this.readyLowMaxDeg = 15, this.readyHighMinDeg = 75, this.smoothN = 5, this.updateInterval = const Duration(milliseconds: 100), }); } const kDefaultPolicy = CapturePolicy(); /// Helper you can reuse everywhere bool isReadyAngle(int? angle, {CapturePolicy policy = kDefaultPolicy}) { final a = angle ?? 90; return a <= policy.readyLowMaxDeg || a >= policy.readyHighMinDeg; }

TIP:

Use isReadyAngle(angle, policy: kDefaultPolicy) for gating and banner colors/messages.

 


Optional: Smooth the Angle (Jitter Control)

For steadier UI, average over a small window before emitting.

Good defaults

  • Update interval: ~100 ms

  • Moving average window: 3–7 samples (start with 5)

  • Or use EMA with α ≈ 0.3–0.5

Common filters

  • Moving Average (MA): smooth, tiny lag, easy.

  • Exponential Moving Average (EMA): smooth with less memory, tuneable responsiveness.

  • Median (3–5 samples): robust to spikes.

Flutter (inside your BLoC)

Moving Average

final _window = <double>[]; static const _maxN = 5; // tune 3–7 void _onDataReceived( AccelerometerDataReceived event, Emitter<AccelerometerState> emit, ) { // ... compute forwardBackwardLeanAngle (double) into `angle` final angle = forwardBackwardLeanAngle; _window.add(angle); if (_window.length > _maxN) _window.removeAt(0); final smoothed = _window.reduce((a, b) => a + b) / _window.length; emit(state.copyWith(tiltAngle: smoothed.round())); }

EMA (alternative)

double? _ema; const _alpha = 0.4; // 0..1 (higher = snappier) void _onDataReceived(AccelerometerDataReceived e, Emitter<AccelerometerState> emit) { final angle = forwardBackwardLeanAngle; _ema = (_ema == null) ? angle : (_alpha * angle + (1 - _alpha) * _ema!); emit(state.copyWith(tiltAngle: _ema!.round())); }

When to pick which

  • MA (window=5): simplest, great for overlays and gating; tiny lag.

  • EMA (α=0.4): a touch snappier with comparable stability.

  • Median (window=3/5): if you see occasional spikes; slightly less smooth.

TIP:

If users feel the shutter “lags,” reduce MA window (e.g., 3) or increase EMA α (e.g., 0.5). If the banner flickers, do the opposite.

 


Lifecycle & Cleanup

Rule: Start sensor updates only while the capture UI is visible; stop them as soon as it isn’t.

  • Page‑scoped BlocProvider ensures close() cancels the stream when the page is popped.

Page-scoped (Same code sample from “Wire it into your Screen” Section - Recommended):

class TswCapturePage extends StatelessWidget { const TswCapturePage({super.key}); @override Widget build(BuildContext context) { return BlocProvider( create: (_) => AccelerometerBloc() ..add(const StartAccelerometerMonitoring()), child: const _TswCaptureView(), ); } }

 


Troubleshooting

Angle stuck near 90°

Device is flat (screen facing up/down). In this model, 75–90° is Ready (green) for mounted tires. If your app requires only vertical captures, adjust the policy (readyLowMaxDeg, readyHighMinDeg) accordingly. Otherwise prompt the user to hold the phone vertically, parallel to the mounted tire.

No updates

Dispatch StartAccelerometerMonitoring when the page builds. Ensure the UI reads the same BLoC instance (provider scope).

UI flickers

Use the smoothing window (above) and rebuild only the small banner—keep the camera preview outside BlocBuilder.

Different device orientations

The formula assumes the phone is vertical/parallel to the tire. If you support rotated UIs, keep the same guidance messaging; the math already uses the dominant gravity axis (max(|gx|, |gy|)) to remain orientation‑robust for the parallel posture.

  • If reference is near 0 while the device is vertical, you’re likely reading un-normalized or paused data; recheck normalization and lifecycle.

 


FAQ

Usually no for accelerometer/gravity on Android/iOS. If you add other motion features, check platform docs.

Yes. As long as the phone is parallel to the tire, the dominant axis logic still treats gz as forward/backward lean.

Start with Green (ready) at 0–15° or 75–90°; Orange (adjust) at 16–74°. Tune with QA and product policy.

Yes—read state.tiltAngle at capture and attach to analytics/Cloud API request metadata.