diff --git a/apps/siro_maps/android/app/build.gradle.kts b/apps/siro_maps/android/app/build.gradle.kts index 27f2569..ed6cc52 100644 --- a/apps/siro_maps/android/app/build.gradle.kts +++ b/apps/siro_maps/android/app/build.gradle.kts @@ -1,9 +1,18 @@ +import java.util.Properties +import java.io.FileInputStream + plugins { id("com.android.application") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + android { namespace = "com.siro_map.siro_maps" compileSdk = flutter.compileSdkVersion @@ -15,20 +24,43 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.siro_map.siro_maps" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = 23 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName } + signingConfigs { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String? + keyPassword = keystoreProperties["keyPassword"] as String? + val storeFilePath = keystoreProperties["storeFile"] as String? + if (storeFilePath != null) { + storeFile = file(storeFilePath) + } + storePassword = keystoreProperties["storePassword"] as String? + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. + val hasReleaseKey = keystorePropertiesFile.exists() && keystoreProperties["storeFile"] != null + signingConfig = if (hasReleaseKey) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } + + // Enable R8 / ProGuard Code Optimization & Shrinking + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + debug { signingConfig = signingConfigs.getByName("debug") } } @@ -36,6 +68,7 @@ android { dependencies { implementation("androidx.car.app:app:1.4.0") + implementation("androidx.car.app:app-projected:1.4.0") } kotlin { diff --git a/apps/siro_maps/android/app/proguard-rules.pro b/apps/siro_maps/android/app/proguard-rules.pro new file mode 100644 index 0000000..a4de778 --- /dev/null +++ b/apps/siro_maps/android/app/proguard-rules.pro @@ -0,0 +1,65 @@ +# ============================================================================== +# Siro Maps (map-saas) ProGuard & R8 Optimization Rules +# ============================================================================== + +# ── 1. FLUTTER EMBEDDING & NATIVE CHANNELS ──────────────────────────────────── +-keep class io.flutter.app.** { *; } +-keep class io.flutter.plugin.** { *; } +-keep class io.flutter.util.** { *; } +-keep class io.flutter.view.** { *; } +-keep class io.flutter.** { *; } +-keep class io.flutter.plugins.** { *; } + +-keepattributes *Annotation* +-keepattributes SourceFile,LineNumberTable +-keepattributes Signature +-keepattributes InnerClasses,EnclosingMethod + +# Keep native methods used by Flutter engine +-keepclasseswithmembers class * { + native ; +} + +# ── 2. ANDROID AUTO (CAR APP LIBRARY) ───────────────────────────────────────── +-keep class androidx.car.app.** { *; } +-keep interface androidx.car.app.** { *; } +-keep class androidx.car.app.model.** { *; } +-keep class androidx.car.app.navigation.** { *; } +-keep class androidx.car.app.navigation.model.** { *; } +-keep class androidx.car.app.validation.** { *; } + +# Keep our custom CarAppService, Session, Screen and State models +-keep class com.siro_map.siro_maps.car.** { *; } +-keepclassmembers class com.siro_map.siro_maps.car.** { *; } + +-dontwarn androidx.car.app.** + +# ── 3. MAPLIBRE GL NATIVE & RENDERING ENGINE ────────────────────────────────── +-keep class org.maplibre.** { *; } +-keep interface org.maplibre.** { *; } +-keep class org.maplibre.android.** { *; } +-keep interface org.maplibre.android.** { *; } +-keep class org.maplibre.android.maps.** { *; } +-keep class org.maplibre.android.geometry.** { *; } +-keep class org.maplibre.android.style.** { *; } + +-dontwarn org.maplibre.** +-dontwarn org.maplibre.android.** + +# ── 4. SHRED PREFERENCES & DATA MODELS ──────────────────────────────────────── +-keepclassmembers class * implements java.io.Serializable { + static final long serialVersionUID; + private static final java.io.ObjectStreamField[] serialPersistentFields; + !static !transient ; + !private ; + !private ; + private void writeObject(java.io.ObjectOutputStream); + private void readObject(java.io.ObjectInputStream); + java.lang.Object writeReplace(); + java.lang.Object readResolve(); +} + +# ── 5. KOTLIN & COROUTINES ──────────────────────────────────────────────────── +-dontwarn kotlin.** +-dontwarn kotlinx.coroutines.** +-keep class kotlin.Metadata { *; } diff --git a/apps/siro_maps/android/app/src/main/kotlin/com/siro_map/siro_maps/car/SiroNavScreen.kt b/apps/siro_maps/android/app/src/main/kotlin/com/siro_map/siro_maps/car/SiroNavScreen.kt index dd18eba..39a47bf 100644 --- a/apps/siro_maps/android/app/src/main/kotlin/com/siro_map/siro_maps/car/SiroNavScreen.kt +++ b/apps/siro_maps/android/app/src/main/kotlin/com/siro_map/siro_maps/car/SiroNavScreen.kt @@ -35,28 +35,31 @@ class SiroNavScreen(carContext: CarContext) : Screen(carContext) { .build() ) - // 3. Live Speed + // 3. Live Speed & Heading paneBuilder.addRow( Row.Builder() - .setTitle("السرعة الحالية") - .addText(state.formattedSpeed) + .setTitle("السرعة والاتجاه") + .addText("${state.formattedSpeed} • الزاوية ${state.bearing.toInt()}°") .build() ) - // Stop navigation action button - paneBuilder.addAction( - Action.Builder() - .setTitle("إنهاء الملاحة") - .setOnClickListener { - SiroCarAppService.stopNavigation() - invalidate() - } - .build() - ) + // Header Action Strip for seamless access across landscape, portrait, and Coolwalk multi-window + val actionStrip = ActionStrip.Builder() + .addAction( + Action.Builder() + .setTitle("إنهاء الملاحة") + .setOnClickListener { + SiroCarAppService.stopNavigation() + invalidate() + } + .build() + ) + .build() return PaneTemplate.Builder(paneBuilder.build()) .setTitle("ملاحة سيرو • جارية الآن") .setHeaderAction(Action.APP_ICON) + .setActionStrip(actionStrip) .build() } @@ -66,7 +69,7 @@ class SiroNavScreen(carContext: CarContext) : Screen(carContext) { paneBuilder.addRow( Row.Builder() .setTitle("خرائط سيرو السيادية (Siro Maps)") - .addText("التطبيق متصل بنجاح بشاشة السيارة") + .addText("متصل بشاشة السيارة • جاهز للتوجيه") .build() ) @@ -80,7 +83,7 @@ class SiroNavScreen(carContext: CarContext) : Screen(carContext) { paneBuilder.addRow( Row.Builder() .setTitle("بدء الملاحة") - .addText("حدد وجهتك من شاشة الهاتف للانتقال الفوري إلى وضع الملاحة ثلاثية الأبعاد") + .addText("حدد وجهتك من تطبيق الهاتف لبدء الملاحة التفاعلية فوراً") .build() ) diff --git a/apps/siro_maps/lib/core/services/vehicle_icon_generator.dart b/apps/siro_maps/lib/core/services/vehicle_icon_generator.dart new file mode 100644 index 0000000..37e20fd --- /dev/null +++ b/apps/siro_maps/lib/core/services/vehicle_icon_generator.dart @@ -0,0 +1,383 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; + +class VehicleStyleOption { + final String id; + final String labelAr; + final IconData icon; + + const VehicleStyleOption({ + required this.id, + required this.labelAr, + required this.icon, + }); +} + +class VehicleColorOption { + final int colorValue; + final String nameAr; + + const VehicleColorOption({ + required this.colorValue, + required this.nameAr, + }); + + Color get color => Color(colorValue); +} + +class VehicleIconGenerator { + VehicleIconGenerator._(); + + static const List availableStyles = [ + VehicleStyleOption( + id: 'car', + labelAr: 'سيارة عصرية 3D', + icon: Icons.directions_car_filled_rounded, + ), + VehicleStyleOption( + id: 'suv', + labelAr: 'دفع رباعي تكتيكي', + icon: Icons.airport_shuttle_rounded, + ), + VehicleStyleOption( + id: 'arrow', + labelAr: 'سهم الملاحة المجسم', + icon: Icons.navigation_rounded, + ), + ]; + + static const List availableColors = [ + VehicleColorOption(colorValue: 0xFF007AFF, nameAr: 'أزرق سيرو'), + VehicleColorOption(colorValue: 0xFF00C853, nameAr: 'أخضر تكتيكي'), + VehicleColorOption(colorValue: 0xFFFF3B30, nameAr: 'أحمر رياضي'), + VehicleColorOption(colorValue: 0xFFFF9500, nameAr: 'برتقالي شمسي'), + VehicleColorOption(colorValue: 0xFF212529, nameAr: 'أسود كربوني'), + VehicleColorOption(colorValue: 0xFF00E5FF, nameAr: 'سماوي كهربائي'), + ]; + + /// Generates crisp PNG image bytes for a vehicle icon based on style and color. + /// Standard canvas size is 192x192 px to ensure crystal clear presentation on high-DPI displays. + static Future generateVehicleIconBytes({ + required String styleId, + required Color primaryColor, + double size = 192.0, + }) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder, Rect.fromLTWH(0, 0, size, size)); + + switch (styleId) { + case 'suv': + _drawSuv(canvas, size, primaryColor); + break; + case 'arrow': + _drawNavigationArrow(canvas, size, primaryColor); + break; + case 'car': + default: + _drawSedanCar(canvas, size, primaryColor); + break; + } + + final picture = recorder.endRecording(); + final img = await picture.toImage(size.toInt(), size.toInt()); + final byteData = await img.toByteData(format: ui.ImageByteFormat.png); + return byteData!.buffer.asUint8List(); + } + + // ── 1. MODERN 3D SEDAN CAR ──────────────────────────────────── + static void _drawSedanCar(Canvas canvas, double size, Color color) { + final center = Offset(size / 2, size / 2); + final width = size * 0.46; + final height = size * 0.82; + + // 1. Soft Realistic Drop Shadow underneath + final shadowPaint = Paint() + ..color = const Color(0x55000000) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12.0); + final shadowRect = RRect.fromRectAndRadius( + Rect.fromCenter(center: center.translate(0, 6), width: width * 1.05, height: height * 1.02), + const Radius.circular(24), + ); + canvas.drawRRect(shadowRect, shadowPaint); + + // 2. Main Car Chassis Body + final chassisRect = RRect.fromRectAndRadius( + Rect.fromCenter(center: center, width: width, height: height), + const Radius.circular(22), + ); + + // Glossy metallic gradient across body + final hsl = HSLColor.fromColor(color); + final lightColor = hsl.withLightness((hsl.lightness + 0.18).clamp(0.0, 1.0)).toColor(); + final darkColor = hsl.withLightness((hsl.lightness - 0.15).clamp(0.0, 1.0)).toColor(); + + final bodyPaint = Paint() + ..shader = ui.Gradient.linear( + Offset(center.dx - width / 2, center.dy), + Offset(center.dx + width / 2, center.dy), + [darkColor, lightColor, color, darkColor], + [0.0, 0.35, 0.7, 1.0], + ); + canvas.drawRRect(chassisRect, bodyPaint); + + // Subtle 3D white chassis outline + final outlinePaint = Paint() + ..color = Colors.white.withValues(alpha: 0.45) + ..style = PaintingStyle.stroke + ..strokeWidth = 2.2; + canvas.drawRRect(chassisRect, outlinePaint); + + // 3. Front Windshield (curved dark glass) + final windshieldPaint = Paint() + ..shader = ui.Gradient.linear( + Offset(center.dx, center.dy - height * 0.28), + Offset(center.dx, center.dy - height * 0.08), + [const Color(0xDD1A2332), const Color(0x992C3E50)], + ); + final windshieldPath = Path() + ..moveTo(center.dx - width * 0.36, center.dy - height * 0.12) + ..lineTo(center.dx - width * 0.28, center.dy - height * 0.26) + ..quadraticBezierTo( + center.dx, center.dy - height * 0.30, + center.dx + width * 0.28, center.dy - height * 0.26, + ) + ..lineTo(center.dx + width * 0.36, center.dy - height * 0.12) + ..close(); + canvas.drawPath(windshieldPath, windshieldPaint); + + // 4. Car Roof / Sunroof + final roofRect = RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(center.dx, center.dy + height * 0.02), + width: width * 0.65, + height: height * 0.28, + ), + const Radius.circular(8), + ); + final roofPaint = Paint() + ..color = hsl.withLightness((hsl.lightness - 0.08).clamp(0.0, 1.0)).toColor(); + canvas.drawRRect(roofRect, roofPaint); + + // Panoramic Sunroof glass + final sunroofRect = RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(center.dx, center.dy + height * 0.02), + width: width * 0.52, + height: height * 0.20, + ), + const Radius.circular(6), + ); + canvas.drawRRect( + sunroofRect, + Paint()..color = const Color(0xBB1E293B), + ); + + // 5. Rear Windshield + final rearGlassPath = Path() + ..moveTo(center.dx - width * 0.35, center.dy + height * 0.18) + ..lineTo(center.dx - width * 0.27, center.dy + height * 0.30) + ..quadraticBezierTo( + center.dx, center.dy + height * 0.32, + center.dx + width * 0.27, center.dy + height * 0.30, + ) + ..lineTo(center.dx + width * 0.35, center.dy + height * 0.18) + ..close(); + canvas.drawPath( + rearGlassPath, + Paint()..color = const Color(0xDD1A2332), + ); + + // 6. LED Headlights (Glowing Cyan-White at front) + final headlightPaint = Paint()..color = const Color(0xFFF8FAFC); + final headlightGlow = Paint() + ..color = const Color(0x8838BDF8) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4.0); + + // Left & right headlights + final leftLight = Rect.fromLTWH(center.dx - width * 0.42, center.dy - height * 0.46, width * 0.22, height * 0.08); + final rightLight = Rect.fromLTWH(center.dx + width * 0.20, center.dy - height * 0.46, width * 0.22, height * 0.08); + canvas.drawRRect(RRect.fromRectAndRadius(leftLight, const Radius.circular(4)), headlightGlow); + canvas.drawRRect(RRect.fromRectAndRadius(leftLight, const Radius.circular(4)), headlightPaint); + canvas.drawRRect(RRect.fromRectAndRadius(rightLight, const Radius.circular(4)), headlightGlow); + canvas.drawRRect(RRect.fromRectAndRadius(rightLight, const Radius.circular(4)), headlightPaint); + + // 7. LED Taillights (Red accent bars at rear) + final taillightPaint = Paint()..color = const Color(0xFFFF2D55); + final leftTail = Rect.fromLTWH(center.dx - width * 0.42, center.dy + height * 0.42, width * 0.22, height * 0.06); + final rightTail = Rect.fromLTWH(center.dx + width * 0.20, center.dy + height * 0.42, width * 0.22, height * 0.06); + canvas.drawRRect(RRect.fromRectAndRadius(leftTail, const Radius.circular(3)), taillightPaint); + canvas.drawRRect(RRect.fromRectAndRadius(rightTail, const Radius.circular(3)), taillightPaint); + + // 8. Side Mirrors + final mirrorPaint = Paint()..color = color; + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(center.dx - width * 0.58, center.dy - height * 0.18, width * 0.12, height * 0.08), + const Radius.circular(4), + ), + mirrorPaint, + ); + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(center.dx + width * 0.46, center.dy - height * 0.18, width * 0.12, height * 0.08), + const Radius.circular(4), + ), + mirrorPaint, + ); + } + + // ── 2. TACTICAL 4x4 SUV ─────────────────────────────────────── + static void _drawSuv(Canvas canvas, double size, Color color) { + final center = Offset(size / 2, size / 2); + final width = size * 0.52; + final height = size * 0.84; + + // Drop Shadow + final shadowPaint = Paint() + ..color = const Color(0x66000000) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 14.0); + final shadowRect = RRect.fromRectAndRadius( + Rect.fromCenter(center: center.translate(0, 6), width: width * 1.06, height: height * 1.02), + const Radius.circular(20), + ); + canvas.drawRRect(shadowRect, shadowPaint); + + // SUV Boxy Rugged Body + final chassisRect = RRect.fromRectAndRadius( + Rect.fromCenter(center: center, width: width, height: height), + const Radius.circular(18), + ); + final hsl = HSLColor.fromColor(color); + final darkColor = hsl.withLightness((hsl.lightness - 0.18).clamp(0.0, 1.0)).toColor(); + final lightColor = hsl.withLightness((hsl.lightness + 0.15).clamp(0.0, 1.0)).toColor(); + + final bodyPaint = Paint() + ..shader = ui.Gradient.linear( + Offset(center.dx - width / 2, center.dy), + Offset(center.dx + width / 2, center.dy), + [darkColor, lightColor, color, darkColor], + [0.0, 0.35, 0.7, 1.0], + ); + canvas.drawRRect(chassisRect, bodyPaint); + + // Heavy outline + canvas.drawRRect( + chassisRect, + Paint() + ..color = Colors.white.withValues(alpha: 0.5) + ..style = PaintingStyle.stroke + ..strokeWidth = 2.4, + ); + + // Roof Rack Crossbars + final rackPaint = Paint() + ..color = const Color(0xFF1E293B) + ..strokeWidth = 3.5 + ..style = PaintingStyle.stroke; + canvas.drawLine( + Offset(center.dx - width * 0.36, center.dy - height * 0.12), + Offset(center.dx + width * 0.36, center.dy - height * 0.12), + rackPaint, + ); + canvas.drawLine( + Offset(center.dx - width * 0.36, center.dy + height * 0.12), + Offset(center.dx + width * 0.36, center.dy + height * 0.12), + rackPaint, + ); + + // Front Windshield + final windshieldRect = RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(center.dx, center.dy - height * 0.22), + width: width * 0.74, + height: height * 0.18, + ), + const Radius.circular(8), + ); + canvas.drawRRect( + windshieldRect, + Paint()..color = const Color(0xDD0F172A), + ); + + // Aggressive LED Front Light Bar + final lightBar = RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(center.dx, center.dy - height * 0.44), + width: width * 0.85, + height: height * 0.07, + ), + const Radius.circular(4), + ); + canvas.drawRRect( + lightBar, + Paint() + ..color = const Color(0xAA38BDF8) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 5), + ); + canvas.drawRRect(lightBar, Paint()..color = Colors.white); + + // Rear Brake Light Bar + final rearLightBar = RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(center.dx, center.dy + height * 0.44), + width: width * 0.80, + height: height * 0.06, + ), + const Radius.circular(3), + ); + canvas.drawRRect(rearLightBar, Paint()..color = const Color(0xFFFF2D55)); + } + + // ── 3. 3D DIRECTIONAL NAVIGATION ARROW (Google / Apple Style) ── + static void _drawNavigationArrow(Canvas canvas, double size, Color color) { + final center = Offset(size / 2, size / 2); + final radius = size * 0.44; + + // Glowing outer circular halo + final haloPaint = Paint() + ..color = color.withValues(alpha: 0.22) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 14.0); + canvas.drawCircle(center, radius, haloPaint); + + // White disc background with elevation shadow + final shadowPaint = Paint() + ..color = const Color(0x44000000) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8.0); + canvas.drawCircle(center.translate(0, 4), radius * 0.78, shadowPaint); + + final discPaint = Paint()..color = Colors.white; + canvas.drawCircle(center, radius * 0.78, discPaint); + + // Inner directional 3D chevron arrow + final arrowPath = Path() + ..moveTo(center.dx, center.dy - radius * 0.65) // Top sharp point + ..lineTo(center.dx + radius * 0.52, center.dy + radius * 0.42) // Bottom right wing + ..lineTo(center.dx, center.dy + radius * 0.18) // Inner center notch + ..lineTo(center.dx - radius * 0.52, center.dy + radius * 0.42) // Bottom left wing + ..close(); + + // 3D Split Lighting on Chevron + final hsl = HSLColor.fromColor(color); + final lightHalf = hsl.withLightness((hsl.lightness + 0.12).clamp(0.0, 1.0)).toColor(); + final darkHalf = hsl.withLightness((hsl.lightness - 0.14).clamp(0.0, 1.0)).toColor(); + + final arrowPaint = Paint() + ..shader = ui.Gradient.linear( + Offset(center.dx - radius * 0.5, center.dy), + Offset(center.dx + radius * 0.5, center.dy), + [darkHalf, lightHalf], + ); + canvas.drawPath(arrowPath, arrowPaint); + + // Center divider line + final linePaint = Paint() + ..color = Colors.white.withValues(alpha: 0.55) + ..strokeWidth = 2.0; + canvas.drawLine( + Offset(center.dx, center.dy - radius * 0.62), + Offset(center.dx, center.dy + radius * 0.18), + linePaint, + ); + } +} diff --git a/apps/siro_maps/lib/core/utils/arabic_search_normalizer.dart b/apps/siro_maps/lib/core/utils/arabic_search_normalizer.dart new file mode 100644 index 0000000..6a81d9f --- /dev/null +++ b/apps/siro_maps/lib/core/utils/arabic_search_normalizer.dart @@ -0,0 +1,51 @@ +class ArabicSearchNormalizer { + ArabicSearchNormalizer._(); + + static final RegExp _diacriticsRegExp = RegExp( + r'[\u064B-\u065F\u0670\u0640]', // Harakat, Tanween, Shadda, Sukoon, Tatweel + ); + + /// Normalizes Arabic text for flexible, fuzzy searching: + /// - Strips diacritics / tashkeel + /// - Normalizes all Alef forms (أ, إ, آ, ٱ) -> ا + /// - Normalizes Alef Maksura (ى) -> ي + /// - Normalizes Teh Marbuta (ة) -> ه + /// - Normalizes Hamza on Waw (ؤ) -> و and Hamza on Yaa (ئ) -> ي + /// - Lowercases English characters and trims spaces + static String normalize(String text) { + if (text.isEmpty) return ''; + + String cleaned = text + .replaceAll(_diacriticsRegExp, '') + .replaceAll(RegExp(r'[أإآٱ]'), 'ا') + .replaceAll('ى', 'ي') + .replaceAll('ة', 'ه') + .replaceAll('ؤ', 'و') + .replaceAll('ئ', 'ي') + .toLowerCase() + .trim(); + + // Collapse multiple whitespaces into a single space + cleaned = cleaned.replaceAll(RegExp(r'\s+'), ' '); + return cleaned; + } + + /// Checks if [target] contains [query], normalizing both in Arabic and English. + static bool matches(String target, String query) { + final normTarget = normalize(target); + final normQuery = normalize(query); + + if (normQuery.isEmpty) return true; + if (normTarget.isEmpty) return false; + + if (normTarget.contains(normQuery)) return true; + + // Split words in query and verify each token is present + final queryWords = normQuery.split(' ').where((w) => w.isNotEmpty); + if (queryWords.length > 1) { + return queryWords.every((word) => normTarget.contains(word)); + } + + return false; + } +} diff --git a/apps/siro_maps/lib/data/repositories/map_saas_repository.dart b/apps/siro_maps/lib/data/repositories/map_saas_repository.dart index f34e93b..ad25308 100644 --- a/apps/siro_maps/lib/data/repositories/map_saas_repository.dart +++ b/apps/siro_maps/lib/data/repositories/map_saas_repository.dart @@ -4,6 +4,7 @@ import 'package:http/http.dart' as http; import 'package:intaleq_maps/intaleq_maps.dart'; import '../../core/constants/api_constants.dart'; import '../../core/utils/polyline_decoder.dart'; +import '../../core/utils/arabic_search_normalizer.dart'; import '../../core/services/location_service.dart'; import '../models/route_model.dart'; import '../models/place_model.dart'; @@ -149,6 +150,7 @@ class MapSaasRepository { String country = 'jordan', }) async { final center = userLocation ?? const LatLng(ApiConstants.defaultLat, ApiConstants.defaultLng); + final normalizedQuery = ArabicSearchNormalizer.normalize(query); List places = []; // 1. Try MapSaaS Primary Geocoding Search Endpoint (Within 50 km) @@ -221,7 +223,7 @@ class MapSaasRepository { } catch (_) {} } - // 3. Strict 50 km radius enforcement and proximity sorting + // 3. Strict 50 km radius enforcement final filteredPlaces = places.where((p) { final distM = LocationService.instance.calculateDistance( center, @@ -230,8 +232,14 @@ class MapSaasRepository { return distM <= ApiConstants.maxSearchRadiusMeters; }).toList(); - // Sort by proximity ascending (closest first) + // 4. Smart Ranking: exact text match priority + proximity ascending filteredPlaces.sort((a, b) { + final matchA = ArabicSearchNormalizer.matches(a.name, normalizedQuery); + final matchB = ArabicSearchNormalizer.matches(b.name, normalizedQuery); + + if (matchA && !matchB) return -1; + if (!matchA && matchB) return 1; + final distA = LocationService.instance.calculateDistance( center, LatLng(a.latitude, a.longitude), diff --git a/apps/siro_maps/lib/logic/cubits/navigation/navigation_cubit.dart b/apps/siro_maps/lib/logic/cubits/navigation/navigation_cubit.dart index 064f218..2be3eb9 100644 --- a/apps/siro_maps/lib/logic/cubits/navigation/navigation_cubit.dart +++ b/apps/siro_maps/lib/logic/cubits/navigation/navigation_cubit.dart @@ -13,6 +13,8 @@ import '../../../core/services/car_platform_bridge.dart'; import '../../../core/services/connectivity_service.dart'; import '../../../core/services/location_service.dart'; import '../../../core/services/tts_service.dart'; +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/repositories/map_saas_repository.dart'; @@ -48,11 +50,40 @@ class NavigationCubit extends Cubit { _init(); } + // ── MOVEMENT INTERPOLATION ENGINE (30 FPS Smooth Vehicle Motion) ── + Timer? _movementInterpolationTimer; + LatLng? _currentDisplayPosition; + double _currentDisplayHeading = 0.0; + LatLng? _animStartPosition; + LatLng? _animTargetPosition; + double _animStartHeading = 0.0; + double _animTargetHeading = 0.0; + int _animCurrentStep = 0; + static const int _animTotalSteps = 25; // 25 steps * 40ms = 1000ms duration + static const Duration _animTickDuration = Duration(milliseconds: 40); + Future _init() async { print("🚀 [NavigationCubit] Initializing NavigationCubit..."); CarPlatformBridge.ensureInitialized(); await ttsService.init(); + // Load saved vehicle & search preferences + try { + final prefs = await SharedPreferences.getInstance(); + final savedColor = prefs.getInt('siro_vehicle_color') ?? 0xFF007AFF; + final savedStyle = prefs.getString('siro_vehicle_style') ?? 'car'; + final savedScale = prefs.getDouble('siro_vehicle_scale') ?? 1.8; + final savedRecent = prefs.getStringList('siro_recent_searches') ?? []; + emit(state.copyWith( + selectedVehicleColor: savedColor, + selectedVehicleStyle: savedStyle, + vehicleScale: savedScale, + recentSearches: savedRecent, + )); + } catch (e) { + print("⚠️ [NavigationCubit] SharedPreferences load error: $e"); + } + // Check & listen to network connectivity connectivityService.initialize(); final isOnline = await connectivityService.checkConnection(); @@ -78,6 +109,8 @@ class NavigationCubit extends Cubit { heading: position.heading, speed: position.speed * 3.6, )); + _currentDisplayPosition = loc; + _currentDisplayHeading = position.heading; _updateCarMarker(loc, position.heading); } else { print("⚠️ [NavigationCubit] Initial GPS returned null, using default Amman center"); @@ -89,12 +122,12 @@ class NavigationCubit extends Cubit { bool _hasInitiallyCenteredCamera = false; bool _isMapStyleLoaded = false; bool get isMapStyleLoaded => _isMapStyleLoaded; + int? _hasAnnouncedEarlyStepIndex; void onMapCreated(IntaleqMapController controller) { print("🗺️ [NavigationCubit] onMapCreated: Native map view created, controller attached."); mapController = controller; emit(state.copyWith(status: NavigationStatus.mapReady)); - // Defer camera animation to onStyleLoaded to prevent iOS native crashes } Future _animateCameraToCurrentPosition() async { @@ -109,19 +142,19 @@ class NavigationCubit extends Cubit { final alt = (pos.altitude.isNaN || pos.altitude.isInfinite) ? 0.0 : pos.altitude; target = loc; heading = pos.heading; - print("📍 [NavigationCubit] Updated GPS target from fresh fix: lat=${loc.latitude.toStringAsFixed(6)}, lng=${loc.longitude.toStringAsFixed(6)}"); emit(state.copyWith( myLocation: loc, altitude: alt, heading: pos.heading, speed: pos.speed * 3.6, )); + _currentDisplayPosition = loc; + _currentDisplayHeading = pos.heading; _updateCarMarker(loc, pos.heading); } } if (target != null && mapController != null && _isMapStyleLoaded) { - print("🎬 [NavigationCubit] Animating camera to target: lat=${target.latitude.toStringAsFixed(6)}, lng=${target.longitude.toStringAsFixed(6)}, zoom=16.5, bearing=$heading"); mapController!.animateCamera( CameraUpdate.newCameraPosition( CameraPosition( @@ -131,36 +164,47 @@ class NavigationCubit extends Cubit { ), ), ); - } else { - print("⏳ [NavigationCubit] Camera animation queued/deferred (styleLoaded=$_isMapStyleLoaded, controller=${mapController != null})"); + } + } + + Future _registerCurrentVehicleIcon() async { + if (mapController == null) return; + try { + final bytes = await VehicleIconGenerator.generateVehicleIconBytes( + styleId: state.selectedVehicleStyle, + 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})"); + } catch (e) { + print("⚠️ [NavigationCubit] Error generating vehicle icon: $e"); } } Future _loadCustomIcons() async { if (mapController == null) return; + + // 1. Dynamic vehicle icon (with user-selected color & model) + await _registerCurrentVehicleIcon(); + + // 2. Fallback static car icon try { final carBytes = await rootBundle.load('assets/images/car.png'); await mapController!.addImage('car_icon', carBytes.buffer.asUint8List()); - print("🚗 [NavigationCubit] car_icon registered into map style successfully."); - } catch (e) { - print("⚠️ [NavigationCubit] Could not load car_icon asset: $e"); - } + } catch (_) {} + // 3. Start & Destination pins try { final startBytes = await rootBundle.load('assets/images/A.png'); await mapController!.addImage('start_icon', startBytes.buffer.asUint8List()); - print("📍 [NavigationCubit] start_icon (Pin A) registered successfully."); - } catch (e) { - print("⚠️ [NavigationCubit] Could not load start_icon asset: $e"); - } + 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()); - print("📍 [NavigationCubit] dest_icon (Pin B) registered successfully."); - } catch (e) { - print("⚠️ [NavigationCubit] Could not load dest_icon asset: $e"); - } + await mapController!.addImage('asset_assets_images_b_png', destBytes.buffer.asUint8List()); + } catch (_) {} } Future onStyleLoaded() async { @@ -168,11 +212,50 @@ class NavigationCubit extends Cubit { _isMapStyleLoaded = true; await _loadCustomIcons(); if (state.myLocation != null) { - _updateCarMarker(state.myLocation!, state.heading); + _updateCarMarker(_currentDisplayPosition ?? state.myLocation!, _currentDisplayHeading); } _animateCameraToCurrentPosition(); } + // ── VEHICLE CUSTOMIZATION HANDLERS ────────────────────────── + + Future setVehicleColor(int colorValue) async { + emit(state.copyWith(selectedVehicleColor: colorValue)); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('siro_vehicle_color', colorValue); + } catch (_) {} + await _registerCurrentVehicleIcon(); + if (state.myLocation != null) { + _updateCarMarker(_currentDisplayPosition ?? state.myLocation!, _currentDisplayHeading); + } + } + + Future setVehicleStyle(String styleId) async { + emit(state.copyWith(selectedVehicleStyle: styleId)); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('siro_vehicle_style', styleId); + } catch (_) {} + await _registerCurrentVehicleIcon(); + if (state.myLocation != null) { + _updateCarMarker(_currentDisplayPosition ?? state.myLocation!, _currentDisplayHeading); + } + } + + Future setVehicleScale(double scale) async { + emit(state.copyWith(vehicleScale: scale)); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble('siro_vehicle_scale', scale); + } catch (_) {} + if (state.myLocation != null) { + _updateCarMarker(_currentDisplayPosition ?? state.myLocation!, _currentDisplayHeading); + } + } + + // ── LOCATION STREAM & SMOOTH INTERPOLATION ──────────────────── + void _startLocationUpdates() { _positionStreamSub?.cancel(); _positionStreamSub = locationService.getPositionStream().listen((pos) { @@ -181,69 +264,183 @@ class NavigationCubit extends Cubit { final heading = pos.heading; final alt = (pos.altitude.isNaN || pos.altitude.isInfinite) ? 0.0 : pos.altitude; - emit(state.copyWith( - myLocation: newLoc, - altitude: alt, - heading: heading, - speed: speedKmH, - )); + _onNewLocationFix(newLoc, heading, speedKmH, alt); + }); + } + void _onNewLocationFix(LatLng newLoc, double heading, double speedKmH, double alt) { + emit(state.copyWith( + myLocation: newLoc, + altitude: alt, + heading: heading, + speed: speedKmH, + )); + + // Smooth movement interpolation for the vehicle marker and 3D camera + if (_currentDisplayPosition == null) { + _currentDisplayPosition = newLoc; + _currentDisplayHeading = heading; _updateCarMarker(newLoc, heading); + } else { + _startMovementInterpolation(newLoc, heading); + } - // Periodic driver telemetry stream (every 5 seconds) - final now = DateTime.now(); - if (_lastTelemetrySent == null || now.difference(_lastTelemetrySent!).inSeconds >= 5) { - _lastTelemetrySent = now; - repository.sendDriverTelemetry( - driverId: _driverId, - latitude: newLoc.latitude, - longitude: newLoc.longitude, - speed: speedKmH, - heading: heading, - elevation: alt, - distance: state.remainingDistance, - ); - } + // Periodic driver telemetry stream (every 5 seconds) + final now = DateTime.now(); + if (_lastTelemetrySent == null || now.difference(_lastTelemetrySent!).inSeconds >= 5) { + _lastTelemetrySent = now; + repository.sendDriverTelemetry( + driverId: _driverId, + latitude: newLoc.latitude, + longitude: newLoc.longitude, + speed: speedKmH, + heading: heading, + elevation: alt, + distance: state.remainingDistance, + ); + } - // Proactively move camera on first acquired GPS lock (only when style is loaded) - if (!_hasInitiallyCenteredCamera && mapController != null && _isMapStyleLoaded) { - _hasInitiallyCenteredCamera = true; - mapController!.animateCamera( - CameraUpdate.newCameraPosition( - CameraPosition(target: newLoc, zoom: 16.5, bearing: heading), - ), - ); - } + // Proactively move camera on first acquired GPS lock (only when style is loaded) + if (!_hasInitiallyCenteredCamera && mapController != null && _isMapStyleLoaded) { + _hasInitiallyCenteredCamera = true; + mapController!.animateCamera( + CameraUpdate.newCameraPosition( + CameraPosition(target: newLoc, zoom: 16.5, bearing: heading), + ), + ); + } + if (state.isNavigating) { + _processActiveNavigationTick(newLoc, speedKmH, heading); + } + } + + void _startMovementInterpolation(LatLng targetPos, double targetHeading) { + _movementInterpolationTimer?.cancel(); + + _animStartPosition = _currentDisplayPosition ?? targetPos; + _animStartHeading = _currentDisplayHeading; + _animTargetPosition = targetPos; + _animTargetHeading = targetHeading; + _animCurrentStep = 0; + + _movementInterpolationTimer = Timer.periodic(_animTickDuration, (timer) { + _animCurrentStep++; + final double t = (_animCurrentStep / _animTotalSteps).clamp(0.0, 1.0); + + // 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); + + _currentDisplayPosition = interpPos; + _currentDisplayHeading = interpHeading; + + _updateCarMarker(interpPos, interpHeading); + + // Smoothly update Google Maps style 3D camera periodically (~every 200ms) or on completion if (state.isCameraLocked && mapController != null && _isMapStyleLoaded && state.isNavigating) { - double effectiveBearing = heading; - if (speedKmH < 4.0 && state.currentRoute != null) { - final coords = state.currentRoute!.coordinates; - if (_lastTraveledIndexInFullRoute + 1 < coords.length) { - effectiveBearing = _calculateBearing( - coords[_lastTraveledIndexInFullRoute], - coords[_lastTraveledIndexInFullRoute + 1], - ); - } + if (_animCurrentStep % 5 == 0 || _animCurrentStep == _animTotalSteps) { + _updateNavigationCamera(interpPos, interpHeading, state.speed, state.distanceToNextStep); } - mapController!.animateCamera( - CameraUpdate.newCameraPosition( - CameraPosition( - target: newLoc, - zoom: 17.5, - tilt: 55.0, - bearing: effectiveBearing, - ), - ), - ); } - if (state.isNavigating) { - _processActiveNavigationTick(newLoc, speedKmH, heading); + if (_animCurrentStep >= _animTotalSteps) { + timer.cancel(); + _movementInterpolationTimer = null; } }); } + // ── GOOGLE MAPS NAVIGATION PERSPECTIVE & DYNAMIC ZOOM ───────── + + 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 + + if (distanceToStep > 0 && distanceToStep < 100.0) { + // Approaching turn / maneuver: camera descends down close to show intersection + targetZoom = 18.2; + lookAheadMeters = 35.0; + } else if (speedKmH > 70.0) { + // High speed (highway): opens up distance ahead ("تكبر تفتح مسافة أكثر") + final speedFactor = ((speedKmH - 70.0) / 50.0).clamp(0.0, 1.0); + targetZoom = 16.5 - (0.7 * speedFactor); // 16.5 -> 15.8 + lookAheadMeters = 70.0 + (30.0 * speedFactor); // 70m -> 100m + } else if (speedKmH > 20.0) { + // Moderate city speed + final speedFactor = ((speedKmH - 20.0) / 50.0).clamp(0.0, 1.0); + targetZoom = 17.8 - (0.8 * speedFactor); // 17.8 -> 17.0 + lookAheadMeters = 40.0 + (25.0 * speedFactor); // 40m -> 65m + } else { + // Stopped / very slow (< 20 km/h) + targetZoom = 18.0; + lookAheadMeters = 35.0; + } + + // 2. Heading stabilization (lock to route if stopped/creeping) + double effectiveBearing = heading; + if (speedKmH < 4.0 && state.currentRoute != null) { + final coords = state.currentRoute!.coordinates; + if (_lastTraveledIndexInFullRoute + 1 < coords.length) { + effectiveBearing = _calculateBearing( + coords[_lastTraveledIndexInFullRoute], + coords[_lastTraveledIndexInFullRoute + 1], + ); + } + } + + // 3. Shift camera target ahead along heading vector + // This positions the car in the bottom ~28% of the viewport with 45° tilt! + final cameraTarget = _computeOffset(pos, lookAheadMeters, effectiveBearing); + + mapController!.animateCamera( + CameraUpdate.newCameraPosition( + CameraPosition( + target: cameraTarget, + zoom: targetZoom, + tilt: targetTilt, + bearing: effectiveBearing, + ), + ), + ); + } + + 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); + final double lonRad = from.longitude * (pi / 180.0); + final double bearingRad = bearingDegrees * (pi / 180.0); + + 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), + ); + + return LatLng(targetLatRad * (180.0 / pi), targetLonRad * (180.0 / pi)); + } + + double _lerpAngle(double from, double to, double t) { + final double diff = ((to - from + 540.0) % 360.0) - 180.0; + return (from + diff * t) % 360.0; + } + + LatLng _lerpLatLng(LatLng a, LatLng b, double t) { + return LatLng( + a.latitude + (b.latitude - a.latitude) * t, + a.longitude + (b.longitude - a.longitude) * t, + ); + } + Future _updateCarMarker(LatLng position, double bearing) async { if (mapController == null || !_isMapStyleLoaded) return; try { @@ -253,7 +450,7 @@ class NavigationCubit extends Cubit { rotation: bearing, anchor: const Offset(0.5, 0.5), flat: true, - icon: InlqBitmap.fromStyleImage('car_icon'), + icon: InlqBitmap.fromStyleImage('current_vehicle_icon', size: state.vehicleScale), zIndex: 100, )); } catch (e) { @@ -269,29 +466,25 @@ class NavigationCubit extends Cubit { print("🎯 [NavigationCubit] relockCameraToUser requested (styleLoaded=$_isMapStyleLoaded, loc=${state.myLocation})"); emit(state.copyWith(isCameraLocked: true)); if (state.myLocation != null && mapController != null && _isMapStyleLoaded) { - double effectiveBearing = state.heading; - if (state.isNavigating && state.speed < 4.0 && state.currentRoute != null) { - final coords = state.currentRoute!.coordinates; - if (_lastTraveledIndexInFullRoute + 1 < coords.length) { - effectiveBearing = _calculateBearing( - coords[_lastTraveledIndexInFullRoute], - coords[_lastTraveledIndexInFullRoute + 1], - ); - } - } - print("🎬 [NavigationCubit] Centering camera on user: target=${state.myLocation}, zoom=${state.isNavigating ? 17.5 : 16.5}, tilt=${state.isNavigating ? 55.0 : 0.0}, bearing=$effectiveBearing"); - mapController!.animateCamera( - CameraUpdate.newCameraPosition( - CameraPosition( - target: state.myLocation!, - zoom: state.isNavigating ? 17.5 : 16.5, - tilt: state.isNavigating ? 55.0 : 0.0, - bearing: effectiveBearing, + if (state.isNavigating) { + _updateNavigationCamera( + _currentDisplayPosition ?? state.myLocation!, + _currentDisplayHeading, + state.speed, + state.distanceToNextStep, + ); + } else { + mapController!.animateCamera( + CameraUpdate.newCameraPosition( + CameraPosition( + target: state.myLocation!, + zoom: 16.5, + tilt: 0.0, + bearing: state.heading, + ), ), - ), - ); - } else { - print("⏳ [NavigationCubit] relockCameraToUser deferred: styleLoaded=$_isMapStyleLoaded, controller=${mapController != null}"); + ); + } } } @@ -316,25 +509,102 @@ class NavigationCubit extends Cubit { void onSearchChanged(String query) { _searchDebounce?.cancel(); - if (query.trim().length < 2) { - emit(state.copyWith(searchResults: [])); + final trimmed = query.trim(); + if (trimmed.length < 2) { + emit(state.copyWith(searchResults: [], isSearching: false)); return; } - _searchDebounce = Timer(const Duration(milliseconds: 400), () async { + emit(state.copyWith(isSearching: true)); + _searchDebounce = Timer(const Duration(milliseconds: 350), () async { final results = await repository.searchPlaces( - query: query, + query: trimmed, userLocation: state.myLocation, ); - emit(state.copyWith(searchResults: results)); + emit(state.copyWith(searchResults: results, isSearching: false)); }); } - void clearSearch() { - emit(state.copyWith(searchResults: [])); + Future searchImmediately(String query) async { + _searchDebounce?.cancel(); + final trimmed = query.trim(); + if (trimmed.isEmpty) { + emit(state.copyWith(searchResults: [], isSearching: false)); + return; + } + emit(state.copyWith(isSearching: true)); + await saveRecentSearch(trimmed); + final results = await repository.searchPlaces( + query: trimmed, + userLocation: state.myLocation, + ); + emit(state.copyWith(searchResults: results, isSearching: false)); } - Future calculateRouteTo(LatLng destination, {String title = 'وجهة مختارة'}) async { - print("🛣️ [NavigationCubit] calculateRouteTo: target=$destination, title=$title, myLocation=${state.myLocation}"); + Future saveRecentSearch(String query) async { + final trimmed = query.trim(); + if (trimmed.length < 2) return; + final updated = List.from(state.recentSearches) + ..remove(trimmed) + ..insert(0, trimmed); + if (updated.length > 8) updated.removeLast(); + emit(state.copyWith(recentSearches: updated)); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList('siro_recent_searches', updated); + } catch (_) {} + } + + Future clearRecentSearches() async { + emit(state.copyWith(recentSearches: [])); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('siro_recent_searches'); + } catch (_) {} + } + + void clearSearch() { + _searchDebounce?.cancel(); + emit(state.copyWith(searchResults: [], isSearching: false)); + } + + // ── STEP BOUNDING OVERVIEW ────────────────────────────────── + + void overviewStepBounding(int stepIndex) { + 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; + + if (interval is List && interval.length >= 2) { + final startIdx = (interval[0] as num).toInt().clamp(0, coords.length - 1); + final endIdx = (interval[1] as num).toInt().clamp(0, coords.length - 1); + + if (startIdx <= endIdx) { + final stepCoords = coords.sublist(startIdx, endIdx + 1); + if (stepCoords.isNotEmpty) { + _fitRouteInView(stepCoords); + return; + } + } + } + + 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) { + mapController!.animateCamera( + CameraUpdate.newCameraPosition( + CameraPosition(target: LatLng(stepLat, stepLng), zoom: 17.5, tilt: 45.0), + ), + ); + } + } + + Future calculateRouteTo( + LatLng destination, { + String title = 'وجهة مختارة', + String originTitle = 'موقعي الحالي', + }) async { + 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!"); return; @@ -343,6 +613,7 @@ class NavigationCubit extends Cubit { status: NavigationStatus.loading, destination: destination, destinationTitle: title, + originTitle: originTitle, searchResults: [], )); @@ -370,9 +641,9 @@ class NavigationCubit extends Cubit { final startMarker = Marker( markerId: const MarkerId('origin_pin'), position: state.myLocation!, - icon: InlqBitmap.fromAsset('assets/images/A.png'), + icon: InlqBitmap.fromStyleImage('start_icon'), anchor: const Offset(0.5, 1.0), - infoWindow: const InfoWindow(title: 'نقطة الانطلاق (أ)'), + infoWindow: InfoWindow(title: originTitle, snippet: 'start'), zIndex: 90, ); @@ -380,9 +651,9 @@ class NavigationCubit extends Cubit { final destMarker = Marker( markerId: const MarkerId('dest_pin'), position: destination, - icon: InlqBitmap.fromAsset('assets/images/b.png'), + icon: InlqBitmap.fromStyleImage('dest_icon'), anchor: const Offset(0.5, 1.0), - infoWindow: InfoWindow(title: title), + infoWindow: InfoWindow(title: title, snippet: 'end'), zIndex: 90, ); @@ -395,6 +666,9 @@ class NavigationCubit extends Cubit { status: NavigationStatus.routePreview, routes: routes, selectedRouteIndex: 0, + destination: destination, + destinationTitle: title, + originTitle: originTitle, routeSteps: primaryRoute.steps, remainingDistance: primaryRoute.distanceM, remainingDuration: primaryRoute.durationS, @@ -403,6 +677,12 @@ class NavigationCubit extends Cubit { arrivalTime: _calculateArrivalTime(primaryRoute.durationS), )); + if (mapController != null && _isMapStyleLoaded) { + await mapController!.addMarker(startMarker); + await mapController!.addMarker(destMarker); + print("📍 [NavigationCubit] Directly added startMarker & destMarker to native map engine."); + } + _fitRouteInView(primaryRoute.coordinates); } catch (e) { print("❌ [NavigationCubit] Error calculating route: $e"); @@ -467,22 +747,15 @@ class NavigationCubit extends Cubit { ttsService.speak(firstInstruction); - // Immediate 3D Heading-Up camera orientation + // Immediate 3D Heading-Up camera orientation with 45° tilt and lookahead offset 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]); } - mapController!.animateCamera( - CameraUpdate.newCameraPosition( - CameraPosition( - target: state.myLocation!, - zoom: 17.5, - tilt: 55.0, - bearing: bearing, - ), - ), - ); + _currentDisplayPosition = state.myLocation; + _currentDisplayHeading = bearing; + _updateNavigationCamera(state.myLocation!, bearing, state.speed, 100.0); } CarPlatformBridge.updateNavState( @@ -501,14 +774,22 @@ class NavigationCubit extends Cubit { void stopNavigation() { print("🛑 [NavigationCubit] stopNavigation triggered."); + _movementInterpolationTimer?.cancel(); + _movementInterpolationTimer = null; ttsService.stop(); CarPlatformBridge.stopNavigation(); _lastTraveledIndexInFullRoute = 0; _offRouteStartTime = null; + _hasAnnouncedEarlyStepIndex = null; final remainingMarkers = Set.from(state.markers) ..removeWhere((m) => m.markerId.value == 'origin_pin' || m.markerId.value == 'dest_pin'); + if (mapController != null && _isMapStyleLoaded) { + mapController!.removeMarker(const MarkerId('origin_pin')); + mapController!.removeMarker(const MarkerId('dest_pin')); + } + emit(state.copyWith( status: NavigationStatus.mapReady, routes: [], @@ -517,6 +798,7 @@ class NavigationCubit extends Cubit { markers: remainingMarkers, destination: null, destinationTitle: '', + originTitle: 'موقعي الحالي', currentInstruction: '', nextInstruction: '', isCameraLocked: true, @@ -531,16 +813,7 @@ class NavigationCubit extends Cubit { } void simulateLocationTick(LatLng pos, {double speed = 60.0, double heading = 0.0, double altitude = 0.0}) { - emit(state.copyWith( - myLocation: pos, - altitude: altitude, - speed: speed, - heading: heading, - )); - _updateCarMarker(pos, heading); - if (state.isNavigating) { - _processActiveNavigationTick(pos, speed, heading); - } + _onNewLocationFix(pos, heading, speed, altitude); } void _processActiveNavigationTick(LatLng pos, double speedKmH, double heading) { @@ -588,6 +861,16 @@ class NavigationCubit extends Cubit { final distToStep = locationService.calculateDistance(pos, stepTarget); emit(state.copyWith(distanceToNextStep: distToStep)); + // Advance voice announcement when approaching turn (150m - 200m) + 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() ?? ''; + if (text.isNotEmpty) { + ttsService.speak('بعد $roundDist متراً، $text'); + } + } + final stepEndIdx = (step['interval'] is List && (step['interval'] as List).length >= 2) ? ((step['interval'] as List)[1] as num).toInt() : -1; @@ -595,6 +878,7 @@ class NavigationCubit extends Cubit { 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() ?? '') : ''; @@ -746,22 +1030,94 @@ class NavigationCubit extends Cubit { _isRerouting = false; } - // ── USER SUBMISSIONS: PLACES & HAZARDS ────────────────────── + // ── USER SUBMISSIONS: PLACES & HAZARDS (WITH PIN PICKER) ───── - Future submitPlace(String name, String category) async { - if (mapController == null) return false; - final center = mapController!.cameraPosition?.target ?? state.myLocation; - if (center == null) return false; + void startLocationPicking(String mode) { + 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' ? '📍 موقع المنشأة المحددة' : '⚠️ موقع البلاغ المحدد', + ), + zIndex: 95, + ); + + final updatedMarkers = Set.from(state.markers) + ..removeWhere((m) => m.markerId.value == 'picker_pin') + ..add(pickMarker); + + emit(state.copyWith( + isSelectingLocationOnMap: true, + activePickerMode: mode, + pickedLocation: targetLoc, + markers: updatedMarkers, + )); + + if (mapController != null && _isMapStyleLoaded) { + mapController!.addMarker(pickMarker); + } + } + + void updatePickedLocation(LatLng newLoc) { + if (!state.isSelectingLocationOnMap) return; + + final pickMarker = Marker( + markerId: const MarkerId('picker_pin'), + position: newLoc, + icon: InlqBitmap.fromStyleImage('dest_icon'), + anchor: const Offset(0.5, 1.0), + infoWindow: InfoWindow( + title: state.activePickerMode == 'place' ? '📍 موقع المنشأة المحددة' : '⚠️ موقع البلاغ المحدد', + ), + zIndex: 95, + ); + + final updatedMarkers = Set.from(state.markers) + ..removeWhere((m) => m.markerId.value == 'picker_pin') + ..add(pickMarker); + + emit(state.copyWith( + pickedLocation: newLoc, + markers: updatedMarkers, + )); + + if (mapController != null && _isMapStyleLoaded) { + mapController!.addMarker(pickMarker); + } + } + + void cancelLocationPicking() { + final remainingMarkers = Set.from(state.markers) + ..removeWhere((m) => m.markerId.value == 'picker_pin'); + + emit(state.copyWith( + isSelectingLocationOnMap: false, + clearActivePickerMode: true, + clearPickedLocation: true, + markers: remainingMarkers, + )); + + if (mapController != null && _isMapStyleLoaded) { + mapController!.removeMarker(const MarkerId('picker_pin')); + } + } + + Future 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( name: name, category: category, - position: center, + position: targetPos, altitude: state.altitude, ); if (success) { - emit(state.copyWith(isSelectingLocationOnMap: false)); + cancelLocationPicking(); } return success; } @@ -770,20 +1126,26 @@ class NavigationCubit extends Cubit { required String type, required String title, required String description, + LatLng? position, }) async { - if (state.myLocation == null) return false; + final targetPos = position ?? state.pickedLocation ?? state.myLocation; + if (targetPos == null) return false; final hazard = HazardModel( type: type, title: title, description: description, - latitude: state.myLocation!.latitude, - longitude: state.myLocation!.longitude, + latitude: targetPos.latitude, + longitude: targetPos.longitude, altitude: state.altitude, createdAt: DateTime.now(), ); - return await repository.reportHazard(hazard); + final success = await repository.reportHazard(hazard); + if (success) { + cancelLocationPicking(); + } + return success; } // ── HELPERS ────────────────────────────────────────────────── @@ -907,6 +1269,7 @@ class NavigationCubit extends Cubit { @override Future close() { + _movementInterpolationTimer?.cancel(); _positionStreamSub?.cancel(); _connectivitySub?.cancel(); _searchDebounce?.cancel(); diff --git a/apps/siro_maps/lib/logic/cubits/navigation/navigation_state.dart b/apps/siro_maps/lib/logic/cubits/navigation/navigation_state.dart index 67252ef..42ede66 100644 --- a/apps/siro_maps/lib/logic/cubits/navigation/navigation_state.dart +++ b/apps/siro_maps/lib/logic/cubits/navigation/navigation_state.dart @@ -29,6 +29,7 @@ class NavigationState extends Equatable { final int selectedRouteIndex; final LatLng? destination; final String destinationTitle; + final String originTitle; final List> routeSteps; final int currentStepIndex; final String currentInstruction; @@ -45,8 +46,15 @@ class NavigationState extends Equatable { final Set polylines; final List searchResults; final bool isSelectingLocationOnMap; + final String? activePickerMode; + final LatLng? pickedLocation; final bool isOnline; final String? errorMessage; + final int selectedVehicleColor; + final String selectedVehicleStyle; + final double vehicleScale; + final List recentSearches; + final bool isSearching; const NavigationState({ this.status = NavigationStatus.initial, @@ -58,6 +66,7 @@ class NavigationState extends Equatable { this.selectedRouteIndex = 0, this.destination, this.destinationTitle = '', + this.originTitle = 'موقعي الحالي', this.routeSteps = const [], this.currentStepIndex = 0, this.currentInstruction = '', @@ -74,8 +83,15 @@ class NavigationState extends Equatable { this.polylines = const {}, this.searchResults = const [], this.isSelectingLocationOnMap = false, + this.activePickerMode, + this.pickedLocation, this.isOnline = true, this.errorMessage, + this.selectedVehicleColor = 0xFF007AFF, + this.selectedVehicleStyle = 'car', + this.vehicleScale = 1.8, + this.recentSearches = const [], + this.isSearching = false, }); RouteData? get currentRoute => @@ -112,6 +128,7 @@ class NavigationState extends Equatable { int? selectedRouteIndex, LatLng? destination, String? destinationTitle, + String? originTitle, List>? routeSteps, int? currentStepIndex, String? currentInstruction, @@ -128,8 +145,17 @@ class NavigationState extends Equatable { Set? polylines, List? searchResults, bool? isSelectingLocationOnMap, + String? activePickerMode, + bool clearActivePickerMode = false, + LatLng? pickedLocation, + bool clearPickedLocation = false, bool? isOnline, String? errorMessage, + int? selectedVehicleColor, + String? selectedVehicleStyle, + double? vehicleScale, + List? recentSearches, + bool? isSearching, }) { return NavigationState( status: status ?? this.status, @@ -141,6 +167,7 @@ class NavigationState extends Equatable { selectedRouteIndex: selectedRouteIndex ?? this.selectedRouteIndex, destination: destination ?? this.destination, destinationTitle: destinationTitle ?? this.destinationTitle, + originTitle: originTitle ?? this.originTitle, routeSteps: routeSteps ?? this.routeSteps, currentStepIndex: currentStepIndex ?? this.currentStepIndex, currentInstruction: currentInstruction ?? this.currentInstruction, @@ -159,8 +186,15 @@ class NavigationState extends Equatable { searchResults: searchResults ?? this.searchResults, isSelectingLocationOnMap: isSelectingLocationOnMap ?? this.isSelectingLocationOnMap, + activePickerMode: clearActivePickerMode ? null : (activePickerMode ?? this.activePickerMode), + pickedLocation: clearPickedLocation ? null : (pickedLocation ?? this.pickedLocation), isOnline: isOnline ?? this.isOnline, errorMessage: errorMessage ?? this.errorMessage, + selectedVehicleColor: selectedVehicleColor ?? this.selectedVehicleColor, + selectedVehicleStyle: selectedVehicleStyle ?? this.selectedVehicleStyle, + vehicleScale: vehicleScale ?? this.vehicleScale, + recentSearches: recentSearches ?? this.recentSearches, + isSearching: isSearching ?? this.isSearching, ); } @@ -175,6 +209,7 @@ class NavigationState extends Equatable { selectedRouteIndex, destination, destinationTitle, + originTitle, routeSteps, currentStepIndex, currentInstruction, @@ -191,7 +226,14 @@ class NavigationState extends Equatable { polylines, searchResults, isSelectingLocationOnMap, + activePickerMode, + pickedLocation, isOnline, errorMessage, + selectedVehicleColor, + selectedVehicleStyle, + vehicleScale, + recentSearches, + isSearching, ]; } diff --git a/apps/siro_maps/lib/views/map/map_view.dart b/apps/siro_maps/lib/views/map/map_view.dart index 88eaf0b..7191eca 100644 --- a/apps/siro_maps/lib/views/map/map_view.dart +++ b/apps/siro_maps/lib/views/map/map_view.dart @@ -13,6 +13,7 @@ import 'widgets/active_nav_hud_widget.dart'; import 'widgets/layer_selector_sheet.dart'; import 'widgets/report_hazard_sheet.dart'; import 'widgets/add_place_sheet.dart'; +import 'widgets/vehicle_customizer_sheet.dart'; class MapView extends StatefulWidget { const MapView({super.key}); @@ -23,11 +24,16 @@ class MapView extends StatefulWidget { class _MapViewState extends State { final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + bool _isSearchFocused = false; @override void initState() { super.initState(); print("🚀 [MapView] initState: MapView mounted."); + _searchFocusNode.addListener(() { + if (mounted) setState(() => _isSearchFocused = _searchFocusNode.hasFocus); + }); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { print("📌 [MapView] PostFrameCallback: Triggering relockCameraToUser"); @@ -40,6 +46,7 @@ class _MapViewState extends State { @override void dispose() { _searchController.dispose(); + _searchFocusNode.dispose(); super.dispose(); } @@ -47,6 +54,18 @@ class _MapViewState extends State { cubit.relockCameraToUser(); } + void _showVehicleCustomizer(BuildContext context, NavigationCubit cubit, NavigationState state) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => VehicleCustomizerSheet( + cubit: cubit, + state: cubit.state, + ), + ); + } + void _showLayerSelector(BuildContext context, NavigationCubit cubit, MapThemeType current) { showModalBottomSheet( context: context, @@ -57,11 +76,15 @@ class _MapViewState extends State { cubit.setMapTheme(theme); Navigator.of(context).pop(); }, + onOpenVehicleCustomizer: () { + Navigator.of(context).pop(); + _showVehicleCustomizer(context, cubit, cubit.state); + }, ), ); } - void _showHazardSheet(BuildContext context, NavigationCubit cubit) { + void _showHazardSheet(BuildContext context, NavigationCubit cubit, {LatLng? initialLocation}) { showModalBottomSheet( context: context, isScrollControlled: true, @@ -69,7 +92,12 @@ class _MapViewState extends State { builder: (_) => ReportHazardSheet( onReport: (type, title, desc) async { final messenger = ScaffoldMessenger.of(context); - final ok = await cubit.reportHazard(type: type, title: title, description: desc); + final ok = await cubit.reportHazard( + type: type, + title: title, + description: desc, + position: initialLocation, + ); if (mounted && ok) { messenger.showSnackBar( const SnackBar( @@ -83,10 +111,14 @@ class _MapViewState extends State { } }, ), - ); + ).whenComplete(() { + if (cubit.state.isSelectingLocationOnMap) { + cubit.cancelLocationPicking(); + } + }); } - void _showAddPlaceSheet(BuildContext context, NavigationCubit cubit) { + void _showAddPlaceSheet(BuildContext context, NavigationCubit cubit, {LatLng? initialLocation}) { showModalBottomSheet( context: context, isScrollControlled: true, @@ -94,7 +126,11 @@ class _MapViewState extends State { builder: (_) => AddPlaceSheet( onSubmit: (name, cat) async { final messenger = ScaffoldMessenger.of(context); - final ok = await cubit.submitPlace(name, cat); + final ok = await cubit.submitPlace( + name, + cat, + position: initialLocation, + ); if (mounted) { messenger.showSnackBar( SnackBar( @@ -108,7 +144,11 @@ class _MapViewState extends State { } }, ), - ); + ).whenComplete(() { + if (cubit.state.isSelectingLocationOnMap) { + cubit.cancelLocationPicking(); + } + }); } @override @@ -129,8 +169,8 @@ class _MapViewState extends State { ); } - // Auto-center camera when location is updated and camera is locked (only when style is loaded) - if (state.isCameraLocked && state.myLocation != null && cubit.mapController != null && cubit.isMapStyleLoaded) { + // Auto-center camera only when idle and locked (NEVER during active navigation or route preview, as navigation cubit controls the 3D tilted camera) + if (state.isCameraLocked && !state.isNavigating && state.status != NavigationStatus.routePreview && state.myLocation != null && cubit.mapController != null && cubit.isMapStyleLoaded) { cubit.mapController!.animateCamera( CameraUpdate.newLatLngZoom(state.myLocation!, 16.5), ); @@ -227,7 +267,10 @@ class _MapViewState extends State { ), SearchBarWidget( controller: _searchController, + focusNode: _searchFocusNode, + isSearching: state.isSearching, onChanged: cubit.onSearchChanged, + onSubmitted: (q) => cubit.searchImmediately(q), onClear: () { _searchController.clear(); cubit.clearSearch(); @@ -238,7 +281,7 @@ class _MapViewState extends State { ExplorePanelWidget( onCategorySelected: (q) { _searchController.text = q; - cubit.onSearchChanged(q); + cubit.searchImmediately(q); }, ), ], @@ -247,8 +290,11 @@ class _MapViewState extends State { ), ), - // ── 3. SEARCH RESULTS DROPDOWN ── - if (state.searchResults.isNotEmpty && !state.isNavigating) + // ── 3. SEARCH RESULTS & RECENT HISTORY DROPDOWN ── + if (!state.isNavigating && + (state.searchResults.isNotEmpty || + (_isSearchFocused && _searchController.text.trim().isEmpty && state.recentSearches.isNotEmpty) || + (_isSearchFocused && _searchController.text.trim().length >= 2 && !state.isSearching && state.searchResults.isEmpty))) Positioned( top: 130, left: 16, @@ -270,157 +316,252 @@ class _MapViewState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'الوجهات المطابقة', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: AppColors.appleBlue.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(10), - ), - child: Text( - '${state.searchResults.length} نتائج', - style: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: AppColors.appleBlue, + if (state.searchResults.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'الوجهات المطابقة', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, ), ), - ), - ], - ), - ), - const Divider(height: 1, color: AppColors.borderSubtle), - Flexible( - child: ListView.separated( - shrinkWrap: true, - padding: const EdgeInsets.symmetric(vertical: 4), - itemCount: state.searchResults.length, - separatorBuilder: (_, __) => - const Divider(height: 1, color: AppColors.borderSubtle), - itemBuilder: (context, index) { - final place = state.searchResults[index]; - final iconData = _getCategoryIcon(place.category); - final iconColor = _getCategoryColor(place.category); - final categoryAr = _getCategoryArabicName(place.category); - - double? distM; - if (state.myLocation != null) { - distM = cubit.locationService.calculateDistance( - state.myLocation!, - LatLng(place.latitude, place.longitude), - ); - } - final distStr = distM != null - ? (distM > 1000 - ? '${(distM / 1000).toStringAsFixed(1)} كم' - : '${distM.round()} م') - : null; - - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), - leading: Container( - width: 38, - height: 38, + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( - color: iconColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), + color: AppColors.appleBlue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '${state.searchResults.length} نتائج', + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: AppColors.appleBlue, + ), ), - child: Icon(iconData, color: iconColor, size: 20), ), - title: Text( - place.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, + ], + ), + ), + const Divider(height: 1, color: AppColors.borderSubtle), + Flexible( + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: state.searchResults.length, + separatorBuilder: (_, __) => + const Divider(height: 1, color: AppColors.borderSubtle), + itemBuilder: (context, index) { + final place = state.searchResults[index]; + final iconData = _getCategoryIcon(place.category); + final iconColor = _getCategoryColor(place.category); + final categoryAr = _getCategoryArabicName(place.category); + + double? distM; + if (state.myLocation != null) { + distM = cubit.locationService.calculateDistance( + state.myLocation!, + LatLng(place.latitude, place.longitude), + ); + } + final distStr = distM != null + ? (distM > 1000 + ? '${(distM / 1000).toStringAsFixed(1)} كم' + : '${distM.round()} م') + : null; + + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + leading: Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(iconData, color: iconColor, size: 20), + ), + title: Text( + place.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + subtitle: Row( + children: [ + Text( + categoryAr, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: iconColor, + ), + ), + if (place.address != null && place.address!.isNotEmpty) ...[ + const Text(' • ', style: TextStyle(color: AppColors.textMuted)), + Expanded( + child: Text( + place.address!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ), + ], + ], + ), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (distStr != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.surfaceMuted, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + distStr, + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ), + if (place.elevationMeters > 0) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + '${place.elevationMeters.toInt()} م', + style: const TextStyle( + fontSize: 9, + color: AppColors.textMuted, + ), + ), + ), + ], + ), + onTap: () { + cubit.saveRecentSearch(place.name); + _searchController.clear(); + cubit.clearSearch(); + _searchFocusNode.unfocus(); + cubit.calculateRouteTo( + LatLng(place.latitude, place.longitude), + title: place.name, + ); + }, + ); + }, + ), + ), + ] else if (_isSearchFocused && _searchController.text.trim().isEmpty && state.recentSearches.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Row( + children: [ + Icon(Icons.history_rounded, size: 16, color: AppColors.appleBlue), + SizedBox(width: 6), + Text( + 'عمليات البحث الأخيرة', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], + ), + GestureDetector( + onTap: cubit.clearRecentSearches, + child: const Text( + 'مسح السجل', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.coralDanger, + ), + ), + ), + ], + ), + ), + const Divider(height: 1, color: AppColors.borderSubtle), + Flexible( + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: state.recentSearches.length, + separatorBuilder: (_, __) => + const Divider(height: 1, color: AppColors.borderSubtle), + itemBuilder: (context, index) { + final query = state.recentSearches[index]; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 0), + leading: const Icon(Icons.access_time_rounded, size: 18, color: AppColors.textMuted), + title: Text( + query, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + ), + trailing: const Icon(Icons.north_west_rounded, size: 14, color: AppColors.textMuted), + onTap: () { + _searchController.text = query; + cubit.searchImmediately(query); + }, + ); + }, + ), + ), + ] else if (_isSearchFocused && _searchController.text.trim().length >= 2 && !state.isSearching) ...[ + Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.search_off_rounded, size: 36, color: AppColors.textMuted), + const SizedBox(height: 8), + Text( + 'لا توجد نتائج مطابقة لـ "${_searchController.text.trim()}"', style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), - subtitle: Row( - children: [ - Text( - categoryAr, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w500, - color: iconColor, - ), - ), - if (place.address != null && place.address!.isNotEmpty) ...[ - const Text(' • ', style: TextStyle(color: AppColors.textMuted)), - Expanded( - child: Text( - place.address!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 11, - color: AppColors.textSecondary, - ), - ), - ), - ], - ], + const SizedBox(height: 4), + const Text( + 'تأكد من كتابة الاسم بدقة أو جرب فئات مثل: مطعم، صيدلية، وقود', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + color: AppColors.textMuted, + ), ), - trailing: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (distStr != null) - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: AppColors.surfaceMuted, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - distStr, - style: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ), - ), - ), - if (place.elevationMeters > 0) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - '${place.elevationMeters.toInt()} م', - style: const TextStyle( - fontSize: 9, - color: AppColors.textMuted, - ), - ), - ), - ], - ), - onTap: () { - _searchController.clear(); - cubit.clearSearch(); - FocusScope.of(context).unfocus(); - cubit.calculateRouteTo( - LatLng(place.latitude, place.longitude), - title: place.name, - ); - }, - ); - }, + ], + ), ), - ), + ], ], ), ), @@ -450,31 +591,82 @@ class _MapViewState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Destination Header & Close Button + // Dual Origin (A) & Destination (B) Header with close button Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Visual Pins (Green A -> Red B with connector) + Column( + children: [ + Container( + width: 22, + height: 22, + decoration: const BoxDecoration( + color: AppColors.tacticalEmerald, + shape: BoxShape.circle, + ), + child: const Center( + child: Text( + 'أ', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + Container( + width: 2, + height: 18, + margin: const EdgeInsets.symmetric(vertical: 2), + color: AppColors.borderSubtle, + ), + Container( + width: 22, + height: 22, + decoration: const BoxDecoration( + color: AppColors.coralDanger, + shape: BoxShape.circle, + ), + child: const Center( + child: Text( + 'ب', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + const SizedBox(width: 12), + // Location Titles & Metrics Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - state.destinationTitle, + state.originTitle.isNotEmpty ? state.originTitle : 'موقعي الحالي', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - fontSize: 17, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, ), ), - const SizedBox(height: 2), + const SizedBox(height: 14), Text( - '${state.routes.length > 1 ? "يتوفر مساران • " : ""}${state.currentRoute!.formattedDuration} (${state.currentRoute!.formattedDistance}) • وصول ${state.arrivalTime}', + state.destinationTitle.isNotEmpty ? state.destinationTitle : 'الوجهة المحددة', + maxLines: 1, + overflow: TextOverflow.ellipsis, style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: AppColors.tacticalEmerald, + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, ), ), ], @@ -486,6 +678,37 @@ class _MapViewState extends State { ), ], ), + const SizedBox(height: 10), + // ETA, Distance & Timing Badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: AppColors.tacticalEmerald.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + state.currentRoute!.formattedDuration, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w800, + color: AppColors.tacticalEmerald, + ), + ), + const SizedBox(width: 8), + Text( + '• ${state.currentRoute!.formattedDistance} • وصول ${state.arrivalTime}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), // Multi-Route Options Selector (Route 1 vs Route 2) if (state.routes.length > 1) ...[ @@ -695,12 +918,203 @@ class _MapViewState extends State { onStopNavigation: cubit.stopNavigation, onToggleMute: cubit.toggleMute, onRecenter: () => _recenterOnUser(cubit, state), + onOverviewStep: (idx) => cubit.overviewStepBounding(idx), + onOpenVehicleCustomizer: () => _showVehicleCustomizer(context, cubit, state), ), ), ), + // ── 5b. INTERACTIVE LOCATION PIN PICKER HUD (Add Place & Hazard) ── + if (state.isSelectingLocationOnMap) ...[ + // Centered Floating Target Pin + IgnorePointer( + child: Center( + child: Padding( + padding: const EdgeInsets.only(bottom: 38), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: state.activePickerMode == 'place' + ? AppColors.appleBlue + : AppColors.coralDanger, + shape: BoxShape.circle, + boxShadow: const [ + BoxShadow( + color: Color(0x33000000), + blurRadius: 16, + offset: Offset(0, 6), + ), + ], + ), + child: Icon( + state.activePickerMode == 'place' + ? Icons.add_location_alt_rounded + : Icons.warning_amber_rounded, + size: 28, + color: Colors.white, + ), + ), + Container( + width: 3, + height: 14, + color: state.activePickerMode == 'place' + ? AppColors.appleBlue + : AppColors.coralDanger, + ), + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + ), + ], + ), + ), + ), + ), + + // Bottom Confirmation Card + Positioned( + bottom: 24, + left: 16, + right: 16, + child: SafeArea( + top: false, + child: Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: AppColors.pureWhite, + borderRadius: BorderRadius.circular(24), + boxShadow: const [ + BoxShadow( + color: Color(0x29000000), + blurRadius: 24, + offset: Offset(0, 8), + ), + ], + border: Border.all(color: AppColors.borderSubtle), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: (state.activePickerMode == 'place' + ? AppColors.appleBlue + : AppColors.coralDanger) + .withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + state.activePickerMode == 'place' + ? Icons.place_rounded + : Icons.report_problem_rounded, + size: 20, + color: state.activePickerMode == 'place' + ? AppColors.appleBlue + : AppColors.coralDanger, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + state.activePickerMode == 'place' + ? 'تحديد موقع المنشأة على الخريطة' + : 'تحديد موقع البلاغ أو الخطر على الخريطة', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + 'إحداثيات: ${state.pickedLocation?.latitude.toStringAsFixed(5) ?? '--'} ، ${state.pickedLocation?.longitude.toStringAsFixed(5) ?? '--'}', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: SizedBox( + height: 48, + child: ElevatedButton( + onPressed: () { + final loc = state.pickedLocation; + if (state.activePickerMode == 'place') { + _showAddPlaceSheet(context, cubit, initialLocation: loc); + } else { + _showHazardSheet(context, cubit, initialLocation: loc); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: state.activePickerMode == 'place' + ? AppColors.appleBlue + : AppColors.coralDanger, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + elevation: 0, + ), + child: const Text( + 'تأكيد الموقع ومتابعة التفاصيل', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 13, + ), + ), + ), + ), + ), + const SizedBox(width: 10), + SizedBox( + height: 48, + child: TextButton( + onPressed: cubit.cancelLocationPicking, + style: TextButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + backgroundColor: AppColors.surfaceMuted, + ), + child: const Text( + 'إلغاء', + style: TextStyle( + color: AppColors.textSecondary, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ], + // ── 6. FLOATING ACTION BUTTONS (Right side, anchored to bottom) ── - if (!state.isNavigating && state.status != NavigationStatus.routePreview) + if (!state.isNavigating && state.status != NavigationStatus.routePreview && !state.isSelectingLocationOnMap) Positioned( right: 16, bottom: 28, @@ -722,7 +1136,7 @@ class _MapViewState extends State { icon: Icons.add_location_alt_rounded, color: AppColors.appleBlue, tooltip: 'إضافة مكان', - onTap: () => _showAddPlaceSheet(context, cubit), + onTap: () => cubit.startLocationPicking('place'), ), const SizedBox(height: 12), // Report Hazard Button @@ -730,7 +1144,7 @@ class _MapViewState extends State { icon: Icons.warning_amber_rounded, color: AppColors.sovereignGold, tooltip: 'إبلاغ عن حالة طريق', - onTap: () => _showHazardSheet(context, cubit), + onTap: () => cubit.startLocationPicking('hazard'), ), const SizedBox(height: 12), // Recenter GPS Button @@ -1028,9 +1442,18 @@ class _MapViewState extends State { tiltGesturesEnabled: true, zoomControlsEnabled: false, compassEnabled: false, + onCameraMove: (pos) { + if (state.isSelectingLocationOnMap) { + cubit.updatePickedLocation(pos.target); + } + }, onTap: (latLng) { - cubit.clearSearch(); - FocusScope.of(context).unfocus(); + if (state.isSelectingLocationOnMap) { + cubit.updatePickedLocation(latLng); + } else { + cubit.clearSearch(); + FocusScope.of(context).unfocus(); + } }, onLongPress: (latLng) { _showDestinationDialog(context, cubit, latLng); diff --git a/apps/siro_maps/lib/views/map/widgets/active_nav_hud_widget.dart b/apps/siro_maps/lib/views/map/widgets/active_nav_hud_widget.dart index be12cf5..1ec5189 100644 --- a/apps/siro_maps/lib/views/map/widgets/active_nav_hud_widget.dart +++ b/apps/siro_maps/lib/views/map/widgets/active_nav_hud_widget.dart @@ -7,6 +7,8 @@ class ActiveNavHudWidget extends StatelessWidget { final VoidCallback onStopNavigation; final VoidCallback onToggleMute; final VoidCallback onRecenter; + final Function(int stepIndex)? onOverviewStep; + final VoidCallback? onOpenVehicleCustomizer; const ActiveNavHudWidget({ super.key, @@ -14,6 +16,8 @@ class ActiveNavHudWidget extends StatelessWidget { required this.onStopNavigation, required this.onToggleMute, required this.onRecenter, + this.onOverviewStep, + this.onOpenVehicleCustomizer, }); IconData _getManeuverIcon(int sign) { @@ -51,84 +55,108 @@ class ActiveNavHudWidget extends StatelessWidget { children: [ // ── TOP INSTRUCTION CARD ── if (state.currentInstruction.isNotEmpty) - Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.appleBlue, - borderRadius: BorderRadius.circular(22), - boxShadow: const [ - BoxShadow( - color: Color(0x2E007AFF), - blurRadius: 20, - offset: Offset(0, 6), - ), - ], - ), - child: Row( - children: [ - // Dynamic Maneuver Direction Icon - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(14), + InkWell( + onTap: () => onOverviewStep?.call(state.currentStepIndex), + borderRadius: BorderRadius.circular(22), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.appleBlue, + borderRadius: BorderRadius.circular(22), + boxShadow: const [ + BoxShadow( + color: Color(0x2E007AFF), + blurRadius: 20, + offset: Offset(0, 6), ), - child: Center( - child: Icon( - _getManeuverIcon(state.currentManeuverModifier), - color: Colors.white, - size: 28, + ], + ), + child: Row( + children: [ + // Dynamic Maneuver Direction Icon + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(14), + ), + child: Center( + child: Icon( + _getManeuverIcon(state.currentManeuverModifier), + color: Colors.white, + size: 28, + ), ), ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (state.distanceToNextStep > 0) + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (state.distanceToNextStep > 0) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'بعد ${state.distanceToNextStep.round()} متر', + style: const TextStyle( + fontFamily: '.SF Pro Text', + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.white70, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'معاينة 🔍', + style: TextStyle( + fontSize: 9, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), Text( - 'بعد ${state.distanceToNextStep.round()} متر', + state.currentInstruction, + maxLines: 2, + overflow: TextOverflow.ellipsis, style: const TextStyle( fontFamily: '.SF Pro Text', - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.white70, + fontSize: 15, + fontWeight: FontWeight.w700, + color: Colors.white, + height: 1.3, ), ), - Text( - state.currentInstruction, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: '.SF Pro Text', - fontSize: 15, - fontWeight: FontWeight.w700, - color: Colors.white, - height: 1.3, - ), - ), - if (state.nextInstruction.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - 'ثم ${state.nextInstruction}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: '.SF Pro Text', - fontSize: 11, - fontWeight: FontWeight.w500, - color: Colors.white60, + if (state.nextInstruction.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'ثم ${state.nextInstruction}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: '.SF Pro Text', + fontSize: 11, + fontWeight: FontWeight.w500, + color: Colors.white60, + ), ), ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ), @@ -252,6 +280,16 @@ class ActiveNavHudWidget extends StatelessWidget { size: 24, ), ), + // Vehicle Customizer + if (onOpenVehicleCustomizer != null) + IconButton( + onPressed: onOpenVehicleCustomizer, + icon: const Icon( + Icons.directions_car_filled_rounded, + color: AppColors.appleBlue, + size: 22, + ), + ), // Recenter Map IconButton( onPressed: onRecenter, diff --git a/apps/siro_maps/lib/views/map/widgets/layer_selector_sheet.dart b/apps/siro_maps/lib/views/map/widgets/layer_selector_sheet.dart index d8179b3..6bf441f 100644 --- a/apps/siro_maps/lib/views/map/widgets/layer_selector_sheet.dart +++ b/apps/siro_maps/lib/views/map/widgets/layer_selector_sheet.dart @@ -5,11 +5,13 @@ import '../../../../logic/cubits/navigation/navigation_state.dart'; class LayerSelectorSheet extends StatelessWidget { final MapThemeType currentTheme; final ValueChanged onThemeChanged; + final VoidCallback? onOpenVehicleCustomizer; const LayerSelectorSheet({ super.key, required this.currentTheme, required this.onThemeChanged, + this.onOpenVehicleCustomizer, }); @override @@ -33,7 +35,7 @@ class LayerSelectorSheet extends StatelessWidget { ), const SizedBox(height: 18), const Text( - 'طبقات الخريطة السيادية', + 'طبقات ومظهر الخريطة السيادية', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w700, @@ -64,6 +66,38 @@ class LayerSelectorSheet extends StatelessWidget { ), ], ), + if (onOpenVehicleCustomizer != null) ...[ + const SizedBox(height: 20), + const Divider(height: 1, color: AppColors.borderSubtle), + const SizedBox(height: 16), + ListTile( + onTap: onOpenVehicleCustomizer, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + tileColor: AppColors.surfaceMuted, + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.appleBlue.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.directions_car_filled_rounded, color: AppColors.appleBlue, size: 22), + ), + title: const Text( + 'تخصيص أيقونة ولون المركبة', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + subtitle: const Text( + 'تغيير النمط، اللون، وتكبير الحجم على الخريطة', + style: TextStyle(fontSize: 11, color: AppColors.textSecondary), + ), + trailing: const Icon(Icons.arrow_forward_ios_rounded, size: 14, color: AppColors.textMuted), + ), + ], ], ), ); diff --git a/apps/siro_maps/lib/views/map/widgets/search_bar_widget.dart b/apps/siro_maps/lib/views/map/widgets/search_bar_widget.dart index 233136e..f677438 100644 --- a/apps/siro_maps/lib/views/map/widgets/search_bar_widget.dart +++ b/apps/siro_maps/lib/views/map/widgets/search_bar_widget.dart @@ -4,15 +4,21 @@ import '../../../../core/constants/app_colors.dart'; class SearchBarWidget extends StatelessWidget { final TextEditingController controller; final ValueChanged onChanged; + final ValueChanged? onSubmitted; final VoidCallback onClear; final VoidCallback onMenuTap; + final bool isSearching; + final FocusNode? focusNode; const SearchBarWidget({ super.key, required this.controller, required this.onChanged, + this.onSubmitted, required this.onClear, required this.onMenuTap, + this.isSearching = false, + this.focusNode, }); @override @@ -34,12 +40,22 @@ class SearchBarWidget extends StatelessWidget { child: Row( children: [ const SizedBox(width: 14), - const Icon(Icons.search_rounded, color: AppColors.appleBlue, size: 22), + if (isSearching) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.appleBlue), + ) + else + const Icon(Icons.search_rounded, color: AppColors.appleBlue, size: 22), const SizedBox(width: 10), Expanded( child: TextField( controller: controller, + focusNode: focusNode, onChanged: onChanged, + onSubmitted: onSubmitted, + textInputAction: TextInputAction.search, textDirection: TextDirection.rtl, style: const TextStyle( fontSize: 14, diff --git a/apps/siro_maps/lib/views/map/widgets/vehicle_customizer_sheet.dart b/apps/siro_maps/lib/views/map/widgets/vehicle_customizer_sheet.dart new file mode 100644 index 0000000..a14a62d --- /dev/null +++ b/apps/siro_maps/lib/views/map/widgets/vehicle_customizer_sheet.dart @@ -0,0 +1,330 @@ +import 'package:flutter/material.dart'; +import '../../../../core/constants/app_colors.dart'; +import '../../../../core/services/vehicle_icon_generator.dart'; +import '../../../../logic/cubits/navigation/navigation_cubit.dart'; +import '../../../../logic/cubits/navigation/navigation_state.dart'; + +class VehicleCustomizerSheet extends StatelessWidget { + final NavigationCubit cubit; + final NavigationState state; + + const VehicleCustomizerSheet({ + super.key, + required this.cubit, + required this.state, + }); + + static const List> _scales = [ + {'scale': 1.4, 'label': 'عادي'}, + {'scale': 1.8, 'label': 'كبير (موصى به)'}, + {'scale': 2.2, 'label': 'فائق الوضوح'}, + {'scale': 2.6, 'label': 'ضخم'}, + ]; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 36), + decoration: const BoxDecoration( + color: AppColors.pureWhite, + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + boxShadow: [ + BoxShadow( + color: Color(0x22000000), + blurRadius: 24, + offset: Offset(0, -6), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Drag Handle + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.borderGlass, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + + // Sheet Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Row( + children: [ + Icon(Icons.tune_rounded, color: AppColors.appleBlue, size: 22), + SizedBox(width: 8), + Text( + 'تخصيص أيقونة المركبة والملاحة', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], + ), + IconButton( + icon: const Icon(Icons.close_rounded, size: 20, color: AppColors.textMuted), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + const SizedBox(height: 16), + + // ── 1. LIVE INTERACTIVE VEHICLE PREVIEW CARD ── + Container( + height: 130, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF1E293B), Color(0xFF0F172A)], + ), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white.withValues(alpha: 0.1)), + ), + child: Stack( + alignment: Alignment.center, + children: [ + // Simulated Asphalt Perspective Road lines + Positioned.fill( + child: CustomPaint( + painter: _RoadPreviewPainter(), + ), + ), + + // Rendered Vehicle Icon + FutureBuilder( + future: VehicleIconGenerator.generateVehicleIconBytes( + styleId: state.selectedVehicleStyle, + primaryColor: Color(state.selectedVehicleColor), + size: 140, + ), + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data != null) { + final scaleFactor = (state.vehicleScale / 1.8).clamp(0.7, 1.4); + return Transform.scale( + scale: scaleFactor, + child: Image.memory( + snapshot.data!, + width: 85, + height: 85, + filterQuality: FilterQuality.high, + ), + ); + } + return const SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white70), + ); + }, + ), + + // Bottom badge + Positioned( + bottom: 8, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'معاينة حية على الخريطة • زاوية 45°', + style: TextStyle( + fontSize: 10, + color: Colors.white.withValues(alpha: 0.8), + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + + // ── 2. VEHICLE MODEL STYLE SELECTOR ── + const Text( + 'نمط المركبة', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 10), + Row( + children: VehicleIconGenerator.availableStyles.map((style) { + final isSelected = state.selectedVehicleStyle == style.id; + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: InkWell( + onTap: () => cubit.setVehicleStyle(style.id), + borderRadius: BorderRadius.circular(14), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: isSelected + ? AppColors.appleBlue.withValues(alpha: 0.1) + : AppColors.surfaceMuted, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isSelected ? AppColors.appleBlue : AppColors.borderSubtle, + width: isSelected ? 2 : 1, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + style.icon, + size: 24, + color: isSelected ? AppColors.appleBlue : AppColors.textSecondary, + ), + const SizedBox(height: 4), + Text( + style.labelAr, + style: TextStyle( + fontSize: 10.5, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected ? AppColors.appleBlue : AppColors.textPrimary, + ), + ), + ], + ), + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 20), + + // ── 3. VEHICLE COLOR SELECTOR ── + const Text( + 'لون المركبة', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: VehicleIconGenerator.availableColors.map((colorOpt) { + final isSelected = state.selectedVehicleColor == colorOpt.colorValue; + return InkWell( + onTap: () => cubit.setVehicleColor(colorOpt.colorValue), + borderRadius: BorderRadius.circular(20), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 42, + height: 42, + decoration: BoxDecoration( + color: colorOpt.color, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: colorOpt.color.withValues(alpha: 0.35), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + border: Border.all( + color: Colors.white, + width: isSelected ? 3.5 : 2, + ), + ), + child: isSelected + ? const Center( + child: Icon(Icons.check_rounded, color: Colors.white, size: 22), + ) + : null, + ), + ); + }).toList(), + ), + const SizedBox(height: 20), + + // ── 4. VEHICLE ICON SIZE SELECTOR ── + const Text( + 'حجم أيقونة المركبة', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + children: _scales.map((item) { + final scale = item['scale'] as double; + final label = item['label'] as String; + final isSelected = (state.vehicleScale - scale).abs() < 0.15; + return ChoiceChip( + label: Text(label), + selected: isSelected, + selectedColor: AppColors.appleBlue.withValues(alpha: 0.15), + backgroundColor: AppColors.surfaceMuted, + labelStyle: TextStyle( + fontSize: 11, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected ? AppColors.appleBlue : AppColors.textPrimary, + ), + side: BorderSide( + color: isSelected ? AppColors.appleBlue : AppColors.borderSubtle, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + onSelected: (_) => cubit.setVehicleScale(scale), + ); + }).toList(), + ), + ], + ), + ); + } +} + +class _RoadPreviewPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = Colors.white.withValues(alpha: 0.18) + ..strokeWidth = 2.0; + + // Center dashed divider + double startY = 10; + while (startY < size.height) { + canvas.drawLine( + Offset(size.width / 2, startY), + Offset(size.width / 2, startY + 12), + paint, + ); + startY += 22; + } + + // Lane side markers + final sidePaint = Paint() + ..color = Colors.white.withValues(alpha: 0.08) + ..strokeWidth = 1.5; + canvas.drawLine(Offset(size.width * 0.22, 0), Offset(size.width * 0.22, size.height), sidePaint); + canvas.drawLine(Offset(size.width * 0.78, 0), Offset(size.width * 0.78, size.height), sidePaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/apps/siro_maps/test/navigation_enhancements_test.dart b/apps/siro_maps/test/navigation_enhancements_test.dart new file mode 100644 index 0000000..c3022f1 --- /dev/null +++ b/apps/siro_maps/test/navigation_enhancements_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:siro_maps/core/services/vehicle_icon_generator.dart'; +import 'package:siro_maps/core/utils/arabic_search_normalizer.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('ArabicSearchNormalizer Tests', () { + test('Normalizes diacritics and tashkeel', () { + const input = 'مَدِينَةُ عَمَّانَ'; + expect(ArabicSearchNormalizer.normalize(input), equals('مدينه عمان')); + }); + + test('Normalizes various forms of Alef', () { + expect(ArabicSearchNormalizer.normalize('أحمد'), equals('احمد')); + expect(ArabicSearchNormalizer.normalize('إبراهيم'), equals('ابراهيم')); + expect(ArabicSearchNormalizer.normalize('آلاء'), equals('الاء')); + expect(ArabicSearchNormalizer.normalize('ٱستقبال'), equals('استقبال')); + }); + + test('Normalizes Teh Marbuta and Alef Maqsura', () { + expect(ArabicSearchNormalizer.normalize('مستشفى'), equals('مستشفي')); + expect(ArabicSearchNormalizer.normalize('جامعة'), equals('جامعه')); + expect(ArabicSearchNormalizer.normalize('صيدلية النهدي'), equals('صيدليه النهدي')); + }); + + test('Matches normalized queries flexibly', () { + expect( + ArabicSearchNormalizer.matches('صيدلية النهدي الكبرى', 'نهدي'), + isTrue, + ); + expect( + ArabicSearchNormalizer.matches('مستشفى الجامعة الأردنية', 'جامعة'), + isTrue, + ); + expect( + ArabicSearchNormalizer.matches('مستشفى الجامعة الأردنية', 'جامعه'), + isTrue, + ); + expect( + ArabicSearchNormalizer.matches('مطعم القدس', 'القدس'), + isTrue, + ); + }); + }); + + group('VehicleIconGenerator Tests', () { + test('Generates valid PNG bytes for modern sedan', () async { + final bytes = await VehicleIconGenerator.generateVehicleIconBytes( + styleId: 'car', + primaryColor: const Color(0xFF007AFF), + ); + expect(bytes, isNotEmpty); + // Valid PNG header starts with 0x89, 'P', 'N', 'G' (0x89, 0x50, 0x4E, 0x47) + expect(bytes[0], equals(0x89)); + expect(bytes[1], equals(0x50)); + expect(bytes[2], equals(0x4E)); + expect(bytes[3], equals(0x47)); + }); + + test('Generates valid PNG bytes for SUV and Arrow styles', () async { + final suvBytes = await VehicleIconGenerator.generateVehicleIconBytes( + styleId: 'suv', + primaryColor: const Color(0xFF00C853), + ); + expect(suvBytes, isNotEmpty); + expect(suvBytes[0], equals(0x89)); + + final arrowBytes = await VehicleIconGenerator.generateVehicleIconBytes( + styleId: 'arrow', + primaryColor: const Color(0xFFFF3B30), + ); + expect(arrowBytes, isNotEmpty); + expect(arrowBytes[0], equals(0x89)); + }); + + test('Exposes predefined vehicle styles and colors', () { + expect(VehicleIconGenerator.availableStyles, isNotEmpty); + expect(VehicleIconGenerator.availableStyles.any((s) => s.id == 'car'), isTrue); + expect(VehicleIconGenerator.availableStyles.any((s) => s.id == 'suv'), isTrue); + expect(VehicleIconGenerator.availableStyles.any((s) => s.id == 'arrow'), isTrue); + + expect(VehicleIconGenerator.availableColors, isNotEmpty); + expect(VehicleIconGenerator.availableColors.any((c) => c.colorValue == 0xFF007AFF), isTrue); + }); + }); +} diff --git a/packages/flutter-sdk/lib/src/intaleq_map_controller.dart b/packages/flutter-sdk/lib/src/intaleq_map_controller.dart index 2afec0f..4afd45f 100644 --- a/packages/flutter-sdk/lib/src/intaleq_map_controller.dart +++ b/packages/flutter-sdk/lib/src/intaleq_map_controller.dart @@ -90,6 +90,7 @@ class IntaleqMapController { // stale handle so the next [setUserMarker] call re-creates it. _userSymbol = null; await _registerDefaultImages(); + await applyMarkerVisibilityDefaults(); // Safely execute camera updates queued before style finished rendering if (_pendingCameraUpdate != null) { @@ -205,6 +206,10 @@ class IntaleqMapController { Future addMarker(Marker marker) async { if (!_isStyleLoaded) return null; try { + if (_symbols.containsKey(marker.markerId)) { + await _updateMarker(marker); + return _symbols[marker.markerId]; + } await _loadBitmapIfNeeded(marker.icon); final symbol = await _raw.addSymbol(marker.toSymbolOptions()); _symbols[marker.markerId] = symbol; @@ -223,7 +228,10 @@ class IntaleqMapController { Future _updateMarker(Marker marker) async { if (!_isStyleLoaded) return; final symbol = _symbols[marker.markerId]; - if (symbol == null) return; + if (symbol == null) { + await addMarker(marker); + return; + } try { await _loadBitmapIfNeeded(marker.icon); await _raw.updateSymbol(symbol, marker.toSymbolOptions()); @@ -243,6 +251,12 @@ class IntaleqMapController { } catch (_) {} } + /// Removes a marker from the map by its [MarkerId]. + Future removeMarker(MarkerId id) => _removeMarker(id); + + /// Removes a polyline from the map by its [PolylineId]. + Future removePolyline(PolylineId id) => _removePolyline(id); + // ── User-location puck (imperative, high-frequency) ──────── /// Places or moves the single "user location" puck. diff --git a/packages/flutter-sdk/lib/src/models/bitmap.dart b/packages/flutter-sdk/lib/src/models/bitmap.dart index 2f7ba3d..650a843 100644 --- a/packages/flutter-sdk/lib/src/models/bitmap.dart +++ b/packages/flutter-sdk/lib/src/models/bitmap.dart @@ -89,6 +89,17 @@ class InlqBitmap { ); } + @override + bool operator ==(Object other) => + identical(this, other) || + other is InlqBitmap && + runtimeType == other.runtimeType && + mapLibreImageId == other.mapLibreImageId && + size == other.size; + + @override + int get hashCode => Object.hash(mapLibreImageId, size); + @override String toString() => 'InlqBitmap($mapLibreImageId)'; }