feat: implement core architecture, design system, localization, and trip features for both driver and rider apps.
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/l10n/app_localizations.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intaleq_maps/intaleq_maps.dart';
|
||||
import '../../../core/design/tokens.dart';
|
||||
import '../bloc/trip_bloc.dart';
|
||||
import '../bloc/trip_state.dart';
|
||||
import '../bloc/trip_event.dart';
|
||||
import 'widgets/driver_card.dart';
|
||||
import 'widgets/trip_status_banner.dart';
|
||||
|
||||
/// شاشة تتبع الرحلة — من searching إلى completed.
|
||||
class TripTrackingScreen extends StatefulWidget {
|
||||
const TripTrackingScreen({super.key});
|
||||
|
||||
@override
|
||||
State<TripTrackingScreen> createState() => _TripTrackingScreenState();
|
||||
}
|
||||
|
||||
class _TripTrackingScreenState extends State<TripTrackingScreen> {
|
||||
IntaleqMapController? _mapController;
|
||||
Timer? _cameraFollowTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// مراقبة حالة التطبيق للتحقق من الرحلة النشطة عند العودة.
|
||||
WidgetsBinding.instance.addObserver(_AppLifecycleObserver(context));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cameraFollowTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return BlocConsumer<TripBloc, TripState>(
|
||||
listenWhen: (prev, cur) => prev.trip?.status != cur.trip?.status,
|
||||
listener: (context, state) {
|
||||
if (state.isCompleted) {
|
||||
// الرحلة اكتملت — انتقل لشاشة الفاتورة.
|
||||
context.pushReplacement('/trip-invoice');
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final trip = state.trip;
|
||||
if (trip == null) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
final pickup = LatLng(trip.pickupLat, trip.pickupLng);
|
||||
final dropoff = LatLng(trip.dropoffLat, trip.dropoffLng);
|
||||
final driverPos = state.driverLat != null && state.driverLng != null
|
||||
? LatLng(state.driverLat!, state.driverLng!)
|
||||
: null;
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
// الخريطة
|
||||
IntaleqMap(
|
||||
apiKey: '',
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: driverPos ?? pickup,
|
||||
zoom: 15,
|
||||
),
|
||||
myLocationEnabled: false,
|
||||
zoomControlsEnabled: false,
|
||||
compassEnabled: false,
|
||||
onMapCreated: (c) => _mapController = c,
|
||||
markers: _buildMarkers(pickup, dropoff, driverPos),
|
||||
),
|
||||
|
||||
// شريط الحالة العلوي
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + T.s8,
|
||||
left: T.pagePadding,
|
||||
right: T.pagePadding,
|
||||
child: TripStatusBanner(status: trip.status),
|
||||
),
|
||||
|
||||
// بطاقة السائق (إذا تم تعيينه)
|
||||
if (state.requestStatus == TripRequestStatus.active)
|
||||
Positioned(
|
||||
left: T.pagePadding,
|
||||
right: T.pagePadding,
|
||||
bottom: T.pagePadding,
|
||||
child: DriverCard(
|
||||
driverName: state.driverName ?? '',
|
||||
vehicleMake: state.vehicleMake ?? '',
|
||||
vehicleModel: state.vehicleModel ?? '',
|
||||
vehiclePlate: state.vehiclePlate ?? '',
|
||||
vehicleColor: state.vehicleColor ?? '',
|
||||
status: trip.status,
|
||||
onCall: () {
|
||||
// TODO: WebRTC calling — Phase 3
|
||||
},
|
||||
onChat: () {
|
||||
// TODO: Chat — Phase 3
|
||||
},
|
||||
onCancel: state.canCancel
|
||||
? () => context.read<TripBloc>().add(const TripCancelRequested())
|
||||
: null,
|
||||
),
|
||||
),
|
||||
|
||||
// شاشة البحث (قبل تعيين السائق)
|
||||
if (state.requestStatus == TripRequestStatus.searching)
|
||||
_SearchingBody(elapsed: 0),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Set<Marker> _buildMarkers(LatLng pickup, LatLng dropoff, LatLng? driverPos) {
|
||||
final markers = <Marker>{
|
||||
Marker(
|
||||
markerId: const MarkerId('pickup'),
|
||||
position: pickup,
|
||||
icon: InlqBitmap.defaultMarkerWithHue(210),
|
||||
anchor: const Offset(0.5, 1.0),
|
||||
),
|
||||
Marker(
|
||||
markerId: const MarkerId('dropoff'),
|
||||
position: dropoff,
|
||||
icon: InlqBitmap.defaultMarkerWithHue(0),
|
||||
anchor: const Offset(0.5, 1.0),
|
||||
),
|
||||
};
|
||||
if (driverPos != null) {
|
||||
markers.add(Marker(
|
||||
markerId: const MarkerId('driver'),
|
||||
position: driverPos,
|
||||
icon: InlqBitmap.defaultMarkerWithHue(140),
|
||||
anchor: const Offset(0.5, 0.5),
|
||||
zIndex: 3,
|
||||
));
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
}
|
||||
|
||||
/// شاشة البحث عن سائق مع مؤقت.
|
||||
class _SearchingBody extends StatefulWidget {
|
||||
final int elapsed;
|
||||
const _SearchingBody({required this.elapsed});
|
||||
|
||||
@override
|
||||
State<_SearchingBody> createState() => _SearchingBodyState();
|
||||
}
|
||||
|
||||
class _SearchingBodyState extends State<_SearchingBody>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _pulse;
|
||||
late Animation<double> _scale;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pulse = AnimationController(vsync: this, duration: const Duration(seconds: 2))
|
||||
..repeat(reverse: true);
|
||||
_scale = Tween<double>(begin: 0.8, end: 1.2).animate(
|
||||
CurvedAnimation(parent: _pulse, curve: Curves.easeInOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pulse.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return Material(
|
||||
color: Colors.black54,
|
||||
child: Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(T.pagePadding * 2),
|
||||
padding: const EdgeInsets.all(T.pagePadding * 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
borderRadius: BorderRadius.circular(T.r16),
|
||||
boxShadow: [T.shadowLg],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ScaleTransition(
|
||||
scale: _scale,
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.directions_car_filled_rounded,
|
||||
size: 40, color: cs.onPrimaryContainer),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: T.s16),
|
||||
Text(l10n.searchingDriver,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
textAlign: TextAlign.center),
|
||||
const SizedBox(height: T.s20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => context.read<TripBloc>().add(const TripCancelRequested()),
|
||||
style: OutlinedButton.styleFrom(foregroundColor: cs.error, side: BorderSide(color: cs.error)),
|
||||
child: Text(l10n.cancelSearch),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// مراقب دورة حياة التطبيق — يتحقق من الرحلة عند العودة.
|
||||
class _AppLifecycleObserver extends WidgetsBindingObserver {
|
||||
final BuildContext context;
|
||||
_AppLifecycleObserver(this.context);
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
context.read<TripBloc>().add(const TripCheckActive());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user