1222 lines
35 KiB
Dart
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;
|
|
}
|