diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index a1274201..6720454f 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -10,6 +10,11 @@
+
+
+
+
https
+ NSBluetoothAlwaysUsageDescription
+ DeFlock uses Bluetooth to receive detection data from FlockSquawk.
+ UIBackgroundModes
+
+ bluetooth-central
+
UIStatusBarHidden
diff --git a/lib/app_state.dart b/lib/app_state.dart
index 0e8ae093..380096d9 100644
--- a/lib/app_state.dart
+++ b/lib/app_state.dart
@@ -34,12 +34,16 @@ import 'state/session_state.dart';
import 'state/settings_state.dart';
import 'state/suspected_location_state.dart';
import 'state/upload_queue_state.dart';
+import 'state/scanner_state.dart';
+import 'services/scanner_service.dart' show ScannerConnectionStatus, ScannerTransportType;
// Re-export types
export 'state/navigation_state.dart' show AppNavigationMode;
export 'state/settings_state.dart' show UploadMode, FollowMeMode;
export 'state/session_state.dart' show AddNodeSession, EditNodeSession;
export 'models/pending_upload.dart' show UploadOperation;
+export 'state/scanner_state.dart' show ScannerState;
+export 'services/scanner_service.dart' show ScannerConnectionStatus, ScannerTransportType;
// ------------------ AppState ------------------
class AppState extends ChangeNotifier {
@@ -56,6 +60,7 @@ class AppState extends ChangeNotifier {
late final SettingsState _settingsState;
late final SuspectedLocationState _suspectedLocationState;
late final UploadQueueState _uploadQueueState;
+ late final ScannerState _scannerState;
bool _isInitialized = false;
@@ -76,7 +81,8 @@ class AppState extends ChangeNotifier {
_settingsState = SettingsState();
_suspectedLocationState = SuspectedLocationState();
_uploadQueueState = UploadQueueState();
-
+ _scannerState = ScannerState();
+
// Set up state change listeners
_authState.addListener(_onStateChanged);
_messagesState.addListener(_onStateChanged);
@@ -88,7 +94,8 @@ class AppState extends ChangeNotifier {
_settingsState.addListener(_onStateChanged);
_suspectedLocationState.addListener(_onStateChanged);
_uploadQueueState.addListener(_onStateChanged);
-
+ _scannerState.addListener(_onStateChanged);
+
_init();
}
@@ -185,6 +192,13 @@ class AppState extends ChangeNotifier {
double? get suspectedLocationsDownloadProgress => _suspectedLocationState.downloadProgress;
Future get suspectedLocationsLastFetch => _suspectedLocationState.lastFetchTime;
+ // Scanner state
+ ScannerConnectionStatus get scannerConnectionStatus => _scannerState.connectionStatus;
+ bool get isScannerConnected => _scannerState.isConnected;
+ int get scannerDetectionCount => _scannerState.detectionCount;
+ ScannerTransportType get scannerTransportType => _scannerState.activeTransportType;
+ ScannerState get scannerState => _scannerState;
+
void _onStateChanged() {
notifyListeners();
}
@@ -224,6 +238,7 @@ class AppState extends ChangeNotifier {
}
await _suspectedLocationState.init(offlineMode: _settingsState.offlineMode);
+ await _scannerState.init();
await _uploadQueueState.init();
await _authState.init(_settingsState.uploadMode);
@@ -829,12 +844,12 @@ class AppState extends ChangeNotifier {
final profileName = profile?.name.startsWith('<') == true && profile?.name.endsWith('>') == true
? 'a'
: profile?.name ?? 'surveillance';
-
+
switch (operation) {
case UploadOperation.create:
return 'Add $profileName surveillance node';
case UploadOperation.modify:
- return 'Update $profileName surveillance node';
+ return 'Update $profileName surveillance node';
case UploadOperation.delete:
return 'Delete $profileName surveillance node';
case UploadOperation.extract:
@@ -842,6 +857,19 @@ class AppState extends ChangeNotifier {
}
}
+ // ---------- Scanner Methods ----------
+ Future reconnectScanner() async {
+ return await _scannerState.reconnect();
+ }
+
+ Future disconnectScanner() async {
+ await _scannerState.disconnect();
+ }
+
+ Future linkDetectionToNode(String mac, int osmNodeId) async {
+ await _scannerState.linkDetectionToNode(mac, osmNodeId);
+ }
+
// ---------- Private Methods ----------
/// Attempts to fetch missing tile preview images in the background (fire and forget)
void _fetchMissingTilePreviews() {
@@ -874,7 +902,9 @@ class AppState extends ChangeNotifier {
_settingsState.removeListener(_onStateChanged);
_suspectedLocationState.removeListener(_onStateChanged);
_uploadQueueState.removeListener(_onStateChanged);
-
+ _scannerState.removeListener(_onStateChanged);
+
+ _scannerState.dispose();
_uploadQueueState.dispose();
super.dispose();
}
diff --git a/lib/main.dart b/lib/main.dart
index 9bd2d565..12d91e41 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -13,6 +13,7 @@ import 'screens/about_screen.dart';
import 'screens/release_notes_screen.dart';
import 'screens/osm_account_screen.dart';
import 'screens/upload_queue_screen.dart';
+import 'screens/scanner_screen.dart';
import 'services/localization_service.dart';
import 'services/version_service.dart';
import 'services/deep_link_service.dart';
@@ -86,6 +87,7 @@ class DeFlockApp extends StatelessWidget {
'/settings/language': (context) => const LanguageSettingsScreen(),
'/settings/about': (context) => const AboutScreen(),
'/settings/release-notes': (context) => const ReleaseNotesScreen(),
+ '/scanner': (context) => const ScannerScreen(),
},
initialRoute: '/',
diff --git a/lib/models/rf_detection.dart b/lib/models/rf_detection.dart
new file mode 100644
index 00000000..1f7461ec
--- /dev/null
+++ b/lib/models/rf_detection.dart
@@ -0,0 +1,377 @@
+import 'dart:convert';
+import 'package:latlong2/latlong.dart';
+
+/// A unique RF device identified by MAC address, with aggregated detection data.
+/// Maps to the `rf_devices` SQLite table.
+class RfDetection {
+ final String mac;
+ final String oui;
+ String label;
+ final String radioType; // "WiFi" or "BLE"
+ String category;
+ int alertLevel;
+ int maxCertainty;
+ int matchFlags;
+ Map detectorData;
+ String? ssid;
+ String? bleName;
+ String? bleServiceUuids;
+ int? osmNodeId;
+ DateTime firstSeenAt;
+ DateTime lastSeenAt;
+ int sightingCount;
+ String? notes;
+
+ /// Best known position (from most recent sighting, joined at query time).
+ LatLng? bestPosition;
+
+ RfDetection({
+ required this.mac,
+ required this.oui,
+ required this.label,
+ required this.radioType,
+ required this.category,
+ required this.alertLevel,
+ required this.maxCertainty,
+ required this.matchFlags,
+ required this.detectorData,
+ this.ssid,
+ this.bleName,
+ this.bleServiceUuids,
+ this.osmNodeId,
+ required this.firstSeenAt,
+ required this.lastSeenAt,
+ this.sightingCount = 1,
+ this.notes,
+ this.bestPosition,
+ });
+
+ /// Parse from scanner serial JSON + phone GPS position.
+ ///
+ /// Supports two formats:
+ ///
+ /// **FlockSquawk** (nested, `event: "target_detected"`):
+ /// ```json
+ /// {
+ /// "event": "target_detected",
+ /// "source": { "radio": "WiFi"|"BLE", "channel": 6, "rssi": -45 },
+ /// "target": { "mac": "b4:1e:52:aa:bb:cc", "label": "Flock-a1b2c3",
+ /// "certainty": 90, "alert_level": 3, "category": "flock_safety_camera",
+ /// "detectors": { "ssid_format": 75, "flock_oui": 90 } }
+ /// }
+ /// ```
+ ///
+ /// **flock-you** (flat, `event: "detection"`):
+ /// ```json
+ /// {
+ /// "event": "detection", "detection_method": "mac_prefix",
+ /// "protocol": "bluetooth_le", "mac_address": "58:8e:81:fd:9b:ca",
+ /// "device_name": "FS Ext Battery", "rssi": -65,
+ /// "is_raven": false, "raven_fw": ""
+ /// }
+ /// ```
+ factory RfDetection.fromSerialJson(
+ Map json,
+ LatLng gpsPos,
+ DateTime now,
+ ) {
+ // Detect format: flock-you has "mac_address" at top level, FlockSquawk has "target"
+ if (json.containsKey('mac_address')) {
+ return RfDetection._fromFlockyouJson(json, gpsPos, now);
+ }
+ return RfDetection._fromFlockSquawkJson(json, gpsPos, now);
+ }
+
+ /// Parse FlockSquawk nested format.
+ factory RfDetection._fromFlockSquawkJson(
+ Map json,
+ LatLng gpsPos,
+ DateTime now,
+ ) {
+ final target = json['target'] as Map;
+ final source = json['source'] as Map;
+ final mac = (target['mac'] as String).toLowerCase();
+ final oui = mac.substring(0, 8); // "b4:1e:52"
+ final radioType = source['radio'] as String;
+
+ // Extract detector data
+ final detectorsRaw = target['detectors'] as Map? ?? {};
+ final detectorData = detectorsRaw.map(
+ (k, v) => MapEntry(k, (v as num).toInt()),
+ );
+
+ // Compute matchFlags from detector keys
+ int matchFlags = 0;
+ for (final key in detectorsRaw.keys) {
+ final bit = _detectorNameToBit(key);
+ if (bit >= 0) matchFlags |= (1 << bit);
+ }
+
+ final label = target['label'] as String? ?? mac;
+
+ return RfDetection(
+ mac: mac,
+ oui: oui,
+ label: label,
+ radioType: radioType,
+ category: target['category'] as String? ?? 'unknown',
+ alertLevel: (target['alert_level'] as num?)?.toInt() ?? 0,
+ maxCertainty: (target['certainty'] as num?)?.toInt() ?? 0,
+ matchFlags: matchFlags,
+ detectorData: detectorData,
+ ssid: radioType == 'WiFi' ? label : null,
+ bleName: radioType == 'BLE' ? label : null,
+ firstSeenAt: now,
+ lastSeenAt: now,
+ sightingCount: 1,
+ bestPosition: gpsPos,
+ );
+ }
+
+ /// Parse flock-you flat format.
+ factory RfDetection._fromFlockyouJson(
+ Map json,
+ LatLng gpsPos,
+ DateTime now,
+ ) {
+ final mac = (json['mac_address'] as String).toLowerCase();
+ final oui = mac.substring(0, 8);
+ final label = json['device_name'] as String? ?? mac;
+ final method = json['detection_method'] as String? ?? '';
+ final isRaven = json['is_raven'] as bool? ?? false;
+ final ravenFw = json['raven_fw'] as String? ?? '';
+
+ // Map protocol string to radio type
+ final protocol = json['protocol'] as String? ?? '';
+ final radioType = protocol == 'bluetooth_le' ? 'BLE' : protocol;
+
+ // Map detection_method → matchFlags bit + certainty + alert_level
+ int matchFlags = 0;
+ final methodBit = _flockyouMethodToBit(method);
+ if (methodBit >= 0) matchFlags = 1 << methodBit;
+
+ final certainty = _flockyouMethodCertainty(method);
+ final alertLevel = _flockyouMethodAlertLevel(method);
+
+ // Build detector data
+ final Map detectorData = {method: certainty};
+ if (isRaven && ravenFw.isNotEmpty) {
+ detectorData['raven_fw'] = 0; // metadata flag — fw version stored as key
+ }
+
+ final category = isRaven ? 'acoustic_detector' : 'unknown';
+
+ return RfDetection(
+ mac: mac,
+ oui: oui,
+ label: label,
+ radioType: radioType,
+ category: category,
+ alertLevel: alertLevel,
+ maxCertainty: certainty,
+ matchFlags: matchFlags,
+ detectorData: detectorData,
+ bleName: radioType == 'BLE' ? label : null,
+ firstSeenAt: now,
+ lastSeenAt: now,
+ sightingCount: 1,
+ bestPosition: gpsPos,
+ );
+ }
+
+ Map toDbRow() {
+ return {
+ 'mac': mac,
+ 'oui': oui,
+ 'label': label,
+ 'radio_type': radioType,
+ 'category': category,
+ 'alert_level': alertLevel,
+ 'max_certainty': maxCertainty,
+ 'match_flags': matchFlags,
+ 'detector_data': jsonEncode(detectorData),
+ 'ssid': ssid,
+ 'ble_name': bleName,
+ 'ble_service_uuids': bleServiceUuids,
+ 'osm_node_id': osmNodeId,
+ 'first_seen_at': firstSeenAt.toIso8601String(),
+ 'last_seen_at': lastSeenAt.toIso8601String(),
+ 'sighting_count': sightingCount,
+ 'notes': notes,
+ };
+ }
+
+ factory RfDetection.fromDbRow(Map row) {
+ Map detectorData = {};
+ final detectorDataStr = row['detector_data'] as String?;
+ if (detectorDataStr != null && detectorDataStr.isNotEmpty) {
+ final decoded = jsonDecode(detectorDataStr) as Map;
+ detectorData = decoded.map((k, v) => MapEntry(k, (v as num).toInt()));
+ }
+
+ return RfDetection(
+ mac: row['mac'] as String,
+ oui: row['oui'] as String,
+ label: row['label'] as String,
+ radioType: row['radio_type'] as String,
+ category: row['category'] as String,
+ alertLevel: row['alert_level'] as int,
+ maxCertainty: row['max_certainty'] as int,
+ matchFlags: row['match_flags'] as int,
+ detectorData: detectorData,
+ ssid: row['ssid'] as String?,
+ bleName: row['ble_name'] as String?,
+ bleServiceUuids: row['ble_service_uuids'] as String?,
+ osmNodeId: row['osm_node_id'] as int?,
+ firstSeenAt: DateTime.parse(row['first_seen_at'] as String),
+ lastSeenAt: DateTime.parse(row['last_seen_at'] as String),
+ sightingCount: row['sighting_count'] as int? ?? 1,
+ notes: row['notes'] as String?,
+ // bestPosition joined at query time via latest sighting
+ bestPosition: row['latest_lat'] != null && row['latest_lng'] != null
+ ? LatLng(
+ (row['latest_lat'] as num).toDouble(),
+ (row['latest_lng'] as num).toDouble(),
+ )
+ : null,
+ );
+ }
+
+ bool get isSubmitted => osmNodeId != null;
+
+ /// Map detector name strings (from JSON) to bit positions matching DetectorFlag enum.
+ static int _detectorNameToBit(String name) {
+ const mapping = {
+ 'ssid_format': 0,
+ 'ssid_keyword': 1,
+ 'mac_oui': 2,
+ 'ble_name': 3,
+ 'raven_custom_uuid': 4,
+ 'raven_std_uuid': 5,
+ 'rssi_modifier': 6,
+ 'flock_oui': 7,
+ 'surveillance_oui': 8,
+ };
+ return mapping[name] ?? -1;
+ }
+
+ /// Map flock-you detection_method strings to matchFlags bit positions.
+ static int _flockyouMethodToBit(String method) {
+ const mapping = {
+ 'mac_prefix': 7, // same as flock_oui
+ 'ble_name': 3, // same as ble_name
+ 'ble_mfr_id': 8, // same as surveillance_oui
+ 'raven_uuid': 4, // same as raven_custom_uuid
+ };
+ return mapping[method] ?? -1;
+ }
+
+ /// Map flock-you detection_method to certainty score.
+ static int _flockyouMethodCertainty(String method) {
+ const mapping = {
+ 'mac_prefix': 20,
+ 'ble_name': 55,
+ 'ble_mfr_id': 45,
+ 'raven_uuid': 80,
+ };
+ return mapping[method] ?? 10;
+ }
+
+ /// Map flock-you detection_method to alert level.
+ static int _flockyouMethodAlertLevel(String method) {
+ const mapping = {
+ 'mac_prefix': 2,
+ 'ble_name': 2,
+ 'ble_mfr_id': 2,
+ 'raven_uuid': 3,
+ };
+ return mapping[method] ?? 2;
+ }
+}
+
+/// A single GPS-stamped sighting of an RF device.
+/// Maps to the `rf_sightings` SQLite table.
+class RfSighting {
+ final int? id;
+ final String mac;
+ final LatLng coord;
+ final double? gpsAccuracy;
+ final int rssi;
+ final int? channel;
+ final DateTime seenAt;
+ final String? rawJson;
+
+ RfSighting({
+ this.id,
+ required this.mac,
+ required this.coord,
+ this.gpsAccuracy,
+ required this.rssi,
+ this.channel,
+ required this.seenAt,
+ this.rawJson,
+ });
+
+ factory RfSighting.fromSerialJson(
+ Map json,
+ LatLng gpsPos,
+ double? gpsAccuracy,
+ DateTime now,
+ ) {
+ // Detect format: flock-you has "mac_address" at top level
+ if (json.containsKey('mac_address')) {
+ final mac = (json['mac_address'] as String).toLowerCase();
+ return RfSighting(
+ mac: mac,
+ coord: gpsPos,
+ gpsAccuracy: gpsAccuracy,
+ rssi: (json['rssi'] as num).toInt(),
+ seenAt: now,
+ rawJson: jsonEncode(json),
+ );
+ }
+
+ final target = json['target'] as Map;
+ final source = json['source'] as Map;
+ final mac = (target['mac'] as String).toLowerCase();
+
+ return RfSighting(
+ mac: mac,
+ coord: gpsPos,
+ gpsAccuracy: gpsAccuracy,
+ rssi: (source['rssi'] as num).toInt(),
+ channel: (source['channel'] as num?)?.toInt(),
+ seenAt: now,
+ rawJson: jsonEncode(json),
+ );
+ }
+
+ Map toDbRow() {
+ return {
+ 'mac': mac,
+ 'lat': coord.latitude,
+ 'lng': coord.longitude,
+ 'gps_accuracy': gpsAccuracy,
+ 'rssi': rssi,
+ 'channel': channel,
+ 'seen_at': seenAt.toIso8601String(),
+ 'raw_json': rawJson,
+ };
+ }
+
+ factory RfSighting.fromDbRow(Map row) {
+ return RfSighting(
+ id: row['id'] as int?,
+ mac: row['mac'] as String,
+ coord: LatLng(
+ (row['lat'] as num).toDouble(),
+ (row['lng'] as num).toDouble(),
+ ),
+ gpsAccuracy: (row['gps_accuracy'] as num?)?.toDouble(),
+ rssi: row['rssi'] as int,
+ channel: row['channel'] as int?,
+ seenAt: DateTime.parse(row['seen_at'] as String),
+ rawJson: row['raw_json'] as String?,
+ );
+ }
+}
diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart
index ff9702e6..78a65ad6 100644
--- a/lib/screens/home_screen.dart
+++ b/lib/screens/home_screen.dart
@@ -13,9 +13,12 @@ import '../widgets/download_area_dialog.dart';
import '../widgets/measured_sheet.dart';
import '../widgets/search_bar.dart';
import '../widgets/suspected_location_sheet.dart';
+import '../widgets/rf_detection_sheet.dart';
+import '../widgets/scanner_status_indicator.dart';
import '../widgets/welcome_dialog.dart';
import '../widgets/changelog_dialog.dart';
import '../models/osm_node.dart';
+import '../models/rf_detection.dart';
import '../models/suspected_location.dart';
import '../models/search_result.dart';
import '../services/changelog_service.dart';
@@ -50,6 +53,10 @@ class _HomeScreenState extends State with TickerProviderStateMixin {
// Track popup display to avoid showing multiple times
bool _hasCheckedForPopup = false;
+ // RF detections for map markers
+ List _rfDetections = [];
+ bool _rfDetectionsLoading = false;
+
@override
void initState() {
super.initState();
@@ -57,10 +64,22 @@ class _HomeScreenState extends State with TickerProviderStateMixin {
_sheetCoordinator = SheetCoordinator();
_navigationCoordinator = NavigationCoordinator();
_mapInteractionHandler = MapInteractionHandler();
+
+ // Listen to scanner state changes to reload RF detections
+ final appState = context.read();
+ appState.scannerState.addListener(_onScannerStateChanged);
+ }
+
+ void _onScannerStateChanged() {
+ _loadRfDetections();
}
@override
void dispose() {
+ // Use try/catch in case context is no longer valid
+ try {
+ context.read().scannerState.removeListener(_onScannerStateChanged);
+ } catch (_) {}
_mapController.dispose();
super.dispose();
}
@@ -378,6 +397,57 @@ class _HomeScreenState extends State with TickerProviderStateMixin {
});
}
+ Future _loadRfDetections() async {
+ if (_rfDetectionsLoading) return;
+ _rfDetectionsLoading = true;
+ try {
+ final appState = context.read();
+ final bounds = _mapController.mapController.camera.visibleBounds;
+ final detections = await appState.scannerState.getDetectionsInBounds(
+ north: bounds.north,
+ south: bounds.south,
+ east: bounds.east,
+ west: bounds.west,
+ );
+ if (mounted) {
+ setState(() => _rfDetections = detections);
+ }
+ } catch (_) {
+ // Map not ready or DB error — ignore
+ } finally {
+ _rfDetectionsLoading = false;
+ }
+ }
+
+ void openRfDetectionSheet(RfDetection detection) {
+ // Disable follow-me when viewing a detection
+ final appState = context.read();
+ if (appState.followMeMode != FollowMeMode.off) {
+ appState.setFollowMeMode(FollowMeMode.off);
+ }
+
+ final controller = _scaffoldKey.currentState!.showBottomSheet(
+ (ctx) => Padding(
+ padding: EdgeInsets.only(
+ bottom: MediaQuery.of(context).padding.bottom,
+ ),
+ child: MeasuredSheet(
+ onHeightChanged: (height) {
+ _sheetCoordinator.updateTagSheetHeight(
+ height + MediaQuery.of(context).padding.bottom,
+ () => setState(() {}),
+ );
+ },
+ child: RfDetectionSheet(detection: detection),
+ ),
+ ),
+ );
+
+ controller.closed.then((_) {
+ _sheetCoordinator.resetTagSheetHeight(() => setState(() {}));
+ });
+ }
+
@override
Widget build(BuildContext context) {
final appState = context.watch();
@@ -432,6 +502,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin {
fit: BoxFit.contain,
),
actions: [
+ const ScannerStatusIndicator(),
IconButton(
tooltip: _getFollowMeTooltip(appState.followMeMode),
icon: Icon(_getFollowMeIcon(appState.followMeMode)),
@@ -488,6 +559,8 @@ class _HomeScreenState extends State with TickerProviderStateMixin {
selectedNodeId: _selectedNodeId,
onNodeTap: openNodeTagSheet,
onSuspectedLocationTap: openSuspectedLocationSheet,
+ onRfDetectionTap: openRfDetectionSheet,
+ rfDetections: _rfDetections,
onSearchPressed: _onNavigationButtonPressed,
onNodeLimitChanged: (isLimited) {
setState(() {
diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart
new file mode 100644
index 00000000..e4db391e
--- /dev/null
+++ b/lib/screens/scanner_screen.dart
@@ -0,0 +1,447 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../app_state.dart';
+import '../models/rf_detection.dart';
+
+/// Full-screen scanner management: live detection feed, stats, USB connection.
+class ScannerScreen extends StatefulWidget {
+ const ScannerScreen({super.key});
+
+ @override
+ State createState() => _ScannerScreenState();
+}
+
+class _ScannerScreenState extends State {
+ int? _alertLevelFilter;
+ Map? _stats;
+
+ @override
+ void initState() {
+ super.initState();
+ _loadStats();
+ }
+
+ Future _loadStats() async {
+ final appState = context.read();
+ final stats = await appState.scannerState.getStats();
+ if (mounted) {
+ setState(() => _stats = stats);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final appState = context.watch();
+ final theme = Theme.of(context);
+ final status = appState.scannerConnectionStatus;
+ final transport = appState.scannerTransportType;
+ final isBle = transport == ScannerTransportType.ble;
+
+ // Transport-aware icon
+ final IconData connectionIcon;
+ final Color? connectionColor;
+ if (appState.isScannerConnected) {
+ connectionIcon = isBle ? Icons.bluetooth_connected : Icons.usb;
+ connectionColor = Colors.green;
+ } else {
+ connectionIcon = isBle ? Icons.bluetooth : Icons.usb_off;
+ connectionColor = null;
+ }
+
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('RF Scanner'),
+ actions: [
+ // Connection status + reconnect
+ IconButton(
+ icon: Icon(connectionIcon, color: connectionColor),
+ tooltip: appState.isScannerConnected
+ ? 'Connected via ${isBle ? 'Bluetooth' : 'USB'}'
+ : 'Reconnect',
+ onPressed: appState.isScannerConnected
+ ? () => appState.disconnectScanner()
+ : () => appState.reconnectScanner(),
+ ),
+ ],
+ ),
+ body: Column(
+ children: [
+ // Status banner
+ _ConnectionBanner(status: status, transport: transport, error: appState.scannerState.lastError),
+
+ // Stats row
+ if (_stats != null)
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ child: Row(
+ children: [
+ _StatChip(
+ label: 'Total',
+ value: '${_stats!['total']}',
+ color: theme.colorScheme.primary,
+ ),
+ const SizedBox(width: 8),
+ _StatChip(
+ label: 'Submitted',
+ value: '${_stats!['submitted']}',
+ color: Colors.green,
+ ),
+ const SizedBox(width: 8),
+ _StatChip(
+ label: 'Pending',
+ value: '${_stats!['unsubmitted']}',
+ color: Colors.orange,
+ ),
+ ],
+ ),
+ ),
+
+ // Alert level filter chips
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: Row(
+ children: [
+ Text('Filter: ', style: theme.textTheme.bodySmall),
+ const SizedBox(width: 4),
+ _FilterChip(
+ label: 'All',
+ selected: _alertLevelFilter == null,
+ onTap: () => setState(() => _alertLevelFilter = null),
+ ),
+ const SizedBox(width: 4),
+ _FilterChip(
+ label: 'Confirmed',
+ selected: _alertLevelFilter == 3,
+ onTap: () => setState(() => _alertLevelFilter = 3),
+ color: Colors.red,
+ ),
+ const SizedBox(width: 4),
+ _FilterChip(
+ label: 'Suspicious',
+ selected: _alertLevelFilter == 2,
+ onTap: () => setState(() => _alertLevelFilter = 2),
+ color: Colors.orange,
+ ),
+ const SizedBox(width: 4),
+ _FilterChip(
+ label: 'Info',
+ selected: _alertLevelFilter == 1,
+ onTap: () => setState(() => _alertLevelFilter = 1),
+ color: Colors.amber,
+ ),
+ ],
+ ),
+ ),
+
+ const Divider(),
+
+ // Detection list
+ Expanded(
+ child: _DetectionList(
+ scannerState: appState.scannerState,
+ alertLevelFilter: _alertLevelFilter,
+ onStatsChanged: _loadStats,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _ConnectionBanner extends StatelessWidget {
+ final ScannerConnectionStatus status;
+ final ScannerTransportType transport;
+ final String? error;
+
+ const _ConnectionBanner({required this.status, required this.transport, this.error});
+
+ @override
+ Widget build(BuildContext context) {
+ final Color bgColor;
+ final String text;
+ final transportName = transport == ScannerTransportType.ble ? 'Bluetooth' : 'USB';
+
+ switch (status) {
+ case ScannerConnectionStatus.connected:
+ bgColor = Colors.green;
+ text = 'Scanner connected via $transportName';
+ case ScannerConnectionStatus.connecting:
+ bgColor = Colors.orange;
+ text = 'Connecting via $transportName...';
+ case ScannerConnectionStatus.error:
+ bgColor = Colors.red;
+ text = error ?? 'Scanner error';
+ case ScannerConnectionStatus.disconnected:
+ bgColor = Colors.grey;
+ text = 'Scanner disconnected — searching for FlockSquawk...';
+ }
+
+ return Container(
+ width: double.infinity,
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ color: bgColor.withValues(alpha: 0.15),
+ child: Row(
+ children: [
+ Icon(
+ status == ScannerConnectionStatus.connected
+ ? Icons.sensors
+ : Icons.sensors_off,
+ size: 16,
+ color: bgColor,
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ text,
+ style: TextStyle(color: bgColor, fontSize: 13),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _DetectionList extends StatefulWidget {
+ final ScannerState scannerState;
+ final int? alertLevelFilter;
+ final VoidCallback onStatsChanged;
+
+ const _DetectionList({
+ required this.scannerState,
+ this.alertLevelFilter,
+ required this.onStatsChanged,
+ });
+
+ @override
+ State<_DetectionList> createState() => _DetectionListState();
+}
+
+class _DetectionListState extends State<_DetectionList> {
+ List? _detections;
+ bool _loading = true;
+
+ @override
+ void initState() {
+ super.initState();
+ _load();
+ // Reload when scanner state changes (new detections)
+ widget.scannerState.addListener(_load);
+ }
+
+ @override
+ void didUpdateWidget(_DetectionList old) {
+ super.didUpdateWidget(old);
+ if (old.alertLevelFilter != widget.alertLevelFilter) {
+ _load();
+ }
+ }
+
+ @override
+ void dispose() {
+ widget.scannerState.removeListener(_load);
+ super.dispose();
+ }
+
+ Future _load() async {
+ final detections = await widget.scannerState.getDetections(
+ minAlertLevel: widget.alertLevelFilter,
+ limit: 200,
+ );
+ if (mounted) {
+ setState(() {
+ _detections = detections;
+ _loading = false;
+ });
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ if (_loading) {
+ return const Center(child: CircularProgressIndicator());
+ }
+
+ if (_detections == null || _detections!.isEmpty) {
+ return Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(Icons.sensors_off, size: 48, color: Colors.grey.shade400),
+ const SizedBox(height: 8),
+ Text(
+ 'No detections yet',
+ style: TextStyle(color: Colors.grey.shade600),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ 'Connect M5StickC and drive near surveillance devices',
+ style: TextStyle(color: Colors.grey.shade500, fontSize: 12),
+ textAlign: TextAlign.center,
+ ),
+ ],
+ ),
+ );
+ }
+
+ return RefreshIndicator(
+ onRefresh: () async {
+ await _load();
+ widget.onStatsChanged();
+ },
+ child: ListView.separated(
+ itemCount: _detections!.length,
+ separatorBuilder: (_, _) => const Divider(height: 1),
+ itemBuilder: (context, index) {
+ final detection = _detections![index];
+ return _DetectionTile(
+ detection: detection,
+ onDelete: () async {
+ await widget.scannerState.deleteDetection(detection.mac);
+ widget.onStatsChanged();
+ },
+ );
+ },
+ ),
+ );
+ }
+}
+
+class _DetectionTile extends StatelessWidget {
+ final RfDetection detection;
+ final VoidCallback onDelete;
+
+ const _DetectionTile({required this.detection, required this.onDelete});
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+
+ final Color alertColor;
+ switch (detection.alertLevel) {
+ case 3:
+ alertColor = Colors.red;
+ case 2:
+ alertColor = Colors.orange;
+ case 1:
+ alertColor = Colors.amber;
+ default:
+ alertColor = Colors.grey;
+ }
+
+ return Dismissible(
+ key: ValueKey(detection.mac),
+ direction: DismissDirection.endToStart,
+ background: Container(
+ color: Colors.red,
+ alignment: Alignment.centerRight,
+ padding: const EdgeInsets.only(right: 16),
+ child: const Icon(Icons.delete, color: Colors.white),
+ ),
+ onDismissed: (_) => onDelete(),
+ child: ListTile(
+ leading: Container(
+ width: 12,
+ height: 12,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: alertColor,
+ ),
+ ),
+ title: Text(
+ detection.label,
+ overflow: TextOverflow.ellipsis,
+ ),
+ subtitle: Text(
+ '${detection.category} | ${detection.radioType} | ${detection.sightingCount} sighting${detection.sightingCount == 1 ? '' : 's'}',
+ style: theme.textTheme.bodySmall,
+ ),
+ trailing: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (detection.isSubmitted)
+ const Icon(Icons.check_circle, color: Colors.green, size: 16),
+ const SizedBox(width: 4),
+ Text(
+ '${detection.maxCertainty}%',
+ style: theme.textTheme.bodySmall?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _StatChip extends StatelessWidget {
+ final String label;
+ final String value;
+ final Color color;
+
+ const _StatChip({
+ required this.label,
+ required this.value,
+ required this.color,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Text(
+ '$label: $value',
+ style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w600),
+ ),
+ );
+ }
+}
+
+class _FilterChip extends StatelessWidget {
+ final String label;
+ final bool selected;
+ final VoidCallback onTap;
+ final Color? color;
+
+ const _FilterChip({
+ required this.label,
+ required this.selected,
+ required this.onTap,
+ this.color,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final chipColor = color ?? theme.colorScheme.primary;
+
+ return GestureDetector(
+ onTap: onTap,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ decoration: BoxDecoration(
+ color: selected ? chipColor.withValues(alpha: 0.2) : Colors.transparent,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: selected ? chipColor : theme.colorScheme.outline.withValues(alpha: 0.3),
+ ),
+ ),
+ child: Text(
+ label,
+ style: TextStyle(
+ fontSize: 11,
+ color: selected ? chipColor : theme.colorScheme.onSurface.withValues(alpha: 0.6),
+ fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/services/ble_scanner_service.dart b/lib/services/ble_scanner_service.dart
new file mode 100644
index 00000000..afbdac0e
--- /dev/null
+++ b/lib/services/ble_scanner_service.dart
@@ -0,0 +1,302 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter_blue_plus/flutter_blue_plus.dart';
+
+import 'scanner_service.dart';
+import 'json_line_parser.dart';
+
+/// FlockSquawk BLE GATT UUIDs — must match the ESP32 firmware.
+class FlockSquawkBleUuids {
+ static final Guid service = Guid('a1b2c3d4-e5f6-7890-abcd-ef0123456789');
+ static final Guid txCharacteristic =
+ Guid('a1b2c3d4-e5f6-7890-abcd-ef01234567aa');
+}
+
+/// BLE transport for FlockSquawk.
+///
+/// Scans for a device advertising the FlockSquawk service UUID, connects,
+/// subscribes to notify on the TX characteristic, and feeds incoming bytes
+/// through [JsonLineParser] to produce detection events.
+class BleScannerService with JsonLineParser implements ScannerService {
+ BluetoothDevice? _device;
+ StreamSubscription>? _scanSubscription;
+ StreamSubscription? _connectionSubscription;
+ StreamSubscription>? _characteristicSubscription;
+ StreamSubscription? _adapterSubscription;
+ Timer? _reconnectTimer;
+
+ final _eventController = StreamController