MENA region data enrichment scripts` -> Wait, let's keep it concise and clean.

*   *Option 3:* `feat: add tourist landmarks support with UI sheets, data models
This commit is contained in:
Hamza-Ayed
2026-09-21 05:21:44 +03:00
parent cff5a8e1ce
commit dc9a736e26
29 changed files with 13396 additions and 1982 deletions
+3
View File
@@ -38,5 +38,8 @@ end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0'
end
end
end
+1 -1
View File
@@ -69,6 +69,6 @@ SPEC CHECKSUMS:
permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
PODFILE CHECKSUM: f8c2dcdfb50bb67645580d28a6bf814fca30bdec
PODFILE CHECKSUM: 50fd0505f665bfa50c65bca6a6da7109c67dd0c6
COCOAPODS: 1.16.2
@@ -58,3 +58,13 @@ class RouteData {
return '$minutes دقيقة';
}
}
class RouteException implements Exception {
final String message;
final int? statusCode;
const RouteException(this.message, {this.statusCode});
@override
String toString() => message;
}
@@ -0,0 +1,202 @@
import 'package:flutter/material.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'place_model.dart';
class TouristLandmark {
final String id;
final String nameAr;
final String nameEn;
final String category; // heritage, nature, religious, adventure, coastal
final String categoryAr;
final String description;
final double latitude;
final double longitude;
final double elevationMeters;
final String bestTimeToVisit;
final String tag;
final IconData iconData;
final Color accentColor;
const TouristLandmark({
required this.id,
required this.nameAr,
required this.nameEn,
required this.category,
required this.categoryAr,
required this.description,
required this.latitude,
required this.longitude,
required this.elevationMeters,
required this.bestTimeToVisit,
required this.tag,
required this.iconData,
required this.accentColor,
});
LatLng get coordinates => LatLng(latitude, longitude);
PlaceModel toPlaceModel() {
return PlaceModel(
id: id,
name: nameAr,
category: category,
latitude: latitude,
longitude: longitude,
elevationMeters: elevationMeters,
address: '$categoryAr • $tag',
);
}
static const List<TouristLandmark> curatedJordanLandmarks = [
TouristLandmark(
id: 'tourist_petra',
nameAr: 'البتراء (المدينة الوردية)',
nameEn: 'Petra',
category: 'heritage',
categoryAr: 'عجائب الدنيا السبع والتراث العالمي',
description: 'عاصمة مملكة الأنباط المنحوتة في الصخور الجبلية الوردية، تضم الخزنة والسيق والدير، وتعد من أعظم التحف المعمارية في التاريخ الإنساني.',
latitude: 30.3285,
longitude: 35.4444,
elevationMeters: 885.0,
bestTimeToVisit: 'الصباح الباكر أو عند الغروب الذهبي',
tag: 'عجائب الدنيا السبع • UNESCO',
iconData: Icons.castle_rounded,
accentColor: Color(0xFFD97706),
),
TouristLandmark(
id: 'tourist_wadi_rum',
nameAr: 'وادي رم (وادي القمر)',
nameEn: 'Wadi Rum',
category: 'adventure',
categoryAr: 'صحراء تكتيكية وجبال جرانيتية',
description: 'أودية صحراوية شاهقة وجبال صخرية ترتفع لأكثر من 1700 متر، مشهورة بالرمال الحمراء والتخييم الفلكي ومسارات السفاري والمغامرات.',
latitude: 29.5734,
longitude: 35.4210,
elevationMeters: 1050.0,
bestTimeToVisit: 'مساءً للتخييم الفلكي ومشاهدة النجوم',
tag: 'تراث عالمي • سياحة فلكية ومغامرات',
iconData: Icons.landscape_rounded,
accentColor: Color(0xFFEA580C),
),
TouristLandmark(
id: 'tourist_dead_sea',
nameAr: 'البحر الميت والموجب',
nameEn: 'Dead Sea & Mujib Canyon',
category: 'nature',
categoryAr: 'أخفض نقطة على وجه الأرض',
description: 'أخفض مسطح مائي في العالم بانخفاض 430 متراً تحت سطح البحر، مياهه الغنية بالأملاح المعدنية ومضيق وادي الموجب المائي للمغامرات المائية.',
latitude: 31.5085,
longitude: 35.5684,
elevationMeters: -430.0,
bestTimeToVisit: 'أشهر الشتاء والربيع (مناخ دافئ)',
tag: 'أخفض نقطة في العالم • علاج طبيعي',
iconData: Icons.waves_rounded,
accentColor: Color(0xFF0284C7),
),
TouristLandmark(
id: 'tourist_jerash',
nameAr: 'مدينة جرش الأثرية (بومبي الشرق)',
nameEn: 'Jerash Roman Ruins',
category: 'heritage',
categoryAr: 'أعظم المدن الرومانية المحفوظة',
description: 'إحدى مدن حلف الديكابوليس الروماني، تضم شارع الأعمدة، المسرح الجنوبي، معبد أرتميس، وساحة الندوة البيضاوية الفريدة من نوعها.',
latitude: 32.2811,
longitude: 35.8917,
elevationMeters: 590.0,
bestTimeToVisit: 'فترة الصباح والظهيرة المعتدلة',
tag: 'حضارة رومانية • شارع الأعمدة',
iconData: Icons.account_balance_rounded,
accentColor: Color(0xFFB45309),
),
TouristLandmark(
id: 'tourist_amman_citadel',
nameAr: 'جبل القلعة والمدرج الروماني (عمان)',
nameEn: 'Amman Citadel & Roman Theatre',
category: 'heritage',
categoryAr: 'قلب العاصمة التاريخي وتراث العمونيين',
description: 'موقع يروي تاريخ الحضارات العمونية واليونانية والرومانية والأموية، يطل على جبال عمان القديمة مع معبد هرقل والقصر الأموي المهيب.',
latitude: 31.9547,
longitude: 35.9352,
elevationMeters: 850.0,
bestTimeToVisit: 'قبل الغروب لإطلالة بانورامية على عمان',
tag: 'معبد هرقل • القصر الأموي',
iconData: Icons.fort_rounded,
accentColor: Color(0xFF4F46E5),
),
TouristLandmark(
id: 'tourist_ajloun_castle',
nameAr: 'قلعة عجلون (حصن صلاح الدين)',
nameEn: 'Ajloun Castle',
category: 'heritage',
categoryAr: 'قلاع العصور الوسطى وغابات الشمال',
description: 'حصن بناه القائد عز الدين أسامة أحد قادة صلاح الدين الأيوبي عام 1184م لمراقبة طرق التجارة وحماية مناجم الحديد في جبال عجلون.',
latitude: 32.3251,
longitude: 35.7275,
elevationMeters: 1023.0,
bestTimeToVisit: 'فصل الربيع بين غابات البلوط والصنوبر',
tag: 'تاريخ إسلامي • إطلالة وادي الأردن',
iconData: Icons.shield_rounded,
accentColor: Color(0xFF15803D),
),
TouristLandmark(
id: 'tourist_mount_nebo',
nameAr: 'جبل نيبو ومادبا (مدينة الفسيفساء)',
nameEn: 'Mount Nebo & Madaba',
category: 'religious',
categoryAr: 'موقع تاريخي وديني وإطلالة مقدسة',
description: 'موقع ذو مكانة تاريخية وروحية كبرى، يطل على وادي الأردن والقدس والبحر الميت، ومادبا الشهيرة بأقدم خارطة فسيفسائية للأراضي المقدسة.',
latitude: 31.7683,
longitude: 35.7253,
elevationMeters: 817.0,
bestTimeToVisit: 'الأيام الصافية لرؤية أفق القدس والضفة',
tag: 'فسيفساء بيزنطية • إطلالة بانورامية',
iconData: Icons.church_rounded,
accentColor: Color(0xFF9333EA),
),
TouristLandmark(
id: 'tourist_umm_qais',
nameAr: 'أم قيس (جدارا الأثرية)',
nameEn: 'Umm Qais (Gadara)',
category: 'heritage',
categoryAr: 'شرفة الشمال وإطلالة طبريا والجولان',
description: 'مدينة الفلاسفة القديمة المشيدة بالحجارة البازلتية السوداء، تطل على بحيرة طبريا، نهر اليرموك، وهضبة الجولان السورية في مشهد جغرافي ساحر.',
latitude: 32.6534,
longitude: 35.6847,
elevationMeters: 378.0,
bestTimeToVisit: 'وقت المغيب لمشاهدة بحيرة طبريا',
tag: 'إطلالة ثلاثية الحدود • حجر بازلتي',
iconData: Icons.visibility_rounded,
accentColor: Color(0xFF0D9488),
),
TouristLandmark(
id: 'tourist_dana_biosphere',
nameAr: 'محمية ضانا للمحيط الحيوي',
nameEn: 'Dana Biosphere Reserve',
category: 'nature',
categoryAr: 'محمية جبلية وأودية طبيعية عذراء',
description: 'أكبر محمية طبيعية في الأردن، تمتد من قمم جبال القادسية بارتفاع 1500 متر نزولاً إلى وادي عربة، تضم تنوعاً نباتياً وحيوانياً فريداً ومسارات مشي.',
latitude: 30.6865,
longitude: 35.6120,
elevationMeters: 1250.0,
bestTimeToVisit: 'فصل الخريف والربيع للمسارات الجبلية',
tag: 'سياحة بيئية • مسارات مشي جبلية',
iconData: Icons.forest_rounded,
accentColor: Color(0xFF16A34A),
),
TouristLandmark(
id: 'tourist_aqaba',
nameAr: 'العقبة (خليج العقبة والشعب المرجانية)',
nameEn: 'Aqaba Red Sea Coast',
category: 'coastal',
categoryAr: 'عروس البحر الأحمر والرياضات البحرية',
description: 'المنفذ البحري الوحيد، شواطئ دافئة طوال العام، غوص بين الشعب المرجانية المحمية عالمياً، ومشاهدة الحياة البحرية الغنية.',
latitude: 29.5320,
longitude: 35.0063,
elevationMeters: 10.0,
bestTimeToVisit: 'طوال العام، ومثالية في فصلي الخريف والشتاء',
tag: 'غوص وشواطئ • شمس دافئة',
iconData: Icons.sailing_rounded,
accentColor: Color(0xFF0284C7),
),
];
}
@@ -96,12 +96,35 @@ class MapSaasRepository {
return routes;
} else {
print("⚠️ [MapSaasRepo] Route fetch returned non-200 status: ${response.statusCode}, body: ${response.body}");
String? serverMsg;
try {
final errJson = jsonDecode(response.body);
serverMsg = errJson['message']?.toString();
} catch (_) {}
if (response.statusCode == 502 || response.statusCode == 503 || response.statusCode == 504) {
throw RouteException(
'خادم حساب المسارات قيد إعادة التشغيل أو التحديث (502)، يرجى المحاولة بعد لحظات.',
statusCode: response.statusCode,
);
} else if (response.statusCode == 422) {
throw RouteException(
serverMsg ?? 'الموقع المحدد خارج نطاق التغطية المعتمدة لشبكة الطرق.',
statusCode: response.statusCode,
);
} else {
throw RouteException(
serverMsg ?? 'تعذر حساب المسار حالياً (${response.statusCode}).',
statusCode: response.statusCode,
);
}
}
} on RouteException {
rethrow;
} catch (e) {
print("❌ [MapSaasRepo] getRoute exception: $e");
throw const RouteException('تعذر الاتصال بالخادم، يرجى التحقق من اتصال الإنترنت والمحاولة مجدداً.');
}
return [];
}
RouteData _parseRouteData({
@@ -19,6 +19,7 @@ import '../../../core/services/vehicle_icon_generator.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../data/models/hazard_model.dart';
import '../../../data/models/route_model.dart';
import '../../../data/models/tourist_landmark_model.dart';
import '../../../data/repositories/map_saas_repository.dart';
import 'navigation_state.dart';
@@ -45,7 +46,8 @@ class NavigationCubit extends Cubit<NavigationState> {
ConnectivityService? connectivityService,
}) : locationService = locationService ?? LocationService.instance,
ttsService = ttsService ?? TtsService.instance,
connectivityService = connectivityService ?? ConnectivityService.instance,
connectivityService =
connectivityService ?? ConnectivityService.instance,
super(const NavigationState()) {
_init();
}
@@ -90,22 +92,28 @@ class NavigationCubit extends Cubit<NavigationState> {
// Check & listen to network connectivity
connectivityService.initialize();
final isOnline = await connectivityService.checkConnection();
print("🌐 [NavigationCubit] Network connectivity status: isOnline=$isOnline");
print(
"🌐 [NavigationCubit] Network connectivity status: isOnline=$isOnline");
emit(state.copyWith(isOnline: isOnline));
_connectivitySub = connectivityService.onConnectivityChanged.listen((online) {
_connectivitySub =
connectivityService.onConnectivityChanged.listen((online) {
print("🌐 [NavigationCubit] Connectivity changed event: online=$online");
emit(state.copyWith(isOnline: online));
});
// Default to Amman if initial position is fetching
final defaultPos = const LatLng(ApiConstants.defaultLat, ApiConstants.defaultLng);
final defaultPos =
const LatLng(ApiConstants.defaultLat, ApiConstants.defaultLng);
emit(state.copyWith(myLocation: defaultPos, altitude: 0.0));
final position = await locationService.getCurrentPosition();
if (position != null) {
final loc = LatLng(position.latitude, position.longitude);
final alt = (position.altitude.isNaN || position.altitude.isInfinite) ? 0.0 : position.altitude;
print("📍 [NavigationCubit] Initial GPS location acquired: lat=${loc.latitude.toStringAsFixed(6)}, lng=${loc.longitude.toStringAsFixed(6)}, alt=${alt.toStringAsFixed(1)}m, heading=${position.heading.toStringAsFixed(1)}°");
final alt = (position.altitude.isNaN || position.altitude.isInfinite)
? 0.0
: position.altitude;
print(
"📍 [NavigationCubit] Initial GPS location acquired: lat=${loc.latitude.toStringAsFixed(6)}, lng=${loc.longitude.toStringAsFixed(6)}, alt=${alt.toStringAsFixed(1)}m, heading=${position.heading.toStringAsFixed(1)}°");
emit(state.copyWith(
myLocation: loc,
altitude: alt,
@@ -116,7 +124,8 @@ class NavigationCubit extends Cubit<NavigationState> {
_currentDisplayHeading = position.heading;
_updateCarMarker(loc, position.heading);
} else {
print("⚠️ [NavigationCubit] Initial GPS returned null, using default Amman center");
print(
"⚠️ [NavigationCubit] Initial GPS returned null, using default Amman center");
}
_startLocationUpdates();
@@ -128,7 +137,8 @@ class NavigationCubit extends Cubit<NavigationState> {
int? _hasAnnouncedEarlyStepIndex;
void onMapCreated(IntaleqMapController controller) {
print("🗺️ [NavigationCubit] onMapCreated: Native map view created, controller attached.");
print(
"🗺️ [NavigationCubit] onMapCreated: Native map view created, controller attached.");
mapController = controller;
emit(state.copyWith(status: NavigationStatus.mapReady));
}
@@ -136,13 +146,18 @@ class NavigationCubit extends Cubit<NavigationState> {
Future<void> _animateCameraToCurrentPosition() async {
LatLng? target = state.myLocation;
double heading = state.heading;
print("🎥 [NavigationCubit] _animateCameraToCurrentPosition (target=$target, styleLoaded=$_isMapStyleLoaded)");
print(
"🎥 [NavigationCubit] _animateCameraToCurrentPosition (target=$target, styleLoaded=$_isMapStyleLoaded)");
if (target == null || (target.latitude == ApiConstants.defaultLat && target.longitude == ApiConstants.defaultLng)) {
if (target == null ||
(target.latitude == ApiConstants.defaultLat &&
target.longitude == ApiConstants.defaultLng)) {
final pos = await locationService.getCurrentPosition();
if (pos != null) {
final loc = LatLng(pos.latitude, pos.longitude);
final alt = (pos.altitude.isNaN || pos.altitude.isInfinite) ? 0.0 : pos.altitude;
final alt = (pos.altitude.isNaN || pos.altitude.isInfinite)
? 0.0
: pos.altitude;
target = loc;
heading = pos.heading;
emit(state.copyWith(
@@ -178,7 +193,8 @@ class NavigationCubit extends Cubit<NavigationState> {
primaryColor: Color(state.selectedVehicleColor),
);
await mapController!.addImage('current_vehicle_icon', bytes);
print("🚗 [NavigationCubit] Registered dynamic current_vehicle_icon (style=${state.selectedVehicleStyle}, color=0x${state.selectedVehicleColor.toRadixString(16)}, scale=${state.vehicleScale})");
print(
"🚗 [NavigationCubit] Registered dynamic current_vehicle_icon (style=${state.selectedVehicleStyle}, color=0x${state.selectedVehicleColor.toRadixString(16)}, scale=${state.vehicleScale})");
} catch (e) {
print("⚠️ [NavigationCubit] Error generating vehicle icon: $e");
}
@@ -199,25 +215,225 @@ class NavigationCubit extends Cubit<NavigationState> {
// 3. Start & Destination pins
try {
final startBytes = await rootBundle.load('assets/images/A.png');
await mapController!.addImage('start_icon', startBytes.buffer.asUint8List());
await mapController!.addImage('asset_assets_images_A_png', startBytes.buffer.asUint8List());
await mapController!
.addImage('start_icon', startBytes.buffer.asUint8List());
await mapController!.addImage(
'asset_assets_images_A_png', startBytes.buffer.asUint8List());
} catch (_) {}
try {
final destBytes = await rootBundle.load('assets/images/b.png');
await mapController!.addImage('dest_icon', destBytes.buffer.asUint8List());
await mapController!.addImage('asset_assets_images_b_png', destBytes.buffer.asUint8List());
await mapController!
.addImage('dest_icon', destBytes.buffer.asUint8List());
await mapController!.addImage(
'asset_assets_images_b_png', destBytes.buffer.asUint8List());
} catch (_) {}
}
Future<void> onStyleLoaded() async {
print("🎨 [NavigationCubit] onStyleLoaded: Map style rendered successfully! Registering custom icons & centering camera.");
print(
"🎨 [NavigationCubit] onStyleLoaded: Map style rendered successfully! Registering custom icons & centering camera.");
_isMapStyleLoaded = true;
await _loadCustomIcons();
if (state.myLocation != null) {
_updateCarMarker(_currentDisplayPosition ?? state.myLocation!, _currentDisplayHeading);
_updateCarMarker(
_currentDisplayPosition ?? state.myLocation!, _currentDisplayHeading);
}
_animateCameraToCurrentPosition();
// Apply 3D terrain exaggeration (default 2.0x) and sun lighting
applySunLighting(state.sunTimeMinutes, state.terrainExaggeration);
mapController?.setTilt(state.cameraTilt);
}
// ── 3D CINEMATIC STUDIO & ORBIT ENGINE ────────────────────────
Timer? _orbitTimer;
Timer? _sunTimer;
void toggleOrbit() {
final nextOrbiting = !state.isOrbiting;
emit(state.copyWith(isOrbiting: nextOrbiting));
_orbitTimer?.cancel();
if (nextOrbiting) {
_orbitTimer = Timer.periodic(const Duration(milliseconds: 60), (_) {
if (isClosed || !state.isOrbiting) return;
mapController?.orbitStep(
state.orbitSpeed,
duration: const Duration(milliseconds: 60),
);
});
}
}
void stopOrbit() {
_orbitTimer?.cancel();
emit(state.copyWith(isOrbiting: false));
}
void setOrbitSpeed(double speed) {
emit(state.copyWith(orbitSpeed: speed));
}
void toggleSunAutoPlay() {
final nextSunPlaying = !state.isSunPlaying;
emit(state.copyWith(isSunPlaying: nextSunPlaying));
_sunTimer?.cancel();
if (nextSunPlaying) {
_sunTimer = Timer.periodic(const Duration(milliseconds: 130), (_) {
if (isClosed || !state.isSunPlaying) return;
int nextMinutes = state.sunTimeMinutes + 12;
if (nextMinutes > 1260) {
nextMinutes = 330;
}
emit(state.copyWith(sunTimeMinutes: nextMinutes));
applySunLighting(nextMinutes);
});
}
}
void stopSunAutoPlay() {
_sunTimer?.cancel();
emit(state.copyWith(isSunPlaying: false));
}
void setSunTimeMinutes(int minutes) {
emit(state.copyWith(sunTimeMinutes: minutes));
applySunLighting(minutes);
}
void setTerrainExaggeration(double value) {
emit(state.copyWith(terrainExaggeration: value));
applySunLighting(state.sunTimeMinutes, value);
}
void setCameraTilt(double tilt) {
emit(state.copyWith(cameraTilt: tilt));
mapController?.setTilt(tilt);
}
void toggleBuildings() {
final next = !state.showBuildings;
emit(state.copyWith(showBuildings: next));
mapController?.setLayerVisibility('building-3d', next);
mapController?.setLayerVisibility('building-3d-osm', next);
}
void toggleContours() {
final next = !state.showContours;
emit(state.copyWith(showContours: next));
mapController?.setLayerVisibility('tactical-contour-minor', next);
mapController?.setLayerVisibility('tactical-contour-major', next);
mapController?.setLayerVisibility('opentopo-contour-lines', next);
}
void toggleHillshading() {
final next = !state.showHillshading;
emit(state.copyWith(showHillshading: next));
mapController?.setLayerVisibility('hillshading', next);
}
void applySunLighting([int? minutes, double? exaggeration]) {
final m = minutes ?? state.sunTimeMinutes;
final ex = exaggeration ?? state.terrainExaggeration;
final double fraction = ((m - 330) / (1260 - 330)).clamp(0.0, 1.0);
final double sunAzimuth = 75.0 + (fraction * 215.0);
String shadowColor;
String highlightColor;
String buildingColor;
double opacity = 0.88;
if (m < 420) {
shadowColor = '#4c1d95';
highlightColor = '#fed7aa';
buildingColor = '#fbcfe8';
opacity = 0.85;
} else if (m < 660) {
shadowColor = '#334155';
highlightColor = '#ffffff';
buildingColor = '#fef08a';
opacity = 0.88;
} else if (m < 840) {
shadowColor = '#1e293b';
highlightColor = '#ffffff';
buildingColor = '#f8fafc';
opacity = 0.90;
} else if (m < 1050) {
shadowColor = '#374151';
highlightColor = '#ffedd5';
buildingColor = '#fed7aa';
opacity = 0.88;
} else if (m < 1170) {
shadowColor = '#3b0764';
highlightColor = '#ffedd5';
buildingColor = '#ea580c';
opacity = 0.92;
} else {
shadowColor = '#020617';
highlightColor = '#60a5fa';
buildingColor = '#1e293b';
opacity = 0.80;
}
mapController?.updateSunSimulation(
sunAzimuth: sunAzimuth,
shadowColorHex: shadowColor,
highlightColorHex: highlightColor,
buildingColorHex: buildingColor,
buildingOpacity: opacity,
hillshadeExaggeration: ex,
);
}
// ── TOURISM & LANDMARKS 3D ───────────────────────────────────
void toggleTouristMode() {
final next = !state.isTouristModeEnabled;
emit(state.copyWith(isTouristModeEnabled: next));
if (next) {
// Switch map to topographical/scenic view with 2.0x 3D terrain
setTerrainExaggeration(2.0);
setCameraTilt(55.0);
if (!state.showHillshading) toggleHillshading();
}
}
void flyToLandmark(TouristLandmark landmark) {
emit(state.copyWith(
selectedLandmarkId: landmark.id,
isTouristModeEnabled: true,
));
// Ensure 3D terrain & hillshading are active
if (!state.showHillshading) toggleHillshading();
setTerrainExaggeration(2.0);
// Smoothly animate camera to the landmark in 3D
mapController?.animateCamera(
CameraUpdate.newCameraPosition(
CameraPosition(
target: landmark.coordinates,
zoom: 14.5,
tilt: 58.0,
bearing: 45.0,
),
),
// duration: const Duration(milliseconds: 1600),
);
}
void startOrbitAroundLandmark(TouristLandmark landmark) {
flyToLandmark(landmark);
if (!state.isOrbiting) {
toggleOrbit();
}
}
void clearSelectedLandmark() {
emit(state.copyWith(clearSelectedLandmark: true));
}
// ── VEHICLE CUSTOMIZATION HANDLERS ──────────────────────────
@@ -230,7 +446,8 @@ class NavigationCubit extends Cubit<NavigationState> {
} catch (_) {}
await _registerCurrentVehicleIcon();
if (state.myLocation != null) {
_updateCarMarker(_currentDisplayPosition ?? state.myLocation!, _currentDisplayHeading);
_updateCarMarker(
_currentDisplayPosition ?? state.myLocation!, _currentDisplayHeading);
}
}
@@ -242,7 +459,8 @@ class NavigationCubit extends Cubit<NavigationState> {
} catch (_) {}
await _registerCurrentVehicleIcon();
if (state.myLocation != null) {
_updateCarMarker(_currentDisplayPosition ?? state.myLocation!, _currentDisplayHeading);
_updateCarMarker(
_currentDisplayPosition ?? state.myLocation!, _currentDisplayHeading);
}
}
@@ -253,7 +471,8 @@ class NavigationCubit extends Cubit<NavigationState> {
await prefs.setDouble('siro_vehicle_scale', scale);
} catch (_) {}
if (state.myLocation != null) {
_updateCarMarker(_currentDisplayPosition ?? state.myLocation!, _currentDisplayHeading);
_updateCarMarker(
_currentDisplayPosition ?? state.myLocation!, _currentDisplayHeading);
}
}
@@ -265,13 +484,15 @@ class NavigationCubit extends Cubit<NavigationState> {
final newLoc = LatLng(pos.latitude, pos.longitude);
final speedKmH = pos.speed * 3.6;
final heading = pos.heading;
final alt = (pos.altitude.isNaN || pos.altitude.isInfinite) ? 0.0 : pos.altitude;
final alt =
(pos.altitude.isNaN || pos.altitude.isInfinite) ? 0.0 : pos.altitude;
_onNewLocationFix(newLoc, heading, speedKmH, alt);
});
}
void _onNewLocationFix(LatLng newLoc, double heading, double speedKmH, double alt) {
void _onNewLocationFix(
LatLng newLoc, double heading, double speedKmH, double alt) {
emit(state.copyWith(
myLocation: newLoc,
altitude: alt,
@@ -289,7 +510,9 @@ class NavigationCubit extends Cubit<NavigationState> {
}
// Proactively move camera on first acquired GPS lock (only when style is loaded)
if (!_hasInitiallyCenteredCamera && mapController != null && _isMapStyleLoaded) {
if (!_hasInitiallyCenteredCamera &&
mapController != null &&
_isMapStyleLoaded) {
_hasInitiallyCenteredCamera = true;
mapController!.animateCamera(
CameraUpdate.newCameraPosition(
@@ -328,8 +551,10 @@ class NavigationCubit extends Cubit<NavigationState> {
// Smooth ease-out curve for natural vehicle movement
final double curvedT = 1.0 - (1.0 - t) * (1.0 - t);
final interpPos = _lerpLatLng(_animStartPosition!, _animTargetPosition!, curvedT);
final interpHeading = _lerpAngle(_animStartHeading, _animTargetHeading, curvedT);
final interpPos =
_lerpLatLng(_animStartPosition!, _animTargetPosition!, curvedT);
final interpHeading =
_lerpAngle(_animStartHeading, _animTargetHeading, curvedT);
_currentDisplayPosition = interpPos;
_currentDisplayHeading = interpHeading;
@@ -337,9 +562,13 @@ class NavigationCubit extends Cubit<NavigationState> {
_updateCarMarker(interpPos, interpHeading);
// Smoothly update Google Maps style 3D camera periodically (~every 200ms) or on completion
if (state.isCameraLocked && mapController != null && _isMapStyleLoaded && state.isNavigating) {
if (state.isCameraLocked &&
mapController != null &&
_isMapStyleLoaded &&
state.isNavigating) {
if (_animCurrentStep % 5 == 0 || _animCurrentStep == _animTotalSteps) {
_updateNavigationCamera(interpPos, interpHeading, state.speed, state.distanceToNextStep);
_updateNavigationCamera(
interpPos, interpHeading, state.speed, state.distanceToNextStep);
}
}
@@ -352,13 +581,16 @@ class NavigationCubit extends Cubit<NavigationState> {
// ── GOOGLE MAPS NAVIGATION PERSPECTIVE & DYNAMIC ZOOM ─────────
void _updateNavigationCamera(LatLng pos, double heading, double speedKmH, double distanceToStep) {
if (mapController == null || !_isMapStyleLoaded || !state.isCameraLocked) return;
void _updateNavigationCamera(
LatLng pos, double heading, double speedKmH, double distanceToStep) {
if (mapController == null || !_isMapStyleLoaded || !state.isCameraLocked)
return;
// 1. Dynamic speed-adaptive zoom & lookahead calculation
double targetZoom;
double lookAheadMeters;
const double targetTilt = 45.0; // Fixed 45-degree angle as requested by user
const double targetTilt =
45.0; // Fixed 45-degree angle as requested by user
if (distanceToStep > 0 && distanceToStep < 100.0) {
// Approaching turn / maneuver: camera descends down close to show intersection
@@ -408,7 +640,8 @@ class NavigationCubit extends Cubit<NavigationState> {
);
}
LatLng _computeOffset(LatLng from, double distanceMeters, double bearingDegrees) {
LatLng _computeOffset(
LatLng from, double distanceMeters, double bearingDegrees) {
const double earthRadius = 6378137.0; // WGS-84 earth radius in meters
final double dByR = distanceMeters / earthRadius;
final double latRad = from.latitude * (pi / 180.0);
@@ -418,10 +651,11 @@ class NavigationCubit extends Cubit<NavigationState> {
final double targetLatRad = asin(
sin(latRad) * cos(dByR) + cos(latRad) * sin(dByR) * cos(bearingRad),
);
final double targetLonRad = lonRad + atan2(
sin(bearingRad) * sin(dByR) * cos(latRad),
cos(dByR) - sin(latRad) * sin(targetLatRad),
);
final double targetLonRad = lonRad +
atan2(
sin(bearingRad) * sin(dByR) * cos(latRad),
cos(dByR) - sin(latRad) * sin(targetLatRad),
);
return LatLng(targetLatRad * (180.0 / pi), targetLonRad * (180.0 / pi));
}
@@ -447,7 +681,8 @@ class NavigationCubit extends Cubit<NavigationState> {
rotation: bearing,
anchor: const Offset(0.5, 0.5),
flat: true,
icon: InlqBitmap.fromStyleImage('current_vehicle_icon', size: state.vehicleScale),
icon: InlqBitmap.fromStyleImage('current_vehicle_icon',
size: state.vehicleScale),
zIndex: 100,
));
} catch (e) {
@@ -460,9 +695,12 @@ class NavigationCubit extends Cubit<NavigationState> {
}
void relockCameraToUser() {
print("🎯 [NavigationCubit] relockCameraToUser requested (styleLoaded=$_isMapStyleLoaded, loc=${state.myLocation})");
print(
"🎯 [NavigationCubit] relockCameraToUser requested (styleLoaded=$_isMapStyleLoaded, loc=${state.myLocation})");
emit(state.copyWith(isCameraLocked: true));
if (state.myLocation != null && mapController != null && _isMapStyleLoaded) {
if (state.myLocation != null &&
mapController != null &&
_isMapStyleLoaded) {
if (state.isNavigating) {
_updateNavigationCamera(
_currentDisplayPosition ?? state.myLocation!,
@@ -486,7 +724,8 @@ class NavigationCubit extends Cubit<NavigationState> {
}
void setMapTheme(MapThemeType theme) {
print("🎨 [NavigationCubit] Changing map theme to: $theme (resetting _isMapStyleLoaded=false)");
print(
"🎨 [NavigationCubit] Changing map theme to: $theme (resetting _isMapStyleLoaded=false)");
_isMapStyleLoaded = false;
emit(state.copyWith(mapTheme: theme));
}
@@ -567,7 +806,9 @@ class NavigationCubit extends Cubit<NavigationState> {
// ── STEP BOUNDING OVERVIEW ──────────────────────────────────
void overviewStepBounding(int stepIndex) {
if (state.currentRoute == null || stepIndex < 0 || stepIndex >= state.routeSteps.length) return;
if (state.currentRoute == null ||
stepIndex < 0 ||
stepIndex >= state.routeSteps.length) return;
final step = state.routeSteps[stepIndex];
final interval = step['interval'];
final coords = state.currentRoute!.coordinates;
@@ -587,10 +828,14 @@ class NavigationCubit extends Cubit<NavigationState> {
final stepLat = (step['lat'] as num?)?.toDouble() ?? 0.0;
final stepLng = (step['lng'] as num?)?.toDouble() ?? 0.0;
if (stepLat != 0.0 && stepLng != 0.0 && mapController != null && _isMapStyleLoaded) {
if (stepLat != 0.0 &&
stepLng != 0.0 &&
mapController != null &&
_isMapStyleLoaded) {
mapController!.animateCamera(
CameraUpdate.newCameraPosition(
CameraPosition(target: LatLng(stepLat, stepLng), zoom: 17.5, tilt: 45.0),
CameraPosition(
target: LatLng(stepLat, stepLng), zoom: 17.5, tilt: 45.0),
),
);
}
@@ -601,9 +846,11 @@ class NavigationCubit extends Cubit<NavigationState> {
String title = 'وجهة مختارة',
String originTitle = 'موقعي الحالي',
}) async {
print("🛣️ [NavigationCubit] calculateRouteTo: origin=$originTitle, target=$destination, title=$title, myLocation=${state.myLocation}");
print(
"🛣️ [NavigationCubit] calculateRouteTo: origin=$originTitle, target=$destination, title=$title, myLocation=${state.myLocation}");
if (state.myLocation == null) {
print("⚠️ [NavigationCubit] Cannot calculate route: current GPS location is null!");
print(
"⚠️ [NavigationCubit] Cannot calculate route: current GPS location is null!");
return;
}
emit(state.copyWith(
@@ -631,7 +878,8 @@ class NavigationCubit extends Cubit<NavigationState> {
}
final primaryRoute = routes.first;
print("✅ [NavigationCubit] Route calculated successfully: ${routes.length} routes found, primary: dist=${primaryRoute.formattedDistance}, dur=${primaryRoute.formattedDuration}, coordsCount=${primaryRoute.coordinates.length}");
print(
"✅ [NavigationCubit] Route calculated successfully: ${routes.length} routes found, primary: dist=${primaryRoute.formattedDistance}, dur=${primaryRoute.formattedDuration}, coordsCount=${primaryRoute.coordinates.length}");
final polylines = _createPolylines(routes, 0);
// Origin Pin A
@@ -655,7 +903,8 @@ class NavigationCubit extends Cubit<NavigationState> {
);
final updatedMarkers = Set<Marker>.from(state.markers)
..removeWhere((m) => m.markerId.value == 'origin_pin' || m.markerId.value == 'dest_pin')
..removeWhere((m) =>
m.markerId.value == 'origin_pin' || m.markerId.value == 'dest_pin')
..add(startMarker)
..add(destMarker);
@@ -677,15 +926,22 @@ class NavigationCubit extends Cubit<NavigationState> {
if (mapController != null && _isMapStyleLoaded) {
await mapController!.addMarker(startMarker);
await mapController!.addMarker(destMarker);
print("📍 [NavigationCubit] Directly added startMarker & destMarker to native map engine.");
print(
"📍 [NavigationCubit] Directly added startMarker & destMarker to native map engine.");
}
_fitRouteInView(primaryRoute.coordinates);
} on RouteException catch (e) {
print("❌ [NavigationCubit] RouteException: ${e.message}");
emit(state.copyWith(
status: NavigationStatus.error,
errorMessage: e.message,
));
} catch (e) {
print("❌ [NavigationCubit] Error calculating route: $e");
emit(state.copyWith(
status: NavigationStatus.error,
errorMessage: 'حدث خطأ أثناء استدعاء خدمة التوجيه.',
errorMessage: 'حدث خطأ غير متوقع أثناء استدعاء خدمة التوجيه.',
));
}
}
@@ -712,13 +968,15 @@ class NavigationCubit extends Cubit<NavigationState> {
void startNavigation() {
print("🧭 [NavigationCubit] startNavigation triggered!");
if (state.currentRoute == null) {
print("⚠️ [NavigationCubit] startNavigation aborted: currentRoute is null");
print(
"⚠️ [NavigationCubit] startNavigation aborted: currentRoute is null");
return;
}
final route = state.currentRoute!;
TelemetryTrackingService.instance.initialize();
final steps = route.steps;
print("🧭 [NavigationCubit] Starting navigation along route: ${route.formattedDistance}, ETA ${route.formattedDuration}, ${steps.length} steps");
print(
"🧭 [NavigationCubit] Starting navigation along route: ${route.formattedDistance}, ETA ${route.formattedDuration}, ${steps.length} steps");
_lastTraveledIndexInFullRoute = 0;
_offRouteStartTime = null;
@@ -727,11 +985,11 @@ class NavigationCubit extends Cubit<NavigationState> {
? (steps[0]['text']?.toString() ?? 'انطلق نحو الوجهة')
: 'انطلق نحو الوجهة';
final nextInst = steps.length > 1
? (steps[1]['text']?.toString() ?? '')
: '';
final nextInst =
steps.length > 1 ? (steps[1]['text']?.toString() ?? '') : '';
final initialModifier = steps.isNotEmpty ? (steps[0]['sign'] ?? steps[0]['modifier'] ?? 0) : 0;
final initialModifier =
steps.isNotEmpty ? (steps[0]['sign'] ?? steps[0]['modifier'] ?? 0) : 0;
emit(state.copyWith(
status: NavigationStatus.navigating,
@@ -740,13 +998,16 @@ class NavigationCubit extends Cubit<NavigationState> {
nextInstruction: nextInst,
currentManeuverModifier: initialModifier,
isCameraLocked: true,
polylines: _createPolylines(state.routes, state.selectedRouteIndex, traveledIndex: 0),
polylines: _createPolylines(state.routes, state.selectedRouteIndex,
traveledIndex: 0),
));
ttsService.speak(firstInstruction);
// Immediate 3D Heading-Up camera orientation with 45° tilt and lookahead offset
if (state.myLocation != null && mapController != null && _isMapStyleLoaded) {
if (state.myLocation != null &&
mapController != null &&
_isMapStyleLoaded) {
double bearing = state.heading;
if (bearing == 0.0 && route.coordinates.length > 1) {
bearing = _calculateBearing(route.coordinates[0], route.coordinates[1]);
@@ -785,7 +1046,8 @@ class NavigationCubit extends Cubit<NavigationState> {
_hasAnnouncedEarlyStepIndex = null;
final remainingMarkers = Set<Marker>.from(state.markers)
..removeWhere((m) => m.markerId.value == 'origin_pin' || m.markerId.value == 'dest_pin');
..removeWhere((m) =>
m.markerId.value == 'origin_pin' || m.markerId.value == 'dest_pin');
if (mapController != null && _isMapStyleLoaded) {
mapController!.removeMarker(const MarkerId('origin_pin'));
@@ -805,7 +1067,9 @@ class NavigationCubit extends Cubit<NavigationState> {
nextInstruction: '',
isCameraLocked: true,
));
if (state.myLocation != null && mapController != null && _isMapStyleLoaded) {
if (state.myLocation != null &&
mapController != null &&
_isMapStyleLoaded) {
mapController!.animateCamera(
CameraUpdate.newCameraPosition(
CameraPosition(target: state.myLocation!, zoom: 16.5, tilt: 0),
@@ -814,11 +1078,13 @@ class NavigationCubit extends Cubit<NavigationState> {
}
}
void simulateLocationTick(LatLng pos, {double speed = 60.0, double heading = 0.0, double altitude = 0.0}) {
void simulateLocationTick(LatLng pos,
{double speed = 60.0, double heading = 0.0, double altitude = 0.0}) {
_onNewLocationFix(pos, heading, speed, altitude);
}
void _processActiveNavigationTick(LatLng pos, double speedKmH, double heading) {
void _processActiveNavigationTick(
LatLng pos, double speedKmH, double heading) {
if (state.currentRoute == null || state.routeSteps.isEmpty) return;
final route = state.currentRoute!;
@@ -864,7 +1130,9 @@ class NavigationCubit extends Cubit<NavigationState> {
emit(state.copyWith(distanceToNextStep: distToStep));
// Advance voice announcement when approaching turn (150m - 200m)
if (distToStep <= 180.0 && distToStep > 50.0 && _hasAnnouncedEarlyStepIndex != stepIdx) {
if (distToStep <= 180.0 &&
distToStep > 50.0 &&
_hasAnnouncedEarlyStepIndex != stepIdx) {
_hasAnnouncedEarlyStepIndex = stepIdx;
final roundDist = ((distToStep / 50).round() * 50).clamp(50, 200);
final text = step['text']?.toString() ?? '';
@@ -873,17 +1141,22 @@ class NavigationCubit extends Cubit<NavigationState> {
}
}
final stepEndIdx = (step['interval'] is List && (step['interval'] as List).length >= 2)
? ((step['interval'] as List)[1] as num).toInt()
: -1;
final hasPassedStep = stepEndIdx > 0 && _lastTraveledIndexInFullRoute >= stepEndIdx;
final stepEndIdx =
(step['interval'] is List && (step['interval'] as List).length >= 2)
? ((step['interval'] as List)[1] as num).toInt()
: -1;
final hasPassedStep =
stepEndIdx > 0 && _lastTraveledIndexInFullRoute >= stepEndIdx;
if ((distToStep < 35.0 || hasPassedStep) && stepIdx + 1 < steps.length) {
if ((distToStep < 35.0 || hasPassedStep) &&
stepIdx + 1 < steps.length) {
stepIdx++;
_hasAnnouncedEarlyStepIndex = null;
final nextStep = steps[stepIdx];
final text = nextStep['text']?.toString() ?? '';
final upcomingText = stepIdx + 1 < steps.length ? (steps[stepIdx + 1]['text']?.toString() ?? '') : '';
final upcomingText = stepIdx + 1 < steps.length
? (steps[stepIdx + 1]['text']?.toString() ?? '')
: '';
final modifier = nextStep['sign'] ?? nextStep['modifier'] ?? 0;
emit(state.copyWith(
@@ -922,8 +1195,10 @@ class NavigationCubit extends Cubit<NavigationState> {
if (coords.length < 2) return;
// Search window constrained around vehicle's last known route progress
final int startWindow = (_lastTraveledIndexInFullRoute - 3).clamp(0, coords.length - 2);
final int endWindow = (_lastTraveledIndexInFullRoute + 45).clamp(0, coords.length - 1);
final int startWindow =
(_lastTraveledIndexInFullRoute - 3).clamp(0, coords.length - 2);
final int endWindow =
(_lastTraveledIndexInFullRoute + 45).clamp(0, coords.length - 1);
double minDistance = double.infinity;
int closestSegmentIndex = _lastTraveledIndexInFullRoute;
@@ -1035,14 +1310,18 @@ class NavigationCubit extends Cubit<NavigationState> {
// ── USER SUBMISSIONS: PLACES & HAZARDS (WITH PIN PICKER) ─────
void startLocationPicking(String mode) {
final targetLoc = mapController?.cameraPosition?.target ?? state.myLocation ?? const LatLng(31.9539, 35.9106);
final targetLoc = mapController?.cameraPosition?.target ??
state.myLocation ??
const LatLng(31.9539, 35.9106);
final pickMarker = Marker(
markerId: const MarkerId('picker_pin'),
position: targetLoc,
icon: InlqBitmap.fromStyleImage('dest_icon'),
anchor: const Offset(0.5, 1.0),
infoWindow: InfoWindow(
title: mode == 'place' ? '📍 موقع المنشأة المحددة' : '⚠️ موقع البلاغ المحدد',
title: mode == 'place'
? '📍 موقع المنشأة المحددة'
: '⚠️ موقع البلاغ المحدد',
),
zIndex: 95,
);
@@ -1072,7 +1351,9 @@ class NavigationCubit extends Cubit<NavigationState> {
icon: InlqBitmap.fromStyleImage('dest_icon'),
anchor: const Offset(0.5, 1.0),
infoWindow: InfoWindow(
title: state.activePickerMode == 'place' ? '📍 موقع المنشأة المحددة' : '⚠️ موقع البلاغ المحدد',
title: state.activePickerMode == 'place'
? '📍 موقع المنشأة المحددة'
: '⚠️ موقع البلاغ المحدد',
),
zIndex: 95,
);
@@ -1107,8 +1388,12 @@ class NavigationCubit extends Cubit<NavigationState> {
}
}
Future<bool> submitPlace(String name, String category, {LatLng? position}) async {
final targetPos = position ?? state.pickedLocation ?? mapController?.cameraPosition?.target ?? state.myLocation;
Future<bool> submitPlace(String name, String category,
{LatLng? position}) async {
final targetPos = position ??
state.pickedLocation ??
mapController?.cameraPosition?.target ??
state.myLocation;
if (targetPos == null) return false;
final success = await repository.submitNewPlace(
@@ -1187,7 +1472,8 @@ class NavigationCubit extends Cubit<NavigationState> {
return locationService.calculateDistance(p, LatLng(projLat, projLng));
}
Set<Polyline> _createPolylines(List<RouteData> routes, int activeIndex, {int traveledIndex = 0}) {
Set<Polyline> _createPolylines(List<RouteData> routes, int activeIndex,
{int traveledIndex = 0}) {
final Set<Polyline> set = {};
// 1. Render alternative routes first
@@ -1234,7 +1520,8 @@ class NavigationCubit extends Cubit<NavigationState> {
void _fitRouteInView(List<LatLng> coords) {
if (coords.isEmpty || mapController == null || !_isMapStyleLoaded) {
print("⚠️ [NavigationCubit] _fitRouteInView deferred/skipped (coordsCount=${coords.length}, mapController=${mapController != null}, styleLoaded=$_isMapStyleLoaded)");
print(
"⚠️ [NavigationCubit] _fitRouteInView deferred/skipped (coordsCount=${coords.length}, mapController=${mapController != null}, styleLoaded=$_isMapStyleLoaded)");
return;
}
double minLat = coords.first.latitude;
@@ -1249,7 +1536,8 @@ class NavigationCubit extends Cubit<NavigationState> {
if (c.longitude > maxLng) maxLng = c.longitude;
}
print("🎬 [NavigationCubit] _fitRouteInView: bounds SW=($minLat, $minLng), NE=($maxLat, $maxLng)");
print(
"🎬 [NavigationCubit] _fitRouteInView: bounds SW=($minLat, $minLng), NE=($maxLat, $maxLng)");
mapController!.animateCamera(
CameraUpdate.newLatLngBounds(
LatLngBounds(
@@ -1265,12 +1553,15 @@ class NavigationCubit extends Cubit<NavigationState> {
}
String _calculateArrivalTime(double durationSeconds) {
final arrival = DateTime.now().add(Duration(seconds: durationSeconds.round()));
final arrival =
DateTime.now().add(Duration(seconds: durationSeconds.round()));
return DateFormat('hh:mm a').format(arrival);
}
@override
Future<void> close() {
_orbitTimer?.cancel();
_sunTimer?.cancel();
_movementInterpolationTimer?.cancel();
_positionStreamSub?.cancel();
_connectivitySub?.cancel();
@@ -55,6 +55,17 @@ class NavigationState extends Equatable {
final double vehicleScale;
final List<String> recentSearches;
final bool isSearching;
final bool isOrbiting;
final double orbitSpeed;
final bool isSunPlaying;
final int sunTimeMinutes;
final double terrainExaggeration;
final double cameraTilt;
final bool showBuildings;
final bool showContours;
final bool showHillshading;
final bool isTouristModeEnabled;
final String? selectedLandmarkId;
const NavigationState({
this.status = NavigationStatus.initial,
@@ -92,6 +103,17 @@ class NavigationState extends Equatable {
this.vehicleScale = 1.8,
this.recentSearches = const [],
this.isSearching = false,
this.isOrbiting = false,
this.orbitSpeed = 1.0,
this.isSunPlaying = false,
this.sunTimeMinutes = 1110,
this.terrainExaggeration = 2.0,
this.cameraTilt = 55.0,
this.showBuildings = true,
this.showContours = true,
this.showHillshading = true,
this.isTouristModeEnabled = false,
this.selectedLandmarkId,
});
RouteData? get currentRoute =>
@@ -166,6 +188,18 @@ class NavigationState extends Equatable {
double? vehicleScale,
List<String>? recentSearches,
bool? isSearching,
bool? isOrbiting,
double? orbitSpeed,
bool? isSunPlaying,
int? sunTimeMinutes,
double? terrainExaggeration,
double? cameraTilt,
bool? showBuildings,
bool? showContours,
bool? showHillshading,
bool? isTouristModeEnabled,
String? selectedLandmarkId,
bool clearSelectedLandmark = false,
}) {
return NavigationState(
status: status ?? this.status,
@@ -205,6 +239,17 @@ class NavigationState extends Equatable {
vehicleScale: vehicleScale ?? this.vehicleScale,
recentSearches: recentSearches ?? this.recentSearches,
isSearching: isSearching ?? this.isSearching,
isOrbiting: isOrbiting ?? this.isOrbiting,
orbitSpeed: orbitSpeed ?? this.orbitSpeed,
isSunPlaying: isSunPlaying ?? this.isSunPlaying,
sunTimeMinutes: sunTimeMinutes ?? this.sunTimeMinutes,
terrainExaggeration: terrainExaggeration ?? this.terrainExaggeration,
cameraTilt: cameraTilt ?? this.cameraTilt,
showBuildings: showBuildings ?? this.showBuildings,
showContours: showContours ?? this.showContours,
showHillshading: showHillshading ?? this.showHillshading,
isTouristModeEnabled: isTouristModeEnabled ?? this.isTouristModeEnabled,
selectedLandmarkId: clearSelectedLandmark ? null : (selectedLandmarkId ?? this.selectedLandmarkId),
);
}
@@ -245,5 +290,16 @@ class NavigationState extends Equatable {
vehicleScale,
recentSearches,
isSearching,
isOrbiting,
orbitSpeed,
isSunPlaying,
sunTimeMinutes,
terrainExaggeration,
cameraTilt,
showBuildings,
showContours,
showHillshading,
isTouristModeEnabled,
selectedLandmarkId,
];
}
@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../core/constants/app_colors.dart';
enum SiroToastType {
success,
error,
info,
warning,
}
class SiroToast {
SiroToast._();
static void showSuccess(
BuildContext context,
String message, {
String? title,
Duration duration = const Duration(seconds: 4),
}) {
show(
context,
message: message,
title: title,
type: SiroToastType.success,
duration: duration,
);
}
static void showError(
BuildContext context,
String message, {
String? title,
Duration duration = const Duration(seconds: 4),
}) {
show(
context,
message: message,
title: title,
type: SiroToastType.error,
duration: duration,
);
}
static void showInfo(
BuildContext context,
String message, {
String? title,
Duration duration = const Duration(seconds: 4),
}) {
show(
context,
message: message,
title: title,
type: SiroToastType.info,
duration: duration,
);
}
static void showWarning(
BuildContext context,
String message, {
String? title,
Duration duration = const Duration(seconds: 4),
}) {
show(
context,
message: message,
title: title,
type: SiroToastType.warning,
duration: duration,
);
}
static void show(
BuildContext context, {
required String message,
String? title,
SiroToastType type = SiroToastType.info,
Duration duration = const Duration(seconds: 4),
}) {
// Trigger tactile haptic feedback
switch (type) {
case SiroToastType.success:
HapticFeedback.lightImpact();
break;
case SiroToastType.error:
HapticFeedback.heavyImpact();
break;
case SiroToastType.warning:
HapticFeedback.mediumImpact();
break;
case SiroToastType.info:
HapticFeedback.selectionClick();
break;
}
Color accentColor;
IconData iconData;
switch (type) {
case SiroToastType.success:
accentColor = AppColors.tacticalEmerald;
iconData = Icons.check_circle_rounded;
break;
case SiroToastType.error:
accentColor = AppColors.coralDanger;
iconData = Icons.error_rounded;
break;
case SiroToastType.warning:
accentColor = AppColors.sovereignGold;
iconData = Icons.warning_amber_rounded;
break;
case SiroToastType.info:
accentColor = AppColors.appleBlue;
iconData = Icons.info_rounded;
break;
}
final messenger = ScaffoldMessenger.of(context);
messenger.hideCurrentSnackBar();
messenger.showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
elevation: 10,
margin: EdgeInsets.only(
bottom: MediaQuery.of(context).size.height * 0.11,
left: 18,
right: 18,
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
side: BorderSide(
color: accentColor.withValues(alpha: 0.35),
width: 1.2,
),
),
backgroundColor: const Color(0xFF1D1D1F).withValues(alpha: 0.95),
duration: duration,
content: Row(
children: [
Container(
padding: const EdgeInsets.all(7),
decoration: BoxDecoration(
color: accentColor.withValues(alpha: 0.18),
shape: BoxShape.circle,
border: Border.all(
color: accentColor.withValues(alpha: 0.3),
width: 1,
),
),
child: Icon(
iconData,
color: accentColor,
size: 18,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null && title.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
title,
style: TextStyle(
color: accentColor,
fontSize: 12,
fontWeight: FontWeight.w700,
fontFamily: '.SF Pro Text',
),
),
),
Text(
message,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
height: 1.3,
fontFamily: '.SF Pro Text',
),
),
],
),
),
],
),
),
);
}
}
+157 -58
View File
@@ -22,6 +22,11 @@ import 'widgets/add_place_sheet.dart';
import 'widgets/vehicle_customizer_sheet.dart';
import 'widgets/about_awards_sheet.dart';
import 'widgets/cinematic_3d_studio_sheet.dart';
import 'widgets/siro_drawer_widget.dart';
import 'widgets/tourist_landmarks_sheet.dart';
import 'widgets/settings_sheet.dart';
import 'widgets/system_updates_sheet.dart';
import '../common/siro_toast.dart';
class MapView extends StatefulWidget {
const MapView({super.key});
@@ -31,6 +36,7 @@ class MapView extends StatefulWidget {
}
class _MapViewState extends State<MapView> {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
bool _isSearchFocused = false;
@@ -117,6 +123,36 @@ class _MapViewState extends State<MapView> {
);
}
void _showTouristLandmarks(BuildContext context, NavigationCubit cubit, NavigationState state) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => TouristLandmarksSheet(
cubit: cubit,
state: state,
),
);
}
void _showSettingsSheet(BuildContext context, NavigationCubit cubit) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => SettingsSheet(cubit: cubit),
);
}
void _showSystemUpdatesSheet(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => const SystemUpdatesSheet(),
);
}
void _showCinematic3DStudio(BuildContext context, NavigationCubit cubit, NavigationState state) {
showModalBottomSheet(
context: context,
@@ -170,14 +206,10 @@ class _MapViewState extends State<MapView> {
position: initialLocation,
);
if (mounted && ok) {
messenger.showSnackBar(
const SnackBar(
content: Text(
'تم إرسال البلاغ بنجاح وتحديث الشبكة التشاركية.',
style: TextStyle(fontSize: 12),
),
backgroundColor: AppColors.tacticalEmerald,
),
_showLuxuryNotification(
context,
message: 'تم إرسال البلاغ بنجاح وتحديث الشبكة التشاركية.',
isError: false,
);
}
},
@@ -196,21 +228,16 @@ class _MapViewState extends State<MapView> {
backgroundColor: Colors.transparent,
builder: (_) => AddPlaceSheet(
onSubmit: (name, cat) async {
final messenger = ScaffoldMessenger.of(context);
final ok = await cubit.submitPlace(
name,
cat,
position: initialLocation,
);
if (mounted) {
messenger.showSnackBar(
SnackBar(
content: Text(
ok ? 'تمت إضافة المكان بنجاح! شكراً لمساهمتك.' : 'تعذر حفظ المكان، يرجى المحاولة لاحقاً.',
style: const TextStyle(fontSize: 12),
),
backgroundColor: ok ? AppColors.tacticalEmerald : AppColors.coralDanger,
),
_showLuxuryNotification(
context,
message: ok ? 'تمت إضافة المكان بنجاح! شكراً لمساهمتك.' : 'تعذر حفظ المكان، يرجى المحاولة لاحقاً.',
isError: !ok,
);
}
},
@@ -222,6 +249,18 @@ class _MapViewState extends State<MapView> {
});
}
void _showLuxuryNotification(
BuildContext context, {
required String message,
bool isError = false,
}) {
if (isError) {
SiroToast.showError(context, message);
} else {
SiroToast.showSuccess(context, message);
}
}
void _showPlaceGatesSheet(BuildContext context, NavigationCubit cubit, PlaceModel place) {
showModalBottomSheet(
context: context,
@@ -255,14 +294,10 @@ class _MapViewState extends State<MapView> {
return BlocConsumer<NavigationCubit, NavigationState>(
listener: (context, state) {
if (state.errorMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
state.errorMessage!,
style: const TextStyle(fontSize: 12),
),
backgroundColor: AppColors.coralDanger,
),
_showLuxuryNotification(
context,
message: state.errorMessage!,
isError: true,
);
}
@@ -313,8 +348,21 @@ class _MapViewState extends State<MapView> {
valueListenable: PipService.instance.isInPipMode,
builder: (context, isInPip, _) {
return Scaffold(
key: _scaffoldKey,
resizeToAvoidBottomInset: false,
backgroundColor: AppColors.canvasLight,
drawer: SiroDrawerWidget(
cubit: cubit,
state: state,
onOpenTouristLandmarks: () => _showTouristLandmarks(context, cubit, state),
onOpen3DStudio: () => _showCinematic3DStudio(context, cubit, state),
onOpenLayers: () => _showLayerSelector(context, cubit, state.mapTheme),
onOpenSettings: () => _showSettingsSheet(context, cubit),
onOpenUpdates: () => _showSystemUpdatesSheet(context),
onOpenAbout: () => _showAboutAwardsSheet(context),
onStartReportHazard: () => cubit.startLocationPicking('hazard'),
onStartAddPlace: () => cubit.startLocationPicking('place'),
),
body: SizedBox.expand(
child: Stack(
fit: StackFit.expand,
@@ -375,7 +423,7 @@ class _MapViewState extends State<MapView> {
_searchController.clear();
cubit.clearSearch();
},
onMenuTap: () => _showLayerSelector(context, cubit, state.mapTheme),
onMenuTap: () => _scaffoldKey.currentState?.openDrawer(),
),
const SizedBox(height: 10),
ExplorePanelWidget(
@@ -383,6 +431,7 @@ class _MapViewState extends State<MapView> {
_searchController.text = q;
cubit.searchImmediately(q);
},
onTouristSelected: () => _showTouristLandmarks(context, cubit, state),
),
],
),
@@ -1276,38 +1325,6 @@ class _MapViewState extends State<MapView> {
onTap: () => _showCinematic3DStudio(context, cubit, state),
),
const SizedBox(height: 12),
// Layer Selector Button
_buildFloatingCircle(
icon: Icons.layers_rounded,
color: AppColors.textPrimary,
tooltip: 'طبقات الخريطة',
onTap: () => _showLayerSelector(context, cubit, state.mapTheme),
),
const SizedBox(height: 12),
// Siro Map Platform Architecture & Info Button
_buildFloatingCircle(
icon: Icons.info_outline_rounded,
color: AppColors.appleBlue,
tooltip: 'عن سيرو ماب والمنظومة التقنية',
onTap: () => _showAboutAwardsSheet(context),
),
const SizedBox(height: 12),
// Add Place Button
_buildFloatingCircle(
icon: Icons.add_location_alt_rounded,
color: AppColors.appleBlue,
tooltip: 'إضافة مكان',
onTap: () => cubit.startLocationPicking('place'),
),
const SizedBox(height: 12),
// Report Hazard Button
_buildFloatingCircle(
icon: Icons.warning_amber_rounded,
color: AppColors.sovereignGold,
tooltip: 'إبلاغ عن حالة طريق',
onTap: () => cubit.startLocationPicking('hazard'),
),
const SizedBox(height: 12),
// Recenter GPS Button
_buildFloatingCircle(
icon: Icons.my_location_rounded,
@@ -1374,6 +1391,88 @@ class _MapViewState extends State<MapView> {
),
),
// ── 6c. FLOATING 3D ORBIT STATUS & STOP BUTTON ──
if (!isInPip && state.isOrbiting)
Positioned(
left: 16,
bottom: (state.speed > 3.0) ? 100 : 32,
child: SafeArea(
top: false,
child: Material(
color: Colors.transparent,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFF1D1D1F).withValues(alpha: 0.94),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: const Color(0xFFEA580C).withValues(alpha: 0.8),
width: 1.5,
),
boxShadow: const [
BoxShadow(
color: Color(0x3D000000),
blurRadius: 16,
offset: Offset(0, 4),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () => cubit.stopOrbit(),
borderRadius: BorderRadius.circular(12),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.stop_circle_rounded,
color: AppColors.coralDanger,
size: 18,
),
SizedBox(width: 6),
Text(
'إيقاف الدوران 360°',
style: TextStyle(
fontFamily: '.SF Pro Text',
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
],
),
),
const SizedBox(width: 10),
Container(
width: 1,
height: 16,
color: Colors.white.withValues(alpha: 0.2),
),
const SizedBox(width: 8),
GestureDetector(
onTap: () => _showCinematic3DStudio(context, cubit, state),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: const Icon(
Icons.tune_rounded,
color: Colors.white,
size: 14,
),
),
),
],
),
),
),
),
),
// ── 7. LOADING OVERLAY ──
if (state.status == NavigationStatus.loading)
Container(
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,16 @@ import '../../../../core/constants/app_colors.dart';
class ExplorePanelWidget extends StatelessWidget {
final Function(String query) onCategorySelected;
final VoidCallback? onTouristSelected;
const ExplorePanelWidget({super.key, required this.onCategorySelected});
const ExplorePanelWidget({
super.key,
required this.onCategorySelected,
this.onTouristSelected,
});
static const List<Map<String, dynamic>> _categories = [
{'title': 'معالم سياحية 🏛️', 'query': '__tourist__', 'icon': Icons.travel_explore_rounded, 'isSpecial': true},
{'title': 'مولات', 'query': 'مول', 'icon': Icons.local_mall_rounded},
{'title': 'مستشفيات', 'query': 'مستشفى', 'icon': Icons.local_hospital_rounded},
{'title': 'مطاعم', 'query': 'مطعم', 'icon': Icons.restaurant_rounded},
@@ -28,28 +34,38 @@ class ExplorePanelWidget extends StatelessWidget {
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final cat = _categories[index];
final isSpecial = cat['isSpecial'] == true;
return ActionChip(
avatar: Icon(
cat['icon'] as IconData,
size: 16,
color: AppColors.appleBlue,
color: isSpecial ? const Color(0xFFD97706) : AppColors.appleBlue,
),
label: Text(
cat['title'] as String,
style: const TextStyle(
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
fontWeight: isSpecial ? FontWeight.w700 : FontWeight.w600,
color: isSpecial ? const Color(0xFF92400E) : AppColors.textPrimary,
),
),
backgroundColor: AppColors.pureWhite,
side: const BorderSide(color: AppColors.borderSubtle),
backgroundColor: isSpecial ? const Color(0xFFFEF3C7) : AppColors.pureWhite,
side: BorderSide(
color: isSpecial ? const Color(0xFFFDE68A) : AppColors.borderSubtle,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
elevation: 1,
shadowColor: Colors.black.withValues(alpha: 0.08),
onPressed: () => onCategorySelected(cat['query'] as String),
onPressed: () {
if (cat['query'] == '__tourist__' && onTouristSelected != null) {
onTouristSelected!();
} else {
onCategorySelected(cat['query'] as String);
}
},
);
},
),
@@ -0,0 +1,345 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../../core/constants/app_colors.dart';
import '../../../../logic/cubits/navigation/navigation_cubit.dart';
import '../../common/siro_toast.dart';
class SettingsSheet extends StatefulWidget {
final NavigationCubit cubit;
const SettingsSheet({super.key, required this.cubit});
@override
State<SettingsSheet> createState() => _SettingsSheetState();
}
class _SettingsSheetState extends State<SettingsSheet> {
bool _avoidTolls = false;
bool _avoidHighways = false;
bool _ecoRouting = true;
bool _autoNightMode = true;
String _unit = 'km';
String _cacheSize = '42.8 ميغابايت';
@override
void initState() {
super.initState();
_loadPreferences();
}
Future<void> _loadPreferences() async {
try {
final prefs = await SharedPreferences.getInstance();
setState(() {
_avoidTolls = prefs.getBool('siro_pref_avoid_tolls') ?? false;
_avoidHighways = prefs.getBool('siro_pref_avoid_highways') ?? false;
_ecoRouting = prefs.getBool('siro_pref_eco_routing') ?? true;
_autoNightMode = prefs.getBool('siro_pref_auto_night') ?? true;
_unit = prefs.getString('siro_pref_unit') ?? 'km';
});
} catch (_) {}
}
Future<void> _saveBool(String key, bool val) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(key, val);
} catch (_) {}
}
Future<void> _clearCache() async {
HapticFeedback.mediumImpact();
setState(() {
_cacheSize = '0.0 ميغابايت';
});
if (mounted) {
SiroToast.showSuccess(
context,
'تم مسح الذاكرة المؤقتة للخرائط بنجاح.',
title: 'تنظيف الذاكرة',
);
}
}
@override
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
decoration: const BoxDecoration(
color: AppColors.pureWhite,
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
boxShadow: [
BoxShadow(
color: Color(0x33000000),
blurRadius: 30,
offset: Offset(0, -6),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag handle
Center(
child: Container(
margin: const EdgeInsets.only(top: 12, bottom: 8),
width: 44,
height: 5,
decoration: BoxDecoration(
color: AppColors.borderSubtle,
borderRadius: BorderRadius.circular(3),
),
),
),
// Header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.surfaceMuted,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.borderSubtle),
),
child: const Icon(Icons.settings_suggest_rounded,
color: AppColors.textPrimary, size: 22),
),
const SizedBox(width: 14),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'إعدادات المنظومة وتفضيلات الملاحة',
style: TextStyle(
fontSize: 16.5,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
fontFamily: '.SF Pro Text',
),
),
SizedBox(height: 2),
Text(
'تخصيص قواعد التوجيه، وحدات القياس، والذاكرة المؤقتة',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
fontFamily: '.SF Pro Text',
),
),
],
),
),
IconButton(
icon: const Icon(Icons.close_rounded,
color: AppColors.textMuted),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 12, color: AppColors.borderSubtle),
// Settings Body
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
children: [
// ── Section 1: تفضيلات التوجيه الذكي ──
_buildSectionHeader(
'تفضيلات التوجيه والمسارات', Icons.alt_route_rounded),
const SizedBox(height: 8),
_buildSwitchTile(
title: 'المسار الاقتصادي الموفر للوقود',
subtitle:
'حساب المسارات بناءً على استواء التضاريس لتقليل استهلاك الوقود والانبعاثات',
value: _ecoRouting,
onChanged: (val) {
setState(() => _ecoRouting = val);
_saveBool('siro_pref_eco_routing', val);
},
),
_buildSwitchTile(
title: 'تجنب الطرق ذات الرسوم (Tolls)',
subtitle: 'تفضيل الطرق المجانية عند اقتراح المسارات البديلة',
value: _avoidTolls,
onChanged: (val) {
setState(() => _avoidTolls = val);
_saveBool('siro_pref_avoid_tolls', val);
},
),
_buildSwitchTile(
title: 'تجنب الطرق السريعة (Highways)',
subtitle: 'اختيار الطرق المحلية والشوارع الحضرية الهادئة',
value: _avoidHighways,
onChanged: (val) {
setState(() => _avoidHighways = val);
_saveBool('siro_pref_avoid_highways', val);
},
),
const SizedBox(height: 20),
// ── Section 2: العرض والنمط الليلي ──
_buildSectionHeader(
'العرض والنمط البصري', Icons.palette_rounded),
const SizedBox(height: 8),
_buildSwitchTile(
title: 'التبديل التلقائي للنمط الليلي',
subtitle:
'تحويل الخريطة لنمط Night / Tactical تلقائياً بعد الغروب',
value: _autoNightMode,
onChanged: (val) {
setState(() => _autoNightMode = val);
_saveBool('siro_pref_auto_night', val);
},
),
const SizedBox(height: 20),
// ── Section 3: الذاكرة والتخزين المؤقت ──
_buildSectionHeader(
'الذاكرة وحزم الخرائط', Icons.storage_rounded),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surfaceMuted,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.borderSubtle),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'حجم الذاكرة المؤقتة (Tiles Cache)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 3),
Text(
_cacheSize,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.appleBlue,
),
),
],
),
TextButton.icon(
onPressed: _clearCache,
icon: const Icon(Icons.delete_sweep_rounded,
size: 18, color: AppColors.coralDanger),
label: const Text(
'مسح الذاكرة',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppColors.coralDanger,
),
),
style: TextButton.styleFrom(
backgroundColor:
AppColors.coralDanger.withValues(alpha: 0.1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
),
),
],
),
),
],
),
),
],
),
);
}
Widget _buildSectionHeader(String title, IconData icon) {
return Row(
children: [
Icon(icon, size: 16, color: AppColors.appleBlue),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
fontFamily: '.SF Pro Text',
),
),
],
);
}
Widget _buildSwitchTile({
required String title,
required String subtitle,
required bool value,
required ValueChanged<bool> onChanged,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: AppColors.surfaceMuted,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.borderSubtle),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: const TextStyle(
fontSize: 11,
height: 1.3,
color: AppColors.textSecondary,
),
),
],
),
),
Switch.adaptive(
value: value,
activeColor: AppColors.appleBlue,
onChanged: (v) {
HapticFeedback.lightImpact();
onChanged(v);
},
),
],
),
);
}
}
@@ -0,0 +1,389 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../core/constants/app_colors.dart';
import '../../../../logic/cubits/navigation/navigation_cubit.dart';
import '../../../../logic/cubits/navigation/navigation_state.dart';
class SiroDrawerWidget extends StatelessWidget {
final NavigationCubit cubit;
final NavigationState state;
final VoidCallback onOpenTouristLandmarks;
final VoidCallback onOpen3DStudio;
final VoidCallback onOpenLayers;
final VoidCallback onOpenSettings;
final VoidCallback onOpenUpdates;
final VoidCallback onOpenAbout;
final VoidCallback onStartReportHazard;
final VoidCallback onStartAddPlace;
const SiroDrawerWidget({
super.key,
required this.cubit,
required this.state,
required this.onOpenTouristLandmarks,
required this.onOpen3DStudio,
required this.onOpenLayers,
required this.onOpenSettings,
required this.onOpenUpdates,
required this.onOpenAbout,
required this.onStartReportHazard,
required this.onStartAddPlace,
});
@override
Widget build(BuildContext context) {
return Drawer(
backgroundColor: AppColors.pureWhite,
elevation: 16,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.horizontal(left: Radius.circular(28)),
),
child: SafeArea(
child: Column(
children: [
// ── Header: Luxury Siro Maps Identity ──
Container(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF1D1D1F), Color(0xFF2C2C2E)],
begin: Alignment.topRight,
end: Alignment.bottomLeft,
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(28),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.sovereignGold.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.sovereignGold.withValues(alpha: 0.4),
),
),
child: const Icon(
Icons.explore_rounded,
color: AppColors.sovereignGold,
size: 24,
),
),
// Online/Offline Sovereign Badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: state.isOnline
? AppColors.tacticalEmerald.withValues(alpha: 0.2)
: AppColors.sovereignGold.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: state.isOnline
? AppColors.tacticalEmerald.withValues(alpha: 0.4)
: AppColors.sovereignGold.withValues(alpha: 0.4),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: state.isOnline ? AppColors.tacticalEmerald : AppColors.sovereignGold,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
state.isOnline ? 'سحابي متصل' : 'توجيه سيادي محلي',
style: TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w700,
color: state.isOnline ? AppColors.tacticalEmerald : AppColors.sovereignGold,
fontFamily: '.SF Pro Text',
),
),
],
),
),
],
),
const SizedBox(height: 14),
const Text(
'سيرو ماب • Siro Maps',
style: TextStyle(
color: Colors.white,
fontSize: 19,
fontWeight: FontWeight.w800,
fontFamily: '.SF Pro Text',
letterSpacing: -0.3,
),
),
const SizedBox(height: 3),
const Text(
'منظومة الملاحة السيادية والاستوديو ثلاثي الأبعاد',
style: TextStyle(
color: Color(0xFF98989D),
fontSize: 11.5,
fontWeight: FontWeight.w500,
fontFamily: '.SF Pro Text',
),
),
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'🛡️ استضافة ذاتية 100% • حماية الهوامش الربحية',
style: TextStyle(
color: AppColors.sovereignGold,
fontSize: 10,
fontWeight: FontWeight.w700,
fontFamily: '.SF Pro Text',
),
),
),
],
),
),
// ── Drawer Menu Items ──
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
children: [
// ── Section 1: السياحة والاستكشاف 3D ──
_buildSectionTitle('السياحة والاستكشاف السينمائي'),
_buildDrawerTile(
icon: Icons.travel_explore_rounded,
color: const Color(0xFFD97706),
title: 'الخريطة والمعالم السياحية 3D',
subtitle: 'عجائب الأردن، المعالم الأثرية والمحميات',
onTap: () {
Navigator.of(context).pop();
onOpenTouristLandmarks();
},
),
_buildDrawerTile(
icon: Icons.view_in_ar_rounded,
color: const Color(0xFFEA580C),
title: 'استوديو التضاريس والسينما 3D',
subtitle: 'محاكاة الشمس، تضخيم الجبال، والدوران',
onTap: () {
Navigator.of(context).pop();
onOpen3DStudio();
},
),
_buildDrawerTile(
icon: Icons.layers_rounded,
color: AppColors.appleBlue,
title: 'طبقات الخريطة والأنماط',
subtitle: 'النمط التكتيكي، الأقمار الصناعية، والطبوغرافي',
onTap: () {
Navigator.of(context).pop();
onOpenLayers();
},
),
const SizedBox(height: 12),
const Divider(height: 1, color: AppColors.borderSubtle),
const SizedBox(height: 8),
// ── Section 2: مجتمع الطرق ──
_buildSectionTitle('المجتمع وحالة الطرق'),
_buildDrawerTile(
icon: Icons.warning_amber_rounded,
color: AppColors.coralDanger,
title: 'إبلاغ عن حالة طريق / مخاطر',
subtitle: 'حوادث، إغلاقات، وأعمال صيانة لحظية',
onTap: () {
Navigator.of(context).pop();
onStartReportHazard();
},
),
_buildDrawerTile(
icon: Icons.add_location_alt_rounded,
color: AppColors.appleBlue,
title: 'إضافة معلم أو منشأة جديدة',
subtitle: 'تثبيت موقع أو نشاط تجاري على الخريطة',
onTap: () {
Navigator.of(context).pop();
onStartAddPlace();
},
),
const SizedBox(height: 12),
const Divider(height: 1, color: AppColors.borderSubtle),
const SizedBox(height: 8),
// ── Section 3: الإعدادات والتحديثات ──
_buildSectionTitle('المنظومة والتحديثات'),
_buildDrawerTile(
icon: Icons.settings_suggest_rounded,
color: AppColors.textPrimary,
title: 'إعدادات المنظومة والملاحة',
subtitle: 'تفضيلات التوجيه، الرسوم، والذاكرة المؤقتة',
onTap: () {
Navigator.of(context).pop();
onOpenSettings();
},
),
_buildDrawerTile(
icon: Icons.system_update_alt_rounded,
color: AppColors.tacticalEmerald,
title: 'التحقق من تحديثات الخرائط',
subtitle: 'حزم البلاطات وقواعد بيانات التوجيه',
onTap: () {
Navigator.of(context).pop();
onOpenUpdates();
},
),
_buildDrawerTile(
icon: Icons.info_outline_rounded,
color: AppColors.appleBlue,
title: 'عن سيرو ماب والجوائز العالمية',
subtitle: 'البنية التحتية، الوفورات الاقتصادية، والتراخيص',
onTap: () {
Navigator.of(context).pop();
onOpenAbout();
},
),
],
),
),
// ── Footer: Version & Technical Footprint ──
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: AppColors.borderSubtle)),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Siro Maps Platform v1.2.0',
style: TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w600,
color: AppColors.textMuted,
fontFamily: '.SF Pro Text',
),
),
Text(
'Sovereignty Architecture',
style: TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w700,
color: AppColors.sovereignGold,
fontFamily: '.SF Pro Text',
),
),
],
),
),
],
),
),
);
}
Widget _buildSectionTitle(String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 10, 8, 6),
child: Text(
title,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: AppColors.textMuted,
fontFamily: '.SF Pro Text',
letterSpacing: -0.2,
),
),
);
}
Widget _buildDrawerTile({
required IconData icon,
required Color color,
required String title,
required String subtitle,
required VoidCallback onTap,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 6),
child: InkWell(
onTap: () {
HapticFeedback.lightImpact();
onTap();
},
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withValues(alpha: 0.25), width: 0.8),
),
child: Icon(icon, color: color, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
fontFamily: '.SF Pro Text',
),
),
const SizedBox(height: 2),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 10.5,
color: AppColors.textSecondary,
fontFamily: '.SF Pro Text',
),
),
],
),
),
const Icon(Icons.arrow_forward_ios_rounded, size: 12, color: AppColors.textMuted),
],
),
),
),
);
}
}
@@ -0,0 +1,277 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../core/constants/app_colors.dart';
import '../../common/siro_toast.dart';
class SystemUpdatesSheet extends StatefulWidget {
const SystemUpdatesSheet({super.key});
@override
State<SystemUpdatesSheet> createState() => _SystemUpdatesSheetState();
}
class _SystemUpdatesSheetState extends State<SystemUpdatesSheet> {
bool _isChecking = false;
String _lastChecked = 'منذ دقيقة واحدة';
bool _isUpToDate = true;
Future<void> _checkForUpdates() async {
setState(() => _isChecking = true);
HapticFeedback.lightImpact();
await Future.delayed(const Duration(milliseconds: 1400));
if (mounted) {
setState(() {
_isChecking = false;
_lastChecked = 'الآن';
_isUpToDate = true;
});
SiroToast.showSuccess(
context,
'حزم الخرائط وقواعد بيانات التوجيه السيادية محدثة بالكامل لأحدث إصدار.',
title: 'المنظومة محدثة',
);
}
}
@override
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.75,
),
decoration: const BoxDecoration(
color: AppColors.pureWhite,
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
boxShadow: [
BoxShadow(
color: Color(0x33000000),
blurRadius: 30,
offset: Offset(0, -6),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag Handle
Center(
child: Container(
margin: const EdgeInsets.only(top: 12, bottom: 8),
width: 44,
height: 5,
decoration: BoxDecoration(
color: AppColors.borderSubtle,
borderRadius: BorderRadius.circular(3),
),
),
),
// Header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.tacticalEmerald.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color:
AppColors.tacticalEmerald.withValues(alpha: 0.3)),
),
child: const Icon(Icons.system_update_alt_rounded,
color: AppColors.tacticalEmerald, size: 22),
),
const SizedBox(width: 14),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'تحديثات المنظومة وحزم الخرائط',
style: TextStyle(
fontSize: 16.5,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
fontFamily: '.SF Pro Text',
),
),
SizedBox(height: 2),
Text(
'متابعة حزم الخرائط غير المتصلة ومحركات التوجيه',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
fontFamily: '.SF Pro Text',
),
),
],
),
),
IconButton(
icon: const Icon(Icons.close_rounded,
color: AppColors.textMuted),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 12, color: AppColors.borderSubtle),
// Updates Body
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Column(
children: [
// Status Box
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surfaceMuted,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: _isUpToDate
? AppColors.tacticalEmerald.withValues(alpha: 0.3)
: AppColors.sovereignGold.withValues(alpha: 0.3),
),
),
child: Column(
children: [
Row(
children: [
Icon(
_isUpToDate
? Icons.check_circle_rounded
: Icons.info_outline_rounded,
color: _isUpToDate
? AppColors.tacticalEmerald
: AppColors.sovereignGold,
size: 26,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_isUpToDate
? 'الخرائط والبيانات محدثة بالكامل'
: 'تحديث جديد متوفر',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
'آخر فحص: $_lastChecked',
style: const TextStyle(
fontSize: 11,
color: AppColors.textMuted,
),
),
],
),
),
],
),
const SizedBox(height: 14),
const Divider(height: 1, color: AppColors.borderSubtle),
const SizedBox(height: 12),
_buildPackageRow(
'حزمة البلاطات الموجهة (Jordan Vector Tiles)',
'v2026.09 (أحدث إصدار)'),
const SizedBox(height: 8),
_buildPackageRow(
'محرك التوجيه السيادي (GraphHopper Graph)',
'v0.13.0 - مُحسَّن ومفهرس'),
const SizedBox(height: 8),
_buildPackageRow(
'بيانات الارتفاعات والتضاريس 3D (DEM & Contours)',
'نشط ومحمّل بالذاكرة'),
],
),
),
const SizedBox(height: 20),
// Check Button
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton.icon(
onPressed: _isChecking ? null : _checkForUpdates,
icon: _isChecking
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.refresh_rounded,
size: 20, color: Colors.white),
label: Text(
_isChecking
? 'جاري التحقق من السيرفر...'
: 'البحث عن تحديثات الآن',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.appleBlue,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
),
),
],
),
),
],
),
);
}
Widget _buildPackageRow(String name, String status) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
name,
style: const TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: AppColors.tacticalEmerald.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
status,
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: AppColors.tacticalEmerald),
),
),
],
);
}
}
@@ -0,0 +1,521 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import '../../../../core/constants/app_colors.dart';
import '../../../../data/models/tourist_landmark_model.dart';
import '../../../../logic/cubits/navigation/navigation_cubit.dart';
import '../../../../logic/cubits/navigation/navigation_state.dart';
import '../../common/siro_toast.dart';
class TouristLandmarksSheet extends StatefulWidget {
final NavigationCubit cubit;
final NavigationState state;
const TouristLandmarksSheet({
super.key,
required this.cubit,
required this.state,
});
@override
State<TouristLandmarksSheet> createState() => _TouristLandmarksSheetState();
}
class _TouristLandmarksSheetState extends State<TouristLandmarksSheet> {
String _selectedCategory = 'all';
String _searchQuery = '';
final TextEditingController _searchController = TextEditingController();
final List<Map<String, String>> _categories = const [
{'key': 'all', 'label': 'الكل 🌍'},
{'key': 'heritage', 'label': 'تراث وعجائب 🏛️'},
{'key': 'nature', 'label': 'طبيعة ومحميات 🌲'},
{'key': 'adventure', 'label': 'مغامرة وتخييم ⛺'},
{'key': 'religious', 'label': 'ديني وتاريخي ⛪'},
{'key': 'coastal', 'label': 'شواطئ وبحر ⛵'},
];
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final myLoc = widget.state.myLocation;
final landmarks = TouristLandmark.curatedJordanLandmarks.where((lm) {
final matchesCategory =
_selectedCategory == 'all' || lm.category == _selectedCategory;
final matchesSearch = _searchQuery.isEmpty ||
lm.nameAr.contains(_searchQuery) ||
lm.nameEn.toLowerCase().contains(_searchQuery.toLowerCase()) ||
lm.tag.contains(_searchQuery);
return matchesCategory && matchesSearch;
}).toList();
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
decoration: const BoxDecoration(
color: AppColors.pureWhite,
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
boxShadow: [
BoxShadow(
color: Color(0x33000000),
blurRadius: 30,
offset: Offset(0, -6),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── Sheet Drag Handle ──
Center(
child: Container(
margin: const EdgeInsets.only(top: 12, bottom: 8),
width: 44,
height: 5,
decoration: BoxDecoration(
color: AppColors.borderSubtle,
borderRadius: BorderRadius.circular(3),
),
),
),
// ── Header Title & Sovereignty Badge ──
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFFD97706), Color(0xFFEA580C)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: const Color(0xFFD97706).withValues(alpha: 0.3),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: const Icon(Icons.travel_explore_rounded,
color: Colors.white, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'الخريطة والمعالم السياحية 3D',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
fontFamily: '.SF Pro Text',
),
),
const SizedBox(height: 2),
Text(
'استكشاف سينمائي ثلاثي الأبعاد لأعظم المعالم الأثرية والطبيعية',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
fontFamily: '.SF Pro Text',
),
),
],
),
),
IconButton(
icon: const Icon(Icons.close_rounded,
color: AppColors.textMuted),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
// ── Search Field ──
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
child: Container(
decoration: BoxDecoration(
color: AppColors.surfaceMuted,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.borderSubtle),
),
child: TextField(
controller: _searchController,
onChanged: (val) {
setState(() {
_searchQuery = val.trim();
});
},
decoration: InputDecoration(
hintText: 'ابحث عن معلم، موقع أثري، أو محمية طبيعية...',
hintStyle: const TextStyle(
fontSize: 12.5,
color: AppColors.textMuted,
fontFamily: '.SF Pro Text',
),
prefixIcon: const Icon(Icons.search_rounded,
color: AppColors.textMuted, size: 20),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear_rounded,
color: AppColors.textMuted, size: 18),
onPressed: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
},
)
: null,
border: InputBorder.none,
contentPadding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
),
),
),
),
// ── Filter Categories Chips ──
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
child: Row(
children: _categories.map((cat) {
final isSelected = _selectedCategory == cat['key'];
return Padding(
padding: const EdgeInsets.only(left: 8),
child: FilterChip(
selected: isSelected,
showCheckmark: false,
label: Text(
cat['label']!,
style: TextStyle(
fontSize: 12,
fontWeight:
isSelected ? FontWeight.w700 : FontWeight.w600,
color:
isSelected ? Colors.white : AppColors.textPrimary,
fontFamily: '.SF Pro Text',
),
),
backgroundColor: AppColors.surfaceMuted,
selectedColor: const Color(0xFFD97706),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: isSelected
? const Color(0xFFD97706)
: AppColors.borderSubtle,
width: 1,
),
),
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
onSelected: (_) {
HapticFeedback.selectionClick();
setState(() {
_selectedCategory = cat['key']!;
});
},
),
);
}).toList(),
),
),
const Divider(height: 12, color: AppColors.borderSubtle),
// ── Landmarks List ──
Expanded(
child: landmarks.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.travel_explore_rounded,
size: 48,
color:
AppColors.textMuted.withValues(alpha: 0.5)),
const SizedBox(height: 10),
const Text(
'لا توجد معالم مطابقة للبحث',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 4),
const Text(
'جرب البحث باسم معلم آخر أو تغيير التصنيف المختار',
style: TextStyle(
fontSize: 12, color: AppColors.textMuted),
),
],
),
),
)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
itemCount: landmarks.length,
separatorBuilder: (_, __) => const SizedBox(height: 14),
itemBuilder: (context, index) {
final lm = landmarks[index];
double? distKm;
if (myLoc != null) {
distKm = widget.cubit.locationService.calculateDistance(
myLoc,
lm.coordinates,
) /
1000.0;
}
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.pureWhite,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: lm.accentColor.withValues(alpha: 0.25),
width: 1.2,
),
boxShadow: const [
BoxShadow(
color: Color(0x0C000000),
blurRadius: 12,
offset: Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: Icon + Title + Tag + Altitude
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color:
lm.accentColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color:
lm.accentColor.withValues(alpha: 0.3),
),
),
child: Icon(lm.iconData,
color: lm.accentColor, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
lm.nameAr,
style: const TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
fontFamily: '.SF Pro Text',
),
),
const SizedBox(height: 3),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: lm.accentColor
.withValues(alpha: 0.1),
borderRadius:
BorderRadius.circular(6),
),
child: Text(
lm.tag,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: lm.accentColor,
),
),
),
if (distKm != null) ...[
const SizedBox(width: 6),
Text(
'• ${distKm.toStringAsFixed(1)} كم',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
],
],
),
],
),
),
// Elevation Badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.surfaceMuted,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.borderSubtle),
),
child: Column(
children: [
const Icon(Icons.terrain_rounded,
size: 14,
color: AppColors.textSecondary),
const SizedBox(height: 2),
Text(
'${lm.elevationMeters.toInt()} م',
style: const TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
],
),
),
],
),
const SizedBox(height: 10),
// Description
Text(
lm.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
height: 1.4,
color: AppColors.textSecondary,
fontFamily: '.SF Pro Text',
),
),
const SizedBox(height: 14),
// Action Buttons: [ 🎬 استعراض سينمائي 3D ] + [ 🧭 توجيه المسار ]
Row(
children: [
// 3D Cinematic Fly-to & Orbit Button
Expanded(
child: SizedBox(
height: 42,
child: OutlinedButton.icon(
onPressed: () {
Navigator.of(context).pop();
widget.cubit
.startOrbitAroundLandmark(lm);
SiroToast.showInfo(
context,
'جاري الطيران السينمائي 3D نحو ${lm.nameAr}',
title: 'استعراض المعلم 360°',
);
},
icon: const Icon(Icons.view_in_ar_rounded,
size: 16),
label: const Text(
'استعراض 3D ودوران',
style: TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w700,
),
),
style: OutlinedButton.styleFrom(
foregroundColor: lm.accentColor,
side: BorderSide(
color: lm.accentColor, width: 1.2),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(12),
),
),
),
),
),
const SizedBox(width: 10),
// Route & Navigate Button
Expanded(
child: SizedBox(
height: 42,
child: ElevatedButton.icon(
onPressed: () {
Navigator.of(context).pop();
widget.cubit.calculateRouteTo(
lm.coordinates,
title: lm.nameAr,
);
SiroToast.showSuccess(
context,
'جاري تخطيط المسار السيادي إلى ${lm.nameAr}',
title: 'تخطيط المسار',
);
},
icon: const Icon(Icons.directions_rounded,
size: 16, color: Colors.white),
label: const Text(
'توجيه المسار',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.appleBlue,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(12),
),
),
),
),
),
],
),
],
),
);
},
),
),
],
),
);
}
}
+6 -6
View File
@@ -627,10 +627,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.18"
material_color_utilities:
dependency: transitive
description:
@@ -643,10 +643,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@@ -1032,10 +1032,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
version: "0.7.9"
typed_data:
dependency: transitive
description: