Files
maps-saas/packages/tactical_app/lib/widgets/tactical_los_sheet.dart
T

1145 lines
48 KiB
Dart

import 'package:flutter/material.dart';
import 'package:intaleq_maps/intaleq_maps.dart';
import '../models/angle_unit.dart';
import '../services/military_grid_utils.dart';
import '../services/offline_los_engine.dart';
import '../services/resection_calculator.dart';
import '../services/tactical_api_service.dart';
class TacticalLosSheet extends StatefulWidget {
final LatLng observer;
final LatLng target;
final LatLng? currentGps;
final AngleUnit angleUnit;
final Function(LatLng newObserver, LatLng newTarget) onUpdateLocations;
final ValueChanged<bool>? onVisibilityChanged;
final ValueChanged<OfflineLosReport>? onReportGenerated;
final VoidCallback onPickObserverOnMap;
final VoidCallback onPickTargetOnMap;
final VoidCallback onClose;
const TacticalLosSheet({
super.key,
required this.observer,
required this.target,
this.currentGps,
required this.angleUnit,
required this.onUpdateLocations,
this.onVisibilityChanged,
this.onReportGenerated,
required this.onPickObserverOnMap,
required this.onPickTargetOnMap,
required this.onClose,
});
@override
State<TacticalLosSheet> createState() => _TacticalLosSheetState();
}
class _TacticalLosSheetState extends State<TacticalLosSheet> with SingleTickerProviderStateMixin {
late TabController _tabController;
// Controllers for Easting / Northing
late TextEditingController _obsEastingCtrl;
late TextEditingController _obsNorthingCtrl;
late TextEditingController _tgtEastingCtrl;
late TextEditingController _tgtNorthingCtrl;
// Controllers for Lat / Lng
late TextEditingController _obsLatCtrl;
late TextEditingController _obsLngCtrl;
late TextEditingController _tgtLatCtrl;
late TextEditingController _tgtLngCtrl;
// Optic Heights (ارتفاع الراصد والهدف عن سطح الأرض)
double _observerHeightM = 2.0;
double _targetHeightM = 2.0;
// Calculation Results (Hybrid Server + Sovereign Offline DEM Engine)
OfflineLosReport? _losReport;
bool _isCalculating = false;
int? _inspectedSampleIndex;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
final obsMil = MilitaryGridUtils.fromLatLng(widget.observer);
final tgtMil = MilitaryGridUtils.fromLatLng(widget.target);
_obsEastingCtrl = TextEditingController(text: obsMil.eastingStr);
_obsNorthingCtrl = TextEditingController(text: obsMil.northingStr);
_tgtEastingCtrl = TextEditingController(text: tgtMil.eastingStr);
_tgtNorthingCtrl = TextEditingController(text: tgtMil.northingStr);
_obsLatCtrl = TextEditingController(text: widget.observer.latitude.toStringAsFixed(6));
_obsLngCtrl = TextEditingController(text: widget.observer.longitude.toStringAsFixed(6));
_tgtLatCtrl = TextEditingController(text: widget.target.latitude.toStringAsFixed(6));
_tgtLngCtrl = TextEditingController(text: widget.target.longitude.toStringAsFixed(6));
_losReport = OfflineLosEngine.calculate(
observer: widget.observer,
target: widget.target,
observerHeightM: _observerHeightM,
targetHeightM: _targetHeightM,
);
_calculateLos(widget.observer, widget.target);
}
@override
void didUpdateWidget(covariant TacticalLosSheet oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.observer != widget.observer || oldWidget.target != widget.target) {
final obsMil = MilitaryGridUtils.fromLatLng(widget.observer);
final tgtMil = MilitaryGridUtils.fromLatLng(widget.target);
_obsEastingCtrl.text = obsMil.eastingStr;
_obsNorthingCtrl.text = obsMil.northingStr;
_tgtEastingCtrl.text = tgtMil.eastingStr;
_tgtNorthingCtrl.text = tgtMil.northingStr;
_obsLatCtrl.text = widget.observer.latitude.toStringAsFixed(6);
_obsLngCtrl.text = widget.observer.longitude.toStringAsFixed(6);
_tgtLatCtrl.text = widget.target.latitude.toStringAsFixed(6);
_tgtLngCtrl.text = widget.target.longitude.toStringAsFixed(6);
_calculateLos(widget.observer, widget.target);
}
}
@override
void dispose() {
_tabController.dispose();
_obsEastingCtrl.dispose();
_obsNorthingCtrl.dispose();
_tgtEastingCtrl.dispose();
_tgtNorthingCtrl.dispose();
_obsLatCtrl.dispose();
_obsLngCtrl.dispose();
_tgtLatCtrl.dispose();
_tgtLngCtrl.dispose();
super.dispose();
}
Future<void> _calculateLos(LatLng obs, LatLng tgt) async {
setState(() => _isCalculating = true);
try {
final report = await TacticalApiService.calculateHybridLineOfSight(
observer: obs,
target: tgt,
observerHeight: _observerHeightM,
targetHeight: _targetHeightM,
);
if (mounted) {
setState(() {
_losReport = report;
_isCalculating = false;
});
}
widget.onVisibilityChanged?.call(report.isDirectlyVisible);
widget.onReportGenerated?.call(report);
} catch (e) {
debugPrint('LOS calculation error: $e');
if (mounted) {
setState(() => _isCalculating = false);
}
}
}
void _applyManualCoordinates() {
LatLng newObs = widget.observer;
LatLng newTgt = widget.target;
if (_tabController.index == 0) {
// UTM Easting/Northing
final obsE = double.tryParse(_obsEastingCtrl.text) ?? 0;
final obsN = double.tryParse(_obsNorthingCtrl.text) ?? 0;
final tgtE = double.tryParse(_tgtEastingCtrl.text) ?? 0;
final tgtN = double.tryParse(_tgtNorthingCtrl.text) ?? 0;
if (obsE > 100000 && obsN > 1000000) {
newObs = MilitaryGridUtils.toLatLng(easting: obsE, northing: obsN);
}
if (tgtE > 100000 && tgtN > 1000000) {
newTgt = MilitaryGridUtils.toLatLng(easting: tgtE, northing: tgtN);
}
} else {
// Decimal Lat/Lng
final obsLat = double.tryParse(_obsLatCtrl.text);
final obsLng = double.tryParse(_obsLngCtrl.text);
final tgtLat = double.tryParse(_tgtLatCtrl.text);
final tgtLng = double.tryParse(_tgtLngCtrl.text);
if (obsLat != null && obsLng != null) newObs = LatLng(obsLat, obsLng);
if (tgtLat != null && tgtLng != null) newTgt = LatLng(tgtLat, tgtLng);
}
widget.onUpdateLocations(newObs, newTgt);
_calculateLos(newObs, newTgt);
}
@override
Widget build(BuildContext context) {
final isVisible = _losReport?.isDirectlyVisible ?? true;
final distanceMeters = _losReport?.totalDistanceM ??
ResectionCalculator.haversineDistance(
widget.observer.latitude,
widget.observer.longitude,
widget.target.latitude,
widget.target.longitude,
);
final azimuthDeg = _losReport?.azimuthDeg ??
ResectionCalculator.calculateBearing(
widget.observer.latitude,
widget.observer.longitude,
widget.target.latitude,
widget.target.longitude,
);
final maxElev = _losReport?.maxElevationM ?? 950.0;
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.88,
),
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Color(0xFF090E1A),
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
border: Border(top: BorderSide(color: Color(0xFF0071E3), width: 2.5)),
boxShadow: [BoxShadow(color: Colors.black87, blurRadius: 30)],
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Handle bar
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(2),
),
),
),
// Header Bar
Row(
children: [
Expanded(
child: Row(
children: [
Container(
padding: const EdgeInsets.all(7),
decoration: BoxDecoration(
color: isVisible ? const Color(0x3322C55E) : const Color(0x33EF4444),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isVisible ? const Color(0xFF22C55E) : const Color(0xFFEF4444),
),
),
child: Icon(
isVisible ? Icons.visibility : Icons.visibility_off,
color: isVisible ? const Color(0xFF4ADE80) : const Color(0xFFF87171),
size: 22,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'منظومة تحليل تبادل الرؤية العسكري (LOS)',
style: TextStyle(color: Colors.white, fontSize: 12.5, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
Row(
children: [
Flexible(
child: Text(
isVisible
? '🟢 خط الرؤية مكشوف وسالك (Clear)'
: '🔴 خط الرؤية محجوب بتضاريس (Blocked)',
style: TextStyle(
color: isVisible ? const Color(0xFF4ADE80) : const Color(0xFFF87171),
fontSize: 10.5,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
if (_isCalculating) ...[
const SizedBox(width: 6),
const SizedBox(
width: 10,
height: 10,
child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF38BDF8)),
),
],
],
),
],
),
),
],
),
),
IconButton(
onPressed: widget.onClose,
icon: const Icon(Icons.close, color: Colors.white70, size: 20),
),
],
),
const SizedBox(height: 10),
// Coordinates Tabs: [شرقيات / شماليات UTM-36N] vs [درجات Lat/Lng]
Container(
height: 32,
decoration: BoxDecoration(
color: const Color(0xFF1E293B),
borderRadius: BorderRadius.circular(10),
),
child: TabBar(
controller: _tabController,
indicator: BoxDecoration(
color: const Color(0xFF0071E3),
borderRadius: BorderRadius.circular(10),
),
labelColor: Colors.white,
unselectedLabelColor: const Color(0xFF94A3B8),
labelStyle: const TextStyle(fontSize: 10.5, fontWeight: FontWeight.bold),
tabs: const [
Tab(text: 'إحداثيات عسكرية (شرقيات / شماليات UTM)'),
Tab(text: 'درجات جغرافية (WGS84 Lat/Lng)'),
],
),
),
const SizedBox(height: 10),
// Coordinates Input Fields Table
SizedBox(
height: 95,
child: TabBarView(
controller: _tabController,
children: [
// 1. Military UTM Easting / Northing Table
Row(
children: [
// Observer Card
Expanded(
child: Container(
padding: const EdgeInsets.all(7),
decoration: BoxDecoration(
color: const Color(0xFF0F172A),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0x3338BDF8)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('🔴 الراصد (Observer)', style: TextStyle(color: Color(0xFF38BDF8), fontSize: 10.5, fontWeight: FontWeight.bold)),
InkWell(
onTap: widget.onPickObserverOnMap,
child: const Icon(Icons.touch_app, size: 15, color: Color(0xFF38BDF8)),
),
],
),
const SizedBox(height: 2),
Row(
children: [
Expanded(
child: TextField(
controller: _obsEastingCtrl,
keyboardType: TextInputType.number,
style: const TextStyle(color: Colors.white, fontSize: 11),
decoration: const InputDecoration(
labelText: 'شرقيات E',
labelStyle: TextStyle(fontSize: 9, color: Color(0xFF94A3B8)),
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 4, vertical: 4),
),
onSubmitted: (_) => _applyManualCoordinates(),
),
),
const SizedBox(width: 4),
Expanded(
child: TextField(
controller: _obsNorthingCtrl,
keyboardType: TextInputType.number,
style: const TextStyle(color: Colors.white, fontSize: 11),
decoration: const InputDecoration(
labelText: 'شماليات N',
labelStyle: TextStyle(fontSize: 9, color: Color(0xFF94A3B8)),
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 4, vertical: 4),
),
onSubmitted: (_) => _applyManualCoordinates(),
),
),
],
),
],
),
),
),
const SizedBox(width: 8),
// Target Card
Expanded(
child: Container(
padding: const EdgeInsets.all(7),
decoration: BoxDecoration(
color: const Color(0xFF0F172A),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0x33F59E0B)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('🎯 الهدف (Target)', style: TextStyle(color: Color(0xFFF59E0B), fontSize: 10.5, fontWeight: FontWeight.bold)),
InkWell(
onTap: widget.onPickTargetOnMap,
child: const Icon(Icons.touch_app, size: 15, color: Color(0xFFF59E0B)),
),
],
),
const SizedBox(height: 2),
Row(
children: [
Expanded(
child: TextField(
controller: _tgtEastingCtrl,
keyboardType: TextInputType.number,
style: const TextStyle(color: Colors.white, fontSize: 11),
decoration: const InputDecoration(
labelText: 'شرقيات E',
labelStyle: TextStyle(fontSize: 9, color: Color(0xFF94A3B8)),
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 4, vertical: 4),
),
onSubmitted: (_) => _applyManualCoordinates(),
),
),
const SizedBox(width: 4),
Expanded(
child: TextField(
controller: _tgtNorthingCtrl,
keyboardType: TextInputType.number,
style: const TextStyle(color: Colors.white, fontSize: 11),
decoration: const InputDecoration(
labelText: 'شماليات N',
labelStyle: TextStyle(fontSize: 9, color: Color(0xFF94A3B8)),
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 4, vertical: 4),
),
onSubmitted: (_) => _applyManualCoordinates(),
),
),
],
),
],
),
),
),
],
),
// 2. Lat / Lng Table
Row(
children: [
// Observer Card
Expanded(
child: Container(
padding: const EdgeInsets.all(7),
decoration: BoxDecoration(
color: const Color(0xFF0F172A),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0x3338BDF8)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('🔴 الراصد (Lat/Lng)', style: TextStyle(color: Color(0xFF38BDF8), fontSize: 10.5, fontWeight: FontWeight.bold)),
const SizedBox(height: 2),
Row(
children: [
Expanded(
child: TextField(
controller: _obsLatCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
style: const TextStyle(color: Colors.white, fontSize: 11),
decoration: const InputDecoration(
labelText: 'خط العرض Lat',
labelStyle: TextStyle(fontSize: 9, color: Color(0xFF94A3B8)),
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 4, vertical: 4),
),
onSubmitted: (_) => _applyManualCoordinates(),
),
),
const SizedBox(width: 4),
Expanded(
child: TextField(
controller: _obsLngCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
style: const TextStyle(color: Colors.white, fontSize: 11),
decoration: const InputDecoration(
labelText: 'خط الطول Lng',
labelStyle: TextStyle(fontSize: 9, color: Color(0xFF94A3B8)),
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 4, vertical: 4),
),
onSubmitted: (_) => _applyManualCoordinates(),
),
),
],
),
],
),
),
),
const SizedBox(width: 8),
// Target Card
Expanded(
child: Container(
padding: const EdgeInsets.all(7),
decoration: BoxDecoration(
color: const Color(0xFF0F172A),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0x33F59E0B)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('🎯 الهدف (Lat/Lng)', style: TextStyle(color: Color(0xFFF59E0B), fontSize: 10.5, fontWeight: FontWeight.bold)),
const SizedBox(height: 2),
Row(
children: [
Expanded(
child: TextField(
controller: _tgtLatCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
style: const TextStyle(color: Colors.white, fontSize: 11),
decoration: const InputDecoration(
labelText: 'خط العرض Lat',
labelStyle: TextStyle(fontSize: 9, color: Color(0xFF94A3B8)),
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 4, vertical: 4),
),
onSubmitted: (_) => _applyManualCoordinates(),
),
),
const SizedBox(width: 4),
Expanded(
child: TextField(
controller: _tgtLngCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
style: const TextStyle(color: Colors.white, fontSize: 11),
decoration: const InputDecoration(
labelText: 'خط الطول Lng',
labelStyle: TextStyle(fontSize: 9, color: Color(0xFF94A3B8)),
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 4, vertical: 4),
),
onSubmitted: (_) => _applyManualCoordinates(),
),
),
],
),
],
),
),
),
],
),
],
),
),
const SizedBox(height: 8),
// Optic Heights Adjustment (ارتفاع البصريات وسواري الرصد)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1E293B),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white10),
),
child: Row(
children: [
const Icon(Icons.height, size: 14, color: Color(0xFF38BDF8)),
const SizedBox(width: 6),
const Text('ارتفاع الرصد والهدف:', style: TextStyle(color: Color(0xFF94A3B8), fontSize: 10)),
const Spacer(),
_buildHeightChip('راصد +${_observerHeightM.round()}م', _observerHeightM, (h) {
setState(() => _observerHeightM = h);
_calculateLos(widget.observer, widget.target);
}),
const SizedBox(width: 6),
_buildHeightChip('هدف +${_targetHeightM.round()}م', _targetHeightM, (h) {
setState(() => _targetHeightM = h);
_calculateLos(widget.observer, widget.target);
}),
],
),
),
const SizedBox(height: 10),
// ── Tactical Elevation Profile & Intervisibility Cross-Section Chart ──
if (_losReport != null && _losReport!.profile.length > 1) ...[
_LosProfileChart(
report: _losReport!,
visible: isVisible,
angleUnit: widget.angleUnit,
selectedIndex: _inspectedSampleIndex,
onHoverSample: (idx) => setState(() => _inspectedSampleIndex = idx),
),
if (_inspectedSampleIndex != null && _inspectedSampleIndex! < _losReport!.profile.length) ...[
const SizedBox(height: 6),
_buildSampleInspectorCard(_losReport!.profile[_inspectedSampleIndex!]),
],
],
const SizedBox(height: 10),
// Tactical Metrics Data Summary
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1E293B),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0x330071E3)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildMetricItem(
'المسافة المباشرة',
distanceMeters >= 1000
? '${(distanceMeters / 1000.0).toStringAsFixed(2)} كم'
: '${distanceMeters.toStringAsFixed(0)} م',
Icons.straighten,
),
_buildMetricItem(
'السمت التكتيكي',
AngleFormatter.format(azimuthDeg, widget.angleUnit, includeLabel: true),
Icons.explore,
),
_buildMetricItem(
'أعلى تضاريس',
'${maxElev.round()} م',
Icons.terrain,
),
],
),
),
if (_losReport != null) ...[
const SizedBox(height: 8),
// Ground & air / LOS geometry detail row
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF0F172A),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isVisible ? const Color(0x3322C55E) : const Color(0x44EF4444),
),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildMetricItem(
'ارتفاع الراصد الكلي',
'${_losReport!.observerTotalElevM.round()} م',
Icons.height,
),
_buildMetricItem(
'ارتفاع الهدف الكلي',
'${_losReport!.targetTotalElevM.round()} م',
Icons.track_changes,
),
_buildMetricItem(
'السمت (NATO / Soviet)',
'${_losReport!.azimuthMilsNato.round()} ₥ / ${_losReport!.azimuthMilsSoviet.round()} ₥',
Icons.explore,
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildMetricItem(
'زاوية ارتفاع الرصد',
'${_losReport!.verticalAngleDeg.toStringAsFixed(2)}° (${_losReport!.verticalAngleMils.toStringAsFixed(1)} ₥)',
Icons.vertical_align_top,
),
_buildMetricItem(
'منطقة ميتة',
'${_losReport!.deadGroundPercent}%',
Icons.block,
),
_buildMetricItem(
'أدنى ارتفاع',
'${_losReport!.minElevationM.round()} م',
Icons.arrow_downward,
),
],
),
if (_losReport!.highestObstacle != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0x22EF4444),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0x55EF4444)),
),
child: Row(
children: [
const Icon(Icons.warning_amber_rounded, size: 16, color: Color(0xFFF87171)),
const SizedBox(width: 8),
Expanded(
child: Text(
'نقطة الحجب القصوى: ارتفاع ${_losReport!.highestObstacle!.elevationM.round()}م على بعد ${_losReport!.highestObstacle!.distanceM.round()}م (اختراق شعاع الرؤية بمقدار +${_losReport!.highestObstacle!.excessM.toStringAsFixed(1)}م)',
style: const TextStyle(
color: Color(0xFFF87171),
fontSize: 10.5,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
],
],
),
),
],
const SizedBox(height: 12),
// Action Buttons
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _applyManualCoordinates,
icon: const Icon(Icons.refresh, size: 16),
label: const Text('تطبيق وإعادة حساب الرؤية', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0071E3),
foregroundColor: Colors.white,
padding: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
),
if (widget.currentGps != null) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () {
widget.onUpdateLocations(widget.currentGps!, widget.target);
},
icon: const Icon(Icons.my_location, size: 16),
label: const Text('موقعي الحالي', style: TextStyle(fontSize: 11)),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF38BDF8),
side: const BorderSide(color: Color(0xFF0071E3)),
padding: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
],
],
),
],
),
),
);
}
Widget _buildHeightChip(String label, double currentHeight, Function(double) onSelected) {
return PopupMenuButton<double>(
initialValue: currentHeight,
onSelected: onSelected,
color: const Color(0xFF1E293B),
itemBuilder: (ctx) => [
const PopupMenuItem(value: 2.0, child: Text('رصد فردي (2 متر)', style: TextStyle(color: Colors.white, fontSize: 11))),
const PopupMenuItem(value: 5.0, child: Text('برج مركبة / سارية (5 متر)', style: TextStyle(color: Colors.white, fontSize: 11))),
const PopupMenuItem(value: 10.0, child: Text('برج مراقبة ميداني (10 متر)', style: TextStyle(color: Colors.white, fontSize: 11))),
const PopupMenuItem(value: 25.0, child: Text('منطاد استطلاع / درون (25 متر)', style: TextStyle(color: Colors.white, fontSize: 11))),
],
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF0071E3).withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFF0071E3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: const TextStyle(color: Color(0xFF38BDF8), fontSize: 10, fontWeight: FontWeight.bold)),
const SizedBox(width: 3),
const Icon(Icons.arrow_drop_down, size: 14, color: Color(0xFF38BDF8)),
],
),
),
);
}
Widget _buildSampleInspectorCard(OfflineLosSample s) {
final mil = MilitaryGridUtils.fromLatLng(LatLng(s.lat, s.lng));
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1E293B),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: s.isBlocked ? const Color(0xFFEF4444) : const Color(0xFF22C55E)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('محطة عند: ${s.distanceM.round()}م', style: const TextStyle(color: Colors.white70, fontSize: 10)),
Text('تضاريس: ${s.groundElevationM.round()}م', style: const TextStyle(color: Color(0xFF38BDF8), fontSize: 10, fontWeight: FontWeight.bold)),
Text('شعاع: ${s.rayHeightM.round()}م', style: const TextStyle(color: Color(0xFFF59E0B), fontSize: 10)),
Text('خلوص: ${s.clearanceM >= 0 ? "+${s.clearanceM.toStringAsFixed(1)}" : s.clearanceM.toStringAsFixed(1)}م',
style: TextStyle(color: s.clearanceM >= 0 ? const Color(0xFF4ADE80) : const Color(0xFFF87171), fontSize: 10, fontWeight: FontWeight.bold)),
Text(mil.shortGrid, style: const TextStyle(color: Colors.white54, fontSize: 9)),
],
),
);
}
Widget _buildMetricItem(String label, String val, IconData icon) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: const Color(0xFF94A3B8)),
const SizedBox(width: 4),
Text(label, style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 9.5)),
],
),
const SizedBox(height: 3),
Text(
val,
style: const TextStyle(color: Colors.white, fontSize: 11.5, fontWeight: FontWeight.bold),
),
],
);
}
}
/// Tactical terrain elevation profile chart with intervisibility ray & obstacle callout.
class _LosProfileChart extends StatelessWidget {
final OfflineLosReport report;
final bool visible;
final AngleUnit angleUnit;
final int? selectedIndex;
final Function(int?) onHoverSample;
const _LosProfileChart({
required this.report,
required this.visible,
required this.angleUnit,
this.selectedIndex,
required this.onHoverSample,
});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(6, 8, 6, 6),
decoration: BoxDecoration(
color: const Color(0xFF0F172A),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0x330071E3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: visible ? const Color(0xFF22C55E) : const Color(0xFFEF4444),
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
visible ? 'مقطع التضاريس وشعاع الرؤية المباشر' : 'مقطع التضاريس مع العائق الجبلي',
style: TextStyle(
color: visible ? const Color(0xFF4ADE80) : const Color(0xFFF87171),
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Text(
'${(report.totalDistanceM / 1000).toStringAsFixed(1)} كم • ${report.azimuthDeg.toStringAsFixed(1)}°',
style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 10, fontWeight: FontWeight.w600),
),
],
),
),
const SizedBox(height: 6),
GestureDetector(
onHorizontalDragUpdate: (details) {
final box = context.findRenderObject() as RenderBox?;
if (box != null) {
final localX = details.localPosition.dx;
final width = box.size.width;
final fraction = (localX / width).clamp(0.0, 1.0);
final idx = (fraction * (report.profile.length - 1)).round();
onHoverSample(idx);
}
},
onTapDown: (details) {
final box = context.findRenderObject() as RenderBox?;
if (box != null) {
final localX = details.localPosition.dx;
final width = box.size.width;
final fraction = (localX / width).clamp(0.0, 1.0);
final idx = (fraction * (report.profile.length - 1)).round();
onHoverSample(idx);
}
},
child: CustomPaint(
painter: _LosProfilePainter(
report: report,
clear: visible,
selectedIndex: selectedIndex,
),
size: const Size(double.infinity, 115),
),
),
],
),
);
}
}
/// Paints the terrain (ground), curvature sagitta, sight ray, obstacle callout, and dead ground.
class _LosProfilePainter extends CustomPainter {
final OfflineLosReport report;
final bool clear;
final int? selectedIndex;
_LosProfilePainter({
required this.report,
required this.clear,
this.selectedIndex,
});
@override
void paint(Canvas canvas, Size size) {
final w = size.width;
final h = size.height;
const pad = 8.0;
final samples = report.profile;
if (samples.isEmpty) return;
double minE = double.infinity;
double maxE = -double.infinity;
for (final s in samples) {
if (s.groundElevationM < minE) minE = s.groundElevationM;
if (s.groundElevationM > maxE) maxE = s.groundElevationM;
if (s.rayHeightM > maxE) maxE = s.rayHeightM;
if (s.rayHeightM < minE) minE = s.rayHeightM;
}
if ((maxE - minE).abs() < 1.0) {
maxE = minE + 10.0;
}
minE -= 15.0; // Margin below
maxE += 20.0; // Margin above
double x(num j) => pad + (w - 2 * pad) * (j.toDouble() / (samples.length - 1));
double y(num e) => h - pad - (h - 2 * pad) * ((e.toDouble() - minE) / (maxE - minE));
// 1. Draw Grid Lines & Elevation scale with Text Labels
final gridPaint = Paint()
..color = Colors.white12
..strokeWidth = 0.8;
void drawGridLevel(double elev) {
final yPos = y(elev);
canvas.drawLine(Offset(pad, yPos), Offset(w - pad, yPos), gridPaint);
final textPainter = TextPainter(
text: TextSpan(
text: '${elev.round()}م',
style: const TextStyle(color: Colors.white38, fontSize: 8.5),
),
textDirection: TextDirection.rtl,
)..layout();
textPainter.paint(canvas, Offset(pad + 2, yPos - 11));
}
final midE = (minE + maxE) / 2;
drawGridLevel(minE + 15);
drawGridLevel(midE);
drawGridLevel(maxE - 15);
// 2. Ground Profile Fill & Stroke
final groundPath = Path()..moveTo(x(0), y(samples.first.groundElevationM));
for (int i = 1; i < samples.length; i++) {
groundPath.lineTo(x(i), y(samples[i].groundElevationM));
}
groundPath.lineTo(x(samples.length - 1), h);
groundPath.lineTo(x(0), h);
groundPath.close();
final groundGradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
const Color(0xFF334155),
const Color(0xFF0F172A),
],
);
final groundPaint = Paint()
..style = PaintingStyle.fill
..shader = groundGradient.createShader(Rect.fromLTWH(0, 0, w, h));
canvas.drawPath(groundPath, groundPaint);
final ridgePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.0
..color = const Color(0xFF38BDF8);
final ridgePath = Path()..moveTo(x(0), y(samples.first.groundElevationM));
for (int i = 1; i < samples.length; i++) {
ridgePath.lineTo(x(i), y(samples[i].groundElevationM));
}
canvas.drawPath(ridgePath, ridgePaint);
// 3. Highlight Dead Ground (المنطقة الميتة)
for (int i = 0; i < samples.length - 1; i++) {
if (!samples[i].isVisible) {
final dgRect = Rect.fromLTRB(x(i), y(samples[i].groundElevationM), x(i + 1), h - pad);
canvas.drawRect(dgRect, Paint()..color = const Color(0x33EF4444));
}
}
// 4. Line of Sight Straight Ray
final rayPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.4
..color = clear ? const Color(0xFF22C55E) : const Color(0xFFEF4444);
final rayPath = Path()..moveTo(x(0), y(samples.first.rayHeightM));
for (int i = 1; i < samples.length; i++) {
rayPath.lineTo(x(i), y(samples[i].rayHeightM));
}
canvas.drawPath(rayPath, rayPaint);
// 5. Highest Obstruction Marker & Deficit Line
final obs = report.highestObstacle;
if (obs != null && !clear) {
final dobj = obs.distanceM;
final maxD = report.totalDistanceM > 0 ? report.totalDistanceM : 1.0;
final fx = pad + (w - 2 * pad) * (dobj / maxD);
final fyGround = y(obs.elevationM);
final fyRay = y(obs.sightRayElevationM);
// Deficit penetration vertical line
final penPaint = Paint()
..color = const Color(0xFFEF4444)
..strokeWidth = 2.0;
canvas.drawLine(Offset(fx, fyGround), Offset(fx, fyRay), penPaint);
// Obstacle Top Triangle
final markerPaint = Paint()
..style = PaintingStyle.fill
..color = const Color(0xFFEF4444);
canvas.drawPath(
Path()
..moveTo(fx - 5, fyGround + 7)
..lineTo(fx, fyGround - 6)
..lineTo(fx + 5, fyGround + 7)
..close(),
markerPaint,
);
}
// 6. Observer & Target Optic Endpoints
void drawEndpoint(double dx, double dyGround, double dyRay, Color color, String label, double groundElev) {
// Mast / Optic height line
canvas.drawLine(
Offset(dx, dyGround),
Offset(dx, dyRay),
Paint()..color = color..strokeWidth = 2.0,
);
// Top Optic Circle
canvas.drawCircle(Offset(dx, dyRay), 4.0, Paint()..color = color);
canvas.drawCircle(Offset(dx, dyRay), 1.5, Paint()..color = const Color(0xFF0F172A));
// Label
final tp = TextPainter(
text: TextSpan(
text: '$label\n${groundElev.round()}م',
style: TextStyle(color: color, fontSize: 9.0, fontWeight: FontWeight.bold),
),
textAlign: TextAlign.center,
textDirection: TextDirection.rtl,
)..layout();
tp.paint(canvas, Offset(dx - tp.width / 2, dyGround + 4));
}
drawEndpoint(x(0), y(samples.first.groundElevationM), y(samples.first.rayHeightM), const Color(0xFF38BDF8), 'الراصد', samples.first.groundElevationM);
drawEndpoint(x(samples.length - 1), y(samples.last.groundElevationM), y(samples.last.rayHeightM), const Color(0xFFF59E0B), 'الهدف', samples.last.groundElevationM);
// 7. Interactive Scrubber Cursor
if (selectedIndex != null && selectedIndex! < samples.length) {
final cur = samples[selectedIndex!];
final sx = x(selectedIndex!);
final syGround = y(cur.groundElevationM);
final syRay = y(cur.rayHeightM);
final curPaint = Paint()..color = Colors.white..strokeWidth = 1.2;
canvas.drawLine(Offset(sx, pad), Offset(sx, h - pad), curPaint);
canvas.drawCircle(Offset(sx, syGround), 3.5, Paint()..color = const Color(0xFF38BDF8));
canvas.drawCircle(Offset(sx, syRay), 3.5, Paint()..color = const Color(0xFFF59E0B));
}
}
@override
bool shouldRepaint(covariant _LosProfilePainter oldDelegate) =>
oldDelegate.report != report ||
oldDelegate.clear != clear ||
oldDelegate.selectedIndex != selectedIndex;
}