import 'package:battery_plus/battery_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:geolocator/geolocator.dart'; /// ───────────────────────────────────────────────────────────────────────── /// WHAT THE CONSOLE NEEDS TO SEE ABOUT THE HANDSET /// /// The dispatcher watching a rider is not only asking *where is he*. When a /// rider stops reporting, the question is **why** — and the answers are all /// facts about the phone: the battery died, the signal dropped, he turned /// location off, the app went to the background and the OS stopped waking it. /// `POST /miler/logs` has fields for every one of them: /// /// ``` /// battery is_charging connection location_service accuracy is_background /// ``` /// /// ── Why the console was showing an em dash for all of it ── /// /// Not because nothing was measured. Every one of these was already being read /// on the heartbeat — and published to **MQTT only**, in a block that ran /// *after* the log had been posted. So the telemetry existed, reached a /// different transport, and the rider-log row the console actually reads /// carried nothing but a position. /// /// It was two lanes for one fact, and the lane the console reads was the empty /// one. This is the reader both now share: one place that knows how to ask the /// handset, so the MQTT publish and the log post cannot report different /// batteries a second apart. /// /// ── Never throws, never blocks ── /// /// Telemetry is the least important thing the app does. A permissions prompt, a /// platform channel that hangs, a plugin missing on a desktop test host — none /// of them may cost the rider his heartbeat, so every read is guarded /// individually and an unavailable one simply does not appear in the map. /// A field that is absent renders as the em dash it already rendered as; a /// field that is *wrong* would be read as fact by somebody making a decision. /// ───────────────────────────────────────────────────────────────────────── class DeviceTelemetry { /// Battery percentage, or null when the platform will not say. final int? battery; /// True while on the charger — a rider at 12% and charging is not the same /// problem as a rider at 12% and riding. final bool? isCharging; /// `WiFi`, `4G` or `none` — the contract's vocabulary, not the plugin's. /// See [_connectionName]. final String? connection; /// `enabled` or `disabled`. See [_locationServiceName]. /// /// The one field here that is about a *choice the rider made*, which is why /// it is worth a column of its own on the console: a disabled location /// service explains a silent rider completely, and no amount of staring at /// his last position does. final String? locationService; const DeviceTelemetry({ this.battery, this.isCharging, this.connection, this.locationService, }); /// Reads what the handset will say about itself, right now. /// /// [locationService] can be supplied by a caller that has just resolved a /// fix and already knows — the position lookup has to ask the same question /// to get a fix at all, and asking twice can disagree with itself when the /// rider toggles the setting between the two calls. static Future read({String? locationService}) async { int? level; bool? charging; try { final battery = Battery(); level = await battery.batteryLevel; final state = await battery.batteryState; charging = state == BatteryState.charging || state == BatteryState.full; } catch (e) { debugPrint('[TELEMETRY] battery unavailable: $e'); } String? connection; try { final result = await Connectivity().checkConnectivity(); connection = _connectionName(result); } catch (e) { debugPrint('[TELEMETRY] connectivity unavailable: $e'); } var service = locationService; if (service == null || service.isEmpty || service == 'unknown') { try { service = await Geolocator.isLocationServiceEnabled() ? 'enabled' : 'disabled'; } catch (e) { debugPrint('[TELEMETRY] location service unavailable: $e'); service = null; } } service = _locationServiceName(service); return DeviceTelemetry( battery: level, isCharging: charging, connection: connection, locationService: service, ); } /// `WiFi` / `4G` / `none`, which is the vocabulary `/miler/logs` documents. /// /// ── The plugin's own spelling is not the contract's ── /// /// This was `result.first.toString().split('.').last`, which is the *enum /// constant* — `wifi`, `mobile`, `ethernet`, `vpn`, `bluetooth`, `other`. /// Two of those happen to look right in lower case and the rest do not /// appear in the contract at all, so the console's Connection column was /// rendering whatever `connectivity_plus` happened to call the transport /// this release. /// /// `first` was also the wrong pick: the list is every active transport, so a /// phone on Wi-Fi with mobile data up could report either depending on the /// order the platform returned them. Wi-Fi wins where both are present, /// because it is the one that explains a rider whose data has run out. static String _connectionName(List results) { if (results.isEmpty) return 'none'; if (results.contains(ConnectivityResult.wifi)) return 'WiFi'; if (results.contains(ConnectivityResult.mobile)) return '4G'; if (results.contains(ConnectivityResult.ethernet)) return 'ethernet'; if (results.every((r) => r == ConnectivityResult.none)) return 'none'; return 'other'; } /// `enabled` or `disabled` — the two values the contract names. /// /// The richer answers the fix lookup produces — `denied`, `denied_forever`, /// `unknown` — are more useful to a dispatcher, and they are not what this /// field accepts. On a backend that validates its enums (and this one does /// silently: see the `Break` / `On_Break` note in [MilerApi]) an unrecognised /// value risks the whole row, which costs the battery and the connection /// alongside it. /// /// So anything that is not `enabled` is `disabled` here, which is the fact /// the column exists to report — this rider's location is not reaching us. /// The distinction between *off* and *denied* is not lost: it still rides on /// the MQTT `location_turned_off` alert as `error_type`. static String? _locationServiceName(String? raw) { if (raw == null || raw.isEmpty) return null; return raw == 'enabled' ? 'enabled' : 'disabled'; } /// The heartbeat payload's own field names, ready to spread into it. /// /// Only what was actually read: an absent key is a field the console draws as /// unknown, which is the truth. A `0` battery or a `none` connection standing /// in for "could not ask" is a fact somebody would act on. Map toPayload() => { if (battery != null) 'battery': '$battery', if (isCharging != null) 'is_charging': isCharging, if (connection != null && connection!.isNotEmpty) 'connection': connection, if (locationService != null && locationService!.isNotEmpty) 'location_service': locationService, }; @override String toString() => 'battery=$battery charging=$isCharging ' 'connection=$connection location=$locationService'; }