diff --git a/assets/konva/app.js b/assets/konva/app.js index 8ec9a4e..c82f245 100644 --- a/assets/konva/app.js +++ b/assets/konva/app.js @@ -582,14 +582,17 @@ window.companion = { updateCloud(particles) { particlesLayer.destroyChildren(); - particles.forEach(({ x, y, weight }) => { - particlesLayer.add(new Konva.Circle({ - x: x * PPM, y: y * PPM, - radius: 2, - fill: `rgba(255,152,0,${Math.max(0, Math.min(1, weight))})`, - listening: false, - })); - }); + if (particles.length > 0) { + const maxW = Math.max(...particles.map(p => p.weight)); + particles.forEach(({ x, y, weight }) => { + particlesLayer.add(new Konva.Circle({ + x: x * PPM, y: y * PPM, + radius: 2, + fill: `rgba(255,152,0,${maxW > 0 ? weight / maxW : 0})`, + listening: false, + })); + }); + } particlesLayer.batchDraw(); }, diff --git a/lib/data/repositories/phoenix_tag_repository.dart b/lib/data/repositories/phoenix_tag_repository.dart index ccdc433..31546ee 100644 --- a/lib/data/repositories/phoenix_tag_repository.dart +++ b/lib/data/repositories/phoenix_tag_repository.dart @@ -53,13 +53,21 @@ class PhoenixTagRepository implements TagRepository { }); @override - Stream> watchParticleCloud() => - realtime.channel('localiserd').map((payload) { - final list = payload['particles'] as List? ?? []; - return list - .map((j) => Particle.fromJson(j as Map)) - .toList(); - }); + Stream watchParticleCloud(String tagId) => + realtime + .channelMessages('particles:$tagId') + .where((m) => m.event == 'particles_updated') + .map((m) { + final est = m.payload['estimate'] as Map; + final list = m.payload['particles'] as List; + return ParticleSnapshot( + tagId: tagId, + estimate: Estimate.fromJson(est), + particles: list + .map((j) => Particle.fromJson(j as Map)) + .toList(), + ); + }); @override Stream>> watchRoomOccupancy() async* { diff --git a/lib/data/repositories/tag_repository.dart b/lib/data/repositories/tag_repository.dart index fec5142..282b23c 100644 --- a/lib/data/repositories/tag_repository.dart +++ b/lib/data/repositories/tag_repository.dart @@ -11,8 +11,8 @@ abstract class TagRepository { /// Live stream of all tag positions, pushed by localiserd over Phoenix channel. Stream> watchPositions(); - /// Live stream of particle filter cloud snapshots. - Stream> watchParticleCloud(); + /// Live stream of particle filter cloud snapshots for [tagId]. + Stream watchParticleCloud(String tagId); /// Room occupancy delta stream. /// room_id -> list of tag_id strings currently in that room. diff --git a/lib/data/sources/ble/tag_scanner.dart b/lib/data/sources/ble/tag_scanner.dart index 4088edd..9781f62 100644 --- a/lib/data/sources/ble/tag_scanner.dart +++ b/lib/data/sources/ble/tag_scanner.dart @@ -50,8 +50,7 @@ class TagScanResult { class TagScanner { static final _eddystoneGuid = Guid('feaa'); - // Continuously scans for iBeacon / AltBeacon / Eddystone-UID, restarting - // each time the scan window closes, until the returned stream is cancelled. + // Continuously scans for iBeacon / AltBeacon / Eddystone-UID Stream scan() { StreamSubscription>? resultsSub; final seen = {}; // tagId -> last rssi, for dedup diff --git a/lib/domain/models/particle.dart b/lib/domain/models/particle.dart index 220baa8..ea3e4a7 100644 --- a/lib/domain/models/particle.dart +++ b/lib/domain/models/particle.dart @@ -12,6 +12,32 @@ class Particle { factory Particle.fromJson(Map json) => Particle( x: (json['x'] as num).toDouble(), y: (json['y'] as num).toDouble(), - weight: (json['weight'] as num).toDouble(), + weight: (json['w'] as num).toDouble(), ); } + +/// Position estimate from the particle filter (weighted mean). +class Estimate { + const Estimate({required this.x, required this.y}); + + final double x; + final double y; + + factory Estimate.fromJson(Map json) => Estimate( + x: (json['x'] as num).toDouble(), + y: (json['y'] as num).toDouble(), + ); +} + +/// A single `particles_updated` push from the server: estimate + raw cloud. +class ParticleSnapshot { + const ParticleSnapshot({ + required this.tagId, + required this.estimate, + required this.particles, + }); + + final String tagId; + final Estimate estimate; + final List particles; +} diff --git a/lib/features/floorplan/floor_plan_screen.dart b/lib/features/floorplan/floor_plan_screen.dart index 410894f..93f7cff 100644 --- a/lib/features/floorplan/floor_plan_screen.dart +++ b/lib/features/floorplan/floor_plan_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import '../../domain/models/floor_plan_mode.dart'; import '../../domain/models/sensor.dart'; +import '../../domain/models/tag.dart'; import '../../providers.dart'; import '../ble_provision/ble_provision_sheet.dart'; import '../sensors/sensor_detail_sheet.dart'; @@ -87,6 +88,7 @@ class _FloorPlanScreenState extends ConsumerState { final roomsAsync = ref.watch(roomsProvider); final sensorsAsync = ref.watch(sensorsProvider); final placingSensor = ref.watch(sensorPlacementProvider); + final trackedTag = ref.watch(trackedTagProvider); ref.listen(roomsProvider, (_, next) { next.whenData((r) => _editorKey.currentState?.loadFloorPlan(r)); @@ -112,8 +114,14 @@ class _FloorPlanScreenState extends ConsumerState { ref.listen(tagPositionsProvider, (_, next) { next.whenData((t) => _editorKey.currentState?.updateTags(t)); }); - ref.listen(particleCloudProvider, (_, next) { - next.whenData((p) => _editorKey.currentState?.updateParticleCloud(p)); + if (trackedTag != null) { + ref.listen(particleSnapshotProvider(trackedTag.tagId), (_, next) { + next.whenData( + (snap) => _editorKey.currentState?.updateParticleCloud(snap.particles)); + }); + } + ref.listen(trackedTagProvider, (prev, next) { + if (next == null) _editorKey.currentState?.updateParticleCloud([]); }); ref.listen(sensorPlacementProvider, (prev, next) => _applyPlacementState()); @@ -244,6 +252,19 @@ class _FloorPlanScreenState extends ConsumerState { ], ), ), + // Tracking card overlay + if (trackedTag != null && placingSensor == null) + Positioned( + top: 16, + left: 16, + right: 16, + child: _TrackingCard( + tag: trackedTag, + onStop: () { + ref.read(trackedTagProvider.notifier).state = null; + }, + ), + ), // Placement mode overlay if (placingSensor != null) ...[ const Center(child: _PlacementDot()), @@ -390,6 +411,42 @@ class _PlacementDot extends StatelessWidget { } } +class _TrackingCard extends StatelessWidget { + const _TrackingCard({required this.tag, required this.onStop}); + + final Tag tag; + final VoidCallback onStop; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + elevation: 4, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + const Icon(Icons.radar), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(tag.name, style: theme.textTheme.labelLarge), + Text('Tracking particle cloud', + style: theme.textTheme.bodySmall), + ], + ), + ), + TextButton(onPressed: onStop, child: const Text('Stop')), + ], + ), + ), + ); + } +} + class _PlacementCard extends StatelessWidget { const _PlacementCard({ required this.sensor, diff --git a/lib/features/floorplan/widgets/floor_plan_editor.dart b/lib/features/floorplan/widgets/floor_plan_editor.dart index ba0cd6a..c452ef9 100644 --- a/lib/features/floorplan/widgets/floor_plan_editor.dart +++ b/lib/features/floorplan/widgets/floor_plan_editor.dart @@ -181,6 +181,7 @@ class FloorPlanEditorState extends State { final payload = jsonEncode( particles.map((p) => {'x': p.x, 'y': p.y, 'weight': p.weight}).toList(), ); + await _controller.runJavaScript('window.companion.updateCloud($payload)'); }); } diff --git a/lib/features/tags/tag_detail_sheet.dart b/lib/features/tags/tag_detail_sheet.dart index fa529dd..f67a40c 100644 --- a/lib/features/tags/tag_detail_sheet.dart +++ b/lib/features/tags/tag_detail_sheet.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../../domain/models/tag.dart'; import '../../providers.dart'; @@ -36,9 +37,7 @@ class _TagDetailSheetState extends ConsumerState { final name = _nameCtrl.text.trim(); setState(() => _editing = false); if (name.isEmpty || name == tag.name) return; - await ref - .read(tagRepositoryProvider) - .updateTag(tag.id, name: name); + await ref.read(tagRepositoryProvider).updateTag(tag.id, name: name); ref.invalidate(tagProvider(tag.id)); ref.invalidate(tagsProvider); } @@ -48,7 +47,7 @@ class _TagDetailSheetState extends ConsumerState { context: context, builder: (ctx) => AlertDialog( title: const Text('Remove tag?'), - content: const Text('This will unenrol the tag from the system.'), + content: const Text('This will delete the tag from the system.'), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), @@ -75,16 +74,15 @@ class _TagDetailSheetState extends ConsumerState { Widget build(BuildContext context) { final tagAsync = ref.watch(tagProvider(widget.tagId)); final occupancy = ref.watch(roomOccupancyProvider); + final trackedTag = ref.watch(trackedTagProvider); return tagAsync.when( loading: () => const SizedBox( height: 200, child: Center(child: CircularProgressIndicator()), ), - error: (e, _) => SizedBox( - height: 200, - child: Center(child: Text(e.toString())), - ), + error: (e, _) => + SizedBox(height: 200, child: Center(child: Text(e.toString()))), data: (tag) { if (!_editing) _nameCtrl.text = tag.name; @@ -118,16 +116,21 @@ class _TagDetailSheetState extends ConsumerState { ), ), ), - const SizedBox(height: 16), + const SizedBox(height: 8), Row( children: [ + Icon( + tag.currentRoomId != null + ? Icons.label + : Icons.label_outline, + ), + const SizedBox(width: 8), Expanded( child: _editing ? TextField( controller: _nameCtrl, autofocus: true, - style: - Theme.of(context).textTheme.titleLarge, + style: Theme.of(context).textTheme.titleLarge, decoration: const InputDecoration( isDense: true, border: InputBorder.none, @@ -145,9 +148,9 @@ class _TagDetailSheetState extends ConsumerState { onPressed: _editing ? () => _saveName(tag) : () => setState(() { - _editing = true; - _nameCtrl.text = tag.name; - }), + _editing = true; + _nameCtrl.text = tag.name; + }), ), ], ), @@ -165,6 +168,32 @@ class _TagDetailSheetState extends ConsumerState { value: _formatLastSeen(tag.lastSeen!), ), const SizedBox(height: 24), + FilledButton.tonal( + onPressed: () { + final isTracking = trackedTag?.tagId == tag.tagId; + ref.read(trackedTagProvider.notifier).state = isTracking + ? null + : tag; + // TODO: move screen to floor plan + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + trackedTag?.tagId == tag.tagId + ? Icons.radar + : Icons.radar_outlined, + ), + const SizedBox(width: 8), + Text( + trackedTag?.tagId == tag.tagId + ? 'Hide particle cloud' + : 'View particle cloud', + ), + ], + ), + ), + const SizedBox(height: 8), TextButton( style: TextButton.styleFrom( foregroundColor: Theme.of(context).colorScheme.error, @@ -193,11 +222,11 @@ class _InfoRow extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 6), child: Row( children: [ - Expanded( - child: Text(label, - style: Theme.of(context).textTheme.bodySmall), + Text(label, style: Theme.of(context).textTheme.bodyMedium), + SizedBox(width: 12), + Flexible( + child: Text(value, style: Theme.of(context).textTheme.labelMedium), ), - Text(value, style: Theme.of(context).textTheme.bodyMedium), ], ), ); diff --git a/lib/providers.dart b/lib/providers.dart index b4bbb10..f3c516d 100644 --- a/lib/providers.dart +++ b/lib/providers.dart @@ -190,7 +190,12 @@ final roomOccupancyProvider = StreamProvider>>((ref) { return ref.watch(tagRepositoryProvider).watchRoomOccupancy(); }); -final particleCloudProvider = StreamProvider>((ref) { - final repo = ref.watch(tagRepositoryProvider); - return repo.watchParticleCloud(); +/// The tag whose particle cloud is being visualised on the floor plan. +/// Null when no tag is tracked. +final trackedTagProvider = StateProvider((ref) => null); + +/// Live particle snapshots for a specific tag (keyed by tag_id string). +final particleSnapshotProvider = + StreamProvider.autoDispose.family((ref, tagId) { + return ref.watch(tagRepositoryProvider).watchParticleCloud(tagId); });