Files
doormile_milderapp/lib/data/device_telemetry.dart
2026-08-28 15:07:30 +05:30

134 lines
5.5 KiB
Dart

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<DeviceTelemetry> 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<String, dynamic> toPayload() => <String, dynamic>{
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';
}