520 lines
20 KiB
Dart
520 lines
20 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:intaleq_maps/intaleq_maps.dart';
|
|
|
|
import '../config/app_config.dart';
|
|
import '../controllers/tactical_map_controller.dart';
|
|
import '../models/navigation_state.dart';
|
|
import '../services/turn_by_turn_navigation_engine.dart';
|
|
import '../widgets/active_navigation_hud.dart';
|
|
import '../widgets/camera_resection_view.dart';
|
|
import '../widgets/interactive_map_picker_hud.dart';
|
|
import '../widgets/place_search_sheet.dart';
|
|
import '../widgets/tactical_artillery_sheet.dart';
|
|
import '../widgets/tactical_drawer.dart';
|
|
import '../widgets/tactical_hlz_sheet.dart';
|
|
import '../widgets/tactical_initial_provisioning_dialog.dart';
|
|
import '../widgets/tactical_isochrone_sheet.dart';
|
|
import '../widgets/tactical_los_sheet.dart';
|
|
import '../widgets/tactical_minefield_sheet.dart';
|
|
import '../widgets/tactical_overlays_sheet.dart';
|
|
import '../widgets/tactical_route_planner_sheet.dart';
|
|
import '../widgets/tactical_symbols_sheet.dart';
|
|
import '../widgets/tactical_viewshed_sheet.dart';
|
|
|
|
/// ============================================================================
|
|
/// [TacticalMapScreen] - الشاشة التكتيكية الرئيسية (GetView Architecture)
|
|
/// ============================================================================
|
|
/// English:
|
|
/// Master map screen implemented as a reactive GetView listening to [TacticalMapController].
|
|
/// Decoupled from direct state mutations, using Obx for declarative UI rendering.
|
|
///
|
|
/// العربية:
|
|
/// شاشة العمليات والخريطة الرئيسية بنمط GetView التفاعلي، ترتبط بمتحكم [TacticalMapController]
|
|
/// وتستخدم Obx لتحديث طبقات الخريطة واللوحات المنبثقة فور تغير الحالة الميدانية.
|
|
/// ============================================================================
|
|
class TacticalMapScreen extends GetView<TacticalMapController> {
|
|
const TacticalMapScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>();
|
|
|
|
// Check Initial Provisioning on startup
|
|
_checkProvisioning(context);
|
|
|
|
return ValueListenableBuilder<ActiveNavigationState>(
|
|
valueListenable: TurnByTurnNavigationEngine.navigationState,
|
|
builder: (context, navState, _) {
|
|
return Scaffold(
|
|
key: scaffoldKey,
|
|
drawer: Obx(
|
|
() => TacticalDrawer(
|
|
currentAngleUnit: controller.angleUnit.value,
|
|
onAngleUnitChanged: controller.setAngleUnit,
|
|
onOpenResectionHud: () => controller.switchMode('resection_cam'),
|
|
onStartRoutingMode: () => _openRoutePlanner(context),
|
|
onStartLosMode: () {
|
|
controller.switchMode('los');
|
|
_openLosSheet(context);
|
|
},
|
|
onStartViewshedMode: () {
|
|
controller.switchMode('viewshed_360');
|
|
_openViewshed360Sheet(context);
|
|
},
|
|
onStartArtilleryMode: () {
|
|
controller.switchMode('artillery');
|
|
_openArtillerySheet(context);
|
|
},
|
|
onStartHlzMode: () {
|
|
controller.switchMode('hlz');
|
|
_openHlzSheet(context);
|
|
},
|
|
onStartMinefieldMode: () {
|
|
controller.switchMode('minefield');
|
|
_openMinefieldSheet(context);
|
|
},
|
|
onStartIsochroneMode: () {
|
|
controller.switchMode('isochrone');
|
|
_openIsochroneSheet(context);
|
|
},
|
|
onStartSymbolsMode: () {
|
|
controller.switchMode('symbols');
|
|
_openSymbolsSheet(context);
|
|
},
|
|
onStartOverlaysMode: () {
|
|
controller.switchMode('overlays');
|
|
_openOverlaysSheet(context);
|
|
},
|
|
onLandmarksSynced: () => controller.update(),
|
|
showContours: controller.showContours.value,
|
|
onToggleContours: controller.toggleContours,
|
|
),
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
// ── 1. Map Canvas with Declarative Layer Overlays ──────
|
|
Obx(() {
|
|
final markers = controller.buildMarkers(navState);
|
|
final polylines = controller.buildPolylines(navState);
|
|
final polygons = controller.buildPolygons();
|
|
|
|
return IntaleqMap(
|
|
apiKey: AppConfig.apiKey,
|
|
initialCameraPosition: CameraPosition(
|
|
target: controller.currentCameraCenter.value,
|
|
zoom: 13.0,
|
|
),
|
|
styleUrl: controller.showContours.value
|
|
? 'assets/style_dark.json'
|
|
: 'assets/style.json',
|
|
onMapCreated: (ctrl) {
|
|
controller.mapController = ctrl;
|
|
},
|
|
onCameraMove: (pos) {
|
|
controller.currentCameraCenter.value = pos.target;
|
|
},
|
|
onTap: controller.handleMapTap,
|
|
markers: markers,
|
|
polylines: polylines,
|
|
polygons: polygons,
|
|
);
|
|
}),
|
|
|
|
// ── 2. Top Tactical Operations Header Bar ──────────────
|
|
Positioned(
|
|
top: 48,
|
|
left: 16,
|
|
right: 16,
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF090E17).withAlpha(220),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: const Color(0xFF0071E3).withAlpha(120)),
|
|
),
|
|
child: IconButton(
|
|
icon: const Icon(Icons.menu, color: Color(0xFF38BDF8)),
|
|
onPressed: () => scaffoldKey.currentState?.openDrawer(),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Container(
|
|
height: 48,
|
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF090E17).withAlpha(220),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.white12),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.shield,
|
|
color: Color(0xFF0071E3), size: 20),
|
|
const SizedBox(width: 8),
|
|
Obx(
|
|
() => Text(
|
|
controller.getModeTitle(),
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF090E17).withAlpha(220),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.white12),
|
|
),
|
|
child: IconButton(
|
|
icon: const Icon(Icons.search,
|
|
color: Color(0xFF38BDF8)),
|
|
onPressed: () => _openPlaceSearch(context),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// ── 3. Interactive Crosshair Target Picker HUD ─────────
|
|
Obx(() {
|
|
if (controller.activePickerTarget.value == null) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
return InteractiveMapPickerHud(
|
|
target: controller.activePickerTarget.value!,
|
|
centerPosition: controller.currentCameraCenter.value,
|
|
onConfirm: () => controller.confirmPicker(
|
|
controller.activePickerTarget.value!,
|
|
controller.currentCameraCenter.value,
|
|
),
|
|
onCancel: controller.cancelPicker,
|
|
);
|
|
}),
|
|
|
|
// ── 4. Optical Resection Camera HUD (GPS-Denied) ───────
|
|
Obx(() {
|
|
if (controller.currentTacticalMode.value != 'resection_cam') {
|
|
return const SizedBox.shrink();
|
|
}
|
|
return CameraResectionView(
|
|
observations: controller.resection.observations,
|
|
angleUnit: controller.angleUnit.value,
|
|
onObservationAdded: controller.resection.addObservation,
|
|
onCalculatePressed: () {
|
|
final res = controller.resection.computePosition();
|
|
if (res != null) {
|
|
controller.switchMode('nav');
|
|
controller.mapController?.animateCamera(
|
|
CameraUpdate.newLatLngZoom(
|
|
LatLng(res.lat, res.lng), 15.0),
|
|
);
|
|
}
|
|
},
|
|
onResetPressed: controller.resection.reset,
|
|
onClose: () => controller.switchMode('nav'),
|
|
);
|
|
}),
|
|
|
|
// ── 5. Active Turn-by-Turn Navigation HUD ──────────────
|
|
if (navState.isNavigating)
|
|
Positioned(
|
|
top: 48,
|
|
left: 16,
|
|
right: 16,
|
|
child: ActiveNavigationHud(
|
|
state: navState,
|
|
angleUnit: controller.angleUnit.value,
|
|
onStopNavigation: controller.navigation.stopNavigation,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
// ── Operations Modal Launchers ─────────────────────────────
|
|
|
|
Future<void> _checkProvisioning(BuildContext context) async {
|
|
final needed =
|
|
await TacticalInitialProvisioningDialog.isProvisioningNeeded();
|
|
if (needed) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (ctx) => TacticalInitialProvisioningDialog(
|
|
onCompleted: () => Navigator.pop(ctx),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
void _openRoutePlanner(BuildContext context) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (ctx) => TacticalRoutePlannerSheet(
|
|
currentGps: controller.currentGpsPosition.value ??
|
|
const LatLng(31.9539, 35.9106),
|
|
resectionFix: controller.resection.resectionResult.value != null
|
|
? LatLng(controller.resection.resectionResult.value!.lat,
|
|
controller.resection.resectionResult.value!.lng)
|
|
: null,
|
|
onRouteConfirmed: (origin, destination, profile, autoStartNav) {
|
|
controller.navigation.calculateRoute(
|
|
origin: origin,
|
|
destination: destination,
|
|
profile: profile,
|
|
autoStart: autoStartNav,
|
|
mapController: controller.mapController,
|
|
);
|
|
},
|
|
onPickOriginOnMap: () =>
|
|
controller.setPickerTarget(MapPickerTarget.routeOrigin),
|
|
onPickDestinationOnMap: () =>
|
|
controller.setPickerTarget(MapPickerTarget.routeDestination),
|
|
onClose: () => Navigator.pop(ctx),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openLosSheet(BuildContext context) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (ctx) => Obx(
|
|
() => TacticalLosSheet(
|
|
observer: controller.los.observerPosition.value ??
|
|
const LatLng(31.9539, 35.9106),
|
|
target: controller.los.targetPosition.value ??
|
|
const LatLng(31.9800, 35.9500),
|
|
currentGps: controller.currentGpsPosition.value,
|
|
angleUnit: controller.angleUnit.value,
|
|
onUpdateLocations: (newObs, newTgt) {
|
|
controller.los.setObserver(newObs);
|
|
controller.los.setTarget(newTgt);
|
|
controller.fitBounds([newObs, newTgt]);
|
|
},
|
|
onVisibilityChanged: (_) {},
|
|
onReportGenerated: (_) {},
|
|
onPickObserverOnMap: () {
|
|
Navigator.pop(context);
|
|
controller.setPickerTarget(MapPickerTarget.losObserver);
|
|
},
|
|
onPickTargetOnMap: () {
|
|
Navigator.pop(context);
|
|
controller.setPickerTarget(MapPickerTarget.losTarget);
|
|
},
|
|
onClose: () {
|
|
Navigator.pop(context);
|
|
controller.switchMode('nav');
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openViewshed360Sheet(BuildContext context) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (ctx) => Obx(
|
|
() => TacticalViewshedSheet(
|
|
observer: controller.viewshed.observerPosition.value ??
|
|
controller.currentGpsPosition.value ??
|
|
const LatLng(31.9539, 35.9106),
|
|
currentGps: controller.currentGpsPosition.value,
|
|
onUpdateObserver: controller.viewshed.setObserver,
|
|
onReportUpdated: (_) {},
|
|
onPickObserverOnMap: () {
|
|
Navigator.pop(context);
|
|
controller.setPickerTarget(MapPickerTarget.viewshedCenter);
|
|
},
|
|
onClose: () => Navigator.pop(context),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openArtillerySheet(BuildContext context) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (ctx) => Obx(
|
|
() => TacticalArtillerySheet(
|
|
gunPosition: controller.artillery.gunPosition.value ??
|
|
controller.currentGpsPosition.value ??
|
|
const LatLng(31.9539, 35.9106),
|
|
targetPosition: controller.artillery.targetPosition.value,
|
|
angleUnit: controller.angleUnit.value,
|
|
onPickGun: () {
|
|
Navigator.pop(context);
|
|
controller.setPickerTarget(MapPickerTarget.artilleryGun);
|
|
},
|
|
onPickTarget: () {
|
|
Navigator.pop(context);
|
|
controller.setPickerTarget(MapPickerTarget.artilleryTarget);
|
|
},
|
|
onSwap: controller.artillery.swapPositions,
|
|
onSolutionCalculated: (sol) {
|
|
controller.fitBounds([sol.gunPosition, sol.targetPosition]);
|
|
},
|
|
onClose: () => Navigator.pop(context),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openHlzSheet(BuildContext context) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (ctx) => Obx(
|
|
() => TacticalHlzSheet(
|
|
selectedPosition: controller.hlz.selectedPosition.value ??
|
|
controller.currentGpsPosition.value ??
|
|
const LatLng(31.9539, 35.9106),
|
|
onPickLocation: () {
|
|
Navigator.pop(context);
|
|
controller.setPickerTarget(MapPickerTarget.hlzCenter);
|
|
},
|
|
onAssessmentCompleted: (_) {},
|
|
onClose: () => Navigator.pop(context),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openMinefieldSheet(BuildContext context) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (ctx) => Obx(
|
|
() => TacticalMinefieldSheet(
|
|
startPoint: controller.minefield.startPoint.value ??
|
|
controller.currentGpsPosition.value ??
|
|
const LatLng(31.9539, 35.9106),
|
|
endPoint: controller.minefield.endPoint.value,
|
|
onPickStart: () {
|
|
Navigator.pop(context);
|
|
controller.setPickerTarget(MapPickerTarget.minefieldStart);
|
|
},
|
|
onPickEnd: () {
|
|
Navigator.pop(context);
|
|
controller.setPickerTarget(MapPickerTarget.minefieldEnd);
|
|
},
|
|
onSwap: controller.minefield.swapPoints,
|
|
onZoneCalculated: (res) {
|
|
controller.fitBounds([res.startPoint, res.endPoint]);
|
|
},
|
|
onClose: () => Navigator.pop(context),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openIsochroneSheet(BuildContext context) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (ctx) => Obx(
|
|
() => TacticalIsochroneSheet(
|
|
center: controller.isochrone.center.value ??
|
|
controller.currentGpsPosition.value ??
|
|
const LatLng(31.9539, 35.9106),
|
|
onPickCenter: () {
|
|
Navigator.pop(context);
|
|
controller.setPickerTarget(MapPickerTarget.isochroneCenter);
|
|
},
|
|
onIsochronesCalculated: (_) {},
|
|
onClose: () => Navigator.pop(context),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openSymbolsSheet(BuildContext context) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (ctx) => Obx(
|
|
() => TacticalSymbolsSheet(
|
|
activePlacementType: controller.symbols.activePlacementType.value,
|
|
placedSymbols: controller.symbols.placedSymbols,
|
|
onSelectSymbolType: (type) {
|
|
controller.symbols.selectSymbolForPlacement(type);
|
|
Navigator.pop(context);
|
|
Get.snackbar(
|
|
'الرموز العسكرية',
|
|
'انقر على الخريطة لتثبيت الرمز في الميدان 📍',
|
|
backgroundColor: const Color(0xFF0071E3),
|
|
colorText: Colors.white,
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
);
|
|
},
|
|
onDeleteSymbol: (s) => controller.symbols.removeSymbol(s.id),
|
|
onClose: () => Navigator.pop(context),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openOverlaysSheet(BuildContext context) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (ctx) => Obx(
|
|
() => TacticalOverlaysSheet(
|
|
layers: controller.overlays.overlayLayers,
|
|
onToggleLayer: controller.overlays.toggleLayer,
|
|
onClose: () => Navigator.pop(context),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openPlaceSearch(BuildContext context) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (ctx) => PlaceSearchSheet(
|
|
onDestinationSelected: (landmark, startNav) {
|
|
final dest = LatLng(landmark.lat, landmark.lng);
|
|
final origin = controller.currentGpsPosition.value ??
|
|
const LatLng(31.9539, 35.9106);
|
|
controller.navigation.calculateRoute(
|
|
origin: origin,
|
|
destination: dest,
|
|
autoStart: startNav,
|
|
mapController: controller.mapController,
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|