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:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user