feat: implement core architecture, design system, localization, and trip features for both driver and rider apps.

This commit is contained in:
Hamza-Ayed
2026-07-19 16:21:58 +03:00
parent 959e2fa758
commit c4dfeb4c6f
207 changed files with 14017 additions and 5567 deletions
@@ -0,0 +1,115 @@
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 '../../../core/design/tokens.dart';
import '../../../core/ui/tripz_button.dart';
import '../bloc/trip_bloc.dart';
import '../bloc/trip_state.dart';
import '../bloc/trip_event.dart';
/// شاشة تقييم الرحلة.
class RatingScreen extends StatefulWidget {
const RatingScreen({super.key});
@override
State<RatingScreen> createState() => _RatingScreenState();
}
class _RatingScreenState extends State<RatingScreen> {
int _stars = 0;
final _commentController = TextEditingController();
@override
void dispose() {
_commentController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final cs = Theme.of(context).colorScheme;
return BlocListener<TripBloc, TripState>(
listenWhen: (prev, cur) => prev.rated != cur.rated,
listener: (context, state) {
if (state.rated) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.thankYou)),
);
context.read<TripBloc>().add(const TripClear());
context.go('/home');
}
},
child: Scaffold(
appBar: AppBar(title: Text(l10n.rateYourTrip)),
body: Padding(
padding: const EdgeInsets.all(T.pagePadding),
child: Column(
children: [
const Spacer(flex: 2),
// نجوم التقييم
Text(
l10n.rateDriver,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: T.s24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(5, (i) {
final index = i + 1;
return GestureDetector(
onTap: () => setState(() => _stars = index),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Icon(
index <= _stars ? Icons.star_rounded : Icons.star_outline_rounded,
size: 48,
color: index <= _stars ? Colors.amber : cs.onSurfaceVariant,
),
),
);
}),
),
const SizedBox(height: T.s24),
// حقل التعليق
TextField(
controller: _commentController,
maxLines: 3,
decoration: InputDecoration(
hintText: l10n.ratingComment,
border: const OutlineInputBorder(),
),
),
const Spacer(flex: 3),
// زر الإرسال
SizedBox(
width: double.infinity,
child: TripzButton(
label: l10n.submitRating,
onPressed: _stars > 0
? () {
context.read<TripBloc>().add(TripRate(
stars: _stars,
comment: _commentController.text,
));
}
: null,
),
),
const SizedBox(height: T.s8),
],
),
),
),
);
}
}
@@ -0,0 +1,136 @@
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 '../../../core/design/tokens.dart';
import '../../../core/ui/tripz_button.dart';
import '../bloc/trip_bloc.dart';
import '../bloc/trip_state.dart';
import '../bloc/trip_event.dart';
/// شاشة فاتورة الرحلة بعد الانتهاء.
class TripInvoiceScreen extends StatelessWidget {
const TripInvoiceScreen({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final cs = Theme.of(context).colorScheme;
return BlocBuilder<TripBloc, TripState>(
builder: (context, state) {
final trip = state.trip;
if (trip == null) {
return Scaffold(
appBar: AppBar(title: Text(l10n.rideCompleted)),
body: const Center(child: CircularProgressIndicator()),
);
}
final fare = trip.fare ?? 0;
final currency = trip.currency ?? 'JOD';
return Scaffold(
appBar: AppBar(
title: Text(l10n.rideCompleted),
automaticallyImplyLeading: false,
),
body: Padding(
padding: const EdgeInsets.all(T.pagePadding),
child: Column(
children: [
// أيقونة النجاح
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(Icons.check_circle_rounded, size: 48, color: Colors.green),
),
const SizedBox(height: T.s16),
Text(
l10n.rideCompleted,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: T.s24),
// تفاصيل الأجرة
Card(
child: Padding(
padding: const EdgeInsets.all(T.pagePadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.fareBreakdown,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
)),
const SizedBox(height: T.s12),
_FareRow(label: l10n.tripFare, value: '$fare $currency'),
if (trip.paymentMethod == 'wallet')
_FareRow(label: l10n.walletPayment, value: l10n.youPaid),
if (trip.paymentMethod == 'cash')
_FareRow(label: l10n.cash, value: l10n.payCash),
const Divider(height: T.s16),
_FareRow(
label: l10n.total,
value: '$fare $currency',
bold: true,
),
],
),
),
),
const Spacer(),
// أزرار الإجراءات
TripzButton(
label: l10n.rateYourTrip,
onPressed: () => context.push('/trip-rating'),
),
const SizedBox(height: T.s8),
TextButton(
onPressed: () {
context.read<TripBloc>().add(const TripClear());
context.go('/home');
},
child: Text(l10n.skipRating),
),
],
),
),
);
},
);
}
}
class _FareRow extends StatelessWidget {
final String label;
final String value;
final bool bold;
const _FareRow({required this.label, required this.value, this.bold = false});
@override
Widget build(BuildContext context) {
final style = bold
? Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)
: Theme.of(context).textTheme.bodyMedium;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: style),
Text(value, style: style),
],
),
);
}
}
@@ -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());
}
}
}
@@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import '../../../../core/l10n/app_localizations.dart';
import '../../../../core/design/tokens.dart';
/// بطاقة السائق في شاشة التتبع.
class DriverCard extends StatelessWidget {
final String driverName;
final String vehicleMake;
final String vehicleModel;
final String vehiclePlate;
final String vehicleColor;
final String status;
final VoidCallback? onCall;
final VoidCallback? onChat;
final VoidCallback? onCancel;
const DriverCard({
super.key,
required this.driverName,
required this.vehicleMake,
required this.vehicleModel,
required this.vehiclePlate,
required this.vehicleColor,
required this.status,
this.onCall,
this.onChat,
this.onCancel,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final cs = Theme.of(context).colorScheme;
return Card(
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(T.pagePadding),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// صف الصورة + المعلومات
Row(
children: [
// صورة السائق ( Placeholder )
CircleAvatar(
radius: 28,
backgroundColor: cs.primaryContainer,
child: Icon(Icons.person, color: cs.onPrimaryContainer),
),
const SizedBox(width: T.s12),
// اسم السائق + التقييم
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
driverName.isNotEmpty ? driverName : l10n.loading,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Row(
children: [
Icon(Icons.star_rounded, size: 16, color: Colors.amber),
const SizedBox(width: 4),
Text('4.8', style: Theme.of(context).textTheme.bodySmall),
],
),
],
),
),
],
),
const SizedBox(height: T.s12),
const Divider(height: 1),
const SizedBox(height: T.s12),
// معلومات المركبة
Row(
children: [
Icon(Icons.directions_car_filled_rounded, size: 20, color: cs.onSurfaceVariant),
const SizedBox(width: T.s8),
Expanded(
child: Text(
'$vehicleColor $vehicleMake $vehicleModel',
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// رقم اللوحة — بارز بوضوح
Container(
padding: const EdgeInsets.symmetric(horizontal: T.s8, vertical: T.s4),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(T.r8),
),
child: Text(
vehiclePlate,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
],
),
const SizedBox(height: T.s12),
// أزرار الإجراءات
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: onCall,
icon: const Icon(Icons.call_rounded, size: 18),
label: Text(l10n.callDriver),
),
),
const SizedBox(width: T.s8),
Expanded(
child: OutlinedButton.icon(
onPressed: onChat,
icon: const Icon(Icons.chat_rounded, size: 18),
label: Text(l10n.chatWithDriver),
),
),
],
),
if (onCancel != null) ...[
const SizedBox(height: T.s8),
SizedBox(
width: double.infinity,
child: TextButton(
onPressed: onCancel,
child: Text(l10n.cancel, style: TextStyle(color: cs.error)),
),
),
],
],
),
),
);
}
}
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import '../../../../core/l10n/app_localizations.dart';
import '../../../../core/design/tokens.dart';
/// شريط الحالة العلوي في شاشة التتبع.
class TripStatusBanner extends StatelessWidget {
final String status;
const TripStatusBanner({super.key, required this.status});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final cs = Theme.of(context).colorScheme;
final (text, icon, color) = switch (status) {
'searching' => (l10n.searchingDriver, Icons.search_rounded, cs.primary),
'assigned' || 'driver_arriving' => (l10n.driverArriving, Icons.directions_car_rounded, cs.tertiary),
'driver_arrived' => (l10n.driverArrived, Icons.location_on_rounded, Colors.green),
'in_progress' => (l10n.rideInProgress, Icons.play_arrow_rounded, cs.primary),
'completed' => (l10n.rideCompleted, Icons.check_circle_rounded, Colors.green),
'cancelled' => (l10n.tripCancelled, Icons.cancel_rounded, cs.error),
'no_drivers' => (l10n.tripNoDrivers, Icons.error_outline_rounded, cs.error),
_ => (l10n.loading, Icons.hourglass_empty_rounded, cs.onSurfaceVariant),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: T.pagePadding, vertical: T.s12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(T.r12),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Row(
children: [
Icon(icon, color: color, size: 20),
const SizedBox(width: T.s8),
Expanded(
child: Text(
text,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}