Uses an up/down arrow instead of a +/- sign so the direction reads at a glance, with the magnitude alongside it.
481 lines
20 KiB
Dart
481 lines
20 KiB
Dart
import 'dart:async';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:flutter_map/flutter_map.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:latlong2/latlong.dart';
|
|
|
|
import '../../../app_mode/app_mode_cubit.dart';
|
|
import '../../../domain/mission/drone_profile.dart';
|
|
import '../../../domain/mission/mission_stats.dart';
|
|
import '../../../domain/mission/mission_warnings.dart';
|
|
import '../../../domain/mission/route_geometry.dart';
|
|
import '../../../domain/telemetry/drone_status.dart';
|
|
import '../../../domain/telemetry/system_message.dart';
|
|
import '../../../domain/waypoint/flat_waypoint_list.dart';
|
|
import '../../../domain/wind/wind_math.dart';
|
|
import '../../../services/mission_sync_service.dart';
|
|
import '../../../transport/flight_controller_link.dart';
|
|
import '../../providers/active_drone_profile_provider.dart';
|
|
import '../../providers/current_mission_provider.dart';
|
|
import '../../providers/follow_drone_provider.dart';
|
|
import '../../providers/map_controller_provider.dart';
|
|
import '../../providers/mission_meta_provider.dart';
|
|
import '../../providers/system_message_log_provider.dart';
|
|
import '../../providers/telemetry_provider.dart';
|
|
import '../../providers/terrain_provider.dart';
|
|
import '../settings/settings_screen.dart';
|
|
import '../../widgets/drone_marker_icon.dart';
|
|
import '../../widgets/drone_status_messages_panel.dart';
|
|
import '../../widgets/mission_footer_bar.dart';
|
|
import '../../widgets/mission_map.dart';
|
|
import '../../widgets/waypoint_list_panel.dart';
|
|
|
|
/// Fly-Modus (Architektur-Doku 3.10/3.11): zeigt dieselbe Karte + Fusszeile
|
|
/// wie der Plan-Modus, aber ohne Reticle/Draeder - im Flug wird die Route
|
|
/// beobachtet statt neu geplant. Der Kartencontroller ist derselbe
|
|
/// app-lebenslange Provider wie im Plan-Modus (Doku: mapControllerProvider),
|
|
/// die Kameraposition bleibt also beim Umschalten erhalten. Wegpunkte lassen
|
|
/// sich weiterhin ueber die Liste anpassen (z.B. spontane Hoehenaenderung),
|
|
/// nur das Karten-Reticle zum Anlegen/Verschieben entfaellt hier.
|
|
class FlyScreen extends ConsumerStatefulWidget {
|
|
const FlyScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<FlyScreen> createState() => _FlyScreenState();
|
|
}
|
|
|
|
class _FlyScreenState extends ConsumerState<FlyScreen> {
|
|
late final MapController _mapController;
|
|
StreamSubscription<MapEvent>? _mapEventSub;
|
|
bool _sending = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_mapController = ref.read(mapControllerProvider);
|
|
_mapEventSub = _mapController.mapEventStream.listen(_onMapEvent);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_mapEventSub?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
/// Jede manuelle Kartengeste (Pan/Pinch/Fling, ...) bricht den ueber den
|
|
/// GPS-Knopf in der Kopfleiste (FlyMapControls) aktivierten Folgemodus ab
|
|
/// - nur Bewegungen mit source == mapController sind programmatisch
|
|
/// (durch die Telemetrie-Nachfuehrung unten selbst oder andere
|
|
/// Kartensprmuenge ausgeloest) und sollen ihn nicht beenden.
|
|
void _onMapEvent(MapEvent event) {
|
|
if (ref.read(followDroneProvider) &&
|
|
event.source != MapEventSource.mapController) {
|
|
ref.read(followDroneProvider.notifier).set(false);
|
|
}
|
|
}
|
|
|
|
void _onCenterOnWaypoint(int index) {
|
|
final waypoints = ref.read(currentMissionProvider);
|
|
if (index < 0 || index >= waypoints.length) return;
|
|
final wp = waypoints[index];
|
|
// Programmatischer Sprung (source == mapController) - _onMapEvent
|
|
// erkennt das nicht als manuelle Geste, der Folgemodus muss also
|
|
// explizit beendet werden (sonst zieht der naechste Telemetrie-Frame
|
|
// die Kamera sofort wieder vom antippten Wegpunkt weg).
|
|
ref.read(followDroneProvider.notifier).set(false);
|
|
_mapController.move(LatLng(wp.lat, wp.lon), _mapController.camera.zoom);
|
|
}
|
|
|
|
void _openWaypointList({PanelTab initialTab = PanelTab.list}) {
|
|
Navigator.of(context).push(
|
|
PageRouteBuilder<void>(
|
|
opaque: true,
|
|
transitionDuration: const Duration(milliseconds: 150),
|
|
reverseTransitionDuration: const Duration(milliseconds: 150),
|
|
pageBuilder: (routeContext, animation, secondaryAnimation) {
|
|
return FadeTransition(
|
|
opacity: animation,
|
|
child: WaypointListPanel(
|
|
activeIndex: -1,
|
|
initialTab: initialTab,
|
|
onClose: () => Navigator.of(routeContext).pop(),
|
|
onCenterOnWaypoint: (index) {
|
|
_onCenterOnWaypoint(index);
|
|
Navigator.of(routeContext).pop();
|
|
},
|
|
onDelete: (index) =>
|
|
ref.read(currentMissionProvider.notifier).removeAt(index),
|
|
onAltitudeChanged: (index, value) => ref
|
|
.read(currentMissionProvider.notifier)
|
|
.setAltitude(index, value),
|
|
onSpeedChanged: (index, value) =>
|
|
ref.read(currentMissionProvider.notifier).setSpeed(index, value),
|
|
onCatchRadiusChanged: (index, value) => ref
|
|
.read(currentMissionProvider.notifier)
|
|
.setCatchRadius(index, value),
|
|
onActionChanged: (index, action) =>
|
|
ref.read(currentMissionProvider.notifier).setAction(index, action),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openSystemMessagesPanel() {
|
|
Navigator.of(context).push(MaterialPageRoute<void>(
|
|
builder: (_) => const DroneStatusMessagesPanel.messages(),
|
|
));
|
|
}
|
|
|
|
/// Laedt die aktuelle Mission auf den Flightcontroller (Doku 2.2/4.5:
|
|
/// Connect -> Upload -> Verify) - MissionSyncService kennt selbst kein
|
|
/// UI, deshalb hier Ladezustand/Fehleranzeige. uploadMission() der
|
|
/// jeweiligen FlightControllerLink-Implementierung verweigert den Versand
|
|
/// bereits selbst im armed-Zustand (Doku 4.5); dieser Handler prueft nur
|
|
/// den trivialen Fall (keine Wegpunkte), um nicht unnoetig einen
|
|
/// Verbindungsversuch auszuloesen. Protokolliert Erfolg/Fehlschlag
|
|
/// zusaetzlich im System-Messages-Log (Doku: "wenn ... Wegpunkte
|
|
/// übertragen werden") inklusive Missionsname. Wird sowohl vom Sende-
|
|
/// Knopf als auch automatisch bei einem Missionswechsel aufgerufen (siehe
|
|
/// den ref.listen(currentMissionMetaProvider, ...) unten) - der
|
|
/// _sending-Schutz verhindert dabei ein doppeltes Senden, falls beides
|
|
/// ueberlappt.
|
|
Future<void> _onSendTap() async {
|
|
if (_sending) return;
|
|
final waypoints = ref.read(currentMissionProvider);
|
|
final missionName = ref.read(currentMissionMetaProvider).name;
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
if (waypoints.isEmpty) {
|
|
messenger.showSnackBar(const SnackBar(content: Text('No waypoints to send')));
|
|
return;
|
|
}
|
|
|
|
setState(() => _sending = true);
|
|
final log = ref.read(systemMessageLogProvider.notifier);
|
|
try {
|
|
final service = MissionSyncService(
|
|
ref.read(flightControllerLinkProvider),
|
|
context.read<AppModeCubit>(),
|
|
);
|
|
await service.syncAndUpload(FlatWaypointList(waypoints));
|
|
messenger.showSnackBar(const SnackBar(content: Text('Mission sent')));
|
|
log.log('Mission sent: "$missionName" (${waypoints.length} waypoints)');
|
|
} catch (e) {
|
|
messenger.showSnackBar(SnackBar(content: Text('Send failed: $e')));
|
|
log.log('Mission upload failed: "$missionName": $e',
|
|
severity: SystemMessageSeverity.critical);
|
|
} finally {
|
|
if (mounted) setState(() => _sending = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final waypoints = ref.watch(currentMissionProvider);
|
|
final droneProfile = ref.watch(activeDroneProfileProvider);
|
|
final telemetryAsync = ref.watch(telemetryProvider);
|
|
final telemetry = telemetryAsync.value;
|
|
final followDrone = ref.watch(followDroneProvider);
|
|
// Beobachtet die automatische Ereigniserkennung (Connected/Lost/
|
|
// Batterie/Failsafe/Verbindungstyp, Doku: "log von kritischen
|
|
// ereignissen mit time stamp", erweitert um Verbindungsereignisse) fuer
|
|
// die Dauer des Fly-Modus - siehe systemMessageAutoLogProvider-Doku,
|
|
// warum das bewusst NICHT systemMessageLogProvider selbst ist (der
|
|
// bliebe sonst auch im Plan-Modus dauerhaft an telemetryProvider
|
|
// gekoppelt). Der Rueckgabewert wird hier nicht gebraucht, nur der
|
|
// Beobachtungs-Seiteneffekt.
|
|
ref.watch(systemMessageAutoLogProvider);
|
|
|
|
// Solange der ueber den GPS-Knopf in der Kopfleiste (FlyMapControls)
|
|
// aktivierte Folgemodus aktiv ist, jede neue Telemetrie-Position
|
|
// programmatisch nachziehen (source == mapController, siehe
|
|
// _onMapEvent - bricht den Modus dadurch nicht selbst wieder ab).
|
|
ref.listen<AsyncValue<TelemetryFrame>>(telemetryProvider, (previous, next) {
|
|
if (!followDrone) return;
|
|
final frame = next.value;
|
|
if (frame != null && frame.hasFix) {
|
|
_mapController.move(LatLng(frame.lat, frame.lon), _mapController.camera.zoom);
|
|
}
|
|
});
|
|
|
|
// Ein Missionswechsel waehrend des Fly-Modus (z.B. ueber die Mission-
|
|
// Pille in der Fusszeile, die auch dort erreichbar bleibt) wird geloggt
|
|
// und laedt die neue Route automatisch auf den Flightcontroller (Doku:
|
|
// "Wechsel der Mission ... nur im fly modus loggen", "automatisch die
|
|
// Wegpunktliste übertragen, wenn die Mission gewechselt wird im fly
|
|
// modus") - beides sitzt bewusst hier statt in
|
|
// mission_meta_provider.dart, das den App-Modus (AppModeCubit,
|
|
// flutter_bloc) nicht kennt; dieser ref.listen existiert dagegen nur,
|
|
// waehrend FlyScreen gemountet ist. switchSeq statt eines simplen
|
|
// id-Vergleichs, weil der erste Autosave einer neuen Mission ebenfalls
|
|
// deren id setzt, ohne dass die Mission dabei "gewechselt" wurde (siehe
|
|
// MissionMeta-Doku).
|
|
ref.listen<MissionMeta>(currentMissionMetaProvider, (previous, next) {
|
|
if (previous != null && next.switchSeq != previous.switchSeq) {
|
|
ref.read(systemMessageLogProvider.notifier).log('Mission changed to "${next.name}"');
|
|
_onSendTap();
|
|
}
|
|
});
|
|
|
|
// Drohnenprofilwechsel waehrend des Fly-Modus (Doku: "Wechsel ... des
|
|
// Drohnenprofils ... nur im fly modus loggen") - anders als beim
|
|
// Missionswechsel bewusst OHNE automatischen Neuversand (Doku sieht das
|
|
// explizit nur fuer den Missionswechsel vor).
|
|
ref.listen<DroneProfile>(activeDroneProfileProvider, (previous, next) {
|
|
if (previous != null && previous.id != next.id) {
|
|
ref.read(systemMessageLogProvider.notifier).log('Drone profile changed to "${next.name}"');
|
|
}
|
|
});
|
|
|
|
final routeGeometry = buildRouteGeometry(
|
|
waypoints,
|
|
minTurnRadius: droneProfile.minTurnRadius,
|
|
maxClimbRate: droneProfile.maxClimbRate,
|
|
maxDescentRate: droneProfile.maxDescentRate,
|
|
);
|
|
final missionStats = computeMissionStats(waypoints);
|
|
final safeEdgeInset = math.max(
|
|
MediaQuery.of(context).padding.left,
|
|
MediaQuery.of(context).padding.right,
|
|
);
|
|
// Gleiche Reservierung fuer die Fusszeile wie die Rad-Readouts im
|
|
// Plan-Modus (PlanScreen: wheelBottomInset), damit die Alt/Speed-
|
|
// Anzeige zwischen Kopf- und Fusszeile vertikal zentriert erscheint,
|
|
// statt am Kartenrand zu kleben.
|
|
final footerBottomInset = math.max(
|
|
MediaQuery.of(context).size.height * 0.06,
|
|
124.0,
|
|
);
|
|
final terrainProfile = ref.watch(terrainProvider).profile;
|
|
final warnings = computeMissionWarnings(
|
|
waypoints: waypoints,
|
|
routeGeometry: routeGeometry,
|
|
legWindBad: computeLegWindBad(waypoints, droneProfile.maxSpeed),
|
|
droneProfile: droneProfile,
|
|
totalDistanceM: missionStats.distanceM,
|
|
totalDurationSec: missionStats.durationSec,
|
|
terrain: terrainProfile,
|
|
);
|
|
|
|
// Vor der ersten Telemetrie (Verbindungsaufbau laeuft noch, oder Mock
|
|
// hat noch nicht getickt) und ohne GPS-Fix (TelemetryFrame wird bewusst
|
|
// nicht auf einen Fix gegated, siehe dortige Doku - ohne Fix meldet der
|
|
// FC aber oft (0,0) oder eine veraltete Position) auf dem ersten
|
|
// Wegpunkt statt an einer erfundenen Stelle anzeigen.
|
|
final droneMarker = telemetry != null && telemetry.hasFix
|
|
? Marker(
|
|
point: LatLng(telemetry.lat, telemetry.lon),
|
|
width: 40,
|
|
height: 40,
|
|
alignment: Alignment.center,
|
|
child: DroneMarkerIcon(headingDeg: telemetry.headingDeg),
|
|
)
|
|
: waypoints.isEmpty
|
|
? null
|
|
: Marker(
|
|
point: LatLng(waypoints.first.lat, waypoints.first.lon),
|
|
width: 40,
|
|
height: 40,
|
|
alignment: Alignment.center,
|
|
child: const DroneMarkerIcon(),
|
|
);
|
|
|
|
// Gestrichelte Linie von der Drohne zum aktiven Wegpunkt (Doku 3.4:
|
|
// TelemetryFrame.activeWaypointIndex aus MSP_NAV_STATUS) - derselbe
|
|
// Darstellungsstil wie das Reticle-Gummiband im Plan-Modus (MissionMap.
|
|
// rubberBandLine), hier aber Drohne -> naechster Wegpunkt statt letzter
|
|
// Wegpunkt -> Kartenmitte.
|
|
List<LatLng>? rubberBand;
|
|
final activeWpIndex = telemetry?.activeWaypointIndex;
|
|
if (telemetry != null &&
|
|
telemetry.hasFix &&
|
|
activeWpIndex != null &&
|
|
activeWpIndex >= 0 &&
|
|
activeWpIndex < waypoints.length) {
|
|
final target = waypoints[activeWpIndex];
|
|
rubberBand = [
|
|
LatLng(telemetry.lat, telemetry.lon),
|
|
LatLng(target.lat, target.lon),
|
|
];
|
|
}
|
|
|
|
// Live-Missionsstatus fuer die Details-Pille (Doku: "aktiver Wegpunkt
|
|
// 3/23, Distanz und Zeit zum naechsten Wegpunkt" statt der statischen
|
|
// Plan-Werte) - siehe FlyMissionStatus-Klassendoku dazu, warum das aus
|
|
// Position+Wegpunktkoordinaten berechnet wird statt direkt von MSP
|
|
// abgefragt.
|
|
final flyMissionStatus = computeFlyMissionStatus(
|
|
waypoints: waypoints,
|
|
activeWaypointIndex: activeWpIndex,
|
|
currentLat: telemetry != null && telemetry.hasFix ? telemetry.lat : null,
|
|
currentLon: telemetry != null && telemetry.hasFix ? telemetry.lon : null,
|
|
currentSpeedMs: telemetry?.speedMs,
|
|
);
|
|
|
|
// Drohnen-Status fuer die Drohnen-Pille (Doku: "im fly modus zeigt die
|
|
// drone pille den zustand der drohne") - das Aussehen des Fly-Modus darf
|
|
// nicht am Vorhandensein eines Telemetrie-Frames haengen (sonst faellt
|
|
// BottomStatsBar auf den Drohnennamen-Chip zurueck, den Plan-Modus-Look).
|
|
// Ohne Frame (Verbindungsaufbau laeuft noch) liefern die Platzhalterwerte
|
|
// ueber computeDroneStatus zuverlaessig idle/rot/rot - eine ehrliche
|
|
// "keine Daten"-Anzeige statt eines unterschiedlichen Widget-Typs.
|
|
//
|
|
// connected: telemetryAsync.hasError statt telemetry == null, weil
|
|
// TelemetryFrame-Werte bei einem Verbindungsverlust bewusst auf dem
|
|
// letzten Stand "einfrieren" (Doku: "im Drone Detail Menü okay, dass
|
|
// Daten einfrieren" - telemetry bleibt also nicht null, siehe
|
|
// telemetryProvider-Doku zu AsyncError mit erhaltenem previous value).
|
|
// Die Pille selbst soll den Verbindungsverlust trotzdem sofort ehrlich
|
|
// als "disconnected" zeigen, statt den zuletzt bekannten (ggf. laengst
|
|
// veralteten) Zustand fortzuschreiben.
|
|
final droneStatus = computeDroneStatus(
|
|
connected: !telemetryAsync.hasError,
|
|
armed: telemetry?.armed ?? false,
|
|
hasFix: telemetry?.hasFix ?? false,
|
|
numSat: telemetry?.numSat ?? 0,
|
|
hdop: telemetry?.hdop ?? 99.0,
|
|
linkQuality: telemetry?.linkQuality ?? 0,
|
|
snrDb: telemetry?.snrDb ?? -99,
|
|
navMode: telemetry?.navMode ?? 0,
|
|
);
|
|
|
|
return Stack(
|
|
children: [
|
|
MissionMap(
|
|
mapController: _mapController,
|
|
routeSegments: routeGeometry.segments,
|
|
rubberBandLine: rubberBand,
|
|
waypointMarkers: [
|
|
for (var i = 0; i < waypoints.length; i++)
|
|
CircleMarker(
|
|
point: LatLng(waypoints[i].lat, waypoints[i].lon),
|
|
radius: 6,
|
|
color: i < routeGeometry.vertexBad.length && routeGeometry.vertexBad[i]
|
|
? const Color(0xFFE2574A)
|
|
: const Color(0xFF2B2B28),
|
|
borderStrokeWidth: 1.5,
|
|
borderColor: Colors.white,
|
|
),
|
|
],
|
|
droneMarker: droneMarker,
|
|
),
|
|
// Live-Hoehe/-Geschwindigkeit aus der Telemetrie (HTML-Demonstrator:
|
|
// #altReadout/#spdReadout im Fly-Modus) - anders als dort keine
|
|
// SET-Zeile, weil es im Waypoint-Fly-Modus kein editierbares
|
|
// Rad-Widget (curAlt/curSpeed) gibt, dessen Wert sinnvoll waere.
|
|
Positioned(
|
|
top: 56,
|
|
bottom: footerBottomInset,
|
|
left: 14 + safeEdgeInset,
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_liveReadout('Alt', telemetry?.altitudeM, 'm'),
|
|
const SizedBox(height: 6),
|
|
// Steig-/Sinkrate direkt unter der Hoehenanzeige (Nutzerwunsch)
|
|
// - Pfeil statt Vorzeichen (+/-), da ein Pfeil auf einen Blick
|
|
// erfassbar ist, waehrend "+"/"-" bei kleiner Schrift leicht
|
|
// uebersehen wird.
|
|
_verticalSpeedReadout(telemetry?.verticalSpeedMs),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
top: 56,
|
|
bottom: footerBottomInset,
|
|
right: 14 + safeEdgeInset,
|
|
child: Center(child: _liveReadout('Speed', telemetry?.speedMs, 'm/s')),
|
|
),
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
child: MissionFooterBar(
|
|
waypoints: waypoints,
|
|
legClimbBad: routeGeometry.legClimbBad,
|
|
vertexBad: routeGeometry.vertexBad,
|
|
activeIndex: -1,
|
|
totalDistanceM: missionStats.distanceM,
|
|
totalDurationSec: missionStats.durationSec,
|
|
flyMissionStatus: flyMissionStatus,
|
|
droneStatus: droneStatus,
|
|
warnings: warnings,
|
|
showWarningsBanner: false,
|
|
batteryPercent: telemetry?.batteryPercent,
|
|
onSendTap: _onSendTap,
|
|
sending: _sending,
|
|
onDetailsTap: _openWaypointList,
|
|
onWarningsTap: _openSystemMessagesPanel,
|
|
onSettingsTap: () => Navigator.of(context).push(MaterialPageRoute(
|
|
builder: (_) => const SettingsScreen(),
|
|
)),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Steig-/Sinkrate unter der Hoehenanzeige (siehe _liveReadout-Aufrufer
|
|
/// oben) - zeigt bewusst einen Pfeil statt eines Vorzeichens vor der Zahl
|
|
/// (Nutzerwunsch), der Betrag bleibt trotzdem als Wert sichtbar. >= 0 gilt
|
|
/// als Steigen, analog zur Vorzeichen-Konvention in
|
|
/// drone_status_messages_panel.dart._formatVerticalSpeed.
|
|
Widget _verticalSpeedReadout(double? valueMs) {
|
|
final Widget content;
|
|
if (valueMs == null) {
|
|
content = const Text(
|
|
'—',
|
|
style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500),
|
|
);
|
|
} else {
|
|
content = Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
valueMs < 0 ? Icons.arrow_downward : Icons.arrow_upward,
|
|
color: Colors.white,
|
|
size: 15,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'${valueMs.abs().toStringAsFixed(1)} m/s',
|
|
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.7),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: content,
|
|
);
|
|
}
|
|
|
|
Widget _liveReadout(String label, double? value, String unit) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.7),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Text(
|
|
'$label: ${value != null ? '${value.round()} $unit' : '—'}',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|