Files
intaleq/lib/views/home/navigation/navigation_view.dart

861 lines
33 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 'navigation_controller.dart';
// ─── Brand colours ───────────────────────────────────────────────────────────
// ─── Theme-aware Brand colours ──────────────────────────────────────────────
Color get _kBlue => const Color(0xFF1A73E8);
Color get _kBlueDark => const Color(0xFF0D47A1);
Color get _kSurface =>
Get.isDarkMode ? const Color(0xFF1E1E1E) : const Color(0xFFFFFFFF);
Color get _kText =>
Get.isDarkMode ? const Color(0xFFF5F5F7) : const Color(0xFF1C1C1E);
Color get _kSubtext =>
Get.isDarkMode ? Colors.white60 : const Color(0xFF6B7280);
Color get _kGreen => const Color(0xFF34A853);
Color get _kGlassSurface => Get.isDarkMode
? Colors.black.withOpacity(0.7)
: Colors.white.withOpacity(0.92);
Color get _kGlassBorder => Get.isDarkMode
? Colors.white.withOpacity(0.12)
: Colors.white.withOpacity(0.5);
class NavigationView extends StatelessWidget {
const NavigationView({super.key});
@override
Widget build(BuildContext context) {
final NavigationController c = Get.put(NavigationController());
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark,
child: Scaffold(
backgroundColor: Colors.black,
body: GetBuilder<NavigationController>(
builder: (_) => Stack(
children: [
// ── Map ────────────────────────────────────────────────────
MapLibreMap(
onMapCreated: c.onMapCreated,
onStyleLoadedCallback: c.onStyleLoaded,
onMapLongClick: c.onMapLongPressed,
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,
),
// ── Top: search bar (always visible) ──────────────────────
if (!c.isNavigating) _SearchBar(controller: c),
// ── Top: turn banner (navigation only) ────────────────────
if (c.isNavigating && c.currentInstruction.isNotEmpty)
_TurnBanner(controller: c),
// ── Right: floating map controls ──────────────────────────
_MapControls(controller: c),
// ── Bottom: route summary card ────────────────────────────
if (!c.isNavigating && c.destinationSymbol != null)
_RouteSummaryCard(controller: c),
// ── Bottom: navigation HUD ────────────────────────────────
if (c.isNavigating) _NavigationHUD(controller: c),
// ── Search results overlay ────────────────────────────────
if (c.placesDestination.isNotEmpty && !c.isNavigating)
_SearchResults(controller: c),
// ── Speed badge (navigating) ──────────────────────────────
if (c.isNavigating) _SpeedBadge(speed: c.currentSpeed),
// ── Loading overlay ───────────────────────────────────────
if (c.isLoading) const _LoadingOverlay(),
],
),
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Search Bar
// ─────────────────────────────────────────────────────────────────────────────
class _SearchBar extends StatelessWidget {
final NavigationController controller;
const _SearchBar({required this.controller});
@override
Widget build(BuildContext context) {
return Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: _GlassCard(
padding: EdgeInsets.zero,
borderRadius: 18,
child: Row(
children: [
const SizedBox(width: 16),
Icon(Icons.search_rounded, color: _kBlue, size: 22),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: controller.placeDestinationController,
onChanged: controller.onSearchChanged,
textInputAction: TextInputAction.search,
style: TextStyle(
fontSize: 16,
color: _kText,
fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'إلى أين تريد الذهاب؟',
hintStyle: TextStyle(color: _kSubtext, fontSize: 15),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
if (controller.placeDestinationController.text.isNotEmpty)
_IconBtn(
icon: Icons.close_rounded,
color: _kSubtext,
onTap: () {
controller.placeDestinationController.clear();
controller.placesDestination = [];
controller.update();
},
)
else if (controller.destinationSymbol != null)
_IconBtn(
icon: Icons.close_rounded,
color: Colors.redAccent,
onTap: () => controller.clearRoute(),
),
const SizedBox(width: 4),
],
),
),
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Search Results
// ─────────────────────────────────────────────────────────────────────────────
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 + 76,
left: 16,
right: 16,
child: _GlassCard(
borderRadius: 18,
padding: const EdgeInsets.symmetric(vertical: 6),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 260),
child: ListView.separated(
shrinkWrap: true,
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.zero,
itemCount: controller.placesDestination.length,
separatorBuilder: (_, __) => Divider(
height: 1,
color: Get.isDarkMode ? Colors.white12 : Colors.grey[100],
indent: 56),
itemBuilder: (_, i) {
final place = controller.placesDestination[i];
final dist = place['distanceKm'] as double?;
return InkWell(
onTap: () => controller.selectDestination(place),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: _kBlue.withOpacity(0.08),
shape: BoxShape.circle,
),
child:
Icon(Icons.place_rounded, color: _kBlue, size: 18),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(place['name'] ?? '',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14.5,
color: _kText),
maxLines: 1,
overflow: TextOverflow.ellipsis),
if ((place['address'] ?? '').isNotEmpty)
Text(place['address'],
style: TextStyle(
fontSize: 12.5, color: _kSubtext),
maxLines: 1,
overflow: TextOverflow.ellipsis),
],
),
),
if (dist != null) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: _kBlue.withOpacity(0.08),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${dist.toStringAsFixed(1)} كم',
style: TextStyle(
color: _kBlue,
fontSize: 12,
fontWeight: FontWeight.w600),
),
),
],
],
),
),
);
},
),
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Turn Banner (top card during navigation — like Google Maps)
// ─────────────────────────────────────────────────────────────────────────────
class _TurnBanner extends StatelessWidget {
final NavigationController controller;
const _TurnBanner({required this.controller});
@override
Widget build(BuildContext context) {
return Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 0),
child: Container(
decoration: BoxDecoration(
color: Get.isDarkMode
? Colors.grey[900]?.withOpacity(0.95)
: _kBlueDark,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: (Get.isDarkMode ? Colors.black : _kBlueDark)
.withOpacity(0.35),
blurRadius: 20,
offset: const Offset(0, 6)),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
child: Row(
children: [
// Turn arrow icon
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.turn_right_rounded,
color: Colors.white, size: 40),
),
const SizedBox(width: 14),
// Instruction text
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
controller.distanceToNextStep,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
controller.currentInstruction,
style: const TextStyle(
color: Colors.white,
fontSize: 26,
fontWeight: FontWeight.bold,
height: 1.2),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Close / stop navigation
_IconBtn(
icon: Icons.close_rounded,
color: Colors.white54,
size: 20,
onTap: () => controller.clearRoute(),
),
],
),
),
),
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Floating map controls (right side)
// ─────────────────────────────────────────────────────────────────────────────
class _MapControls extends StatelessWidget {
final NavigationController controller;
const _MapControls({required this.controller});
@override
Widget build(BuildContext context) {
final bottomOffset = controller.isNavigating ? 190.0 : 100.0;
return Positioned(
bottom: bottomOffset,
right: 14,
child: Column(
children: [
// Re-centre / lock camera
_MapFab(
icon: controller.isCameraLocked
? Icons.gps_fixed_rounded
: Icons.gps_not_fixed_rounded,
iconColor: controller.isCameraLocked ? _kBlue : Colors.grey[600]!,
onTap: () {
HapticFeedback.lightImpact();
controller.relockCameraToUser();
},
tooltip: 'موقعي',
),
if (controller.isNavigating) ...[
const SizedBox(height: 10),
_MapFab(
icon: Icons.sync_alt_rounded,
iconColor: _kBlueDark,
onTap: () {
HapticFeedback.mediumImpact();
controller.recalculateRoute();
},
tooltip: 'إعادة التوجيه',
),
],
],
),
);
}
}
class _MapFab extends StatelessWidget {
final IconData icon;
final Color iconColor;
final VoidCallback onTap;
final String tooltip;
const _MapFab({
required this.icon,
required this.iconColor,
required this.onTap,
required this.tooltip,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: GestureDetector(
onTap: onTap,
child: Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.14),
blurRadius: 12,
offset: const Offset(0, 4)),
],
),
child: Icon(icon, color: iconColor, size: 22),
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Route Summary Card (before navigation starts)
// ─────────────────────────────────────────────────────────────────────────────
class _RouteSummaryCard extends StatelessWidget {
final NavigationController controller;
const _RouteSummaryCard({required this.controller});
@override
Widget build(BuildContext context) {
return Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
color: _kSurface,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: [
BoxShadow(
color: Color(0x1A000000),
blurRadius: 24,
offset: Offset(0, -6)),
],
),
padding: EdgeInsets.fromLTRB(
20, 16, 20, MediaQuery.of(context).padding.bottom + 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle
Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(bottom: 18),
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
Row(
children: [
// Info pills
Expanded(
child: Row(
children: [
_InfoPill(
icon: Icons.schedule_rounded,
label: controller.estimatedTimeRemaining.isNotEmpty
? controller.estimatedTimeRemaining
: '--',
color: _kGreen,
),
const SizedBox(width: 10),
_InfoPill(
icon: Icons.straighten_rounded,
label: controller.totalDistanceRemaining.isNotEmpty
? controller.totalDistanceRemaining
: '--',
color: _kBlue,
),
],
),
),
// Start button
ElevatedButton.icon(
onPressed: () {
HapticFeedback.mediumImpact();
controller.isNavigating = true;
controller.relockCameraToUser();
controller.update();
},
style: ElevatedButton.styleFrom(
backgroundColor: _kBlue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 13),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14)),
elevation: 0,
),
icon: const Icon(Icons.navigation_rounded, size: 18),
label: const Text('ابدأ',
style:
TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
),
],
),
],
),
),
);
}
}
class _InfoPill extends StatelessWidget {
final IconData icon;
final String label;
final Color color;
const _InfoPill(
{required this.icon, required this.label, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: color.withOpacity(0.08),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: color.withOpacity(0.2)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: color, size: 22),
const SizedBox(width: 8),
Text(label,
style: TextStyle(
color: color, fontSize: 18, fontWeight: FontWeight.w800)),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Navigation HUD (bottom during active navigation — like HERE Maps)
// ─────────────────────────────────────────────────────────────────────────────
class _NavigationHUD extends StatelessWidget {
final NavigationController controller;
const _NavigationHUD({required this.controller});
@override
Widget build(BuildContext context) {
final bottomPad = MediaQuery.of(context).padding.bottom;
return Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
color: _kSurface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.12),
blurRadius: 20,
offset: const Offset(0, -4)),
],
),
padding: EdgeInsets.fromLTRB(20, 14, 20, bottomPad + 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── Next instruction row ───────────────────────────────────
if (controller.nextInstruction.isNotEmpty)
Container(
margin: const EdgeInsets.only(bottom: 12),
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Get.isDarkMode
? Colors.white.withOpacity(0.05)
: const Color(0xFFF8F9FA),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Get.isDarkMode
? Colors.white10
: Colors.grey.withOpacity(0.15)),
),
child: Row(
children: [
Icon(Icons.arrow_forward_rounded,
size: 20, color: _kSubtext),
const SizedBox(width: 10),
Expanded(
child: Text(
controller.nextInstruction,
style: TextStyle(
color: _kText,
fontSize: 16,
fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
// ── ETA / distance strip ───────────────────────────────────
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_InfoPill(
icon: Icons.schedule_rounded,
label: controller.estimatedTimeRemaining.isNotEmpty
? controller.estimatedTimeRemaining
: '--',
color: _kGreen,
),
_InfoPill(
icon: Icons.straighten_rounded,
label: controller.totalDistanceRemaining.isNotEmpty
? controller.totalDistanceRemaining
: '--',
color: _kBlue,
),
// Stop navigation
GestureDetector(
onTap: () {
HapticFeedback.mediumImpact();
controller.clearRoute();
},
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.withOpacity(0.2)),
),
child: Row(
children: [
const Icon(Icons.stop_rounded,
color: Colors.redAccent, size: 24),
const SizedBox(width: 6),
const Text('إيقاف',
style: TextStyle(
color: Colors.redAccent,
fontSize: 18,
fontWeight: FontWeight.bold)),
],
),
),
),
],
),
],
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Speed badge (bottom-left during navigation)
// ─────────────────────────────────────────────────────────────────────────────
class _SpeedBadge extends StatelessWidget {
final double speed;
const _SpeedBadge({required this.speed});
@override
Widget build(BuildContext context) {
final int kmh = speed.toInt();
final bool fast = kmh > 100;
return Positioned(
bottom: MediaQuery.of(context).padding.bottom + 150,
left: 16,
child: Stack(
alignment: Alignment.center,
children: [
// Circular progress mimicking a speedometer
SizedBox(
width: 86,
height: 86,
child: CircularProgressIndicator(
value: (kmh / 140.0)
.clamp(0.0, 1.0), // Assuming 140 is max speed shown
strokeWidth: 6,
backgroundColor: Get.isDarkMode
? Colors.white10
: Colors.grey.withOpacity(0.3),
valueColor: AlwaysStoppedAnimation<Color>(
fast ? Colors.redAccent : _kBlue),
),
),
Container(
width: 74,
height: 74,
decoration: BoxDecoration(
color: fast ? const Color(0xFFD93025) : _kSurface,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.15),
blurRadius: 16,
offset: const Offset(0, 6)),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'$kmh',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.w900,
color: fast ? Colors.white : _kText,
height: 1),
),
Text(
'كم/س',
style: TextStyle(
fontSize: 13,
color: fast ? Colors.white70 : _kSubtext,
fontWeight: FontWeight.w600),
),
],
),
),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Loading Overlay
// ─────────────────────────────────────────────────────────────────────────────
class _LoadingOverlay extends StatelessWidget {
const _LoadingOverlay();
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4),
child: Container(
color: Colors.black.withOpacity(0.35),
child: Center(
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation(_kBlue),
strokeWidth: 3,
),
const SizedBox(height: 14),
Text('جاري حساب المسار...',
style: TextStyle(
color: _kSubtext,
fontSize: 14,
fontWeight: FontWeight.w500)),
],
),
),
),
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Shared primitives
// ─────────────────────────────────────────────────────────────────────────────
class _GlassCard extends StatelessWidget {
final Widget child;
final double borderRadius;
final EdgeInsets padding;
const _GlassCard({
required this.child,
this.borderRadius = 16,
this.padding = const EdgeInsets.all(16),
});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
decoration: BoxDecoration(
color: _kGlassSurface,
borderRadius: BorderRadius.circular(borderRadius),
border: Border.all(color: _kGlassBorder),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(Get.isDarkMode ? 0.4 : 0.07),
blurRadius: 16,
offset: const Offset(0, 8)),
],
),
padding: padding,
child: child,
),
),
);
}
}
class _IconBtn extends StatelessWidget {
final IconData icon;
final Color color;
final VoidCallback onTap;
final double size;
const _IconBtn({
required this.icon,
required this.color,
required this.onTap,
this.size = 22,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(icon, color: color, size: size),
),
);
}
}