From 4d5800ff9bbfa7ac34b4ac4b2e429e8b355bab1a Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sun, 5 Apr 2026 02:50:22 +0300 Subject: [PATCH] 2026-04-05-maplibra succsess for all and add navigation paage --- ios/Podfile.lock | 17 + .../home/map_passenger_controller.dart | 91 +- .../map_widget.dart/left_main_menu_icons.dart | 79 +- .../navigation/navigation_controller.dart | 632 +++++++++ .../home/navigation/navigation_view.dart | 368 +++++ lib/views/home/profile/order_history.dart | 685 +++++++--- .../home/profile/passenger_profile_page.dart | 1180 +++++++++++++---- lib/views/widgets/elevated_btn.dart | 186 ++- lib/views/widgets/error_snakbar.dart | 769 ++++------- .../widgets/my_circular_indicator_timer.dart | 149 ++- lib/views/widgets/mydialoug.dart | 662 ++++++--- 11 files changed, 3512 insertions(+), 1306 deletions(-) create mode 100644 lib/views/home/navigation/navigation_controller.dart create mode 100644 lib/views/home/navigation/navigation_view.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 5063dee..4e647ef 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -84,6 +84,12 @@ PODS: - geolocator_apple (1.2.0): - Flutter - FlutterMacOS + - Google-Maps-iOS-Utils (6.1.3): + - GoogleMaps (~> 10.0) + - google_maps_flutter_ios (0.0.1): + - Flutter + - Google-Maps-iOS-Utils (< 7.0, >= 5.0) + - GoogleMaps (< 11.0, >= 8.4) - google_sign_in_ios (0.0.1): - Flutter - FlutterMacOS @@ -92,6 +98,9 @@ PODS: - GoogleDataTransport (10.1.0): - nanopb (~> 3.30910.0) - PromisesObjC (~> 2.4) + - GoogleMaps (10.10.0): + - GoogleMaps/Maps (= 10.10.0) + - GoogleMaps/Maps (10.10.0) - GoogleSignIn (9.1.0): - AppAuth (~> 2.0) - AppCheckCore (~> 11.0) @@ -263,6 +272,7 @@ DEPENDENCIES: - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) - flutter_tts (from `.symlinks/plugins/flutter_tts/ios`) - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) + - google_maps_flutter_ios (from `.symlinks/plugins/google_maps_flutter_ios/ios`) - google_sign_in_ios (from `.symlinks/plugins/google_sign_in_ios/darwin`) - image_cropper (from `.symlinks/plugins/image_cropper/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) @@ -299,7 +309,9 @@ SPEC REPOS: - FirebaseCoreInternal - FirebaseInstallations - FirebaseMessaging + - Google-Maps-iOS-Utils - GoogleDataTransport + - GoogleMaps - GoogleSignIn - GoogleUtilities - GTMAppAuth @@ -349,6 +361,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_tts/ios" geolocator_apple: :path: ".symlinks/plugins/geolocator_apple/darwin" + google_maps_flutter_ios: + :path: ".symlinks/plugins/google_maps_flutter_ios/ios" google_sign_in_ios: :path: ".symlinks/plugins/google_sign_in_ios/darwin" image_cropper: @@ -420,8 +434,11 @@ SPEC CHECKSUMS: flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 flutter_tts: 35ac3c7d42412733e795ea96ad2d7e05d0a75113 geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e + Google-Maps-iOS-Utils: bed22fa703c919259b3901449434d60d994fae20 + google_maps_flutter_ios: 17552876e72723da1d41accc22f03b5f7afbde69 google_sign_in_ios: 000870aa06da9b28d1d0bf7ef70ff0213059dd28 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleMaps: 133ba5beb4979592001a6cd0125a502243439ff9 GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238 diff --git a/lib/controller/home/map_passenger_controller.dart b/lib/controller/home/map_passenger_controller.dart index a4eab5e..4c7dbf0 100644 --- a/lib/controller/home/map_passenger_controller.dart +++ b/lib/controller/home/map_passenger_controller.dart @@ -137,6 +137,7 @@ class MapPassengerController extends GetxController { late LatLng newPointLocation3 = const LatLng(32.115295, 36.064773); late LatLng newPointLocation4 = const LatLng(32.115295, 36.064773); late LatLng myDestination; + LatLngBounds? lastComputedBounds; List polylineCoordinates = []; List polylineCoordinates0 = []; List polylineCoordinates1 = []; @@ -5872,13 +5873,25 @@ Intaleq Team'''; void onStyleLoaded() async { Log.print('๐Ÿ—บ๏ธ MapLibre Style Loaded. Syncing markers and layers...'); isStyleLoaded = true; + + // Load icons and refresh existing elements if any await _loadMapIcons(); await refreshMapElements(); - // Initial camera set - mapController?.animateCamera( - CameraUpdate.newLatLng(passengerLocation), - ); + // Smart Camera Reset logic: + // Only reset to passengerLocation if we aren't showing an active route + if (mapController != null) { + if (isMarkersShown && lastComputedBounds != null) { + mapController!.animateCamera( + CameraUpdate.newLatLngBounds(lastComputedBounds!, + left: 100, top: 100, right: 100, bottom: 100), + ); + } else { + mapController!.animateCamera( + CameraUpdate.newLatLng(passengerLocation), + ); + } + } update(); } @@ -5889,6 +5902,9 @@ Intaleq Team'''; await _addMapImage(motoIcon, 'assets/images/moto.png'); await _addMapImage(ladyIcon, 'assets/images/lady.png'); await _addMapImage('picker_icon', 'assets/images/picker.png'); + // Waypoint markers - use moto1 & lady1 as colored waypoint icons + await _addMapImage('orange_marker', 'assets/images/moto1.png'); + await _addMapImage('violet_marker', 'assets/images/lady1.png'); } Future _addMapImage(String id, String path) async { @@ -6568,6 +6584,8 @@ Intaleq Team'''; markers.add(SymbolOptions( geometry: startLoc, iconImage: startIcon, + iconSize: 1.0, + iconAnchor: 'bottom', textField: 'start_point', textOpacity: 0, )); @@ -6575,22 +6593,28 @@ Intaleq Team'''; markers.add(SymbolOptions( geometry: endLoc, iconImage: endIcon, + iconSize: 1.0, + iconAnchor: 'bottom', textField: 'end_point', textOpacity: 0, - // snippet and distance text can be handled via symbols or separate UI )); - // 6b. Add waypoint markers + // 6b. Add waypoint markers (Stop 1 & Stop 2) for (int i = 0; i < activeMenuWaypointCount; i++) { final wp = menuWaypoints[i]; if (wp != null) { + final bool isFirstWaypoint = i == 0; markers.add(SymbolOptions( geometry: wp, - iconImage: i == 0 - ? 'orange_marker' - : 'violet_marker', // placeholders or specific icons - textField: 'waypoint_$i', - textOpacity: 0, + iconImage: isFirstWaypoint ? 'orange_marker' : 'violet_marker', + iconSize: 1.2, + iconAnchor: 'bottom', + textField: isFirstWaypoint ? 'Stop 1' : 'Stop 2', + textOffset: const Offset(0, 0.5), + textColor: isFirstWaypoint ? '#F59E0B' : '#7C3AED', + textHaloColor: '#FFFFFF', + textHaloWidth: 1.5, + textSize: 11, )); } } @@ -6606,15 +6630,14 @@ Intaleq Team'''; bottomSheet(); // 8. Compute bounds from all polyline points - LatLngBounds? boundsData; if (minLat != null) { - boundsData = LatLngBounds( + lastComputedBounds = LatLngBounds( northeast: LatLng(maxLat!, maxLng!), southwest: LatLng(minLat!, minLng!)); } // ุชุดุบูŠู„ ุงู„ุฃู†ูŠู…ูŠุดู† ุงู„ุฎููŠู ู„ูˆู…ุถุงุช ุงู„ู…ุณุงุฑ + fit camera after - _playRouteAnimation(polylineCoordinates, boundsData); + _playRouteAnimation(polylineCoordinates, lastComputedBounds); } catch (e, stackTrace) { // ๐Ÿšจ ู‡ุฐุง ุงู„ุณุทุฑ ุณูŠูุถุญ ุงู„ู…ุดูƒู„ุฉ ุงู„ุญู‚ูŠู‚ูŠุฉ! ๐Ÿšจ print('๐Ÿšจ CRITICAL ERROR IN getDirectionMap: $e'); @@ -6629,7 +6652,7 @@ Intaleq Team'''; } } - // --- ุฏุงู„ุฉ ุงู„ุฃู†ูŠู…ูŠุดู† ู…ุน ุฃู„ูˆุงู† ู…ุฎุชู„ูุฉ ู„ูƒู„ ู‚ุทุนุฉ --- + // --- ุฑุณู… ุงู„ู…ุณุงุฑ ุงู„ู†ู‡ุงุฆูŠ ู…ุน ุชู‚ุณูŠู… ู…ู„ูˆู† ุญุณุจ ู†ู‚ุงุท ุงู„ุชูˆู‚ู --- Future _playRouteAnimation( List coords, LatLngBounds? bounds) async { // Segment colors matching UI dots: green โ†’ amber โ†’ purple โ†’ red @@ -6640,32 +6663,10 @@ Intaleq Team'''; Color(0xFFEF4444), // Red (fallback) ]; - // Loading animation (4 flashes) - Color lightColor = Colors.grey.shade400; - Color darkColor = Colors.grey.shade700; - - for (int i = 0; i < 4; i++) { - polyLines = polyLines - .where((p) => p.lineOpacity != 0.99) - .toList(); // Simple filter for non-animated - - polyLines.add(LineOptions( - geometry: coords, - lineColor: i.isEven ? '#BDBDBD' : '#616161', - lineWidth: 5, - lineOpacity: 0.99, // tag for removal - )); - - refreshMapElements(); - update(); - await Future.delayed(const Duration(milliseconds: 250)); - } - - // After animation: draw coloured segments - polyLines = polyLines.where((p) => p.lineOpacity != 0.99).toList(); + // โ”€โ”€ Build final polyline segments โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + polyLines.clear(); if (activeMenuWaypointCount > 0) { - // ... split indices logic remains valid as it is math-based ... List splitIndices = []; for (int w = 0; w < activeMenuWaypointCount; w++) { final wp = menuWaypoints[w]; @@ -6710,13 +6711,17 @@ Intaleq Team'''; )); } - refreshMapElements(); - update(); + // โ”€โ”€ Ensure icons are loaded on the CURRENT mapController โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // (handles the case where update() triggered a map rebuild) + await _loadMapIcons(); + + // โ”€โ”€ Draw everything at once โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + await refreshMapElements(); update(); - // Fit camera to full route bounds AFTER polylines are drawn + // โ”€โ”€ Fit camera to full route bounds โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if (bounds != null) { - await Future.delayed(const Duration(milliseconds: 500)); + await Future.delayed(const Duration(milliseconds: 400)); try { mapController?.animateCamera(CameraUpdate.newLatLngBounds(bounds, left: 100, top: 100, right: 100, bottom: 100)); diff --git a/lib/views/home/map_widget.dart/left_main_menu_icons.dart b/lib/views/home/map_widget.dart/left_main_menu_icons.dart index 4d52cf2..ec85eab 100644 --- a/lib/views/home/map_widget.dart/left_main_menu_icons.dart +++ b/lib/views/home/map_widget.dart/left_main_menu_icons.dart @@ -1,5 +1,8 @@ import 'dart:math'; +import 'package:Intaleq/views/widgets/elevated_btn.dart'; +import 'package:Intaleq/views/widgets/error_snakbar.dart'; +import 'package:Intaleq/views/widgets/mycircular.dart'; import 'package:flutter/material.dart'; import 'package:flutter_font_icons/flutter_font_icons.dart'; import 'package:get/get.dart'; @@ -8,10 +11,9 @@ import 'dart:ui'; // ู…ู‡ู… ู„ุฅุถุงูุฉ ุชุฃุซูŠุฑ ุงู„ุถุจุงุจูŠุฉ import '../../../constant/colors.dart'; import '../../../controller/functions/tts.dart'; -import '../../../controller/home/ios_live_activity_service.dart'; import '../../../controller/home/map_passenger_controller.dart'; import '../../../controller/home/vip_waitting_page.dart'; -import '../../../print.dart'; +import '../navigation/navigation_view.dart'; // --- ุงู„ุฏุงู„ุฉ ุงู„ุฑุฆูŠุณูŠุฉ ุจุงู„ุชุตู…ูŠู… ุงู„ุฌุฏูŠุฏ --- GetBuilder leftMainMenuIcons() { @@ -44,7 +46,7 @@ GetBuilder leftMainMenuIcons() { _buildMapActionButton( icon: Icons.satellite_alt_outlined, tooltip: 'Toggle Map Type', - onPressed: () => controller.changeMapType(), + onPressed: () => Get.to(() => NavigationView()), ), // _buildVerticalDivider(), // _buildMapActionButton( @@ -131,78 +133,17 @@ class TestPage extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - // ุฒุฑ ุงู„ุจุฏุก - ElevatedButton( - onPressed: () async { - print("๐ŸŽ ู…ุญุงูˆู„ุฉ ุชุดุบูŠู„ Live Activity (Start)..."); - try { - await IosLiveActivityService.startRideActivity( - rideId: "123", - driverName: "ุชุฌุฑุจุฉ ู…ุจุฏุฆูŠุฉ", - carDetails: "ุชูˆูŠูˆุชุง โ€ข ุฃุณูˆุฏ", - etaText: "5 ุฏู‚ุงุฆู‚", - progress: 0.2, - ); - Log.print( - "โœ… ุชู… ุชุดุบูŠู„ Live Activity ุจู†ุฌุงุญ! ุฃุบู„ู‚ ุงู„ุดุงุดุฉ ู„ุชุฑู‰ ุงู„ู†ุชูŠุฌุฉ."); - } catch (e) { - Log.print("โŒ ุฎุทุฃ ููŠ Start Live Activity: $e"); - } - }, - child: const Text('Start Activity'), + MyCircularProgressIndicator(), + MyElevatedButton( + title: 'title', + onPressed: () {}, ), - - const SizedBox(height: 16), - - // ุฒุฑ ุงู„ุชุญุฏูŠุซ ุงู„ุนุดูˆุงุฆูŠ - ElevatedButton( - onPressed: () async { - Log.print("๐Ÿ”„ ู…ุญุงูˆู„ุฉ ุชุญุฏูŠุซ Live Activity (Update)..."); - - // ุชูˆู„ูŠุฏ ุจูŠุงู†ุงุช ุนุดูˆุงุฆูŠุฉ ู„ู„ุงุฎุชุจุงุฑ - final statuses = ['waiting', 'ongoing']; - final status = statuses[random.nextInt(statuses.length)]; - - final int minutes = random.nextInt(15) + 1; // 1โ€“15 - final String eta = "$minutes ุฏู‚ุงุฆู‚"; - - final double progress = (random.nextDouble() * 0.9) + 0.05; - // ุจูŠู† 0.05 ูˆ 0.95 ุชู‚ุฑูŠุจู‹ุง - - try { - await IosLiveActivityService.updateRideActivity( - status: status, - driverName: - status == 'waiting' ? 'ุงู„ุณุงุฆู‚ ููŠ ุงู„ุทุฑูŠู‚' : 'ุงู„ุณุงุฆู‚ ู…ุนูƒ', - carDetails: "ุชูˆูŠูˆุชุง โ€ข ุฃุณูˆุฏ", - etaText: eta, - progress: progress, - ); - Log.print( - "โœ… ุชู… ุชุญุฏูŠุซ Live Activity: status=$status, eta=$eta, progress=$progress"); - } catch (e) { - Log.print("โŒ ุฎุทุฃ ููŠ Update Live Activity: $e"); - } - }, - child: const Text('Update (Random)'), - ), - - const SizedBox(height: 16), - // ุฒุฑ ุงู„ุฅู†ู‡ุงุก ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: Colors.red, ), - onPressed: () async { - Log.print("๐Ÿ›‘ ู…ุญุงูˆู„ุฉ ุฅู†ู‡ุงุก Live Activity (End)..."); - try { - await IosLiveActivityService.endRideActivity(); - Log.print("โœ… ุชู… ุฅู†ู‡ุงุก Live Activity."); - } catch (e) { - Log.print("โŒ ุฎุทุฃ ููŠ End Live Activity: $e"); - } - }, + onPressed: () async {}, child: const Text('End Activity'), ), ], diff --git a/lib/views/home/navigation/navigation_controller.dart b/lib/views/home/navigation/navigation_controller.dart new file mode 100644 index 0000000..f3c37d7 --- /dev/null +++ b/lib/views/home/navigation/navigation_controller.dart @@ -0,0 +1,632 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; +import 'package:Intaleq/views/widgets/error_snakbar.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:get/get.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; // Replaced Google Maps +import 'package:http/http.dart' as http; +import '../../../constant/box_name.dart'; +import '../../../constant/colors.dart'; +import '../../../constant/country_polygons.dart'; +import '../../../constant/links.dart'; +import '../../../controller/functions/crud.dart'; +import '../../../controller/functions/tts.dart'; +import '../../../controller/home/decode_polyline_isolate.dart'; +import '../../../env/env.dart'; +import '../../../main.dart'; +import '../../../print.dart'; + +class NavigationController extends GetxController { + bool isLoading = false; + MaplibreMapController? mapController; + bool isStyleLoaded = false; + final TextEditingController placeDestinationController = + TextEditingController(); + + LatLng? myLocation; + double heading = 0.0; + + // MapLibre Object Tracking + Symbol? carSymbol; + Symbol? destinationSymbol; + Line? remainingRouteLine; + Line? traveledRouteLine; + + Timer? _locationUpdateTimer; + final Duration _currentUpdateInterval = const Duration(seconds: 1); + LatLng? _lastRecordedLocation; + + List placesDestination = []; + Timer? _debounce; + + LatLng? _finalDestination; + List> routeSteps = []; + List _fullRouteCoordinates = []; + int _lastTraveledIndexInFullRoute = 0; + + bool _nextInstructionSpoken = false; + String currentInstruction = ""; + String nextInstruction = ""; + int currentStepIndex = 0; + + double currentSpeed = 0.0; + String distanceToNextStep = ""; + + static final String _routeApiBaseUrl = + "${AppLink.routesOsm}/route/v1/driving"; + + @override + void onInit() { + super.onInit(); + _initialize(); + } + + Future _initialize() async { + await _getCurrentLocationAndStartUpdates(); + if (!Get.isRegistered()) { + Get.put(TextToSpeechController()); + } + } + + @override + void onClose() { + _locationUpdateTimer?.cancel(); + mapController?.dispose(); + _debounce?.cancel(); + placeDestinationController.dispose(); + super.onClose(); + } + + // ======================================================================= + // Map Initialization & Callbacks + // ======================================================================= + + void onMapCreated(MaplibreMapController controller) { + mapController = controller; + } + + Future onStyleLoaded() async { + isStyleLoaded = true; + await _loadCustomIcons(); + + if (myLocation != null) { + animateCameraToPosition(myLocation!); + _updateCarMarker(); + } + + if (_fullRouteCoordinates.isNotEmpty) { + _updatePolylinesSets([], _fullRouteCoordinates); + } + } + + Future onMapLongPressed(Point point, LatLng tappedPoint) async { + Get.dialog( + AlertDialog( + title: const Text('ุจุฏุก ุงู„ู…ู„ุงุญุฉุŸ'), + content: const Text('ู‡ู„ ุชุฑูŠุฏ ุงู„ุฐู‡ุงุจ ุฅู„ู‰ ู‡ุฐุง ุงู„ู…ูˆู‚ุน ุงู„ู…ุญุฏุฏุŸ'), + actionsAlignment: MainAxisAlignment.spaceBetween, + actions: [ + TextButton( + child: const Text('ุฅู„ุบุงุก', style: TextStyle(color: Colors.grey)), + onPressed: () => Get.back(), + ), + TextButton( + child: const Text('ุงุฐู‡ุจ ุงู„ุขู†'), + onPressed: () { + Get.back(); + startNavigationTo(tappedPoint, infoWindowTitle: 'ุงู„ู…ูˆู‚ุน ุงู„ู…ุญุฏุฏ'); + }, + ), + ], + ), + ); + } + + // ======================================================================= + // Location Management + // ======================================================================= + + Future _getCurrentLocationAndStartUpdates() async { + try { + Position position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high); + myLocation = LatLng(position.latitude, position.longitude); + update(); + if (isStyleLoaded) animateCameraToPosition(myLocation!); + _startLocationTimer(); + } catch (e) { + Log.print("Error getting initial location: $e"); + } + } + + void _startLocationTimer() { + _locationUpdateTimer?.cancel(); + _locationUpdateTimer = Timer.periodic(_currentUpdateInterval, (timer) { + _updateLocationAndProcess(); + }); + } + + Future _updateLocationAndProcess() async { + try { + final position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high); + final newLoc = LatLng(position.latitude, position.longitude); + + if (_lastRecordedLocation != null) { + double dist = Geolocator.distanceBetween( + newLoc.latitude, + newLoc.longitude, + _lastRecordedLocation!.latitude, + _lastRecordedLocation!.longitude); + if (dist < 2.0) return; + } + + myLocation = newLoc; + _lastRecordedLocation = newLoc; + heading = position.heading; + currentSpeed = position.speed * 3.6; + + if (isStyleLoaded) _updateCarMarker(); + + if (_fullRouteCoordinates.isNotEmpty) { + animateCameraToPosition(myLocation!, bearing: heading, zoom: 18.0); + _updateTraveledPolylineSmart(myLocation!); + _checkNavigationStep(myLocation!); + } + + update(); + } catch (e) { + // Log.print("Loc update error: $e"); + } + } + + Future _updateCarMarker() async { + if (myLocation == null || mapController == null || !isStyleLoaded) return; + + if (carSymbol == null) { + carSymbol = await mapController!.addSymbol(SymbolOptions( + geometry: myLocation, + iconImage: 'car_icon', + iconSize: 1.0, + iconRotate: heading, + )); + } else { + mapController!.updateSymbol( + carSymbol!, + SymbolOptions( + geometry: myLocation, + iconRotate: heading, + )); + } + } + + void animateCameraToPosition(LatLng position, + {double zoom = 17.0, double bearing = 0.0}) { + mapController?.animateCamera( + CameraUpdate.newCameraPosition( + CameraPosition( + target: position, zoom: zoom, bearing: bearing, tilt: 45.0), + ), + ); + } + + // ======================================================================= + // Route Management + // ======================================================================= + + void _updateTraveledPolylineSmart(LatLng currentPos) { + if (_fullRouteCoordinates.isEmpty) return; + + int searchWindow = 60; + int startIndex = _lastTraveledIndexInFullRoute; + int endIndex = min(startIndex + searchWindow, _fullRouteCoordinates.length); + + double minDistance = double.infinity; + int closestIndex = startIndex; + bool foundCloser = false; + + for (int i = startIndex; i < endIndex; i++) { + final point = _fullRouteCoordinates[i]; + final dist = Geolocator.distanceBetween(currentPos.latitude, + currentPos.longitude, point.latitude, point.longitude); + + if (dist < minDistance) { + minDistance = dist; + closestIndex = i; + foundCloser = true; + } + } + + if (foundCloser && + minDistance < 50 && + closestIndex > _lastTraveledIndexInFullRoute) { + _lastTraveledIndexInFullRoute = closestIndex; + final remaining = + _fullRouteCoordinates.sublist(_lastTraveledIndexInFullRoute); + final traveled = + _fullRouteCoordinates.sublist(0, _lastTraveledIndexInFullRoute + 1); + _updatePolylinesSets(traveled, remaining); + } + } + + Future _updatePolylinesSets( + List traveled, List remaining) async { + if (mapController == null || !isStyleLoaded) return; + + if (remainingRouteLine != null) + await mapController!.removeLine(remainingRouteLine!); + if (traveledRouteLine != null) + await mapController!.removeLine(traveledRouteLine!); + + if (remaining.isNotEmpty) { + remainingRouteLine = await mapController!.addLine(LineOptions( + geometry: remaining, + lineColor: '#0D47A1', + lineWidth: 6.0, + lineJoin: 'round', + )); + } + + if (traveled.isNotEmpty) { + traveledRouteLine = await mapController!.addLine(LineOptions( + geometry: traveled, + lineColor: '#BDBDBD', + lineWidth: 6.0, + lineJoin: 'round', + )); + } + } + + // ======================================================================= + // Routing API & Navigation + // ======================================================================= + + Future getRoute(LatLng origin, LatLng destination) async { + String coords = + "${origin.longitude},${origin.latitude};${destination.longitude},${destination.latitude}"; + String url = + "$_routeApiBaseUrl/$coords?steps=true&overview=full&geometries=polyline"; + + try { + final response = await http.get(Uri.parse(url)); + if (response.statusCode != 200) { + mySnackbarWarning('ุชุนุฐุฑ ุงู„ุงุชุตุงู„ ุจุฎุฏู…ุฉ ุงู„ุชูˆุฌูŠู‡.'); + return; + } + + final responseData = jsonDecode(response.body); + if (responseData['code'] != 'Ok' || + (responseData['routes'] as List).isEmpty) { + mySnackbarWarning('ู„ู… ูŠุชู… ุงู„ุนุซูˆุฑ ุนู„ู‰ ู…ุณุงุฑ.'); + return; + } + + var route = responseData['routes'][0]; + final pointsString = route['geometry']; + // ููƒ ุชุดููŠุฑ Polyline ุจุทุฑูŠู‚ุฉ ุขู…ู†ุฉ ู†ูˆุนูŠุงู‹ (Type-Safe) + _fullRouteCoordinates = await compute>( + decodePolylineIsolate, pointsString.toString()); + + _lastTraveledIndexInFullRoute = 0; + if (isStyleLoaded) _updatePolylinesSets([], _fullRouteCoordinates); + + var legs = route['legs'] as List; + if (legs.isNotEmpty) { + var steps = legs[0]['steps'] as List; + routeSteps = List>.from(steps); + } else { + routeSteps = []; + } + + for (var step in routeSteps) { + step['instruction_text'] = _createInstructionFromManeuver(step); + } + + currentStepIndex = 0; + _nextInstructionSpoken = false; + if (routeSteps.isNotEmpty) { + currentInstruction = routeSteps[0]['instruction_text']; + nextInstruction = routeSteps.length > 1 + ? "ุซู… ${routeSteps[1]['instruction_text']}" + : "ุงู„ูˆุฌู‡ุฉ ุงู„ู†ู‡ุงุฆูŠุฉ"; + Get.find().speakText(currentInstruction); + } + + if (_fullRouteCoordinates.isNotEmpty) { + final bounds = _boundsFromLatLngList(_fullRouteCoordinates); + mapController?.animateCamera(CameraUpdate.newLatLngBounds(bounds, + bottom: 200, top: 150, left: 50, right: 50)); + } + + update(); + } catch (e) { + Log.print("GetRoute Error: $e"); + Get.snackbar('ุฎุทุฃ', 'ุญุฏุซ ุฎุทุฃ ุบูŠุฑ ู…ุชูˆู‚ุน.'); + } + } + + // --- Map Object Handlers --- + + Future startNavigationTo(LatLng destination, + {String infoWindowTitle = ''}) async { + isLoading = true; + update(); + try { + _finalDestination = destination; + await clearRoute(isNewRoute: true); + + if (isStyleLoaded && mapController != null) { + destinationSymbol = await mapController!.addSymbol(SymbolOptions( + geometry: destination, + iconImage: 'dest_icon', + iconSize: 1.0, + textField: infoWindowTitle, + textOffset: const Offset(0, 2), + )); + } + + if (myLocation != null) await getRoute(myLocation!, destination); + } finally { + isLoading = false; + update(); + } + } + + Future recalculateRoute() async { + if (myLocation == null || _finalDestination == null || isLoading) return; + isLoading = true; + update(); + Get.snackbar('ุฅุนุงุฏุฉ ุงู„ุชูˆุฌูŠู‡', 'ุฌุงุฑูŠ ุญุณุงุจ ู…ุณุงุฑ ุฌุฏูŠุฏ...', + backgroundColor: AppColor.goldenBronze); + await getRoute(myLocation!, _finalDestination!); + isLoading = false; + update(); + } + + Future clearRoute({bool isNewRoute = false}) async { + if (!isNewRoute) { + if (destinationSymbol != null && mapController != null) { + await mapController!.removeSymbol(destinationSymbol!); + destinationSymbol = null; + } + if (remainingRouteLine != null && mapController != null) { + await mapController!.removeLine(remainingRouteLine!); + remainingRouteLine = null; + } + if (traveledRouteLine != null && mapController != null) { + await mapController!.removeLine(traveledRouteLine!); + traveledRouteLine = null; + } + _finalDestination = null; + } + routeSteps.clear(); + _fullRouteCoordinates.clear(); + _lastTraveledIndexInFullRoute = 0; + currentInstruction = ""; + nextInstruction = ""; + distanceToNextStep = ""; + update(); + } + + Future _loadCustomIcons() async { + if (mapController == null) return; + + final ByteData carBytes = await rootBundle.load('assets/images/car.png'); + final Uint8List carList = carBytes.buffer.asUint8List(); + await mapController!.addImage('car_icon', carList); + + final ByteData destBytes = await rootBundle.load('assets/images/b.png'); + final Uint8List destList = destBytes.buffer.asUint8List(); + await mapController!.addImage('dest_icon', destList); + } + + // --- Step Tracking & Instructions (Omitted unchanged logic to save space, retain your existing string matchers) --- + void _checkNavigationStep(LatLng currentPosition) { + if (routeSteps.isEmpty || currentStepIndex >= routeSteps.length) return; + final step = routeSteps[currentStepIndex]; + final maneuver = step['maneuver']; + final List location = maneuver['location']; + final endLatLng = LatLng(location[1], location[0]); + + final distance = Geolocator.distanceBetween( + currentPosition.latitude, + currentPosition.longitude, + endLatLng.latitude, + endLatLng.longitude, + ); + + distanceToNextStep = distance > 1000 + ? "${(distance / 1000).toStringAsFixed(1)} ูƒู…" + : "${distance.toStringAsFixed(0)} ู…ุชุฑ"; + + if (distance < 50 && + !_nextInstructionSpoken && + nextInstruction.isNotEmpty) { + Get.find().speakText(nextInstruction); + _nextInstructionSpoken = true; + } + + if (distance < 20) _advanceStep(); + } + + void _advanceStep() { + currentStepIndex++; + if (currentStepIndex < routeSteps.length) { + currentInstruction = routeSteps[currentStepIndex]['instruction_text']; + nextInstruction = (currentStepIndex + 1) < routeSteps.length + ? "ุซู… ${routeSteps[currentStepIndex + 1]['instruction_text']}" + : "ุณุชุตู„ ุฅู„ู‰ ูˆุฌู‡ุชูƒ"; + _nextInstructionSpoken = false; + update(); + } else { + _finishNavigation(); + } + } + + void _finishNavigation() { + currentInstruction = "ู„ู‚ุฏ ูˆุตู„ุช ุฅู„ู‰ ูˆุฌู‡ุชูƒ"; + nextInstruction = ""; + distanceToNextStep = ""; + Get.find().speakText(currentInstruction); + update(); + } + + String _createInstructionFromManeuver(Map step) { + if (step['maneuver'] == null) return "ุชุงุจุน ุงู„ู…ุณูŠุฑ"; + final maneuver = step['maneuver']; + final type = maneuver['type'] ?? 'continue'; + final modifier = maneuver['modifier'] ?? 'straight'; + final name = step['name'] ?? ''; + String instruction = ""; + + switch (type) { + case 'depart': + instruction = "ุงู†ุทู„ู‚"; + break; + case 'arrive': + return "ู„ู‚ุฏ ูˆุตู„ุช ุฅู„ู‰ ูˆุฌู‡ุชูƒุŒ $name"; + case 'turn': + case 'fork': + case 'roundabout': + case 'merge': + case 'on ramp': + case 'off ramp': + case 'end of road': + instruction = _getTurnInstruction(modifier); + break; + case 'new name': + instruction = "ุชุงุจุน ุงู„ู…ุณูŠุฑ"; + break; + default: + instruction = "ุชุงุจุน ุงู„ู…ุณูŠุฑ"; + } + + if (name.isNotEmpty) + instruction += (type == 'new name' || type == 'continue') + ? " ุนู„ู‰ $name" + : " ู†ุญูˆ $name"; + return instruction; + } + + String _getTurnInstruction(String modifier) { + switch (modifier) { + case 'uturn': + return "ู‚ู… ุจุงู„ุงุณุชุฏุงุฑุฉ ูˆุงู„ุนูˆุฏุฉ"; + case 'sharp right': + return "ุงู†ุนุทู ูŠู…ูŠู†ุงู‹ ุจุญุฏุฉ"; + case 'right': + return "ุงู†ุนุทู ูŠู…ูŠู†ุงู‹"; + case 'slight right': + return "ุงู†ุนุทู ูŠู…ูŠู†ุงู‹ ู‚ู„ูŠู„ุงู‹"; + case 'straight': + return "ุงุณุชู…ุฑ ู„ู„ุฃู…ุงู…"; + case 'slight left': + return "ุงู†ุนุทู ูŠุณุงุฑุงู‹ ู‚ู„ูŠู„ุงู‹"; + case 'left': + return "ุงู†ุนุทู ูŠุณุงุฑุงู‹"; + case 'sharp left': + return "ุงู†ุนุทู ูŠุณุงุฑุงู‹ ุจุญุฏุฉ"; + default: + return "ุงุชุฌู‡"; + } + } + + // --- Search & Utils (Retained entirely, no map logic here) --- + Future getPlaces() async { + final q = placeDestinationController.text.trim(); + if (q.length < 3) { + placesDestination = []; + update(); + return; + } + if (myLocation == null) return; + + final lat = myLocation!.latitude; + final lng = myLocation!.longitude; + const radiusKm = 200.0; + final payload = { + 'query': q, + 'lat_min': (lat - _kmToLatDelta(radiusKm)).toString(), + 'lat_max': (lat + _kmToLatDelta(radiusKm)).toString(), + 'lng_min': (lng - _kmToLngDelta(radiusKm, lat)).toString(), + 'lng_max': (lng + _kmToLngDelta(radiusKm, lat)).toString(), + }; + + try { + final response = + await CRUD().post(link: AppLink.getPlacesSyria, payload: payload); + List list; + if (response is Map && response['status'] == 'success') + list = List.from(response['message'] as List); + else if (response is List) + list = List.from(response); + else + return; + + for (final p in list) { + final plat = double.tryParse(p['latitude']?.toString() ?? '0.0') ?? 0.0; + final plng = + double.tryParse(p['longitude']?.toString() ?? '0.0') ?? 0.0; + p['distanceKm'] = _haversineKm(lat, lng, plat, plng); + } + + list.sort((a, b) => + (a['distanceKm'] as double).compareTo(b['distanceKm'] as double)); + placesDestination = list; + update(); + } catch (e) { + print('Exception in getPlaces: $e'); + } + } + + Future selectDestination(dynamic place) async { + placeDestinationController.clear(); + placesDestination = []; + final double lat = double.parse(place['latitude'].toString()); + final double lng = double.parse(place['longitude'].toString()); + await startNavigationTo(LatLng(lat, lng), + infoWindowTitle: place['name'] ?? 'ูˆุฌู‡ุฉ'); + } + + void onSearchChanged(String query) { + if (_debounce?.isActive ?? false) _debounce!.cancel(); + _debounce = Timer(const Duration(milliseconds: 700), () => getPlaces()); + } + + double _haversineKm(double lat1, double lon1, double lat2, double lon2) { + const R = 6371.0; + final dLat = (lat2 - lat1) * (pi / 180.0); + final dLon = (lon2 - lon1) * (pi / 180.0); + final a = sin(dLat / 2) * sin(dLat / 2) + + cos(lat1 * pi / 180) * + cos(lat2 * pi / 180) * + sin(dLon / 2) * + sin(dLon / 2); + return R * 2 * atan2(sqrt(a), sqrt(1 - a)); + } + + double _kmToLatDelta(double km) => km / 111.32; + double _kmToLngDelta(double km, double lat) => + km / (111.32 * cos(lat * pi / 180)); + + LatLngBounds _boundsFromLatLngList(List list) { + double? x0, x1, y0, y1; + for (LatLng latLng in list) { + if (x0 == null) { + x0 = x1 = latLng.latitude; + y0 = y1 = latLng.longitude; + } else { + if (latLng.latitude > x1!) x1 = latLng.latitude; + if (latLng.latitude < x0) x0 = latLng.latitude; + if (latLng.longitude > y1!) y1 = latLng.longitude; + if (latLng.longitude < y0!) y0 = latLng.longitude; + } + } + return LatLngBounds( + northeast: LatLng(x1!, y1!), southwest: LatLng(x0!, y0!)); + } +} diff --git a/lib/views/home/navigation/navigation_view.dart b/lib/views/home/navigation/navigation_view.dart new file mode 100644 index 0000000..a308e9c --- /dev/null +++ b/lib/views/home/navigation/navigation_view.dart @@ -0,0 +1,368 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; // Replaced Google Maps +import 'dart:ui'; + +import 'navigation_controller.dart'; + +const Color kPrimaryColor = Color(0xFF0D47A1); + +class NavigationView extends StatelessWidget { + const NavigationView({super.key}); + + @override + Widget build(BuildContext context) { + final NavigationController controller = Get.put(NavigationController()); + + return Scaffold( + body: GetBuilder( + builder: (_) => Stack( + children: [ + // --- ุงู„ุฎุฑูŠุทุฉ --- + MapLibreMap( + onMapCreated: controller.onMapCreated, + onStyleLoadedCallback: controller.onStyleLoaded, + onMapLongClick: controller.onMapLongPressed, + styleString: "assets/style.json", + initialCameraPosition: CameraPosition( + target: controller.myLocation ?? const LatLng(33.5138, 36.2765), + zoom: 16.0, + ), + myLocationEnabled: false, + compassEnabled: false, + ), + + // --- ูˆุงุฌู‡ุฉ ุงู„ุจุญุซ (ุชุตู…ูŠู… ุฒุฌุงุฌูŠ) --- + _buildGlassSearchUI(controller), + + // --- ุฅุฑุดุงุฏุงุช ุงู„ู…ู„ุงุญุฉ (ุชุตู…ูŠู… ุนุงุฆู…) --- + if (controller.currentInstruction.isNotEmpty) + _buildFloatingNavigationUI(controller), + + // --- ุฃุฒุฑุงุฑ ุงู„ุชุญูƒู… (ุชุตู…ูŠู… ุนุงุฆู…) --- + _buildFloatingMapControls(controller), + + // --- ู…ุคุดุฑ ุงู„ุชุญู…ูŠู„ --- + if (controller.isLoading) + Container( + color: Colors.black.withOpacity(0.5), + child: const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white), + strokeWidth: 3, + ), + ), + ), + ], + ), + ), + ); + } + + // --- All UI Sub-Widgets remain identical, simply change the 'if' checks to rely on the new variables --- + + Widget _buildGlassSearchUI(NavigationController controller) { + return Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(28.0), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), + child: Container( + height: 56, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.85), + borderRadius: BorderRadius.circular(28.0), + border: Border.all(color: Colors.white.withOpacity(0.4)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 15, + offset: const Offset(0, 5)), + ], + ), + child: Row( + children: [ + const Padding( + padding: EdgeInsets.only(left: 18.0, right: 10.0), + child: Icon(Icons.search, + color: kPrimaryColor, size: 24), + ), + Expanded( + child: TextField( + controller: controller.placeDestinationController, + onChanged: controller.onSearchChanged, + textInputAction: TextInputAction.search, + style: const TextStyle( + fontSize: 16, color: Colors.black87), + decoration: const InputDecoration( + hintText: 'ุฅู„ู‰ ุฃูŠู† ุชุฑูŠุฏ ุงู„ุฐู‡ุงุจุŸ', + hintStyle: TextStyle( + color: Colors.black45, fontSize: 16), + border: InputBorder.none, + contentPadding: EdgeInsets.only(bottom: 2), + ), + ), + ), + if (controller + .placeDestinationController.text.isNotEmpty) + _buildClearButton(controller) + else if (controller.destinationSymbol != + null) // Changed condition here + _buildCancelRouteButton(controller), + ], + ), + ), + ), + ), + const SizedBox(height: 10), + if (controller.placesDestination.isNotEmpty) + _buildSearchResultsList(controller), + ], + ), + ), + ), + ); + } + + Widget _buildClearButton(NavigationController controller) { + return IconButton( + icon: const Icon(Icons.clear, color: Colors.grey, size: 22), + onPressed: () { + controller.placeDestinationController.clear(); + controller.placesDestination = []; + controller.update(); + }, + ); + } + + Widget _buildCancelRouteButton(NavigationController controller) { + return IconButton( + tooltip: 'ุฅู„ุบุงุก ุงู„ู…ุณุงุฑ', + icon: const Icon(Icons.close, color: Colors.redAccent, size: 22), + onPressed: () => controller.clearRoute(), + ); + } + + Widget _buildSearchResultsList(NavigationController controller) { + return ClipRRect( + borderRadius: BorderRadius.circular(24.0), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), + child: Container( + constraints: const BoxConstraints(maxHeight: 220), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.85), + borderRadius: BorderRadius.circular(24.0), + border: Border.all(color: Colors.white.withOpacity(0.4)), + ), + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8.0), + itemCount: controller.placesDestination.length, + itemBuilder: (context, index) { + final place = controller.placesDestination[index]; + final distance = place['distanceKm'] as double?; + final address = (place['address'] ?? '').toString(); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => controller.selectDestination(place), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, vertical: 12.0), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: kPrimaryColor.withOpacity(0.1), + shape: BoxShape.circle), + child: const Icon(Icons.location_on_outlined, + color: kPrimaryColor, size: 20), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(place['name'] ?? 'ุงุณู… ุบูŠุฑ ู…ุนุฑูˆู', + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + color: Colors.black87), + maxLines: 1, + overflow: TextOverflow.ellipsis), + if (address.isNotEmpty) + Text(address, + style: const TextStyle( + color: Colors.black54, fontSize: 13), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ], + ), + ), + const SizedBox(width: 10), + if (distance != null) + Text('${distance.toStringAsFixed(1)} ูƒู…', + style: const TextStyle( + color: kPrimaryColor, + fontWeight: FontWeight.w500, + fontSize: 13)), + ], + ), + ), + ), + ); + }, + ), + ), + ), + ); + } + + Widget _buildFloatingMapControls(NavigationController controller) { + return Positioned( + bottom: controller.currentInstruction.isNotEmpty ? 190 : 24, + right: 16, + child: Column( + children: [ + if (controller.destinationSymbol != null) ...[ + // Changed condition + FloatingActionButton( + heroTag: 'rerouteBtn', + backgroundColor: Colors.white, + elevation: 6, + onPressed: () => controller.recalculateRoute(), + tooltip: 'ุฅุนุงุฏุฉ ุญุณุงุจ ุงู„ู…ุณุงุฑ', + child: const Icon(Icons.sync_alt, color: kPrimaryColor, size: 24), + ), + const SizedBox(height: 12), + ], + FloatingActionButton( + heroTag: 'gpsBtn', + backgroundColor: Colors.white, + elevation: 6, + onPressed: () { + if (controller.myLocation != null) { + controller.animateCameraToPosition(controller.myLocation!, + bearing: controller.heading, zoom: 18.5); + } + }, + child: const Icon(Icons.gps_fixed, color: Colors.black54, size: 24), + ), + ], + ), + ); + } + + Widget _buildFloatingNavigationUI(NavigationController controller) { + return Positioned( + bottom: 16, + left: 16, + right: 16, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF1E88E5), Color(0xFF0D47A1)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + borderRadius: BorderRadius.circular(28), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 25, + offset: const Offset(0, 10)) + ], + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(22, 20, 22, 22), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + shape: BoxShape.circle), + child: const Icon(Icons.navigation_rounded, + color: Colors.white, size: 28), + ), + const SizedBox(width: 16), + Expanded( + child: Text( + controller.currentInstruction, + style: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.bold, + height: 1.3), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 16), + Text(controller.distanceToNextStep, + style: const TextStyle( + color: Colors.white, + fontSize: 32, + fontWeight: FontWeight.bold)), + ], + ), + if (controller.nextInstruction.isNotEmpty || + controller.currentSpeed > 0) + const Padding( + padding: EdgeInsets.symmetric(vertical: 14.0), + child: Divider(color: Colors.white30, height: 1), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: controller.nextInstruction.isNotEmpty + ? Text( + 'ุงู„ุชุงู„ูŠ: ${controller.nextInstruction}', + style: const TextStyle( + color: Colors.white70, + fontSize: 15, + fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : const SizedBox(), + ), + Row( + children: [ + Text(controller.currentSpeed.toStringAsFixed(0), + style: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.bold)), + const SizedBox(width: 6), + const Text('ูƒู…/ุณ', + style: TextStyle( + color: Colors.white70, + fontSize: 14, + fontWeight: FontWeight.w500)), + ], + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/home/profile/order_history.dart b/lib/views/home/profile/order_history.dart index a59beae..32b0fd5 100644 --- a/lib/views/home/profile/order_history.dart +++ b/lib/views/home/profile/order_history.dart @@ -1,21 +1,26 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:get/get.dart'; -import 'package:Intaleq/constant/style.dart'; -import 'package:Intaleq/views/widgets/my_scafold.dart'; -import 'package:Intaleq/views/widgets/mycircular.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import '../../../constant/colors.dart'; +import '../../../constant/style.dart'; import '../../../controller/functions/launch.dart'; import '../../../controller/home/profile/order_history_controller.dart'; +import '../../widgets/my_scafold.dart'; +import '../../widgets/mycircular.dart'; -// --- ุงู„ูˆูŠุฏุฌุช ุงู„ุฑุฆูŠุณูŠุฉ ุจุงู„ุชุตู…ูŠู… ุงู„ุฌุฏูŠุฏ --- +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Main Screen +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ class OrderHistory extends StatelessWidget { const OrderHistory({super.key}); @override Widget build(BuildContext context) { - // ู†ูุณ ู…ู†ุทู‚ ุงุณุชุฏุนุงุก ุงู„ูƒู†ุชุฑูˆู„ุฑ Get.put(OrderHistoryController()); return MyScafolld( @@ -24,7 +29,6 @@ class OrderHistory extends StatelessWidget { body: [ GetBuilder( builder: (controller) { - // --- ู†ูุณ ู…ู†ุทู‚ ุงู„ุชุญู…ูŠู„ ูˆุงู„ุญุงู„ุฉ ุงู„ูุงุฑุบุฉ --- if (controller.isloading) { return const MyCircularProgressIndicator(); } @@ -33,159 +37,550 @@ class OrderHistory extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.map_outlined, - size: 80, color: AppColor.writeColor.withOpacity(0.4)), + Icon(Icons.route_outlined, + size: 80, color: AppColor.writeColor.withOpacity(0.3)), const SizedBox(height: 16), Text('No trip history found'.tr, style: AppStyle.headTitle2), - Text("Your past trips will appear here.".tr, + const SizedBox(height: 6), + Text('Your past trips will appear here.'.tr, style: AppStyle.subtitle), ], ), ); } - // --- ุงุณุชุฎุฏุงู… ListView.separated ู„ูุตู„ ุงู„ุจุทุงู‚ุงุช --- return ListView.separated( - padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), itemCount: controller.orderHistoryListPassenger.length, - separatorBuilder: (context, index) => const SizedBox(height: 16), - itemBuilder: (BuildContext context, int index) { + separatorBuilder: (_, __) => const SizedBox(height: 14), + itemBuilder: (context, index) { final ride = controller.orderHistoryListPassenger[index]; - // --- ุงุณุชุฏุนุงุก ูˆูŠุฏุฌุช ุงู„ุจุทุงู‚ุฉ ุงู„ุฌุฏูŠุฏุฉ --- - return _buildHistoryCard(context, ride); + return _HistoryCard( + key: ValueKey(ride['id'] ?? index), + ride: ride, + ); }, ); }, - ) + ), ], ); } +} - // --- ูˆูŠุฏุฌุช ุจู†ุงุก ุจุทุงู‚ุฉ ุงู„ุฑุญู„ุฉ --- - Widget _buildHistoryCard(BuildContext context, Map ride) { - // --- ู†ูุณ ู…ู†ุทู‚ ุญุณุงุจ ุฅุญุฏุงุซูŠุงุช ุงู„ุฎุฑูŠุทุฉ --- - final LatLng startLocation = LatLng( - double.parse(ride['start_location'].toString().split(',')[0]), - double.parse(ride['start_location'].toString().split(',')[1]), - ); - final LatLng endLocation = LatLng( - double.parse(ride['end_location'].toString().split(',')[0]), - double.parse(ride['end_location'].toString().split(',')[1]), - ); - - final LatLngBounds bounds = LatLngBounds( - northeast: LatLng( - startLocation.latitude > endLocation.latitude - ? startLocation.latitude - : endLocation.latitude, - startLocation.longitude > endLocation.longitude - ? startLocation.longitude - : endLocation.longitude, - ), - southwest: LatLng( - startLocation.latitude < endLocation.latitude - ? startLocation.latitude - : endLocation.latitude, - startLocation.longitude < endLocation.longitude - ? startLocation.longitude - : endLocation.longitude, - ), - ); +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Coordinate helpers +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +LatLng _parseLatLng(String raw, LatLng fallback) { + try { + final parts = raw.split(','); + return LatLng(double.parse(parts[0]), double.parse(parts[1])); + } catch (_) { + return fallback; + } +} - return InkWell( - // --- ู†ูุณ ุฏุงู„ุฉ onTap ุงู„ู‚ุฏูŠู…ุฉ --- - onTap: () { - String mapUrl = - 'https://www.google.com/maps/dir/${ride['start_location']}/${ride['end_location']}/'; - showInBrowser(mapUrl); - }, - borderRadius: BorderRadius.circular(16), - child: Container( - decoration: BoxDecoration( - color: AppColor.secondaryColor, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.15), - blurRadius: 8, - offset: const Offset(0, 4), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // --- 1. ู‚ุณู… ุงู„ุฎุฑูŠุทุฉ --- - ClipRRect( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), +const LatLng _kDamascus = LatLng(33.5, 36.3); + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Lightweight card โ€” NO native map in the list +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +class _HistoryCard extends StatelessWidget { + final Map ride; + const _HistoryCard({Key? key, required this.ride}) : super(key: key); + + @override + Widget build(BuildContext context) { + final start = _parseLatLng(ride['start_location'] ?? '', _kDamascus); + final end = _parseLatLng(ride['end_location'] ?? '', _kDamascus); + final status = ride['status'] ?? ''; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _openDetail(context, ride, start, end), + borderRadius: BorderRadius.circular(18), + child: Ink( + decoration: BoxDecoration( + color: AppColor.secondaryColor, + borderRadius: BorderRadius.circular(18), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.12), + blurRadius: 10, + offset: const Offset(0, 4), ), - child: SizedBox( - height: 150, // ุงุฑุชูุงุน ุซุงุจุช ู„ู„ุฎุฑูŠุทุฉ - child: AbsorbPointer( - // ู„ู…ู†ุน ุงู„ุชูุงุนู„ ุงู„ู…ุจุงุดุฑ ู…ุน ุงู„ุฎุฑูŠุทุฉ ุฏุงุฎู„ ุงู„ู‚ุงุฆู…ุฉ - child: MapLibreMap( - styleString: "assets/style.json", - initialCameraPosition: - CameraPosition(target: startLocation, zoom: 12), - onStyleLoadedCallback: () async { - // This is a bit tricky in a list, but we can do it: - // Since we don't have the controller here easily without state, - // we'll rely on the simple map view or use a stateful widget for each card. - // For now, let's keep it simple. - }, - onMapCreated: (MapLibreMapController controller) async { - await controller.addSymbol(SymbolOptions( - geometry: startLocation, - iconImage: 'start_icon', - )); - await controller.addSymbol(SymbolOptions( - geometry: endLocation, - iconImage: 'end_icon', - )); - await controller.addLine(LineOptions( - geometry: [startLocation, endLocation], - lineColor: '#${AppColor.primaryColor.value.toRadixString(16).substring(2)}', - lineWidth: 4, - )); - - controller.animateCamera( - CameraUpdate.newLatLngBounds(bounds, left: 20, top: 20, right: 20, bottom: 20)); - }, + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // โ”€โ”€ Lightweight route preview (pure Flutter, zero native cost) โ”€โ”€ + ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(18), + topRight: Radius.circular(18), + ), + child: SizedBox( + height: 130, + width: double.infinity, + child: CustomPaint( + painter: _RoutePainter(start: start, end: end), + child: Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: const EdgeInsets.all(8), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.45), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.map_outlined, + color: Colors.white, size: 13), + const SizedBox(width: 4), + Text('View Map'.tr, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600)), + ], + ), + ), + ), + ), ), ), ), + + // โ”€โ”€ Details โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Padding( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(Icons.access_time_rounded, + size: 14, + color: AppColor.writeColor.withOpacity(0.5)), + const SizedBox(width: 4), + Text( + '${ride['date']} ยท ${ride['time']}', + style: AppStyle.subtitle.copyWith( + fontSize: 12, + color: AppColor.writeColor.withOpacity(0.6)), + ), + ], + ), + _StatusChip(status: status), + ], + ), + const Divider(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Total Price'.tr, + style: AppStyle.title.copyWith(fontSize: 15)), + Text( + '${ride['price']} ${'SYP'.tr}', + style: AppStyle.headTitle.copyWith( + fontSize: 20, color: AppColor.primaryColor), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + void _openDetail(BuildContext context, Map ride, + LatLng start, LatLng end) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _RideDetailSheet(ride: ride, start: start, end: end), + ); + } +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Pure-Flutter route painter โ€” grid background + animated dashed line +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +class _RoutePainter extends CustomPainter { + final LatLng start; + final LatLng end; + + const _RoutePainter({required this.start, required this.end}); + + @override + void paint(Canvas canvas, Size size) { + // Background gradient + final bgPaint = Paint() + ..shader = LinearGradient( + colors: [ + AppColor.primaryColor.withOpacity(0.08), + AppColor.primaryColor.withOpacity(0.18), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)); + canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), bgPaint); + + // Subtle grid + final gridPaint = Paint() + ..color = AppColor.primaryColor.withOpacity(0.06) + ..strokeWidth = 1; + const step = 20.0; + for (double x = 0; x < size.width; x += step) { + canvas.drawLine(Offset(x, 0), Offset(x, size.height), gridPaint); + } + for (double y = 0; y < size.height; y += step) { + canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint); + } + + // Map lat/lng to canvas โ€” simple linear projection + final minLat = math.min(start.latitude, end.latitude); + final maxLat = math.max(start.latitude, end.latitude); + final minLng = math.min(start.longitude, end.longitude); + final maxLng = math.max(start.longitude, end.longitude); + + final latRange = maxLat - minLat; + final lngRange = maxLng - minLng; + + final pad = 32.0; + + Offset project(LatLng p) { + double x, y; + if (lngRange < 0.0005) { + x = size.width / 2; + } else { + x = pad + ((p.longitude - minLng) / lngRange) * (size.width - 2 * pad); + } + if (latRange < 0.0005) { + y = size.height / 2; + } else { + // Invert y (latitude grows up, canvas grows down) + y = size.height - + pad - + ((p.latitude - minLat) / latRange) * (size.height - 2 * pad); + } + return Offset(x, y); + } + + final startPt = project(start); + final endPt = project(end); + + // Dashed route line + final linePaint = Paint() + ..color = AppColor.primaryColor + ..strokeWidth = 2.5 + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke; + + _drawDashedLine(canvas, startPt, endPt, linePaint, 8, 5); + + // Glow behind markers + final glowPaint = Paint() + ..color = AppColor.primaryColor.withOpacity(0.2) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8); + canvas.drawCircle(startPt, 14, glowPaint); + canvas.drawCircle(endPt, 14, glowPaint); + + // Start dot (A) + _drawMarker(canvas, startPt, AppColor.primaryColor, 'A'); + + // End dot (B) + _drawMarker(canvas, endPt, AppColor.redColor, 'B'); + } + + void _drawDashedLine(Canvas canvas, Offset p1, Offset p2, Paint paint, + double dashLen, double gapLen) { + final dx = p2.dx - p1.dx; + final dy = p2.dy - p1.dy; + final dist = math.sqrt(dx * dx + dy * dy); + if (dist == 0) return; + final ux = dx / dist; + final uy = dy / dist; + double traveled = 0; + bool drawing = true; + while (traveled < dist) { + final segLen = drawing ? dashLen : gapLen; + final next = math.min(traveled + segLen, dist); + if (drawing) { + canvas.drawLine( + Offset(p1.dx + ux * traveled, p1.dy + uy * traveled), + Offset(p1.dx + ux * next, p1.dy + uy * next), + paint, + ); + } + traveled = next; + drawing = !drawing; + } + } + + void _drawMarker(Canvas canvas, Offset center, Color color, String label) { + // Outer ring + canvas.drawCircle(center, 12, Paint()..color = color.withOpacity(0.25)); + // Solid circle + canvas.drawCircle(center, 8, Paint()..color = color); + // White inner + canvas.drawCircle(center, 4, Paint()..color = Colors.white); + // Label text + final tp = TextPainter( + text: TextSpan( + text: label, + style: TextStyle( + color: color, fontSize: 7, fontWeight: FontWeight.w900)), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, center - Offset(tp.width / 2, tp.height / 2)); + } + + @override + bool shouldRepaint(_RoutePainter old) => old.start != start || old.end != end; +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Status chip +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +class _StatusChip extends StatelessWidget { + final String status; + const _StatusChip({required this.status}); + + @override + Widget build(BuildContext context) { + Color color; + IconData icon; + + if (status == 'Canceled'.tr) { + color = AppColor.redColor; + icon = Icons.cancel_outlined; + } else if (status == 'Finished'.tr) { + color = AppColor.greenColor; + icon = Icons.check_circle_outline; + } else { + color = AppColor.yellowColor; + icon = Icons.hourglass_empty_rounded; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withOpacity(0.3), width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 13), + const SizedBox(width: 4), + Text(status, + style: AppStyle.subtitle.copyWith( + color: color, fontWeight: FontWeight.bold, fontSize: 11)), + ], + ), + ); + } +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Detail bottom sheet โ€” ONE MapLibre instance, only when user requests it +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +class _RideDetailSheet extends StatefulWidget { + final Map ride; + final LatLng start; + final LatLng end; + const _RideDetailSheet( + {required this.ride, required this.start, required this.end}); + + @override + State<_RideDetailSheet> createState() => _RideDetailSheetState(); +} + +class _RideDetailSheetState extends State<_RideDetailSheet> { + MapLibreMapController? _mc; + + LatLngBounds? get _bounds { + final latDiff = (widget.start.latitude - widget.end.latitude).abs(); + final lngDiff = (widget.start.longitude - widget.end.longitude).abs(); + if (latDiff < 0.0005 && lngDiff < 0.0005) return null; + return LatLngBounds( + northeast: LatLng( + math.max(widget.start.latitude, widget.end.latitude), + math.max(widget.start.longitude, widget.end.longitude), + ), + southwest: LatLng( + math.min(widget.start.latitude, widget.end.latitude), + math.min(widget.start.longitude, widget.end.longitude), + ), + ); + } + + void _onMapCreated(MapLibreMapController c) => _mc = c; + + Future _onStyleLoaded() async { + WidgetsBinding.instance.addPostFrameCallback((_) async { + await Future.delayed(const Duration(milliseconds: 400)); + if (!mounted || _mc == null) return; + await _draw(); + }); + } + + Future _draw() async { + final mc = _mc; + if (mc == null || !mounted) return; + + try { + final aData = await rootBundle.load('assets/images/A.png'); + await mc.addImage('det_start', aData.buffer.asUint8List()); + final bData = await rootBundle.load('assets/images/b.png'); + await mc.addImage('det_end', bData.buffer.asUint8List()); + } catch (_) {} + + await mc.addLine(LineOptions( + geometry: [widget.start, widget.end], + lineColor: + '#${AppColor.primaryColor.value.toRadixString(16).substring(2)}', + lineWidth: 4.0, + lineOpacity: 1.0, + )); + await mc.addSymbol(SymbolOptions( + geometry: widget.start, + iconImage: 'det_start', + iconSize: 0.8, + iconAnchor: 'bottom', + )); + await mc.addSymbol(SymbolOptions( + geometry: widget.end, + iconImage: 'det_end', + iconSize: 0.8, + iconAnchor: 'bottom', + )); + + final b = _bounds; + if (b != null) { + await mc.animateCamera(CameraUpdate.newLatLngBounds(b, + left: 60, top: 60, right: 60, bottom: 60)); + } else { + await mc.animateCamera(CameraUpdate.newLatLngZoom(widget.start, 14)); + } + } + + @override + void dispose() { + _mc?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final ride = widget.ride; + final center = LatLng( + (widget.start.latitude + widget.end.latitude) / 2, + (widget.start.longitude + widget.end.longitude) / 2, + ); + + return DraggableScrollableSheet( + initialChildSize: 0.88, + minChildSize: 0.5, + maxChildSize: 0.95, + builder: (_, scrollController) => Container( + decoration: BoxDecoration( + color: AppColor.secondaryColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + children: [ + // Handle + Container( + margin: const EdgeInsets.symmetric(vertical: 10), + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColor.writeColor.withOpacity(0.2), + borderRadius: BorderRadius.circular(2), + ), ), - // --- 2. ู‚ุณู… ุชูุงุตูŠู„ ุงู„ุฑุญู„ุฉ --- - Padding( - padding: const EdgeInsets.all(12.0), + + // Map โ€” only ONE instance, created on demand + Expanded( + child: ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + child: MapLibreMap( + styleString: 'assets/style.json', + initialCameraPosition: + CameraPosition(target: center, zoom: 12), + onMapCreated: _onMapCreated, + onStyleLoadedCallback: _onStyleLoaded, + myLocationEnabled: false, + trackCameraPosition: false, + ), + ), + ), + + // Trip info strip + Container( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 20), child: Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - '${ride['date']} - ${ride['time']}', - style: AppStyle.subtitle.copyWith( - color: AppColor.writeColor.withOpacity(0.7)), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${ride['date']} ยท ${ride['time']}', + style: AppStyle.subtitle.copyWith( + fontSize: 12, + color: + AppColor.writeColor.withOpacity(0.55))), + const SizedBox(height: 2), + Text('${ride['price']} ${'SYP'.tr}', + style: AppStyle.headTitle.copyWith( + fontSize: 22, color: AppColor.primaryColor)), + ], ), - // --- ูˆูŠุฏุฌุช ุฌุฏูŠุฏุฉ ู„ุนุฑุถ ุญุงู„ุฉ ุงู„ุฑุญู„ุฉ --- - _buildStatusChip(ride['status']), + _StatusChip(status: ride['status'] ?? ''), ], ), - const Divider(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Total Price'.tr, - style: AppStyle.title.copyWith(fontSize: 16)), - Text( - '${ride['price']} ${'SYP'.tr}', - style: AppStyle.headTitle.copyWith( - fontSize: 20, color: AppColor.primaryColor), + const SizedBox(height: 14), + // Open in Google Maps + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + final url = + 'https://www.google.com/maps/dir/${ride['start_location']}/${ride['end_location']}/'; + showInBrowser(url); + }, + icon: const Icon(Icons.open_in_new, size: 16), + label: Text('Open in Google Maps'.tr), + style: ElevatedButton.styleFrom( + backgroundColor: AppColor.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 13), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), ), - ], + ), ), ], ), @@ -195,42 +590,4 @@ class OrderHistory extends StatelessWidget { ), ); } - - // --- ูˆูŠุฏุฌุช ู…ุณุงุนุฏุฉ ู„ุนุฑุถ ุญุงู„ุฉ ุงู„ุฑุญู„ุฉ ุจุดูƒู„ ุฃู†ูŠู‚ --- - Widget _buildStatusChip(String status) { - Color chipColor; - IconData chipIcon; - - // --- ู†ูุณ ู…ู†ุทู‚ ุชุญุฏูŠุฏ ุงู„ู„ูˆู† --- - if (status == 'Canceled'.tr) { - chipColor = AppColor.redColor; - chipIcon = Icons.cancel_outlined; - } else if (status == 'Finished'.tr) { - chipColor = AppColor.greenColor; - chipIcon = Icons.check_circle_outline; - } else { - chipColor = AppColor.yellowColor; - chipIcon = Icons.hourglass_empty_rounded; - } - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: chipColor.withOpacity(0.15), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(chipIcon, color: chipColor, size: 16), - const SizedBox(width: 6), - Text( - status, - style: AppStyle.subtitle.copyWith( - color: chipColor, fontWeight: FontWeight.bold, fontSize: 12), - ), - ], - ), - ); - } } diff --git a/lib/views/home/profile/passenger_profile_page.dart b/lib/views/home/profile/passenger_profile_page.dart index e4fe7e7..ae5e76c 100644 --- a/lib/views/home/profile/passenger_profile_page.dart +++ b/lib/views/home/profile/passenger_profile_page.dart @@ -1,36 +1,195 @@ -import 'package:flutter/cupertino.dart'; +import 'dart:io'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:image_cropper/image_cropper.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:http/http.dart' as http; +import 'package:Intaleq/constant/api_key.dart'; import 'package:Intaleq/constant/box_name.dart'; import 'package:Intaleq/constant/colors.dart'; -import 'package:Intaleq/constant/style.dart'; +import 'package:Intaleq/constant/links.dart'; import 'package:Intaleq/controller/profile/profile_controller.dart'; import 'package:Intaleq/main.dart'; -import 'package:Intaleq/views/auth/login_page.dart'; import 'package:Intaleq/views/widgets/elevated_btn.dart'; import 'package:Intaleq/views/widgets/my_textField.dart'; import 'package:Intaleq/views/widgets/mycircular.dart'; -import '../../../controller/auth/login_controller.dart'; import '../../../controller/functions/log_out.dart'; -class PassengerProfilePage extends StatelessWidget { - PassengerProfilePage({super.key}); +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Main Page +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +class PassengerProfilePage extends StatefulWidget { + const PassengerProfilePage({super.key}); - final LogOutController logOutController = Get.put(LogOutController()); + @override + State createState() => _PassengerProfilePageState(); +} + +class _PassengerProfilePageState extends State { + late final LogOutController logOutController; + File? _pickedImage; + bool _isUploadingImage = false; + + @override + void initState() { + super.initState(); + logOutController = Get.put(LogOutController()); + Get.put(ProfileController()); + } + + // โ”€โ”€โ”€ Image Handling โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + Future _pickAndUploadImage() async { + // 1. Pick source + final ImageSource? source = await _showImageSourceSheet(); + if (source == null) return; + + // 2. Pick image + final XFile? picked = + await ImagePicker().pickImage(source: source, imageQuality: 85); + if (picked == null) return; + + // 3. Crop + final CroppedFile? cropped = await ImageCropper().cropImage( + sourcePath: picked.path, + aspectRatio: const CropAspectRatio(ratioX: 1, ratioY: 1), + uiSettings: [ + AndroidUiSettings( + toolbarTitle: 'Crop Photo'.tr, + toolbarColor: AppColor.primaryColor, + toolbarWidgetColor: Colors.white, + initAspectRatio: CropAspectRatioPreset.square, + lockAspectRatio: true, + hideBottomControls: false, + ), + IOSUiSettings( + title: 'Crop Photo'.tr, + aspectRatioLockEnabled: true, + resetAspectRatioEnabled: false, + ), + ], + ); + if (cropped == null) return; + + // 4. Upload + setState(() { + _pickedImage = File(cropped.path); + _isUploadingImage = true; + }); + + try { + final passengerId = box.read(BoxName.passengerID).toString(); + final uri = Uri.parse('${AK.serverPHP}/upload_passenger_image.php'); + final request = http.MultipartRequest('POST', uri) + ..fields['passenger_id'] = passengerId + ..files.add(await http.MultipartFile.fromPath('image', cropped.path)); + + final response = await request.send(); + if (response.statusCode == 200) { + Get.snackbar( + 'Success'.tr, + 'Profile photo updated'.tr, + backgroundColor: Colors.green.withOpacity(0.9), + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + margin: const EdgeInsets.all(16), + borderRadius: 12, + ); + } else { + _showUploadError(); + } + } catch (_) { + _showUploadError(); + } finally { + setState(() => _isUploadingImage = false); + } + } + + void _showUploadError() { + Get.snackbar( + 'Error'.tr, + 'Failed to upload photo'.tr, + backgroundColor: Colors.red.withOpacity(0.9), + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + margin: const EdgeInsets.all(16), + borderRadius: 12, + ); + } + + Future _showImageSourceSheet() async { + return await showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + margin: const EdgeInsets.only(top: 10, bottom: 6), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2)), + ), + const SizedBox(height: 8), + Text('Change Photo'.tr, + style: + const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColor.primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.camera_alt_outlined, + color: AppColor.primaryColor), + ), + title: Text('Take a Photo'.tr, + style: const TextStyle(fontWeight: FontWeight.w500)), + onTap: () => Navigator.pop(context, ImageSource.camera), + ), + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.photo_library_outlined, + color: Colors.blue), + ), + title: Text('Choose from Gallery'.tr, + style: const TextStyle(fontWeight: FontWeight.w500)), + onTap: () => Navigator.pop(context, ImageSource.gallery), + ), + const SizedBox(height: 12), + ], + ), + ), + ); + } + + // โ”€โ”€โ”€ Build โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @override Widget build(BuildContext context) { - Get.put(ProfileController()); - return Scaffold( - backgroundColor: Colors.grey[100], + backgroundColor: const Color(0xFFF4F6F9), appBar: AppBar( title: Text('My Profile'.tr, - style: const TextStyle(fontWeight: FontWeight.bold)), - backgroundColor: Colors.grey[100], + style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 20)), + backgroundColor: const Color(0xFFF4F6F9), elevation: 0, centerTitle: true, + iconTheme: const IconThemeData(color: Colors.black87), ), body: GetBuilder( builder: (controller) { @@ -38,82 +197,129 @@ class PassengerProfilePage extends StatelessWidget { return const MyCircularProgressIndicator(); } return ListView( - padding: - const EdgeInsets.symmetric(horizontal: 16.0, vertical: 10.0), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + physics: const BouncingScrollPhysics(), children: [ _buildProfileHeader(controller), - const SizedBox(height: 24), + const SizedBox(height: 32), _buildSectionCard( - 'Personal Information'.tr, - [ - _buildProfileTile( - icon: Icons.person_outline, - color: Colors.blue, + title: 'Personal Information'.tr, + sectionIcon: Icons.person_outline_rounded, + children: [ + _buildTile( + icon: Icons.badge_outlined, + color: Colors.blueAccent, title: 'Name'.tr, subtitle: - '${controller.prfoileData['first_name'] ?? ''} ${controller.prfoileData['last_name'] ?? ''}', - onTap: () => - controller.updatField('first_name', TextInputType.name), + '${controller.prfoileData['first_name'] ?? ''} ${controller.prfoileData['last_name'] ?? ''}' + .trim(), + onTap: () => _showNameSheet(controller), ), - _buildProfileTile( + _divider(), + _buildTile( icon: Icons.wc_outlined, - color: Colors.pink, + color: Colors.pinkAccent, title: 'Gender'.tr, subtitle: controller.prfoileData['gender']?.toString() ?? 'Not set'.tr, - onTap: () => _showGenderDialog(controller), + onTap: () => _showGenderSheet(controller), ), - _buildProfileTile( + _divider(), + _buildTile( icon: Icons.school_outlined, - color: Colors.orange, + color: Colors.orangeAccent, title: 'Education'.tr, subtitle: controller.prfoileData['education']?.toString() ?? 'Not set'.tr, - onTap: () => _showEducationDialog(controller), + onTap: () => _showEducationSheet(controller), ), ], ), - const SizedBox(height: 24), + const SizedBox(height: 20), _buildSectionCard( - 'Work & Contact'.tr, - [ - _buildProfileTile( + title: 'Work & Contact'.tr, + sectionIcon: Icons.work_outline_rounded, + children: [ + _buildTile( icon: Icons.work_outline, color: Colors.green, title: 'Employment Type'.tr, subtitle: controller.prfoileData['employmentType']?.toString() ?? 'Not set'.tr, - onTap: () => controller.updatField( - 'employmentType', TextInputType.name), + onTap: () => _showFieldSheet( + controller: controller, + fieldKey: 'employmentType', + title: 'Employment Type'.tr, + icon: Icons.work_outline, + iconColor: Colors.green, + keyboardType: TextInputType.text, + ), ), - _buildProfileTile( + _divider(), + _buildTile( icon: Icons.favorite_border, - color: Colors.purple, + color: Colors.purpleAccent, title: 'Marital Status'.tr, subtitle: controller.prfoileData['maritalStatus']?.toString() ?? 'Not set'.tr, - onTap: () => controller.updatField( - 'maritalStatus', TextInputType.name), + onTap: () => _showFieldSheet( + controller: controller, + fieldKey: 'maritalStatus', + title: 'Marital Status'.tr, + icon: Icons.favorite_border, + iconColor: Colors.purpleAccent, + keyboardType: TextInputType.text, + ), ), - _buildProfileTile( + _divider(), + _buildTile( icon: Icons.sos_outlined, - color: Colors.red, + color: Colors.redAccent, title: 'SOS Phone'.tr, subtitle: controller.prfoileData['sosPhone']?.toString() ?? 'Not set'.tr, - onTap: () async { - await controller.updatField( - 'sosPhone', TextInputType.phone); - box.write(BoxName.sosPhonePassenger, - controller.prfoileData['sosPhone']); - }, + onTap: () => _showFieldSheet( + controller: controller, + fieldKey: 'sosPhone', + title: 'SOS Phone'.tr, + icon: Icons.sos_outlined, + iconColor: Colors.redAccent, + keyboardType: TextInputType.phone, + onSaved: () { + box.write(BoxName.sosPhonePassenger, + controller.prfoileData['sosPhone']); + }, + ), + ), + ], + ), + const SizedBox(height: 20), + _buildSectionCard( + title: 'Account'.tr, + sectionIcon: Icons.manage_accounts_outlined, + children: [ + _buildTile( + icon: Icons.logout_rounded, + color: Colors.blueGrey, + title: 'Sign Out'.tr, + subtitle: '', + onTap: () => logOutController.logOutPassenger(), + trailing: const SizedBox.shrink(), + ), + _divider(), + _buildTile( + icon: Icons.delete_forever_outlined, + color: Colors.redAccent, + title: 'Delete My Account'.tr, + subtitle: '', + onTap: () => _showDeleteSheet(), + trailing: const SizedBox.shrink(), ), ], ), const SizedBox(height: 32), - _buildAccountActions(context, logOutController), ], ); }, @@ -121,264 +327,730 @@ class PassengerProfilePage extends StatelessWidget { ); } - Widget _buildProfileHeader(ProfileController controller) { - String fullName = - '${controller.prfoileData['first_name'] ?? ''} ${controller.prfoileData['last_name'] ?? ''}'; - String initials = (fullName.isNotEmpty && fullName.contains(" ")) - ? fullName.split(" ").map((e) => e.isNotEmpty ? e[0] : "").join() - : (fullName.isNotEmpty ? fullName[0] : ""); + // โ”€โ”€โ”€ Profile Header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + Widget _buildProfileHeader(ProfileController controller) { + final firstName = controller.prfoileData['first_name']?.toString() ?? ''; + final lastName = controller.prfoileData['last_name']?.toString() ?? ''; + final fullName = '$firstName $lastName'.trim(); + final passengerId = box.read(BoxName.passengerID)?.toString() ?? ''; - // Logic to hide email if it contains 'intaleqapp.com' String email = box.read(BoxName.email) ?? ''; - if (email.contains('intaleqapp.com')) { - email = ''; // Clear the email if it contains the domain - } + if (email.contains('intaleqapp.com')) email = ''; + + final initials = fullName.isNotEmpty + ? fullName + .split(' ') + .where((e) => e.isNotEmpty) + .map((e) => e[0].toUpperCase()) + .take(2) + .join() + : '?'; + + final avatarUrl = + '${AK.serverPHP}/portrate_passenger_image/$passengerId.jpg'; return Center( child: Column( children: [ - CircleAvatar( - radius: 50, - backgroundColor: AppColor.primaryColor.withOpacity(0.2), - child: Text( - initials, - style: - const TextStyle(fontSize: 40, color: AppColor.primaryColor), - ), + // โ”€โ”€ Avatar with camera button โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Stack( + alignment: Alignment.bottomRight, + children: [ + // Outer glow ring + Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + AppColor.primaryColor.withOpacity(0.6), + AppColor.primaryColor.withOpacity(0.1), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: CircleAvatar( + radius: 52, + backgroundColor: Colors.grey[100], + child: ClipOval( + child: _isUploadingImage + ? const SizedBox( + width: 104, + height: 104, + child: Center( + child: CircularProgressIndicator( + color: AppColor.primaryColor, + strokeWidth: 2, + ), + ), + ) + : _pickedImage != null + ? Image.file( + _pickedImage!, + width: 104, + height: 104, + fit: BoxFit.cover, + ) + : CachedNetworkImage( + imageUrl: avatarUrl, + width: 104, + height: 104, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + width: 104, + height: 104, + color: + AppColor.primaryColor.withOpacity(0.08), + child: Center( + child: Text( + initials, + style: const TextStyle( + fontSize: 34, + fontWeight: FontWeight.bold, + color: AppColor.primaryColor, + ), + ), + ), + ), + errorWidget: (_, __, ___) => Container( + width: 104, + height: 104, + color: + AppColor.primaryColor.withOpacity(0.08), + child: Center( + child: Text( + initials, + style: const TextStyle( + fontSize: 34, + fontWeight: FontWeight.bold, + color: AppColor.primaryColor, + ), + ), + ), + ), + ), + ), + ), + ), + + // Camera button + GestureDetector( + onTap: _isUploadingImage ? null : _pickAndUploadImage, + child: Container( + width: 36, + height: 36, + margin: const EdgeInsets.only(right: 2, bottom: 2), + decoration: BoxDecoration( + color: AppColor.primaryColor, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2.5), + boxShadow: [ + BoxShadow( + color: AppColor.primaryColor.withOpacity(0.4), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + child: const Icon(Icons.camera_alt_rounded, + color: Colors.white, size: 17), + ), + ), + ], ), - const SizedBox(height: 12), - Text( - fullName, - style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold), + + const SizedBox(height: 16), + + // Name + edit hint + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + fullName.isEmpty ? 'Passenger'.tr : fullName, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + const SizedBox(width: 6), + GestureDetector( + onTap: () => _showNameSheet(Get.find()), + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: AppColor.primaryColor.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: const Icon(Icons.edit_rounded, + size: 14, color: AppColor.primaryColor), + ), + ), + ], ), - if (email - .isNotEmpty) // Only show the Text widget if the email is not empty + + if (email.isNotEmpty) ...[ + const SizedBox(height: 4), Text( email, - style: TextStyle(fontSize: 16, color: Colors.grey[600]), + style: TextStyle( + fontSize: 13, + color: Colors.grey[500], + fontWeight: FontWeight.w500), ), + ], + + const SizedBox(height: 10), + + // Passenger badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 5), + decoration: BoxDecoration( + color: AppColor.primaryColor.withOpacity(0.08), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColor.primaryColor.withOpacity(0.2)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.verified_user_rounded, + size: 13, color: AppColor.primaryColor), + const SizedBox(width: 5), + Text( + 'Verified Passenger'.tr, + style: const TextStyle( + fontSize: 12, + color: AppColor.primaryColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), ], ), ); } - Widget _buildSectionCard(String title, List children) { + // โ”€โ”€โ”€ Section Card โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + Widget _buildSectionCard({ + required String title, + required IconData sectionIcon, + required List children, + }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.only(left: 8.0, bottom: 8.0), - child: Text( - title, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.grey[700]), + padding: const EdgeInsets.only(left: 4, bottom: 10), + child: Row( + children: [ + Icon(sectionIcon, size: 14, color: Colors.grey[500]), + const SizedBox(width: 6), + Text( + title.toUpperCase(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.grey[500], + letterSpacing: 1.3, + ), + ), + ], ), ), Container( decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - children: children, + borderRadius: BorderRadius.circular(18), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], ), + child: Column(children: children), ), ], ); } - Widget _buildProfileTile({ + Widget _buildTile({ required IconData icon, required Color color, required String title, required String subtitle, required VoidCallback onTap, + Widget? trailing, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(18), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: color, size: 21), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14.5, + color: Colors.black87)), + if (subtitle.isNotEmpty) ...[ + const SizedBox(height: 3), + Text(subtitle, + style: + TextStyle(color: Colors.grey[500], fontSize: 12.5)), + ], + ], + ), + ), + trailing ?? + Icon(Icons.arrow_forward_ios_rounded, + size: 13, color: Colors.grey[350]), + ], + ), + ), + ); + } + + Widget _divider() => + Divider(height: 1, thickness: 1, indent: 72, color: Colors.grey[100]); + + // โ”€โ”€โ”€ Bottom Sheets โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /// Name: first + last together + void _showNameSheet(ProfileController controller) { + final firstCtrl = TextEditingController( + text: controller.prfoileData['first_name']?.toString() ?? ''); + final lastCtrl = TextEditingController( + text: controller.prfoileData['last_name']?.toString() ?? ''); + + _openSheet( + title: 'Update Name'.tr, + headerIcon: Icons.badge_outlined, + headerColor: Colors.blueAccent, + child: StatefulBuilder( + builder: (ctx, setStateInner) => Column( + children: [ + _styledField( + controller: firstCtrl, + label: 'First Name'.tr, + hint: 'Enter your first name'.tr, + icon: Icons.person_outline, + type: TextInputType.name, + ), + const SizedBox(height: 14), + _styledField( + controller: lastCtrl, + label: 'Last Name'.tr, + hint: 'Enter your last name'.tr, + icon: Icons.person_outline, + type: TextInputType.name, + ), + const SizedBox(height: 24), + _sheetSaveButton( + label: 'Save Name'.tr, + onPressed: () async { + Get.back(); + // Update both columns sequentially + await controller.updateColumn({ + 'id': box.read(BoxName.passengerID).toString(), + 'first_name': firstCtrl.text.trim(), + 'last_name': lastCtrl.text.trim(), + }); + }, + ), + ], + ), + ), + ); + } + + /// Single text field update + void _showFieldSheet({ + required ProfileController controller, + required String fieldKey, + required String title, + required IconData icon, + required Color iconColor, + required TextInputType keyboardType, + VoidCallback? onSaved, + }) { + final tempCtrl = TextEditingController( + text: controller.prfoileData[fieldKey]?.toString() ?? ''); + + _openSheet( + title: title, + headerIcon: icon, + headerColor: iconColor, + child: Column( + children: [ + _styledField( + controller: tempCtrl, + label: title, + hint: '', + icon: icon, + type: keyboardType, + ), + const SizedBox(height: 24), + _sheetSaveButton( + onPressed: () async { + Get.back(); + await controller.updateColumn({ + 'id': box.read(BoxName.passengerID).toString(), + fieldKey: tempCtrl.text.trim(), + }); + onSaved?.call(); + }, + ), + ], + ), + ); + } + + /// Gender picker sheet + void _showGenderSheet(ProfileController controller) { + final options = ['Male'.tr, 'Female'.tr, 'Other'.tr]; + _openSheet( + title: 'Select Gender'.tr, + headerIcon: Icons.wc_outlined, + headerColor: Colors.pinkAccent, + child: Column( + children: options + .map((g) => _choiceTile( + label: g, + isSelected: controller.prfoileData['gender']?.toString() == g, + onTap: () async { + Get.back(); + await controller.updateColumn({ + 'id': box.read(BoxName.passengerID).toString(), + 'gender': g, + }); + }, + )) + .toList(), + ), + ); + } + + /// Education picker sheet + void _showEducationSheet(ProfileController controller) { + final options = [ + 'High School Diploma'.tr, + 'Associate Degree'.tr, + "Bachelor's Degree".tr, + "Master's Degree".tr, + 'Doctoral Degree'.tr, + ]; + _openSheet( + title: 'Select Education'.tr, + headerIcon: Icons.school_outlined, + headerColor: Colors.orangeAccent, + child: Column( + children: options + .map((d) => _choiceTile( + label: d, + isSelected: + controller.prfoileData['education']?.toString() == d, + onTap: () async { + Get.back(); + await controller.updateColumn({ + 'id': box.read(BoxName.passengerID).toString(), + 'education': d, + }); + }, + )) + .toList(), + ), + ); + } + + /// Delete account sheet + void _showDeleteSheet() { + _openSheet( + title: 'Delete Account'.tr, + headerIcon: Icons.delete_forever_outlined, + headerColor: Colors.redAccent, + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.red.withOpacity(0.15)), + ), + child: Row( + children: [ + const Icon(Icons.warning_amber_rounded, + color: Colors.redAccent, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + 'This action is permanent and cannot be undone.'.tr, + style: TextStyle(color: Colors.red[700], fontSize: 13), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _styledField( + controller: logOutController.emailTextController, + label: 'Confirm your Email'.tr, + hint: 'example@domain.com', + icon: Icons.email_outlined, + type: TextInputType.emailAddress, + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () { + logOutController.emailTextController.clear(); + Get.back(); + }, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + side: BorderSide(color: Colors.grey[300]!), + ), + child: Text('Cancel'.tr, + style: TextStyle(color: Colors.grey[600])), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: () async { + await logOutController.deletePassengerAccount(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + ), + child: Text('Delete'.tr, + style: const TextStyle(fontWeight: FontWeight.bold)), + ), + ), + ], + ), + ], + ), + ); + } + + // โ”€โ”€โ”€ Sheet Primitives โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + void _openSheet({ + required String title, + required IconData headerIcon, + required Color headerColor, + required Widget child, + }) { + Get.bottomSheet( + isScrollControlled: true, + _SheetWrapper( + title: title, + headerIcon: headerIcon, + headerColor: headerColor, + child: child, + ), + ); + } + + Widget _styledField({ + required TextEditingController controller, + required String label, + required String hint, + required IconData icon, + required TextInputType type, + }) { + return TextField( + controller: controller, + keyboardType: type, + style: const TextStyle(fontSize: 15, color: Colors.black87), + decoration: InputDecoration( + labelText: label, + hintText: hint, + hintStyle: TextStyle(color: Colors.grey[400], fontSize: 13), + prefixIcon: Icon(icon, size: 20, color: Colors.grey[500]), + filled: true, + fillColor: const Color(0xFFF8F9FA), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 15), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(13), + borderSide: BorderSide(color: Colors.grey[200]!)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(13), + borderSide: BorderSide(color: Colors.grey[200]!)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(13), + borderSide: + const BorderSide(color: AppColor.primaryColor, width: 1.5)), + labelStyle: TextStyle(color: Colors.grey[500], fontSize: 13), + ), + ); + } + + Widget _sheetSaveButton({required VoidCallback onPressed, String? label}) { + return SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: AppColor.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 15), + elevation: 0, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(13)), + ), + child: Text( + label ?? 'Save Changes'.tr, + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold), + ), + ), + ); + } + + Widget _choiceTile({ + required String label, + required bool isSelected, + required VoidCallback onTap, }) { return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), onTap: onTap, - leading: Container( - padding: const EdgeInsets.all(8), + leading: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 22, + height: 22, decoration: BoxDecoration( - color: color.withOpacity(0.1), - borderRadius: BorderRadius.circular(10), - ), - child: Icon(icon, color: color, size: 24), - ), - title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), - subtitle: Text(subtitle, style: TextStyle(color: Colors.grey[600])), - trailing: - Icon(Icons.arrow_forward_ios, size: 16, color: Colors.grey[400]), - ); - } - - Widget _buildAccountActions( - BuildContext context, LogOutController logOutController) { - return Column( - children: [ - SizedBox( - width: double.infinity, - child: TextButton.icon( - icon: const Icon(Icons.logout), - label: Text('Sign Out'.tr), - style: TextButton.styleFrom( - foregroundColor: Colors.blueGrey, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - onPressed: () { - logOutController.logOutPassenger(); - }, + shape: BoxShape.circle, + border: Border.all( + color: isSelected ? AppColor.primaryColor : Colors.grey[300]!, + width: 2, ), + color: isSelected + ? AppColor.primaryColor.withOpacity(0.1) + : Colors.transparent, ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: TextButton.icon( - icon: const Icon(Icons.delete_forever_outlined), - label: Text('Delete My Account'.tr), - style: TextButton.styleFrom( - foregroundColor: Colors.red, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - onPressed: () => - _showDeleteAccountDialog(context, logOutController), - ), - ), - ], - ); - } - - void _showGenderDialog(ProfileController controller) { - Get.defaultDialog( - title: 'Update Gender'.tr, - content: Column( - children: [ - GenderPicker(), - const SizedBox(height: 16), - MyElevatedButton( - title: 'Update'.tr, - onPressed: () { - controller.updateColumn({ - 'id': controller.prfoileData['id'].toString(), - 'gender': controller.gender, - }); - Get.back(); - }, - ) - ], - ), - ); - } - - void _showEducationDialog(ProfileController controller) { - Get.defaultDialog( - title: 'Update Education'.tr, - content: Column( - children: [ - EducationDegreePicker(), - const SizedBox(height: 16), - MyElevatedButton( - title: 'Update'.tr, - onPressed: () { - controller.updateColumn({ - 'id': controller.prfoileData['id'].toString(), - 'education': controller.selectedDegree, - }); - Get.back(); - }, - ), - ], - ), - ); - } - - void _showDeleteAccountDialog( - BuildContext context, LogOutController logOutController) { - Get.defaultDialog( - title: 'Delete My Account'.tr, - middleText: 'Are you sure? This action cannot be undone.'.tr, - content: Form( - key: logOutController.formKey1, - child: MyTextForm( - controller: logOutController.emailTextController, - label: 'Confirm your Email'.tr, - hint: 'Type your Email'.tr, - type: TextInputType.emailAddress, - ), - ), - confirm: MyElevatedButton( - title: 'Delete Permanently'.tr, - kolor: AppColor.redColor, - onPressed: () async { - await logOutController.deletePassengerAccount(); - }, - ), - cancel: TextButton( - child: Text('Cancel'.tr), - onPressed: () { - logOutController.emailTextController.clear(); - Get.back(); - }, + child: isSelected + ? const Icon(Icons.check_rounded, + size: 14, color: AppColor.primaryColor) + : null, ), + title: Text(label, + style: TextStyle( + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected ? AppColor.primaryColor : Colors.black87)), ); } } -// --- Helper Widgets for Pickers --- +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Reusable Sheet Wrapper +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +class _SheetWrapper extends StatelessWidget { + final String title; + final IconData headerIcon; + final Color headerColor; + final Widget child; -class GenderPicker extends StatelessWidget { - final ProfileController controller = Get.find(); - final List genderOptions = ['Male'.tr, 'Female'.tr, 'Other'.tr]; - - GenderPicker({super.key}); + const _SheetWrapper({ + required this.title, + required this.headerIcon, + required this.headerColor, + required this.child, + }); @override Widget build(BuildContext context) { - return SizedBox( - height: 150, - child: CupertinoPicker( - itemExtent: 40.0, - onSelectedItemChanged: (int index) { - controller.setGender(genderOptions[index]); - }, - children: genderOptions.map((String value) { - return Center(child: Text(value)); - }).toList(), + return Container( + padding: EdgeInsets.fromLTRB( + 24, + 16, + 24, + MediaQuery.of(context).viewInsets.bottom + 28, + ), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(26)), + ), + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Handle bar + Center( + child: Container( + width: 38, + height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: Colors.grey[250], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + // Header row + Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: headerColor.withOpacity(0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(headerIcon, color: headerColor, size: 22), + ), + const SizedBox(width: 12), + Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ], + ), + const SizedBox(height: 24), + child, + ], + ), ), ); } } - -class EducationDegreePicker extends StatelessWidget { - final ProfileController controller = Get.find(); - final List degreeOptions = [ - 'High School Diploma'.tr, - 'Associate Degree'.tr, - "Bachelor's Degree".tr, - "Master's Degree".tr, - 'Doctoral Degree'.tr, - ]; - - EducationDegreePicker({super.key}); - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 180, - child: CupertinoPicker( - itemExtent: 40.0, - onSelectedItemChanged: (int index) { - controller.setDegree(degreeOptions[index]); - }, - children: degreeOptions.map((String value) { - return Center(child: Text(value)); - }).toList(), - ), - ); - } -} - -// NOTE: The CountryPicker and CountryPickerFromSetting widgets were not part of the main -// profile page UI, so they are excluded here to keep the file focused. -// If they are needed elsewhere, they should be moved to their own files. diff --git a/lib/views/widgets/elevated_btn.dart b/lib/views/widgets/elevated_btn.dart index edce2a4..a50a133 100644 --- a/lib/views/widgets/elevated_btn.dart +++ b/lib/views/widgets/elevated_btn.dart @@ -1,5 +1,4 @@ import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:vibration/vibration.dart'; @@ -9,50 +8,175 @@ import '../../constant/colors.dart'; import '../../constant/style.dart'; import '../../main.dart'; -class MyElevatedButton extends StatelessWidget { +class MyElevatedButton extends StatefulWidget { final String title; final VoidCallback onPressed; final Color kolor; final int vibrateDuration; + final IconData? icon; + final bool isLoading; + final double? height; + final double? fontSize; + const MyElevatedButton({ Key? key, required this.title, required this.onPressed, this.kolor = AppColor.primaryColor, - this.vibrateDuration = 100, + this.vibrateDuration = 50, // Shorter = crisper feedback + this.icon, + this.isLoading = false, + this.height = 52, + this.fontSize, }) : super(key: key); @override - Widget build(BuildContext context) { - bool vibrate = box.read(BoxName.isvibrate) ?? true; - return ElevatedButton( - style: ButtonStyle( - backgroundColor: WidgetStateProperty.all(kolor), - shadowColor: WidgetStateProperty.all(Colors.transparent), - shape: WidgetStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - onPressed: () async { - // Handle haptic feedback for both iOS and Android - if (vibrate == true) { - if (Platform.isIOS) { - HapticFeedback.selectionClick(); - } else if (Platform.isAndroid) { - await Vibration.vibrate(duration: vibrateDuration); - } else {} - } + State createState() => _MyElevatedButtonState(); +} - // Ensure the onPressed callback is called after haptic feedback - onPressed(); +class _MyElevatedButtonState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _pressController; + bool _isVibrateEnabled = true; + + @override + void initState() { + super.initState(); + _pressController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 120), + reverseDuration: const Duration(milliseconds: 180), + ); + _loadVibratePreference(); + } + + Future _loadVibratePreference() async { + // Reactive to preference changes if needed later + setState(() { + _isVibrateEnabled = box.read(BoxName.isvibrate) ?? true; + }); + } + + @override + void dispose() { + _pressController.dispose(); + super.dispose(); + } + + void _triggerHaptic() { + if (!_isVibrateEnabled) return; + + // Unified approach: HapticFeedback works well on both platforms + if (Platform.isIOS) { + HapticFeedback.lightImpact(); + } else if (Platform.isAndroid) { + // Try native haptic first, fallback to Vibration package if needed + HapticFeedback.mediumImpact(); + // Optional stronger feedback: + // Vibration.vibrate(duration: widget.vibrateDuration); + } + } + + void _handlePress() { + if (widget.isLoading) return; + + _triggerHaptic(); + _pressController.forward().then((_) => _pressController.reverse()); + + // Small delay ensures animation starts before callback + Future.delayed(const Duration(milliseconds: 80), widget.onPressed); + } + + @override + Widget build(BuildContext context) { + final isEnabled = !widget.isLoading && widget.onPressed != () {}; + + return AnimatedBuilder( + animation: _pressController, + builder: (context, child) { + final scale = 1.0 - (_pressController.value * 0.03); + + return Transform.scale( + scale: scale, + child: Container( + height: widget.height, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: isEnabled + ? [ + BoxShadow( + color: widget.kolor.withOpacity(0.25), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ] + : null, + ), + child: ElevatedButton( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith( + (states) { + if (!isEnabled) return widget.kolor.withOpacity(0.5); + if (states.contains(WidgetState.pressed)) { + return widget.kolor.withOpacity(0.92); + } + return widget.kolor; + }, + ), + elevation: WidgetStateProperty.resolveWith( + (states) => isEnabled + ? (states.contains(WidgetState.pressed) ? 2 : 6) + : 0, + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + padding: WidgetStateProperty.all( + const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + ), + ), + onPressed: isEnabled ? _handlePress : null, + child: _buildContent(), + ), + ), + ); }, - child: Text( - title, - textAlign: TextAlign.center, - style: AppStyle.title.copyWith(color: AppColor.secondaryColor), - ), ); } + + Widget _buildContent() { + if (widget.isLoading) { + return SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.5, + valueColor: AlwaysStoppedAnimation(AppColor.secondaryColor), + ), + ); + } + + final textStyle = AppStyle.title.copyWith( + color: AppColor.secondaryColor, + fontSize: widget.fontSize, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ); + + if (widget.icon != null) { + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(widget.icon, size: 18, color: AppColor.secondaryColor), + const SizedBox(width: 8), + Text(widget.title, style: textStyle, textAlign: TextAlign.center), + ], + ); + } + + return Text(widget.title, style: textStyle, textAlign: TextAlign.center); + } } diff --git a/lib/views/widgets/error_snakbar.dart b/lib/views/widgets/error_snakbar.dart index e968f24..8016844 100644 --- a/lib/views/widgets/error_snakbar.dart +++ b/lib/views/widgets/error_snakbar.dart @@ -1,547 +1,292 @@ +import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; -import 'dart:ui'; import '../../constant/colors.dart'; -class SnackbarConfig { - static const duration = Duration(seconds: 4); - static const animationDuration = Duration(milliseconds: 400); - static const margin = EdgeInsets.symmetric(horizontal: 16, vertical: 12); - static const borderRadius = 16.0; - static const elevation = 0.0; // ุชู‚ู„ูŠู„ ุงู„ุงุฑุชูุงุน ู„ุฃู†ู†ุง ุณู†ุณุชุฎุฏู… ุชุฃุซูŠุฑุงุช ุฒุฌุงุฌูŠุฉ +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Snackbar variant definition +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +enum _SnackVariant { success, error, info, warning } - // ุชุฃุซูŠุฑ ุฒุฌุงุฌูŠ - static const double blurStrength = 15.0; - static const double opacity = 0.85; +extension _VariantProps on _SnackVariant { + Color get baseColor => switch (this) { + _SnackVariant.success => const Color(0xFF1A9E5C), + _SnackVariant.error => const Color(0xFFD93025), + _SnackVariant.info => const Color(0xFF1A73E8), + _SnackVariant.warning => const Color(0xFFF29900), + }; - // ุญุฏูˆุฏ ุดูุงูุฉ - static final Border glassBorder = Border.all( - color: Colors.white.withOpacity(0.25), - width: 1.5, - ); + Color get surfaceColor => switch (this) { + _SnackVariant.success => const Color(0xFFF0FBF5), + _SnackVariant.error => const Color(0xFFFEF2F1), + _SnackVariant.info => const Color(0xFFF0F6FF), + _SnackVariant.warning => const Color(0xFFFFF8E6), + }; - // ุธู„ ุฃูƒุซุฑ ู†ุนูˆู…ุฉ ูˆุงู†ุชุดุงุฑ - static final List shadows = [ - BoxShadow( - color: Colors.black.withOpacity(0.15), - blurRadius: 12, - spreadRadius: 1, - offset: const Offset(0, 4), - ), - BoxShadow( - color: Colors.black.withOpacity(0.08), - blurRadius: 20, - spreadRadius: 0, - offset: const Offset(0, 2), - ), - ]; + IconData get icon => switch (this) { + _SnackVariant.success => Icons.check_circle_rounded, + _SnackVariant.error => Icons.error_rounded, + _SnackVariant.info => Icons.info_rounded, + _SnackVariant.warning => Icons.warning_amber_rounded, + }; + + String get label => switch (this) { + _SnackVariant.success => 'Success', + _SnackVariant.error => 'Error', + _SnackVariant.info => 'Info', + _SnackVariant.warning => 'Warning', + }; + + HapticFeedbackType get haptic => switch (this) { + _SnackVariant.error => HapticFeedbackType.medium, + _SnackVariant.warning => HapticFeedbackType.medium, + _SnackVariant.success => HapticFeedbackType.light, + _SnackVariant.info => HapticFeedbackType.selection, + }; } -// ุชุทุจูŠู‚ ุชุฃุซูŠุฑ ุฒุฌุงุฌูŠ ุจุงุณุชุฎุฏุงู… Container ู…ุฎุตุต -class GlassSnackbar extends StatelessWidget { - final Color baseColor; - final Widget child; +enum HapticFeedbackType { light, medium, selection } - const GlassSnackbar({ - required this.baseColor, - required this.child, - Key? key, - }) : super(key: key); +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Core snackbar widget +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +class _SnackContent extends StatefulWidget { + final String message; + final _SnackVariant variant; + + const _SnackContent({required this.message, required this.variant}); + + @override + State<_SnackContent> createState() => _SnackContentState(); +} + +class _SnackContentState extends State<_SnackContent> + with TickerProviderStateMixin { + late final AnimationController _ctrl; + late final Animation _scaleAnim; + late final Animation _progressAnim; + + static const Duration _displayDuration = Duration(seconds: 4); + + @override + void initState() { + super.initState(); + _ctrl = AnimationController(vsync: this, duration: _displayDuration); + + _scaleAnim = CurvedAnimation( + parent: AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + )..forward(), + curve: Curves.elasticOut, + ); + + _progressAnim = Tween(begin: 1.0, end: 0.0).animate( + CurvedAnimation(parent: _ctrl, curve: Curves.linear), + ); + + _ctrl.forward(); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - return ClipRRect( - borderRadius: BorderRadius.circular(SnackbarConfig.borderRadius), - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: SnackbarConfig.blurStrength, - sigmaY: SnackbarConfig.blurStrength, - ), - child: Container( - decoration: BoxDecoration( - color: baseColor.withOpacity(SnackbarConfig.opacity), - borderRadius: BorderRadius.circular(SnackbarConfig.borderRadius), - border: SnackbarConfig.glassBorder, - boxShadow: SnackbarConfig.shadows, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - baseColor.withOpacity(SnackbarConfig.opacity + 0.05), - baseColor.withOpacity(SnackbarConfig.opacity - 0.05), - ], - ), + final v = widget.variant; + final accent = v.baseColor; + final surface = v.surfaceColor; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: surface, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: accent.withOpacity(0.18), width: 1.2), + boxShadow: [ + BoxShadow( + color: accent.withOpacity(0.12), + blurRadius: 20, + spreadRadius: -2, + offset: const Offset(0, 6), ), - child: child, + BoxShadow( + color: Colors.black.withOpacity(0.06), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // โ”€โ”€ Main row โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 10, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Animated icon badge + ScaleTransition( + scale: _scaleAnim, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: accent.withOpacity(0.12), + shape: BoxShape.circle, + ), + child: Icon(v.icon, color: accent, size: 22), + ), + ), + const SizedBox(width: 12), + + // Text content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + v.label.tr, + style: TextStyle( + color: accent, + fontSize: 13, + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 3), + Text( + widget.message, + style: TextStyle( + color: Colors.grey[800], + fontSize: 13.5, + height: 1.4, + fontWeight: FontWeight.w400, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + + // Close button + GestureDetector( + onTap: () { + HapticFeedback.lightImpact(); + Get.closeCurrentSnackbar(); + }, + child: Container( + width: 30, + height: 30, + margin: const EdgeInsets.only(left: 6), + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + Icons.close_rounded, + size: 16, + color: Colors.grey[500], + ), + ), + ), + ], + ), + ), + + // โ”€โ”€ Thin progress strip โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + AnimatedBuilder( + animation: _progressAnim, + builder: (_, __) => Stack( + children: [ + // Track + Container( + height: 3, + color: accent.withOpacity(0.08), + ), + // Fill + FractionallySizedBox( + widthFactor: _progressAnim.value, + child: Container( + height: 3, + decoration: BoxDecoration( + color: accent.withOpacity(0.45), + borderRadius: const BorderRadius.only( + topRight: Radius.circular(4), + bottomRight: Radius.circular(4), + ), + ), + ), + ), + ], + ), + ), + ], ), ), ); } } -SnackbarController mySnackeBarError(String message) { - // ุชุฃุซูŠุฑ ุงู‡ุชุฒุงุฒ ู„ู„ุฃุฎุทุงุก - HapticFeedback.mediumImpact(); +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Internal dispatcher โ€” single source of truth +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +SnackbarController _show(_SnackVariant variant, String message) { + // Dismiss any existing snackbar first (no stacking) + if (Get.isSnackbarOpen) Get.closeCurrentSnackbar(); - final Color errorBaseColor = AppColor.redColor; + switch (variant.haptic) { + case HapticFeedbackType.light: + HapticFeedback.lightImpact(); + case HapticFeedbackType.medium: + HapticFeedback.mediumImpact(); + case HapticFeedbackType.selection: + HapticFeedback.selectionClick(); + } return Get.snackbar( '', '', snackPosition: SnackPosition.TOP, - margin: SnackbarConfig.margin, - duration: SnackbarConfig.duration, - animationDuration: SnackbarConfig.animationDuration, - borderRadius: SnackbarConfig.borderRadius, - backgroundColor: Colors.transparent, // ุดูุงู ู„ุฃู†ู†ุง ุณู†ุณุชุฎุฏู… ุญุงูˆูŠุฉ ู…ุฎุตุตุฉ - barBlur: 0, // ุฅูŠู‚ุงู ุชุดูˆูŠุด ุงู„ุฎู„ููŠุฉ ุงู„ุงูุชุฑุงุถูŠ ู„ุฃู†ู†ุง ุณู†ุณุชุฎุฏู… BlurFilter - overlayBlur: 1.5, - overlayColor: Colors.black12, - userInputForm: Form( - child: GlassSnackbar( - baseColor: errorBaseColor, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - child: Row( - children: [ - // ุฃูŠู‚ูˆู†ุฉ ู…ุชุญุฑูƒุฉ - TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: 1.0), - duration: const Duration(milliseconds: 500), - curve: Curves.elasticOut, - builder: (context, value, child) { - return Transform.scale( - scale: value, - child: child, - ); - }, - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.25), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.error_rounded, - color: Colors.white, - size: 26, - ), - ), - ), - const SizedBox(width: 16), - // ู…ุญุชูˆู‰ ุงู„ู†ุต - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Error'.tr, - style: const TextStyle( - fontWeight: FontWeight.w700, - color: Colors.white, - fontSize: 16, - letterSpacing: 0.3, - shadows: [ - Shadow( - color: Colors.black26, - offset: Offset(0, 1), - blurRadius: 2, - ), - ], - ), - ), - const SizedBox(height: 4), - Text( - message, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - height: 1.3, - fontWeight: FontWeight.w400, - ), - ), - ], - ), - ), - // ุฒุฑ ุงู„ุฅุบู„ุงู‚ - InkWell( - onTap: () { - HapticFeedback.lightImpact(); - Get.closeCurrentSnackbar(); - }, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.close_rounded, - color: Colors.white, - size: 18, - ), - ), - ), - ], - ), - ), - ), - ), + backgroundColor: Colors.transparent, + margin: EdgeInsets.zero, + padding: EdgeInsets.zero, + duration: const Duration(seconds: 4), + animationDuration: const Duration(milliseconds: 380), + barBlur: 0, + overlayBlur: 0, + overlayColor: Colors.transparent, isDismissible: true, - dismissDirection: DismissDirection.horizontal, - forwardAnimationCurve: Curves.easeOutBack, + dismissDirection: DismissDirection.up, + forwardAnimationCurve: Curves.easeOutCubic, reverseAnimationCurve: Curves.easeInCubic, + snackStyle: SnackStyle.FLOATING, + userInputForm: Form( + child: _SnackContent(message: message, variant: variant), + ), ); } -SnackbarController mySnackbarSuccess(String message) { - // ุชุฃุซูŠุฑ ุงู‡ุชุฒุงุฒ ู„ู„ู†ุฌุงุญ - HapticFeedback.lightImpact(); +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Public API โ€” drop-in replacements for the old functions +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +SnackbarController mySnackbarSuccess(String message) => + _show(_SnackVariant.success, message); - final Color successBaseColor = AppColor.greenColor; +SnackbarController mySnackeBarError(String message) => + _show(_SnackVariant.error, message); - return Get.snackbar( - '', - '', - snackPosition: SnackPosition.TOP, - margin: SnackbarConfig.margin, - duration: SnackbarConfig.duration, - animationDuration: SnackbarConfig.animationDuration, - borderRadius: SnackbarConfig.borderRadius, - backgroundColor: Colors.transparent, // ุดูุงู ู„ุฃู†ู†ุง ุณู†ุณุชุฎุฏู… ุญุงูˆูŠุฉ ู…ุฎุตุตุฉ - barBlur: 0, // ุฅูŠู‚ุงู ุชุดูˆูŠุด ุงู„ุฎู„ููŠุฉ ุงู„ุงูุชุฑุงุถูŠ ู„ุฃู†ู†ุง ุณู†ุณุชุฎุฏู… BlurFilter - overlayBlur: 1.5, - overlayColor: Colors.black12, - userInputForm: Form( - child: GlassSnackbar( - baseColor: successBaseColor, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - child: Row( - children: [ - // ุฃูŠู‚ูˆู†ุฉ ู…ุชุญุฑูƒุฉ - TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: 1.0), - duration: const Duration(milliseconds: 600), - curve: Curves.elasticOut, - builder: (context, value, child) { - return Transform.scale( - scale: value, - child: child, - ); - }, - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.25), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.check_circle_rounded, - color: Colors.white, - size: 26, - ), - ), - ), - const SizedBox(width: 16), - // ู…ุญุชูˆู‰ ุงู„ู†ุต - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Success'.tr, - style: const TextStyle( - fontWeight: FontWeight.w700, - color: Colors.white, - fontSize: 16, - letterSpacing: 0.3, - shadows: [ - Shadow( - color: Colors.black26, - offset: Offset(0, 1), - blurRadius: 2, - ), - ], - ), - ), - const SizedBox(height: 4), - Text( - message, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - height: 1.3, - fontWeight: FontWeight.w400, - ), - ), - ], - ), - ), - // ุฒุฑ ุงู„ุฅุบู„ุงู‚ - InkWell( - onTap: () { - HapticFeedback.lightImpact(); - Get.closeCurrentSnackbar(); - }, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.close_rounded, - color: Colors.white, - size: 18, - ), - ), - ), - ], - ), - ), - ), - ), - isDismissible: true, - dismissDirection: DismissDirection.horizontal, - forwardAnimationCurve: Curves.easeOutBack, - reverseAnimationCurve: Curves.easeInCubic, - ); -} +SnackbarController mySnackbarInfo(String message) => + _show(_SnackVariant.info, message); -// ุฅุถุงูุฉ: ุฏุงู„ุฉ ู„ู„ู…ุนู„ูˆู…ุงุช ูˆุงู„ุชู†ุจูŠู‡ุงุช -SnackbarController mySnackbarInfo(String message) { - // ุชุฃุซูŠุฑ ุงู‡ุชุฒุงุฒ ุฎููŠู - HapticFeedback.selectionClick(); - - final Color infoBaseColor = Colors.blue; - - return Get.snackbar( - '', - '', - snackPosition: SnackPosition.TOP, - margin: SnackbarConfig.margin, - duration: SnackbarConfig.duration, - animationDuration: SnackbarConfig.animationDuration, - borderRadius: SnackbarConfig.borderRadius, - backgroundColor: Colors.transparent, // ุดูุงู ู„ุฃู†ู†ุง ุณู†ุณุชุฎุฏู… ุญุงูˆูŠุฉ ู…ุฎุตุตุฉ - barBlur: 0, // ุฅูŠู‚ุงู ุชุดูˆูŠุด ุงู„ุฎู„ููŠุฉ ุงู„ุงูุชุฑุงุถูŠ ู„ุฃู†ู†ุง ุณู†ุณุชุฎุฏู… BlurFilter - overlayBlur: 1.5, - overlayColor: Colors.black12, - userInputForm: Form( - child: GlassSnackbar( - baseColor: infoBaseColor, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - child: Row( - children: [ - // ุฃูŠู‚ูˆู†ุฉ ู…ุชุญุฑูƒุฉ - TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: 1.0), - duration: const Duration(milliseconds: 500), - curve: Curves.elasticOut, - builder: (context, value, child) { - return Transform.scale( - scale: value, - child: child, - ); - }, - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.25), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.info_rounded, - color: Colors.white, - size: 26, - ), - ), - ), - const SizedBox(width: 16), - // ู…ุญุชูˆู‰ ุงู„ู†ุต - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Info'.tr, - style: const TextStyle( - fontWeight: FontWeight.w700, - color: Colors.white, - fontSize: 16, - letterSpacing: 0.3, - shadows: [ - Shadow( - color: Colors.black26, - offset: Offset(0, 1), - blurRadius: 2, - ), - ], - ), - ), - const SizedBox(height: 4), - Text( - message, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - height: 1.3, - fontWeight: FontWeight.w400, - ), - ), - ], - ), - ), - // ุฒุฑ ุงู„ุฅุบู„ุงู‚ - InkWell( - onTap: () { - HapticFeedback.lightImpact(); - Get.closeCurrentSnackbar(); - }, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.close_rounded, - color: Colors.white, - size: 18, - ), - ), - ), - ], - ), - ), - ), - ), - isDismissible: true, - dismissDirection: DismissDirection.horizontal, - forwardAnimationCurve: Curves.easeOutBack, - reverseAnimationCurve: Curves.easeInCubic, - ); -} - -// ุฅุถุงูุฉ: ุฏุงู„ุฉ ู„ู„ุชุญุฐูŠุฑุงุช -SnackbarController mySnackbarWarning(String message) { - // ุชุฃุซูŠุฑ ุงู‡ุชุฒุงุฒ ู…ุชูˆุณุท - HapticFeedback.mediumImpact(); - - final Color warningBaseColor = Colors.orange; - - return Get.snackbar( - '', - '', - snackPosition: SnackPosition.TOP, - margin: SnackbarConfig.margin, - duration: SnackbarConfig.duration, - animationDuration: SnackbarConfig.animationDuration, - borderRadius: SnackbarConfig.borderRadius, - backgroundColor: Colors.transparent, // ุดูุงู ู„ุฃู†ู†ุง ุณู†ุณุชุฎุฏู… ุญุงูˆูŠุฉ ู…ุฎุตุตุฉ - barBlur: 0, // ุฅูŠู‚ุงู ุชุดูˆูŠุด ุงู„ุฎู„ููŠุฉ ุงู„ุงูุชุฑุงุถูŠ ู„ุฃู†ู†ุง ุณู†ุณุชุฎุฏู… BlurFilter - overlayBlur: 1.5, - overlayColor: Colors.black12, - userInputForm: Form( - child: GlassSnackbar( - baseColor: warningBaseColor, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - child: Row( - children: [ - // ุฃูŠู‚ูˆู†ุฉ ู…ุชุญุฑูƒุฉ - TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: 1.0), - duration: const Duration(milliseconds: 500), - curve: Curves.elasticOut, - builder: (context, value, child) { - return Transform.scale( - scale: value, - child: child, - ); - }, - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.25), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.warning_rounded, - color: Colors.white, - size: 26, - ), - ), - ), - const SizedBox(width: 16), - // ู…ุญุชูˆู‰ ุงู„ู†ุต - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Warning'.tr, - style: const TextStyle( - fontWeight: FontWeight.w700, - color: Colors.white, - fontSize: 16, - letterSpacing: 0.3, - shadows: [ - Shadow( - color: Colors.black26, - offset: Offset(0, 1), - blurRadius: 2, - ), - ], - ), - ), - const SizedBox(height: 4), - Text( - message, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - height: 1.3, - fontWeight: FontWeight.w400, - ), - ), - ], - ), - ), - // ุฒุฑ ุงู„ุฅุบู„ุงู‚ - InkWell( - onTap: () { - HapticFeedback.lightImpact(); - Get.closeCurrentSnackbar(); - }, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.close_rounded, - color: Colors.white, - size: 18, - ), - ), - ), - ], - ), - ), - ), - ), - isDismissible: true, - dismissDirection: DismissDirection.horizontal, - forwardAnimationCurve: Curves.easeOutBack, - reverseAnimationCurve: Curves.easeInCubic, - ); -} +SnackbarController mySnackbarWarning(String message) => + _show(_SnackVariant.warning, message); diff --git a/lib/views/widgets/my_circular_indicator_timer.dart b/lib/views/widgets/my_circular_indicator_timer.dart index 54488fe..d0215c5 100644 --- a/lib/views/widgets/my_circular_indicator_timer.dart +++ b/lib/views/widgets/my_circular_indicator_timer.dart @@ -1,68 +1,151 @@ -import 'package:flutter/material.dart'; import 'dart:async'; - +import 'package:flutter/material.dart'; import '../../constant/style.dart'; -class MyCircularProgressIndicatorWithTimer extends StatelessWidget { +class MyCircularProgressIndicatorWithTimer extends StatefulWidget { final Color backgroundColor; final bool isLoading; - MyCircularProgressIndicatorWithTimer({ + const MyCircularProgressIndicatorWithTimer({ Key? key, - this.backgroundColor = Colors.transparent, + this.backgroundColor = Colors.white, required this.isLoading, }) : super(key: key); - final StreamController _streamController = StreamController(); + @override + State createState() => + _MyCircularProgressIndicatorWithTimerState(); +} - void startTimer() { - int _timeLeft = 60; - Timer.periodic(const Duration(seconds: 1), (timer) { - if (_timeLeft > 0 && isLoading) { - _streamController.add(_timeLeft); - _timeLeft--; - } else { - timer.cancel(); - _streamController.close(); - } +class _MyCircularProgressIndicatorWithTimerState + extends State { + Timer? _timer; + int _timeLeft = 60; + bool _isLowTime = false; + + @override + void initState() { + super.initState(); + if (widget.isLoading) _startTimer(); + } + + @override + void didUpdateWidget( + covariant MyCircularProgressIndicatorWithTimer oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLoading && !oldWidget.isLoading) { + _resetTimer(); + _startTimer(); + } else if (!widget.isLoading && oldWidget.isLoading) { + _cancelTimer(); + } + } + + void _startTimer() { + _cancelTimer(); // Ensure no duplicate timers + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() { + if (_timeLeft > 0) { + _timeLeft--; + _isLowTime = _timeLeft <= 10; + } else { + _cancelTimer(); + } + }); }); } + void _resetTimer() { + _cancelTimer(); + setState(() { + _timeLeft = 60; + _isLowTime = false; + }); + } + + void _cancelTimer() { + _timer?.cancel(); + _timer = null; + } + + @override + void dispose() { + _cancelTimer(); + super.dispose(); + } + @override Widget build(BuildContext context) { - if (isLoading) { - startTimer(); - } + if (!widget.isLoading) return const SizedBox.shrink(); + + final progress = _timeLeft / 60.0; + final timerColor = _isLowTime ? Colors.orangeAccent : Colors.blueAccent; return Center( child: Container( width: 200, height: 200, decoration: BoxDecoration( - color: backgroundColor, + color: widget.backgroundColor == Colors.transparent + ? Colors.white.withOpacity(0.96) + : widget.backgroundColor, shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 24, + spreadRadius: 2, + offset: const Offset(0, 8), + ), + ], ), child: Stack( + alignment: Alignment.center, children: [ - const Center(child: CircularProgressIndicator()), + // Elegant circular progress ring + SizedBox( + width: 184, + height: 184, + child: CircularProgressIndicator( + value: progress, + strokeWidth: 6, + backgroundColor: Colors.grey.shade100, + valueColor: AlwaysStoppedAnimation(timerColor), + strokeCap: StrokeCap.round, + ), + ), + // Center content Column( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ - Align( - alignment: Alignment.center, + // Subtle scale animation when time is low + AnimatedContainer( + duration: const Duration(milliseconds: 400), + curve: Curves.easeOutCubic, + transform: Matrix4.identity()..scale(_isLowTime ? 1.08 : 1.0), child: Image.asset( 'assets/images/logo.gif', - width: 140, - height: 140, + width: 96, + height: 96, + fit: BoxFit.contain, ), ), - const SizedBox(height: 10), - StreamBuilder( - stream: _streamController.stream, - initialData: 60, - builder: (context, snapshot) { - return Text('${snapshot.data}', style: AppStyle.title); - }, + const SizedBox(height: 14), + // Clean, modern timer text + AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w600, + color: _isLowTime + ? Colors.orangeAccent + : Colors.blueGrey.shade800, + letterSpacing: 1.2, + fontFeatures: const [FontFeature.tabularFigures()], + ), + child: Text('${_timeLeft}s'), ), ], ), diff --git a/lib/views/widgets/mydialoug.dart b/lib/views/widgets/mydialoug.dart index 2471a52..0ec2c0f 100644 --- a/lib/views/widgets/mydialoug.dart +++ b/lib/views/widgets/mydialoug.dart @@ -8,239 +8,501 @@ import '../../constant/colors.dart'; import '../../constant/style.dart'; import '../../controller/functions/tts.dart'; -class DialogConfig { - static const Duration animationDuration = Duration(milliseconds: 200); - static const double blurStrength = 8.0; - static const double cornerRadius = 14.0; - static final BoxDecoration decoration = BoxDecoration( - borderRadius: BorderRadius.circular(cornerRadius), - boxShadow: [ - BoxShadow( - color: Colors.black.withAlpha(38), // 0.15 opacity - blurRadius: 16, - offset: const Offset(0, 8), - ), - ], - ); +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Config +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +class _DC { + static const Duration animDuration = Duration(milliseconds: 280); + static const double blur = 20.0; + static const double radius = 24.0; + static const Color barrierColor = Color(0x66000000); } +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Shared animated wrapper โ€” every dialog uses this +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +class _DialogShell extends StatelessWidget { + final Widget child; + const _DialogShell({required this.child}); + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + duration: _DC.animDuration, + tween: Tween(begin: 0.0, end: 1.0), + curve: Curves.easeOutBack, + builder: (_, v, c) => Transform.scale( + scale: 0.88 + (0.12 * v), + child: Opacity(opacity: v.clamp(0.0, 1.0), child: c), + ), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: _DC.blur, sigmaY: _DC.blur), + child: Dialog( + backgroundColor: Colors.transparent, + insetPadding: + const EdgeInsets.symmetric(horizontal: 28, vertical: 40), + child: child, + ), + ), + ); + } +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Shared glass card +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +class _GlassCard extends StatelessWidget { + final Widget child; + const _GlassCard({required this.child}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(_DC.radius), + gradient: LinearGradient( + colors: [ + Colors.white.withOpacity(0.95), + Colors.white.withOpacity(0.88), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.18), + blurRadius: 40, + spreadRadius: -4, + offset: const Offset(0, 16), + ), + BoxShadow( + color: AppColor.primaryColor.withOpacity(0.08), + blurRadius: 20, + offset: const Offset(0, 4), + ), + ], + border: Border.all( + color: Colors.white.withOpacity(0.6), + width: 1.2, + ), + ), + child: child, + ); + } +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Shared bottom action row +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +class _ActionRow extends StatelessWidget { + final VoidCallback onCancel; + final VoidCallback onConfirm; + final String confirmLabel; + final bool isDestructive; + + const _ActionRow({ + required this.onCancel, + required this.onConfirm, + this.confirmLabel = 'OK', + this.isDestructive = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: Colors.grey.withOpacity(0.15), width: 1), + ), + ), + child: Row( + children: [ + // Cancel + Expanded( + child: _ActionButton( + label: 'Cancel'.tr, + color: Colors.grey[600]!, + backgroundColor: Colors.grey.withOpacity(0.07), + onPressed: () { + HapticFeedback.lightImpact(); + onCancel(); + }, + isLeft: true, + ), + ), + Container(width: 1, height: 52, color: Colors.grey.withOpacity(0.15)), + // Confirm + Expanded( + child: _ActionButton( + label: confirmLabel, + color: isDestructive ? AppColor.redColor : AppColor.primaryColor, + backgroundColor: isDestructive + ? AppColor.redColor.withOpacity(0.07) + : AppColor.primaryColor.withOpacity(0.07), + onPressed: () { + HapticFeedback.mediumImpact(); + onConfirm(); + }, + isLeft: false, + isBold: true, + ), + ), + ], + ), + ); + } +} + +class _ActionButton extends StatelessWidget { + final String label; + final Color color; + final Color backgroundColor; + final VoidCallback onPressed; + final bool isLeft; + final bool isBold; + + const _ActionButton({ + required this.label, + required this.color, + required this.backgroundColor, + required this.onPressed, + required this.isLeft, + this.isBold = false, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.only( + bottomLeft: isLeft ? const Radius.circular(_DC.radius) : Radius.zero, + bottomRight: + !isLeft ? const Radius.circular(_DC.radius) : Radius.zero, + ), + child: Container( + height: 52, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.only( + bottomLeft: + isLeft ? const Radius.circular(_DC.radius) : Radius.zero, + bottomRight: + !isLeft ? const Radius.circular(_DC.radius) : Radius.zero, + ), + ), + alignment: Alignment.center, + child: Text( + label, + style: TextStyle( + color: color, + fontSize: 15, + fontWeight: isBold ? FontWeight.w700 : FontWeight.w500, + letterSpacing: -0.2, + ), + ), + ), + ), + ); + } +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// TTS speak button +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +class _SpeakButton extends StatefulWidget { + final List texts; + const _SpeakButton({required this.texts}); + + @override + State<_SpeakButton> createState() => _SpeakButtonState(); +} + +class _SpeakButtonState extends State<_SpeakButton> + with SingleTickerProviderStateMixin { + bool _speaking = false; + late AnimationController _pulse; + + @override + void initState() { + super.initState(); + _pulse = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + lowerBound: 0.92, + upperBound: 1.0, + ); + } + + @override + void dispose() { + _pulse.dispose(); + super.dispose(); + } + + Future _onTap() async { + if (_speaking) return; + HapticFeedback.selectionClick(); + setState(() => _speaking = true); + _pulse.repeat(reverse: true); + + final tts = Get.put(TextToSpeechController()); + for (final t in widget.texts) { + await tts.speakText(t); + } + + _pulse.stop(); + _pulse.reset(); + if (mounted) setState(() => _speaking = false); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _onTap, + child: AnimatedBuilder( + animation: _pulse, + builder: (_, child) => + Transform.scale(scale: _pulse.value, child: child), + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: _speaking + ? AppColor.primaryColor.withOpacity(0.15) + : AppColor.primaryColor.withOpacity(0.08), + borderRadius: BorderRadius.circular(30), + border: Border.all( + color: AppColor.primaryColor.withOpacity(_speaking ? 0.4 : 0.15), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _speaking + ? CupertinoIcons.speaker_3_fill + : CupertinoIcons.speaker_2_fill, + color: AppColor.primaryColor, + size: 16, + ), + const SizedBox(width: 6), + Text( + _speaking ? 'Speaking...'.tr : 'Listen'.tr, + style: TextStyle( + color: AppColor.primaryColor, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ); + } +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// MyDialog โ€” title + text content +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ class MyDialog extends GetxController { - void getDialog(String title, String? midTitle, VoidCallback onPressed) { - final textToSpeechController = Get.put(TextToSpeechController()); - + void getDialog( + String title, + String? midTitle, + VoidCallback onPressed, { + IconData? icon, + bool isDestructive = false, + }) { HapticFeedback.mediumImpact(); Get.dialog( - TweenAnimationBuilder( - duration: DialogConfig.animationDuration, - tween: Tween(begin: 0.0, end: 1.0), - builder: (context, value, child) { - return Transform.scale( - scale: 0.95 + (0.05 * value), - child: Opacity(opacity: value, child: child), - ); - }, - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: DialogConfig.blurStrength, - sigmaY: DialogConfig.blurStrength, - ), - child: Theme( - data: ThemeData.light().copyWith( - dialogBackgroundColor: CupertinoColors.systemBackground, - ), - child: CupertinoAlertDialog( - title: Column( - children: [ - Text( - title, - style: AppStyle.title.copyWith( - fontSize: 20, - fontWeight: FontWeight.w700, - letterSpacing: -0.5, - color: AppColor.primaryColor, - ), - ), - const SizedBox(height: 8), - ], - ), - content: Column( - children: [ - CupertinoButton( - padding: const EdgeInsets.all(8), - onPressed: () async { - HapticFeedback.selectionClick(); - await textToSpeechController.speakText(title); - await textToSpeechController.speakText(midTitle!); - }, - child: Container( - padding: const EdgeInsets.all(8), + _DialogShell( + child: _GlassCard( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // โ”€โ”€ Body โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Padding( + padding: const EdgeInsets.fromLTRB(24, 28, 24, 20), + child: Column( + children: [ + // Icon badge + Container( + width: 56, + height: 56, decoration: BoxDecoration( - color: - AppColor.primaryColor.withAlpha(26), // 0.1 opacity - borderRadius: BorderRadius.circular(8), + shape: BoxShape.circle, + color: (isDestructive + ? AppColor.redColor + : AppColor.primaryColor) + .withOpacity(0.1), + border: Border.all( + color: (isDestructive + ? AppColor.redColor + : AppColor.primaryColor) + .withOpacity(0.2), + ), ), child: Icon( - CupertinoIcons.speaker_2_fill, - color: AppColor.primaryColor, - size: 24, + icon ?? + (isDestructive + ? Icons.warning_amber_rounded + : Icons.info_outline_rounded), + color: isDestructive + ? AppColor.redColor + : AppColor.primaryColor, + size: 26, ), ), - ), - const SizedBox(height: 8), - Text( - midTitle!, - style: AppStyle.title.copyWith( - fontSize: 16, - height: 1.3, - color: Colors.black87, + const SizedBox(height: 16), + + // Title + Text( + title, + textAlign: TextAlign.center, + style: AppStyle.title.copyWith( + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + color: Colors.black87, + ), ), - textAlign: TextAlign.center, - ), - ], + + if (midTitle != null && midTitle.isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + midTitle, + textAlign: TextAlign.center, + style: AppStyle.subtitle.copyWith( + fontSize: 14.5, + height: 1.5, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 16), + + // TTS button + _SpeakButton( + texts: [title, if (midTitle.isNotEmpty) midTitle]), + ], + ], + ), ), - actions: [ - CupertinoDialogAction( - onPressed: () { - HapticFeedback.lightImpact(); - Get.back(); - }, - child: Text( - 'Cancel'.tr, - style: TextStyle( - color: AppColor.redColor, - fontWeight: FontWeight.w600, - fontSize: 17, - ), - ), - ), - CupertinoDialogAction( - onPressed: () { - HapticFeedback.mediumImpact(); - onPressed(); - }, - child: Text( - 'OK'.tr, - style: TextStyle( - color: AppColor.greenColor, - fontWeight: FontWeight.w600, - fontSize: 17, - ), - ), - ), - ], - ), + + // โ”€โ”€ Actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + _ActionRow( + onCancel: () => Get.back(), + onConfirm: onPressed, + confirmLabel: 'OK'.tr, + isDestructive: isDestructive, + ), + ], ), ), ), barrierDismissible: true, - barrierColor: Colors.black.withAlpha(102), // 0.4 opacity + barrierColor: _DC.barrierColor, ); } } +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// MyDialogContent โ€” title + arbitrary widget content +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ class MyDialogContent extends GetxController { - void getDialog(String title, Widget? content, VoidCallback onPressed) { - final textToSpeechController = Get.put(TextToSpeechController()); - + void getDialog( + String title, + Widget? content, + VoidCallback onPressed, { + IconData? icon, + bool isDestructive = false, + String confirmLabel = 'OK', + }) { HapticFeedback.mediumImpact(); Get.dialog( - TweenAnimationBuilder( - duration: DialogConfig.animationDuration, - tween: Tween(begin: 0.0, end: 1.0), - builder: (context, value, child) { - return Transform.scale( - scale: 0.95 + (0.05 * value), - child: Opacity(opacity: value, child: child), - ); - }, - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: DialogConfig.blurStrength, - sigmaY: DialogConfig.blurStrength, - ), - child: Theme( - data: ThemeData.light().copyWith( - dialogBackgroundColor: CupertinoColors.systemBackground, - ), - child: CupertinoAlertDialog( - title: Column( - children: [ - Text( - title, - style: AppStyle.title.copyWith( - fontSize: 20, - fontWeight: FontWeight.w700, - letterSpacing: -0.5, - color: AppColor.primaryColor, + _DialogShell( + child: _GlassCard( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // โ”€โ”€ Body โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Padding( + padding: const EdgeInsets.fromLTRB(24, 28, 24, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row: icon + title + TTS + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: (isDestructive + ? AppColor.redColor + : AppColor.primaryColor) + .withOpacity(0.1), + ), + child: Icon( + icon ?? + (isDestructive + ? Icons.warning_amber_rounded + : Icons.tune_rounded), + color: isDestructive + ? AppColor.redColor + : AppColor.primaryColor, + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: AppStyle.title.copyWith( + fontSize: 17, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + color: Colors.black87, + ), + ), + ), + _SpeakButton(texts: [title]), + ], ), - ), - const SizedBox(height: 8), - ], - ), - content: Column( - children: [ - CupertinoButton( - padding: const EdgeInsets.all(8), - onPressed: () async { - HapticFeedback.selectionClick(); - await textToSpeechController.speakText(title); - }, - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: - AppColor.primaryColor.withAlpha(26), // 0.1 opacity - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - CupertinoIcons.headphones, - color: AppColor.primaryColor, - size: 24, + + // Divider + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Divider( + height: 1, + color: Colors.grey.withOpacity(0.15), ), ), - ), - const SizedBox(height: 12), - content!, - ], + + // Content + if (content != null) content, + ], + ), ), - actions: [ - CupertinoDialogAction( - onPressed: () { - HapticFeedback.lightImpact(); - Get.back(); - }, - child: Text( - 'Cancel', - style: TextStyle( - color: AppColor.redColor, - fontWeight: FontWeight.w600, - fontSize: 17, - ), - ), - ), - CupertinoDialogAction( - onPressed: () { - HapticFeedback.mediumImpact(); - onPressed(); - }, - child: Text( - 'OK'.tr, - style: TextStyle( - color: AppColor.greenColor, - fontWeight: FontWeight.w600, - fontSize: 17, - ), - ), - ), - ], - ), + + // โ”€โ”€ Actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + _ActionRow( + onCancel: () => Get.back(), + onConfirm: onPressed, + confirmLabel: confirmLabel.tr, + isDestructive: isDestructive, + ), + ], ), ), ), barrierDismissible: true, - barrierColor: Colors.black.withAlpha(102), // 0.4 opacity + barrierColor: _DC.barrierColor, ); } }