feat: implement core architecture, design system, localization, and trip features for both driver and rider apps.
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user