Files
intaleq/lib/views/home/navigation/navigation_view.dart
2026-04-16 19:45:03 +03:00

1163 lines
42 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)),
],
),
),
),
),
),
);
}
}