From 45222d2887c84b229b57cfd8e2561574b8070dd5 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Mon, 6 Apr 2026 22:00:13 +0300 Subject: [PATCH] 2026-04-06 redesign splash screen and drawer menu --- assets/style.json | 112 ++++ .../home/splash_screen_controlle.dart | 34 +- lib/print.dart | 2 +- lib/splash_screen_page.dart | 542 ++++++++++++++---- lib/views/home/map_page_passenger.dart | 30 + .../google_map_passenger_widget.dart | 2 + .../home/map_widget.dart/map_menu_widget.dart | 534 +++++++++++------ .../navigation/navigation_controller.dart | 166 +++++- 8 files changed, 1112 insertions(+), 310 deletions(-) diff --git a/assets/style.json b/assets/style.json index 880fe53..26ceeef 100644 --- a/assets/style.json +++ b/assets/style.json @@ -42,6 +42,20 @@ "https://tiles.intaleqapp.com/places_egypt/{z}/{x}/{y}" ], "maxzoom": 14 + }, + "places_syria": { + "type": "vector", + "tiles": [ + "https://tiles.intaleqapp.com/places_syria/{z}/{x}/{y}" + ], + "maxzoom": 14 + }, + "places_jordan": { + "type": "vector", + "tiles": [ + "https://tiles.intaleqapp.com/places_jordan/{z}/{x}/{y}" + ], + "maxzoom": 14 } }, "layers": [ @@ -1796,6 +1810,104 @@ "text-halo-color": "rgba(255, 255, 255, 0.9)", "text-halo-width": 2 } + }, + { + "id": "places-syria-labels", + "type": "symbol", + "source": "places_syria", + "source-layer": "places_syria", + "minzoom": 10, + "layout": { + "text-field": [ + "coalesce", + [ + "get", + "name_ar" + ], + [ + "get", + "name" + ], + "" + ], + "text-font": [ + "Noto Sans Bold" + ], + "text-size": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 9, + 16, + 13 + ], + "text-offset": [ + 0, + 1.5 + ], + "text-anchor": "top", + "text-padding": 8, + "text-allow-overlap": false + }, + "paint": { + "text-color": "#2D3748", + "text-halo-color": "rgba(255, 255, 255, 0.9)", + "text-halo-width": 2 + } + }, + { + "id": "places-jordan-labels", + "type": "symbol", + "source": "places_jordan", + "source-layer": "places_jordan", + "minzoom": 10, + "layout": { + "text-field": [ + "coalesce", + [ + "get", + "name_ar" + ], + [ + "get", + "name" + ], + "" + ], + "text-font": [ + "Noto Sans Bold" + ], + "text-size": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 9, + 16, + 13 + ], + "text-offset": [ + 0, + 1.5 + ], + "text-anchor": "top", + "text-padding": 8, + "text-allow-overlap": false + }, + "paint": { + "text-color": "#2D3748", + "text-halo-color": "rgba(255, 255, 255, 0.9)", + "text-halo-width": 2 + } } ] } \ No newline at end of file diff --git a/lib/controller/home/splash_screen_controlle.dart b/lib/controller/home/splash_screen_controlle.dart index 4a033cb..7aaf40e 100644 --- a/lib/controller/home/splash_screen_controlle.dart +++ b/lib/controller/home/splash_screen_controlle.dart @@ -18,6 +18,7 @@ import '../functions/encrypt_decrypt.dart'; class SplashScreenController extends GetxController with GetTickerProviderStateMixin { + // ─── انيميشن الـ splash الأصلي ─────────────────────────────────────────── late AnimationController _animationController; late Animation titleFadeAnimation, titleScaleAnimation, @@ -25,38 +26,67 @@ class SplashScreenController extends GetxController footerFadeAnimation; late Animation taglineSlideAnimation; + // ─── انيميشن الحلقات المدارية ──────────────────────────────────────────── + late AnimationController _orbitController; + late Animation orbitAnimation; + + // ─── انيميشن التوهج المتنفّس ───────────────────────────────────────────── + late AnimationController _glowController; + late Animation glowAnimation; + final progress = 0.0.obs; Timer? _progressTimer; @override void onInit() { super.onInit(); + + // ── الكنترولر الرئيسي للـ splash ───────────────────────────────────── _animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 2000)); - // Animation definitions titleFadeAnimation = Tween(begin: 0.0, end: 1.0).animate( CurvedAnimation( parent: _animationController, curve: const Interval(0.0, 0.5, curve: Curves.easeOut))); + titleScaleAnimation = Tween(begin: 0.8, end: 1.0).animate( CurvedAnimation( parent: _animationController, curve: const Interval(0.0, 0.5, curve: Curves.easeOut))); + taglineFadeAnimation = Tween(begin: 0.0, end: 1.0).animate( CurvedAnimation( parent: _animationController, curve: const Interval(0.3, 0.8, curve: Curves.easeOut))); + taglineSlideAnimation = Tween(begin: const Offset(0, 0.5), end: Offset.zero).animate( CurvedAnimation( parent: _animationController, curve: const Interval(0.3, 0.8, curve: Curves.easeOut))); + footerFadeAnimation = Tween(begin: 0.0, end: 1.0).animate( CurvedAnimation( parent: _animationController, curve: const Interval(0.5, 1.0, curve: Curves.easeOut))); + // ── كنترولر الدوران المداري — دورة كاملة كل 7 ثوانٍ ───────────────── + _orbitController = + AnimationController(vsync: this, duration: const Duration(seconds: 7)) + ..repeat(); + + orbitAnimation = + Tween(begin: 0.0, end: 1.0).animate(_orbitController); + + // ── كنترولر التوهج المتنفّس — نبضة كل ثانيتين ─────────────────────── + _glowController = AnimationController( + vsync: this, duration: const Duration(milliseconds: 2000)) + ..repeat(reverse: true); + + glowAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _glowController, curve: Curves.easeInOut)); + _animationController.forward(); _initializeApp(); } @@ -191,6 +221,8 @@ class SplashScreenController extends GetxController void onClose() { _progressTimer?.cancel(); _animationController.dispose(); + _orbitController.dispose(); + _glowController.dispose(); super.onClose(); } } diff --git a/lib/print.dart b/lib/print.dart index a3d59f6..63efb2d 100644 --- a/lib/print.dart +++ b/lib/print.dart @@ -4,7 +4,7 @@ class Log { Log._(); static void print(String value, {StackTrace? stackTrace}) { - developer.log(value, name: 'LOG', stackTrace: stackTrace); + // developer.log(value, name: 'LOG', stackTrace: stackTrace); } static Object? inspect(Object? object) { diff --git a/lib/splash_screen_page.dart b/lib/splash_screen_page.dart index ce2052e..33d2dee 100644 --- a/lib/splash_screen_page.dart +++ b/lib/splash_screen_page.dart @@ -1,167 +1,469 @@ +import 'dart:math'; import 'package:animated_text_kit/animated_text_kit.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:Intaleq/constant/colors.dart'; import 'package:Intaleq/constant/style.dart'; import 'package:Intaleq/constant/box_name.dart'; import 'package:Intaleq/main.dart'; import 'controller/home/splash_screen_controlle.dart'; -// شاشة بداية بتصميم جديد وحركات وألوان محسّنة class SplashScreen extends StatelessWidget { const SplashScreen({super.key}); @override Widget build(BuildContext context) { - // تهيئة الكنترولر final SplashScreenController controller = Get.put(SplashScreenController()); + final size = MediaQuery.of(context).size; - // تعريف الألوان المستخدمة في حركة اسم التطبيق + // ألوان الـ colorize — سيان كهربائي → أبيض → ذهبي عنبري const colorizeColors = [ + Color(0xFF00D4FF), Colors.white, - Color(0xFF89D4CF), // لون تركواز فاتح - Color(0xFF734AE8), // لون بنفسجي مشرق - Colors.white, + Color(0xFFFFB700), + Color(0xFF00D4FF), ]; return SafeArea( child: Scaffold( - body: Container( - // --- تحسين الألوان --- - // تم استخدام تدرج لوني جديد أكثر حيوية وعصرية - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Color(0xFF2E3192), // أزرق داكن - Color(0xFF1BFFFF), // سماوي ساطع - ], + backgroundColor: const Color(0xFF060B18), + body: Stack( + children: [ + // ── طبقة الشبكة الهندسية ────────────────────────────────── + Positioned.fill( + child: CustomPaint(painter: _GridPainter()), ), - ), - child: Stack( - children: [ - // دوائر زخرفية لإضافة عمق للتصميم - _buildDecorativeCircles(), - // المحتوى الرئيسي مع الحركات المتتالية - Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // --- حركة اسم التطبيق --- - // تم إلغاء الشعار واستبداله بحركة نصية ملونة لكلمة "Intaleq" - FadeTransition( - opacity: controller.titleFadeAnimation, - child: ScaleTransition( - scale: controller.titleScaleAnimation, - child: AnimatedTextKit( - animatedTexts: [ - ColorizeAnimatedText( - 'Intaleq', - textStyle: AppStyle.headTitle.copyWith( - fontSize: 65.0, // تكبير حجم الخط - fontWeight: FontWeight.bold, - shadows: [ - const Shadow( - blurRadius: 15.0, - color: Colors.black38, - offset: Offset(0, 3.0), - ), - ], - ), - colors: colorizeColors, - speed: const Duration(milliseconds: 300), - ), - ], - isRepeatingAnimation: false, - ), - ), - ), - const SizedBox(height: 18), - - // --- حركة الشعار النصي --- - FadeTransition( - opacity: controller.taglineFadeAnimation, - child: SlideTransition( - position: controller.taglineSlideAnimation, - child: Text( - 'Your Journey Begins Here'.tr, - style: AppStyle.title.copyWith( - color: AppColor.writeColor.withOpacity(0.9), - fontSize: 18, - ), - ), - ), - ), - ], + // ── توهج سماوي — أعلى اليمين ───────────────────────────── + Positioned( + top: -size.height * 0.18, + right: -size.width * 0.25, + child: Container( + width: size.width * 0.85, + height: size.width * 0.85, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient(colors: [ + const Color(0xFF00D4FF).withOpacity(0.11), + Colors.transparent, + ]), ), ), + ), - // قسم سفلي لشريط التقدم ومعلومات الإصدار - Align( - alignment: Alignment.bottomCenter, - child: FadeTransition( - opacity: controller.footerFadeAnimation, - child: Padding( - padding: const EdgeInsets.only( - bottom: 40.0, left: 40, right: 40), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Obx(() => LinearProgressIndicator( - value: controller.progress.value, - backgroundColor: - AppColor.writeColor.withOpacity(0.2), - valueColor: const AlwaysStoppedAnimation( - AppColor.writeColor), - minHeight: 5, - )), + // ── توهج أزرق غامق — أسفل اليسار ──────────────────────── + Positioned( + bottom: -size.height * 0.12, + left: -size.width * 0.22, + child: Container( + width: size.width * 0.75, + height: size.width * 0.75, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient(colors: [ + const Color(0xFF0052FF).withOpacity(0.09), + Colors.transparent, + ]), + ), + ), + ), + + // ── المحتوى الرئيسي ─────────────────────────────────────── + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // ── حلقات مدارية + اسم التطبيق ─────────────────── + FadeTransition( + opacity: controller.titleFadeAnimation, + child: ScaleTransition( + scale: controller.titleScaleAnimation, + child: SizedBox( + width: 220, + height: 220, + child: Stack( + alignment: Alignment.center, + children: [ + // الحلقة الخارجية — تدور ببطء + AnimatedBuilder( + animation: controller.orbitAnimation, + builder: (_, __) => Transform.rotate( + angle: controller.orbitAnimation.value * 2 * pi, + child: CustomPaint( + painter: _OrbitalRingPainter( + radius: 100, + dotColor: const Color(0xFF00D4FF), + lineOpacity: 0.22, + dotSize: 5.5, + ), + size: const Size(220, 220), + ), + ), + ), + // الحلقة الداخلية — تدور عكسياً + AnimatedBuilder( + animation: controller.orbitAnimation, + builder: (_, __) => Transform.rotate( + angle: -controller.orbitAnimation.value * + 2 * + pi * + 0.65, + child: CustomPaint( + painter: _OrbitalRingPainter( + radius: 73, + dotColor: const Color(0xFFFFB700), + lineOpacity: 0.14, + dotSize: 4, + dashCount: 20, + ), + size: const Size(220, 220), + ), + ), + ), + // النقطة المركزية المضيئة + AnimatedBuilder( + animation: controller.glowAnimation, + builder: (_, __) => Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFF00D4FF), + boxShadow: [ + BoxShadow( + color: const Color(0xFF00D4FF) + .withOpacity(0.25 + + controller.glowAnimation.value * + 0.35), + blurRadius: 20 + + controller.glowAnimation.value * 20, + spreadRadius: 4, + ), + ], + ), + ), + ), + // ── اسم "Intaleq" مع توهج متنفّس ───────── + AnimatedBuilder( + animation: controller.glowAnimation, + builder: (_, child) => Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: const Color(0xFF00D4FF) + .withOpacity(0.08 + + controller.glowAnimation.value * + 0.10), + blurRadius: 40 + + controller.glowAnimation.value * 25, + spreadRadius: 0, + ), + ], + ), + child: child, + ), + child: AnimatedTextKit( + animatedTexts: [ + ColorizeAnimatedText( + 'Intaleq', + textStyle: const TextStyle( + fontSize: 38, + fontWeight: FontWeight.w800, + letterSpacing: 3, + height: 1, + ), + colors: colorizeColors, + speed: const Duration(milliseconds: 380), + ), + ], + isRepeatingAnimation: false, + ), + ), + ], ), - const SizedBox(height: 20), - Text( - 'Version: ${box.read(BoxName.packagInfo) ?? '1.0.0'}', - style: AppStyle.subtitle.copyWith( - color: AppColor.writeColor.withOpacity(0.7), - fontWeight: FontWeight.w600, - ), - ), - ], + ), ), ), + + const SizedBox(height: 28), + + // ── شريحة "AI-Powered" + الشعار النصي ─────────────── + FadeTransition( + opacity: controller.taglineFadeAnimation, + child: SlideTransition( + position: controller.taglineSlideAnimation, + child: Column( + children: [ + // شريحة الذكاء الاصطناعي + Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 5), + decoration: BoxDecoration( + border: Border.all( + color: + const Color(0xFF00D4FF).withOpacity(0.35), + width: 1, + ), + borderRadius: BorderRadius.circular(20), + color: const Color(0xFF00D4FF).withOpacity(0.06), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // نقطة نبضية + AnimatedBuilder( + animation: controller.glowAnimation, + builder: (_, __) => Container( + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFF00D4FF) + .withOpacity(0.5 + + controller.glowAnimation.value * + 0.5), + boxShadow: [ + BoxShadow( + color: const Color(0xFF00D4FF) + .withOpacity(controller + .glowAnimation.value * + 0.6), + blurRadius: 6, + spreadRadius: 1, + ), + ], + ), + ), + ), + const SizedBox(width: 8), + Text( + 'AI-Powered Mobility', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: const Color(0xFF00D4FF) + .withOpacity(0.85), + letterSpacing: 1.4, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + // الشعار النصي + Text( + 'Your Journey Begins Here'.tr, + style: AppStyle.title.copyWith( + color: const Color(0xFF7A8FA8), + fontSize: 14, + letterSpacing: 0.6, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + ), + ], + ), + ), + + // ── القسم السفلي: شريط التقدم + الإصدار ───────────────── + Align( + alignment: Alignment.bottomCenter, + child: FadeTransition( + opacity: controller.footerFadeAnimation, + child: Padding( + padding: + const EdgeInsets.only(bottom: 44, left: 36, right: 36), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // شريط تقدم رفيع مضيء + Obx(() => _GlowProgressBar( + value: controller.progress.value, + )), + const SizedBox(height: 18), + // معلومات الإصدار + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'INTALEQ', + style: TextStyle( + fontSize: 9.5, + fontWeight: FontWeight.w700, + color: Colors.white.withOpacity(0.18), + letterSpacing: 3.5, + ), + ), + Text( + 'v${box.read(BoxName.packagInfo) ?? '1.0.0'}', + style: TextStyle( + fontSize: 9.5, + fontWeight: FontWeight.w500, + color: Colors.white.withOpacity(0.18), + letterSpacing: 1, + ), + ), + ], + ), + ], + ), ), ), - ], - ), + ), + ], ), ), ); } +} - /// بناء دوائر زخرفية لتحسين الخلفية - Widget _buildDecorativeCircles() { +// ── شريط التقدم المضيء ───────────────────────────────────────────────────── +class _GlowProgressBar extends StatelessWidget { + final double value; + const _GlowProgressBar({required this.value}); + + @override + Widget build(BuildContext context) { return Stack( children: [ - Positioned( - top: -80, - left: -100, - child: CircleAvatar( - radius: 120, - backgroundColor: Colors.white.withOpacity(0.05), + // المسار + Container( + height: 2, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.07), + borderRadius: BorderRadius.circular(2), ), ), - Positioned( - bottom: -120, - right: -150, - child: CircleAvatar( - radius: 180, - backgroundColor: Colors.white.withOpacity(0.07), + // الملء المضيء + FractionallySizedBox( + widthFactor: value.clamp(0.0, 1.0), + child: Container( + height: 2, + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [ + Color(0xFF0052FF), + Color(0xFF00D4FF), + ]), + borderRadius: BorderRadius.circular(2), + boxShadow: [ + BoxShadow( + color: const Color(0xFF00D4FF).withOpacity(0.55), + blurRadius: 8, + spreadRadius: 1, + ), + ], + ), ), ), ], ); } } + +// ── رسّام الشبكة الهندسية ───────────────────────────────────────────────── +class _GridPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final linePaint = Paint() + ..color = const Color(0xFF00D4FF).withOpacity(0.04) + ..strokeWidth = 0.5; + + const spacing = 36.0; + + for (double y = 0; y < size.height; y += spacing) { + canvas.drawLine(Offset(0, y), Offset(size.width, y), linePaint); + } + for (double x = 0; x < size.width; x += spacing) { + canvas.drawLine(Offset(x, 0), Offset(x, size.height), linePaint); + } + + // نقاط التقاطع + final dotPaint = Paint() + ..color = const Color(0xFF00D4FF).withOpacity(0.07) + ..style = PaintingStyle.fill; + + for (double y = 0; y < size.height; y += spacing) { + for (double x = 0; x < size.width; x += spacing) { + canvas.drawCircle(Offset(x, y), 0.9, dotPaint); + } + } + } + + @override + bool shouldRepaint(_GridPainter old) => false; +} + +// ── رسّام الحلقة المدارية ───────────────────────────────────────────────── +class _OrbitalRingPainter extends CustomPainter { + final double radius; + final Color dotColor; + final double lineOpacity; + final double dotSize; + final int dashCount; + + const _OrbitalRingPainter({ + this.radius = 95, + this.dotColor = const Color(0xFF00D4FF), + this.lineOpacity = 0.25, + this.dotSize = 5.5, + this.dashCount = 0, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + + if (dashCount > 0) { + // حلقة متقطعة (dashed) + final dashPaint = Paint() + ..color = const Color(0xFF00D4FF).withOpacity(lineOpacity) + ..strokeWidth = 1 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + + const dashAngle = 2 * pi / 40; + final gapRatio = 0.45; + + for (int i = 0; i < dashCount; i++) { + final startAngle = i * (2 * pi / dashCount); + final sweepAngle = (2 * pi / dashCount) * (1 - gapRatio); + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + startAngle, + sweepAngle, + false, + dashPaint, + ); + } + } else { + // حلقة متصلة + final ringPaint = Paint() + ..color = const Color(0xFF00D4FF).withOpacity(lineOpacity) + ..strokeWidth = 1 + ..style = PaintingStyle.stroke; + canvas.drawCircle(center, radius, ringPaint); + } + + // النقطة البراقة — دائماً في القمة (before rotation) + final dotPos = Offset(center.dx, center.dy - radius); + + // توهج خلف النقطة + final glowPaint = Paint() + ..color = dotColor.withOpacity(0.35) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 9); + canvas.drawCircle(dotPos, dotSize + 2, glowPaint); + + // النقطة نفسها + final dotPaint = Paint() + ..color = dotColor + ..style = PaintingStyle.fill; + canvas.drawCircle(dotPos, dotSize, dotPaint); + } + + @override + bool shouldRepaint(_OrbitalRingPainter old) => false; +} diff --git a/lib/views/home/map_page_passenger.dart b/lib/views/home/map_page_passenger.dart index 9cbb725..142771e 100644 --- a/lib/views/home/map_page_passenger.dart +++ b/lib/views/home/map_page_passenger.dart @@ -63,6 +63,7 @@ class MapPagePassenger extends StatelessWidget { CashConfirmPageShown(), const PaymentMethodPage(), const SearchingCaptainWindow(), + AttributionMap(), // timerForCancelTripFromPassenger(), // const DriverTimeArrivePassengerPage(), // const TimerToPassengerFromDriver(), @@ -81,6 +82,35 @@ class MapPagePassenger extends StatelessWidget { } } +class AttributionMap extends StatelessWidget { + const AttributionMap({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Positioned( + left: 4, + bottom: 20, + child: RotatedBox( + quarterTurns: 0, // يخلي النص عمودي (من تحت لفوق) + child: Opacity( + opacity: 0.7, + child: Text( + "Intaleq Maps", + // "Intaleq Maps • © OpenStreetMap contributors", + style: TextStyle( + fontSize: 10, + color: Colors.grey[700], + fontWeight: FontWeight.w400, + ), + ), + ), + ), + ); + } +} + class CancelRidePageShow extends StatelessWidget { const CancelRidePageShow({ super.key, diff --git a/lib/views/home/map_widget.dart/google_map_passenger_widget.dart b/lib/views/home/map_widget.dart/google_map_passenger_widget.dart index 2296d43..bebf7b5 100644 --- a/lib/views/home/map_widget.dart/google_map_passenger_widget.dart +++ b/lib/views/home/map_widget.dart/google_map_passenger_widget.dart @@ -26,6 +26,8 @@ class GoogleMapPassengerWidget extends StatelessWidget { left: 0, right: 0, child: MapLibreMap( + attributionButtonPosition: AttributionButtonPosition.bottomLeft, + attributionButtonMargins: null, onMapCreated: controller.onMapCreated, onStyleLoadedCallback: () => controller.onStyleLoaded(), styleString: "assets/style.json", diff --git a/lib/views/home/map_widget.dart/map_menu_widget.dart b/lib/views/home/map_widget.dart/map_menu_widget.dart index c4fc4c1..5684493 100644 --- a/lib/views/home/map_widget.dart/map_menu_widget.dart +++ b/lib/views/home/map_widget.dart/map_menu_widget.dart @@ -1,4 +1,4 @@ -import 'dart:ui'; // مهم لإضافة تأثير الضبابية +import 'dart:ui'; import 'package:Intaleq/constant/box_name.dart'; import 'package:Intaleq/main.dart'; @@ -22,7 +22,15 @@ import '../HomePage/share_app_page.dart'; import '../setting_page.dart'; import '../profile/passenger_profile_page.dart'; -// --- الويدجت الرئيسية بالتصميم الجديد --- +// ─── ألوان النظام ─────────────────────────────────────────────────────────── +const _kBg = Color(0xFF060B18); +const _kBgSurface = Color(0xFF0D1525); +const _kCyan = Color(0xFF00D4FF); +const _kAmber = Color(0xFFFFB700); +const _kBorder = Color(0x1A00D4FF); +const _kText = Colors.white; +const _kTextMuted = Color(0xFF7A8FA8); + class MapMenuWidget extends StatelessWidget { const MapMenuWidget({super.key}); @@ -33,51 +41,58 @@ class MapMenuWidget extends StatelessWidget { return GetBuilder( builder: (controller) => Stack( children: [ - // --- خلفية معتمة عند فتح القائمة --- + // ── تعتيم الخلفية ─────────────────────────────────────────────── if (controller.widthMenu > 0) GestureDetector( onTap: controller.getDrawerMenu, - child: Container( - color: Colors.black.withOpacity(0.4), - ), + child: Container(color: Colors.black.withOpacity(0.55)), ), - // --- القائمة الجانبية المنزلقة --- _buildSideMenu(controller), - - // --- زر القائمة العائم --- _buildMenuButton(controller), ], ), ); } - // --- ويدجت لبناء زر القائمة --- + // ── زر القائمة العائم ──────────────────────────────────────────────────── Widget _buildMenuButton(MapPassengerController controller) { return Positioned( top: 45, left: 16, child: SafeArea( - child: InkWell( + child: GestureDetector( onTap: controller.getDrawerMenu, - borderRadius: BorderRadius.circular(50), child: ClipRRect( - borderRadius: BorderRadius.circular(50), + borderRadius: BorderRadius.circular(16), child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 5.0, sigmaY: 5.0), + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), child: AnimatedContainer( duration: const Duration(milliseconds: 300), - padding: const EdgeInsets.all(12), + curve: Curves.easeOut, + width: 48, + height: 48, decoration: BoxDecoration( - color: AppColor.secondaryColor.withOpacity(0.8), - shape: BoxShape.circle, - border: - Border.all(color: AppColor.writeColor.withOpacity(0.2)), + color: _kBg.withOpacity(0.88), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _kCyan.withOpacity(0.25), width: 1), + boxShadow: [ + BoxShadow( + color: _kCyan.withOpacity(0.12), + blurRadius: 16, + ), + ], ), - child: Icon( - controller.widthMenu > 0 ? Icons.close : Icons.menu, - color: AppColor.writeColor, - size: 26, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: Icon( + controller.widthMenu > 0 + ? Icons.close_rounded + : Icons.menu_rounded, + key: ValueKey(controller.widthMenu > 0), + color: _kCyan, + size: 22, + ), ), ), ), @@ -87,109 +102,125 @@ class MapMenuWidget extends StatelessWidget { ); } - // --- ويدجت لبناء القائمة الجانبية --- + // ── القائمة الجانبية ───────────────────────────────────────────────────── Widget _buildSideMenu(MapPassengerController controller) { return AnimatedPositioned( - duration: const Duration(milliseconds: 400), + duration: const Duration(milliseconds: 420), curve: Curves.fastOutSlowIn, top: 0, bottom: 0, left: controller.widthMenu > 0 ? 0 : -Get.width, - child: ClipRRect( + child: ClipRect( child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), child: Container( width: Get.width * 0.8, constraints: const BoxConstraints(maxWidth: 320), decoration: BoxDecoration( - color: AppColor.secondaryColor.withOpacity(0.95), + color: _kBg.withOpacity(0.97), + border: Border( + right: BorderSide(color: _kCyan.withOpacity(0.12), width: 1), + ), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.2), - blurRadius: 20, - ) + color: Colors.black.withOpacity(0.5), + blurRadius: 32, + ), ], ), - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildMenuHeader(), - _buildQuickActionButtons(), - const Divider( - color: Colors.white24, - indent: 16, - endIndent: 16, - height: 24), - Expanded( - child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 8), - children: [ - MenuListItem( - title: 'My Balance'.tr, - icon: Icons.account_balance_wallet_outlined, - onTap: () => Get.to(() => const PassengerWallet())), - MenuListItem( - title: 'Order History'.tr, - icon: Icons.history_rounded, - onTap: () => Get.to(() => const OrderHistory())), - MenuListItem( - title: 'Promos'.tr, - icon: Icons.local_offer_outlined, - onTap: () => - Get.to(() => const PromosPassengerPage())), - MenuListItem( - title: 'Contact Us'.tr, - icon: Icons.contact_support_outlined, - onTap: () => Get.to(() => ContactUsPage())), - MenuListItem( - title: 'Complaint'.tr, - icon: Icons.flag_outlined, - onTap: () => Get.to(() => ComplaintPage())), - MenuListItem( - title: 'Driver'.tr, - icon: Ionicons.car_sport_outline, - onTap: () => _launchDriverAppUrl()), - MenuListItem( - title: 'Share App'.tr, - icon: Icons.share_outlined, - onTap: () => Get.to(() => ShareAppPage())), - MenuListItem( - title: 'Privacy Policy'.tr, - icon: Icons.shield_outlined, - onTap: () => launchUrl(Uri.parse( - '${AppLink.server}/privacy_policy.php')), + child: Stack( + children: [ + // شبكة خلفية + Positioned.fill( + child: CustomPaint(painter: _MenuGridPainter())), + + // المحتوى + SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildMenuHeader(), + _buildQuickActionButtons(), + _buildDivider(), + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + children: [ + MenuListItem( + title: 'My Balance'.tr, + icon: Icons.account_balance_wallet_outlined, + onTap: () => + Get.to(() => const PassengerWallet()), + ), + MenuListItem( + title: 'Order History'.tr, + icon: Icons.history_rounded, + onTap: () => Get.to(() => const OrderHistory()), + ), + MenuListItem( + title: 'Promos'.tr, + icon: Icons.local_offer_outlined, + onTap: () => + Get.to(() => const PromosPassengerPage()), + ), + MenuListItem( + title: 'Contact Us'.tr, + icon: Icons.contact_support_outlined, + onTap: () => Get.to(() => ContactUsPage()), + ), + MenuListItem( + title: 'Complaint'.tr, + icon: Icons.flag_outlined, + onTap: () => Get.to(() => ComplaintPage()), + ), + MenuListItem( + title: 'Driver'.tr, + icon: Ionicons.car_sport_outline, + onTap: () => _launchDriverAppUrl(), + ), + MenuListItem( + title: 'Share App'.tr, + icon: Icons.share_outlined, + onTap: () => Get.to(() => ShareAppPage()), + ), + MenuListItem( + title: 'Privacy Policy'.tr, + icon: Icons.shield_outlined, + onTap: () => launchUrl( + Uri.parse( + '${AppLink.server}/privacy_policy.php'), + ), + ), + ], ), - ], - ), - ), - const Divider( - color: Colors.white24, - indent: 16, - endIndent: 16, - height: 1), - Padding( - padding: const EdgeInsets.all(8.0), - child: MenuListItem( - title: 'Logout'.tr, - icon: Icons.logout_rounded, - onTap: () { - Get.defaultDialog( - title: "Logout".tr, - middleText: "Are you sure you want to logout?".tr, - textConfirm: "Logout".tr, - textCancel: "Cancel".tr, - onConfirm: () { - // controller.logout(); - Get.back(); + ), + _buildDivider(), + // زر الخروج + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 12), + child: MenuListItem( + title: 'Logout'.tr, + icon: Icons.logout_rounded, + isDestructive: true, + onTap: () { + Get.defaultDialog( + title: "Logout".tr, + middleText: "Are you sure you want to logout?".tr, + textConfirm: "Logout".tr, + textCancel: "Cancel".tr, + onConfirm: () { + // controller.logout(); + Get.back(); + }, + ); }, - ); - }, - color: Colors.red.shade300, - ), + ), + ), + ], ), - ], - ), + ), + ], ), ), ), @@ -197,33 +228,96 @@ class MapMenuWidget extends StatelessWidget { ); } - // --- ويدجت رأس القائمة --- + // ── رأس القائمة ────────────────────────────────────────────────────────── Widget _buildMenuHeader() { - return Padding( - padding: const EdgeInsets.fromLTRB(20, 30, 20, 16), + return Container( + margin: const EdgeInsets.fromLTRB(16, 24, 16, 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _kBgSurface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _kCyan.withOpacity(0.15), width: 1), + ), child: Row( children: [ - const CircleAvatar( - radius: 30, - backgroundColor: AppColor.primaryColor, - child: Icon(Icons.person, color: AppColor.writeColor, size: 35), + // أفاتار المستخدم + Stack( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + _kCyan.withOpacity(0.2), + _kAmber.withOpacity(0.12), + ], + ), + border: + Border.all(color: _kCyan.withOpacity(0.35), width: 1.5), + ), + child: + const Icon(Icons.person_rounded, color: _kCyan, size: 28), + ), + // نقطة الحضور + Positioned( + bottom: 1, + right: 1, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: const Color(0xFF00E676), + shape: BoxShape.circle, + border: Border.all(color: _kBg, width: 2), + boxShadow: [ + BoxShadow( + color: const Color(0xFF00E676).withOpacity(0.5), + blurRadius: 6, + ), + ], + ), + ), + ), + ], ), - const SizedBox(width: 16), + const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( box.read(BoxName.name) ?? 'Guest', - style: AppStyle.headTitle.copyWith(fontSize: 20), + style: const TextStyle( + color: _kText, + fontSize: 17, + fontWeight: FontWeight.w700, + letterSpacing: 0.3, + ), overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 4), - Text( - "Intaleq Passenger".tr, - style: AppStyle.title.copyWith( - color: AppColor.writeColor.withOpacity(0.7), - fontSize: 14), + const SizedBox(height: 5), + Row( + children: [ + Container( + width: 5, + height: 5, + decoration: const BoxDecoration( + color: _kCyan, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Text( + "Intaleq Passenger".tr, + style: const TextStyle( + color: _kTextMuted, + fontSize: 12, + letterSpacing: 0.4, + ), + ), + ], ), ], ), @@ -233,47 +327,44 @@ class MapMenuWidget extends StatelessWidget { ); } - // --- ويدجت الأزرار السريعة --- + // ── أزرار الإجراءات السريعة ─────────────────────────────────────────────── Widget _buildQuickActionButtons() { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - _buildSmallActionButton( - icon: Icons.person_outline_rounded, - label: 'Profile'.tr, - onTap: () => Get.to(() => PassengerProfilePage())), - _buildSmallActionButton( - icon: Icons.notifications_none_rounded, - label: 'Alerts'.tr, - onTap: () => Get.to(() => const NotificationPage())), - _buildSmallActionButton( - icon: Icons.settings_outlined, - label: 'Settings'.tr, - onTap: () => Get.to(() => const SettingPage())), + _QuickBtn( + icon: Icons.person_outline_rounded, + label: 'Profile'.tr, + onTap: () => Get.to(() => PassengerProfilePage()), + ), + const SizedBox(width: 8), + _QuickBtn( + icon: Icons.notifications_none_rounded, + label: 'Alerts'.tr, + onTap: () => Get.to(() => const NotificationPage()), + ), + const SizedBox(width: 8), + _QuickBtn( + icon: Icons.settings_outlined, + label: 'Settings'.tr, + onTap: () => Get.to(() => const SettingPage()), + ), ], ), ); } - Widget _buildSmallActionButton( - {required IconData icon, - required String label, - required VoidCallback onTap}) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 12.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: AppColor.writeColor.withOpacity(0.9), size: 24), - const SizedBox(height: 6), - Text(label, - style: AppStyle.subtitle.copyWith( - fontSize: 12, color: AppColor.writeColor.withOpacity(0.9))), + Widget _buildDivider() { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + height: 1, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.transparent, + _kCyan.withOpacity(0.15), + Colors.transparent, ], ), ), @@ -304,7 +395,52 @@ class MapMenuWidget extends StatelessWidget { } } -// --- ويدجت عناصر القائمة بتصميم محسن --- +// ── زر الإجراء السريع ──────────────────────────────────────────────────────── +class _QuickBtn extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + + const _QuickBtn({ + required this.icon, + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: _kBgSurface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _kCyan.withOpacity(0.12), width: 1), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: _kCyan, size: 22), + const SizedBox(height: 6), + Text( + label, + style: const TextStyle( + color: _kTextMuted, + fontSize: 11, + letterSpacing: 0.4, + ), + ), + ], + ), + ), + ), + ); + } +} + +// ── عنصر القائمة ───────────────────────────────────────────────────────────── class MenuListItem extends StatelessWidget { const MenuListItem({ super.key, @@ -312,33 +448,87 @@ class MenuListItem extends StatelessWidget { required this.onTap, required this.icon, this.color, + this.isDestructive = false, }); final String title; final IconData icon; final VoidCallback onTap; final Color? color; + final bool isDestructive; @override Widget build(BuildContext context) { - return ListTile( - onTap: onTap, - leading: Icon( - icon, - size: 26, - color: color ?? AppColor.writeColor.withOpacity(0.8), - ), - title: Text( - title.tr, - style: AppStyle.title.copyWith( - fontSize: 16, - color: color ?? AppColor.writeColor, + final iconColor = isDestructive + ? const Color(0xFFFF5252) + : (color ?? _kCyan.withOpacity(0.80)); + final textColor = + isDestructive ? const Color(0xFFFF5252) : (color ?? _kText); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + splashColor: _kCyan.withOpacity(0.07), + highlightColor: _kCyan.withOpacity(0.04), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + // أيقونة بخلفية دقيقة + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: isDestructive + ? const Color(0xFFFF5252).withOpacity(0.08) + : _kCyan.withOpacity(0.07), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 19, color: iconColor), + ), + const SizedBox(width: 14), + Expanded( + child: Text( + title.tr, + style: TextStyle( + color: textColor, + fontSize: 15, + fontWeight: FontWeight.w500, + letterSpacing: 0.2, + ), + ), + ), + Icon( + Icons.chevron_right_rounded, + color: _kTextMuted.withOpacity(0.4), + size: 18, + ), + ], + ), ), ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - splashColor: AppColor.primaryColor.withOpacity(0.2), ); } } + +// ── رسّام الشبكة ────────────────────────────────────────────────────────────── +class _MenuGridPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = const Color(0xFF00D4FF).withOpacity(0.025) + ..strokeWidth = 0.5; + const spacing = 36.0; + for (double y = 0; y < size.height; y += spacing) { + canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); + } + for (double x = 0; x < size.width; x += spacing) { + canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint); + } + } + + @override + bool shouldRepaint(_MenuGridPainter old) => false; +} diff --git a/lib/views/home/navigation/navigation_controller.dart b/lib/views/home/navigation/navigation_controller.dart index 3556411..feee5c5 100644 --- a/lib/views/home/navigation/navigation_controller.dart +++ b/lib/views/home/navigation/navigation_controller.dart @@ -34,6 +34,12 @@ class NavigationController extends GetxController { /// Minimum metres the device must move between general location ticks. static const double _minMoveToProcess = 2.0; + /// Metres off-route before the auto-recalculate countdown starts. + static const double _offRouteThresholdM = 25.0; + + /// Seconds the user must remain off-route before auto-recalculate fires. + static const int _offRouteTriggerSeconds = 6; + // ========================================================================== // ── Map state ───────────────────────────────────────────────────────────── // ========================================================================== @@ -46,6 +52,11 @@ class NavigationController extends GetxController { LatLng? myLocation; double heading = 0.0; + + /// Smoothed heading used for the car icon and camera bearing. + /// Updated every tick via angle-aware lerp to eliminate snap/jitter. + double _smoothedHeading = 0.0; + double currentSpeed = 0.0; // km/h double totalDistance = 0.0; // metres accumulated this session @@ -85,6 +96,19 @@ class NavigationController extends GetxController { // Camera bool isNavigating = false; bool _cameraLockedToUser = true; + bool _mapReady = false; // true only after layout has settled + + // ========================================================================== + // ── Off-route auto-recalculate ──────────────────────────────────────────── + // ========================================================================== + + /// Wall-clock time when the user first went more than [_offRouteThresholdM] + /// metres away from the nearest route point. Null means on-route. + DateTime? _offRouteStartTime; + + /// True while an auto-recalculate triggered from off-route detection is in + /// progress — prevents a second trigger from firing. + bool _autoRecalcInProgress = false; // ========================================================================== // ── Batch location tracking ─────────────────────────────────────────────── @@ -167,14 +191,22 @@ class NavigationController extends GetxController { isStyleLoaded = true; await _loadCustomIcons(); - if (myLocation != null) { - animateCameraToPosition(myLocation!); - _updateCarMarker(); - } - - if (_fullRouteCoordinates.isNotEmpty) { - _updatePolylinesSets([], _fullRouteCoordinates); - } + // Wait one full frame for the native MapLibre view to finish layout. + // Without this, ANY animateCamera call throws std::domain_error on iOS + // because the view still has zero pixel dimensions at this point. + WidgetsBinding.instance.addPostFrameCallback((_) async { + await Future.delayed(const Duration(milliseconds: 300)); + if (!_mapReady) { + _mapReady = true; + if (myLocation != null) { + animateCameraToPosition(myLocation!); + _updateCarMarker(); + } + if (_fullRouteCoordinates.isNotEmpty) { + _updatePolylinesSets([], _fullRouteCoordinates); + } + } + }); } Future onMapLongPressed(Point point, LatLng tappedPoint) async { @@ -217,6 +249,7 @@ class NavigationController extends GetxController { final position = await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.high); myLocation = LatLng(position.latitude, position.longitude); + _smoothedHeading = position.heading; // seed so first lerp is instant update(); if (isStyleLoaded) animateCameraToPosition(myLocation!); _startLocationTimer(); @@ -264,6 +297,13 @@ class NavigationController extends GetxController { myLocation = newLoc; _lastProcessedLocation = newLoc; heading = position.heading; + + // ── Smooth the heading with an angle-aware exponential lerp ────────── + // Factor 0.25 means ~75 % of the old angle is kept each tick, giving a + // ~4-tick (≈4 s) settling time — smooth enough to look fluid on screen + // while still reacting quickly to real turns. + _smoothedHeading = _lerpAngle(_smoothedHeading, heading, 0.25); + currentSpeed = position.speed * 3.6; if (isStyleLoaded) _updateCarMarker(); @@ -271,17 +311,86 @@ class NavigationController extends GetxController { if (_fullRouteCoordinates.isNotEmpty) { if (_cameraLockedToUser) { animateCameraToPosition(myLocation!, - bearing: heading, zoom: _targetZoom, tilt: _targetTilt); + bearing: _smoothedHeading, zoom: _targetZoom, tilt: _targetTilt); } _updateTraveledPolylineSmart(myLocation!); _checkNavigationStep(myLocation!); _recomputeETA(); + + // ── Off-route auto-recalculate ───────────────────────────────────── + _checkOffRoute(myLocation!); } update(); } catch (_) {} } + // ========================================================================== + // ── Heading utilities ───────────────────────────────────────────────────── + // ========================================================================== + + /// Lerps from [from] to [to] by factor [t], correctly handling the 0/360 + /// wrap-around so we never spin the wrong way (e.g. 350° → 10° goes +20°, + /// not −340°). + 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) % 360.0; + } + + // ========================================================================== + // ── Off-route detection ─────────────────────────────────────────────────── + // ========================================================================== + + /// Called every tick while navigating. Measures the distance from [pos] to + /// the nearest upcoming route coordinate. If the driver stays more than + /// [_offRouteThresholdM] metres away for at least [_offRouteTriggerSeconds] + /// seconds, an automatic route recalculation is triggered. + void _checkOffRoute(LatLng pos) { + if (_autoRecalcInProgress || isLoading) return; + if (_fullRouteCoordinates.isEmpty) return; + + // Search a window ahead of the last tracked index for the nearest point. + const int searchWindow = 80; + final int start = _lastTraveledIndexInFullRoute; + final int end = min(start + searchWindow, _fullRouteCoordinates.length); + + double minDist = double.infinity; + for (int i = start; i < end; i++) { + final d = Geolocator.distanceBetween( + pos.latitude, + pos.longitude, + _fullRouteCoordinates[i].latitude, + _fullRouteCoordinates[i].longitude, + ); + if (d < minDist) minDist = d; + } + + if (minDist > _offRouteThresholdM) { + // Driver is off the route. + if (_offRouteStartTime == null) { + _offRouteStartTime = DateTime.now(); + Log.print('⚠️ Off-route detected (${minDist.toStringAsFixed(0)} m). ' + 'Countdown started.'); + } else { + final elapsed = + DateTime.now().difference(_offRouteStartTime!).inSeconds; + if (elapsed >= _offRouteTriggerSeconds) { + Log.print('🔄 Auto-recalculate triggered after ${elapsed}s ' + 'off-route (${minDist.toStringAsFixed(0)} m).'); + _offRouteStartTime = null; + _autoRecalcInProgress = true; + recalculateRoute().then((_) => _autoRecalcInProgress = false); + } + } + } else { + // Back on (or close enough to) the route — reset the clock. + if (_offRouteStartTime != null) { + Log.print('✅ Back on route — off-route timer reset.'); + } + _offRouteStartTime = null; + } + } + // ========================================================================== // ── Batch tracking: record every 3 s, upload every 2 min ───────────────── // ========================================================================== @@ -389,12 +498,15 @@ class NavigationController extends GetxController { geometry: myLocation, iconImage: 'car_icon', iconSize: 1.0, - iconRotate: heading, + iconRotate: _smoothedHeading, // ← use smoothed heading )); } else { mapController!.updateSymbol( carSymbol!, - SymbolOptions(geometry: myLocation, iconRotate: heading), + SymbolOptions( + geometry: myLocation, + iconRotate: _smoothedHeading, // ← use smoothed heading + ), ); } } @@ -405,7 +517,10 @@ class NavigationController extends GetxController { void animateCameraToPosition(LatLng position, {double? zoom, double bearing = 0.0, double tilt = 0.0}) { - mapController?.animateCamera( + // Guard: skip if the native view is not ready yet + if (!_mapReady || mapController == null) return; + + mapController!.animateCamera( CameraUpdate.newCameraPosition( CameraPosition( target: position, @@ -426,7 +541,9 @@ class NavigationController extends GetxController { _cameraLockedToUser = true; if (myLocation != null) { animateCameraToPosition(myLocation!, - bearing: heading, zoom: _targetZoom, tilt: _targetTilt); + bearing: _smoothedHeading, // ← use smoothed heading + zoom: _targetZoom, + tilt: _targetTilt); } update(); } @@ -561,6 +678,9 @@ class NavigationController extends GetxController { isNavigating = true; _cameraLockedToUser = true; + // Reset off-route state after a successful recalculation + _offRouteStartTime = null; + if (routeSteps.isNotEmpty) { currentInstruction = routeSteps[0]['instruction_text']; nextInstruction = routeSteps.length > 1 @@ -569,10 +689,20 @@ class NavigationController extends GetxController { Get.find().speakText(currentInstruction); } - if (_fullRouteCoordinates.isNotEmpty) { + if (_fullRouteCoordinates.length >= 2) { final bounds = _boundsFromLatLngList(_fullRouteCoordinates); - mapController?.animateCamera(CameraUpdate.newLatLngBounds(bounds, - bottom: 220, top: 150, left: 50, right: 50)); + + final latDiff = + (bounds.northeast.latitude - bounds.southwest.latitude).abs(); + final lngDiff = + (bounds.northeast.longitude - bounds.southwest.longitude).abs(); + + if (latDiff > 0.0001 || lngDiff > 0.0001) { + mapController?.animateCamera(CameraUpdate.newLatLngBounds(bounds, + bottom: 220, top: 150, left: 50, right: 50)); + } else { + animateCameraToPosition(_fullRouteCoordinates.first, zoom: 15.0); + } } update(); @@ -638,6 +768,10 @@ class NavigationController extends GetxController { } Future clearRoute({bool isNewRoute = false}) async { + // Reset off-route state whenever the route is cleared + _offRouteStartTime = null; + _autoRecalcInProgress = false; + if (!isNewRoute) { if (destinationSymbol != null && mapController != null) { await mapController!.removeSymbol(destinationSymbol!);