feat: Add Intaleq Native Android Kotlin & iOS Swift SDKs, Incline slope routing, Topographic contours and modular tactical app

This commit is contained in:
Hamza-Ayed
2026-08-20 23:54:58 +03:00
parent 4f6595b1eb
commit 431d1f95ff
187 changed files with 20640 additions and 151 deletions
@@ -0,0 +1,194 @@
import 'package:flutter/material.dart';
import '../models/navigation_state.dart';
class ActiveNavigationTopBanner extends StatelessWidget {
final ActiveNavigationState navState;
const ActiveNavigationTopBanner({super.key, required this.navState});
@override
Widget build(BuildContext context) {
final step = navState.currentStep;
if (step == null) return const SizedBox.shrink();
return Positioned(
top: 50,
left: 16,
right: 16,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: const Color(0xF50F172A),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF0071E3), width: 1.5),
boxShadow: const [
BoxShadow(color: Colors.black87, blurRadius: 20, offset: Offset(0, 4)),
],
),
child: Row(
children: [
// Maneuver Icon
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFF0071E3),
borderRadius: BorderRadius.circular(12),
boxShadow: const [
BoxShadow(color: Color(0x660071E3), blurRadius: 8),
],
),
child: Icon(step.icon, color: Colors.white, size: 28),
),
const SizedBox(width: 14),
// Instruction Text
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'بعد ${step.distanceMeters.round()} متر',
style: const TextStyle(
color: Color(0xFF38BDF8),
fontSize: 12,
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 2),
Text(
step.instructionAr,
style: const TextStyle(
color: Colors.white,
fontSize: 13.5,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Speed badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF020617),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
navState.currentSpeedKmH.round().toString(),
style: const TextStyle(
color: Color(0xFF4ADE80),
fontSize: 16,
fontWeight: FontWeight.w900,
),
),
const Text(
'كم/س',
style: TextStyle(color: Color(0xFF94A3B8), fontSize: 9),
),
],
),
),
],
),
),
);
}
}
class ActiveNavigationBottomHUD extends StatelessWidget {
final ActiveNavigationState navState;
final VoidCallback onStopNavigation;
const ActiveNavigationBottomHUD({
super.key,
required this.navState,
required this.onStopNavigation,
});
@override
Widget build(BuildContext context) {
final now = DateTime.now();
final arrivalTime = now.add(Duration(minutes: navState.remainingDurationMinutes.round()));
final hourStr = arrivalTime.hour.toString().padLeft(2, '0');
final minStr = arrivalTime.minute.toString().padLeft(2, '0');
return Positioned(
bottom: 20,
left: 16,
right: 16,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xF50F172A),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFF22C55E), width: 1.5),
boxShadow: const [
BoxShadow(color: Colors.black87, blurRadius: 25, offset: Offset(0, 4)),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Metrics Row
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildHUDCol('وقت الوصول (ETA)', '$hourStr:$minStr', const Color(0xFF4ADE80)),
Container(width: 1, height: 32, color: Colors.white12),
_buildHUDCol('المسافة المتبقية', '${navState.remainingDistanceKm} كم', Colors.white),
Container(width: 1, height: 32, color: Colors.white12),
_buildHUDCol('الزمن المتبقي', '${navState.remainingDurationMinutes.round()} دقيقة', const Color(0xFF38BDF8)),
],
),
const SizedBox(height: 14),
// Stop Navigation Button
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: onStopNavigation,
icon: const Icon(Icons.close, size: 18),
label: const Text(
'إنهاء الملاحة الميدانية (Exit Navigation)',
style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.bold),
),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFEF4444),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
],
),
],
),
),
);
}
Widget _buildHUDCol(String label, String value, Color color) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 10)),
const SizedBox(height: 3),
Text(
value,
style: TextStyle(color: color, fontSize: 14, fontWeight: FontWeight.w900),
),
],
);
}
}
@@ -0,0 +1,521 @@
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:flutter_compass/flutter_compass.dart';
import '../models/angle_unit.dart';
import '../models/landmark.dart';
import '../services/landmark_database.dart';
import '../services/resection_calculator.dart';
import 'tactical_crosshair.dart';
class CameraResectionView extends StatefulWidget {
final List<ResectionObservation> observations;
final AngleUnit angleUnit;
final Function(ResectionObservation) onObservationAdded;
final VoidCallback onCalculatePressed;
final VoidCallback onResetPressed;
final VoidCallback onClose;
const CameraResectionView({
super.key,
required this.observations,
required this.angleUnit,
required this.onObservationAdded,
required this.onCalculatePressed,
required this.onResetPressed,
required this.onClose,
});
@override
State<CameraResectionView> createState() => _CameraResectionViewState();
}
class _CameraResectionViewState extends State<CameraResectionView> {
CameraController? _cameraController;
bool _isCameraReady = false;
double _headingDeg = 45.0;
bool _isLockedAnim = false;
// Search & Filter state
final TextEditingController _searchController = TextEditingController();
String _selectedRegion = 'الكل';
List<TacticalLandmark> _filteredLandmarks = [];
TacticalLandmark? _selectedLandmark;
final List<String> _regions = const [
'الكل',
'عمان الغربية',
'وسط عمان',
'شمال عمان',
'الزرقاء',
'إربد',
'عجلون',
'جرش',
'المفرق',
'الكرك',
'الطفيلة',
'معان',
'العقبة',
];
@override
void initState() {
super.initState();
_filteredLandmarks = LandmarkDatabase.allLandmarks;
if (_filteredLandmarks.isNotEmpty) {
_selectedLandmark = _filteredLandmarks[0];
}
_initCamera();
_initCompass();
}
Future<void> _initCamera() async {
try {
final cameras = await availableCameras();
if (cameras.isNotEmpty) {
final backCamera = cameras.firstWhere(
(c) => c.lensDirection == CameraLensDirection.back,
orElse: () => cameras.first,
);
_cameraController = CameraController(
backCamera,
ResolutionPreset.high,
enableAudio: false,
);
await _cameraController!.initialize();
if (mounted) {
setState(() => _isCameraReady = true);
}
}
} catch (e) {
debugPrint('Camera init exception: $e');
}
}
void _initCompass() {
FlutterCompass.events?.listen((event) {
if (event.heading != null && mounted) {
setState(() {
_headingDeg = (event.heading! + 360) % 360;
});
}
});
}
@override
void dispose() {
_cameraController?.dispose();
_searchController.dispose();
super.dispose();
}
void _onSearchChanged(String query) {
setState(() {
_filteredLandmarks = LandmarkDatabase.search(
query,
region: _selectedRegion,
);
if (_filteredLandmarks.isNotEmpty &&
!_filteredLandmarks.any((l) => l.id == _selectedLandmark?.id)) {
_selectedLandmark = _filteredLandmarks[0];
}
});
}
void _onRegionSelected(String region) {
setState(() {
_selectedRegion = region;
_filteredLandmarks = LandmarkDatabase.search(
_searchController.text,
region: region,
);
if (_filteredLandmarks.isNotEmpty &&
!_filteredLandmarks.any((l) => l.id == _selectedLandmark?.id)) {
_selectedLandmark = _filteredLandmarks[0];
}
});
}
void _lockCurrentObservation() {
if (_selectedLandmark == null) return;
final trueAzimuth = ResectionCalculator.getTrueAzimuth(_headingDeg);
final obs = ResectionObservation(
landmark: _selectedLandmark!,
observedAzimuthDeg: _headingDeg,
trueAzimuthDeg: trueAzimuth,
);
setState(() => _isLockedAnim = true);
Future.delayed(const Duration(milliseconds: 300), () {
if (mounted) setState(() => _isLockedAnim = false);
});
widget.onObservationAdded(obs);
final remaining = _filteredLandmarks
.where((l) => l.id != _selectedLandmark!.id && !widget.observations.any((o) => o.landmark.id == l.id))
.toList();
if (remaining.isNotEmpty) {
setState(() => _selectedLandmark = remaining[0]);
}
}
@override
Widget build(BuildContext context) {
final trueAzimuth = ResectionCalculator.getTrueAzimuth(_headingDeg);
final obsCount = widget.observations.length;
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
// ── 1. Camera Feed / Viewfinder Background ─────────────────
if (_isCameraReady && _cameraController != null)
SizedBox.expand(
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(
width: _cameraController!.value.previewSize?.height ?? 1,
height: _cameraController!.value.previewSize?.width ?? 1,
child: CameraPreview(_cameraController!),
),
),
)
else
Container(
decoration: const BoxDecoration(
gradient: RadialGradient(
center: Alignment.center,
radius: 1.2,
colors: [Color(0xFF0F172A), Color(0xFF020617)],
),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0x330071E3),
borderRadius: BorderRadius.circular(50),
border: Border.all(color: const Color(0x660071E3)),
),
child: const Icon(Icons.camera_alt, size: 36, color: Color(0xFF38BDF8)),
),
const SizedBox(height: 12),
const Text(
'منظار الاستطلاع والتسديد البصري (HUD)',
style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
const Text(
'وجه الكاميرا أو حرك مؤشر السمت للتسديد على المعلم',
style: TextStyle(color: Color(0xFF94A3B8), fontSize: 11),
),
],
),
),
),
// ── 2. Tactical Military Crosshair & Reticle ────────────────
TacticalCrosshair(
headingDeg: _headingDeg,
trueAzimuthDeg: trueAzimuth,
angleUnit: widget.angleUnit,
isAimLocked: _isLockedAnim,
targetName: _selectedLandmark?.name,
),
// ── 3. Top Header: Step Flow + Close Button ─────────────────
Positioned(
top: 48,
left: 16,
right: 16,
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Step Flow Badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xEB0F172A),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: obsCount >= 2 ? const Color(0xFF22C55E) : const Color(0xFF0071E3),
),
),
child: Row(
children: [
Icon(
obsCount >= 2 ? Icons.check_circle : Icons.gps_fixed,
size: 14,
color: obsCount >= 2 ? const Color(0xFF4ADE80) : const Color(0xFF38BDF8),
),
const SizedBox(width: 6),
Text(
obsCount == 0
? 'الخطوة 1: تسديد المعلم الأول'
: (obsCount == 1
? 'الخطوة 2: تسديد المعلم الثاني'
: 'تم رصد $obsCount معالم (جاهز للحساب)'),
style: TextStyle(
color: obsCount >= 2 ? const Color(0xFF4ADE80) : Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
],
),
),
// Close / Return to Map Button
IconButton.filled(
onPressed: widget.onClose,
icon: const Icon(Icons.close, size: 16),
style: IconButton.styleFrom(
backgroundColor: const Color(0xCC0F172A),
foregroundColor: Colors.white,
),
),
],
),
const SizedBox(height: 10),
// Search Bar for Landmarks
Container(
decoration: BoxDecoration(
color: const Color(0xEB0F172A),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white12),
),
child: TextField(
controller: _searchController,
onChanged: _onSearchChanged,
style: const TextStyle(color: Colors.white, fontSize: 12),
decoration: InputDecoration(
hintText: 'ابحث عن معلم بالاسم أو المحافظة أو النوع...',
hintStyle: const TextStyle(color: Color(0xFF64748B), fontSize: 11),
prefixIcon: const Icon(Icons.search, color: Color(0xFF38BDF8), size: 18),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 14, color: Colors.white60),
onPressed: () {
_searchController.clear();
_onSearchChanged('');
},
)
: null,
contentPadding: const EdgeInsets.symmetric(vertical: 10),
border: InputBorder.none,
),
),
),
const SizedBox(height: 6),
// Region filter horizontal pills
SizedBox(
height: 28,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _regions.length,
itemBuilder: (context, i) {
final r = _regions[i];
final isSelected = r == _selectedRegion;
return Padding(
padding: const EdgeInsets.only(left: 6),
child: ChoiceChip(
label: Text(r, style: TextStyle(fontSize: 10, color: isSelected ? Colors.white : const Color(0xFF94A3B8))),
selected: isSelected,
onSelected: (_) => _onRegionSelected(r),
backgroundColor: const Color(0x990F172A),
selectedColor: const Color(0xFF0071E3),
padding: const EdgeInsets.symmetric(horizontal: 4),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
);
},
),
),
],
),
),
// ── 4. Bottom Control Panel ────────────────────────────────
Positioned(
bottom: 20,
left: 16,
right: 16,
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xF50F172A),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0x800071E3)),
boxShadow: const [BoxShadow(color: Colors.black87, blurRadius: 25)],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Heading Slider fine-tune (for desktop/field override)
Row(
children: [
const Text('ضبط السمت:', style: TextStyle(fontSize: 10, color: Color(0xFF94A3B8))),
Expanded(
child: Slider(
value: _headingDeg,
min: 0,
max: 359,
activeColor: const Color(0xFF0071E3),
onChanged: (v) => setState(() => _headingDeg = v),
),
),
Text(
AngleFormatter.format(_headingDeg, widget.angleUnit, includeLabel: false),
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Color(0xFF38BDF8)),
),
],
),
// Landmark Selector Cards (Horizontal Carousel)
if (_filteredLandmarks.isNotEmpty)
SizedBox(
height: 52,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _filteredLandmarks.length,
itemBuilder: (context, idx) {
final lm = _filteredLandmarks[idx];
final isSelected = lm.id == _selectedLandmark?.id;
final isObserved = widget.observations.any((o) => o.landmark.id == lm.id);
return GestureDetector(
onTap: () => setState(() => _selectedLandmark = lm),
child: Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: isSelected
? const Color(0xFF0071E3)
: (isObserved ? const Color(0x3322C55E) : const Color(0xFF1E293B)),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isSelected
? Colors.white
: (isObserved ? const Color(0xFF22C55E) : Colors.white12),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
if (isObserved)
const Icon(Icons.check_circle, size: 12, color: Color(0xFF4ADE80)),
if (isObserved) const SizedBox(width: 4),
Text(
lm.name,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: isSelected ? Colors.white : const Color(0xFFE2E8F0),
),
),
],
),
Text(
'${lm.region} • ${lm.elevationM}م',
style: TextStyle(
fontSize: 9.5,
color: isSelected ? Colors.white70 : const Color(0xFF94A3B8),
),
),
],
),
),
);
},
),
),
const SizedBox(height: 12),
// Action Buttons: Lock Landmark + Calculate Button
Row(
children: [
// Lock Target Button
Expanded(
flex: 3,
child: ElevatedButton.icon(
onPressed: _lockCurrentObservation,
icon: const Icon(Icons.lock_outline, size: 16),
label: Text(
obsCount == 0
? 'تثبيت رصد المعلم الأول'
: (obsCount == 1 ? 'تثبيت رصد المعلم الثاني' : 'تثبيت معلم إضافي (+1)'),
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0071E3),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
),
// Calculate & Show on Map (Available when >= 2)
if (obsCount >= 2) ...[
const SizedBox(width: 8),
Expanded(
flex: 2,
child: ElevatedButton.icon(
onPressed: widget.onCalculatePressed,
icon: const Icon(Icons.map, size: 16),
label: const Text(
'استخراج الموقع',
style: TextStyle(fontSize: 11.5, fontWeight: FontWeight.w900),
),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF22C55E),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
),
],
// Reset Button
if (obsCount > 0) ...[
const SizedBox(width: 8),
IconButton.filledTonal(
onPressed: widget.onResetPressed,
icon: const Icon(Icons.refresh, size: 16),
style: IconButton.styleFrom(
backgroundColor: const Color(0x33EF4444),
foregroundColor: const Color(0xFFF87171),
),
),
],
],
),
],
),
),
),
],
),
);
}
}
@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import '../services/offline_package_manager.dart';
class OfflinePackageDialog extends StatefulWidget {
final VoidCallback onPackageSynced;
const OfflinePackageDialog({super.key, required this.onPackageSynced});
@override
State<OfflinePackageDialog> createState() => _OfflinePackageDialogState();
}
class _OfflinePackageDialogState extends State<OfflinePackageDialog> {
bool _isDownloading = false;
OfflinePackageInfo? _info;
@override
void initState() {
super.initState();
_loadPackageInfo();
}
Future<void> _loadPackageInfo() async {
final info = await OfflinePackageManager.getPackageInfo();
if (mounted) setState(() => _info = info);
}
Future<void> _startSync({bool force = false}) async {
setState(() => _isDownloading = true);
final success = await OfflinePackageManager.downloadFullPackage(force: force);
if (mounted) {
setState(() => _isDownloading = false);
await _loadPackageInfo();
if (success) {
widget.onPackageSynced();
}
}
}
@override
Widget build(BuildContext context) {
final canSync = _info?.canSyncNow ?? true;
final remaining = _info?.daysUntilNextSync ?? 0;
return Dialog(
backgroundColor: const Color(0xFF0F172A),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20), side: const BorderSide(color: Color(0xFF0071E3), width: 1.5)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0x330071E3),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFF0071E3)),
),
child: const Icon(Icons.download_for_offline, color: Color(0xFF38BDF8), size: 22),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'حزمة بيانات الأردن التكتيكية',
style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold),
),
Text(
'Offline Sovereign Data Package v2.4',
style: TextStyle(color: Color(0xFF94A3B8), fontSize: 10.5),
),
],
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white54, size: 18),
onPressed: () => Navigator.pop(context),
),
],
),
const SizedBox(height: 16),
// Package Stats Container
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFF020617),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white12),
),
child: Column(
children: [
_buildStatRow('حجم حزمة التوجيه والطرق:', '3.5 MB (شبكة طوبولوجية كاملة)'),
const Divider(color: Colors.white12, height: 14),
_buildStatRow('حجم معالم PostGIS المفهرسة:', '~25 MB (SQLite FTS5 سريع)'),
const Divider(color: Colors.white12, height: 14),
_buildStatRow('محرك التوجيه غير المتصل:', '100% On-Device (خوارزمية A*)'),
const Divider(color: Colors.white12, height: 14),
_buildStatRow('قفل المزامنة الميداني:', 'كل 14 يوماً لحماية السيرفر'),
if (_info?.lastSyncTime != null) ...[
const Divider(color: Colors.white12, height: 14),
_buildStatRow(
'آخر مزامنة ناجحة:',
'${_info!.lastSyncTime!.day}/${_info!.lastSyncTime!.month}/${_info!.lastSyncTime!.year}',
),
],
],
),
),
const SizedBox(height: 16),
// Live Sync Progress Stream
ValueListenableBuilder<double>(
valueListenable: OfflinePackageManager.syncProgress,
builder: (context, progress, _) {
return ValueListenableBuilder<String>(
valueListenable: OfflinePackageManager.syncStatusMessage,
builder: (context, msg, _) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_isDownloading) ...[
LinearProgressIndicator(
value: progress > 0 ? progress : null,
backgroundColor: const Color(0xFF1E293B),
color: const Color(0xFF0071E3),
borderRadius: BorderRadius.circular(4),
),
const SizedBox(height: 8),
],
Text(
msg,
style: TextStyle(
color: progress == 1.0 ? const Color(0xFF4ADE80) : const Color(0xFF94A3B8),
fontSize: 11,
fontWeight: progress == 1.0 ? FontWeight.bold : FontWeight.normal,
),
),
],
);
},
);
},
),
const SizedBox(height: 18),
// Action Buttons
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _isDownloading
? null
: (canSync
? () => _startSync()
: () => _startSync(force: true)),
icon: _isDownloading
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: Icon(canSync ? Icons.sync : Icons.lock_clock, size: 16),
label: Text(
_isDownloading
? 'جاري المزامنة...'
: (canSync
? 'مزامنة وتحديث الحزمة الآن'
: 'الحزمة محدثة (تحديث إجباري / متبقي $remaining يوم)'),
style: const TextStyle(fontSize: 11.5, fontWeight: FontWeight.bold),
),
style: ElevatedButton.styleFrom(
backgroundColor: canSync ? const Color(0xFF0071E3) : const Color(0xFF1E293B),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: canSync ? const Color(0xFF0071E3) : Colors.white24),
),
),
),
),
],
),
],
),
),
);
}
Widget _buildStatRow(String title, String value) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(title, style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 11)),
Text(value, style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold)),
],
);
}
}
@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import '../models/landmark.dart';
import '../services/landmark_database.dart';
import '../services/local_sqlite_db.dart';
class PlaceSearchSheet extends StatefulWidget {
final Function(TacticalLandmark, bool startNavigation) onDestinationSelected;
const PlaceSearchSheet({super.key, required this.onDestinationSelected});
@override
State<PlaceSearchSheet> createState() => _PlaceSearchSheetState();
}
class _PlaceSearchSheetState extends State<PlaceSearchSheet> {
final TextEditingController _controller = TextEditingController();
List<TacticalLandmark> _results = [];
bool _isSearching = false;
@override
void initState() {
super.initState();
_results = LandmarkDatabase.allLandmarks.take(15).toList();
}
void _onSearch(String query) async {
if (query.trim().isEmpty) {
setState(() => _results = LandmarkDatabase.allLandmarks.take(15).toList());
return;
}
setState(() => _isSearching = true);
// 1. Search SQLite Database
final dbResults = await LocalSqliteDb.searchPlaces(query, limit: 30);
// 2. Search Landmark database as fallback
final staticResults = LandmarkDatabase.search(query);
final combined = <TacticalLandmark>[...dbResults, ...staticResults];
final unique = combined.toSet().toList();
if (mounted) {
setState(() {
_results = unique;
_isSearching = false;
});
}
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.only(top: 16, left: 16, right: 16, bottom: 24),
decoration: const BoxDecoration(
color: Color(0xFF0F172A),
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: [BoxShadow(color: Colors.black87, blurRadius: 25)],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag Handle
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 14),
// Search Field
TextField(
controller: _controller,
onChanged: _onSearch,
style: const TextStyle(color: Colors.white, fontSize: 13),
decoration: InputDecoration(
hintText: 'ابحث عن أي وجهة، شارع، معلم، أو منشأة في الأردن...',
hintStyle: const TextStyle(color: Color(0xFF64748B), fontSize: 11.5),
prefixIcon: const Icon(Icons.search, color: Color(0xFF0071E3), size: 20),
suffixIcon: _controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 16, color: Colors.white54),
onPressed: () {
_controller.clear();
_onSearch('');
},
)
: null,
filled: true,
fillColor: const Color(0xFF020617),
contentPadding: const EdgeInsets.symmetric(vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Colors.white12),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Color(0xFF0071E3), width: 1.5),
),
),
),
if (_isSearching) ...[
const SizedBox(height: 4),
const LinearProgressIndicator(minHeight: 2, color: Color(0xFF0071E3), backgroundColor: Colors.transparent),
],
const SizedBox(height: 12),
// Results List
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 280),
child: _results.isEmpty
? const Padding(
padding: EdgeInsets.all(24),
child: Text(
'لم يتم العثور على نتائج مطابقة',
style: TextStyle(color: Color(0xFF94A3B8), fontSize: 12),
),
)
: ListView.separated(
shrinkWrap: true,
itemCount: _results.length,
separatorBuilder: (_, __) => const Divider(color: Colors.white10, height: 1),
itemBuilder: (context, index) {
final item = _results[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF1E293B),
borderRadius: BorderRadius.circular(8),
),
child: Icon(item.typeIcon, color: const Color(0xFF38BDF8), size: 18),
),
title: Text(
item.name,
style: const TextStyle(color: Colors.white, fontSize: 12.5, fontWeight: FontWeight.bold),
),
subtitle: Text(
'${item.region} • ${item.typeLabelArabic}',
style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 10.5),
),
trailing: ElevatedButton.icon(
onPressed: () {
Navigator.pop(context);
widget.onDestinationSelected(item, true); // Direct start navigation
},
icon: const Icon(Icons.navigation, size: 13),
label: const Text('انطلق', style: TextStyle(fontSize: 11)),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0071E3),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,249 @@
import 'package:flutter/material.dart';
import '../models/angle_unit.dart';
class TacticalCrosshair extends StatelessWidget {
final double headingDeg;
final double trueAzimuthDeg;
final AngleUnit angleUnit;
final bool isAimLocked;
final String? targetName;
const TacticalCrosshair({
super.key,
required this.headingDeg,
required this.trueAzimuthDeg,
this.angleUnit = AngleUnit.dual,
this.isAimLocked = false,
this.targetName,
});
@override
Widget build(BuildContext context) {
return Stack(
children: [
// Reticle Canvas
CustomPaint(
size: Size.infinite,
painter: _CrosshairPainter(
isLocked: isAimLocked,
),
),
// Top Azimuth & Mils Telemetry Strip
Positioned(
top: 16,
left: 20,
right: 20,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: Colors.black.withAlpha(204),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isAimLocked ? const Color(0xFF22C55E) : const Color(0xFF00F0FF),
width: 1.5,
),
boxShadow: [
BoxShadow(
color: isAimLocked
? const Color(0xFF22C55E).withAlpha(102)
: const Color(0xFF00F0FF).withAlpha(102),
blurRadius: 12,
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.explore,
size: 16,
color: isAimLocked ? const Color(0xFF4ADE80) : const Color(0xFF00F0FF),
),
const SizedBox(width: 8),
Text(
'السمت: ${AngleFormatter.format(trueAzimuthDeg, angleUnit, includeLabel: false)}',
style: TextStyle(
color: isAimLocked ? const Color(0xFF4ADE80) : const Color(0xFF00F0FF),
fontSize: 12.5,
fontWeight: FontWeight.w900,
),
),
const SizedBox(width: 8),
Container(width: 1, height: 14, color: Colors.white24),
const SizedBox(width: 8),
Text(
'مغناطيسي: ${headingDeg.toStringAsFixed(0).padLeft(3, '0')}°',
style: const TextStyle(
color: Color(0xFF94A3B8),
fontSize: 10.5,
),
),
],
),
),
),
),
// Target Aiming Name Label under reticle
if (targetName != null)
Positioned(
bottom: 90,
left: 20,
right: 20,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xF00F172A),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isAimLocked ? const Color(0xFF22C55E) : const Color(0xFF38BDF8),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isAimLocked ? Icons.lock : Icons.my_location,
size: 14,
color: isAimLocked ? const Color(0xFF4ADE80) : const Color(0xFF38BDF8),
),
const SizedBox(width: 8),
Flexible(
child: Text(
'المعلم المصوب عليه: $targetName',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
),
],
);
}
}
class _CrosshairPainter extends CustomPainter {
final bool isLocked;
_CrosshairPainter({required this.isLocked});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final primaryColor = isLocked ? const Color(0xFF22C55E) : const Color(0xFF00F0FF);
final linePaint = Paint()
..color = primaryColor
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
final glowPaint = Paint()
..color = primaryColor.withAlpha(51)
..strokeWidth = 4.0
..style = PaintingStyle.stroke;
final centerDotPaint = Paint()
..color = isLocked ? const Color(0xFF22C55E) : const Color(0xFFEF4444)
..style = PaintingStyle.fill;
// Center Crosshair Circle (Outer)
const outerRadius = 46.0;
canvas.drawCircle(center, outerRadius, glowPaint);
canvas.drawCircle(center, outerRadius, linePaint);
// Inner Aiming Circle
const innerRadius = 18.0;
canvas.drawCircle(center, innerRadius, linePaint);
// Red Center Pip
canvas.drawCircle(center, 3.5, centerDotPaint);
// Crosshair Lines with Mil Marks
const lineLen = 70.0;
const gap = 24.0;
// Top ray
canvas.drawLine(Offset(center.dx, center.dy - gap), Offset(center.dx, center.dy - gap - lineLen), linePaint);
// Bottom ray
canvas.drawLine(Offset(center.dx, center.dy + gap), Offset(center.dx, center.dy + gap + lineLen), linePaint);
// Left ray
canvas.drawLine(Offset(center.dx - gap, center.dy), Offset(center.dx - gap - lineLen, center.dy), linePaint);
// Right ray
canvas.drawLine(Offset(center.dx + gap, center.dy), Offset(center.dx + gap + lineLen, center.dy), linePaint);
// Stadia / Mil tick marks
for (int i = 1; i <= 3; i++) {
final d = gap + i * 20.0;
final tickLen = (i % 2 == 0) ? 8.0 : 4.0;
// Top tick
canvas.drawLine(Offset(center.dx - tickLen, center.dy - d), Offset(center.dx + tickLen, center.dy - d), linePaint);
// Bottom tick
canvas.drawLine(Offset(center.dx - tickLen, center.dy + d), Offset(center.dx + tickLen, center.dy + d), linePaint);
// Left tick
canvas.drawLine(Offset(center.dx - d, center.dy - tickLen), Offset(center.dx - d, center.dy + tickLen), linePaint);
// Right tick
canvas.drawLine(Offset(center.dx + d, center.dy - tickLen), Offset(center.dx + d, center.dy + tickLen), linePaint);
}
// Corner HUD Bracket Accents
final bracketPaint = Paint()
..color = primaryColor.withAlpha(153)
..strokeWidth = 2.0
..style = PaintingStyle.stroke;
const bSize = 25.0;
const pad = 35.0;
// Top-Left
canvas.drawPath(
Path()
..moveTo(pad, pad + bSize)
..lineTo(pad, pad)
..lineTo(pad + bSize, pad),
bracketPaint,
);
// Top-Right
canvas.drawPath(
Path()
..moveTo(size.width - pad - bSize, pad)
..lineTo(size.width - pad, pad)
..lineTo(size.width - pad, pad + bSize),
bracketPaint,
);
// Bottom-Left
canvas.drawPath(
Path()
..moveTo(pad, size.height - pad - bSize)
..lineTo(pad, size.height - pad)
..lineTo(pad + bSize, size.height - pad),
bracketPaint,
);
// Bottom-Right
canvas.drawPath(
Path()
..moveTo(size.width - pad - bSize, size.height - pad)
..lineTo(size.width - pad, size.height - pad)
..lineTo(size.width - pad, size.height - pad - bSize),
bracketPaint,
);
}
@override
bool shouldRepaint(covariant _CrosshairPainter oldDelegate) {
return oldDelegate.isLocked != isLocked;
}
}
@@ -0,0 +1,367 @@
import 'package:flutter/material.dart';
import '../models/angle_unit.dart';
import '../services/landmark_database.dart';
import 'offline_package_dialog.dart';
class TacticalDrawer extends StatefulWidget {
final AngleUnit currentAngleUnit;
final Function(AngleUnit) onAngleUnitChanged;
final VoidCallback onOpenResectionHud;
final VoidCallback onStartRoutingMode;
final VoidCallback onStartLosMode;
final VoidCallback onStartIsochroneMode;
final VoidCallback onLandmarksSynced;
final bool showContours;
final Function(bool) onToggleContours;
const TacticalDrawer({
super.key,
required this.currentAngleUnit,
required this.onAngleUnitChanged,
required this.onOpenResectionHud,
required this.onStartRoutingMode,
required this.onStartLosMode,
required this.onStartIsochroneMode,
required this.onLandmarksSynced,
this.showContours = true,
required this.onToggleContours,
});
@override
State<TacticalDrawer> createState() => _TacticalDrawerState();
}
class _TacticalDrawerState extends State<TacticalDrawer> {
int _landmarkCount = LandmarkDatabase.allLandmarks.length;
void _openOfflinePackageModal() {
showDialog(
context: context,
builder: (ctx) => OfflinePackageDialog(
onPackageSynced: () {
setState(() {
_landmarkCount = LandmarkDatabase.allLandmarks.length;
});
widget.onLandmarksSynced();
},
),
);
}
@override
Widget build(BuildContext context) {
return Drawer(
backgroundColor: const Color(0xFF090E17),
child: Column(
children: [
// ── Header ───────────────────────────────────────────────
Container(
padding: const EdgeInsets.only(top: 54, bottom: 20, right: 18, left: 18),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topRight,
end: Alignment.bottomLeft,
colors: [Color(0xFF0F172A), Color(0xFF020617)],
),
border: Border(bottom: BorderSide(color: Colors.white12)),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF0071E3),
borderRadius: BorderRadius.circular(10),
boxShadow: const [BoxShadow(color: Color(0x660071E3), blurRadius: 10)],
),
child: const Icon(Icons.shield, size: 24, color: Colors.white),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'مركز العمليات التكتيكية',
style: TextStyle(color: Colors.white, fontSize: 14.5, fontWeight: FontWeight.bold),
),
Text(
'منظومة Intaleq Defense الميدانية (Off-Grid)',
style: TextStyle(color: Color(0xFF94A3B8), fontSize: 10),
),
],
),
),
],
),
),
// ── Drawer Body (Scrollable Operations) ────────────────────
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
children: [
// 1. Angle Unit Selector (نظام الزوايا: الدرجات والميل العسكري)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF0F172A),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF0071E3).withAlpha(102)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.explore, size: 16, color: Color(0xFF38BDF8)),
SizedBox(width: 8),
Text(
'نظام قياس الزوايا والسمت:',
style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 8),
SegmentedButton<AngleUnit>(
segments: const [
ButtonSegment(
value: AngleUnit.degrees,
label: Text('درجات °', style: TextStyle(fontSize: 10.5)),
),
ButtonSegment(
value: AngleUnit.mils,
label: Text('ميل ₥', style: TextStyle(fontSize: 10.5)),
),
ButtonSegment(
value: AngleUnit.dual,
label: Text('مزدوج', style: TextStyle(fontSize: 10.5)),
),
],
selected: {widget.currentAngleUnit},
onSelectionChanged: (set) {
if (set.isNotEmpty) {
widget.onAngleUnitChanged(set.first);
}
},
style: SegmentedButton.styleFrom(
backgroundColor: const Color(0xFF1E293B),
selectedBackgroundColor: const Color(0xFF0071E3),
selectedForegroundColor: Colors.white,
foregroundColor: const Color(0xFF94A3B8),
),
),
],
),
),
const SizedBox(height: 14),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 4, vertical: 4),
child: Text(
'العمليات الميدانية التكتيكية (100% Offline)',
style: TextStyle(color: Color(0xFF64748B), fontSize: 11, fontWeight: FontWeight.w800),
),
),
// 2. Visual Resection Launch
_buildOperationTile(
icon: Icons.camera_alt,
iconColor: const Color(0xFF00F0FF),
title: 'التقاطع البصري (GPS-Denied HUD)',
subtitle: 'تسديد الكاميرا وتحديد الإحداثيات بالرماية',
onTap: () {
Navigator.pop(context);
widget.onOpenResectionHud();
},
),
// 3. Tactical Routing Engine (100% On-Device)
_buildOperationTile(
icon: Icons.alt_route,
iconColor: const Color(0xFF38BDF8),
title: 'محرك التوجيه وقوافل الإمداد (On-Device)',
subtitle: 'توجيه وحساب مسارات محلياً بدون إنترنت وسيرفر',
onTap: () {
Navigator.pop(context);
widget.onStartRoutingMode();
},
),
// 4. Line-of-Sight & Intervisibility
_buildOperationTile(
icon: Icons.remove_red_eye,
iconColor: const Color(0xFFF59E0B),
title: 'تبادل الرؤية والمراقبة (LOS)',
subtitle: 'كشف النقاط الميتة وقطاع الرصد الميداني',
onTap: () {
Navigator.pop(context);
widget.onStartLosMode();
},
),
// 5. Isochrone Reachability
_buildOperationTile(
icon: Icons.timelapse,
iconColor: const Color(0xFFA855F7),
title: 'مضلعات الوصول ونطاق الحركة (Isochrone)',
subtitle: 'نطاق استجابة الإسعاف والدفاع والتدخل السريع',
onTap: () {
Navigator.pop(context);
widget.onStartIsochroneMode();
},
),
const SizedBox(height: 14),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 4, vertical: 4),
child: Text(
'السيادة الرقمية وحزم البيانات',
style: TextStyle(color: Color(0xFF64748B), fontSize: 11, fontWeight: FontWeight.w800),
),
),
// 6. Topographic Contour Lines (خطوط الكنتور الطبوغرافية)
Container(
margin: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF0F172A),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFF59E0B).withAlpha(102)),
),
child: SwitchListTile(
value: widget.showContours,
onChanged: widget.onToggleContours,
secondary: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0x33F59E0B),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.terrain, color: Color(0xFFF59E0B), size: 20),
),
title: const Text(
'خطوط الكنتور الطبوغرافية (Topographic Contours)',
style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold),
),
subtitle: const Text(
'إظهار خطوط مناسيب الارتفاع والمنحدرات (كل 20م و 100م)',
style: TextStyle(color: Color(0xFF94A3B8), fontSize: 10),
),
activeTrackColor: const Color(0xFFF59E0B),
activeThumbColor: Colors.white,
),
),
// 7. Offline Jordan Package & Sync Manager
Container(
margin: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF0F172A),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF22C55E).withAlpha(102)),
),
child: ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0x3322C55E),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.download_for_offline, color: Color(0xFF4ADE80), size: 20),
),
title: Text(
'حزمة بيانات الأردن ($_landmarkCount معلم)',
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold),
),
subtitle: const Text(
'مزامنة معالم PostGIS وشبكة التوجيه (825 KB)',
style: TextStyle(color: Color(0xFF94A3B8), fontSize: 10),
),
trailing: const Icon(Icons.chevron_left, color: Colors.white30, size: 18),
onTap: _openOfflinePackageModal,
),
),
],
),
),
// ── Footer: Air-Gapped Security Badge ─────────────────────
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: const BoxDecoration(
color: Color(0xFF020617),
border: Border(top: BorderSide(color: Colors.white12)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Row(
children: [
Icon(Icons.airplanemode_active, size: 14, color: Color(0xFF4ADE80)),
SizedBox(width: 6),
Text(
'جاهز للعمل بوضع الطيران 100%',
style: TextStyle(color: Color(0xFF4ADE80), fontSize: 11, fontWeight: FontWeight.bold),
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0x3322C55E),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'OFF-GRID',
style: TextStyle(color: Color(0xFF4ADE80), fontSize: 9, fontWeight: FontWeight.w900),
),
),
],
),
),
],
),
);
}
Widget _buildOperationTile({
required IconData icon,
required Color iconColor,
required String title,
required String subtitle,
required VoidCallback onTap,
}) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF0F172A),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white12),
),
child: ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: iconColor.withAlpha(38),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: iconColor, size: 18),
),
title: Text(
title,
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold),
),
subtitle: Text(
subtitle,
style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 10),
),
trailing: const Icon(Icons.chevron_left, color: Colors.white30, size: 18),
onTap: onTap,
),
);
}
}
@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import '../services/offline_routing_engine.dart';
class TacticalRoutePreviewCard extends StatelessWidget {
final OfflineRoutePlan activeRoute;
final VoidCallback onClose;
final VoidCallback onStartNavigation;
const TacticalRoutePreviewCard({
super.key,
required this.activeRoute,
required this.onClose,
required this.onStartNavigation,
});
@override
Widget build(BuildContext context) {
return Positioned(
bottom: 16,
right: 16,
left: 16,
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xF50F172A),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0xFF38BDF8), width: 1.5),
boxShadow: const [BoxShadow(color: Colors.black87, blurRadius: 25)],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(
activeRoute.isOffline ? Icons.offline_pin : Icons.cloud_done,
color: const Color(0xFF38BDF8),
size: 18,
),
const SizedBox(width: 8),
Text(
activeRoute.isOffline
? 'مسار تكتيكي محلي (On-Device 100%)'
: 'مسار خادم السيرفر السحابي (Live Route)',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: Color(0xFF38BDF8),
),
),
],
),
IconButton(
icon: const Icon(Icons.close, size: 16, color: Colors.white54),
onPressed: onClose,
),
],
),
const SizedBox(height: 8),
// Metrics Row
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildMetricCol('المسافة', '${activeRoute.totalDistanceKm} كم'),
_buildMetricCol('زمن العبور', '${activeRoute.estimatedDurationMinutes.round()} دقيقة'),
_buildMetricCol('الصعود / الهبوط', '+${activeRoute.elevationGainMeters} م / -${activeRoute.elevationLossMeters} م'),
_buildMetricCol('أقصى ميل', '${activeRoute.maxInclinePercent.abs()}%'),
],
),
const SizedBox(height: 8),
// Incline Profile Badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: const Color(0xFF020617),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.terrain, size: 14, color: Color(0xFFF59E0B)),
const SizedBox(width: 6),
Flexible(
child: Text(
activeRoute.inclineSummaryArabic,
style: const TextStyle(fontSize: 10.5, color: Color(0xFFCBD5E1), fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
const SizedBox(height: 12),
// Start Navigation Button (Google Maps / Siro Style)
ElevatedButton.icon(
onPressed: onStartNavigation,
icon: const Icon(Icons.navigation, size: 18),
label: const Text(
'بدء الملاحة الحية ثلاثية الأبعاد (Start 3D Navigation)',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w900),
),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF22C55E),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
minimumSize: const Size(double.infinity, 44),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
],
),
),
);
}
Widget _buildMetricCol(String label, String value) {
return Column(
children: [
Text(label, style: const TextStyle(fontSize: 9.5, color: Color(0xFF94A3B8))),
const SizedBox(height: 2),
Text(value, style: const TextStyle(fontSize: 11.5, fontWeight: FontWeight.bold, color: Colors.white)),
],
);
}
}