529 lines
20 KiB
Dart
529 lines
20 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:intaleq_maps/intaleq_maps.dart';
|
|
import '../../../core/l10n/app_localizations.dart';
|
|
import '../../../core/design/tokens.dart';
|
|
import '../../auth/cubit/auth_cubit.dart';
|
|
import '../../auth/cubit/auth_state.dart';
|
|
import '../../trip/bloc/trip_bloc.dart';
|
|
import '../../trip/bloc/trip_state.dart';
|
|
import '../../trip/bloc/trip_event.dart';
|
|
import '../cubit/home_cubit.dart';
|
|
import '../cubit/saved_places_cubit.dart';
|
|
import '../data/models/place_model.dart';
|
|
import '../data/maps_repository.dart';
|
|
import '../../../core/storage/saved_places_store.dart';
|
|
import '../../../core/di.dart';
|
|
|
|
/// زر عائم دائري صغير — للقائمة وإعادة التوسيط فوق الخريطة.
|
|
class _MapCircleButton extends StatelessWidget {
|
|
const _MapCircleButton({required this.icon, required this.onPressed});
|
|
|
|
final IconData icon;
|
|
final VoidCallback onPressed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: cs.surface,
|
|
shape: BoxShape.circle,
|
|
boxShadow: const [T.shadowSm],
|
|
),
|
|
child: IconButton(
|
|
icon: Icon(icon, color: cs.onSurface),
|
|
onPressed: onPressed,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class HomeScreen extends StatefulWidget {
|
|
const HomeScreen({super.key});
|
|
|
|
@override
|
|
State<HomeScreen> createState() => _HomeScreenState();
|
|
}
|
|
|
|
class _HomeScreenState extends State<HomeScreen> {
|
|
IntaleqMapController? _mapController;
|
|
bool _isSelectingOnMap = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
context.read<TripBloc>().add(const TripCheckActive());
|
|
context.read<SavedPlacesCubit>().load();
|
|
});
|
|
}
|
|
|
|
String _timeBasedGreeting(String? name, AppLocalizations l10n) {
|
|
final hour = DateTime.now().hour;
|
|
final isMorning = hour >= 5 && hour < 12;
|
|
final isAr = Localizations.localeOf(context).languageCode == 'ar';
|
|
final prefix = isMorning
|
|
? (isAr ? 'صباح الخير' : 'Good morning')
|
|
: (isAr ? 'مساء الخير' : 'Good evening');
|
|
|
|
final firstName = (name != null && name.trim().isNotEmpty)
|
|
? name.trim().split(' ').first
|
|
: '';
|
|
return firstName.isNotEmpty ? '$prefix، $firstName' : prefix;
|
|
}
|
|
|
|
String _whereToPrompt(String? name, AppLocalizations l10n) {
|
|
final firstName = (name != null && name.trim().isNotEmpty)
|
|
? name.trim().split(' ').first
|
|
: '';
|
|
if (firstName.isNotEmpty) {
|
|
final isAr = Localizations.localeOf(context).languageCode == 'ar';
|
|
return isAr ? 'إلى أين يا $firstName؟' : 'Where to, $firstName?';
|
|
}
|
|
return l10n.whereTo;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final cs = Theme.of(context).colorScheme;
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
|
|
return BlocListener<TripBloc, TripState>(
|
|
listenWhen: (prev, cur) => prev.trip?.status != cur.trip?.status,
|
|
listener: (context, state) {
|
|
if (state.trip != null && !state.isCompleted) {
|
|
context.push('/trip-tracking');
|
|
}
|
|
},
|
|
child: Scaffold(
|
|
backgroundColor: cs.surface,
|
|
// الخريطة تملأ الشاشة حتى الحافة العلوية (docs/29 §3.6) — كل العناصر
|
|
// العائمة فوقها تعيش داخل SafeArea الخاصة بها لا الشاشة كلها.
|
|
extendBodyBehindAppBar: true,
|
|
body: Stack(
|
|
children: [
|
|
// ── الخريطة (75% من الشاشة) ──
|
|
Positioned(
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: MediaQuery.of(context).size.height * 0.75,
|
|
child: BlocConsumer<HomeCubit, HomeState>(
|
|
listenWhen: (prev, cur) =>
|
|
prev.currentLocation == null && cur.currentLocation != null,
|
|
buildWhen: (prev, cur) =>
|
|
prev.currentLocation != cur.currentLocation ||
|
|
prev.pickup != cur.pickup ||
|
|
prev.destination != cur.destination,
|
|
listener: (context, state) {
|
|
if (state.currentLocation != null && _mapController != null) {
|
|
_mapController!.animateCamera(
|
|
CameraUpdate.newLatLngZoom(state.currentLocation!, 16),
|
|
);
|
|
}
|
|
},
|
|
builder: (context, state) {
|
|
final center =
|
|
state.currentLocation ?? const LatLng(31.9539, 35.9106);
|
|
return IntaleqMap(
|
|
apiKey: '',
|
|
initialCameraPosition:
|
|
CameraPosition(target: center, zoom: 15),
|
|
mapType:
|
|
isDark ? IntaleqMapType.normal : IntaleqMapType.light,
|
|
myLocationEnabled: true,
|
|
myLocationButtonEnabled: false,
|
|
zoomControlsEnabled: false,
|
|
compassEnabled: false,
|
|
onMapCreated: (controller) {
|
|
_mapController = controller;
|
|
if (state.currentLocation != null) {
|
|
Future.delayed(const Duration(milliseconds: 300), () {
|
|
controller.animateCamera(
|
|
CameraUpdate.newLatLngZoom(
|
|
state.currentLocation!, 16),
|
|
);
|
|
});
|
|
}
|
|
},
|
|
markers: _buildMarkers(state),
|
|
onCameraMove: (position) {
|
|
context
|
|
.read<HomeCubit>()
|
|
.updateCameraTarget(position.target);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
|
|
// زر القائمة أعلى-بداية
|
|
PositionedDirectional(
|
|
top: MediaQuery.of(context).padding.top + T.s8,
|
|
start: T.pagePadding,
|
|
child: _MapCircleButton(
|
|
icon: Icons.menu_rounded,
|
|
onPressed: () => _showLogoutDialog(context, l10n),
|
|
),
|
|
),
|
|
|
|
// زر إعادة التوسيط عائم أعلى البطاقة السفلية
|
|
PositionedDirectional(
|
|
end: T.pagePadding,
|
|
bottom: (_isSelectingOnMap ? 100 : MediaQuery.of(context).size.height * 0.28) + T.s16,
|
|
child: BlocBuilder<HomeCubit, HomeState>(
|
|
builder: (context, state) {
|
|
return _MapCircleButton(
|
|
icon: Icons.my_location_rounded,
|
|
onPressed: state.currentLocation == null
|
|
? () {}
|
|
: () => _mapController?.animateCamera(
|
|
CameraUpdate.newLatLngZoom(
|
|
state.currentLocation!, 16),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
|
|
if (_isSelectingOnMap)
|
|
// علامة المركز (دبوس) لاختيار الموقع
|
|
Positioned(
|
|
top: (MediaQuery.of(context).size.height * 0.75) / 2 - 20,
|
|
left: MediaQuery.of(context).size.width / 2 - 20,
|
|
child: const Icon(Icons.location_on, size: 40, color: Colors.black),
|
|
),
|
|
|
|
// ── البطاقة السفلية: «إلى أين؟» والأماكن المحفوظة ──
|
|
if (!_isSelectingOnMap)
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
height: MediaQuery.of(context).size.height * 0.30,
|
|
child: Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.fromLTRB(
|
|
T.pagePadding, T.s24, T.pagePadding, T.s16),
|
|
decoration: BoxDecoration(
|
|
color: cs.surface,
|
|
borderRadius: const BorderRadius.vertical(
|
|
top: Radius.circular(T.r24),
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.1),
|
|
blurRadius: 20,
|
|
offset: const Offset(0, -5),
|
|
)
|
|
],
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// التحية الزمنية مع اسم الراكب بتصميم أنيق
|
|
BlocBuilder<AuthCubit, AuthState>(
|
|
builder: (context, authState) {
|
|
final greeting = _timeBasedGreeting(authState.user?.name, l10n);
|
|
return Text(
|
|
greeting,
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.titleLarge
|
|
?.copyWith(
|
|
fontWeight: FontWeight.w800,
|
|
color: cs.onSurface,
|
|
letterSpacing: -0.5,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
|
|
const SizedBox(height: T.s24),
|
|
|
|
// حقل «إلى أين؟»
|
|
BlocBuilder<AuthCubit, AuthState>(
|
|
builder: (context, authState) {
|
|
final searchPrompt = _whereToPrompt(authState.user?.name, l10n);
|
|
return Material(
|
|
color: cs.surfaceContainerLowest,
|
|
elevation: 2,
|
|
shadowColor: cs.shadow.withValues(alpha: 0.05),
|
|
borderRadius: BorderRadius.circular(T.r16),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(T.r16),
|
|
onTap: () => _openDestinationSearch(context),
|
|
child: Container(
|
|
height: 60,
|
|
padding: const EdgeInsets.symmetric(horizontal: T.s16),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(T.r16),
|
|
border: Border.all(
|
|
color: cs.outlineVariant.withValues(alpha: 0.5),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: cs.primaryContainer.withValues(alpha: 0.5),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
Icons.search_rounded,
|
|
color: cs.primary,
|
|
size: 22,
|
|
),
|
|
),
|
|
const SizedBox(width: T.s16),
|
|
Expanded(
|
|
child: Text(
|
|
searchPrompt,
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.titleMedium
|
|
?.copyWith(
|
|
color: cs.onSurface,
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 18,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
|
|
const Spacer(),
|
|
Divider(color: cs.outlineVariant.withValues(alpha: 0.3), height: 1),
|
|
const SizedBox(height: T.s12),
|
|
|
|
// الأماكن المحفوظة (سطر أفقي أنيق)
|
|
BlocBuilder<SavedPlacesCubit, SavedPlacesState>(
|
|
builder: (context, savedState) {
|
|
final items = savedState.places.take(3).toList();
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
_buildQuickAction(context, Icons.home_rounded, l10n.homePlace, items.isNotEmpty ? items.first : null),
|
|
Container(width: 1, height: 32, color: cs.outlineVariant.withValues(alpha: 0.3)),
|
|
_buildQuickAction(context, Icons.work_rounded, l10n.workPlace, items.length > 1 ? items[1] : null),
|
|
Container(width: 1, height: 32, color: cs.outlineVariant.withValues(alpha: 0.3)),
|
|
_buildQuickAction(context, Icons.star_rounded, l10n.favorites, items.length > 2 ? items[2] : null),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: T.s8),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
else
|
|
// زر تأكيد الوجهة في وضع اختيار الخريطة
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
child: Container(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
T.pagePadding, T.s16, T.pagePadding, T.s32),
|
|
decoration: BoxDecoration(
|
|
color: cs.surface,
|
|
borderRadius: const BorderRadius.vertical(
|
|
top: Radius.circular(T.r24),
|
|
),
|
|
boxShadow: const [T.shadowLg],
|
|
),
|
|
child: FilledButton(
|
|
onPressed: _confirmMapDestination,
|
|
style: FilledButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(T.r12),
|
|
),
|
|
),
|
|
child: Text('تأكيد الوجهة', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: cs.onPrimary)),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _onPlaceTap(BuildContext context, SavedPlace place) {
|
|
context.read<HomeCubit>().setDestination(PlaceModel(
|
|
name: place.name,
|
|
address: place.address,
|
|
lat: place.lat,
|
|
lng: place.lng,
|
|
));
|
|
final state = context.read<HomeCubit>().state;
|
|
if (state.pickup != null) {
|
|
_openRoutePreview(context, state);
|
|
}
|
|
}
|
|
|
|
Set<Marker> _buildMarkers(HomeState state) {
|
|
final markers = <Marker>{};
|
|
if (state.pickup != null) {
|
|
markers.add(Marker(
|
|
markerId: const MarkerId('pickup'),
|
|
position: LatLng(state.pickup!.lat, state.pickup!.lng),
|
|
icon: InlqBitmap.defaultMarkerWithHue(210),
|
|
anchor: const Offset(0.5, 1.0),
|
|
zIndex: 2,
|
|
));
|
|
}
|
|
if (state.destination != null) {
|
|
markers.add(Marker(
|
|
markerId: const MarkerId('destination'),
|
|
position: LatLng(state.destination!.lat, state.destination!.lng),
|
|
icon: InlqBitmap.defaultMarkerWithHue(0),
|
|
anchor: const Offset(0.5, 1.0),
|
|
zIndex: 1,
|
|
));
|
|
}
|
|
return markers;
|
|
}
|
|
|
|
void _openDestinationSearch(BuildContext context) {
|
|
final homeCubit = context.read<HomeCubit>();
|
|
final pickup = homeCubit.state.pickup;
|
|
final currentLocation = homeCubit.state.currentLocation;
|
|
|
|
context.push<dynamic>('/destination', extra: {
|
|
'currentLocation': currentLocation,
|
|
}).then((result) {
|
|
if (!mounted) return;
|
|
if (result is Map && result['pickFromMap'] == true) {
|
|
setState(() {
|
|
_isSelectingOnMap = true;
|
|
});
|
|
return;
|
|
}
|
|
|
|
final place = result as PlaceModel?;
|
|
if (place != null && pickup != null) {
|
|
homeCubit.setDestination(place);
|
|
// ignore: use_build_context_synchronously
|
|
context.push('/route-preview', extra: {
|
|
'pickup': pickup,
|
|
'destination': place,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _confirmMapDestination() async {
|
|
final homeCubit = context.read<HomeCubit>();
|
|
final target = homeCubit.state.cameraTarget;
|
|
if (target == null) return;
|
|
|
|
// We reverse geocode the target to get the address name
|
|
final repo = getIt<MapsRepository>();
|
|
try {
|
|
final place = await repo.reverseGeocode(target.latitude, target.longitude);
|
|
setState(() {
|
|
_isSelectingOnMap = false;
|
|
});
|
|
homeCubit.setDestination(place);
|
|
if (homeCubit.state.pickup != null) {
|
|
_openRoutePreview(context, homeCubit.state);
|
|
}
|
|
} catch (_) {
|
|
// In case of error, still set it with empty name
|
|
setState(() {
|
|
_isSelectingOnMap = false;
|
|
});
|
|
homeCubit.setDestination(PlaceModel(
|
|
name: 'موقع محدد على الخريطة',
|
|
lat: target.latitude,
|
|
lng: target.longitude,
|
|
));
|
|
if (homeCubit.state.pickup != null) {
|
|
_openRoutePreview(context, homeCubit.state);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _openRoutePreview(BuildContext context, HomeState state) {
|
|
if (state.pickup == null || state.destination == null) return;
|
|
context.push('/route-preview', extra: {
|
|
'pickup': state.pickup,
|
|
'destination': state.destination,
|
|
});
|
|
}
|
|
|
|
void _showLogoutDialog(BuildContext context, AppLocalizations l10n) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(l10n.logout),
|
|
content: Text(l10n.logout),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx),
|
|
child: Text(l10n.cancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () async {
|
|
Navigator.pop(ctx);
|
|
await context.read<AuthCubit>().logout();
|
|
if (context.mounted) context.go('/phone');
|
|
},
|
|
style: FilledButton.styleFrom(backgroundColor: cs.error),
|
|
child: Text(l10n.logout),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildQuickAction(
|
|
BuildContext context, IconData icon, String label, SavedPlace? place) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
return InkWell(
|
|
onTap: place != null ? () => _onPlaceTap(context, place) : null,
|
|
borderRadius: BorderRadius.circular(T.r12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: T.s12, vertical: T.s8),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon,
|
|
color: place != null
|
|
? cs.primary
|
|
: cs.onSurfaceVariant.withValues(alpha: 0.5),
|
|
size: 28),
|
|
const SizedBox(height: T.s4),
|
|
Text(
|
|
label,
|
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
|
color: place != null
|
|
? cs.onSurface
|
|
: cs.onSurfaceVariant.withValues(alpha: 0.5),
|
|
fontWeight:
|
|
place != null ? FontWeight.w600 : FontWeight.normal,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|