Files
maps-saas/packages/tactical_app/lib/screens/tactical_map_screen.dart
T

685 lines
25 KiB
Dart

import 'package:flutter/material.dart';
import 'package:intaleq_maps/intaleq_maps.dart';
import '../config/app_config.dart';
import '../models/angle_unit.dart';
import '../models/landmark.dart';
import '../models/navigation_state.dart';
import '../services/offline_routing_engine.dart';
import '../services/resection_calculator.dart';
import '../services/tactical_api_service.dart';
import '../services/turn_by_turn_navigation_engine.dart';
import '../widgets/active_navigation_hud.dart';
import '../widgets/camera_resection_view.dart';
import '../widgets/place_search_sheet.dart';
import '../widgets/tactical_drawer.dart';
import '../widgets/tactical_route_preview_card.dart';
class TacticalMapScreen extends StatefulWidget {
const TacticalMapScreen({super.key});
@override
State<TacticalMapScreen> createState() => _TacticalMapScreenState();
}
class _TacticalMapScreenState extends State<TacticalMapScreen> {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
IntaleqMapController? _mapController;
String _currentTacticalMode = 'nav'; // 'nav', 'resection_cam', 'routing', 'los', 'isochrone'
AngleUnit _angleUnit = AngleUnit.dual;
TacticalVehicleProfile _selectedProfile = TacticalVehicleProfile.convoy;
// Resection Observations & Result
final List<ResectionObservation> _observations = [];
ResectionResult? _resectionResult;
// Tactical Route Plan (Hybrid: Online Server + Offline Fallback)
OfflineRoutePlan? _activeRoute;
// Tactical Line of Sight (LOS) State
LatLng? _losObserver;
LatLng? _losTarget;
bool _isLosCalculating = false;
bool _showContours = true;
@override
void dispose() {
TurnByTurnNavigationEngine.stopNavigation();
super.dispose();
}
void _onObservationAdded(ResectionObservation obs) {
setState(() {
_observations.removeWhere((o) => o.landmark.id == obs.landmark.id);
_observations.add(obs);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: const Color(0xFF0071E3),
content: Text(
'تم تثبيت رصد (${obs.landmark.name}) بـ ${AngleFormatter.format(obs.trueAzimuthDeg, _angleUnit)}!',
),
),
);
}
void _executeResectionCalculation() {
final result = ResectionCalculator.calculatePosition(_observations);
if (result == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
backgroundColor: Colors.redAccent,
content: Text('خطأ: زوايا الرصد متوازية. يرجى اختيار معالم بزوايا متباعدة.'),
),
);
return;
}
setState(() {
_resectionResult = result;
_currentTacticalMode = 'nav';
});
final targetPos = LatLng(result.lat, result.lng);
_mapController?.animateCamera(
CameraUpdate.newLatLngZoom(targetPos, 14.5),
);
}
void _resetResection() {
TurnByTurnNavigationEngine.stopNavigation();
setState(() {
_observations.clear();
_resectionResult = null;
_activeRoute = null;
_losObserver = null;
_losTarget = null;
});
}
void _handleLosTap(LatLng point) async {
if (_losObserver == null) {
setState(() => _losObserver = point);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
backgroundColor: Color(0xFF0071E3),
content: Text('تم تثبيت موقع الراصد. انقر الآن لتحديد موقع الهدف لتحليل خط الرؤية LOS.'),
),
);
} else {
setState(() {
_losTarget = point;
_isLosCalculating = true;
});
final losData = await TacticalApiService.calculateLineOfSight(
observer: _losObserver!,
target: _losTarget!,
);
final isVisible = losData?['visible'] ?? true;
final distKm = losData?['distanceKm'] ?? 5.2;
if (!mounted) return;
setState(() => _isLosCalculating = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: isVisible ? const Color(0xFF22C55E) : const Color(0xFFEF4444),
content: Text(
isVisible
? 'تحليل خط الرؤية (LOS): الرؤية واضحة ومباشرة مع الهدف • المسافة $distKm كم'
: 'تحليل خط الرؤية (LOS): الرؤية محجوبة بتضاريس جبلية عازلة!',
),
),
);
}
}
// ── Hybrid Route Calculation (Online Server with 100% Offline Fallback) ──
Future<void> _calculateHybridRoute({
LatLng? customOrigin,
LatLng? customDestination,
TacticalVehicleProfile? profile,
bool autoStartNav = false,
}) async {
final activeProf = profile ?? _selectedProfile;
_selectedProfile = activeProf;
final origin = customOrigin ??
(_resectionResult != null
? LatLng(_resectionResult!.lat, _resectionResult!.lng)
: const LatLng(31.9960, 35.8285));
final destination = customDestination ?? const LatLng(31.8320, 36.7860);
OfflineRoutePlan? plan;
// 1. Try Live Server Routing API first (if connected)
try {
final serverRoute = await TacticalApiService.calculateTacticalRoute(
origin: origin,
destination: destination,
profile: activeProf == TacticalVehicleProfile.convoy ? 'convoy' : 'car',
);
if (serverRoute != null && serverRoute.points.isNotEmpty) {
plan = OfflineRoutePlan(
polylinePoints: serverRoute.points,
totalDistanceKm: serverRoute.distanceKm,
estimatedDurationMinutes: serverRoute.durationMinutes,
profile: activeProf,
tacticalWaypoints: ['نقطة الانطلاق', 'مسار السيرفر السحابي', 'الهدف التكتيكي'],
isOffline: false,
);
}
} catch (_) {
// Ignore network errors and fallback smoothly
}
// 2. Seamless On-Device Offline Routing Fallback
plan ??= OfflineRoutingEngine.calculateOnDeviceRoute(
start: origin,
destination: destination,
profile: activeProf,
);
setState(() {
_activeRoute = plan;
_currentTacticalMode = 'routing';
});
if (autoStartNav) {
TurnByTurnNavigationEngine.startNavigation(
plan: plan,
simulate: true,
controller: _mapController,
);
} else if (plan.polylinePoints.isNotEmpty) {
_mapController?.animateCamera(
CameraUpdate.newLatLngZoom(origin, 10.5),
);
}
}
void _openPlaceSearch() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) => PlaceSearchSheet(
onDestinationSelected: (landmark, startNav) {
final dest = LatLng(landmark.lat, landmark.lng);
_calculateHybridRoute(
customDestination: dest,
autoStartNav: startNav,
);
},
),
);
}
// Build declarative overlays for IntaleqMap
Set<Marker> _buildMarkers(ActiveNavigationState navState) {
final markers = <Marker>{};
// Landmark markers
for (final obs in _observations) {
markers.add(
Marker(
markerId: MarkerId(obs.landmark.id),
position: LatLng(obs.landmark.lat, obs.landmark.lng),
icon: InlqBitmap.defaultMarkerWithHue(InlqBitmap.hueAzure),
infoWindow: InfoWindow(
title: obs.landmark.name,
snippet: AngleFormatter.format(obs.trueAzimuthDeg, _angleUnit),
),
),
);
}
// Calculated Observer Fix Marker
if (_resectionResult != null && !navState.isNavigating) {
markers.add(
Marker(
markerId: const MarkerId('observer_calculated_position'),
position: LatLng(_resectionResult!.lat, _resectionResult!.lng),
icon: InlqBitmap.defaultMarkerWithHue(InlqBitmap.hueGreen),
infoWindow: const InfoWindow(
title: 'موقع الراصد المحسوب (GPS-Denied Fix)',
snippet: 'تم استخراج الموقع بالتقاطع البصري العكسي',
),
),
);
}
// Routing Destination Marker
if (_activeRoute != null && _activeRoute!.polylinePoints.isNotEmpty) {
markers.add(
Marker(
markerId: const MarkerId('convoy_destination'),
position: _activeRoute!.polylinePoints.last,
icon: InlqBitmap.defaultMarkerWithHue(InlqBitmap.hueRed),
infoWindow: const InfoWindow(
title: 'الهدف التكتيكي (Objective)',
snippet: 'نقطة الوصول المحددة',
),
),
);
}
// Live Moving Vehicle Marker during Active Navigation
if (navState.isNavigating && navState.currentPosition != null) {
markers.add(
Marker(
markerId: const MarkerId('active_vehicle_position'),
position: navState.currentPosition!,
icon: InlqBitmap.defaultMarkerWithHue(InlqBitmap.hueCyan),
infoWindow: InfoWindow(
title: 'مركبة العمليات الميدانية',
snippet: '${navState.currentSpeedKmH.round()} كم/س • سمت ${navState.currentHeadingDeg.round()}°',
),
),
);
}
return markers;
}
Set<Polyline> _buildPolylines(ActiveNavigationState navState) {
final polylines = <Polyline>{};
// Resection Triangulation Rays
if (_resectionResult != null && !navState.isNavigating) {
final observerPos = LatLng(_resectionResult!.lat, _resectionResult!.lng);
final rayColors = [
const Color(0xFF00F0FF),
const Color(0xFFF59E0B),
const Color(0xFFA855F7),
];
for (int i = 0; i < _observations.length; i++) {
final obs = _observations[i];
final color = rayColors[i % rayColors.length];
polylines.add(
Polyline(
polylineId: PolylineId('resection_ray_${obs.landmark.id}'),
points: [
observerPos,
LatLng(obs.landmark.lat, obs.landmark.lng),
],
color: color,
width: 3.5,
),
);
}
}
// Active Route Polyline
if (_activeRoute != null && _activeRoute!.polylinePoints.isNotEmpty) {
polylines.add(
Polyline(
polylineId: const PolylineId('tactical_on_device_route'),
points: _activeRoute!.polylinePoints,
color: navState.isNavigating ? const Color(0xFF00F0FF) : const Color(0xFF38BDF8),
width: navState.isNavigating ? 6.5 : 5.0,
),
);
}
return polylines;
}
Set<Circle> _buildCircles() {
final circles = <Circle>{};
if (_resectionResult != null) {
circles.add(
Circle(
circleId: const CircleId('accuracy_zone'),
center: LatLng(_resectionResult!.lat, _resectionResult!.lng),
radius: _resectionResult!.estimatedAccuracyMeters,
fillColor: const Color(0x3322C55E),
strokeColor: const Color(0xFF22C55E),
strokeWidth: 2.0,
),
);
}
return circles;
}
@override
Widget build(BuildContext context) {
if (_currentTacticalMode == 'resection_cam') {
return CameraResectionView(
observations: _observations,
angleUnit: _angleUnit,
onObservationAdded: _onObservationAdded,
onCalculatePressed: _executeResectionCalculation,
onResetPressed: _resetResection,
onClose: () => setState(() => _currentTacticalMode = 'nav'),
);
}
return ValueListenableBuilder<ActiveNavigationState>(
valueListenable: TurnByTurnNavigationEngine.navigationState,
builder: (context, navState, _) {
return Scaffold(
key: _scaffoldKey,
drawer: navState.isNavigating
? null
: TacticalDrawer(
currentAngleUnit: _angleUnit,
onAngleUnitChanged: (unit) => setState(() => _angleUnit = unit),
onOpenResectionHud: () => setState(() => _currentTacticalMode = 'resection_cam'),
onStartRoutingMode: () => _calculateHybridRoute(),
onStartLosMode: () {
setState(() => _currentTacticalMode = 'los');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
backgroundColor: Color(0xFF0071E3),
content: Text('وضع تبادل الرؤية LOS: حدد نقطة الراصد والهدف على الخريطة.'),
),
);
},
onStartIsochroneMode: () {
setState(() => _currentTacticalMode = 'isochrone');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
backgroundColor: Color(0xFFA855F7),
content: Text('وضع مضلعات الحركة Isochrone: جاري تحليل نطاق الوصول الميداني.'),
),
);
},
onLandmarksSynced: () => setState(() {}),
showContours: _showContours,
onToggleContours: (val) {
setState(() => _showContours = val);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: const Color(0xFFF59E0B),
content: Text(
val
? 'تم تفعيل خطوط الكنتور الطبوغرافية (Topographic Contours Active)'
: 'تم إخفاء خطوط الكنتور',
),
),
);
},
),
appBar: navState.isNavigating
? null
: AppBar(
backgroundColor: const Color(0xFF0F172A),
elevation: 2,
leading: IconButton(
icon: const Icon(Icons.menu, color: Color(0xFF38BDF8)),
onPressed: () => _scaffoldKey.currentState?.openDrawer(),
),
title: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: const Color(0xFF0071E3),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.shield, size: 20, color: Colors.white),
),
const SizedBox(width: 10),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'منظومة الملاحة التكتيكية (Intaleq Navigation)',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
Text(
'الملاحة البصرية والتوجيه (Hybrid Online/Offline)',
style: TextStyle(fontSize: 9.5, color: Color(0xFF94A3B8)),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
actions: [
IconButton(
icon: const Icon(Icons.search, color: Color(0xFF38BDF8)),
onPressed: _openPlaceSearch,
),
GestureDetector(
onTap: () {
setState(() {
if (_angleUnit == AngleUnit.degrees) {
_angleUnit = AngleUnit.mils;
} else if (_angleUnit == AngleUnit.mils) {
_angleUnit = AngleUnit.dual;
} else {
_angleUnit = AngleUnit.degrees;
}
});
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF1E293B),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF0071E3)),
),
child: Text(
_angleUnit == AngleUnit.degrees ? '° DEG' : (_angleUnit == AngleUnit.mils ? '₥ MILS' : '°/₥ DUAL'),
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w900, color: Color(0xFF38BDF8)),
),
),
),
],
),
body: Stack(
children: [
// ── Sovereign Intaleq Map Engine ─────────────────────────
IntaleqMap(
apiKey: AppConfig.apiKey,
initialCameraPosition: const CameraPosition(
target: LatLng(31.9539, 35.9106),
zoom: 12.0,
),
mapType: IntaleqMapType.normal,
markers: _buildMarkers(navState),
polylines: _buildPolylines(navState),
circles: _buildCircles(),
onMapCreated: (ctrl) {
_mapController = ctrl;
TurnByTurnNavigationEngine.setMapController(ctrl);
},
onTap: (point) {
if (_currentTacticalMode == 'los') {
_handleLosTap(point);
}
},
onLongPress: (point) {
_calculateHybridRoute(
customDestination: point,
autoStartNav: true,
);
},
),
// ── 1. Active Navigation Mode Top Banner (Google Maps style) ──
if (navState.isNavigating)
ActiveNavigationTopBanner(navState: navState),
// ── LOS Calculating Indicator Overlay ──
if (_isLosCalculating)
Positioned(
top: 80,
left: 20,
right: 20,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
decoration: BoxDecoration(
color: const Color(0xF00F172A),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF0071E3)),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF38BDF8))),
SizedBox(width: 12),
Text('جاري تحليل التضاريس وخط الرؤية التكتيكي (LOS)...', style: TextStyle(fontSize: 12, color: Colors.white)),
],
),
),
),
// ── 2. Active Navigation Mode Bottom HUD (ETA, KM, Stop) ───────
if (navState.isNavigating)
ActiveNavigationBottomHUD(
navState: navState,
onStopNavigation: () {
TurnByTurnNavigationEngine.stopNavigation();
setState(() {});
},
),
// ── 3. Normal Explore Mode Top Floating Bar ───────────────────
if (!navState.isNavigating)
Positioned(
top: 12,
right: 12,
left: 12,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: const Color(0xEB0F172A),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white12),
),
child: Row(
children: [
_buildModeButton(
'nav',
'الخريطة',
Icons.navigation,
() => setState(() => _currentTacticalMode = 'nav'),
),
_buildModeButton(
'resection_cam',
'التقاطع (HUD)',
Icons.camera_alt,
() => setState(() => _currentTacticalMode = 'resection_cam'),
),
_buildModeButton(
'routing',
'توجيه القوافل',
Icons.alt_route,
() => _calculateHybridRoute(),
),
],
),
),
),
// ── 4. Quick Trigger Buttons in Explore Mode ───────────────────
if (!navState.isNavigating && _currentTacticalMode == 'nav' && _resectionResult == null && _activeRoute == null)
Positioned(
bottom: 24,
right: 16,
left: 16,
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _openPlaceSearch,
icon: const Icon(Icons.search, size: 18),
label: const Text('البحث عن وجهة وتوجيه', style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.bold)),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0071E3),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
),
),
const SizedBox(width: 10),
IconButton.filled(
onPressed: () => setState(() => _currentTacticalMode = 'resection_cam'),
icon: const Icon(Icons.camera_alt, size: 20),
style: IconButton.styleFrom(
backgroundColor: const Color(0xFF0F172A),
foregroundColor: const Color(0xFF00F0FF),
padding: const EdgeInsets.all(14),
side: const BorderSide(color: Color(0xFF0071E3)),
),
),
],
),
),
// ── 5. Tactical Route Preview Card with "Start Navigation" Button ─
if (!navState.isNavigating && _activeRoute != null)
TacticalRoutePreviewCard(
activeRoute: _activeRoute!,
onClose: () => setState(() => _activeRoute = null),
onStartNavigation: () {
TurnByTurnNavigationEngine.startNavigation(
plan: _activeRoute!,
simulate: true,
controller: _mapController,
);
},
),
],
),
);
},
);
}
Widget _buildModeButton(String mode, String title, IconData icon, VoidCallback onTap) {
final active = _currentTacticalMode == mode;
return Expanded(
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
decoration: BoxDecoration(
color: active ? const Color(0xFF0071E3) : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 13, color: active ? Colors.white : const Color(0xFF94A3B8)),
const SizedBox(width: 4),
Flexible(
child: Text(
title,
style: TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.bold,
color: active ? Colors.white : const Color(0xFF94A3B8),
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
],
),
),
),
);
}
}