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
@@ -1,8 +1,12 @@
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 '../cubit/auth_cubit.dart';
import '../cubit/auth_state.dart';
import '../../../core/ui/tripz_scaffold.dart';
import '../../../core/ui/tripz_button.dart';
import '../../../core/ui/tripz_text_field.dart';
class OtpScreen extends StatefulWidget {
const OtpScreen({super.key});
@@ -13,60 +17,61 @@ class OtpScreen extends StatefulWidget {
class _OtpScreenState extends State<OtpScreen> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_forward),
onPressed: () => context.go('/phone'),
),
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: BlocConsumer<AuthCubit, AuthState>(
listener: (context, state) {
if (state.status == AuthStatus.error && state.error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.error!)),
);
}
},
builder: (context, state) {
final loading = state.status == AuthStatus.authenticating;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 24),
Text('أدخل رمز التحقق',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
Text('أُرسل إلى ${state.phone}'),
const SizedBox(height: 28),
TextField(
controller: _controller,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
decoration: const InputDecoration(labelText: 'الرمز'),
),
const SizedBox(height: 20),
FilledButton(
onPressed: loading
? null
: () => context.read<AuthCubit>().verify(_controller.text),
child: loading
? const SizedBox(
height: 22, width: 22,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('تأكيد'),
),
],
);
},
),
),
final l10n = AppLocalizations.of(context)!;
return TripzScaffold(
showBackButton: true,
onBack: () => context.go('/phone'),
child: BlocConsumer<AuthCubit, AuthState>(
listener: (context, state) {
if (state.status == AuthStatus.error && state.error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.error!)),
);
}
},
builder: (context, state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 24),
Text(
l10n.otpTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
l10n.otpSentTo(state.phone),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 28),
TripzTextField(
controller: _controller,
label: l10n.otpLabel,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
maxLength: 6,
),
const SizedBox(height: 20),
TripzButton(
label: l10n.confirm,
loading: state.status == AuthStatus.authenticating,
onPressed: () =>
context.read<AuthCubit>().verify(_controller.text),
),
],
);
},
),
);
}
@@ -1,7 +1,12 @@
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 '../cubit/auth_cubit.dart';
import '../cubit/auth_state.dart';
import '../../../core/ui/tripz_scaffold.dart';
import '../../../core/ui/tripz_button.dart';
import '../../../core/ui/tripz_text_field.dart';
class PhoneScreen extends StatefulWidget {
const PhoneScreen({super.key});
@@ -12,55 +17,61 @@ class PhoneScreen extends StatefulWidget {
class _PhoneScreenState extends State<PhoneScreen> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 40),
Text('Tripz', style: Theme.of(context).textTheme.headlineMedium),
const SizedBox(height: 8),
const Text('أدخل رقم هاتفك للمتابعة'),
const SizedBox(height: 32),
TextField(
controller: _controller,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'رقم الهاتف',
hintText: '07XXXXXXXX',
),
),
const SizedBox(height: 20),
BlocConsumer<AuthCubit, AuthState>(
listener: (context, state) {
if (state.status == AuthStatus.error && state.error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.error!)),
);
}
},
builder: (context, state) {
final loading = state.status == AuthStatus.authenticating;
return FilledButton(
onPressed: loading
? null
: () => context.read<AuthCubit>().sendOtp(_controller.text),
child: loading
? const SizedBox(
height: 22, width: 22,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('إرسال الرمز'),
);
},
),
],
final l10n = AppLocalizations.of(context)!;
return TripzScaffold(
safeAreaTop: true,
padding: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Spacer(flex: 2),
Text(
l10n.appName,
style: Theme.of(context).textTheme.headlineLarge,
),
),
const SizedBox(height: 8),
Text(
l10n.phoneTitle,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const Spacer(),
TripzTextField(
controller: _controller,
label: l10n.phoneLabel,
hint: l10n.phoneHint,
keyboardType: TextInputType.phone,
),
const SizedBox(height: 20),
BlocConsumer<AuthCubit, AuthState>(
listener: (context, state) {
if (state.status == AuthStatus.error && state.error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.error!)),
);
}
},
builder: (context, state) {
return TripzButton(
label: l10n.sendCode,
loading: state.status == AuthStatus.authenticating,
onPressed: () =>
context.read<AuthCubit>().sendOtp(_controller.text),
);
},
),
const Spacer(flex: 2),
],
),
);
}
@@ -0,0 +1,89 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../data/maps_repository.dart';
import '../data/models/place_model.dart';
/// حالة شاشة البحث عن الوجهة.
class DestinationState extends Equatable {
final String query;
final List<PlaceModel> results;
final bool searching;
final String? error;
const DestinationState({
this.query = '',
this.results = const [],
this.searching = false,
this.error,
});
DestinationState copyWith({
String? query,
List<PlaceModel>? results,
bool? searching,
String? error,
bool clearError = false,
}) {
return DestinationState(
query: query ?? this.query,
results: results ?? this.results,
searching: searching ?? this.searching,
error: clearError ? null : (error ?? this.error),
);
}
@override
List<Object?> get props => [query, results, searching, error];
}
/// البحث عن وجهة مع debounce.
class DestinationCubit extends Cubit<DestinationState> {
final MapsRepository _mapsRepo;
Timer? _debounce;
DestinationCubit(this._mapsRepo) : super(const DestinationState());
void onQueryChanged(String query) {
_debounce?.cancel();
emit(state.copyWith(query: query, clearError: true));
if (query.trim().length < 2) {
emit(state.copyWith(results: [], searching: false));
return;
}
emit(state.copyWith(searching: true));
_debounce = Timer(const Duration(milliseconds: 500), () {
_search(query);
});
}
Future<void> _search(String query) async {
try {
final results = await _mapsRepo.autocomplete(query);
emit(state.copyWith(results: results, searching: false));
} catch (e) {
emit(state.copyWith(searching: false, error: e.toString()));
}
}
Future<void> searchDirectly(String query) async {
_debounce?.cancel();
if (query.trim().length < 2) return;
emit(state.copyWith(searching: true, clearError: true));
try {
final results = await _mapsRepo.autocomplete(query);
emit(state.copyWith(results: results, searching: false));
} catch (e) {
emit(state.copyWith(searching: false, error: e.toString()));
}
}
@override
Future<void> close() {
_debounce?.cancel();
return super.close();
}
}
@@ -0,0 +1,128 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:geolocator/geolocator.dart';
import 'package:intaleq_maps/intaleq_maps.dart';
import '../data/maps_repository.dart';
import '../data/models/place_model.dart';
/// حالة شاشة الخريطة الرئيسية.
class HomeState extends Equatable {
final bool loading;
final LatLng? currentLocation;
final LatLng? cameraTarget;
final PlaceModel? pickup;
final PlaceModel? destination;
final String? error;
const HomeState({
this.loading = true,
this.currentLocation,
this.cameraTarget,
this.pickup,
this.destination,
this.error,
});
HomeState copyWith({
bool? loading,
LatLng? currentLocation,
LatLng? cameraTarget,
PlaceModel? pickup,
PlaceModel? destination,
String? error,
bool clearPickup = false,
bool clearDestination = false,
bool clearError = false,
}) {
return HomeState(
loading: loading ?? this.loading,
currentLocation: currentLocation ?? this.currentLocation,
cameraTarget: cameraTarget ?? this.cameraTarget,
pickup: clearPickup ? null : (pickup ?? this.pickup),
destination: clearDestination ? null : (destination ?? this.destination),
error: clearError ? null : (error ?? this.error),
);
}
@override
List<Object?> get props => [loading, currentLocation, cameraTarget, pickup, destination, error];
}
/// إدارة موقع المستخدم الحالي + الخريطة.
class HomeCubit extends Cubit<HomeState> {
final MapsRepository _mapsRepo;
HomeCubit(this._mapsRepo) : super(const HomeState()) {
initLocation();
}
Future<void> initLocation() async {
try {
emit(state.copyWith(loading: true, clearError: true));
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
emit(state.copyWith(
loading: false,
error: 'Location permission denied',
));
return;
}
}
final pos = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
timeLimit: Duration(seconds: 10),
),
);
final loc = LatLng(pos.latitude, pos.longitude);
// جلب العنوان العكسي للموقع الحالي.
final place = await _mapsRepo.reverseGeocode(pos.latitude, pos.longitude);
emit(state.copyWith(
loading: false,
currentLocation: loc,
cameraTarget: loc,
pickup: place,
));
} catch (e) {
emit(state.copyWith(
loading: false,
error: e.toString(),
));
}
}
void updateCameraTarget(LatLng target) {
emit(state.copyWith(cameraTarget: target));
}
void setPickup(PlaceModel place) {
emit(state.copyWith(pickup: place));
}
void setPickupFromLatLng(LatLng loc, {String? address}) {
emit(state.copyWith(
pickup: PlaceModel(
name: address ?? '',
address: address,
lat: loc.latitude,
lng: loc.longitude,
),
));
}
void setDestination(PlaceModel place) {
emit(state.copyWith(destination: place));
}
void clearTripData() {
emit(state.copyWith(clearPickup: true, clearDestination: true, clearError: true));
}
}
@@ -0,0 +1,108 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../data/tariff_repository.dart';
import '../data/models/quote_model.dart';
/// حالة شاشة اختيار فئة الرحلة + السعر.
class QuoteState extends Equatable {
final bool loading;
final QuoteModel? quote;
final String? selectedClass;
final String paymentMethod;
final String? couponCode;
final String? error;
const QuoteState({
this.loading = false,
this.quote,
this.selectedClass,
this.paymentMethod = 'cash',
this.couponCode,
this.error,
});
QuoteState copyWith({
bool? loading,
QuoteModel? quote,
String? selectedClass,
String? paymentMethod,
String? couponCode,
String? error,
bool clearError = false,
bool clearCoupon = false,
}) {
return QuoteState(
loading: loading ?? this.loading,
quote: quote ?? this.quote,
selectedClass: selectedClass ?? this.selectedClass,
paymentMethod: paymentMethod ?? this.paymentMethod,
couponCode: clearCoupon ? null : (couponCode ?? this.couponCode),
error: clearError ? null : (error ?? this.error),
);
}
QuoteClassModel? get selectedQuoteClass {
if (quote == null || selectedClass == null) return null;
try {
return quote!.classes.firstWhere((c) => c.vehicleClass == selectedClass);
} catch (_) {
return null;
}
}
@override
List<Object?> get props => [loading, quote, selectedClass, paymentMethod, couponCode, error];
}
/// جلب الأسعار واختيار الفئة.
class QuoteCubit extends Cubit<QuoteState> {
final TariffRepository _tariffRepo;
QuoteCubit(this._tariffRepo) : super(const QuoteState());
Future<void> fetchQuote({
required double pickupLat,
required double pickupLng,
String? pickupAddress,
required double dropoffLat,
required double dropoffLng,
String? dropoffAddress,
List<Map<String, dynamic>>? stops,
String? vehicleClass,
String? scheduledAt,
}) async {
emit(state.copyWith(loading: true, clearError: true));
try {
final quote = await _tariffRepo.getQuote(
pickupLat: pickupLat,
pickupLng: pickupLng,
pickupAddress: pickupAddress,
dropoffLat: dropoffLat,
dropoffLng: dropoffLng,
dropoffAddress: dropoffAddress,
vehicleClass: vehicleClass,
scheduledAt: scheduledAt,
);
final firstClass = quote.classes.isNotEmpty ? quote.classes.first.vehicleClass : null;
emit(state.copyWith(
loading: false,
quote: quote,
selectedClass: firstClass,
));
} catch (e) {
emit(state.copyWith(loading: false, error: e.toString()));
}
}
void selectClass(String vehicleClass) {
emit(state.copyWith(selectedClass: vehicleClass));
}
void setPaymentMethod(String method) {
emit(state.copyWith(paymentMethod: method));
}
void setCouponCode(String? code) {
emit(state.copyWith(couponCode: code));
}
}
@@ -0,0 +1,83 @@
import '../../../core/api/api_client.dart';
import 'models/place_model.dart';
import 'models/route_model.dart';
/// نقطة وصول وحيدة لبيانات الخرائط — كل نداء يمرّ عبر سيرفرنا
/// (docs/23 §4) لحماية المفتاح وتفعيل التخزين المؤقت على Redis.
class MapsRepository {
final ApiClient _api;
MapsRepository(this._api);
/// بحث مكاني — يمرّ عبر `GET /maps/geocode`.
Future<List<PlaceModel>> autocomplete(String query) async {
if (query.trim().length < 2) return [];
final r = await _api.dio.get(
'/maps/geocode',
queryParameters: {'q': query.trim(), 'country': 'JO'},
);
final items = (r.data['items'] ?? r.data ?? []) as List;
return items
.map((e) => PlaceModel.fromJson(e as Map<String, dynamic>))
.toList();
}
/// بحث حرّ (geocode).
Future<List<PlaceModel>> geocode(String query) async {
if (query.trim().length < 2) return [];
final r = await _api.dio.get(
'/maps/geocode',
queryParameters: {'q': query.trim(), 'country': 'JO'},
);
final items = (r.data['items'] ?? r.data ?? []) as List;
return items
.map((e) => PlaceModel.fromJson(e as Map<String, dynamic>))
.toList();
}
/// تفكيك عنوان عكسي (إحداثيات → نص).
Future<PlaceModel> reverseGeocode(double lat, double lng) async {
final r = await _api.dio.get(
'/maps/reverse',
queryParameters: {'lat': lat, 'lng': lng},
);
final j = r.data as Map<String, dynamic>;
return PlaceModel.fromJson({
'name': j['formattedAddress'] ?? '',
'address': j['formattedAddress'],
'lat': lat,
'lng': lng,
});
}
/// تفاصيل مكان بالـ placeId — يمرّ عبر geocode.
Future<PlaceModel> placeDetails(String placeId) async {
final r = await _api.dio.get(
'/maps/geocode',
queryParameters: {'q': placeId, 'country': 'JO'},
);
final items = (r.data['items'] ?? r.data ?? []) as List;
if (items.isEmpty) {
throw Exception('Place not found');
}
return PlaceModel.fromJson(items.first as Map<String, dynamic>);
}
/// حساب المسار بين نقطتين.
Future<RouteModel> getRoute({
required double fromLat,
required double fromLng,
required double toLat,
required double toLng,
}) async {
final r = await _api.dio.get(
'/maps/route',
queryParameters: {
'fromLat': fromLat,
'fromLng': fromLng,
'toLat': toLat,
'toLng': toLng,
},
);
return RouteModel.fromJson(r.data as Map<String, dynamic>);
}
}
@@ -0,0 +1,45 @@
import 'package:equatable/equatable.dart';
/// نموذج مكان من نتائج البحث (autocomplete / geocode).
class PlaceModel extends Equatable {
final String? placeId;
final String name;
final String? address;
final double lat;
final double lng;
const PlaceModel({
this.placeId,
required this.name,
this.address,
required this.lat,
required this.lng,
});
factory PlaceModel.fromJson(Map<String, dynamic> j) => PlaceModel(
placeId: j['placeId'] as String?,
name: (j['description'] ?? j['name'] ?? '') as String,
address: j['address'] as String?,
lat: (j['lat'] as num).toDouble(),
lng: (j['lng'] as num).toDouble(),
);
factory PlaceModel.fromGeocode(Map<String, dynamic> j) => PlaceModel(
placeId: j['placeId'] as String?,
name: (j['name'] ?? j['formattedAddress'] ?? '') as String,
address: j['formattedAddress'] as String?,
lat: (j['lat'] as num).toDouble(),
lng: (j['lng'] as num).toDouble(),
);
Map<String, dynamic> toJson() => {
if (placeId != null) 'placeId': placeId,
'name': name,
if (address != null) 'address': address,
'lat': lat,
'lng': lng,
};
@override
List<Object?> get props => [placeId, name, address, lat, lng];
}
@@ -0,0 +1,85 @@
import 'package:equatable/equatable.dart';
/// فئة رحلة واحدة من الاستجابة (tariff/quote).
class QuoteClassModel extends Equatable {
final String vehicleClass;
final String name;
final int price;
final String currency;
final int capacity;
final String? description;
final String? imageUrl;
final int estimatedTime; // ثوانٍ تقريبية
const QuoteClassModel({
required this.vehicleClass,
required this.name,
required this.price,
required this.currency,
required this.capacity,
this.description,
this.imageUrl,
required this.estimatedTime,
});
factory QuoteClassModel.fromJson(Map<String, dynamic> j) => QuoteClassModel(
vehicleClass: j['vehicleClass'] as String,
name: (j['name'] ?? j['vehicleClass']) as String,
price: j['price'] as int,
currency: (j['currency'] ?? 'JOD') as String,
capacity: j['capacity'] as int? ?? 4,
description: j['description'] as String?,
imageUrl: j['imageUrl'] as String?,
estimatedTime: j['estimatedTime'] as int? ?? 0,
);
String get estimatedTimeText {
final min = estimatedTime ~/ 60;
return '$min min';
}
@override
List<Object?> get props => [vehicleClass, name, price, currency, capacity];
}
/// استجابة tariff/quote الكاملة.
class QuoteModel extends Equatable {
final String? pickupAddress;
final String? dropoffAddress;
final int distanceMeters;
final int durationSeconds;
final String? distanceText;
final String? durationText;
final String currency;
final List<QuoteClassModel> classes;
const QuoteModel({
this.pickupAddress,
this.dropoffAddress,
required this.distanceMeters,
required this.durationSeconds,
this.distanceText,
this.durationText,
required this.currency,
required this.classes,
});
factory QuoteModel.fromJson(Map<String, dynamic> j) {
final data = j['data'] ?? j;
return QuoteModel(
pickupAddress: data['pickupAddress'] as String?,
dropoffAddress: data['dropoffAddress'] as String?,
distanceMeters: (data['distanceMeters'] as num?)?.toInt() ?? 0,
durationSeconds: (data['durationSeconds'] as num?)?.toInt() ?? 0,
distanceText: data['distanceText'] as String?,
durationText: data['durationText'] as String?,
currency: (data['currency'] ?? 'JOD') as String,
classes: (data['classes'] as List? ?? [])
.map((c) => QuoteClassModel.fromJson(c as Map<String, dynamic>))
.toList(),
);
}
@override
List<Object?> get props => [distanceMeters, durationSeconds, currency, classes];
}
@@ -0,0 +1,35 @@
import 'package:equatable/equatable.dart';
/// فئة رحلة من ride-types endpoint (تُستخدم كقائمة المرجع).
class RideTypeModel extends Equatable {
final String id;
final String name;
final String? vehicleClass;
final int capacity;
final String? description;
final String? imageUrl;
final bool active;
const RideTypeModel({
required this.id,
required this.name,
this.vehicleClass,
required this.capacity,
this.description,
this.imageUrl,
required this.active,
});
factory RideTypeModel.fromJson(Map<String, dynamic> j) => RideTypeModel(
id: j['id'] as String,
name: (j['name'] ?? '') as String,
vehicleClass: j['vehicleClass'] as String?,
capacity: j['capacity'] as int? ?? 4,
description: j['description'] as String?,
imageUrl: j['imageUrl'] as String?,
active: j['active'] as bool? ?? true,
);
@override
List<Object?> get props => [id, name, vehicleClass, capacity, active];
}
@@ -0,0 +1,74 @@
import 'package:equatable/equatable.dart';
import 'package:intaleq_maps/intaleq_maps.dart';
/// خطاب polyline مشفر ومسافة/وقت تقريبيان.
class RouteModel extends Equatable {
final int distanceMeters;
final int durationSeconds;
final String? polyline;
const RouteModel({
required this.distanceMeters,
required this.durationSeconds,
this.polyline,
});
factory RouteModel.fromJson(Map<String, dynamic> j) {
final dist = j['distanceMeters'] ?? j['distance'];
final dur = j['durationSeconds'] ?? j['duration'];
return RouteModel(
distanceMeters: dist is int ? dist : int.tryParse('$dist') ?? 0,
durationSeconds: dur is int ? dur : int.tryParse('$dur') ?? 0,
polyline: j['polyline'] as String?,
);
}
String get distanceText {
if (distanceMeters < 1000) return '$distanceMeters m';
return '${(distanceMeters / 1000).toStringAsFixed(1)} km';
}
String get durationText {
final min = durationSeconds ~/ 60;
if (min < 60) return '$min min';
final h = min ~/ 60;
final rem = min % 60;
return '$h h ${rem > 0 ? '$rem min' : ''}';
}
List<LatLng> decodePolyline() {
if (polyline == null || polyline!.isEmpty) return [];
return _decodeEncodedPolyline(polyline!);
}
/// فك تشفير polyline وفقاً لخوارزمية Google.
static List<LatLng> _decodeEncodedPolyline(String encoded) {
final List<LatLng> poly = [];
int index = 0, lat = 0, lng = 0;
while (index < encoded.length) {
int b, shift = 0, result = 0;
do {
b = encoded.codeUnitAt(index++) - 63;
result |= (b & 0x1f) << shift;
shift += 5;
} while (b >= 0x20);
lat += ((result & 1) != 0) ? ~(result >> 1) : (result >> 1);
shift = 0;
result = 0;
do {
b = encoded.codeUnitAt(index++) - 63;
result |= (b & 0x1f) << shift;
shift += 5;
} while (b >= 0x20);
lng += ((result & 1) != 0) ? ~(result >> 1) : (result >> 1);
poly.add(LatLng(lat / 1e5, lng / 1e5));
}
return poly;
}
@override
List<Object?> get props => [distanceMeters, durationSeconds, polyline];
}
@@ -0,0 +1,33 @@
import 'package:equatable/equatable.dart';
/// نقطة توقف أثناء الرحلة.
class StopModel extends Equatable {
final double lat;
final double lng;
final String? label;
final String? address;
const StopModel({
required this.lat,
required this.lng,
this.label,
this.address,
});
factory StopModel.fromJson(Map<String, dynamic> j) => StopModel(
lat: (j['lat'] as num).toDouble(),
lng: (j['lng'] as num).toDouble(),
label: j['label'] as String?,
address: j['address'] as String?,
);
Map<String, dynamic> toJson() => {
'lat': lat,
'lng': lng,
if (label != null) 'label': label,
if (address != null) 'address': address,
};
@override
List<Object?> get props => [lat, lng, label, address];
}
@@ -0,0 +1,17 @@
import '../../../core/api/api_client.dart';
import 'models/ride_type_model.dart';
/// جلب فئات الرحلة المتاحة من المستأجر.
class RideTypesRepository {
final ApiClient _api;
RideTypesRepository(this._api);
/// GET /ride-types — تُستخدم كمرجع للفئات النشطة.
Future<List<RideTypeModel>> getRideTypes() async {
final r = await _api.dio.get('/ride-types');
final items = (r.data['items'] ?? r.data ?? []) as List;
return items
.map((e) => RideTypeModel.fromJson(e as Map<String, dynamic>))
.toList();
}
}
@@ -0,0 +1,45 @@
import '../../../core/api/api_client.dart';
import 'models/quote_model.dart';
import 'models/stop_model.dart';
/// استخراج الأسعار لكل فئة رحلة.
class TariffRepository {
final ApiClient _api;
TariffRepository(this._api);
/// جلب عرض أسعار للمسار المحدد — POST /tariff/quote.
///
/// الاستجابة تحتوي `classes[]` بسعر لكل فئة مع breakdown.
Future<QuoteModel> getQuote({
required double pickupLat,
required double pickupLng,
required String? pickupAddress,
required double dropoffLat,
required double dropoffLng,
required String? dropoffAddress,
List<StopModel>? stops,
String? vehicleClass,
String? scheduledAt,
}) async {
final data = <String, dynamic>{
'pickup': {
'lat': pickupLat,
'lng': pickupLng,
if (pickupAddress != null) 'address': pickupAddress,
},
'dropoff': {
'lat': dropoffLat,
'lng': dropoffLng,
if (dropoffAddress != null) 'address': dropoffAddress,
},
};
if (stops != null && stops.isNotEmpty) {
data['stops'] = stops.nonNulls.map((s) => s.toJson()).toList();
}
if (vehicleClass != null) data['vehicleClass'] = vehicleClass;
if (scheduledAt != null) data['scheduledAt'] = scheduledAt;
final r = await _api.dio.post('/tariff/quote', data: data);
return QuoteModel.fromJson(r.data as Map<String, dynamic>);
}
}
@@ -0,0 +1,194 @@
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_scaffold.dart';
import '../../../core/ui/tripz_text_field.dart';
import '../cubit/destination_cubit.dart';
import '../data/models/place_model.dart';
import 'widgets/place_tile.dart';
/// شاشة البحث عن وجهة — full-screen مع autocomplete.
class DestinationScreen extends StatefulWidget {
const DestinationScreen({super.key});
@override
State<DestinationScreen> createState() => _DestinationScreenState();
}
class _DestinationScreenState extends State<DestinationScreen> {
final _controller = TextEditingController();
final _focusNode = FocusNode();
@override
void initState() {
super.initState();
_focusNode.requestFocus();
}
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return TripzScaffold(
padding: false,
child: Column(
children: [
_SearchHeader(
controller: _controller,
focusNode: _focusNode,
l10n: l10n,
),
Expanded(
child: BlocBuilder<DestinationCubit, DestinationState>(
builder: (context, state) {
if (state.searching) {
return const Center(child: CircularProgressIndicator());
}
if (state.results.isEmpty && state.query.length >= 2) {
return Center(
child: Text(l10n.noResults, style: Theme.of(context).textTheme.bodyLarge),
);
}
return ListView.builder(
padding: EdgeInsets.zero,
itemCount: state.results.length,
itemBuilder: (context, index) {
final place = state.results[index];
return PlaceTile(
place: place,
onTap: () => context.pop<PlaceModel>(place),
);
},
);
},
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.all(T.pagePadding),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => context.pop<PlaceModel>(null),
icon: const Icon(Icons.map_outlined),
label: Text(l10n.chooseOnMap),
),
),
),
),
],
),
);
}
}
class _SearchHeader extends StatelessWidget {
final TextEditingController controller;
final FocusNode focusNode;
final AppLocalizations l10n;
const _SearchHeader({
required this.controller,
required this.focusNode,
required this.l10n,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
color: cs.surface,
padding: const EdgeInsets.only(
left: T.pagePadding,
right: T.pagePadding,
top: T.s8,
bottom: T.s12,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.arrow_back_rounded),
),
Expanded(
child: TripzTextField(
controller: controller,
focusNode: focusNode,
hint: l10n.searchPlaces,
prefix: const Icon(Icons.search_rounded),
onChanged: (v) => context.read<DestinationCubit>().onQueryChanged(v),
),
),
],
),
const SizedBox(height: T.s8),
Row(
children: [
Column(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: cs.primary,
shape: BoxShape.circle,
),
),
Container(width: 1.5, height: 24, color: cs.outlineVariant),
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: cs.error,
shape: BoxShape.circle,
),
),
],
),
const SizedBox(width: T.s12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.pickupLocation,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: cs.onSurfaceVariant,
),
),
const SizedBox(height: T.s4),
Text(
l10n.pickupSet,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: T.s16),
Text(
l10n.dropoffLocation,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: cs.onSurfaceVariant,
),
),
],
),
),
],
),
],
),
);
}
}
@@ -1,76 +1,260 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../../core/l10n/app_localizations.dart';
import 'package:go_router/go_router.dart';
import 'package:intaleq_maps/intaleq_maps.dart';
import '../../auth/cubit/auth_cubit.dart';
import '../../auth/cubit/auth_state.dart';
import '../../../core/ui/tripz_button.dart';
import '../../../core/design/tokens.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 '../data/models/place_model.dart';
class HomeScreen extends StatelessWidget {
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
IntaleqMapController? _mapController;
@override
void initState() {
super.initState();
// فحص رحلة نشطة عند فتح الشاشة.
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<TripBloc>().add(const TripCheckActive());
});
}
@override
Widget build(BuildContext context) {
final center = const LatLng(31.9539, 35.9106); // عمّان
return Scaffold(
appBar: AppBar(
title: const Text('Tripz'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => context.read<AuthCubit>().logout(),
),
],
),
body: Stack(
children: [
FlutterMap(
options: MapOptions(initialCenter: center, initialZoom: 13),
children: [
// TODO: استبدال بلاطات انطلق (Martin) بدل OSM
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.mobileapp.store.ride',
),
MarkerLayer(markers: [
Marker(
point: center,
width: 40,
height: 40,
child: const Icon(Icons.location_pin, color: Colors.red, size: 40),
),
]),
],
),
Positioned(
left: 16,
right: 16,
bottom: 24,
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: BlocBuilder<AuthCubit, AuthState>(
builder: (context, state) {
final name = state.user?.name ?? state.user?.phone ?? '';
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('مرحباً $name',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () {},
icon: const Icon(Icons.local_taxi),
label: const Text('اطلب رحلة (قريباً)'),
),
],
);
final l10n = AppLocalizations.of(context)!;
final cs = Theme.of(context).colorScheme;
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(
body: Stack(
children: [
// الخريطة
BlocBuilder<HomeCubit, HomeState>(
builder: (context, state) {
final center = state.currentLocation ?? const LatLng(31.9539, 35.9106);
return IntaleqMap(
apiKey: '',
initialCameraPosition: CameraPosition(target: center, zoom: 15),
myLocationEnabled: true,
zoomControlsEnabled: false,
compassEnabled: false,
onMapCreated: (controller) => _mapController = controller,
markers: _buildMarkers(state),
onCameraMove: (position) {
context.read<HomeCubit>().updateCameraTarget(position.target);
},
);
},
),
// زر إعادة التوسيط
Positioned(
right: T.pagePadding,
bottom: 180,
child: FloatingActionButton.small(
heroTag: 'recenter',
backgroundColor: cs.surface,
onPressed: () {
final loc = context.read<HomeCubit>().state.currentLocation;
if (loc != null) {
_mapController?.animateCamera(CameraUpdate.newLatLngZoom(loc, 15));
}
},
child: Icon(Icons.my_location_rounded, color: cs.onSurface),
),
),
// الشريط السفلي
Positioned(
left: T.pagePadding,
right: T.pagePadding,
bottom: T.pagePadding,
child: Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(T.pagePadding),
child: BlocBuilder<HomeCubit, HomeState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
BlocBuilder<AuthCubit, AuthState>(
builder: (context, authState) {
final name = authState.user?.name ?? '';
if (name.isNotEmpty) {
return Padding(
padding: const EdgeInsets.only(bottom: T.s12),
child: Text(
l10n.welcome(name),
style: Theme.of(context).textTheme.titleMedium,
),
);
}
return const SizedBox.shrink();
},
),
if (state.destination != null)
_DestinationPreview(
destination: state.destination!,
onTap: () => _openDestinationSearch(context),
onClear: () => context.read<HomeCubit>().clearTripData(),
)
else
TripzButton(
label: l10n.whereTo,
style: TripzButtonStyle.secondary,
icon: Icons.search_rounded,
onPressed: () => _openDestinationSearch(context),
),
if (state.destination != null) ...[
const SizedBox(height: T.s12),
TripzButton(
label: l10n.requestRide,
onPressed: () => _openRoutePreview(context, state),
),
],
],
);
},
),
),
),
),
),
],
// حالة التحميل
BlocBuilder<HomeCubit, HomeState>(
builder: (context, state) {
if (state.loading) {
return Container(
color: Colors.black26,
child: const Center(child: CircularProgressIndicator()),
);
}
return const SizedBox.shrink();
},
),
],
),
),
);
}
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) {
context.push<PlaceModel>('/destination').then((place) {
if (place != null && mounted) {
context.read<HomeCubit>().setDestination(place);
_openRoutePreview(context, context.read<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,
});
}
}
class _DestinationPreview extends StatelessWidget {
final PlaceModel destination;
final VoidCallback onTap;
final VoidCallback onClear;
const _DestinationPreview({
required this.destination,
required this.onTap,
required this.onClear,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(T.r12),
child: Container(
padding: const EdgeInsets.all(T.s12),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(T.r12),
),
child: Row(
children: [
Icon(Icons.location_on_outlined, color: cs.error, size: 20),
const SizedBox(width: T.s8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
destination.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (destination.address != null)
Text(
destination.address!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
IconButton(
onPressed: onClear,
icon: Icon(Icons.close_rounded, color: cs.onSurfaceVariant, size: 20),
visualDensity: VisualDensity.compact,
),
],
),
),
);
}
@@ -0,0 +1,307 @@
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 '../../../core/ui/tripz_button.dart';
import '../../trip/bloc/trip_bloc.dart';
import '../../trip/bloc/trip_state.dart';
import '../../trip/bloc/trip_event.dart';
import '../cubit/quote_cubit.dart';
import '../data/models/place_model.dart';
import 'widgets/ride_class_card.dart';
/// شاشة معاينة المسار + اختيار فئة الرحلة + تأكيد الطلب.
class RoutePreviewScreen extends StatefulWidget {
final PlaceModel pickup;
final PlaceModel destination;
const RoutePreviewScreen({
super.key,
required this.pickup,
required this.destination,
});
@override
State<RoutePreviewScreen> createState() => _RoutePreviewScreenState();
}
class _RoutePreviewScreenState extends State<RoutePreviewScreen> {
IntaleqMapController? _mapController;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<QuoteCubit>().fetchQuote(
pickupLat: widget.pickup.lat,
pickupLng: widget.pickup.lng,
pickupAddress: widget.pickup.name,
dropoffLat: widget.destination.lat,
dropoffLng: widget.destination.lng,
dropoffAddress: widget.destination.name,
);
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final cs = Theme.of(context).colorScheme;
final pickupLoc = LatLng(widget.pickup.lat, widget.pickup.lng);
final destLoc = LatLng(widget.destination.lat, widget.destination.lng);
return BlocListener<TripBloc, TripState>(
listenWhen: (prev, cur) =>
prev.requestStatus != cur.requestStatus &&
cur.requestStatus == TripRequestStatus.searching,
listener: (context, state) {
// تم إنشاء الرحلة — انتقل للتتبع.
context.pushReplacement('/trip-tracking');
},
child: Scaffold(
body: Column(
children: [
// الخريطة مع المسار
Expanded(
flex: 2,
child: Stack(
children: [
IntaleqMap(
apiKey: '',
initialCameraPosition: CameraPosition(
target: LatLng(
(pickupLoc.latitude + destLoc.latitude) / 2,
(pickupLoc.longitude + destLoc.longitude) / 2,
),
zoom: 14,
),
myLocationEnabled: false,
zoomControlsEnabled: false,
compassEnabled: false,
onMapCreated: (c) => _mapController = c,
markers: {
Marker(
markerId: const MarkerId('pickup'),
position: pickupLoc,
icon: InlqBitmap.defaultMarkerWithHue(210),
anchor: const Offset(0.5, 1.0),
),
Marker(
markerId: const MarkerId('destination'),
position: destLoc,
icon: InlqBitmap.defaultMarkerWithHue(0),
anchor: const Offset(0.5, 1.0),
),
},
polylines: {
Polyline(
polylineId: const PolylineId('route'),
points: [pickupLoc, destLoc],
color: cs.primary,
width: 4,
),
},
),
Positioned(
top: MediaQuery.of(context).padding.top + T.s8,
left: T.s12,
child: FloatingActionButton.small(
heroTag: 'back',
backgroundColor: cs.surface,
onPressed: () => context.pop(),
child: const Icon(Icons.arrow_back_rounded),
),
),
Positioned(
top: MediaQuery.of(context).padding.top + T.s8,
right: T.s12,
child: BlocBuilder<QuoteCubit, QuoteState>(
builder: (context, state) {
if (state.quote == null) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: T.s12, vertical: T.s8),
decoration: BoxDecoration(
color: cs.surface,
borderRadius: BorderRadius.circular(T.r12),
boxShadow: [T.shadowSm],
),
child: Text(
l10n.routeSummary(
state.quote!.distanceText ?? '',
state.quote!.durationText ?? '',
),
style: Theme.of(context).textTheme.labelLarge,
),
);
},
),
),
],
),
),
// قائمة فئات الرحلة
Expanded(
flex: 3,
child: Container(
color: cs.surface,
child: BlocBuilder<QuoteCubit, QuoteState>(
builder: (context, state) {
if (state.loading) {
return const Center(child: CircularProgressIndicator());
}
if (state.error != null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.errorGeneric),
const SizedBox(height: T.s12),
TripzButton(
label: l10n.retry,
onPressed: () => context.read<QuoteCubit>().fetchQuote(
pickupLat: widget.pickup.lat,
pickupLng: widget.pickup.lng,
pickupAddress: widget.pickup.name,
dropoffLat: widget.destination.lat,
dropoffLng: widget.destination.lng,
dropoffAddress: widget.destination.name,
),
),
],
),
);
}
final classes = state.quote?.classes ?? [];
if (classes.isEmpty) {
return Center(child: Text(l10n.noResults));
}
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(T.pagePadding, T.s16, T.pagePadding, T.s8),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Text(l10n.rideClasses, style: Theme.of(context).textTheme.titleMedium),
),
),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: T.pagePadding),
itemCount: classes.length,
separatorBuilder: (_, __) => const SizedBox(height: T.s8),
itemBuilder: (context, index) {
final cls = classes[index];
return RideClassCard(
quoteClass: cls,
selected: state.selectedClass == cls.vehicleClass,
onTap: () => context.read<QuoteCubit>().selectClass(cls.vehicleClass),
);
},
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.all(T.pagePadding),
child: Column(
children: [
_PaymentRow(
method: state.paymentMethod,
onChanged: (m) => context.read<QuoteCubit>().setPaymentMethod(m),
),
const SizedBox(height: T.s12),
// زر طلب الرحلة — يستخدم TripBloc
BlocBuilder<TripBloc, TripState>(
builder: (context, tripState) {
final isLoading = tripState.requestStatus == TripRequestStatus.loading;
return SizedBox(
width: double.infinity,
child: TripzButton(
label: isLoading ? l10n.loading : l10n.requestRide,
onPressed: (state.selectedClass != null && !isLoading)
? () => _requestTrip(context, state)
: null,
),
);
},
),
],
),
),
),
],
);
},
),
),
),
],
),
),
);
}
void _requestTrip(BuildContext context, QuoteState state) {
if (state.selectedClass == null) return;
context.read<TripBloc>().add(TripRequested(
pickupLat: widget.pickup.lat,
pickupLng: widget.pickup.lng,
pickupAddress: widget.pickup.name,
dropoffLat: widget.destination.lat,
dropoffLng: widget.destination.lng,
dropoffAddress: widget.destination.name,
vehicleClass: state.selectedClass!,
paymentMethod: state.paymentMethod,
couponCode: state.couponCode,
));
}
}
class _PaymentRow extends StatelessWidget {
final String method;
final ValueChanged<String> onChanged;
const _PaymentRow({required this.method, required this.onChanged});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final cs = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: T.pagePadding, vertical: T.s12),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(T.r12),
),
child: Row(
children: [
Icon(
method == 'wallet' ? Icons.account_balance_wallet_outlined : Icons.money_rounded,
color: cs.onSurfaceVariant,
),
const SizedBox(width: T.s12),
Text(l10n.paymentMethod, style: Theme.of(context).textTheme.bodyMedium),
const Spacer(),
DropdownButton<String>(
value: method,
underline: const SizedBox.shrink(),
isDense: true,
items: [
DropdownMenuItem(value: 'cash', child: Text(l10n.cash)),
DropdownMenuItem(value: 'wallet', child: Text(l10n.walletPayment)),
],
onChanged: (v) {
if (v != null) onChanged(v);
},
),
],
),
);
}
}
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import '../../../../core/design/tokens.dart';
import '../../data/models/place_model.dart';
/// عنصر نتائج البحث عن مكان.
class PlaceTile extends StatelessWidget {
final PlaceModel place;
final VoidCallback? onTap;
final IconData? icon;
const PlaceTile({
super.key,
required this.place,
this.onTap,
this.icon,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final displayIcon = icon ?? Icons.location_on_outlined;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: T.pagePadding,
vertical: T.s12,
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(displayIcon, size: 20, color: cs.onSurfaceVariant),
),
const SizedBox(width: T.s12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
place.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (place.address != null && place.address!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
place.address!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: cs.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
Icon(Icons.chevron_right_rounded, color: cs.onSurfaceVariant),
],
),
),
);
}
}
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import '../../../../core/l10n/app_localizations.dart';
import '../../../../core/design/tokens.dart';
import '../../data/models/quote_model.dart';
/// بطاقة فئة رحلة واحدة في قائمة الاختيار.
class RideClassCard extends StatelessWidget {
final QuoteClassModel quoteClass;
final bool selected;
final VoidCallback? onTap;
const RideClassCard({
super.key,
required this.quoteClass,
this.selected = false,
this.onTap,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(T.r12),
child: Container(
padding: const EdgeInsets.all(T.pagePadding),
decoration: BoxDecoration(
color: selected ? cs.primaryContainer : cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(T.r12),
border: Border.all(
color: selected ? cs.primary : Colors.transparent,
width: 2,
),
),
child: Row(
children: [
// أيقونة المركبة
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: selected ? cs.primary : cs.surface,
shape: BoxShape.circle,
),
child: Icon(
Icons.directions_car_filled_rounded,
color: selected ? cs.onPrimary : cs.onSurfaceVariant,
size: 24,
),
),
const SizedBox(width: T.s12),
// اسم الفئة + السعة
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
quoteClass.name,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
l10n.capacity(quoteClass.capacity),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: cs.onSurfaceVariant,
),
),
],
),
),
// الوقت التقديري + السعر
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
quoteClass.estimatedTimeText,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: cs.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
'${quoteClass.price} ${quoteClass.currency}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: selected ? cs.primary : cs.onSurface,
),
),
],
),
],
),
),
);
}
}
@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import '../../../../core/l10n/app_localizations.dart';
import '../../../../core/design/tokens.dart';
/// طبقة البحث عن سائق — نبض + مؤقت + زر إلغاء.
class SearchingOverlay extends StatefulWidget {
final int elapsed;
final VoidCallback? onCancel;
const SearchingOverlay({
super.key,
required this.elapsed,
this.onCancel,
});
@override
State<SearchingOverlay> createState() => _SearchingOverlayState();
}
class _SearchingOverlayState extends State<SearchingOverlay>
with SingleTickerProviderStateMixin {
late AnimationController _pulseController;
late Animation<double> _pulseAnimation;
@override
void initState() {
super.initState();
_pulseController = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat(reverse: true);
_pulseAnimation = Tween<double>(begin: 0.8, end: 1.2).animate(
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_pulseController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final cs = Theme.of(context).colorScheme;
final minutes = widget.elapsed ~/ 60;
final seconds = widget.elapsed % 60;
final timeStr = '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
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: _pulseAnimation,
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.s8),
// المؤقت
Text(
timeStr,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: cs.primary,
fontWeight: FontWeight.w700,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
const SizedBox(height: T.s20),
// زر الإلغاء
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: widget.onCancel,
style: OutlinedButton.styleFrom(
foregroundColor: cs.error,
side: BorderSide(color: cs.error),
),
child: Text(l10n.cancelSearch),
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'settings_state.dart';
/// الإعدادات العامة: الثيم + اللغة.
/// يُحفظ محلياً عبر SharedPreferences (ليس سراً — docs/26 §4).
class SettingsCubit extends Cubit<SettingsState> {
static const _kThemeKey = 'settings_theme_mode';
static const _kLocaleKey = 'settings_locale';
SettingsCubit() : super(const SettingsState()) {
_load();
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
final themeIndex = prefs.getInt(_kThemeKey) ?? 0;
final localeCode = prefs.getString(_kLocaleKey) ?? 'ar';
emit(state.copyWith(
themeMode: ThemeMode.values[themeIndex],
locale: Locale(localeCode),
));
}
Future<void> setThemeMode(ThemeMode mode) async {
emit(state.copyWith(themeMode: mode));
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_kThemeKey, mode.index);
}
Future<void> setLocale(Locale locale) async {
emit(state.copyWith(locale: locale));
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kLocaleKey, locale.languageCode);
}
}
@@ -0,0 +1,31 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
enum SettingsStatus { loaded, error }
class SettingsState extends Equatable {
final SettingsStatus status;
final ThemeMode themeMode;
final Locale locale;
const SettingsState({
this.status = SettingsStatus.loaded,
this.themeMode = ThemeMode.system,
this.locale = const Locale('ar'),
});
SettingsState copyWith({
SettingsStatus? status,
ThemeMode? themeMode,
Locale? locale,
}) {
return SettingsState(
status: status ?? this.status,
themeMode: themeMode ?? this.themeMode,
locale: locale ?? this.locale,
);
}
@override
List<Object?> get props => [status, themeMode, locale];
}
@@ -0,0 +1,268 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../data/models/trip_model.dart';
import '../data/trip_repository.dart';
import '../data/trip_socket_service.dart';
import '../data/trip_event_merger.dart';
import 'trip_event.dart';
import 'trip_state.dart';
/// TripBloc — Bloc الوحيد للراكب (docs/03 §14).
///
/// يتعامل مع Lifecycle كامل: إنشاء → تتبّع → إتمام → تقييم.
/// المصادر الثلاث: أحداث المستخدم، WebSocket، FCM — تمر عبر TripEventMerger.
class TripBloc extends Bloc<TripEvent, TripState> {
final TripRepository _tripRepo;
final TripSocketService _socket;
final TripEventMerger _merger;
Timer? _reconciliationTimer;
TripBloc({
required TripRepository tripRepo,
required TripSocketService socket,
TripEventMerger? merger,
}) : _tripRepo = tripRepo,
_socket = socket,
_merger = merger ?? TripEventMerger(),
super(const TripState()) {
on<TripCheckActive>(_onCheckActive);
on<TripRequested>(_onRequested);
on<TripUpdateReceived>(_onUpdateReceived);
on<TripDriverLocation>(_onDriverLocation);
on<TripCancelRequested>(_onCancel);
on<TripRetry>(_onRetry);
on<TripRate>(_onRate);
on<TripClear>(_onClear);
// استمع لتحديثات TripEventMerger.
_merger.updateStream.listen((update) {
add(TripUpdateReceived(
tripId: update.tripId,
status: update.status,
driverId: update.driverId,
extra: update.extra,
));
});
_merger.locationStream.listen((loc) {
add(TripDriverLocation(
tripId: loc.tripId,
lat: loc.lat,
lng: loc.lng,
heading: loc.heading,
speed: loc.speed,
));
});
// اتصل بالـWebSocket.
_socket.connect();
}
/// فحص رحلة نشطة عند بدء التشغيل أو العودة من الخلفية.
Future<void> _onCheckActive(TripCheckActive event, Emitter<TripState> emit) async {
try {
final trip = await _tripRepo.getActiveTrip();
if (trip != null) {
emit(state.copyWith(
requestStatus: _statusFromTrip(trip.status),
trip: trip,
));
_joinTripRoom(trip.id);
_startReconciliation(trip.id);
}
} catch (_) {
// خطأ صامت — سنحاول مرة أخرى عند إعادة الاتصال.
}
}
/// طلب رحلة جديدة.
Future<void> _onRequested(TripRequested event, Emitter<TripState> emit) async {
emit(state.copyWith(requestStatus: TripRequestStatus.loading, clearError: true));
try {
final trip = await _tripRepo.createTrip(
pickupLat: event.pickupLat,
pickupLng: event.pickupLng,
pickupAddress: event.pickupAddress,
dropoffLat: event.dropoffLat,
dropoffLng: event.dropoffLng,
dropoffAddress: event.dropoffAddress,
vehicleClass: event.vehicleClass,
paymentMethod: event.paymentMethod,
couponCode: event.couponCode,
);
emit(state.copyWith(
requestStatus: TripRequestStatus.searching,
trip: trip,
));
_joinTripRoom(trip.id);
_startReconciliation(trip.id);
} catch (e) {
emit(state.copyWith(
requestStatus: TripRequestStatus.error,
error: e.toString(),
));
}
}
/// تحديث حالة الرحلة — من WebSocket أو FCM عبر TripEventMerger.
void _onUpdateReceived(TripUpdateReceived event, Emitter<TripState> emit) {
final currentTrip = state.trip;
if (currentTrip == null || currentTrip.id != event.tripId) return;
// التصفية الصامتة: تجاهل الأحداث القديمة.
final currentOrder = statusOrder(currentTrip.status);
final newOrder = statusOrder(event.status);
if (newOrder >= 0 && newOrder <= currentOrder) return;
// تحديث النموذج محلياً.
final updatedTrip = TripModel(
id: currentTrip.id,
status: event.status,
clientId: currentTrip.clientId,
pickupLat: currentTrip.pickupLat,
pickupLng: currentTrip.pickupLng,
pickupAddress: currentTrip.pickupAddress,
dropoffLat: currentTrip.dropoffLat,
dropoffLng: currentTrip.dropoffLng,
dropoffAddress: currentTrip.dropoffAddress,
vehicleClass: currentTrip.vehicleClass,
fare: (event.extra['fare'] as num?) ?? currentTrip.fare,
currency: event.extra['currency'] as String? ?? currentTrip.currency,
paymentMethod: currentTrip.paymentMethod,
driverId: event.driverId ?? currentTrip.driverId,
createdAt: currentTrip.createdAt,
updatedAt: DateTime.now(),
);
final newStatus = _statusFromTrip(event.status);
// استخراج معلومات السائق من extra.
final vehicle = event.extra['vehicle'] as Map<String, dynamic>?;
emit(state.copyWith(
requestStatus: newStatus,
trip: updatedTrip,
driverName: event.extra['driverName'] as String?,
vehicleMake: vehicle?['make'] as String?,
vehicleModel: vehicle?['model'] as String?,
vehiclePlate: vehicle?['plate'] as String?,
vehicleColor: vehicle?['color'] as String?,
));
// حالة نهائية: أوقف مؤقت الم reconciliation.
if (isTerminalStatus(event.status)) {
_stopReconciliation();
_socket.leaveTrip(event.tripId);
}
}
/// موقع السائق الحي.
void _onDriverLocation(TripDriverLocation event, Emitter<TripState> emit) {
if (state.trip?.id != event.tripId) return;
emit(state.copyWith(
driverLat: event.lat,
driverLng: event.lng,
driverHeading: event.heading,
));
}
/// إلغاء الرحلة.
Future<void> _onCancel(TripCancelRequested event, Emitter<TripState> emit) async {
if (state.trip == null) return;
try {
final trip = await _tripRepo.cancelTrip(state.trip!.id);
_stopReconciliation();
_socket.leaveTrip(state.trip!.id);
emit(state.copyWith(
requestStatus: TripRequestStatus.completed,
trip: trip,
));
} catch (e) {
emit(state.copyWith(error: e.toString()));
}
}
/// إعادة المحاولة بعد خطأ.
void _onRetry(TripRetry event, Emitter<TripState> emit) {
emit(state.copyWith(requestStatus: TripRequestStatus.idle, clearError: true));
}
/// تقييم الرحلة.
Future<void> _onRate(TripRate event, Emitter<TripState> emit) async {
if (state.trip == null) return;
try {
await _tripRepo.rateTrip(state.trip!.id, stars: event.stars, comment: event.comment);
emit(state.copyWith(rated: true));
} catch (e) {
emit(state.copyWith(error: e.toString()));
}
}
/// مسح الرحلة من الذاكرة.
void _onClear(TripClear event, Emitter<TripState> emit) {
_stopReconciliation();
emit(const TripState());
}
// --- مساعدات ---
void _joinTripRoom(String tripId) {
_socket.joinTrip(tripId);
}
void _startReconciliation(String tripId) {
_stopReconciliation();
_reconciliationTimer = Timer.periodic(const Duration(seconds: 25), (_) async {
try {
final trip = await _tripRepo.getTrip(tripId);
if (trip != null) {
add(TripUpdateReceived(
tripId: trip.id,
status: trip.status,
driverId: trip.driverId,
extra: {
if (trip.fare != null) 'fare': trip.fare,
if (trip.currency != null) 'currency': trip.currency,
},
));
}
} catch (_) {
// خطأ صامت — سيحاول مرة أخرى.
}
});
}
void _stopReconciliation() {
_reconciliationTimer?.cancel();
_reconciliationTimer = null;
}
TripRequestStatus _statusFromTrip(String status) {
switch (status) {
case 'searching':
return TripRequestStatus.searching;
case 'assigned':
case 'driver_arriving':
case 'driver_arrived':
case 'in_progress':
return TripRequestStatus.active;
case 'completed':
case 'paid':
return TripRequestStatus.completed;
case 'cancelled':
case 'no_drivers':
case 'expired':
return TripRequestStatus.completed;
default:
return TripRequestStatus.searching;
}
}
@override
Future<void> close() {
_stopReconciliation();
_merger.dispose();
_socket.disconnect();
return super.close();
}
}
@@ -0,0 +1,109 @@
import 'package:equatable/equatable.dart';
/// أحداث TripBloc — المصادر الثلاث: المستخدم، WebSocket، FCM.
///
/// كل حدث يحمل `timestamp` لتمييز التسلسل عند الحاجة.
sealed class TripEvent extends Equatable {
const TripEvent();
@override
List<Object?> get props => [];
}
/// فحص رحلة نشطة عند بدء التشغيل أو العودة للخلفية.
class TripCheckActive extends TripEvent {
const TripCheckActive();
}
/// طلب رحلة جديدة (من شاشة تأكيد الاختيار).
class TripRequested extends TripEvent {
final double pickupLat;
final double pickupLng;
final String? pickupAddress;
final double dropoffLat;
final double dropoffLng;
final String? dropoffAddress;
final String vehicleClass;
final String paymentMethod;
final String? couponCode;
const TripRequested({
required this.pickupLat,
required this.pickupLng,
this.pickupAddress,
required this.dropoffLat,
required this.dropoffLng,
this.dropoffAddress,
required this.vehicleClass,
this.paymentMethod = 'cash',
this.couponCode,
});
@override
List<Object?> get props => [pickupLat, pickupLng, dropoffLat, dropoffLng, vehicleClass];
}
/// تحديث حالة الرحلة — ي הגיע عبر WebSocket أو FCM.
/// الترتيب: searching < assigned < driver_arriving < driver_arrived < in_progress < completed < paid
class TripUpdateReceived extends TripEvent {
final String tripId;
final String status;
final String? driverId;
final Map<String, dynamic> extra;
const TripUpdateReceived({
required this.tripId,
required this.status,
this.driverId,
this.extra = const {},
});
@override
List<Object?> get props => [tripId, status, driverId];
}
/// موقع السائق الحي — من WebSocket.
class TripDriverLocation extends TripEvent {
final String tripId;
final double lat;
final double lng;
final double heading;
final double speed;
const TripDriverLocation({
required this.tripId,
required this.lat,
required this.lng,
this.heading = 0,
this.speed = 0,
});
@override
List<Object?> get props => [tripId, lat, lng];
}
/// إلغاء الرحلة من الراكب.
class TripCancelRequested extends TripEvent {
const TripCancelRequested();
}
/// إعادة المحاولة بعد خطأ.
class TripRetry extends TripEvent {
const TripRetry();
}
/// تقييم الرحلة المكتملة.
class TripRate extends TripEvent {
final int stars;
final String? comment;
const TripRate({required this.stars, this.comment});
@override
List<Object?> get props => [stars, comment];
}
/// مسح الرحلة من الذاكرة (بعد التقييم أو العودة للرئيسية).
class TripClear extends TripEvent {
const TripClear();
}
@@ -0,0 +1,111 @@
import 'package:equatable/equatable.dart';
import '../data/models/trip_model.dart';
/// هل هذه الحالة نهائية (لا انتقالات منها)?
bool isTerminalStatus(String status) {
return status == 'cancelled' || status == 'no_drivers' || status == 'expired' || status == 'paid';
}
/// حالة TripBloc الموحدة.
class TripState extends Equatable {
final TripRequestStatus requestStatus;
final TripModel? trip;
final String? driverName;
final String? vehicleMake;
final String? vehicleModel;
final String? vehiclePlate;
final String? vehicleColor;
final double? driverLat;
final double? driverLng;
final double? driverHeading;
final String? error;
final bool rated;
const TripState({
this.requestStatus = TripRequestStatus.idle,
this.trip,
this.driverName,
this.vehicleMake,
this.vehicleModel,
this.vehiclePlate,
this.vehicleColor,
this.driverLat,
this.driverLng,
this.driverHeading,
this.error,
this.rated = false,
});
TripState copyWith({
TripRequestStatus? requestStatus,
TripModel? trip,
String? driverName,
String? vehicleMake,
String? vehicleModel,
String? vehiclePlate,
String? vehicleColor,
double? driverLat,
double? driverLng,
double? driverHeading,
String? error,
bool? rated,
bool clearError = false,
bool clearDriver = false,
}) {
return TripState(
requestStatus: requestStatus ?? this.requestStatus,
trip: trip ?? this.trip,
driverName: clearDriver ? null : (driverName ?? this.driverName),
vehicleMake: clearDriver ? null : (vehicleMake ?? this.vehicleMake),
vehicleModel: clearDriver ? null : (vehicleModel ?? this.vehicleModel),
vehiclePlate: clearDriver ? null : (vehiclePlate ?? this.vehiclePlate),
vehicleColor: clearDriver ? null : (vehicleColor ?? this.vehicleColor),
driverLat: driverLat ?? this.driverLat,
driverLng: driverLng ?? this.driverLng,
driverHeading: driverHeading ?? this.driverHeading,
error: clearError ? null : (error ?? this.error),
rated: rated ?? this.rated,
);
}
/// هل هناك رحلة نشطة (ليست idle ولا terminal)?
bool get hasActiveTrip =>
trip != null &&
requestStatus != TripRequestStatus.idle &&
!isTerminalStatus(trip!.status);
/// هل يمكن إلغاء الرحلة حالياً?
bool get canCancel {
if (trip == null) return false;
return ['searching', 'assigned', 'driver_arriving', 'driver_arrived']
.contains(trip!.status);
}
/// هل يمكن للمستخدم ركوب الرحلة (اكتملت)?
bool get isCompleted => trip?.status == 'completed' || trip?.status == 'paid';
@override
List<Object?> get props => [
requestStatus,
trip,
driverName,
vehicleMake,
vehicleModel,
vehiclePlate,
vehicleColor,
driverLat,
driverLng,
driverHeading,
error,
rated,
];
}
enum TripRequestStatus {
idle,
loading,
searching,
active, // assigned → in_progress
completed,
error,
}
@@ -0,0 +1,79 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:firebase_messaging/firebase_messaging.dart';
import '../../../core/api/api_client.dart';
import '../../../core/di.dart';
import 'trip_event_merger.dart';
/// القناة الثانية للزمن الحقيقي — FCM data-message (docs/23 §15).
///
/// تستقبل رسائل data-only من السيرفر حول تحديثات الرحلة وتُمرّرها
/// إلى TripEventMerger بنفس شكل WebSocket — أيّهما يسبق يفوز،
/// والـ merger يتكفّل بالتصفية الصامتة.
class FcmService {
final TripEventMerger _merger;
StreamSubscription<RemoteMessage>? _onMessageSub;
StreamSubscription<RemoteMessage>? _onOpenSub;
FcmService(this._merger);
/// تهيئة FCM بعد تسجيل الدخول — تُستدعى من نقطة واحدة.
Future<void> init() async {
final messaging = FirebaseMessaging.instance;
await messaging.requestPermission(
alert: false,
badge: false,
sound: false,
provisional: false,
);
final token = await messaging.getToken();
if (token != null) {
_registerToken(token);
}
messaging.onTokenRefresh.listen(_registerToken);
_onMessageSub = FirebaseMessaging.onMessage.listen(_handleMessage);
_onOpenSub = FirebaseMessaging.onMessageOpenedApp.listen(_handleMessage);
}
void dispose() {
_onMessageSub?.cancel();
_onOpenSub?.cancel();
}
void _handleMessage(RemoteMessage message) {
final data = message.data;
final type = data['type'] as String?;
final tripId = data['tripId'] as String?;
if (type == null || tripId == null || tripId.isEmpty) return;
final status = type.startsWith('trip_') ? type.substring(5) : null;
if (status == null) return;
final extra = Map<String, dynamic>.from(data)
..remove('type')
..remove('tripId')
..remove('title')
..remove('body');
_merger.addUpdate(TripUpdate(
tripId: tripId,
status: status,
driverId: data['driverId'] as String?,
extra: extra,
));
}
static Future<void> _registerToken(String token) async {
try {
final api = getIt<ApiClient>();
await api.dio.post('/notifications/token', data: {
'token': token,
'platform': Platform.isIOS ? 'ios' : 'android',
});
} catch (_) {}
}
}
@@ -0,0 +1,33 @@
import 'package:equatable/equatable.dart';
/// نقطة توقف أثناء الرحلة.
class StopModel extends Equatable {
final double lat;
final double lng;
final String? label;
final String? address;
const StopModel({
required this.lat,
required this.lng,
this.label,
this.address,
});
factory StopModel.fromJson(Map<String, dynamic> j) => StopModel(
lat: (j['lat'] as num).toDouble(),
lng: (j['lng'] as num).toDouble(),
label: j['label'] as String?,
address: j['address'] as String?,
);
Map<String, dynamic> toJson() => {
'lat': lat,
'lng': lng,
if (label != null) 'label': label,
if (address != null) 'address': address,
};
@override
List<Object?> get props => [lat, lng, label, address];
}
@@ -0,0 +1,121 @@
import 'package:equatable/equatable.dart';
/// حالة الرحلة — تُرتيب حسب docs/03 §7.
enum TripStatus {
requested,
accepted,
arriving,
arrived,
inProgress,
completed,
cancelled,
}
/// نموذج الرحلة المُنشأة من trips endpoint.
class TripModel extends Equatable {
final String id;
final String status;
final String clientId;
final double pickupLat;
final double pickupLng;
final String? pickupAddress;
final double dropoffLat;
final double dropoffLng;
final String? dropoffAddress;
final String vehicleClass;
final num? fare;
final String? currency;
final String? paymentMethod;
final String? couponCode;
final String? driverId;
final String? driverName;
final String? driverPhone;
final double? driverLat;
final double? driverLng;
final String? vehicleMake;
final String? vehicleModel;
final String? vehicleColor;
final String? plateNumber;
final DateTime? createdAt;
final DateTime? updatedAt;
const TripModel({
required this.id,
required this.status,
required this.clientId,
required this.pickupLat,
required this.pickupLng,
this.pickupAddress,
required this.dropoffLat,
required this.dropoffLng,
this.dropoffAddress,
required this.vehicleClass,
this.fare,
this.currency,
this.paymentMethod,
this.couponCode,
this.driverId,
this.driverName,
this.driverPhone,
this.driverLat,
this.driverLng,
this.vehicleMake,
this.vehicleModel,
this.vehicleColor,
this.plateNumber,
this.createdAt,
this.updatedAt,
});
factory TripModel.fromJson(Map<String, dynamic> j) => TripModel(
id: j['id'] as String,
status: j['status'] as String,
clientId: (j['clientId'] ?? '') as String,
pickupLat: (j['pickup']?['lat'] ?? j['pickupLat'] ?? 0).toDouble(),
pickupLng: (j['pickup']?['lng'] ?? j['pickupLng'] ?? 0).toDouble(),
pickupAddress: (j['pickup']?['address'] ?? j['pickupAddress']) as String?,
dropoffLat: (j['dropoff']?['lat'] ?? j['dropoffLat'] ?? 0).toDouble(),
dropoffLng: (j['dropoff']?['lng'] ?? j['dropoffLng'] ?? 0).toDouble(),
dropoffAddress: (j['dropoff']?['address'] ?? j['dropoffAddress']) as String?,
vehicleClass: (j['vehicleClass'] ?? '') as String,
fare: (j['fare'] as num?),
currency: j['currency'] as String?,
paymentMethod: j['paymentMethod'] as String?,
couponCode: j['couponCode'] as String?,
driverId: j['driverId'] as String?,
driverName: j['driverName'] as String?,
driverPhone: j['driverPhone'] as String?,
driverLat: (j['driverLat'] as num?)?.toDouble(),
driverLng: (j['driverLng'] as num?)?.toDouble(),
vehicleMake: j['vehicleMake'] as String?,
vehicleModel: j['vehicleModel'] as String?,
vehicleColor: j['vehicleColor'] as String?,
plateNumber: j['plateNumber'] as String?,
createdAt: j['createdAt'] != null ? DateTime.tryParse(j['createdAt'] as String) : null,
updatedAt: j['updatedAt'] != null ? DateTime.tryParse(j['updatedAt'] as String) : null,
);
TripStatus get parsedStatus {
switch (status) {
case 'requested':
return TripStatus.requested;
case 'accepted':
return TripStatus.accepted;
case 'arriving':
return TripStatus.arriving;
case 'arrived':
return TripStatus.arrived;
case 'in_progress':
return TripStatus.inProgress;
case 'completed':
return TripStatus.completed;
case 'cancelled':
return TripStatus.cancelled;
default:
return TripStatus.requested;
}
}
@override
List<Object?> get props => [id, status, clientId, vehicleClass];
}
@@ -0,0 +1,96 @@
import 'dart:async';
/// تحديث حالة من أي مصدر (WebSocket أو FCM).
class TripUpdate {
final String tripId;
final String status;
final String? driverId;
final Map<String, dynamic> extra;
const TripUpdate({
required this.tripId,
required this.status,
this.driverId,
this.extra = const {},
});
}
/// موقع سائق من WebSocket.
class DriverLocationUpdate {
final String tripId;
final double lat;
final double lng;
final double heading;
final double speed;
const DriverLocationUpdate({
required this.tripId,
required this.lat,
required this.lng,
this.heading = 0,
this.speed = 0,
});
}
/// ترتيب الحالات — يُستخدم للتصفية الصامتة (docs/23 §15).
int statusOrder(String s) {
switch (s) {
case 'searching':
return 0;
case 'assigned':
return 1;
case 'driver_arriving':
return 2;
case 'driver_arrived':
return 3;
case 'in_progress':
return 4;
case 'completed':
return 5;
case 'paid':
return 6;
default:
return -1;
}
}
/// محول أحداث موحّد — يستقبل من WebSocket وFCM ويُخرج أولاً-فوزان.
///
/// التصفية الصامتة: أي حدث بحالة ≤ الحالة المعروضة يُهمل.
/// لا حاجة لمعرّفات رسالة — ترتيب الحالات يكفي (docs/23 §15).
class TripEventMerger {
final _updateController = StreamController<TripUpdate>.broadcast();
final _locationController = StreamController<DriverLocationUpdate>.broadcast();
/// الحالة المعروضة حالياً لكل رحلة — للتصفية الصامتة.
final Map<String, int> _currentOrder = {};
Stream<TripUpdate> get updateStream => _updateController.stream;
Stream<DriverLocationUpdate> get locationStream => _locationController.stream;
/// استقبال تحديث حالة من أي مصدر.
void addUpdate(TripUpdate update) {
final newOrder = statusOrder(update.status);
final currentOrder = _currentOrder[update.tripId] ?? -1;
// التصفية الصامتة: تجاهل الأحداث القديمة أو المتطابقة.
if (newOrder >= 0 && newOrder <= currentOrder) return;
// تحديث الحالة المعروضة.
if (newOrder >= 0) {
_currentOrder[update.tripId] = newOrder;
}
_updateController.add(update);
}
/// استقبال موقع سائق (لا يحتاج تصفية — كل ping مفيد).
void addLocation(DriverLocationUpdate location) {
_locationController.add(location);
}
void dispose() {
_updateController.close();
_locationController.close();
}
}
@@ -0,0 +1,105 @@
import 'package:uuid/uuid.dart';
import '../../../core/api/api_client.dart';
import 'models/trip_model.dart';
import 'models/stop_model.dart';
const _uuid = Uuid();
/// مستودع الرحلات — يُستخدم من TripBloc.
class TripRepository {
final ApiClient _api;
TripRepository(this._api);
/// GET /trips/mine — استرجاع رحلة نشطة.
Future<TripModel?> getActiveTrip() async {
try {
final r = await _api.dio.get('/trips/mine');
final data = r.data;
if (data == null) return null;
final items = (data['items'] ?? data) as List;
if (items.isEmpty) return null;
final trip = TripModel.fromJson(items.first as Map<String, dynamic>);
final active = ['searching', 'assigned', 'driver_arriving', 'driver_arrived', 'in_progress'];
return active.contains(trip.status) ? trip : null;
} catch (_) {
return null;
}
}
/// GET /trips/:id — جلب رحلة محددة.
Future<TripModel?> getTrip(String tripId) async {
try {
final r = await _api.dio.get('/trips/$tripId');
return TripModel.fromJson(r.data as Map<String, dynamic>);
} catch (_) {
return null;
}
}
/// POST /trips — إنشاء رحلة جديدة.
Future<TripModel> createTrip({
required double pickupLat,
required double pickupLng,
String? pickupAddress,
required double dropoffLat,
required double dropoffLng,
String? dropoffAddress,
List<StopModel>? stops,
required String vehicleClass,
String paymentMethod = 'cash',
String? couponCode,
String? scheduledAt,
}) async {
final data = <String, dynamic>{
'clientId': _uuid.v4(),
'pickup': {
'lat': pickupLat,
'lng': pickupLng,
if (pickupAddress != null) 'address': pickupAddress,
},
'dropoff': {
'lat': dropoffLat,
'lng': dropoffLng,
if (dropoffAddress != null) 'address': dropoffAddress,
},
'vehicleClass': vehicleClass,
'paymentMethod': paymentMethod,
};
if (stops != null && stops.isNotEmpty) {
data['stops'] = stops.map((s) => s.toJson()).toList();
}
if (couponCode != null && couponCode.isNotEmpty) {
data['couponCode'] = couponCode;
}
if (scheduledAt != null) data['scheduledAt'] = scheduledAt;
final r = await _api.dio.post('/trips', data: data);
return TripModel.fromJson(r.data as Map<String, dynamic>);
}
/// POST /trips/:id/cancel — إلغاء الرحلة.
Future<TripModel> cancelTrip(String tripId) async {
final r = await _api.dio.post('/trips/$tripId/cancel');
return TripModel.fromJson(r.data as Map<String, dynamic>);
}
/// POST /trips/:id/rate — تقييم الرحلة.
Future<void> rateTrip(String tripId, {required int stars, String? comment}) async {
await _api.dio.post('/trips/$tripId/rate', data: {
'stars': stars,
if (comment != null && comment.isNotEmpty) 'comment': comment,
});
}
/// GET /trips/rating/pending — هل هناك تقييم معلّق?
Future<Map<String, dynamic>?> getPendingRating() async {
try {
final r = await _api.dio.get('/trips/rating/pending');
final data = r.data as Map<String, dynamic>;
if (data['pending'] != null) return data;
return null;
} catch (_) {
return null;
}
}
}
@@ -0,0 +1,105 @@
import 'dart:async';
import 'package:socket_io_client/socket_io_client.dart' as io;
import '../../../core/config.dart';
import '../../../core/storage/token_store.dart';
import 'trip_event_merger.dart';
/// خدمة WebSocket — تتبع رحلة عبر Socket.IO.
///
/// الاتصال مُصادق عبر JWT (نفس توكن API).
/// الأحداث الواردة تُمرّ عبر TripEventMerger.
class TripSocketService {
final TokenStore _tokenStore;
final TripEventMerger _merger;
io.Socket? _socket;
bool _connected = false;
String? _currentTripId;
TripSocketService(this._tokenStore, this._merger);
/// الاتصال بالخادم.
Future<void> connect() async {
if (_connected) return;
final token = await _tokenStore.access();
if (token == null || token.isEmpty) return;
_socket = io.io(
AppConfig.baseUrl.replaceAll('/api', ''),
io.OptionBuilder()
.setTransports(['websocket'])
.enableAutoConnect()
.enableReconnection()
.setReconnectionDelay(2000)
.setReconnectionAttempts(10)
.setAuth({'token': token})
.build(),
);
_socket!.onConnect((_) {
_connected = true;
});
_socket!.onDisconnect((_) {
_connected = false;
});
_socket!.onReconnect((_) {
// عند إعادة الاتصال: أعد الانضمام لغرفة الرحلة.
if (_currentTripId != null) {
joinTrip(_currentTripId!);
}
});
// استقبال تحديثات الحالة.
_socket!.on('trip:update', (data) {
if (data is! Map) return;
final map = Map<String, dynamic>.from(data);
_merger.addUpdate(TripUpdate(
tripId: map['tripId'] as String? ?? '',
status: map['status'] as String? ?? '',
driverId: map['driverId'] as String?,
extra: map..remove('tripId')..remove('status')..remove('driverId'),
));
});
// استقبال موقع السائق الحي.
_socket!.on('driver:location', (data) {
if (data is! Map) return;
final map = Map<String, dynamic>.from(data);
_merger.addLocation(DriverLocationUpdate(
tripId: map['tripId'] as String? ?? '',
lat: (map['lat'] as num?)?.toDouble() ?? 0,
lng: (map['lng'] as num?)?.toDouble() ?? 0,
heading: (map['heading'] as num?)?.toDouble() ?? 0,
speed: (map['speed'] as num?)?.toDouble() ?? 0,
));
});
_socket!.connect();
}
/// الانضمام لغرفة الرحلة.
void joinTrip(String tripId) {
_currentTripId = tripId;
_socket?.emit('trip:join', {'tripId': tripId});
}
/// مغادرة غرفة الرحلة.
void leaveTrip(String tripId) {
_currentTripId = null;
// Socket.IO لا يدعم مغادرة الغرفة بشكل صريح،
// لكن عند رحلة جديدة سننضم للغرفة الجديدة.
}
/// قطع الاتصال.
void disconnect() {
_socket?.disconnect();
_socket?.dispose();
_socket = null;
_connected = false;
}
bool get isConnected => _connected;
}
@@ -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,
),
),
),
],
),
);
}
}