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`, `mobile`, `none` … as the platform names it. final String? connection; /// `enabled`, `disabled`, `denied`, `denied_forever`, `unknown`. /// /// 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 = result.isNotEmpty ? result.first.toString().split('.').last : 'none'; } 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; } } return DeviceTelemetry( battery: level, isCharging: charging, connection: connection, locationService: service, ); } /// 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'; }