Files
companion/lib/features/sensors/calibration_sheet.dart
T

1222 lines
35 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/models/calibration.dart';
import '../../domain/models/tag.dart';
import '../../providers.dart';
void showCalibrationSheet(
BuildContext context,
int sensorId,
String sensorDeviceId,
) {
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
isDismissible: true,
enableDrag: true,
builder: (ctx) => SizedBox(
height: MediaQuery.of(ctx).size.height * 0.95,
child: CalibrationSheet(
sensorId: sensorId,
sensorDeviceId: sensorDeviceId,
),
),
);
}
class CalibrationSheet extends ConsumerStatefulWidget {
const CalibrationSheet({
super.key,
required this.sensorId,
required this.sensorDeviceId,
});
final int sensorId;
final String sensorDeviceId;
@override
ConsumerState<CalibrationSheet> createState() => _CalibrationSheetState();
}
class _CalibrationSheetState extends ConsumerState<CalibrationSheet> {
late final PageController _pageController;
late final ({int id, String deviceId}) _key;
bool _calibrationComplete = false;
@override
void initState() {
super.initState();
_pageController = PageController();
_key = (id: widget.sensorId, deviceId: widget.sensorDeviceId);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
Future<void> _begin() async {
await ref.read(calibrationProvider(_key).notifier).beginCalibration();
if (mounted) {
_pageController.animateToPage(
1,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
Future<void> _commitTag() async {
await ref.read(calibrationProvider(_key).notifier).commitTag();
if (mounted) {
_pageController.animateToPage(
2,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
Future<void> _finish() async {
await ref.read(calibrationProvider(_key).notifier).finishCalibration();
if (mounted) {
_pageController.animateToPage(
3,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
void _cancelAndClose(BuildContext ctx) {
_calibrationComplete = true; // suppress PopScope cancel
ref.read(calibrationProvider(_key).notifier).cancelCalibration();
Navigator.of(ctx, rootNavigator: true).pop();
}
void _done(BuildContext ctx) {
_calibrationComplete = true;
Navigator.of(ctx, rootNavigator: true).pop();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(calibrationProvider(_key));
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, _) {
if (didPop && !_calibrationComplete) {
ref.read(calibrationProvider(_key).notifier).cancelCalibration();
}
},
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
child: Scaffold(
body: PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
children: [
_IntroPage(
onBegin: _begin,
onCancel: () => _cancelAndClose(context),
),
_TagSelectionPage(
state: state,
sensorKey: _key,
onNext: _commitTag,
),
_CollectingPage(state: state, sensorKey: _key, onFinish: _finish),
_DonePage(state: state, onDone: () => _done(context)),
],
),
),
),
);
}
}
// ---------------------------------------------------------------------------
// Page 1 — Intro
// ---------------------------------------------------------------------------
class _IntroPage extends StatelessWidget {
const _IntroPage({required this.onBegin, required this.onCancel});
final VoidCallback onCancel;
final Future<void> Function() onBegin;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 32),
Icon(Icons.tune, size: 56, color: theme.colorScheme.primary),
const SizedBox(height: 18),
Text(
'Calibrate sensor',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'Calibration improves distance estimation accuracy. You will hold a tag at a series of known distances from the sensor while it collects readings.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
'You will need at least two distance measurements to complete calibration. More distances improve accuracy.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
'Keep the path between the tag and sensor unobstructed during each measurement.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const Spacer(),
_AsyncButton(
onPressed: onBegin,
label: 'Get started',
icon: Icons.arrow_forward,
),
const SizedBox(height: 12),
TextButton(onPressed: onCancel, child: const Text('Cancel')),
],
),
),
);
}
}
// ---------------------------------------------------------------------------
// Page 2 — Tag selection
// ---------------------------------------------------------------------------
class _TagSelectionPage extends ConsumerWidget {
const _TagSelectionPage({
required this.state,
required this.sensorKey,
required this.onNext,
});
final CalibrationState state;
final ({int id, String deviceId}) sensorKey;
final Future<void> Function() onNext;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final notifier = ref.read(calibrationProvider(sensorKey).notifier);
final tagsAsync = ref.watch(tagsProvider);
final tags = tagsAsync.whenOrNull(data: (t) => t) ?? const [];
final readings = state.tagRssiReadings;
// Sort: tags with readings by RSSI descending (nearest first),
// then tags without readings by id.
final sorted = [...tags]
..sort((a, b) {
final ra = readings[a.tagId];
final rb = readings[b.tagId];
if (ra != null && rb != null) return rb.compareTo(ra);
if (ra != null) return -1;
if (rb != null) return 1;
return a.id.compareTo(b.id);
});
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 24),
Text(
'Select your tag',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Hold the tag near the sensor — the one you\'re using will show a stronger signal.',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Expanded(
child: tags.isEmpty
? Center(
child: Text(
'No tags enrolled',
style: TextStyle(
color: theme.colorScheme.onSurfaceVariant,
),
),
)
: ListView.builder(
itemCount: sorted.length,
itemBuilder: (context, i) {
final tag = sorted[i];
final rssi = readings[tag.tagId];
final selected = tag.tagId == state.selectedTagId;
return _TagListTile(
key: ValueKey(tag.tagId),
tag: tag,
rssi: rssi,
selected: selected,
onTap: () => notifier.selectTag(tag.tagId),
);
},
),
),
const SizedBox(height: 16),
_AsyncButton(
onPressed: onNext,
enabled: state.selectedTagId != null,
label: 'Next',
icon: Icons.arrow_forward,
),
],
),
),
);
}
}
// TODO: use 3rd party library
String _formatLastSeen(DateTime dt) {
final diff = DateTime.now().difference(dt);
if (diff.inSeconds < 60) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
return '${diff.inDays}d ago';
}
class _TagListTile extends StatelessWidget {
const _TagListTile({
super.key,
required this.tag,
required this.rssi,
required this.selected,
required this.onTap,
});
final Tag tag;
final double? rssi;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return ListTile(
selected: selected,
selectedTileColor: cs.primaryContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
leading: Icon(
selected ? Icons.radio_button_checked : Icons.radio_button_unchecked,
color: selected ? cs.primary : cs.outline,
),
title: Text(tag.name),
subtitle: Text(
"Last seen ${_formatLastSeen(tag.lastSeen!)}",
style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant),
),
trailing: rssi != null
? Chip(
label: Text(
'${rssi!.round()} dBm',
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
side: BorderSide.none,
padding: const EdgeInsets.symmetric(horizontal: 4),
)
: Chip(
label: Text(
'',
style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant),
),
side: BorderSide.none,
padding: const EdgeInsets.symmetric(horizontal: 4),
),
onTap: onTap,
);
}
}
// ---------------------------------------------------------------------------
// Page 3 — Collecting
// ---------------------------------------------------------------------------
class _CollectingPage extends ConsumerWidget {
const _CollectingPage({
required this.state,
required this.sensorKey,
required this.onFinish,
});
final CalibrationState state;
final ({int id, String deviceId}) sensorKey;
final Future<void> Function() onFinish;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final notifier = ref.read(calibrationProvider(sensorKey).notifier);
final collecting = state.phase == CalibrationPhase.collecting;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Drag handle
Center(
child: Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 24),
// Dot-dash stage indicator
_StageIndicator(
distances: calibrationDistances,
completedDistances: state.completedDistances,
selectedDistance: state.selectedDistance,
),
const SizedBox(height: 18),
// Title + subtitle
Text(
collecting
? 'Hold steady'
: state.selectedDistance != null
? 'Step to ${_fmtDist(state.selectedDistance!)} metres'
: 'Select a distance',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
collecting
? "Don't move until the ring fills."
: 'Place or hold the tag in unobstructed view of the sensor antenna.',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 18),
// Distance chips
_DistanceChips(
distances: calibrationDistances,
completedDistances: state.completedDistances,
selectedDistance: state.selectedDistance,
enabled: !collecting,
onSelect: notifier.selectDistance,
),
const SizedBox(height: 28),
// Ring + pulse
Center(
child: _RingArea(
state: state,
onStart: collecting ? null : notifier.startStage,
),
),
const SizedBox(height: 28),
// Stats row
_StatsRow(
latestRssi: state.latestRssi,
samples: state.samplesCollected,
avgRssi: state.avgRssi,
),
const SizedBox(height: 28),
// Waveform
SizedBox(
height: 72,
child: Padding(
padding: const EdgeInsetsGeometry.symmetric(horizontal: 8),
child: CustomPaint(
painter: _WaveformPainter(
readings: state.waveform,
color: theme.colorScheme.primary,
),
),
),
),
const Spacer(),
// Finish button (shown when >= 2 stages done)
if (state.canFinish)
_AsyncButton(
onPressed: onFinish,
label: 'Finish calibration',
icon: Icons.check,
),
],
),
),
);
}
}
String _fmtDist(double d) =>
d == d.roundToDouble() ? d.toInt().toString() : d.toString();
// ---------------------------------------------------------------------------
// Page 3 — Done
// ---------------------------------------------------------------------------
class _DonePage extends StatelessWidget {
const _DonePage({required this.state, required this.onDone});
final CalibrationState state;
final VoidCallback onDone;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final rssiRef = state.resultRssiRef;
final exp = state.resultPathLossExp;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 32),
Icon(
Icons.check_circle_rounded,
size: 80,
color: Colors.green.shade600,
),
const SizedBox(height: 18),
Text(
'Calibration complete',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'The sensor model has been updated with your measurements.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
if (rssiRef != null && exp != null) ...[
_ResultRow(
label: 'RSSI at 1 m (A)',
value: '${rssiRef.toStringAsFixed(1)} dBm',
),
_ResultRow(
label: 'Path loss exponent (n)',
value: exp.toStringAsFixed(3),
),
// const SizedBox(height: 12),
SizedBox(
height: 240,
child: CustomPaint(
painter: _ModelCurvePainter(
rssiRef: rssiRef,
pathLossExp: exp,
color: theme.colorScheme.primary,
gridColor: theme.colorScheme.outlineVariant,
labelColor: theme.colorScheme.onSurfaceVariant,
),
),
),
],
const Spacer(),
FilledButton(onPressed: onDone, child: const Text('Done')),
],
),
),
);
}
}
// ---------------------------------------------------------------------------
// Sub-widgets
// ---------------------------------------------------------------------------
class _StageIndicator extends StatelessWidget {
const _StageIndicator({
required this.distances,
required this.completedDistances,
required this.selectedDistance,
});
final List<double> distances;
final Set<double> completedDistances;
final double? selectedDistance;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: distances.map((d) {
final isCompleted = completedDistances.contains(d);
final isCurrent = d == selectedDistance && !isCompleted;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: isCurrent ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: isCompleted
? Colors.green.shade500
: isCurrent
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(4),
),
),
);
}).toList(),
);
}
}
class _DistanceChips extends StatelessWidget {
const _DistanceChips({
required this.distances,
required this.completedDistances,
required this.selectedDistance,
required this.enabled,
required this.onSelect,
});
final List<double> distances;
final Set<double> completedDistances;
final double? selectedDistance;
final bool enabled;
final void Function(double) onSelect;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Wrap(
alignment: WrapAlignment.center,
spacing: 8,
children: distances.map((d) {
final completed = completedDistances.contains(d);
final selected = d == selectedDistance;
return FilterChip(
label: Text('${_fmtDist(d)} m'),
avatar: completed ? const Icon(Icons.check, size: 16) : null,
selected: selected,
onSelected: enabled && !completed ? (_) => onSelect(d) : null,
selectedColor: cs.primaryContainer,
checkmarkColor: cs.onPrimaryContainer,
side: completed
? BorderSide(color: Colors.green.shade100, width: 1.5)
: null,
labelStyle: TextStyle(
color: completed
? Colors.black
: selected
? cs.onPrimaryContainer
: null,
),
disabledColor: completed ? Colors.green.shade50 : null,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
);
}).toList(),
);
}
}
class _RingArea extends StatefulWidget {
const _RingArea({required this.state, required this.onStart});
final CalibrationState state;
final Future<void> Function()? onStart;
@override
State<_RingArea> createState() => _RingAreaState();
}
class _RingAreaState extends State<_RingArea> with TickerProviderStateMixin {
final _pulses = <AnimationController>[];
@override
void didUpdateWidget(_RingArea oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.state.phase == CalibrationPhase.collecting &&
widget.state.samplesCollected != oldWidget.state.samplesCollected) {
_spawnPulse();
HapticFeedback.lightImpact();
}
}
void _spawnPulse() {
final ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
setState(() => _pulses.add(ctrl));
ctrl.forward().then((_) {
if (mounted) setState(() => _pulses.remove(ctrl));
ctrl.dispose();
});
}
@override
void dispose() {
for (final c in _pulses) {
c.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final collecting = widget.state.phase == CalibrationPhase.collecting;
const size = 200.0;
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
// One independent pulse ring per in-flight animation.
for (final ctrl in _pulses)
_PulseRing(
controller: ctrl,
color: theme.colorScheme.primary,
size: size,
),
// Ring
CustomPaint(
size: const Size(size, size),
painter: _CalibrationRingPainter(
progress: widget.state.progress,
collecting: collecting,
primaryColor: theme.colorScheme.primary,
trackColor: theme.colorScheme.surfaceContainerHighest,
),
),
// Center content
if (collecting)
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.state.latestRssi != null
? widget.state.latestRssi!.round().toString()
: '',
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 32,
fontWeight: FontWeight.w500,
),
),
Text(
'dBm',
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
)
else if (widget.onStart != null)
Positioned.fill(
child: _AsyncButton(
onPressed: widget.onStart!,
label: 'Start',
circular: true,
),
),
],
),
);
}
}
class _PulseRing extends StatelessWidget {
const _PulseRing({
required this.controller,
required this.color,
required this.size,
});
final AnimationController controller;
final Color color;
final double size;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller,
builder: (_, _) {
final t = controller.value;
final scale = 1.0 + t * 0.5;
final opacity = (1.0 - t) * 0.5;
return Transform.scale(
scale: scale,
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: color.withValues(alpha: opacity),
width: 3,
),
),
),
);
},
);
}
}
class _StatsRow extends StatelessWidget {
const _StatsRow({
required this.latestRssi,
required this.samples,
required this.avgRssi,
});
final double? latestRssi;
final int samples;
final double? avgRssi;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_StatCell(
value: latestRssi != null ? '${latestRssi!.round()}' : '',
label: 'current',
),
_StatCell(value: '$samples', label: 'samples'),
_StatCell(
value: avgRssi != null ? '${avgRssi!.round()}' : '',
label: 'avg dBm',
),
],
);
}
}
class _StatCell extends StatelessWidget {
const _StatCell({required this.value, required this.label});
final String value;
final String label;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
children: [
Text(
value,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 20,
fontWeight: FontWeight.w500,
),
),
Text(
label,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
}
}
class _ResultRow extends StatelessWidget {
const _ResultRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(
child: Text(label, style: Theme.of(context).textTheme.bodySmall),
),
Text(value, style: const TextStyle(fontFamily: 'monospace')),
],
),
);
}
}
/// Button that shows a CircularProgressIndicator while its async [onPressed]
/// callback is running.
class _AsyncButton extends StatefulWidget {
const _AsyncButton({
required this.onPressed,
required this.label,
this.icon,
this.compact = false,
this.circular = false,
this.enabled = true,
});
final Future<void> Function() onPressed;
final String label;
final IconData? icon;
final bool compact;
final bool circular;
final bool enabled;
@override
State<_AsyncButton> createState() => _AsyncButtonState();
}
class _AsyncButtonState extends State<_AsyncButton> {
bool _loading = false;
@override
Widget build(BuildContext context) {
final shape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
);
final child = _loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: widget.icon != null
? Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(widget.label),
const SizedBox(width: 8),
Icon(widget.icon, size: 18),
],
)
: Text(
widget.label,
style: TextStyle(
fontSize: 36,
color: Theme.of(context).colorScheme.primary,
),
);
final canPress = !_loading && widget.enabled;
if (widget.circular) {
final cs = Theme.of(context).colorScheme;
return SizedBox.expand(
child: TextButton(
style: TextButton.styleFrom(
shape: const CircleBorder(),
foregroundColor: cs.primary,
overlayColor: cs.onSurface,
padding: EdgeInsets.zero,
),
onPressed: canPress ? _run : null,
child: child,
),
);
}
if (widget.compact) {
return TextButton(
style: TextButton.styleFrom(
shape: shape,
textStyle: const TextStyle(fontSize: 36, color: Colors.black),
),
onPressed: canPress ? _run : null,
child: child,
);
}
return FilledButton(
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48)),
onPressed: canPress ? _run : null,
child: child,
);
}
Future<void> _run() async {
setState(() => _loading = true);
try {
await widget.onPressed();
} finally {
if (mounted) setState(() => _loading = false);
}
}
}
// ---------------------------------------------------------------------------
// Custom painters
// ---------------------------------------------------------------------------
class _CalibrationRingPainter extends CustomPainter {
const _CalibrationRingPainter({
required this.progress,
required this.collecting,
required this.primaryColor,
required this.trackColor,
});
final double progress;
final bool collecting;
final Color primaryColor;
final Color trackColor;
@override
void paint(Canvas canvas, Size size) {
const strokeWidth = 12.0;
final center = Offset(size.width / 2, size.height / 2);
final radius = (size.width - strokeWidth) / 2;
final rect = Rect.fromCircle(center: center, radius: radius);
final trackPaint = Paint()
..color = trackColor
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
canvas.drawArc(rect, 0, math.pi * 2, false, trackPaint);
if (progress > 0 && collecting) {
final arcPaint = Paint()
..color = primaryColor
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
canvas.drawArc(
rect,
-math.pi / 2,
math.pi * 2 * progress.clamp(0.0, 1.0),
false,
arcPaint,
);
}
}
@override
bool shouldRepaint(_CalibrationRingPainter old) =>
old.progress != progress || old.collecting != collecting;
}
class _WaveformPainter extends CustomPainter {
const _WaveformPainter({required this.readings, required this.color});
final List<double> readings;
final Color color;
static const _minRssi = -100.0;
static const _maxRssi = -30.0;
@override
void paint(Canvas canvas, Size size) {
if (readings.length < 2) return;
final paint = Paint()
..color = color
..strokeWidth = 1.5
..style = PaintingStyle.stroke
..strokeJoin = StrokeJoin.round;
final path = Path();
for (var i = 0; i < readings.length; i++) {
final x = size.width * i / (readings.length - 1);
final norm = ((readings[i] - _minRssi) / (_maxRssi - _minRssi)).clamp(
0.0,
1.0,
);
final y = size.height * (1 - norm);
if (i == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
}
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(_WaveformPainter old) => old.readings != readings;
}
class _ModelCurvePainter extends CustomPainter {
const _ModelCurvePainter({
required this.rssiRef,
required this.pathLossExp,
required this.color,
required this.gridColor,
required this.labelColor,
});
final double rssiRef;
final double pathLossExp;
final Color color;
final Color gridColor;
final Color labelColor;
static const _dMin = 0.1;
static const _dMax = 10.0;
static const _rssiMin = -100.0;
static const _rssiMax = -20.0;
double _rssi(double d) =>
rssiRef - 10 * pathLossExp * math.log(d) / math.ln10;
@override
void paint(Canvas canvas, Size size) {
const padding = 32.0;
final plotW = size.width - padding;
final plotH = size.height - padding;
// Grid lines & labels
final gridPaint = Paint()
..color = gridColor
..strokeWidth = 0.5;
final labelStyle = TextStyle(fontSize: 10, color: labelColor);
for (final rssiVal in [-40.0, -60.0, -80.0]) {
final norm = (_rssiMax - rssiVal) / (_rssiMax - _rssiMin);
final y = padding + norm * plotH;
canvas.drawLine(Offset(padding, y), Offset(size.width, y), gridPaint);
_drawText(canvas, '${rssiVal.toInt()}', Offset(0, y - 6), labelStyle);
}
// Curve
final curvePaint = Paint()
..color = color
..strokeWidth = 2
..style = PaintingStyle.stroke
..strokeJoin = StrokeJoin.round;
final path = Path();
const steps = 100;
for (var i = 0; i <= steps; i++) {
final t = i / steps;
final d = _dMin + t * (_dMax - _dMin);
final rssi = _rssi(d).clamp(_rssiMin, _rssiMax);
final x = padding + t * plotW;
final norm = (_rssiMax - rssi) / (_rssiMax - _rssiMin);
final y = padding + norm * plotH;
if (i == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
}
canvas.drawPath(path, curvePaint);
// X axis labels
for (final dist in [1.0, 3.0, 5.0, 10.0]) {
final t = (dist - _dMin) / (_dMax - _dMin);
final x = padding + t * plotW;
_drawText(
canvas,
'${dist.toInt()}m',
Offset(x - 8, size.height - 14),
labelStyle,
);
}
}
void _drawText(Canvas canvas, String text, Offset offset, TextStyle style) {
final tp = TextPainter(
text: TextSpan(text: text, style: style),
textDirection: TextDirection.ltr,
)..layout();
tp.paint(canvas, offset);
}
@override
bool shouldRepaint(_ModelCurvePainter old) =>
old.rssiRef != rssiRef || old.pathLossExp != pathLossExp;
}