822 lines
31 KiB
Dart
822 lines
31 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 ───────────────────────────────────────────────────────────
|
|
const Color _kBlue = Color(0xFF1A73E8);
|
|
const Color _kBlueDark = Color(0xFF0D47A1);
|
|
const Color _kSurface = Color(0xFFFFFFFF);
|
|
const Color _kText = Color(0xFF1C1C1E);
|
|
const Color _kSubtext = Color(0xFF6B7280);
|
|
const Color _kGreen = Color(0xFF34A853);
|
|
|
|
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: "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: const 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: 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: const Icon(Icons.place_rounded,
|
|
color: _kBlue, size: 18),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(place['name'] ?? '',
|
|
style: const 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: const 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: _kBlueDark,
|
|
borderRadius: BorderRadius.circular(20),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: _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: 52,
|
|
height: 52,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.15),
|
|
borderRadius: BorderRadius.circular(14),
|
|
),
|
|
child: const Icon(Icons.turn_right_rounded,
|
|
color: Colors.white, size: 30),
|
|
),
|
|
const SizedBox(width: 14),
|
|
|
|
// Instruction text
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
controller.distanceToNextStep,
|
|
style: const TextStyle(
|
|
color: Colors.white70,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
controller.currentInstruction,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 19,
|
|
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: const 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: 12, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.08),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: color.withOpacity(0.2)),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, color: color, size: 15),
|
|
const SizedBox(width: 5),
|
|
Text(label,
|
|
style: TextStyle(
|
|
color: color, fontSize: 13.5, fontWeight: FontWeight.w700)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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: const Color(0xFFF8F9FA),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.grey.withOpacity(0.15)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.arrow_forward_rounded,
|
|
size: 15, color: _kSubtext),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
controller.nextInstruction,
|
|
style: TextStyle(
|
|
color: _kSubtext,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500),
|
|
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: 16),
|
|
const SizedBox(width: 5),
|
|
const Text('إيقاف',
|
|
style: TextStyle(
|
|
color: Colors.redAccent,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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 + 130,
|
|
left: 14,
|
|
child: Container(
|
|
width: 62,
|
|
height: 62,
|
|
decoration: BoxDecoration(
|
|
color: fast ? const Color(0xFFD93025) : _kSurface,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: fast ? Colors.red.withOpacity(0.3) : Colors.grey[200]!,
|
|
width: 2),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.12),
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 4)),
|
|
],
|
|
),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
'$kmh',
|
|
style: TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
color: fast ? Colors.white : _kText,
|
|
height: 1),
|
|
),
|
|
Text(
|
|
'كم/س',
|
|
style: TextStyle(
|
|
fontSize: 9,
|
|
color: fast ? Colors.white70 : _kSubtext,
|
|
fontWeight: FontWeight.w500),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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: [
|
|
const 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: Colors.white.withOpacity(0.92),
|
|
borderRadius: BorderRadius.circular(borderRadius),
|
|
border: Border.all(color: Colors.white.withOpacity(0.5)),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.07),
|
|
blurRadius: 16,
|
|
offset: const Offset(0, 4)),
|
|
],
|
|
),
|
|
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),
|
|
),
|
|
);
|
|
}
|
|
}
|