1163 lines
42 KiB
Dart
1163 lines
42 KiB
Dart
import 'dart:ui';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:get/get.dart';
|
||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||
|
||
import '../../../constant/box_name.dart';
|
||
import '../../../main.dart';
|
||
import '../../widgets/error_snakbar.dart';
|
||
import 'navigation_controller.dart';
|
||
|
||
// ─── Color Palette Matching the HTML Specs ──────────────────────────────────
|
||
Color get _kSurface =>
|
||
Get.isDarkMode ? const Color(0xFF131b2e) : const Color(0xFFfaf8ff);
|
||
Color get _kSurfaceContainerLowest =>
|
||
Get.isDarkMode ? const Color(0xFF1e293b) : const Color(0xFFffffff);
|
||
Color get _kSurfaceContainerHigh =>
|
||
Get.isDarkMode ? const Color(0xFF334155) : const Color(0xFFe2e7ff);
|
||
Color get _kPrimary => const Color(0xFF000000);
|
||
Color get _kPrimaryContainer => const Color(0xFF131b2e);
|
||
Color get _kOnPrimaryContainer => const Color(0xFF7c839b);
|
||
Color get _kOnSurface =>
|
||
Get.isDarkMode ? const Color(0xFFffffff) : const Color(0xFF131b2e);
|
||
Color get _kOnSurfaceVariant => const Color(0xFF45464d);
|
||
Color get _kErrorContainer => const Color(0xFFffdad6);
|
||
Color get _kError => const Color(0xFFba1a1a);
|
||
Color get _kOutlineVariant => const Color(0xFFc6c6cd);
|
||
|
||
class NavigationView extends StatelessWidget {
|
||
const NavigationView({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final NavigationController c = Get.put(NavigationController());
|
||
|
||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||
value: Get.isDarkMode
|
||
? SystemUiOverlayStyle.light
|
||
: SystemUiOverlayStyle.dark,
|
||
child: Scaffold(
|
||
backgroundColor: _kSurface,
|
||
body: GetBuilder<NavigationController>(
|
||
builder: (_) => Stack(
|
||
children: [
|
||
// ── 1. Map Layer ──────────────────────────────────────────────
|
||
MapLibreMap(
|
||
onMapCreated: c.onMapCreated,
|
||
onStyleLoadedCallback: c.onStyleLoaded,
|
||
onMapLongClick: c.onMapLongPressed,
|
||
onMapClick: (point, tappedPoint) =>
|
||
c.onMapTapped(point, tappedPoint),
|
||
styleString: Get.isDarkMode
|
||
? "assets/style_dark.json"
|
||
: "assets/style.json",
|
||
initialCameraPosition: CameraPosition(
|
||
target: c.myLocation ?? const LatLng(33.5138, 36.2765),
|
||
zoom: 16.0),
|
||
myLocationEnabled: false,
|
||
compassEnabled: false,
|
||
trackCameraPosition: true,
|
||
),
|
||
|
||
// ── 2. Top UI (Explore Mode) ──────────────────────────────────
|
||
if (!c.isNavigating) _ExploreTopUI(controller: c),
|
||
|
||
// ── 3. Top UI (Active Navigation Banner) ──────────────────────
|
||
if (c.isNavigating && c.currentInstruction.isNotEmpty)
|
||
_ActiveTopBanner(controller: c),
|
||
|
||
// ── 4. Map Controls (Floating Right) ──────────────────────────
|
||
_MapControls(controller: c),
|
||
|
||
// ── 5. Bottom Panel (Explore Mode / Route Setup) ──────────────
|
||
if (!c.isNavigating) _ExploreBottomPanel(controller: c),
|
||
|
||
// ── 6. Bottom HUD (Active Navigation) ─────────────────────────
|
||
if (c.isNavigating) _ActiveBottomHUD(controller: c),
|
||
|
||
// ── 7. Speedometer Badge ──────────────────────────────────────
|
||
if (c.isNavigating) _SpeedBadge(speed: c.currentSpeed),
|
||
|
||
// ── 8. Search Results Dropdown ────────────────────────────────
|
||
if (c.placesDestination.isNotEmpty && !c.isNavigating)
|
||
_SearchResults(controller: c),
|
||
|
||
// ── 9. Loading Overlay ────────────────────────────────────────
|
||
if (c.isLoading) const _LoadingOverlay(),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// EXPLORE MODE COMPONENTS
|
||
// =============================================================================
|
||
|
||
class _ExploreTopUI extends StatelessWidget {
|
||
final NavigationController controller;
|
||
const _ExploreTopUI({required this.controller});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Positioned(
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
child: SafeArea(
|
||
bottom: false,
|
||
child: Column(
|
||
children: [
|
||
// Search Pill
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: _kSurfaceContainerLowest.withOpacity(0.95),
|
||
borderRadius: BorderRadius.circular(50),
|
||
border: Border.all(color: Colors.white.withOpacity(0.2)),
|
||
boxShadow: const [
|
||
BoxShadow(
|
||
color: Color(0x0F000000),
|
||
blurRadius: 32,
|
||
offset: Offset(0, 8))
|
||
],
|
||
),
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
|
||
child: Row(
|
||
children: [
|
||
IconButton(
|
||
icon: Icon(Icons.menu_rounded, color: _kOnSurface),
|
||
onPressed: () {}, // Drawer or Menu logic here
|
||
),
|
||
Expanded(
|
||
child: TextField(
|
||
controller: controller.placeDestinationController,
|
||
onChanged: controller.onSearchChanged,
|
||
textInputAction: TextInputAction.search,
|
||
style: TextStyle(
|
||
fontSize: 16,
|
||
color: _kOnSurface,
|
||
fontWeight: FontWeight.w600),
|
||
decoration: InputDecoration(
|
||
hintText: 'Where to?'.tr,
|
||
hintStyle: TextStyle(
|
||
color: _kOnSurfaceVariant,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w500),
|
||
border: InputBorder.none,
|
||
isDense: true,
|
||
contentPadding:
|
||
const EdgeInsets.symmetric(vertical: 10),
|
||
),
|
||
),
|
||
),
|
||
if (controller.placeDestinationController.text.isNotEmpty)
|
||
IconButton(
|
||
icon: const Icon(Icons.close_rounded),
|
||
color: _kOnSurfaceVariant,
|
||
onPressed: () {
|
||
controller.placeDestinationController.clear();
|
||
controller.placesDestination = [];
|
||
controller.update();
|
||
})
|
||
else if (controller.destinationSymbol != null)
|
||
IconButton(
|
||
icon: const Icon(Icons.close_rounded),
|
||
color: _kError,
|
||
onPressed: () => controller.clearRoute()),
|
||
|
||
// Avatar
|
||
const Padding(
|
||
padding: EdgeInsets.only(right: 4, left: 4),
|
||
child: CircleAvatar(
|
||
radius: 18,
|
||
backgroundColor: Colors.grey,
|
||
child: Icon(Icons.person_rounded,
|
||
color: Colors.white, size: 20),
|
||
// backgroundImage: AssetImage('assets/images/placeholder_avatar.png'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
|
||
// Quick Access Chips
|
||
SizedBox(
|
||
height: 44,
|
||
child: ListView(
|
||
scrollDirection: Axis.horizontal,
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
physics: const BouncingScrollPhysics(),
|
||
children: [
|
||
// "Home" (Al-Ra'isiya) removed as per user request if it was redundant,
|
||
// but we keep the functional Home/Work/Saved/Airport
|
||
_QuickChip(
|
||
icon: Icons.home_rounded,
|
||
label: 'Home'.tr,
|
||
onTap: () => controller.goToFavorite('home'),
|
||
),
|
||
_QuickChip(
|
||
icon: Icons.work_rounded,
|
||
label: 'Work'.tr,
|
||
onTap: () => controller.goToFavorite('work'),
|
||
),
|
||
_QuickChip(
|
||
icon: Icons.bookmark_rounded,
|
||
label: 'Saved'.tr,
|
||
onTap: () {}, // Future logic
|
||
),
|
||
_QuickChip(
|
||
icon: Icons.flight_rounded,
|
||
label: 'Airport'.tr,
|
||
onTap: () => controller.goToFavorite('airport'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _QuickChip extends StatelessWidget {
|
||
final IconData icon;
|
||
final String label;
|
||
final VoidCallback onTap;
|
||
const _QuickChip(
|
||
{required this.icon, required this.label, required this.onTap});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return GestureDetector(
|
||
onTap: () {
|
||
HapticFeedback.lightImpact();
|
||
onTap();
|
||
},
|
||
child: Container(
|
||
margin: const EdgeInsets.only(right: 8),
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
color: _kSurfaceContainerLowest.withOpacity(0.95),
|
||
borderRadius: BorderRadius.circular(50),
|
||
boxShadow: const [
|
||
BoxShadow(
|
||
color: Color(0x0A000000), blurRadius: 4, offset: Offset(0, 2))
|
||
],
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(icon, size: 18, color: _kOnSurface),
|
||
const SizedBox(width: 8),
|
||
Text(label,
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w600,
|
||
color: _kOnSurface)),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ExploreBottomPanel extends StatelessWidget {
|
||
final NavigationController controller;
|
||
const _ExploreBottomPanel({required this.controller});
|
||
|
||
String _formatDuration(double seconds) {
|
||
final mins = (seconds / 60).toInt();
|
||
if (mins >= 60) {
|
||
final h = mins ~/ 60;
|
||
final m = mins % 60;
|
||
return box.read(BoxName.lang) == 'ar'
|
||
? '$h ساعة ${m > 0 ? '$m د' : ''}'
|
||
: '${h}h ${m > 0 ? '${m}m' : ''}';
|
||
}
|
||
return box.read(BoxName.lang) == 'ar' ? '$mins دقيقة' : '$mins min';
|
||
}
|
||
|
||
String _formatDistance(double meters) {
|
||
if (meters >= 1000) {
|
||
return box.read(BoxName.lang) == 'ar'
|
||
? '${(meters / 1000).toStringAsFixed(1)} كم'
|
||
: '${(meters / 1000).toStringAsFixed(1)} km';
|
||
}
|
||
return box.read(BoxName.lang) == 'ar'
|
||
? '${meters.toInt()} م'
|
||
: '${meters.toInt()} m';
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final bool hasRoutes = controller.routes.isNotEmpty;
|
||
final bool isArabic = box.read(BoxName.lang) == 'ar';
|
||
final bottomPad = MediaQuery.of(context).padding.bottom;
|
||
|
||
return Positioned(
|
||
bottom: 0,
|
||
left: 0,
|
||
right: 0,
|
||
child: ClipRRect(
|
||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||
child: BackdropFilter(
|
||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||
child: Container(
|
||
padding: EdgeInsets.only(bottom: bottomPad + 12),
|
||
decoration: BoxDecoration(
|
||
color: _kSurfaceContainerLowest.withOpacity(0.92),
|
||
boxShadow: const [
|
||
BoxShadow(
|
||
color: Color(0x14000000),
|
||
blurRadius: 48,
|
||
offset: Offset(0, -12))
|
||
],
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
// Drag Handle
|
||
Container(
|
||
width: 40,
|
||
height: 4,
|
||
margin: const EdgeInsets.only(top: 12, bottom: 16),
|
||
decoration: BoxDecoration(
|
||
color: _kOutlineVariant.withOpacity(0.4),
|
||
borderRadius: BorderRadius.circular(10))),
|
||
|
||
// ── Route Selection Cards ──
|
||
if (hasRoutes) ...[
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||
child: Column(
|
||
children: List.generate(controller.routes.length, (index) {
|
||
final r = controller.routes[index];
|
||
final isSelected = controller.selectedRouteIndex == index;
|
||
return _RouteOptionCard(
|
||
index: index,
|
||
distance: _formatDistance(r.distanceM),
|
||
duration: _formatDuration(r.durationS),
|
||
isSelected: isSelected,
|
||
isArabic: isArabic,
|
||
onTap: () => controller.selectRoute(index),
|
||
);
|
||
}),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
],
|
||
|
||
// ── Main Action Button ──
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
onTap: hasRoutes
|
||
? () {
|
||
HapticFeedback.mediumImpact();
|
||
controller.isNavigating = true;
|
||
controller.relockCameraToUser();
|
||
controller.update();
|
||
}
|
||
: null,
|
||
borderRadius: BorderRadius.circular(16),
|
||
child: Ink(
|
||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
colors: hasRoutes
|
||
? [const Color(0xFF0D47A1), const Color(0xFF1565C0)]
|
||
: [Colors.grey.shade400, Colors.grey.shade500],
|
||
),
|
||
borderRadius: BorderRadius.circular(16),
|
||
boxShadow: hasRoutes
|
||
? [
|
||
BoxShadow(
|
||
color: const Color(0xFF0D47A1).withOpacity(0.35),
|
||
blurRadius: 16,
|
||
offset: const Offset(0, 6))
|
||
]
|
||
: [],
|
||
),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
const Icon(Icons.navigation_rounded, color: Colors.white, size: 22),
|
||
const SizedBox(width: 10),
|
||
Text(
|
||
hasRoutes
|
||
? (isArabic ? 'ابدأ الملاحة' : 'Start Navigation')
|
||
: (isArabic ? 'خطط المسار' : 'Plan Route'),
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 17,
|
||
fontWeight: FontWeight.w800,
|
||
letterSpacing: 0.3)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// ── Recent Places ──
|
||
if (!hasRoutes && controller.recentLocations.isNotEmpty) ...[
|
||
const SizedBox(height: 20),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||
child: Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Text(isArabic ? 'الأماكن الأخيرة' : 'RECENT PLACES',
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w800,
|
||
color: _kOnSurfaceVariant.withOpacity(0.6),
|
||
letterSpacing: 1.2)),
|
||
),
|
||
),
|
||
...controller.recentLocations.map((loc) => _CompactRecentPlace(
|
||
title: loc['name'] ?? 'Unknown',
|
||
subtitle: loc['address'] ?? '',
|
||
onTap: () {
|
||
if (controller.myLocation != null &&
|
||
loc['latitude'] != null &&
|
||
loc['longitude'] != null) {
|
||
controller.getRoute(
|
||
controller.myLocation!,
|
||
LatLng(
|
||
double.parse(loc['latitude'].toString()),
|
||
double.parse(loc['longitude'].toString())));
|
||
}
|
||
},
|
||
)),
|
||
const SizedBox(height: 8),
|
||
] else if (!hasRoutes) ...[
|
||
const SizedBox(height: 16),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _RouteOptionCard extends StatelessWidget {
|
||
final int index;
|
||
final String distance;
|
||
final String duration;
|
||
final bool isSelected;
|
||
final bool isArabic;
|
||
final VoidCallback onTap;
|
||
|
||
const _RouteOptionCard({
|
||
required this.index,
|
||
required this.distance,
|
||
required this.duration,
|
||
required this.isSelected,
|
||
required this.isArabic,
|
||
required this.onTap,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return GestureDetector(
|
||
onTap: () {
|
||
HapticFeedback.selectionClick();
|
||
onTap();
|
||
},
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 200),
|
||
margin: const EdgeInsets.only(bottom: 8),
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||
decoration: BoxDecoration(
|
||
color: isSelected
|
||
? const Color(0xFF0D47A1)
|
||
: _kSurfaceContainerHigh.withOpacity(0.4),
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(
|
||
color: isSelected
|
||
? const Color(0xFF42A5F5).withOpacity(0.4)
|
||
: _kOutlineVariant.withOpacity(0.15),
|
||
width: isSelected ? 1.5 : 1,
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
// Route icon
|
||
Container(
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(
|
||
color: isSelected
|
||
? Colors.white.withOpacity(0.15)
|
||
: _kSurfaceContainerHigh.withOpacity(0.5),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Icon(
|
||
index == 0 ? Icons.route_rounded : Icons.alt_route_rounded,
|
||
color: isSelected ? Colors.white : _kOnSurfaceVariant,
|
||
size: 20,
|
||
),
|
||
),
|
||
const SizedBox(width: 14),
|
||
|
||
// Route Label
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
index == 0
|
||
? (isArabic ? 'أفضل مسار' : 'Best Route')
|
||
: (isArabic ? 'مسار بديل $index' : 'Alternative $index'),
|
||
style: TextStyle(
|
||
color: isSelected ? Colors.white : _kOnSurface,
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
distance,
|
||
style: TextStyle(
|
||
color: isSelected
|
||
? Colors.white.withOpacity(0.7)
|
||
: _kOnSurfaceVariant,
|
||
fontSize: 12,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// Duration (prominent)
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: isSelected
|
||
? Colors.white.withOpacity(0.15)
|
||
: const Color(0xFFE3F2FD),
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: Text(
|
||
duration,
|
||
style: TextStyle(
|
||
color: isSelected ? Colors.white : const Color(0xFF0D47A1),
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w800,
|
||
),
|
||
),
|
||
),
|
||
|
||
// Selection indicator
|
||
const SizedBox(width: 8),
|
||
Icon(
|
||
isSelected ? Icons.check_circle_rounded : Icons.radio_button_unchecked_rounded,
|
||
color: isSelected ? Colors.white : _kOutlineVariant,
|
||
size: 22,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
|
||
class _CompactRecentPlace extends StatelessWidget {
|
||
final String title;
|
||
final String subtitle;
|
||
final VoidCallback onTap;
|
||
|
||
const _CompactRecentPlace({
|
||
required this.title,
|
||
required this.subtitle,
|
||
required this.onTap,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ListTile(
|
||
onTap: onTap,
|
||
dense: true,
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 0),
|
||
leading: Container(
|
||
padding: const EdgeInsets.all(8),
|
||
decoration: BoxDecoration(
|
||
color: _kSurfaceContainerHigh.withOpacity(0.5),
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: Icon(Icons.history_rounded, size: 18, color: _kOnSurface),
|
||
),
|
||
title: Text(title,
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.w700, color: _kOnSurface, fontSize: 14)),
|
||
subtitle: Text(subtitle,
|
||
style: TextStyle(color: _kOnSurfaceVariant, fontSize: 12),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis),
|
||
);
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// ACTIVE NAVIGATION MODE COMPONENTS
|
||
// =============================================================================
|
||
|
||
class _ActiveTopBanner extends StatelessWidget {
|
||
final NavigationController controller;
|
||
const _ActiveTopBanner({required this.controller});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Positioned(
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
child: SafeArea(
|
||
bottom: false,
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF1B5E20), // Google Maps Green
|
||
borderRadius: BorderRadius.circular(16),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withOpacity(0.3),
|
||
blurRadius: 15,
|
||
offset: const Offset(0, 5))
|
||
],
|
||
),
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
||
child: Row(
|
||
children: [
|
||
// Direction indicator
|
||
Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white.withOpacity(0.1),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Icon(controller.currentManeuverIcon,
|
||
color: Colors.white, size: 48),
|
||
),
|
||
const SizedBox(width: 16),
|
||
// Full instruction text
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
controller.currentInstruction,
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 22,
|
||
fontWeight: FontWeight.w800,
|
||
letterSpacing: -0.5),
|
||
maxLines: 3,
|
||
overflow: TextOverflow.visible,
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
controller.distanceToNextStep,
|
||
style: const TextStyle(
|
||
color: Colors.white70,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w900),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ActiveBottomHUD extends StatelessWidget {
|
||
final NavigationController controller;
|
||
const _ActiveBottomHUD({required this.controller});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final bottomPad = MediaQuery.of(context).padding.bottom;
|
||
final isArabic = (Get.locale?.languageCode ?? 'ar') == 'ar';
|
||
|
||
return Positioned(
|
||
bottom: 0,
|
||
left: 0,
|
||
right: 0,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
decoration: BoxDecoration(
|
||
color: _kSurfaceContainerLowest,
|
||
borderRadius:
|
||
const BorderRadius.vertical(top: Radius.circular(24)),
|
||
boxShadow: const [
|
||
BoxShadow(
|
||
color: Color(0x0F000000),
|
||
blurRadius: 32,
|
||
offset: Offset(0, -8))
|
||
],
|
||
),
|
||
padding: EdgeInsets.fromLTRB(
|
||
24, 16, 24, bottomPad + 12), // Reduced top padding
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
// Stats Row
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
_StatItem(
|
||
label: isArabic ? 'الوصول' : 'ARRIVAL',
|
||
value: controller.arrivalTime,
|
||
color: _kOnSurface),
|
||
_StatItem(
|
||
label: isArabic ? 'الوقت' : 'TIME',
|
||
value: isArabic
|
||
? '${controller.estimatedTimeRemaining} دقيقة'
|
||
: '${controller.estimatedTimeRemaining} min',
|
||
color: _kOnSurface),
|
||
_StatItem(
|
||
label: isArabic ? 'المسافة' : 'DISTANCE',
|
||
value: controller.distanceWithUnit, // Use fix
|
||
color: _kOnSurface),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
const Divider(height: 1),
|
||
const SizedBox(height: 16),
|
||
// Action Buttons Row
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||
children: [
|
||
_ActionButton(
|
||
icon: Icons.report_problem_rounded,
|
||
label: isArabic ? 'تقرير' : 'Report',
|
||
color: const Color(0xFFE8EAF6),
|
||
onPressed: () {}),
|
||
_ActionButton(
|
||
icon: controller.isMuted
|
||
? Icons.volume_off_rounded
|
||
: Icons.volume_up_rounded,
|
||
label: isArabic ? 'الصوت' : 'Sound',
|
||
color: const Color(0xFFE8EAF6),
|
||
onPressed: () => controller.toggleMute()),
|
||
_ActionButton(
|
||
icon: Icons.add_rounded,
|
||
label: isArabic ? 'إضافة' : 'Add',
|
||
color: const Color(0xFFE8EAF6),
|
||
onPressed: () =>
|
||
_showSuggestPlaceDialog(context, controller)),
|
||
_ActionButton(
|
||
icon: Icons.close_rounded,
|
||
label: isArabic ? 'إنهاء' : 'End',
|
||
color: const Color(0xFFFFEBEE),
|
||
iconColor: _kError,
|
||
onPressed: () => controller.clearRoute()),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
void _showSuggestPlaceDialog(
|
||
BuildContext context, NavigationController controller) {
|
||
final entryController = TextEditingController();
|
||
Get.dialog(
|
||
AlertDialog(
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||
title: Row(
|
||
children: [
|
||
const Icon(Icons.stars_rounded, color: Colors.amber, size: 28),
|
||
const SizedBox(width: 10),
|
||
Text(box.read(BoxName.lang) == 'ar'
|
||
? "ساهم في تحسين الخريطة"
|
||
: "Contribute to Map"),
|
||
],
|
||
),
|
||
content: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
box.read(BoxName.lang) == 'ar'
|
||
? "هل هناك مكان غير موجود؟ أضفه الآن واحصل على ٥٠ نقطة مكافأة!"
|
||
: "Is there a missing place? Add it now and earn 50 reward points!",
|
||
style: const TextStyle(fontSize: 14),
|
||
),
|
||
const SizedBox(height: 20),
|
||
TextField(
|
||
controller: entryController,
|
||
decoration: InputDecoration(
|
||
hintText:
|
||
box.read(BoxName.lang) == 'ar' ? "اسم المكان" : "Place Name",
|
||
filled: true,
|
||
fillColor: Colors.grey.shade100,
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
borderSide: BorderSide.none,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Get.back(),
|
||
child: Text(box.read(BoxName.lang) == 'ar' ? "تراجع" : "Cancel",
|
||
style: const TextStyle(color: Colors.grey)),
|
||
),
|
||
ElevatedButton(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: const Color(0xFF1B5E20),
|
||
shape:
|
||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
onPressed: () {
|
||
if (entryController.text.isNotEmpty) {
|
||
controller.submitPlaceSuggestion(entryController.text);
|
||
Get.back();
|
||
}
|
||
},
|
||
child: Text(
|
||
box.read(BoxName.lang) == 'ar'
|
||
? "إرسال واحصل على النقاط"
|
||
: "Submit & Earn Points",
|
||
style: const TextStyle(color: Colors.white)),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
class _StatItem extends StatelessWidget {
|
||
final String label;
|
||
final String value;
|
||
final Color color;
|
||
const _StatItem(
|
||
{required this.label, required this.value, required this.color});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
children: [
|
||
Text(label,
|
||
style: TextStyle(
|
||
fontSize: 10,
|
||
color: _kOnSurfaceVariant,
|
||
fontWeight: FontWeight.bold)),
|
||
Text(value,
|
||
style: TextStyle(
|
||
fontSize: 16, color: color, fontWeight: FontWeight.w900)),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ActionButton extends StatelessWidget {
|
||
final IconData icon;
|
||
final String label;
|
||
final Color color;
|
||
final Color? iconColor;
|
||
final VoidCallback onPressed;
|
||
const _ActionButton(
|
||
{required this.icon,
|
||
required this.label,
|
||
required this.color,
|
||
this.iconColor,
|
||
required this.onPressed});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
children: [
|
||
IconButton(
|
||
onPressed: onPressed,
|
||
icon: Icon(icon, color: iconColor ?? _kOnSurface),
|
||
style: IconButton.styleFrom(
|
||
backgroundColor: color, fixedSize: const Size(50, 50)),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(label, style: const TextStyle(fontSize: 11)),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SpeedBadge extends StatelessWidget {
|
||
final double speed;
|
||
const _SpeedBadge({required this.speed});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final int kmh = speed.toInt();
|
||
|
||
// Exact positioning from HTML (bottom-64 left-6, which is approx 256px from bottom)
|
||
return Positioned(
|
||
bottom: 256,
|
||
left: 24,
|
||
child: Container(
|
||
width: 80,
|
||
height: 80,
|
||
decoration: BoxDecoration(
|
||
color: _kSurfaceContainerLowest,
|
||
shape: BoxShape.circle,
|
||
border: Border.all(color: _kPrimary, width: 4),
|
||
boxShadow: const [
|
||
BoxShadow(
|
||
color: Color(0x0F000000), blurRadius: 32, offset: Offset(0, 8))
|
||
],
|
||
),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Text('$kmh',
|
||
style: TextStyle(
|
||
color: _kOnSurface,
|
||
fontSize: 28,
|
||
fontWeight: FontWeight.w900,
|
||
height: 1.0)),
|
||
Text('km/h',
|
||
style: TextStyle(
|
||
color: _kOnSurfaceVariant,
|
||
fontSize: 10,
|
||
fontWeight: FontWeight.w800,
|
||
letterSpacing: -0.5,
|
||
)),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// SHARED UTILITIES
|
||
// =============================================================================
|
||
|
||
class _MapControls extends StatelessWidget {
|
||
final NavigationController controller;
|
||
const _MapControls({required this.controller});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Positioned(
|
||
right: 16,
|
||
top: MediaQuery.of(context).size.height * 0.45,
|
||
child: Column(
|
||
children: [
|
||
_MapFab(
|
||
icon: Icons.my_location_rounded,
|
||
iconColor:
|
||
controller.isCameraLocked ? _kPrimary : Colors.grey[400]!,
|
||
onTap: () {
|
||
HapticFeedback.lightImpact();
|
||
controller.relockCameraToUser();
|
||
},
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _MapFab extends StatelessWidget {
|
||
final IconData icon;
|
||
final Color iconColor;
|
||
final VoidCallback onTap;
|
||
const _MapFab(
|
||
{required this.icon, required this.iconColor, required this.onTap});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return GestureDetector(
|
||
onTap: onTap,
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(16),
|
||
child: BackdropFilter(
|
||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||
child: Container(
|
||
width: 56,
|
||
height: 56,
|
||
decoration: BoxDecoration(
|
||
color: _kSurfaceContainerLowest.withOpacity(0.95),
|
||
borderRadius: BorderRadius.circular(16),
|
||
boxShadow: const [
|
||
BoxShadow(
|
||
color: Color(0x1A000000),
|
||
blurRadius: 16,
|
||
offset: Offset(0, 8))
|
||
]),
|
||
child: Icon(icon, color: iconColor, size: 26),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SearchResults extends StatelessWidget {
|
||
final NavigationController controller;
|
||
const _SearchResults({required this.controller});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Positioned(
|
||
top: MediaQuery.of(context).padding.top + 90,
|
||
left: 16,
|
||
right: 16,
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(24),
|
||
child: BackdropFilter(
|
||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: _kSurfaceContainerLowest.withOpacity(0.95),
|
||
borderRadius: BorderRadius.circular(24),
|
||
border: Border.all(color: Colors.white.withOpacity(0.2)),
|
||
boxShadow: const [
|
||
BoxShadow(
|
||
color: Color(0x1A000000),
|
||
blurRadius: 32,
|
||
offset: Offset(0, 16))
|
||
]),
|
||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxHeight: 300),
|
||
child: ListView.separated(
|
||
shrinkWrap: true,
|
||
padding: EdgeInsets.zero,
|
||
itemCount: controller.placesDestination.length,
|
||
separatorBuilder: (_, __) => Divider(
|
||
height: 1,
|
||
color: _kOutlineVariant.withOpacity(0.2),
|
||
indent: 72),
|
||
itemBuilder: (_, i) {
|
||
final place = controller.placesDestination[i];
|
||
final dist = place['distanceKm'] as double?;
|
||
return InkWell(
|
||
onTap: () => controller.selectDestination(place),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 20, vertical: 16),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(
|
||
color: _kSurfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(12)),
|
||
child: Icon(Icons.place_rounded,
|
||
color: _kOnSurfaceVariant, size: 20)),
|
||
const SizedBox(width: 16),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(place['name'] ?? '',
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 16,
|
||
color: _kOnSurface),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis),
|
||
if ((place['address'] ?? '').isNotEmpty)
|
||
Text(place['address'],
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
color: _kOnSurfaceVariant),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis),
|
||
],
|
||
),
|
||
),
|
||
if (dist != null) ...[
|
||
const SizedBox(width: 12),
|
||
Text('${dist.toStringAsFixed(1)} km',
|
||
style: TextStyle(
|
||
color: _kOnSurfaceVariant,
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.bold)),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _LoadingOverlay extends StatelessWidget {
|
||
const _LoadingOverlay();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Positioned.fill(
|
||
child: BackdropFilter(
|
||
filter: ImageFilter.blur(sigmaX: 6, sigmaY: 6),
|
||
child: Container(
|
||
color: _kPrimaryContainer.withOpacity(0.4),
|
||
child: Center(
|
||
child: Container(
|
||
padding: const EdgeInsets.all(32),
|
||
decoration: BoxDecoration(
|
||
color: _kSurfaceContainerLowest,
|
||
borderRadius: BorderRadius.circular(24),
|
||
boxShadow: const [
|
||
BoxShadow(
|
||
color: Color(0x33000000),
|
||
blurRadius: 48,
|
||
offset: Offset(0, 16))
|
||
]),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const CircularProgressIndicator(
|
||
valueColor: AlwaysStoppedAnimation(Colors.black),
|
||
strokeWidth: 4),
|
||
const SizedBox(height: 24),
|
||
Text('Routing...'.tr,
|
||
style: TextStyle(
|
||
color: _kOnSurface,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w800)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|