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
- 1 Summary - What You Get
- 2 Prerequisites
- 3 Files to Add
- 3.1 Code Examples
- 4 Wire It Into Your Screen
- 5 Live Guidance Overlay (UX)
- 5.1 Code Example
- 6 Gate the Capture
- 7 Configuration Hooks
- 8 Optional: Smooth the Angle (Jitter Control)
- 8.1 Flutter (inside your BLoC)
- 8.1.1 Moving Average
- 8.1.2 EMA (alternative)
- 8.2 When to pick which
- 8.1 Flutter (inside your BLoC)
- 9 Lifecycle & Cleanup
- 10 Troubleshooting
- 10.1 Angle stuck near 90°
- 10.2 No updates
- 10.3 UI flickers
- 10.4 Different device orientations
- 11 FAQ
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_plussince it’s the more standard package, depending on your dependencies, you can also usedchs_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
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°orangle ≥ 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
tiltAngleat 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
BlocProviderensuresclose()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
referenceis near 0 while the device is vertical, you’re likely reading un-normalized or paused data; recheck normalization and lifecycle.
FAQ