1145 lines
48 KiB
Dart
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;
|
|
}
|